diff --git a/HISTORY.md b/HISTORY.md index 879970b2530..80aba04e025 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,5 +1,15 @@ # Keyman Version History +## 17.0.304 beta 2024-04-09 + +* fix(android): atomically updates selection with text (#11188) +* (#11178) + +## 17.0.303 beta 2024-04-05 + +* fix(windows): decode uri for Package ID and filename (#11152) +* fix(common/models): suggestion stability after multiple whitespaces (#11164) + ## 17.0.302 beta 2024-04-04 * fix(mac): load only 80 characters from context when processing keystrokes (#11141) diff --git a/VERSION.md b/VERSION.md index d43a7b030b7..f9d9a9bbd8f 100644 --- a/VERSION.md +++ b/VERSION.md @@ -1 +1 @@ -17.0.303 \ No newline at end of file +17.0.305 \ No newline at end of file diff --git a/android/KMAPro/kMAPro/src/main/java/com/keyman/android/SystemKeyboard.java b/android/KMAPro/kMAPro/src/main/java/com/keyman/android/SystemKeyboard.java index e9cab39752e..85c14dfcb96 100644 --- a/android/KMAPro/kMAPro/src/main/java/com/keyman/android/SystemKeyboard.java +++ b/android/KMAPro/kMAPro/src/main/java/com/keyman/android/SystemKeyboard.java @@ -130,7 +130,7 @@ public View onCreateInputView() { @Override public void onUpdateSelection(int oldSelStart, int oldSelEnd, int newSelStart, int newSelEnd, int candidatesStart, int candidatesEnd) { super.onUpdateSelection(oldSelStart, oldSelEnd, newSelStart, newSelEnd, candidatesStart, candidatesEnd); - KMManager.updateSelectionRange(KMManager.KeyboardType.KEYBOARD_TYPE_SYSTEM, newSelStart, newSelEnd); + KMManager.updateSelectionRange(KMManager.KeyboardType.KEYBOARD_TYPE_SYSTEM); } /** @@ -170,9 +170,7 @@ public void onStartInput(EditorInfo attribute, boolean restarting) { ExtractedText icText = ic.getExtractedText(new ExtractedTextRequest(), 0); if (icText != null) { boolean didUpdateText = KMManager.updateText(KeyboardType.KEYBOARD_TYPE_SYSTEM, icText.text.toString()); - int selStart = icText.startOffset + icText.selectionStart; - int selEnd = icText.startOffset + icText.selectionEnd; - boolean didUpdateSelection = KMManager.updateSelectionRange(KeyboardType.KEYBOARD_TYPE_SYSTEM, selStart, selEnd); + boolean didUpdateSelection = KMManager.updateSelectionRange(KeyboardType.KEYBOARD_TYPE_SYSTEM); if (!didUpdateText || !didUpdateSelection) exText = icText; } diff --git a/android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboard.java b/android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboard.java index bdd0f4f65c4..a356a20ad55 100644 --- a/android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboard.java +++ b/android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboard.java @@ -163,7 +163,7 @@ protected boolean updateText(String text) { return result; } - protected boolean updateSelectionRange(int selStart, int selEnd) { + protected boolean updateSelectionRange() { boolean result = false; InputConnection ic = KMManager.getInputConnection(this.keyboardType); if (ic != null) { @@ -175,6 +175,16 @@ protected boolean updateSelectionRange(int selStart, int selEnd) { String rawText = icText.text.toString(); updateText(rawText.toString()); + int selStart = icText.selectionStart; + int selEnd = icText.selectionEnd; + + int selMin = selStart, selMax = selEnd; + if (selStart > selEnd) { + // Selection is reversed so "swap" + selMin = selEnd; + selMax = selStart; + } + /* The values of selStart & selEnd provided by the system are in code units, not code-points. We need to account for surrogate pairs here. @@ -193,8 +203,8 @@ protected boolean updateSelectionRange(int selStart, int selEnd) { selStart -= pairsAtStart; selEnd -= (pairsAtStart + pairsSelected); + this.loadJavascript(KMString.format("updateKMSelectionRange(%d,%d)", selStart, selEnd)); } - this.loadJavascript(KMString.format("updateKMSelectionRange(%d,%d)", selStart, selEnd)); result = true; return result; diff --git a/android/KMEA/app/src/main/java/com/keyman/engine/KMManager.java b/android/KMEA/app/src/main/java/com/keyman/engine/KMManager.java index c113ab313d4..b31fe425066 100644 --- a/android/KMEA/app/src/main/java/com/keyman/engine/KMManager.java +++ b/android/KMEA/app/src/main/java/com/keyman/engine/KMManager.java @@ -2137,23 +2137,40 @@ public static boolean updateText(KeyboardType kbType, String text) { return result; } + /** + * Updates the active range for selected text. + * @deprecated + * This method no longer needs the `selStart` and `selEnd` parameters. + *

