Skip to content

Commit

Permalink
chore(web): Merge branch 'refactor/web/layer-group-encapsulation' int…
Browse files Browse the repository at this point in the history
…o feat/web/vkbd-layout-optimization
  • Loading branch information
jahorton committed Apr 10, 2024
2 parents 5a55ee1 + e7d7e1c commit 573ffd8
Show file tree
Hide file tree
Showing 12 changed files with 133 additions and 73 deletions.
10 changes: 10 additions & 0 deletions HISTORY.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
2 changes: 1 addition & 1 deletion VERSION.md
Original file line number Diff line number Diff line change
@@ -1 +1 @@
17.0.303
17.0.305
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/**
Expand Down Expand Up @@ -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;
}
Expand Down
14 changes: 12 additions & 2 deletions android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboard.java
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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.
Expand All @@ -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;
Expand Down
33 changes: 25 additions & 8 deletions android/KMEA/app/src/main/java/com/keyman/engine/KMManager.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.
* <p>Use {@link KMManager#updateSelectionRange(KeyboardType)} instead.</p>
*
* @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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Expand Down Expand Up @@ -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);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,51 +36,53 @@ export class AsyncClosureDispatchQueue {
return this.queue.length == 0 && !this.waitLock;
}

private async setWaitLock(promise: Promise<any>) {
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<any>;
try {
// Is either undefined (return type: void) or is a Promise.
result = functor() as undefined | Promise<any>;
/* 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<any>;
try {
// Is either undefined (return type: void) or is a Promise.
result = functor() as undefined | Promise<any>;
/* 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -157,14 +157,14 @@ export class GestureMatcher<Type, StateToken = any> 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;
}
}
Expand Down Expand Up @@ -367,7 +367,7 @@ export class GestureMatcher<Type, StateToken = any> implements PredecessorMatch<
return this._result;
}

private addContactInternal(simpleSource: GestureSourceSubview<Type>, basePathStats: CumulativePathStats<Type>) {
private addContactInternal(simpleSource: GestureSourceSubview<Type>, basePathStats: CumulativePathStats<Type>, whileInitializing?: boolean) {
// The number of already-active contacts tracked for this gesture
const existingContacts = this.pathMatchers.length;

Expand Down Expand Up @@ -491,16 +491,22 @@ export class GestureMatcher<Type, StateToken = any> 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.
Expand Down
7 changes: 7 additions & 0 deletions core/src/km_core_state_context_set_if_needed.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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);

Expand Down
14 changes: 13 additions & 1 deletion core/tests/unit/ldml/test_context_normalization.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

//-------------------------------------------------------------------------------------
Expand Down
10 changes: 5 additions & 5 deletions web/src/engine/osk/src/keyboard-layout/oskLayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,12 +89,12 @@ export default class OSKLayer {

if(this.spaceBarKey) {
const spacebarLabel = this.spaceBarKey.label;
let tParent = <HTMLElement>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') {
Expand Down
2 changes: 1 addition & 1 deletion web/src/engine/osk/src/keyboard-layout/oskLayerGroup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down

0 comments on commit 573ffd8

Please sign in to comment.