Use {@link KMManager#updateSelectionRange(KeyboardType)} instead.

+ * + * @param kbType A value indicating if this request is for the in-app keyboard or the system keyboard + * @param selStart (deprecated) the start index for the range + * @param selEnd (deprecated) the end index for the selected range + * @return + */ + @Deprecated public static boolean updateSelectionRange(KeyboardType kbType, int selStart, int selEnd) { + return updateSelectionRange(kbType); + } + + /** + * Performs a synchronization check for the active range for selected text, + * ensuring it matches the text-editor's current state. + * @param kbType A value indicating if this request is for the in-app or system keyboard. + * @return + */ + public static boolean updateSelectionRange(KeyboardType kbType) { boolean result = false; - int selMin = selStart, selMax = selEnd; - if (selStart > selEnd) { - // Selection is reversed so "swap" - selMin = selEnd; - selMax = selStart; - } + if (kbType == KeyboardType.KEYBOARD_TYPE_INAPP) { if (isKeyboardLoaded(KeyboardType.KEYBOARD_TYPE_INAPP) && !InAppKeyboard.shouldIgnoreSelectionChange()) { - result = InAppKeyboard.updateSelectionRange(selMin, selMax); + result = InAppKeyboard.updateSelectionRange(); } InAppKeyboard.setShouldIgnoreSelectionChange(false); } else if (kbType == KeyboardType.KEYBOARD_TYPE_SYSTEM) { if (isKeyboardLoaded(KeyboardType.KEYBOARD_TYPE_SYSTEM) && !SystemKeyboard.shouldIgnoreSelectionChange()) { - result = SystemKeyboard.updateSelectionRange(selMin, selMax); + result = SystemKeyboard.updateSelectionRange(); } SystemKeyboard.setShouldIgnoreSelectionChange(false); diff --git a/android/KMEA/app/src/main/java/com/keyman/engine/KMTextView.java b/android/KMEA/app/src/main/java/com/keyman/engine/KMTextView.java index 70774a4e2c7..3c5959fc9d6 100644 --- a/android/KMEA/app/src/main/java/com/keyman/engine/KMTextView.java +++ b/android/KMEA/app/src/main/java/com/keyman/engine/KMTextView.java @@ -63,10 +63,8 @@ public KMTextView(Context context, AttributeSet attrs, int defStyle) { */ public static void updateTextContext() { KMTextView textView = (KMTextView) activeView; - int selStart = textView.getSelectionStart(); - int selEnd = textView.getSelectionEnd(); KMManager.updateText(KeyboardType.KEYBOARD_TYPE_INAPP, textView.getText().toString()); - if (KMManager.updateSelectionRange(KeyboardType.KEYBOARD_TYPE_INAPP, selStart, selEnd)) { + if (KMManager.updateSelectionRange(KeyboardType.KEYBOARD_TYPE_INAPP)) { KMManager.resetContext(KeyboardType.KEYBOARD_TYPE_INAPP); } } @@ -167,7 +165,7 @@ protected void onTextChanged(CharSequence text, int start, int lengthBefore, int protected void onSelectionChanged(int selStart, int selEnd) { super.onSelectionChanged(selStart, selEnd); if (activeView != null && activeView.equals(this)) { - if (KMManager.updateSelectionRange(KMManager.KeyboardType.KEYBOARD_TYPE_INAPP, selStart, selEnd)) { + if (KMManager.updateSelectionRange(KMManager.KeyboardType.KEYBOARD_TYPE_INAPP)) { KMManager.resetContext(KeyboardType.KEYBOARD_TYPE_INAPP); } } diff --git a/common/web/gesture-recognizer/src/engine/headless/asyncClosureDispatchQueue.ts b/common/web/gesture-recognizer/src/engine/headless/asyncClosureDispatchQueue.ts index bc912975348..a15265cb928 100644 --- a/common/web/gesture-recognizer/src/engine/headless/asyncClosureDispatchQueue.ts +++ b/common/web/gesture-recognizer/src/engine/headless/asyncClosureDispatchQueue.ts @@ -36,51 +36,53 @@ export class AsyncClosureDispatchQueue { return this.queue.length == 0 && !this.waitLock; } - private async setWaitLock(promise: Promise) { - this.waitLock = promise; + private async triggerNextClosure() { + if(this.queue.length == 0) { + return; + } + + const functor = this.queue.shift(); + + // A stand-in so that `ready` doesn't report true while the closure runs. + this.waitLock = Promise.resolve(); + + /* + It is imperative that any errors triggered by the functor do not prevent this method from setting + the wait lock that will trigger the following event (if it exists). Failure to do so will + result in all further queued closures never getting the opportunity to run! + */ + let result: undefined | Promise; + try { + // Is either undefined (return type: void) or is a Promise. + result = functor() as undefined | Promise; + /* c8 ignore start */ + } catch (err) { + reportError('Error from queued closure', err); + } + /* c8 ignore end */ + + /* + Replace the stand-in with the _true_ post-closure wait. + + If the closure returns a Promise, the implication is that the further processing of queued + functions should be blocked until that Promise is fulfilled. + + If not, we just add a default delay. + */ + result = result ?? this.defaultWaitFactory(); + this.waitLock = result; try { - await promise; + await result; } catch(err) { reportError('Async error from queued closure', err); } this.waitLock = null; + // if queue is length zero, auto-returns. this.triggerNextClosure(); } - private async triggerNextClosure() { - if(this.queue.length > 0) { - const functor = this.queue.shift(); - - // A stand-in so that `ready` doesn't report true while the closure runs. - this.waitLock = Promise.resolve(); - - /* - It is imperative that any errors triggered by the functor do not prevent this method from setting - the wait lock that will trigger the following event (if it exists). Failure to do so will - result in all further queued closures never getting the opportunity to run! - */ - let result: undefined | Promise; - try { - // Is either undefined (return type: void) or is a Promise. - result = functor() as undefined | Promise; - /* c8 ignore start */ - } catch (err) { - reportError('Error from queued closure', err); - } - /* c8 ignore end */ - - /* - If the closure returns a Promise, the implication is that the further processing of queued - functions should be blocked until that Promise is fulfilled. - - If not, we still add a delay according to the specified default. - */ - this.setWaitLock(result ?? this.defaultWaitFactory()); - } - } - runAsync(closure: QueueClosure) { // Check before putting the closure on the internal queue; the check is based in part // upon the existing queue length. diff --git a/common/web/gesture-recognizer/src/engine/headless/gestures/matchers/gestureMatcher.ts b/common/web/gesture-recognizer/src/engine/headless/gestures/matchers/gestureMatcher.ts index 4257ec8c25c..8a3c14fc6ee 100644 --- a/common/web/gesture-recognizer/src/engine/headless/gestures/matchers/gestureMatcher.ts +++ b/common/web/gesture-recognizer/src/engine/headless/gestures/matchers/gestureMatcher.ts @@ -157,14 +157,14 @@ export class GestureMatcher implements PredecessorMatch< return; case 'full': contact = srcContact.constructSubview(false, true); - this.addContactInternal(contact, srcContact.path.stats); + this.addContactInternal(contact, srcContact.path.stats, true); continue; case 'partial': preserveBaseItem = true; // Intentional fall-through case 'chop': contact = srcContact.constructSubview(true, preserveBaseItem); - this.addContactInternal(contact, srcContact.path.stats); + this.addContactInternal(contact, srcContact.path.stats, true); break; } } @@ -367,7 +367,7 @@ export class GestureMatcher implements PredecessorMatch< return this._result; } - private addContactInternal(simpleSource: GestureSourceSubview, basePathStats: CumulativePathStats) { + private addContactInternal(simpleSource: GestureSourceSubview, basePathStats: CumulativePathStats, whileInitializing?: boolean) { // The number of already-active contacts tracked for this gesture const existingContacts = this.pathMatchers.length; @@ -491,16 +491,22 @@ export class GestureMatcher implements PredecessorMatch< const result = contactModel.update(); if(result?.type == 'reject') { /* - Refer to the earlier comment in this method re: use of 'cancelled'; we need to - prevent any and all further attempts to match against this model We'd - instantly reject it anyway due to its rejected initial state. Failing to do so - can cause an infinite async loop. - - If we weren't using 'cancelled', 'path' would correspond best with a rejection - here, as the decision is made due to the GestureSource's current path being - rejected by one of the `PathModel`s comprising the `GestureModel`. + Refer to the earlier comment in this method re: use of 'cancelled'; we + need to prevent any and all further attempts to match against this model + We'd instantly reject it anyway due to its rejected initial state. + Failing to do so can cause an infinite async loop. + + If we weren't using 'cancelled', 'path' would correspond best with a + rejection here, as the decision is made due to the GestureSource's + current path being rejected by one of the `PathModel`s comprising the + `GestureModel`. + + If the model's already been initialized, it's possible that a _new_ + incoming touch needs special handling. We'll allow one reset. In the + case that it would try to restart itself, the restarted model will + instantly fail and thus cancel. */ - this.finalize(false, 'cancelled'); + this.finalize(false, whileInitializing ? 'cancelled' : 'path'); } // Standard path: trigger either resolution or rejection when the contact model signals either. diff --git a/core/src/km_core_state_context_set_if_needed.cpp b/core/src/km_core_state_context_set_if_needed.cpp index fd6106fa7ae..7107815249f 100644 --- a/core/src/km_core_state_context_set_if_needed.cpp +++ b/core/src/km_core_state_context_set_if_needed.cpp @@ -15,6 +15,7 @@ #include "state.hpp" #include "debuglog.h" #include "core_icu.h" +#include "kmx/kmx_xstring.h" // for Unicode routines using namespace km::core; @@ -59,6 +60,12 @@ km_core_state_context_set_if_needed( return KM_CORE_CONTEXT_STATUS_INVALID_ARGUMENT; } + // if the app context begins with a trailing surrogate, + // skip over it. + if (Uni_IsSurrogate2(*new_app_context)) { + new_app_context++; + } + auto app_context = km_core_state_app_context(state); auto cached_context = km_core_state_context(state); diff --git a/core/tests/unit/ldml/test_context_normalization.cpp b/core/tests/unit/ldml/test_context_normalization.cpp index f9c9a694ff7..07fe82fdbd6 100644 --- a/core/tests/unit/ldml/test_context_normalization.cpp +++ b/core/tests/unit/ldml/test_context_normalization.cpp @@ -119,11 +119,23 @@ void test_context_normalization_invalid_unicode() { teardown(); } +void test_context_normalization_lone_trailing_surrogate() { + // unpaired trail surrogate + km_core_cp const application_context[] = { 0xDC01, 0x0020, 0x0020, 0x0000 }; + km_core_cp const cached_context[] = /* skipped*/ { 0x0020, 0x0020, 0x0000 }; + setup("k_001_tiny.kmx"); + assert(km_core_state_context_set_if_needed(test_state, application_context) == KM_CORE_CONTEXT_STATUS_UPDATED); + assert(is_identical_context(application_context+1, KM_CORE_DEBUG_CONTEXT_APP)); // first code unit is skipped + assert(is_identical_context(cached_context, KM_CORE_DEBUG_CONTEXT_CACHED)); + teardown(); +} + void test_context_normalization() { test_context_normalization_already_nfd(); test_context_normalization_basic(); test_context_normalization_hefty(); - // TODO: we need to strip illegal chars: test_context_normalization_invalid_unicode(); // -- unpaired surrogate, illegals + // TODO: see #10392 we need to strip illegal chars: test_context_normalization_invalid_unicode(); // -- unpaired surrogate, illegals + test_context_normalization_lone_trailing_surrogate(); } //------------------------------------------------------------------------------------- diff --git a/web/src/engine/osk/src/keyboard-layout/oskLayer.ts b/web/src/engine/osk/src/keyboard-layout/oskLayer.ts index 4e9968e77c8..ee84679a217 100644 --- a/web/src/engine/osk/src/keyboard-layout/oskLayer.ts +++ b/web/src/engine/osk/src/keyboard-layout/oskLayer.ts @@ -89,12 +89,12 @@ export default class OSKLayer { if(this.spaceBarKey) { const spacebarLabel = this.spaceBarKey.label; - let tParent = spacebarLabel.parentNode; + let tButton = this.spaceBarKey.btn; - if (typeof (tParent.className) == 'undefined' || tParent.className == '') { - tParent.className = 'kmw-spacebar'; - } else if (tParent.className.indexOf('kmw-spacebar') == -1) { - tParent.className += ' kmw-spacebar'; + if (typeof (tButton.className) == 'undefined' || tButton.className == '') { + tButton.className = 'kmw-spacebar'; + } else if (tButton.className.indexOf('kmw-spacebar') == -1) { + tButton.className += ' kmw-spacebar'; } if (spacebarLabel.className != 'kmw-spacebar-caption') { diff --git a/web/src/engine/osk/src/keyboard-layout/oskLayerGroup.ts b/web/src/engine/osk/src/keyboard-layout/oskLayerGroup.ts index 32512da70a4..6d7065e6b79 100644 --- a/web/src/engine/osk/src/keyboard-layout/oskLayerGroup.ts +++ b/web/src/engine/osk/src/keyboard-layout/oskLayerGroup.ts @@ -206,7 +206,7 @@ export default class OSKLayerGroup { Assumes there is no fine-tuning of the row ranges to be done - each takes a perfect fraction of the overall layer height without any padding above or below. */ - const rowIndex = Math.floor(proportionalCoords.y * layer.rows.length); + const rowIndex = Math.max(0, Math.min(layer.rows.length-1, Math.floor(proportionalCoords.y * layer.rows.length))); const row = layer.rows[rowIndex]; // Assertion: row no longer `null`.