From e396d1f1cbcef21609602de1483aad13200127ba Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Tue, 20 Feb 2024 11:52:39 +0700 Subject: [PATCH 001/170] fix(web): key preview stickiness --- web/src/engine/osk/src/visualKeyboard.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/web/src/engine/osk/src/visualKeyboard.ts b/web/src/engine/osk/src/visualKeyboard.ts index dbdaaa33ad7..e7b4b98c985 100644 --- a/web/src/engine/osk/src/visualKeyboard.ts +++ b/web/src/engine/osk/src/visualKeyboard.ts @@ -677,6 +677,7 @@ export default class VisualKeyboard extends EventEmitter implements Ke } else if(gestureStage.matchedId.includes('modipress') && gestureStage.matchedId.includes('-start')) { // There shouldn't be a preview host for modipress keys... but it doesn't hurt to add the check. existingPreviewHost?.cancel(); + this.gesturePreviewHost = null; if(this.layerLocked) { console.warn("Unexpected state: modipress start attempt during an active modipress"); @@ -697,6 +698,7 @@ export default class VisualKeyboard extends EventEmitter implements Ke } else { // Probably an initial-tap or a simple-tap. existingPreviewHost?.cancel(); + this.gesturePreviewHost = null; } if(handlers) { From bd012679c01e34d8e65e1e97779c519518eeb314 Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Mon, 26 Feb 2024 10:54:10 +0700 Subject: [PATCH 002/170] fix(web): early gesture-match abort when unable to extend existing gestures --- .../gestures/matchers/matcherSelector.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/common/web/gesture-recognizer/src/engine/headless/gestures/matchers/matcherSelector.ts b/common/web/gesture-recognizer/src/engine/headless/gestures/matchers/matcherSelector.ts index 9d9b894f54b..6f0286a647e 100644 --- a/common/web/gesture-recognizer/src/engine/headless/gestures/matchers/matcherSelector.ts +++ b/common/web/gesture-recognizer/src/engine/headless/gestures/matchers/matcherSelector.ts @@ -15,6 +15,15 @@ interface GestureSourceTracker { */ source: GestureSource; matchPromise: ManagedPromise>; + /** + * Set to `true` during the timeout period needed to complete existing trackers & + * initialize new ones. Once that process is complete, set to false. + * + * This is needed to ensure that failure to extend an existing gesture doesn't + * result in outright selection-failure before attempting to match as a + * newly-started gesture. + */ + preserve: boolean; } export interface MatcherSelection { @@ -257,7 +266,8 @@ export class MatcherSelector extends EventEmitter = { source: src, - matchPromise: matchPromise + matchPromise: matchPromise, + preserve: true }; this._sourceSelector.push(sourceSelectors); @@ -359,6 +369,10 @@ export class MatcherSelector extends EventEmitter { + tracker.preserve = false; + }) + // If in a sustain mode, no models for new sources may launch; // only existing sequences are allowed to continue. if(this.sustainMode && unmatchedSource) { @@ -653,7 +667,7 @@ export class MatcherSelector extends EventEmitter Date: Mon, 26 Feb 2024 11:05:09 +0700 Subject: [PATCH 003/170] fix(web): infinite model-match replacement looping --- .../gestures/matchers/gestureMatcher.ts | 34 +++++++++++++++++-- 1 file changed, 31 insertions(+), 3 deletions(-) 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 4606ff2071f..75499f357ad 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 @@ -437,6 +437,7 @@ export class GestureMatcher implements PredecessorMatch< } } + // Check that initial "item" and "state" properties are legal for this type of gesture. if(contactSpec.model.allowsInitialState) { const initialStateCheck = contactSpec.model.allowsInitialState( simpleSource.currentSample, @@ -450,13 +451,40 @@ export class GestureMatcher implements PredecessorMatch< // pathMatcher for a source that failed to meet initial conditions. this.pathMatchers.pop(); - this.finalize(false, 'path'); + /* + * To prevent any further retries for the model (via rejectionActions), we list the + * cause as 'cancelled'. The rejection-action mechanism already blocks paths that + * are rejected synchronously by this method; this adds an extra layer of protection + * and is likely also more clear. + * + * Alternatively, 'item' _should_ be fine - and corresponds best with a + * rejection based on the initial item. + */ + this.finalize(false, 'cancelled'); } } - contactModel.update(); - // Now that we've done the initial-state check, we can check for instantly-matching path models. + // Now that we've done the initial-state check, we can check for instantly-matching and + // instantly-rejecting path models... particularly from from causes other than initial-item + // and state, such as rejection due to an extra touch. + // + // KMW example: longpresses cancel when a new touch comes in during the longpress timer; + // they should never become valid again for that base touch. + const result = contactModel.update(); + if(result?.type == 'reject') { + /* + * To prevent any further retries for the model (via rejectionActions), we list the + * cause as 'cancelled'. The rejection-action mechanism already blocks paths that + * are rejected synchronously by this method; this adds an extra layer of protection + * and is likely also more clear. + * + * Alternatively, 'path' _should_ be fine - and corresponds best with a + * rejected contact-path-model. + */ + this.finalize(false, 'cancelled'); + } + // Standard path: trigger either resolution or rejection when the contact model signals either. contactModel.promise.then((resolution) => { this.finalize(resolution.type == 'resolve', resolution.cause); }); From 5e4de069c3c21feb4bee00128f76bcc546e5cc84 Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Mon, 26 Feb 2024 12:01:28 +0700 Subject: [PATCH 004/170] fix(web): proper gesture-match sequencing --- .../gestures/matchers/matcherSelector.ts | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/common/web/gesture-recognizer/src/engine/headless/gestures/matchers/matcherSelector.ts b/common/web/gesture-recognizer/src/engine/headless/gestures/matchers/matcherSelector.ts index 6f0286a647e..14fd9154e2a 100644 --- a/common/web/gesture-recognizer/src/engine/headless/gestures/matchers/matcherSelector.ts +++ b/common/web/gesture-recognizer/src/engine/headless/gestures/matchers/matcherSelector.ts @@ -234,10 +234,23 @@ export class MatcherSelector extends EventEmitter(); + + this.pendingMatchSetup = childLock.corePromise; + // If a prior call is still waiting on the `await` below, wait for it to clear // entirely before proceeding; there could be effects for how the next part below is processed. - await this.pendingMatchSetup; + + await parentLockPromise; + + childLock.resolve(); // allow the next matchGesture call through. + + if(this.pendingMatchSetup == childLock.corePromise) { + this.pendingMatchSetup = null; + } } if(sourceNotYetStaged) { @@ -315,12 +328,17 @@ export class MatcherSelector extends EventEmitter(); this.pendingMatchSetup = pendingMatchGesture.corePromise; + await timedPromise(0); // A second one, in case of a deferred modipress completion (via awaitNested) // (which itself needs a macroqueue wait) await timedPromise(0); - this.pendingMatchSetup = null; + pendingMatchGesture.resolve(); + // Only clear the promise if no extra entries were added to the implied `matchGesture` queue. + if(this.pendingMatchSetup == pendingMatchGesture.corePromise) { + this.pendingMatchSetup = null; + } // stateToken may have shifted by the time we regain control here. const incomingStateToken = this.stateToken; From f9d5ddc809f62bc7c48f31985c4cf25ebd0a908d Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Mon, 26 Feb 2024 12:03:00 +0700 Subject: [PATCH 005/170] change(web): local var name, for clarity/consistency --- .../engine/headless/gestures/matchers/matcherSelector.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/common/web/gesture-recognizer/src/engine/headless/gestures/matchers/matcherSelector.ts b/common/web/gesture-recognizer/src/engine/headless/gestures/matchers/matcherSelector.ts index 14fd9154e2a..8e6e46ef1d2 100644 --- a/common/web/gesture-recognizer/src/engine/headless/gestures/matchers/matcherSelector.ts +++ b/common/web/gesture-recognizer/src/engine/headless/gestures/matchers/matcherSelector.ts @@ -326,17 +326,17 @@ export class MatcherSelector extends EventEmitter(); - this.pendingMatchSetup = pendingMatchGesture.corePromise; + const matchingLock = new ManagedPromise(); + this.pendingMatchSetup = matchingLock.corePromise; await timedPromise(0); // A second one, in case of a deferred modipress completion (via awaitNested) // (which itself needs a macroqueue wait) await timedPromise(0); - pendingMatchGesture.resolve(); + matchingLock.resolve(); // Only clear the promise if no extra entries were added to the implied `matchGesture` queue. - if(this.pendingMatchSetup == pendingMatchGesture.corePromise) { + if(this.pendingMatchSetup == matchingLock.corePromise) { this.pendingMatchSetup = null; } From 57079f3c0241aefa16020b6396824fc146ee198c Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Mon, 26 Feb 2024 12:09:43 +0700 Subject: [PATCH 006/170] chore(web): cleans condition comment --- .../src/engine/headless/gestures/matchers/matcherSelector.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/web/gesture-recognizer/src/engine/headless/gestures/matchers/matcherSelector.ts b/common/web/gesture-recognizer/src/engine/headless/gestures/matchers/matcherSelector.ts index 8e6e46ef1d2..c94f25fb190 100644 --- a/common/web/gesture-recognizer/src/engine/headless/gestures/matchers/matcherSelector.ts +++ b/common/web/gesture-recognizer/src/engine/headless/gestures/matchers/matcherSelector.ts @@ -235,7 +235,7 @@ export class MatcherSelector extends EventEmitter(); From fd97412bc44ae468ca1216a4bcbd445a97a38152 Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Mon, 26 Feb 2024 14:01:52 +0700 Subject: [PATCH 007/170] change(web): implements a touch-event sequentialization queue --- .../src/engine/eventSequentializationQueue.ts | 51 +++++ .../src/engine/headless/inputEngineBase.ts | 2 + .../engine/headless/touchpointCoordinator.ts | 16 +- .../src/engine/inputEventEngine.ts | 1 + .../src/engine/touchEventEngine.ts | 205 +++++++++++------- 5 files changed, 196 insertions(+), 79 deletions(-) create mode 100644 common/web/gesture-recognizer/src/engine/eventSequentializationQueue.ts diff --git a/common/web/gesture-recognizer/src/engine/eventSequentializationQueue.ts b/common/web/gesture-recognizer/src/engine/eventSequentializationQueue.ts new file mode 100644 index 00000000000..2cc962c73d0 --- /dev/null +++ b/common/web/gesture-recognizer/src/engine/eventSequentializationQueue.ts @@ -0,0 +1,51 @@ +import { timedPromise } from "@keymanapp/web-utils"; + +export class EventSequentializationQueue { + private queue: (() => Promise | void)[]; + private defermentPromise: Promise; + + constructor() { + this.queue = []; + } + + private setDeferment(promise: Promise) { + this.defermentPromise = promise; + promise.then(() => { + this.defermentPromise = null; + this.triggerEvent(); + }); + } + + private async triggerEvent() { + while(this.queue.length > 0) { + const functor = this.queue.shift(); + + // Things break _badly_ if we don't keep the queue running if errors are triggered by the functor. + // It's best to ignore the error and let things play out. + try { + // Is either undefined or is a Promise. + const result = functor(); + // We either wait on a manual lock (from within an InputEventEngine) or a macrotask queue wait, + // allowing gesture-matching microtask queue Promises to complete before proceeding. + this.setDeferment(result ? result : timedPromise(0)); + } catch (err) { + const baseMsg = 'Error sequentializing received inputs'; + if(err instanceof Error) { + console.error(`${baseMsg}: ${err.message}\n\n${err.stack}`); + } else { + console.error(baseMsg); + console.error(err); + } + } + } + } + + queueEventFunctor(functor: () => Promise | void) { + this.queue.push(functor); + // We only need to trigger events if the queue has no prior entries and there isn't an + // active deferment that will auto-trigger the event at the appropriate time. + if(this.queue.length == 1 && !this.defermentPromise) { + this.triggerEvent(); + } + } +} \ No newline at end of file diff --git a/common/web/gesture-recognizer/src/engine/headless/inputEngineBase.ts b/common/web/gesture-recognizer/src/engine/headless/inputEngineBase.ts index 3acd723c426..e73c502369a 100644 --- a/common/web/gesture-recognizer/src/engine/headless/inputEngineBase.ts +++ b/common/web/gesture-recognizer/src/engine/headless/inputEngineBase.ts @@ -56,6 +56,8 @@ export abstract class InputEngineBase extends return source; } + public unlockTouchpoint?: (touchpoint: GestureSource) => void; + /** * Calls to this method will cancel any touchpoints whose internal IDs are _not_ included in the parameter. * Designed to facilitate recovery from error cases and peculiar states that sometimes arise when debugging. diff --git a/common/web/gesture-recognizer/src/engine/headless/touchpointCoordinator.ts b/common/web/gesture-recognizer/src/engine/headless/touchpointCoordinator.ts index ff812771874..522af5317c0 100644 --- a/common/web/gesture-recognizer/src/engine/headless/touchpointCoordinator.ts +++ b/common/web/gesture-recognizer/src/engine/headless/touchpointCoordinator.ts @@ -223,7 +223,21 @@ export class TouchpointCoordinator extends Even const selector = this.currentSelector; touchpoint.setGestureMatchInspector(this.buildGestureMatchInspector(selector)); - this.emit('inputstart', touchpoint); + + // If there's an error in code receiving this event, we must not let that break the flow of + // event input processing here! + try { + this.emit('inputstart', touchpoint); + } catch (err) { + console.error(err); + } + + // In particular, things will break HORRIBLY if this code block does not get to run. + this.inputEngines.forEach((engine) => { + // It is now safe to signal further updates for this touchpoint, as we can be sure + // that each will be received. + engine.unlockTouchpoint?.(touchpoint); + }); const selection = await selectionPromise; diff --git a/common/web/gesture-recognizer/src/engine/inputEventEngine.ts b/common/web/gesture-recognizer/src/engine/inputEventEngine.ts index 8f434b71df8..f3cb583e6fb 100644 --- a/common/web/gesture-recognizer/src/engine/inputEventEngine.ts +++ b/common/web/gesture-recognizer/src/engine/inputEventEngine.ts @@ -48,6 +48,7 @@ export abstract class InputEventEngine extends Inpu }); this.emit('pointstart', touchpoint); + return touchpoint; } protected onInputMove(identifier: number, sample: InputSample, target: EventTarget) { diff --git a/common/web/gesture-recognizer/src/engine/touchEventEngine.ts b/common/web/gesture-recognizer/src/engine/touchEventEngine.ts index a2fc2b6b2c6..1c387834ef0 100644 --- a/common/web/gesture-recognizer/src/engine/touchEventEngine.ts +++ b/common/web/gesture-recognizer/src/engine/touchEventEngine.ts @@ -4,6 +4,9 @@ import { InputSample } from "./headless/inputSample.js"; import { Nonoptional } from "./nonoptional.js"; import { ZoneBoundaryChecker } from "./configuration/zoneBoundaryChecker.js"; import { GestureSource } from "./headless/gestureSource.js"; +import { ManagedPromise } from "@keymanapp/web-utils"; +import { EventSequentializationQueue } from "./eventSequentializationQueue.js"; +import { GesturePath } from "./index.js"; function touchListToArray(list: TouchList) { const arr: Touch[] = []; @@ -19,7 +22,11 @@ export class TouchEventEngine extends InputEv private readonly _touchMove: typeof TouchEventEngine.prototype.onTouchMove; private readonly _touchEnd: typeof TouchEventEngine.prototype.onTouchEnd; + protected readonly sequentializer = new EventSequentializationQueue(); + private safeBoundMaskMap: {[id: number]: number} = {}; + private pendingSourceIdentifiers: Map = new Map(); + private inputStartSignalMap: Map, ManagedPromise> = new Map(); public constructor(config: Nonoptional>) { super(config); @@ -35,17 +42,6 @@ export class TouchEventEngine extends InputEv return this.config.touchEventRoot; } - // public static forPredictiveBanner(banner: SuggestionBanner, handlerRoot: SuggestionManager) { - // const config: GestureRecognizerConfiguration = { - // targetRoot: banner.getDiv(), - // // document.body is the event root b/c we need to track the mouse if it leaves - // // the VisualKeyboard's hierarchy. - // eventRoot: banner.getDiv(), - // }; - - // return new TouchEventEngine(config); - // } - registerEventHandlers() { // The 'passive' property ensures we can prevent MouseEvent followups from TouchEvents. // It is only specified during `addEventListener`, not during `removeEventListener`. @@ -86,6 +82,19 @@ export class TouchEventEngine extends InputEv } } + public unlockTouchpoint? = (touchpoint: GestureSource>) => { + const lock = this.inputStartSignalMap.get(touchpoint); + if(lock) { + lock.resolve(); + this.inputStartSignalMap.delete(touchpoint); + } + }; + + public hasActiveTouchpoint(identifier: number): boolean { + const baseResult = super.hasActiveTouchpoint(identifier); + return baseResult || !!this.pendingSourceIdentifiers.has(identifier); + } + private buildSampleFromTouch(touch: Touch, timestamp: number) { // WILL be null for newly-starting `GestureSource`s / contact points. const source = this.getTouchpointWithId(touch.identifier); @@ -106,89 +115,129 @@ export class TouchEventEngine extends InputEv // during a touchstart.) const allTouches = touchListToArray(event.touches); const newTouches = touchListToArray(event.changedTouches); - // Maintain all touches in the `.touches` array that are NOT marked as `.changedTouches` (and therefore, new) - this.maintainTouchpointsWithIds(allTouches - .filter((touch1) => newTouches.findIndex(touch2 => touch1.identifier == touch2.identifier) == -1) - .map((touch) => touch.identifier) - ); - - // Ensure the same timestamp is used for all touches being updated. - const timestamp = performance.now(); - - // During a touch-start, only _new_ touch contact points are listed here; - // we shouldn't signal "input start" for any previously-existing touch points, - // so `.changedTouches` is the best way forward. - for(let i=0; i < event.changedTouches.length; i++) { - const touch = event.changedTouches.item(i); - const sample = this.buildSampleFromTouch(touch, timestamp); - - if(!ZoneBoundaryChecker.inputStartOutOfBoundsCheck(sample, this.config)) { - // If we started very close to a safe zone border, remember which one(s). - // This is important for input-sequence cancellation check logic. - this.safeBoundMaskMap[touch.identifier] = ZoneBoundaryChecker.inputStartSafeBoundProximityCheck(sample, this.config); - } else { - // This touchpoint shouldn't be considered; do not signal a touchstart for it. - continue; + + this.sequentializer.queueEventFunctor(() => { + // Maintain all touches in the `.touches` array that are NOT marked as `.changedTouches` (and therefore, new) + this.maintainTouchpointsWithIds(allTouches + .filter((touch1) => newTouches.findIndex(touch2 => touch1.identifier == touch2.identifier) == -1) + .map((touch) => touch.identifier) + ); + }); + + this.sequentializer.queueEventFunctor(() => { + // Ensure the same timestamp is used for all touches being updated. + const timestamp = performance.now(); + let lastValidTouchpoint: GestureSource = null; + let lastValidTouchId: number; + const uniqueObject = {}; + + // During a touch-start, only _new_ touch contact points are listed here; + // we shouldn't signal "input start" for any previously-existing touch points, + // so `.changedTouches` is the best way forward. + for(let i=0; i < event.changedTouches.length; i++) { + const touch = event.changedTouches.item(i); + const touchId = touch.identifier; + const sample = this.buildSampleFromTouch(touch, timestamp); + + this.pendingSourceIdentifiers.set(touchId, uniqueObject); + + if(!ZoneBoundaryChecker.inputStartOutOfBoundsCheck(sample, this.config)) { + // If we started very close to a safe zone border, remember which one(s). + // This is important for input-sequence cancellation check logic. + this.safeBoundMaskMap[touchId] = ZoneBoundaryChecker.inputStartSafeBoundProximityCheck(sample, this.config); + } else { + // This touchpoint shouldn't be considered; do not signal a touchstart for it. + continue; + } + + lastValidTouchpoint = this.onInputStart(touchId, sample, event.target, true); + lastValidTouchId = touchId; } - this.onInputStart(touch.identifier, sample, event.target, true); - } - } + if(lastValidTouchpoint) { + // Ensure we only do the cleanup if and when it hasn't already been replaced by new events later. + const cleanup = () => { + if(this.pendingSourceIdentifiers.get(lastValidTouchId) == uniqueObject) { + this.pendingSourceIdentifiers.delete(lastValidTouchId); + } + } - onTouchMove(event: TouchEvent) { - let propagationActive = true; - // Ensure the same timestamp is used for all touches being updated. - const timestamp = performance.now(); - - this.maintainTouchpointsWithIds(touchListToArray(event.touches) - .map((touch) => touch.identifier) - ); - - // Do not change to `changedTouches` - we need a sample for all active touches in order - // to facilitate path-update synchronization for multi-touch gestures. - // - // May be worth doing changedTouches _first_ though. - for(let i=0; i < event.touches.length; i++) { - const touch = event.touches.item(i); + lastValidTouchpoint.path.on('complete', cleanup); + lastValidTouchpoint.path.on('invalidated', cleanup); + + // This 'lock' should only be released when the last simultaneously-registered touch is published via + // gesture-recognizer event. + let eventSignalPromise = new ManagedPromise(); + this.inputStartSignalMap.set(lastValidTouchpoint, eventSignalPromise); - if(!this.hasActiveTouchpoint(touch.identifier)) { - continue; + return eventSignalPromise.corePromise; } + }); + } - if(propagationActive) { + onTouchMove(event: TouchEvent) { + for(let i = 0; i < event.touches.length; i++) { + const touch = event.touches.item(i); + if(this.hasActiveTouchpoint(touch.identifier)) { this.preventPropagation(event); - propagationActive = false; + break; } + } - const config = this.getConfigForId(touch.identifier); - - const sample = this.buildSampleFromTouch(touch, timestamp); - - if(!ZoneBoundaryChecker.inputMoveCancellationCheck(sample, config, this.safeBoundMaskMap[touch.identifier])) { - this.onInputMove(touch.identifier, sample, touch.target); - } else { - this.onInputMoveCancel(touch.identifier, sample, touch.target); + this.sequentializer.queueEventFunctor(() => { + this.maintainTouchpointsWithIds(touchListToArray(event.touches) + .map((touch) => touch.identifier) + ); + }); + + this.sequentializer.queueEventFunctor(() => { + // Ensure the same timestamp is used for all touches being updated. + const timestamp = performance.now(); + + // Do not change to `changedTouches` - we need a sample for all active touches in order + // to facilitate path-update synchronization for multi-touch gestures. + // + // May be worth doing changedTouches _first_ though. + for(let i=0; i < event.touches.length; i++) { + const touch = event.touches.item(i); + + if(!this.hasActiveTouchpoint(touch.identifier)) { + continue; + } + + const config = this.getConfigForId(touch.identifier); + const sample = this.buildSampleFromTouch(touch, timestamp); + + if(!ZoneBoundaryChecker.inputMoveCancellationCheck(sample, config, this.safeBoundMaskMap[touch.identifier])) { + this.onInputMove(touch.identifier, sample, touch.target); + } else { + this.onInputMoveCancel(touch.identifier, sample, touch.target); + } } - } + }) + } onTouchEnd(event: TouchEvent) { - let propagationActive = true; - - // Only lists touch contact points that have been lifted; touchmove is raised separately if any movement occurred. - for(let i=0; i < event.changedTouches.length; i++) { + for(let i = 0; i < event.changedTouches.length; i++) { const touch = event.changedTouches.item(i); - - if(!this.hasActiveTouchpoint(touch.identifier)) { - continue; - } - - if(propagationActive) { + if(this.hasActiveTouchpoint(touch.identifier)) { this.preventPropagation(event); - propagationActive = false; + break; } - - this.onInputEnd(touch.identifier, event.target); } + + this.sequentializer.queueEventFunctor(() => { + // Only lists touch contact points that have been lifted; touchmove is raised separately if any movement occurred. + for(let i=0; i < event.changedTouches.length; i++) { + const touch = event.changedTouches.item(i); + + if(!this.hasActiveTouchpoint(touch.identifier)) { + continue; + } + + this.onInputEnd(touch.identifier, event.target); + } + }); } } \ No newline at end of file From c915d1e6a26284fd89d159987a7c7fb68f4751d3 Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Mon, 26 Feb 2024 14:02:12 +0700 Subject: [PATCH 008/170] fix(web): unit test adjustment for compat with last commit --- .../src/test/auto/browser/cases/canary.js | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/common/web/gesture-recognizer/src/test/auto/browser/cases/canary.js b/common/web/gesture-recognizer/src/test/auto/browser/cases/canary.js index debee09c995..ff9a83fa66e 100644 --- a/common/web/gesture-recognizer/src/test/auto/browser/cases/canary.js +++ b/common/web/gesture-recognizer/src/test/auto/browser/cases/canary.js @@ -72,16 +72,18 @@ describe("'Canary' checks", function() { let fireEvent = () => { playbackEngine.replayTouchSamples(/*relative coord:*/ [ { sample: {targetX: 10, targetY: 10}, identifier: 1}], /*state:*/ "start", - /*recentTouches:*/ [], + /*recentTouches:*/ [/* TODO */], ); } // Ensure that the expected handler is called. let fakeHandler = sinon.fake(); - this.controller.recognizer.on('inputstart', fakeHandler) + this.controller.recognizer.on('inputstart', fakeHandler); fireEvent(); - await Promise.resolve(); + await new Promise((resolve) => { + window.setTimeout(resolve, 0); + }); assert.isTrue(fakeHandler.called, "Unit test attempt failed: handler was not called successfully."); }); @@ -99,10 +101,12 @@ describe("'Canary' checks", function() { // Ensure that the expected handler is called. let fakeHandler = sinon.fake(); - this.controller.recognizer.on('inputstart', fakeHandler) + this.controller.recognizer.on('inputstart', fakeHandler); fireEvent(); - await Promise.resolve(); + await new Promise((resolve) => { + window.setTimeout(resolve, 0); + }); assert.isTrue(fakeHandler.called, "Unit test attempt failed: handler was not called successfully."); }); From 3e7c8fa1645951943fe1a5c2f4498303f6ecc1d0 Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Tue, 27 Feb 2024 09:25:06 +0700 Subject: [PATCH 009/170] fix(web): triggerEvent should fire just one, not loop --- .../src/engine/eventSequentializationQueue.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/web/gesture-recognizer/src/engine/eventSequentializationQueue.ts b/common/web/gesture-recognizer/src/engine/eventSequentializationQueue.ts index 2cc962c73d0..2ebb8445040 100644 --- a/common/web/gesture-recognizer/src/engine/eventSequentializationQueue.ts +++ b/common/web/gesture-recognizer/src/engine/eventSequentializationQueue.ts @@ -17,7 +17,7 @@ export class EventSequentializationQueue { } private async triggerEvent() { - while(this.queue.length > 0) { + if(this.queue.length > 0) { const functor = this.queue.shift(); // Things break _badly_ if we don't keep the queue running if errors are triggered by the functor. From e8fb4a144134282532fcb135597a166db891b8bf Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Tue, 27 Feb 2024 10:02:09 +0700 Subject: [PATCH 010/170] change(web): more explicit 'cancelled'-rejection handling, documentation --- .../gestures/matchers/gestureMatcher.ts | 73 ++++++++++++------- .../headless/gestures/specs/gestureModel.ts | 23 +++++- 2 files changed, 68 insertions(+), 28 deletions(-) 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 75499f357ad..11ff4b2694e 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 @@ -193,10 +193,16 @@ export class GestureMatcher implements PredecessorMatch< // Easy peasy - resolutions only need & have the one defined action type. action = this.model.resolutionAction; } else { - // Some gesture types may wish to restart with a new base item if they fail due to - // it changing during its lifetime or due to characteristics of the contact-point's - // path. - if(this.model.rejectionActions?.[cause]) { + /* + Some gesture types may wish to restart with a new base item if they fail due to + it changing during its lifetime or due to characteristics of the contact-point's + path. + + If a gesture model match is outright-cancelled, matcher restarts should be completely + blocked. One notable reason: if a model-match is _immediately_ cancelled due to + initial conditions, reattempting it can cause an infinite (async) loop! + */ + if(cause != 'cancelled' && this.model.rejectionActions?.[cause]) { action = this.model.rejectionActions[cause]; action.item = 'none'; } @@ -451,36 +457,49 @@ export class GestureMatcher implements PredecessorMatch< // pathMatcher for a source that failed to meet initial conditions. this.pathMatchers.pop(); - /* - * To prevent any further retries for the model (via rejectionActions), we list the - * cause as 'cancelled'. The rejection-action mechanism already blocks paths that - * are rejected synchronously by this method; this adds an extra layer of protection - * and is likely also more clear. - * - * Alternatively, 'item' _should_ be fine - and corresponds best with a - * rejection based on the initial item. - */ + /* + To prevent any further retries for the model (via rejectionActions), we list the + cause as 'cancelled'. 'Cancelled' match attempts will never be retried, and we + wish to prevent an infinite (async) loop from retrying something we know will + auto-cancel. (That loop would automatically end upon a different model's match + or upon all possible models failing to match at the same time, but it's still + far from ideal.) + + The rejection-action mechanism in MatcherSelector's `replacer` method (refer to + https://github.com/keymanapp/keyman/blob/be867604e4b2650a60e69dc6bbe0b6115315efff/common/web/gesture-recognizer/src/engine/headless/gestures/matchers/matcherSelector.ts#L559-L575) + already blocks paths that are rejected synchronously by this method. Use of + 'cancelled' is thus not necessary for avoiding the loop-scenario, but it does + add an extra layer of protection. Also, it's more explicit about the fact that + we _are_ permanently cancelling any and all future attempts to match against + it in the future for this `GestureSource`. + + If we weren't using 'cancelled', 'item' would correspond best with a rejection + here, as the decision is made due to a validation check against the initial item. + */ this.finalize(false, 'cancelled'); } } - // Now that we've done the initial-state check, we can check for instantly-matching and - // instantly-rejecting path models... particularly from from causes other than initial-item - // and state, such as rejection due to an extra touch. - // - // KMW example: longpresses cancel when a new touch comes in during the longpress timer; - // they should never become valid again for that base touch. + /* + Now that we've done the initial-state check, we can check for instantly-matching and + instantly-rejecting path models... particularly from from causes other than initial-item + and state, such as rejection due to an extra touch. + + KMW example: longpresses cancel when a new touch comes in during the longpress timer; + they should never become valid again for that base touch. + */ const result = contactModel.update(); if(result?.type == 'reject') { /* - * To prevent any further retries for the model (via rejectionActions), we list the - * cause as 'cancelled'. The rejection-action mechanism already blocks paths that - * are rejected synchronously by this method; this adds an extra layer of protection - * and is likely also more clear. - * - * Alternatively, 'path' _should_ be fine - and corresponds best with a - * rejected contact-path-model. - */ + 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`. + */ this.finalize(false, 'cancelled'); } diff --git a/common/web/gesture-recognizer/src/engine/headless/gestures/specs/gestureModel.ts b/common/web/gesture-recognizer/src/engine/headless/gestures/specs/gestureModel.ts index 27b2a4d32fc..8deb7b8b4a2 100644 --- a/common/web/gesture-recognizer/src/engine/headless/gestures/specs/gestureModel.ts +++ b/common/web/gesture-recognizer/src/engine/headless/gestures/specs/gestureModel.ts @@ -142,7 +142,28 @@ export interface GestureModel { readonly resolutionAction: GestureResolutionSpec; - readonly rejectionActions?: Partial>; + /* + Do NOT allow 'cancelled' rejection-actions. If 'cancelled', the corresponding `GestureSource`s + can no longer be valid matches for the GestureModel under any condition. + + Generally, this is due to the underlying sources themselves being cancelled, but this can also + arise under the following combination of conditions: + - a model instantly rejects... + - whenever a new `GestureSource` starts and matches an instantly-rejecting `PathModel` for this + `GestureModel` (cause: 'path') + - when it fails initial-state validation (cause: 'item') + - a corresponding rejection action has been defined. + - For example, it also rejects under certain path conditions (for its original `GestureSource`) + that are recoverable. + + Upon receiving an incoming extra GestureSource, the model would instantly reject (cause: 'path') + and could attempt to restart if specified to do so by a 'path' rejection action. In such a case, + it would instantly reject again due to the same reason. Instant rejection of a replacement model + during a rejection action is reported as 'cancellation'. + */ + + readonly rejectionActions?: Partial, RejectionReplace>>; + // If there is a 'gesture stack' associated with the gesture chain, it's auto-popped // upon completion of the chain. Optional-chaining can sustain the chain while the // potential child gesture is still a possibility. From 03a329ae4616298ffcf89f3508c549f45b3cdcf8 Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Tue, 27 Feb 2024 10:06:17 +0700 Subject: [PATCH 011/170] docs(web): minor comment tweak --- .../src/engine/headless/gestures/matchers/gestureMatcher.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 11ff4b2694e..4257ec8c25c 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 @@ -471,7 +471,7 @@ export class GestureMatcher implements PredecessorMatch< 'cancelled' is thus not necessary for avoiding the loop-scenario, but it does add an extra layer of protection. Also, it's more explicit about the fact that we _are_ permanently cancelling any and all future attempts to match against - it in the future for this `GestureSource`. + it in the future for the affected `GestureSource`(s). If we weren't using 'cancelled', 'item' would correspond best with a rejection here, as the decision is made due to a validation check against the initial item. From dae1126098cf008a6a193885ec9b77d6e7129fa6 Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Tue, 27 Feb 2024 10:40:02 +0700 Subject: [PATCH 012/170] change(web): better nomenclature, fixes triggerEvent --- .../src/engine/asyncClosureDispatchQueue.ts | 68 +++++++++++++++++++ .../src/engine/eventSequentializationQueue.ts | 51 -------------- .../engine/headless/touchpointCoordinator.ts | 5 +- .../src/engine/reportError.ts | 9 +++ .../src/engine/touchEventEngine.ts | 14 ++-- 5 files changed, 86 insertions(+), 61 deletions(-) create mode 100644 common/web/gesture-recognizer/src/engine/asyncClosureDispatchQueue.ts delete mode 100644 common/web/gesture-recognizer/src/engine/eventSequentializationQueue.ts create mode 100644 common/web/gesture-recognizer/src/engine/reportError.ts diff --git a/common/web/gesture-recognizer/src/engine/asyncClosureDispatchQueue.ts b/common/web/gesture-recognizer/src/engine/asyncClosureDispatchQueue.ts new file mode 100644 index 00000000000..2b9c1fe927b --- /dev/null +++ b/common/web/gesture-recognizer/src/engine/asyncClosureDispatchQueue.ts @@ -0,0 +1,68 @@ +import { timedPromise } from "@keymanapp/web-utils"; +import { reportError } from "./reportError.js"; + +type QueueClosure = () => (Promise | void); + +/** + This class is modeled somewhat after Swift's `DispatchQueue` class, but with + the twist that each closure may return a `Promise` (in Swift: a `Future`) to + lock out further closure processing until the `Promise` resolves. +*/ +export class AsyncClosureDispatchQueue { + private queue: QueueClosure[]; + private waitLock: Promise; + + constructor() { + this.queue = []; + } + + private async setWaitLock(promise: Promise) { + this.waitLock = promise; + + try { + await promise; + } catch(err) { + reportError('Async error from queued closure', err); + } + + this.waitLock = null; + this.triggerNextEvent(); + } + + private async triggerNextEvent() { + if(this.queue.length > 0) { + const functor = this.queue.shift(); + + /* + 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; + } catch (err) { + reportError('Error from queued closure', err); + } + + /* + 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 delay until the microtask queue is complete as our default. + */ + this.setWaitLock(result ?? timedPromise(0)); + } + } + + runAsync(closure: QueueClosure) { + this.queue.push(closure); + + // We only need to trigger events if the queue has no prior entries and there isn't an + // active wait-lock; for the latter, we'll auto-trigger the next function when it unlocks. + if(this.queue.length == 1 && !this.waitLock) { + this.triggerNextEvent(); + } + } +} \ No newline at end of file diff --git a/common/web/gesture-recognizer/src/engine/eventSequentializationQueue.ts b/common/web/gesture-recognizer/src/engine/eventSequentializationQueue.ts deleted file mode 100644 index 2ebb8445040..00000000000 --- a/common/web/gesture-recognizer/src/engine/eventSequentializationQueue.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { timedPromise } from "@keymanapp/web-utils"; - -export class EventSequentializationQueue { - private queue: (() => Promise | void)[]; - private defermentPromise: Promise; - - constructor() { - this.queue = []; - } - - private setDeferment(promise: Promise) { - this.defermentPromise = promise; - promise.then(() => { - this.defermentPromise = null; - this.triggerEvent(); - }); - } - - private async triggerEvent() { - if(this.queue.length > 0) { - const functor = this.queue.shift(); - - // Things break _badly_ if we don't keep the queue running if errors are triggered by the functor. - // It's best to ignore the error and let things play out. - try { - // Is either undefined or is a Promise. - const result = functor(); - // We either wait on a manual lock (from within an InputEventEngine) or a macrotask queue wait, - // allowing gesture-matching microtask queue Promises to complete before proceeding. - this.setDeferment(result ? result : timedPromise(0)); - } catch (err) { - const baseMsg = 'Error sequentializing received inputs'; - if(err instanceof Error) { - console.error(`${baseMsg}: ${err.message}\n\n${err.stack}`); - } else { - console.error(baseMsg); - console.error(err); - } - } - } - } - - queueEventFunctor(functor: () => Promise | void) { - this.queue.push(functor); - // We only need to trigger events if the queue has no prior entries and there isn't an - // active deferment that will auto-trigger the event at the appropriate time. - if(this.queue.length == 1 && !this.defermentPromise) { - this.triggerEvent(); - } - } -} \ No newline at end of file diff --git a/common/web/gesture-recognizer/src/engine/headless/touchpointCoordinator.ts b/common/web/gesture-recognizer/src/engine/headless/touchpointCoordinator.ts index 522af5317c0..96ee27f507a 100644 --- a/common/web/gesture-recognizer/src/engine/headless/touchpointCoordinator.ts +++ b/common/web/gesture-recognizer/src/engine/headless/touchpointCoordinator.ts @@ -5,9 +5,9 @@ import { MatcherSelection, MatcherSelector } from "./gestures/matchers/matcherSe import { GestureSequence } from "./gestures/matchers/gestureSequence.js"; import { GestureModelDefs, getGestureModel, getGestureModelSet } from "./gestures/specs/gestureModelDefs.js"; import { GestureModel } from "./gestures/specs/gestureModel.js"; -import { timedPromise } from "@keymanapp/web-utils"; import { InputSample } from "./inputSample.js"; import { GestureDebugPath } from "./gestureDebugPath.js"; +import { reportError } from "../reportError.js"; interface EventMap { /** @@ -229,10 +229,9 @@ export class TouchpointCoordinator extends Even try { this.emit('inputstart', touchpoint); } catch (err) { - console.error(err); + reportError("Error from 'inputstart' event listener", err); } - // In particular, things will break HORRIBLY if this code block does not get to run. this.inputEngines.forEach((engine) => { // It is now safe to signal further updates for this touchpoint, as we can be sure // that each will be received. diff --git a/common/web/gesture-recognizer/src/engine/reportError.ts b/common/web/gesture-recognizer/src/engine/reportError.ts new file mode 100644 index 00000000000..076a987bdd6 --- /dev/null +++ b/common/web/gesture-recognizer/src/engine/reportError.ts @@ -0,0 +1,9 @@ +export function reportError(baseMsg: string, err: Error) { + // Our mobile-app Sentry logging will listen for this and log it. + if(err instanceof Error) { + console.error(`${baseMsg}: ${err.message}\n\n${err.stack}`); + } else { + console.error(baseMsg); + console.error(err); + } +} \ No newline at end of file diff --git a/common/web/gesture-recognizer/src/engine/touchEventEngine.ts b/common/web/gesture-recognizer/src/engine/touchEventEngine.ts index 1c387834ef0..2cd122b3004 100644 --- a/common/web/gesture-recognizer/src/engine/touchEventEngine.ts +++ b/common/web/gesture-recognizer/src/engine/touchEventEngine.ts @@ -5,7 +5,7 @@ import { Nonoptional } from "./nonoptional.js"; import { ZoneBoundaryChecker } from "./configuration/zoneBoundaryChecker.js"; import { GestureSource } from "./headless/gestureSource.js"; import { ManagedPromise } from "@keymanapp/web-utils"; -import { EventSequentializationQueue } from "./eventSequentializationQueue.js"; +import { AsyncClosureDispatchQueue } from "./asyncClosureDispatchQueue.js"; import { GesturePath } from "./index.js"; function touchListToArray(list: TouchList) { @@ -22,7 +22,7 @@ export class TouchEventEngine extends InputEv private readonly _touchMove: typeof TouchEventEngine.prototype.onTouchMove; private readonly _touchEnd: typeof TouchEventEngine.prototype.onTouchEnd; - protected readonly sequentializer = new EventSequentializationQueue(); + protected readonly eventDispatcher = new AsyncClosureDispatchQueue(); private safeBoundMaskMap: {[id: number]: number} = {}; private pendingSourceIdentifiers: Map = new Map(); @@ -116,7 +116,7 @@ export class TouchEventEngine extends InputEv const allTouches = touchListToArray(event.touches); const newTouches = touchListToArray(event.changedTouches); - this.sequentializer.queueEventFunctor(() => { + this.eventDispatcher.runAsync(() => { // Maintain all touches in the `.touches` array that are NOT marked as `.changedTouches` (and therefore, new) this.maintainTouchpointsWithIds(allTouches .filter((touch1) => newTouches.findIndex(touch2 => touch1.identifier == touch2.identifier) == -1) @@ -124,7 +124,7 @@ export class TouchEventEngine extends InputEv ); }); - this.sequentializer.queueEventFunctor(() => { + this.eventDispatcher.runAsync(() => { // Ensure the same timestamp is used for all touches being updated. const timestamp = performance.now(); let lastValidTouchpoint: GestureSource = null; @@ -184,13 +184,13 @@ export class TouchEventEngine extends InputEv } } - this.sequentializer.queueEventFunctor(() => { + this.eventDispatcher.runAsync(() => { this.maintainTouchpointsWithIds(touchListToArray(event.touches) .map((touch) => touch.identifier) ); }); - this.sequentializer.queueEventFunctor(() => { + this.eventDispatcher.runAsync(() => { // Ensure the same timestamp is used for all touches being updated. const timestamp = performance.now(); @@ -227,7 +227,7 @@ export class TouchEventEngine extends InputEv } } - this.sequentializer.queueEventFunctor(() => { + this.eventDispatcher.runAsync(() => { // Only lists touch contact points that have been lifted; touchmove is raised separately if any movement occurred. for(let i=0; i < event.changedTouches.length; i++) { const touch = event.changedTouches.item(i); From 2da832816acb7794c3a3d84b3d9edce531d5e20f Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Tue, 27 Feb 2024 10:59:07 +0700 Subject: [PATCH 013/170] change(web): unlockTouchpoint -> fulfillInputStart --- .../src/engine/headless/inputEngineBase.ts | 2 +- .../engine/headless/touchpointCoordinator.ts | 25 +++++++++++++++---- .../src/engine/inputEventEngine.ts | 8 +++++- .../src/engine/mouseEventEngine.ts | 1 + .../src/engine/touchEventEngine.ts | 4 +-- 5 files changed, 31 insertions(+), 9 deletions(-) diff --git a/common/web/gesture-recognizer/src/engine/headless/inputEngineBase.ts b/common/web/gesture-recognizer/src/engine/headless/inputEngineBase.ts index e73c502369a..db442541a29 100644 --- a/common/web/gesture-recognizer/src/engine/headless/inputEngineBase.ts +++ b/common/web/gesture-recognizer/src/engine/headless/inputEngineBase.ts @@ -56,7 +56,7 @@ export abstract class InputEngineBase extends return source; } - public unlockTouchpoint?: (touchpoint: GestureSource) => void; + public fulfillInputStart(touchpoint: GestureSource) {} /** * Calls to this method will cancel any touchpoints whose internal IDs are _not_ included in the parameter. diff --git a/common/web/gesture-recognizer/src/engine/headless/touchpointCoordinator.ts b/common/web/gesture-recognizer/src/engine/headless/touchpointCoordinator.ts index 96ee27f507a..6335cef6168 100644 --- a/common/web/gesture-recognizer/src/engine/headless/touchpointCoordinator.ts +++ b/common/web/gesture-recognizer/src/engine/headless/touchpointCoordinator.ts @@ -224,20 +224,34 @@ export class TouchpointCoordinator extends Even touchpoint.setGestureMatchInspector(this.buildGestureMatchInspector(selector)); - // If there's an error in code receiving this event, we must not let that break the flow of - // event input processing here! + /* + If there's an error in code receiving this event, we must not let that break the flow of + event input processing - we may still have a locking Promise corresponding to our active + GestureSource. (See: next comment) + */ try { this.emit('inputstart', touchpoint); } catch (err) { reportError("Error from 'inputstart' event listener", err); } + /* + If an `InputEventEngine` internally utilizes the `AsyncClosureDispatchQueue`, this is the point + at which we are now safe to process further events. The correct 'stateToken' has been identified + and all GestureMatcher possibilities for the source have been launched; path updates to resume _and_ + new incoming paths may now be safely handled. As such, we can now fulfill any Promise returned by + a closure defined within its `inputStart` method for the `GestureSource` under consideration. + + It is quite important that we _do_ fulfill the `Promise` if it exists - further event processing will + be blocked for such engines until this is done! (Hence the try-catch above) + */ this.inputEngines.forEach((engine) => { - // It is now safe to signal further updates for this touchpoint, as we can be sure - // that each will be received. - engine.unlockTouchpoint?.(touchpoint); + engine.fulfillInputStart(touchpoint); }); + // ---------------------------------------- + + // All gesture-matching is prepared; now we await the source's first gesture model match. const selection = await selectionPromise; // Any related 'push' mechanics that may still be lingering are currently handled by GestureSequence @@ -268,6 +282,7 @@ export class TouchpointCoordinator extends Even // Could track sequences easily enough; the question is how to tell when to 'let go'. + // No try-catch because only there's no critical code after it. this.emit('recognizedgesture', gestureSequence); } diff --git a/common/web/gesture-recognizer/src/engine/inputEventEngine.ts b/common/web/gesture-recognizer/src/engine/inputEventEngine.ts index f3cb583e6fb..64eedef8a45 100644 --- a/common/web/gesture-recognizer/src/engine/inputEventEngine.ts +++ b/common/web/gesture-recognizer/src/engine/inputEventEngine.ts @@ -2,6 +2,7 @@ import { InputEngineBase } from "./headless/inputEngineBase.js"; import { InputSample } from "./headless/inputSample.js"; import { GestureSource } from "./headless/gestureSource.js"; import { GestureRecognizerConfiguration } from "./index.js"; +import { reportError } from "./reportError.js"; export function processSampleClientCoords(config: GestureRecognizerConfiguration, clientX: number, clientY: number) { const targetRect = config.targetRoot.getBoundingClientRect(); @@ -47,7 +48,12 @@ export abstract class InputEventEngine extends Inpu this.dropTouchpoint(touchpoint); }); - this.emit('pointstart', touchpoint); + try { + this.emit('pointstart', touchpoint); + } catch(err) { + reportError('Engine-internal error while initializing gesture matching for new source', err); + } + return touchpoint; } diff --git a/common/web/gesture-recognizer/src/engine/mouseEventEngine.ts b/common/web/gesture-recognizer/src/engine/mouseEventEngine.ts index 42baf53299b..d3a05a81793 100644 --- a/common/web/gesture-recognizer/src/engine/mouseEventEngine.ts +++ b/common/web/gesture-recognizer/src/engine/mouseEventEngine.ts @@ -4,6 +4,7 @@ import { InputSample } from "./headless/inputSample.js"; import { Nonoptional } from "./nonoptional.js"; import { ZoneBoundaryChecker } from "./configuration/zoneBoundaryChecker.js"; +// Does NOT use the AsyncClosureDispatchQueue... simply because there can only ever be one mouse touchpoint. export class MouseEventEngine extends InputEventEngine { private readonly _mouseStart: typeof MouseEventEngine.prototype.onMouseStart; private readonly _mouseMove: typeof MouseEventEngine.prototype.onMouseMove; diff --git a/common/web/gesture-recognizer/src/engine/touchEventEngine.ts b/common/web/gesture-recognizer/src/engine/touchEventEngine.ts index 2cd122b3004..e93a026169e 100644 --- a/common/web/gesture-recognizer/src/engine/touchEventEngine.ts +++ b/common/web/gesture-recognizer/src/engine/touchEventEngine.ts @@ -82,11 +82,11 @@ export class TouchEventEngine extends InputEv } } - public unlockTouchpoint? = (touchpoint: GestureSource>) => { + public fulfillInputStart(touchpoint: GestureSource>) { const lock = this.inputStartSignalMap.get(touchpoint); if(lock) { - lock.resolve(); this.inputStartSignalMap.delete(touchpoint); + lock.resolve(); } }; From b5cf14a6e02f19e135712d0c1bb3170521c643e2 Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Tue, 27 Feb 2024 13:08:53 +0700 Subject: [PATCH 014/170] feat(web): closure-queue unit tests --- .../asyncClosureDispatchQueue.ts | 46 +++- .../gesture-recognizer/src/engine/index.ts | 1 + .../src/engine/touchEventEngine.ts | 2 +- .../asyncClosureDispatchQueue.spec.ts | 198 ++++++++++++++++++ 4 files changed, 235 insertions(+), 12 deletions(-) rename common/web/gesture-recognizer/src/engine/{ => headless}/asyncClosureDispatchQueue.ts (57%) create mode 100644 common/web/gesture-recognizer/src/test/auto/headless/asyncClosureDispatchQueue.spec.ts diff --git a/common/web/gesture-recognizer/src/engine/asyncClosureDispatchQueue.ts b/common/web/gesture-recognizer/src/engine/headless/asyncClosureDispatchQueue.ts similarity index 57% rename from common/web/gesture-recognizer/src/engine/asyncClosureDispatchQueue.ts rename to common/web/gesture-recognizer/src/engine/headless/asyncClosureDispatchQueue.ts index 2b9c1fe927b..a6246296d3e 100644 --- a/common/web/gesture-recognizer/src/engine/asyncClosureDispatchQueue.ts +++ b/common/web/gesture-recognizer/src/engine/headless/asyncClosureDispatchQueue.ts @@ -1,7 +1,7 @@ import { timedPromise } from "@keymanapp/web-utils"; -import { reportError } from "./reportError.js"; +import { reportError } from "../reportError.js"; -type QueueClosure = () => (Promise | void); +export type QueueClosure = () => (Promise | void); /** This class is modeled somewhat after Swift's `DispatchQueue` class, but with @@ -11,9 +11,25 @@ type QueueClosure = () => (Promise | void); export class AsyncClosureDispatchQueue { private queue: QueueClosure[]; private waitLock: Promise; + private defaultWaitFactory: () => Promise; - constructor() { + /** + * + * @param defaultWaitFactory A factory returning Promises to use for default + * delays between tasks. If not specified, Promises corresponding to + * setTimeout(0) will be used, allowing the microqueue task to flush between + * tasks. + */ + constructor(defaultWaitFactory?: () => Promise) { + // We only need to trigger events if the queue has no prior entries and there isn't an + // active wait-lock; for the latter, we'll auto-trigger the next function when it unlocks. this.queue = []; + + this.defaultWaitFactory = defaultWaitFactory || (() => { return timedPromise(0) }); + } + + get ready() { + return this.queue.length == 0 && !this.waitLock; } private async setWaitLock(promise: Promise) { @@ -26,13 +42,16 @@ export class AsyncClosureDispatchQueue { } this.waitLock = null; - this.triggerNextEvent(); + this.triggerNextClosure(); } - private async triggerNextEvent() { + 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 @@ -42,27 +61,32 @@ export class AsyncClosureDispatchQueue { 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 delay until the microtask queue is complete as our default. + If not, we still add a delay according to the specified default. */ - this.setWaitLock(result ?? timedPromise(0)); + 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. + const isReady = this.ready; + this.queue.push(closure); - // We only need to trigger events if the queue has no prior entries and there isn't an - // active wait-lock; for the latter, we'll auto-trigger the next function when it unlocks. - if(this.queue.length == 1 && !this.waitLock) { - this.triggerNextEvent(); + // If `!isReady`, the next closure will automatically be triggered when possible. + if(isReady) { + this.triggerNextClosure(); } } } \ No newline at end of file diff --git a/common/web/gesture-recognizer/src/engine/index.ts b/common/web/gesture-recognizer/src/engine/index.ts index 76e4cbe4840..3da7cc16dac 100644 --- a/common/web/gesture-recognizer/src/engine/index.ts +++ b/common/web/gesture-recognizer/src/engine/index.ts @@ -1,5 +1,6 @@ import { validateModelDefs } from './headless/gestures/specs/modelDefValidator.js'; +export { AsyncClosureDispatchQueue, QueueClosure } from './headless/asyncClosureDispatchQueue.js'; export { CumulativePathStats } from './headless/cumulativePathStats.js'; export { GestureModelDefs } from './headless/gestures/specs/gestureModelDefs.js'; export { GestureRecognizer } from "./gestureRecognizer.js"; diff --git a/common/web/gesture-recognizer/src/engine/touchEventEngine.ts b/common/web/gesture-recognizer/src/engine/touchEventEngine.ts index e93a026169e..accefd08b90 100644 --- a/common/web/gesture-recognizer/src/engine/touchEventEngine.ts +++ b/common/web/gesture-recognizer/src/engine/touchEventEngine.ts @@ -5,7 +5,7 @@ import { Nonoptional } from "./nonoptional.js"; import { ZoneBoundaryChecker } from "./configuration/zoneBoundaryChecker.js"; import { GestureSource } from "./headless/gestureSource.js"; import { ManagedPromise } from "@keymanapp/web-utils"; -import { AsyncClosureDispatchQueue } from "./asyncClosureDispatchQueue.js"; +import { AsyncClosureDispatchQueue } from "./headless/asyncClosureDispatchQueue.js"; import { GesturePath } from "./index.js"; function touchListToArray(list: TouchList) { diff --git a/common/web/gesture-recognizer/src/test/auto/headless/asyncClosureDispatchQueue.spec.ts b/common/web/gesture-recognizer/src/test/auto/headless/asyncClosureDispatchQueue.spec.ts new file mode 100644 index 00000000000..860f5134ae5 --- /dev/null +++ b/common/web/gesture-recognizer/src/test/auto/headless/asyncClosureDispatchQueue.spec.ts @@ -0,0 +1,198 @@ +import { assert } from 'chai' +import { default as sinon, type SinonSpy } from 'sinon'; +import { AsyncClosureDispatchQueue, type QueueClosure } from '@keymanapp/gesture-recognizer'; +import { ManagedPromise, timedPromise } from '@keymanapp/web-utils'; + +type ClosureSpy = SinonSpy<[], ReturnType>; + +describe('AsyncClosureDispatchQueue', () => { + it('initial, state', () => { + const queue = new AsyncClosureDispatchQueue(); + assert.isTrue(queue.ready); + }); + + it('empty queue, default configuration, simple closure', async () => { + const queue = new AsyncClosureDispatchQueue(); + + const fake = sinon.fake(); + queue.runAsync(() => { + fake(queue.ready); + }); + + assert.isFalse(queue.ready); + // If the queue was ready, this should be called immediately. + assert.isTrue(fake.called); + + // Default delay between entries: a macroqueue task (i.e., setTimeout(0)) + await timedPromise(0); + + assert.isTrue(queue.ready); + assert.isTrue(fake.called); + + // During the actual closure call, the queue is still awaiting async completion of the closure. + // Default wait: a macroqueue task + assert.isFalse(fake.firstCall.args[0]); + }); + + it('empty queue, default configuration, Promise-returning closure', async () => { + const queue = new AsyncClosureDispatchQueue(); + + const lock = new ManagedPromise(); + const fake = sinon.fake(); + queue.runAsync(() => { + fake(queue.ready); + return lock.corePromise; + }); + + assert.isFalse(queue.ready); + // If the queue was ready, this should be called immediately. + assert.isTrue(fake.called); + assert.isFalse(fake.firstCall.args[0]); + + await timedPromise(50); + + assert.isFalse(queue.ready); + + lock.resolve(); + + // Allow the newly-resolved Promise to chain. + // ("White-box" info here, but once is enough.) + await Promise.resolve(); + + assert.isTrue(queue.ready); + assert.isTrue(fake.called); + }); + + it('complex case 1 - all queued at the same time', async () => { + // Uses the default timeout between events; just making it extra-explicit here. + const queue = new AsyncClosureDispatchQueue(() => { return timedPromise(0) }); + + const buildSet = (n: number) => { + let set: ClosureSpy[] = []; + + // Deliberately using the same one multiple times - the class gives us a call count. + let closure = sinon.spy(() => {}); + for(let i=0; i < n; i++) { + set.push(closure); + } + + return set; + } + + const set0 = buildSet(3); + const lock0 = new ManagedPromise(); + const set1 = buildSet(7); + const lock1 = new ManagedPromise(); + const set2 = buildSet(5); + + const fakeTimers = sinon.useFakeTimers(); + + set0.forEach((entry) => queue.runAsync(entry)); + queue.runAsync(() => { + return lock0.corePromise; + }); + set1.forEach((entry) => queue.runAsync(entry)); + queue.runAsync(() => { + return lock1.corePromise; + }) + set2.forEach((entry) => queue.runAsync(entry)); + + assert.isFalse(queue.ready); + + try { + // Run set0; it'll stop before set1 due to lock0 not being resolved. + await fakeTimers.tickAsync(50); + + assert.equal(set0[0].callCount, 3); + assert.equal(set1[0].callCount, 0); + assert.equal(set2[0].callCount, 0); + + // Now we run set1; it'll stop before set2 due to lock1 not being resolved. + lock0.resolve(); + await fakeTimers.tickAsync(50); + + assert.equal(set0[0].callCount, 3); + assert.equal(set1[0].callCount, 7); + assert.equal(set2[0].callCount, 0); + + // Now we run set2, flushing out the queue. + lock1.resolve(); + await fakeTimers.tickAsync(50); + + assert.equal(set0[0].callCount, 3); + assert.equal(set1[0].callCount, 7); + assert.equal(set2[0].callCount, 5); + + assert.isTrue(queue.ready); + } finally { + fakeTimers.restore(); + } + }); + + it('complex case 2 - inverted unlock order', async () => { + // Uses the default timeout between events; just making it extra-explicit here. + const queue = new AsyncClosureDispatchQueue(() => { return timedPromise(0) }); + + const buildSet = (n: number) => { + let set: ClosureSpy[] = []; + + // Deliberately using the same one multiple times - the class gives us a call count. + let closure = sinon.spy(() => {}); + for(let i=0; i < n; i++) { + set.push(closure); + } + + return set; + } + + const fakeTimers = sinon.useFakeTimers(); + + const set0 = buildSet(3); + const lock0 = new ManagedPromise(); + const set1 = buildSet(7); + const lock1 = new ManagedPromise(); + const set2 = buildSet(5); + + set0.forEach((entry) => queue.runAsync(entry)); + queue.runAsync(() => { + return lock0.corePromise; + }); + set1.forEach((entry) => queue.runAsync(entry)); + queue.runAsync(() => { + return lock1.corePromise; + }) + set2.forEach((entry) => queue.runAsync(entry)); + + assert.isFalse(queue.ready); + + try { + // Run set0; it'll stop before set1 due to lock0 not being resolved. + await fakeTimers.tickAsync(50); + + assert.equal(set0[0].callCount, 3); + assert.equal(set1[0].callCount, 0); + assert.equal(set2[0].callCount, 0); + + // Now we resolve lock1 - but this isn't what is currently blocking the queue. + // No new tasks should run. + lock1.resolve(); /* NOTE: is being unlocked before lock0, which is earlier! */ + await fakeTimers.tickAsync(50); + + assert.equal(set0[0].callCount, 3); + assert.equal(set1[0].callCount, 0); + assert.equal(set2[0].callCount, 0); + + // Now we resolve lock0, allowing both set1 and set2 to complete. + lock0.resolve(); + await fakeTimers.tickAsync(50); + + assert.equal(set0[0].callCount, 3); + assert.equal(set1[0].callCount, 7); + assert.equal(set2[0].callCount, 5); + + assert.isTrue(queue.ready); + } finally { + fakeTimers.restore(); + } + }); +}); \ No newline at end of file From fd30786ac52f96d2b2d5c87ffdce4e7dfcb23b0d Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Tue, 27 Feb 2024 13:16:33 +0700 Subject: [PATCH 015/170] feat(web): an extra unit test --- .../asyncClosureDispatchQueue.spec.ts | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/common/web/gesture-recognizer/src/test/auto/headless/asyncClosureDispatchQueue.spec.ts b/common/web/gesture-recognizer/src/test/auto/headless/asyncClosureDispatchQueue.spec.ts index 860f5134ae5..1e1a864a08c 100644 --- a/common/web/gesture-recognizer/src/test/auto/headless/asyncClosureDispatchQueue.spec.ts +++ b/common/web/gesture-recognizer/src/test/auto/headless/asyncClosureDispatchQueue.spec.ts @@ -63,6 +63,44 @@ describe('AsyncClosureDispatchQueue', () => { assert.isTrue(fake.called); }); + it('non-empty queue, default configuration, simple closure', async () => { + const queue = new AsyncClosureDispatchQueue(); + + const fakeTimers = sinon.useFakeTimers(); + + const lock = new ManagedPromise(); + queue.runAsync(() => lock.corePromise); + + const fake = sinon.fake(); + queue.runAsync(() => { + fake(queue.ready); + }); + + try { + assert.isFalse(queue.ready); + assert.isFalse(fake.called); + + // Doesn't matter how long we wait; there's still a pending entry in front of `fake`. + await fakeTimers.tickAsync(50); + + assert.isFalse(queue.ready); + assert.isFalse(fake.called); + + // Allow that pending entry to resolve; `fake` should be able to resolve afterward with little issue. + lock.resolve(); + await fakeTimers.tickAsync(50); + + assert.isTrue(queue.ready); + assert.isTrue(fake.called); + + // During the actual closure call, the queue is still awaiting async completion of the closure. + // Default wait: a macroqueue task + assert.isFalse(fake.firstCall.args[0]); + } finally { + fakeTimers.restore(); + } + }); + it('complex case 1 - all queued at the same time', async () => { // Uses the default timeout between events; just making it extra-explicit here. const queue = new AsyncClosureDispatchQueue(() => { return timedPromise(0) }); From b09a1ba176ef24447664fcbdea49d837d0d59d8d Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Tue, 27 Feb 2024 13:31:08 +0700 Subject: [PATCH 016/170] change(web): concurrency lock-management ordering --- .../engine/headless/gestures/matchers/matcherSelector.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/common/web/gesture-recognizer/src/engine/headless/gestures/matchers/matcherSelector.ts b/common/web/gesture-recognizer/src/engine/headless/gestures/matchers/matcherSelector.ts index c94f25fb190..c8a2cefa1a1 100644 --- a/common/web/gesture-recognizer/src/engine/headless/gestures/matchers/matcherSelector.ts +++ b/common/web/gesture-recognizer/src/engine/headless/gestures/matchers/matcherSelector.ts @@ -246,11 +246,10 @@ export class MatcherSelector extends EventEmitter extends EventEmitter Date: Thu, 29 Feb 2024 12:59:17 +0700 Subject: [PATCH 017/170] chore(web): minor extra cleanup --- .../src/test/auto/browser/cases/canary.js | 2 +- .../auto/headless/asyncClosureDispatchQueue.spec.ts | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/common/web/gesture-recognizer/src/test/auto/browser/cases/canary.js b/common/web/gesture-recognizer/src/test/auto/browser/cases/canary.js index ff9a83fa66e..2c7f885c528 100644 --- a/common/web/gesture-recognizer/src/test/auto/browser/cases/canary.js +++ b/common/web/gesture-recognizer/src/test/auto/browser/cases/canary.js @@ -72,7 +72,7 @@ describe("'Canary' checks", function() { let fireEvent = () => { playbackEngine.replayTouchSamples(/*relative coord:*/ [ { sample: {targetX: 10, targetY: 10}, identifier: 1}], /*state:*/ "start", - /*recentTouches:*/ [/* TODO */], + /*recentTouches:*/ [], ); } diff --git a/common/web/gesture-recognizer/src/test/auto/headless/asyncClosureDispatchQueue.spec.ts b/common/web/gesture-recognizer/src/test/auto/headless/asyncClosureDispatchQueue.spec.ts index 1e1a864a08c..6b684968aa1 100644 --- a/common/web/gesture-recognizer/src/test/auto/headless/asyncClosureDispatchQueue.spec.ts +++ b/common/web/gesture-recognizer/src/test/auto/headless/asyncClosureDispatchQueue.spec.ts @@ -6,12 +6,12 @@ import { ManagedPromise, timedPromise } from '@keymanapp/web-utils'; type ClosureSpy = SinonSpy<[], ReturnType>; describe('AsyncClosureDispatchQueue', () => { - it('initial, state', () => { + it('has expected initial state', () => { const queue = new AsyncClosureDispatchQueue(); assert.isTrue(queue.ready); }); - it('empty queue, default configuration, simple closure', async () => { + it('proper handling of simple closure when queue is empty (default config)', async () => { const queue = new AsyncClosureDispatchQueue(); const fake = sinon.fake(); @@ -34,7 +34,7 @@ describe('AsyncClosureDispatchQueue', () => { assert.isFalse(fake.firstCall.args[0]); }); - it('empty queue, default configuration, Promise-returning closure', async () => { + it('proper handling of Promise-returning closure when queue is empty (default config)', async () => { const queue = new AsyncClosureDispatchQueue(); const lock = new ManagedPromise(); @@ -63,7 +63,7 @@ describe('AsyncClosureDispatchQueue', () => { assert.isTrue(fake.called); }); - it('non-empty queue, default configuration, simple closure', async () => { + it('proper handling of simple closure when queue is not empty (default config)', async () => { const queue = new AsyncClosureDispatchQueue(); const fakeTimers = sinon.useFakeTimers(); @@ -101,7 +101,7 @@ describe('AsyncClosureDispatchQueue', () => { } }); - it('complex case 1 - all queued at the same time', async () => { + it('complex case 1 - many tasks, all queued at the same time', async () => { // Uses the default timeout between events; just making it extra-explicit here. const queue = new AsyncClosureDispatchQueue(() => { return timedPromise(0) }); @@ -167,7 +167,7 @@ describe('AsyncClosureDispatchQueue', () => { } }); - it('complex case 2 - inverted unlock order', async () => { + it('complex case 2 - queued closure promises "unlocking" out of order', async () => { // Uses the default timeout between events; just making it extra-explicit here. const queue = new AsyncClosureDispatchQueue(() => { return timedPromise(0) }); From d74bc8f926422f5303c900055515025e8c58c928 Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Thu, 29 Feb 2024 13:03:12 +0700 Subject: [PATCH 018/170] docs(web): doc-comment typo --- .../src/engine/headless/touchpointCoordinator.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/web/gesture-recognizer/src/engine/headless/touchpointCoordinator.ts b/common/web/gesture-recognizer/src/engine/headless/touchpointCoordinator.ts index 6335cef6168..157da4b99b4 100644 --- a/common/web/gesture-recognizer/src/engine/headless/touchpointCoordinator.ts +++ b/common/web/gesture-recognizer/src/engine/headless/touchpointCoordinator.ts @@ -238,7 +238,7 @@ export class TouchpointCoordinator extends Even /* If an `InputEventEngine` internally utilizes the `AsyncClosureDispatchQueue`, this is the point at which we are now safe to process further events. The correct 'stateToken' has been identified - and all GestureMatcher possibilities for the source have been launched; path updates to resume _and_ + and all GestureMatcher possibilities for the source have been launched; path updates may resume _and_ new incoming paths may now be safely handled. As such, we can now fulfill any Promise returned by a closure defined within its `inputStart` method for the `GestureSource` under consideration. From 1c2baf2448532901896c2c36b71e857b9667de84 Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Fri, 1 Mar 2024 08:39:54 +0700 Subject: [PATCH 019/170] chore(web): common key-preview cancellation func --- web/src/engine/osk/src/visualKeyboard.ts | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/web/src/engine/osk/src/visualKeyboard.ts b/web/src/engine/osk/src/visualKeyboard.ts index e7b4b98c985..64de7bc2fea 100644 --- a/web/src/engine/osk/src/visualKeyboard.ts +++ b/web/src/engine/osk/src/visualKeyboard.ts @@ -484,6 +484,7 @@ export default class VisualKeyboard extends EventEmitter implements Ke this.highlightKey(oldKey, false); this.gesturePreviewHost?.cancel(); this.gesturePreviewHost = null; + trackingEntry.previewHost = null; const previewHost = this.highlightKey(key, true); if(previewHost) { @@ -531,6 +532,13 @@ export default class VisualKeyboard extends EventEmitter implements Ke return sourceTrackingMap[id]?.previewHost; }).find((obj) => !!obj); + const clearPreviewHost = () => { + if(existingPreviewHost) { + existingPreviewHost.cancel(); + this.gesturePreviewHost = null; + } + } + let handlers: GestureHandler[] = gestureHandlerMap.get(gestureSequence); if(!handlers && existingPreviewHost && !gestureStage.matchedId.includes('flick')) { existingPreviewHost.clearFlick(); @@ -625,7 +633,7 @@ export default class VisualKeyboard extends EventEmitter implements Ke if(gestureStage.matchedId == 'special-key-start') { if(gestureKey.key.spec.baseKeyID == 'K_BKSP') { // There shouldn't be a preview host for special keys... but it doesn't hurt to add the check. - existingPreviewHost?.cancel(); + clearPreviewHost(); // Possible enhancement: maybe update the held location for the backspace if there's movement? // But... that seems pretty low-priority. @@ -640,7 +648,7 @@ export default class VisualKeyboard extends EventEmitter implements Ke clearActiveGestures(coordSource.identifier); } } else if(gestureStage.matchedId.indexOf('longpress') > -1) { - existingPreviewHost?.cancel(); + clearPreviewHost(); // Matches: 'longpress', 'longpress-reset'. // Likewise. @@ -659,8 +667,7 @@ export default class VisualKeyboard extends EventEmitter implements Ke trackingEntry.previewHost = null; gestureSequence.on('complete', () => { - existingPreviewHost?.cancel(); - this.gesturePreviewHost = null; + clearPreviewHost(); }) // Past that, mere construction of the class for delegation is enough. @@ -676,8 +683,7 @@ export default class VisualKeyboard extends EventEmitter implements Ke )]; } else if(gestureStage.matchedId.includes('modipress') && gestureStage.matchedId.includes('-start')) { // There shouldn't be a preview host for modipress keys... but it doesn't hurt to add the check. - existingPreviewHost?.cancel(); - this.gesturePreviewHost = null; + clearPreviewHost(); if(this.layerLocked) { console.warn("Unexpected state: modipress start attempt during an active modipress"); @@ -697,8 +703,7 @@ export default class VisualKeyboard extends EventEmitter implements Ke } } else { // Probably an initial-tap or a simple-tap. - existingPreviewHost?.cancel(); - this.gesturePreviewHost = null; + clearPreviewHost(); } if(handlers) { From 3755de74ac2cb04e7d70cf3b41bf746009f19373 Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Mon, 4 Mar 2024 09:32:04 +0700 Subject: [PATCH 020/170] fix(web): pending-source tracking cleanup --- .../src/engine/touchEventEngine.ts | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/common/web/gesture-recognizer/src/engine/touchEventEngine.ts b/common/web/gesture-recognizer/src/engine/touchEventEngine.ts index accefd08b90..0c6aea31297 100644 --- a/common/web/gesture-recognizer/src/engine/touchEventEngine.ts +++ b/common/web/gesture-recognizer/src/engine/touchEventEngine.ts @@ -127,8 +127,7 @@ export class TouchEventEngine extends InputEv this.eventDispatcher.runAsync(() => { // Ensure the same timestamp is used for all touches being updated. const timestamp = performance.now(); - let lastValidTouchpoint: GestureSource = null; - let lastValidTouchId: number; + let touchpoint: GestureSource = null; const uniqueObject = {}; // During a touch-start, only _new_ touch contact points are listed here; @@ -150,25 +149,27 @@ export class TouchEventEngine extends InputEv continue; } - lastValidTouchpoint = this.onInputStart(touchId, sample, event.target, true); - lastValidTouchId = touchId; - } + touchpoint = this.onInputStart(touchId, sample, event.target, true); - if(lastValidTouchpoint) { // Ensure we only do the cleanup if and when it hasn't already been replaced by new events later. + // + // Must be done for EACH source - we can't risk leaving a lingering entry once we've dismissed + // processing for the source. const cleanup = () => { - if(this.pendingSourceIdentifiers.get(lastValidTouchId) == uniqueObject) { - this.pendingSourceIdentifiers.delete(lastValidTouchId); + if(this.pendingSourceIdentifiers.get(touchId) == uniqueObject) { + this.pendingSourceIdentifiers.delete(touchId); } } - lastValidTouchpoint.path.on('complete', cleanup); - lastValidTouchpoint.path.on('invalidated', cleanup); + touchpoint.path.on('complete', cleanup); + touchpoint.path.on('invalidated', cleanup); + } + if(touchpoint) { // This 'lock' should only be released when the last simultaneously-registered touch is published via // gesture-recognizer event. let eventSignalPromise = new ManagedPromise(); - this.inputStartSignalMap.set(lastValidTouchpoint, eventSignalPromise); + this.inputStartSignalMap.set(touchpoint, eventSignalPromise); return eventSignalPromise.corePromise; } @@ -201,10 +202,15 @@ export class TouchEventEngine extends InputEv for(let i=0; i < event.touches.length; i++) { const touch = event.touches.item(i); + // Requires that the `pendingSourceIdentifiers` map is properly maintained; + // any lingering entries from a completed source could prevent this guard + // from blocking further processing. if(!this.hasActiveTouchpoint(touch.identifier)) { continue; } + // This method expects that processing for the corresponding source is + // NOT completed. const config = this.getConfigForId(touch.identifier); const sample = this.buildSampleFromTouch(touch, timestamp); From 0046eef72cd9bdfc61f1fec5178758096e104550 Mon Sep 17 00:00:00 2001 From: jahorton Date: Tue, 5 Mar 2024 08:58:02 +0700 Subject: [PATCH 021/170] fix(ios): bad initial in-app layout --- .../Keyboard/InputViewController.swift | 23 ++++--------------- .../Keyboard/KeymanWebViewController.swift | 8 +++++++ 2 files changed, 12 insertions(+), 19 deletions(-) diff --git a/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/InputViewController.swift b/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/InputViewController.swift index 20f912c96eb..bbe8cb8d50b 100644 --- a/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/InputViewController.swift +++ b/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/InputViewController.swift @@ -73,7 +73,6 @@ private class CustomInputView: UIInputView, UIInputViewAudioFeedback { func setConstraints() { let innerView = keymanWeb.view! - let guide = self.safeAreaLayoutGuide // Fallback on earlier versions @@ -89,11 +88,13 @@ private class CustomInputView: UIInputView, UIInputViewAudioFeedback { kbdWidthConstraint.priority = .defaultHigh kbdWidthConstraint.isActive = true + let bannerHeight = InputViewController.topBarHeight + // Cannot be met by the in-app keyboard, but helps to 'force' height for the system keyboard. - let portraitHeight = innerView.heightAnchor.constraint(equalToConstant: keymanWeb.constraintTargetHeight(isPortrait: true)) + let portraitHeight = innerView.heightAnchor.constraint(equalToConstant: bannerHeight + keymanWeb.constraintTargetHeight(isPortrait: true)) portraitHeight.identifier = "Height constraint for portrait mode" portraitHeight.priority = .defaultHigh - let landscapeHeight = innerView.heightAnchor.constraint(equalToConstant: keymanWeb.constraintTargetHeight(isPortrait: false)) + let landscapeHeight = innerView.heightAnchor.constraint(equalToConstant: bannerHeight + keymanWeb.constraintTargetHeight(isPortrait: false)) landscapeHeight.identifier = "Height constraint for landscape mode" landscapeHeight.priority = .defaultHigh @@ -105,22 +106,6 @@ private class CustomInputView: UIInputView, UIInputViewAudioFeedback { override func updateConstraints() { super.updateConstraints() - // Keep the constraints up-to-date! They should vary based upon the selected keyboard. - let userData = Storage.active.userDefaults - let alwaysShow = userData.bool(forKey: Key.optShouldShowBanner) - - var hideBanner = true - if alwaysShow || Manager.shared.isSystemKeyboard || keymanWeb.activeModel { - hideBanner = false - } - let topBarDelta = hideBanner ? 0 : InputViewController.topBarHeight - - // Sets height before the constraints, as it's the height constraint that triggers OSK resizing. - keymanWeb.setBannerHeight(to: Int(InputViewController.topBarHeight)) - - portraitConstraint?.constant = topBarDelta + keymanWeb.constraintTargetHeight(isPortrait: true) - landscapeConstraint?.constant = topBarDelta + keymanWeb.constraintTargetHeight(isPortrait: false) - // Activate / deactivate layout-specific constraints. if InputViewController.isPortrait { landscapeConstraint?.isActive = false diff --git a/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/KeymanWebViewController.swift b/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/KeymanWebViewController.swift index 9ed3643d819..26d72e9065f 100644 --- a/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/KeymanWebViewController.swift +++ b/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/KeymanWebViewController.swift @@ -131,6 +131,14 @@ class KeymanWebViewController: UIViewController { webView!.navigationDelegate = self webView!.scrollView.isScrollEnabled = false + // Disable WKWebView default layout-constraint manipulations. We ensure + // safe-area boundaries are respected via InputView / InputViewController + // contraints. + // + // Fixes #10859. + // Ref: https://stackoverflow.com/a/63741514 + webView!.scrollView.contentInsetAdjustmentBehavior = .never + view = webView // Set UILongPressGestureRecognizer to show sub keys From c6a287307b3969cefa1b1151ba7904f32ebf479e Mon Sep 17 00:00:00 2001 From: jahorton Date: Tue, 5 Mar 2024 09:21:00 +0700 Subject: [PATCH 022/170] chore(ios): typo in comment --- .../KeymanEngine/Classes/Keyboard/KeymanWebViewController.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/KeymanWebViewController.swift b/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/KeymanWebViewController.swift index 26d72e9065f..7df21e96035 100644 --- a/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/KeymanWebViewController.swift +++ b/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/KeymanWebViewController.swift @@ -133,7 +133,7 @@ class KeymanWebViewController: UIViewController { // Disable WKWebView default layout-constraint manipulations. We ensure // safe-area boundaries are respected via InputView / InputViewController - // contraints. + // constraints. // // Fixes #10859. // Ref: https://stackoverflow.com/a/63741514 From a4cdc9203caaebf4dc6f8f8bbac68cea86d3e14c Mon Sep 17 00:00:00 2001 From: jahorton Date: Tue, 5 Mar 2024 09:35:51 +0700 Subject: [PATCH 023/170] fix(ios): delayed banner --- .../Classes/Keyboard/KeymanWebViewController.swift | 4 ++-- .../resources/Keyman.bundle/Contents/Resources/ios-host.js | 6 ++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/KeymanWebViewController.swift b/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/KeymanWebViewController.swift index 7df21e96035..4350b14936c 100644 --- a/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/KeymanWebViewController.swift +++ b/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/KeymanWebViewController.swift @@ -423,8 +423,6 @@ extension KeymanWebViewController { } else { // We're registering a model in the background - don't change settings. webView!.evaluateJavaScript("keyman.addModel(\(stubString));", completionHandler: nil) } - - setBannerHeight(to: Int(InputViewController.topBarHeight)) } func showBanner(_ display: Bool) { @@ -744,6 +742,8 @@ extension KeymanWebViewController: KeymanWebDelegate { // Reset the keyboard's size. keyboardSize = kbSize + setBannerHeight(to: Int(InputViewController.topBarHeight)) + fixLayout() // Will trigger Manager's `keyboardLoaded` method. diff --git a/ios/engine/KMEI/KeymanEngine/resources/Keyman.bundle/Contents/Resources/ios-host.js b/ios/engine/KMEI/KeymanEngine/resources/Keyman.bundle/Contents/Resources/ios-host.js index 621e643bb94..82e20d9edb1 100644 --- a/ios/engine/KMEI/KeymanEngine/resources/Keyman.bundle/Contents/Resources/ios-host.js +++ b/ios/engine/KMEI/KeymanEngine/resources/Keyman.bundle/Contents/Resources/ios-host.js @@ -76,7 +76,9 @@ function showBanner(flag) { console.log("Setting banner display for dictionaryless keyboards to " + flag); var bc = keyman.osk.bannerController; - bc.inactiveBanner = flag ? new bc.ImageBanner(bannerImgPath) : null; + if(bannerImgPath) { + bc.inactiveBanner = new bc.ImageBanner(bannerImgPath); + } } function setBannerImage(path) { @@ -88,7 +90,7 @@ function setBannerImage(path) { } // If an inactive banner is set, update its image. - bc.inactiveBanner = bc.inactiveBanner ? new bc.ImageBanner(bannerImgPath) : null; + bc.inactiveBanner = new bc.ImageBanner(bannerImgPath); } function setBannerHeight(h) { From 78fb91400ab742a56b264420191c35ccc62f8f96 Mon Sep 17 00:00:00 2001 From: jahorton Date: Tue, 5 Mar 2024 09:51:14 +0700 Subject: [PATCH 024/170] chore(ios): removes the "Show Banner" settings option --- .../KMEI/KeymanEngine/Classes/Constants.swift | 1 + .../Keyboard/InputViewController.swift | 18 +-------- .../Classes/Keyboard/KeyboardMenuView.swift | 1 - .../Keyboard/KeymanWebViewController.swift | 20 ++-------- .../Classes/SettingsViewController.swift | 39 ------------------- 5 files changed, 6 insertions(+), 73 deletions(-) diff --git a/ios/engine/KMEI/KeymanEngine/Classes/Constants.swift b/ios/engine/KMEI/KeymanEngine/Classes/Constants.swift index c2831845957..3b4ed5a1d62 100644 --- a/ios/engine/KMEI/KeymanEngine/Classes/Constants.swift +++ b/ios/engine/KMEI/KeymanEngine/Classes/Constants.swift @@ -61,6 +61,7 @@ public enum Key { // Settings-related keys static let optShouldReportErrors = "ShouldReportErrors" + // Deprecated - no longer used static let optShouldShowBanner = "ShouldShowBanner" static let optSpacebarText = "SpacebarText" // This one SHOULD be app-only, but is needed by the currently diff --git a/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/InputViewController.swift b/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/InputViewController.swift index bbe8cb8d50b..8e539bfe84b 100644 --- a/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/InputViewController.swift +++ b/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/InputViewController.swift @@ -427,10 +427,6 @@ open class InputViewController: UIInputViewController, KeymanWebDelegate { case .doNothing: break } - - // If we allow the system keyboard to show no banners, this line is needed - // for variable system keyboard height. - updateShowBannerSetting() } else { // Use in-app keyboard behavior instead. if !(Manager.shared.currentResponder?.showKeyboardPicker() ?? false) { _ = Manager.shared.switchToNextKeyboard @@ -438,11 +434,6 @@ open class InputViewController: UIInputViewController, KeymanWebDelegate { } } - // Needed due to protection level on the `keymanWeb` property - func updateShowBannerSetting() { - keymanWeb.updateShowBannerSetting() - } - func updateSpacebarText() { keymanWeb.updateSpacebarText() } @@ -469,14 +460,7 @@ open class InputViewController: UIInputViewController, KeymanWebDelegate { } public var isTopBarActive: Bool { - let userData = Storage.active.userDefaults - let alwaysShow = userData.bool(forKey: Key.optShouldShowBanner) - - if alwaysShow || Manager.shared.isSystemKeyboard || keymanWeb.activeModel { - return true - } - - return false + return true } public var activeTopBarHeight: CGFloat { diff --git a/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/KeyboardMenuView.swift b/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/KeyboardMenuView.swift index 9ae5434174a..397697222cc 100644 --- a/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/KeyboardMenuView.swift +++ b/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/KeyboardMenuView.swift @@ -339,7 +339,6 @@ class KeyboardMenuView: UIView, UITableViewDelegate, UITableViewDataSource, UIGe // keyboard loaded does not provide suggestions. Parking it here as it's not // worth pursuing further at this stage. inputViewController?.updateViewConstraints() - inputViewController?.updateShowBannerSetting() return } } diff --git a/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/KeymanWebViewController.swift b/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/KeymanWebViewController.swift index 4350b14936c..f269d4fa099 100644 --- a/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/KeymanWebViewController.swift +++ b/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/KeymanWebViewController.swift @@ -425,11 +425,11 @@ extension KeymanWebViewController { } } - func showBanner(_ display: Bool) { - let message = "Changing banner's alwaysShow property to \(display)" + func setUpBanner() { + let message = "Changing banner's alwaysShow property to true" os_log("%{public}s", log: KeymanEngineLogger.settings, type: .debug, message) SentryManager.breadcrumb(message, category: "engine", sentryLevel: .debug) - webView?.evaluateJavaScript("showBanner(\(display ? "true" : "false"))", completionHandler: nil) + webView?.evaluateJavaScript("showBanner(true)", completionHandler: nil) } func setBannerImage(to path: String) { @@ -737,7 +737,7 @@ extension KeymanWebViewController: KeymanWebDelegate { } updateSpacebarText() - updateShowBannerSetting() + setUpBanner() setBannerImage(to: bannerImgPath) // Reset the keyboard's size. keyboardSize = kbSize @@ -757,16 +757,6 @@ extension KeymanWebViewController: KeymanWebDelegate { } } - func updateShowBannerSetting() { - let userData = Storage.active.userDefaults - let alwaysShow = userData.bool(forKey: Key.optShouldShowBanner) - if !Manager.shared.isSystemKeyboard { - showBanner(false) - } else { - showBanner(alwaysShow) - } - } - func insertText(_ view: KeymanWebViewController, numCharsToDelete: Int, newText: String) { dismissHelpBubble() Manager.shared.isKeymanHelpOn = false @@ -1128,8 +1118,6 @@ extension KeymanWebViewController { isLoading = true updateSpacebarText() - // Check for a change of "always show banner" state - updateShowBannerSetting() } /* diff --git a/ios/engine/KMEI/KeymanEngine/Classes/SettingsViewController.swift b/ios/engine/KMEI/KeymanEngine/Classes/SettingsViewController.swift index ad65e491b4a..543453bb0c1 100644 --- a/ios/engine/KMEI/KeymanEngine/Classes/SettingsViewController.swift +++ b/ios/engine/KMEI/KeymanEngine/Classes/SettingsViewController.swift @@ -61,12 +61,6 @@ open class SettingsViewController: UITableViewController { "reuseid" : "languages" ]) - itemsArray.append([ - "title": NSLocalizedString("menu-settings-show-banner", bundle: engineBundle, comment: ""), - "subtitle": "", - "reuseid" : "showbanner" - ]) - itemsArray.append([ "title": NSLocalizedString("menu-settings-startup-get-started", bundle: engineBundle, comment: ""), "subtitle": "", @@ -151,21 +145,6 @@ open class SettingsViewController: UITableViewController { switch(cellIdentifier) { case "languages": break - case "showbanner": - let showBannerSwitch = UISwitch() - showBannerSwitch.translatesAutoresizingMaskIntoConstraints = false - - let switchFrame = frameAtRightOfCell(cell: cell.frame, controlSize: showBannerSwitch.frame.size) - showBannerSwitch.frame = switchFrame - - showBannerSwitch.isOn = showBanner - showBannerSwitch.addTarget(self, action: #selector(self.bannerSwitchValueChanged), - for: .valueChanged) - cell.addSubview(showBannerSwitch) - cell.contentView.isUserInteractionEnabled = false - - showBannerSwitch.rightAnchor.constraint(equalTo: cell.layoutMarginsGuide.rightAnchor).isActive = true - showBannerSwitch.centerYAnchor.constraint(equalTo: cell.layoutMarginsGuide.centerYAnchor).isActive = true case "showgetstarted": let showAgainSwitch = UISwitch() showAgainSwitch.translatesAutoresizingMaskIntoConstraints = false @@ -216,19 +195,6 @@ open class SettingsViewController: UITableViewController { } } - @objc func bannerSwitchValueChanged(_ sender: Any) { - let userData = Storage.active.userDefaults - if let toggle = sender as? UISwitch { - // actually this should call into KMW, which controls the banner - userData.set(toggle.isOn, forKey: Key.optShouldShowBanner) - userData.synchronize() - } - - // Necessary for the keyboard to visually update to match - // the new setting. - Manager.shared.inputViewController.setShouldReload() - } - @objc func showGetStartedSwitchValueChanged(_ sender: Any) { let userData = Storage.active.userDefaults if let toggle = sender as? UISwitch { @@ -236,11 +202,6 @@ open class SettingsViewController: UITableViewController { userData.synchronize() } } - - private var showBanner: Bool { - let userData = Storage.active.userDefaults - return userData.bool(forKey: Key.optShouldShowBanner) - } private var showGetStarted: Bool { let userData = Storage.active.userDefaults From 8f1feeaecf952e1d32aae879b59fae0f11aeeb9e Mon Sep 17 00:00:00 2001 From: jahorton Date: Wed, 6 Mar 2024 08:29:22 +0700 Subject: [PATCH 025/170] chore(ios): drops isTopBarActive, activeTopBarHeight --- .../Classes/Keyboard/InputViewController.swift | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/InputViewController.swift b/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/InputViewController.swift index 8e539bfe84b..172385c6fcf 100644 --- a/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/InputViewController.swift +++ b/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/InputViewController.swift @@ -153,7 +153,7 @@ open class InputViewController: UIInputViewController, KeymanWebDelegate { } var expandedHeight: CGFloat { - return keymanWeb.keyboardHeight + activeTopBarHeight + return keymanWeb.keyboardHeight + InputViewController.topBarHeight } public convenience init() { @@ -459,15 +459,6 @@ open class InputViewController: UIInputViewController, KeymanWebDelegate { baseWidthConstraint.isActive = true } - public var isTopBarActive: Bool { - return true - } - - public var activeTopBarHeight: CGFloat { - // If 'isSystemKeyboard' is true, always show the top bar. - return isTopBarActive ? CGFloat(InputViewController.topBarHeight) : 0 - } - public var kmwHeight: CGFloat { return keymanWeb.keyboardHeight } From a8a49d886586843f647bcb4226ba5bf2f1c8621a Mon Sep 17 00:00:00 2001 From: jahorton Date: Wed, 6 Mar 2024 08:36:18 +0700 Subject: [PATCH 026/170] chore(ios): integrates showBanner within init-followup --- .../Classes/Keyboard/KeymanWebViewController.swift | 8 -------- .../Keyman.bundle/Contents/Resources/ios-host.js | 14 +++++--------- 2 files changed, 5 insertions(+), 17 deletions(-) diff --git a/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/KeymanWebViewController.swift b/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/KeymanWebViewController.swift index f269d4fa099..1bf54832f08 100644 --- a/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/KeymanWebViewController.swift +++ b/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/KeymanWebViewController.swift @@ -425,13 +425,6 @@ extension KeymanWebViewController { } } - func setUpBanner() { - let message = "Changing banner's alwaysShow property to true" - os_log("%{public}s", log: KeymanEngineLogger.settings, type: .debug, message) - SentryManager.breadcrumb(message, category: "engine", sentryLevel: .debug) - webView?.evaluateJavaScript("showBanner(true)", completionHandler: nil) - } - func setBannerImage(to path: String) { bannerImgPath = path // Save the path in case delayed initializaiton is needed. var logString: String @@ -737,7 +730,6 @@ extension KeymanWebViewController: KeymanWebDelegate { } updateSpacebarText() - setUpBanner() setBannerImage(to: bannerImgPath) // Reset the keyboard's size. keyboardSize = kbSize diff --git a/ios/engine/KMEI/KeymanEngine/resources/Keyman.bundle/Contents/Resources/ios-host.js b/ios/engine/KMEI/KeymanEngine/resources/Keyman.bundle/Contents/Resources/ios-host.js index 82e20d9edb1..e681fb6332d 100644 --- a/ios/engine/KMEI/KeymanEngine/resources/Keyman.bundle/Contents/Resources/ios-host.js +++ b/ios/engine/KMEI/KeymanEngine/resources/Keyman.bundle/Contents/Resources/ios-host.js @@ -60,6 +60,11 @@ function init() { kmw.osk.bannerView.activeBannerHeight = bannerHeight; keyman.refreshOskLayout(); } + + var bc = keyman.osk.bannerController; + if(bannerImgPath) { + bc.inactiveBanner = new bc.ImageBanner(bannerImgPath); + } }); } @@ -72,15 +77,6 @@ function verifyLoaded() { } } -function showBanner(flag) { - console.log("Setting banner display for dictionaryless keyboards to " + flag); - - var bc = keyman.osk.bannerController; - if(bannerImgPath) { - bc.inactiveBanner = new bc.ImageBanner(bannerImgPath); - } -} - function setBannerImage(path) { bannerImgPath = path; From b548c884580db1d160a037047e6ba176a7e3e6c3 Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Thu, 7 Mar 2024 14:08:13 +0700 Subject: [PATCH 027/170] fix(web): proper linkage of sources to events --- .../headless/asyncClosureDispatchQueue.ts | 4 + .../src/engine/inputEventEngine.ts | 52 +++--- .../src/engine/mouseEventEngine.ts | 59 ++++--- .../src/engine/touchEventEngine.ts | 158 ++++++++++++++---- .../browser/cases/recordedCoordSequences.js | 8 +- 5 files changed, 180 insertions(+), 101 deletions(-) diff --git a/common/web/gesture-recognizer/src/engine/headless/asyncClosureDispatchQueue.ts b/common/web/gesture-recognizer/src/engine/headless/asyncClosureDispatchQueue.ts index a6246296d3e..bc912975348 100644 --- a/common/web/gesture-recognizer/src/engine/headless/asyncClosureDispatchQueue.ts +++ b/common/web/gesture-recognizer/src/engine/headless/asyncClosureDispatchQueue.ts @@ -28,6 +28,10 @@ export class AsyncClosureDispatchQueue { this.defaultWaitFactory = defaultWaitFactory || (() => { return timedPromise(0) }); } + get defaultWait() { + return this.defaultWaitFactory(); + } + get ready() { return this.queue.length == 0 && !this.waitLock; } diff --git a/common/web/gesture-recognizer/src/engine/inputEventEngine.ts b/common/web/gesture-recognizer/src/engine/inputEventEngine.ts index 64eedef8a45..eb3c262ebb4 100644 --- a/common/web/gesture-recognizer/src/engine/inputEventEngine.ts +++ b/common/web/gesture-recognizer/src/engine/inputEventEngine.ts @@ -14,12 +14,12 @@ export function processSampleClientCoords(config: GestureRecog } as InputSample; } -export abstract class InputEventEngine extends InputEngineBase { +export abstract class InputEventEngine extends InputEngineBase { abstract registerEventHandlers(): void; abstract unregisterEventHandlers(): void; - protected buildSampleFor(clientX: number, clientY: number, target: EventTarget, timestamp: number, source: GestureSource): InputSample { - const sample: InputSample = { + protected buildSampleFor(clientX: number, clientY: number, target: EventTarget, timestamp: number, source: GestureSource): InputSample { + const sample: InputSample = { ...processSampleClientCoords(this.config, clientX, clientY), t: timestamp, stateToken: source?.stateToken ?? this.stateToken @@ -32,7 +32,7 @@ export abstract class InputEventEngine extends Inpu return sample; } - protected onInputStart(identifier: number, sample: InputSample, target: EventTarget, isFromTouch: boolean) { + protected onInputStart(identifier: number, sample: InputSample, target: EventTarget, isFromTouch: boolean) { const touchpoint = this.createTouchpoint(identifier, isFromTouch); touchpoint.update(sample); @@ -57,46 +57,40 @@ export abstract class InputEventEngine extends Inpu return touchpoint; } - protected onInputMove(identifier: number, sample: InputSample, target: EventTarget) { - const activePoint = this.getTouchpointWithId(identifier); - if(!activePoint) { + protected onInputMove(touchpoint: GestureSource, sample: InputSample, target: EventTarget) { + if(!touchpoint) { return; } - activePoint.update(sample); + try { + touchpoint.update(sample); + } catch(err) { + reportError('Error occurred while updating source', err); + } } - protected onInputMoveCancel(identifier: number, sample: InputSample, target: EventTarget) { - const touchpoint = this.getTouchpointWithId(identifier); + protected onInputMoveCancel(touchpoint: GestureSource, sample: InputSample, target: EventTarget) { if(!touchpoint) { return; } - touchpoint.update(sample); - touchpoint.path.terminate(true); + try { + touchpoint.update(sample); + touchpoint.path.terminate(true); + } catch(err) { + reportError('Error occurred while cancelling further input for source', err); + } } - protected onInputEnd(identifier: number, target: EventTarget) { - const touchpoint = this.getTouchpointWithId(identifier); + protected onInputEnd(touchpoint: GestureSource, target: EventTarget) { if(!touchpoint) { return; } - const lastEntry = touchpoint.path.stats.lastSample; - const sample = this.buildSampleFor(lastEntry.clientX, lastEntry.clientY, target, lastEntry.t, touchpoint); - - /* While an 'end' event immediately follows a 'move' if it occurred simultaneously, - * this is decidedly _not_ the case if the touchpoint was held for a while without - * moving, even at the point of its release. - * - * We'll never need to worry about the touchpoint moving here, and thus we don't - * need to worry about `currentHoveredItem` changing. We're only concerned with - * recording the _timing_ of the touchpoint's release. - */ - if(sample.t != lastEntry.t) { - touchpoint.update(sample); + try { + touchpoint.path.terminate(false); + } catch(err) { + reportError('Error occurred while finalizing input for source', err); } - - this.getTouchpointWithId(identifier)?.path.terminate(false); } } \ No newline at end of file diff --git a/common/web/gesture-recognizer/src/engine/mouseEventEngine.ts b/common/web/gesture-recognizer/src/engine/mouseEventEngine.ts index d3a05a81793..a84f4294fe8 100644 --- a/common/web/gesture-recognizer/src/engine/mouseEventEngine.ts +++ b/common/web/gesture-recognizer/src/engine/mouseEventEngine.ts @@ -1,11 +1,11 @@ import { GestureRecognizerConfiguration } from "./configuration/gestureRecognizerConfiguration.js"; import { InputEventEngine } from "./inputEventEngine.js"; -import { InputSample } from "./headless/inputSample.js"; import { Nonoptional } from "./nonoptional.js"; import { ZoneBoundaryChecker } from "./configuration/zoneBoundaryChecker.js"; +import { GestureSource } from "./headless/gestureSource.js"; // Does NOT use the AsyncClosureDispatchQueue... simply because there can only ever be one mouse touchpoint. -export class MouseEventEngine extends InputEventEngine { +export class MouseEventEngine extends InputEventEngine { private readonly _mouseStart: typeof MouseEventEngine.prototype.onMouseStart; private readonly _mouseMove: typeof MouseEventEngine.prototype.onMouseMove; private readonly _mouseEnd: typeof MouseEventEngine.prototype.onMouseEnd; @@ -13,7 +13,10 @@ export class MouseEventEngine extends InputEv private hasActiveClick: boolean = false; private disabledSafeBounds: number = 0; - public constructor(config: Nonoptional>) { + private currentSource: GestureSource = null; + private readonly activeIdentifier = 0; + + public constructor(config: Nonoptional>) { super(config); // We use this approach, rather than .bind, because _this_ version allows hook @@ -26,20 +29,6 @@ export class MouseEventEngine extends InputEv private get eventRoot(): HTMLElement { return this.config.mouseEventRoot; } - private get activeIdentifier(): number { - return 0; - } - - // public static forPredictiveBanner(banner: SuggestionBanner, handlerRoot: SuggestionManager) { - // const config: GestureRecognizerConfiguration = { - // targetRoot: banner.getDiv(), - // // document.body is the event root b/c we need to track the mouse if it leaves - // // the VisualKeyboard's hierarchy. - // eventRoot: document.body, - // }; - - // return new MouseEventEngine(config); - // } registerEventHandlers() { this.eventRoot.addEventListener('mousedown', this._mouseStart, true); @@ -67,10 +56,9 @@ export class MouseEventEngine extends InputEv } } - private buildSampleFromEvent(event: MouseEvent, identifier: number) { + private buildSampleFromEvent(event: MouseEvent) { // WILL be null for newly-starting `GestureSource`s / contact points. - const source = this.getTouchpointWithId(identifier); - return this.buildSampleFor(event.clientX, event.clientY, event.target, performance.now(), source); + return this.buildSampleFor(event.clientX, event.clientY, event.target, performance.now(), this.currentSource); } onMouseStart(event: MouseEvent) { @@ -82,8 +70,7 @@ export class MouseEventEngine extends InputEv this.preventPropagation(event); - const identifier = this.activeIdentifier; - const sample = this.buildSampleFromEvent(event, identifier); + const sample = this.buildSampleFromEvent(event); if(!ZoneBoundaryChecker.inputStartOutOfBoundsCheck(sample, this.config)) { // If we started very close to a safe zone border, remember which one(s). @@ -91,36 +78,46 @@ export class MouseEventEngine extends InputEv this.disabledSafeBounds = ZoneBoundaryChecker.inputStartSafeBoundProximityCheck(sample, this.config); } - this.onInputStart(identifier, sample, event.target, false); + const touchpoint = this.onInputStart(this.activeIdentifier, sample, event.target, false); + this.currentSource = touchpoint; + + const cleanup = () => { + this.currentSource = null; + } + + touchpoint.path.on('complete', cleanup); + touchpoint.path.on('invalidated', cleanup); } onMouseMove(event: MouseEvent) { - if(!this.hasActiveTouchpoint(this.activeIdentifier)) { + const source = this.currentSource; + if(!source) { return; } - const sample = this.buildSampleFromEvent(event, this.activeIdentifier); + const sample = this.buildSampleFromEvent(event); if(!event.buttons) { if(this.hasActiveClick) { this.hasActiveClick = false; - this.onInputMoveCancel(this.activeIdentifier, sample, event.target); + this.onInputMoveCancel(source, sample, event.target); } return; } this.preventPropagation(event); - const config = this.getConfigForId(this.activeIdentifier); + const config = source.currentRecognizerConfig; if(!ZoneBoundaryChecker.inputMoveCancellationCheck(sample, config, this.disabledSafeBounds)) { - this.onInputMove(this.activeIdentifier, sample, event.target); + this.onInputMove(source, sample, event.target); } else { - this.onInputMoveCancel(this.activeIdentifier, sample, event.target); + this.onInputMoveCancel(source, sample, event.target); } } onMouseEnd(event: MouseEvent) { - if(!this.hasActiveTouchpoint(this.activeIdentifier)) { + const source = this.currentSource; + if(!source) { return; } @@ -128,6 +125,6 @@ export class MouseEventEngine extends InputEv this.hasActiveClick = false; } - this.onInputEnd(this.activeIdentifier, event.target); + this.onInputEnd(source, event.target); } } \ No newline at end of file diff --git a/common/web/gesture-recognizer/src/engine/touchEventEngine.ts b/common/web/gesture-recognizer/src/engine/touchEventEngine.ts index 0c6aea31297..baef76cebe9 100644 --- a/common/web/gesture-recognizer/src/engine/touchEventEngine.ts +++ b/common/web/gesture-recognizer/src/engine/touchEventEngine.ts @@ -1,6 +1,5 @@ import { GestureRecognizerConfiguration } from "./configuration/gestureRecognizerConfiguration.js"; import { InputEventEngine } from "./inputEventEngine.js"; -import { InputSample } from "./headless/inputSample.js"; import { Nonoptional } from "./nonoptional.js"; import { ZoneBoundaryChecker } from "./configuration/zoneBoundaryChecker.js"; import { GestureSource } from "./headless/gestureSource.js"; @@ -17,7 +16,7 @@ function touchListToArray(list: TouchList) { return arr; } -export class TouchEventEngine extends InputEventEngine { +export class TouchEventEngine extends InputEventEngine { private readonly _touchStart: typeof TouchEventEngine.prototype.onTouchStart; private readonly _touchMove: typeof TouchEventEngine.prototype.onTouchMove; private readonly _touchEnd: typeof TouchEventEngine.prototype.onTouchEnd; @@ -25,10 +24,11 @@ export class TouchEventEngine extends InputEv protected readonly eventDispatcher = new AsyncClosureDispatchQueue(); private safeBoundMaskMap: {[id: number]: number} = {}; - private pendingSourceIdentifiers: Map = new Map(); - private inputStartSignalMap: Map, ManagedPromise> = new Map(); + // This map works synchronously with the actual event handlers. + private pendingSourcePromises: Map>> = new Map(); + private inputStartSignalMap: Map, ManagedPromise> = new Map(); - public constructor(config: Nonoptional>) { + public constructor(config: Nonoptional>) { super(config); // We use this approach, rather than .bind, because _this_ version allows hook @@ -72,7 +72,7 @@ export class TouchEventEngine extends InputEv } } - public dropTouchpoint(source: GestureSource) { + public dropTouchpoint(source: GestureSource) { super.dropTouchpoint(source); for(const key of Object.keys(this.safeBoundMaskMap)) { @@ -82,7 +82,7 @@ export class TouchEventEngine extends InputEv } } - public fulfillInputStart(touchpoint: GestureSource>) { + public fulfillInputStart(touchpoint: GestureSource>) { const lock = this.inputStartSignalMap.get(touchpoint); if(lock) { this.inputStartSignalMap.delete(touchpoint); @@ -92,12 +92,11 @@ export class TouchEventEngine extends InputEv public hasActiveTouchpoint(identifier: number): boolean { const baseResult = super.hasActiveTouchpoint(identifier); - return baseResult || !!this.pendingSourceIdentifiers.has(identifier); + return baseResult || !!this.pendingSourcePromises.has(identifier); } - private buildSampleFromTouch(touch: Touch, timestamp: number) { + private buildSampleFromTouch(touch: Touch, timestamp: number, source: GestureSource) { // WILL be null for newly-starting `GestureSource`s / contact points. - const source = this.getTouchpointWithId(touch.identifier); return this.buildSampleFor(touch.clientX, touch.clientY, touch.target, timestamp, source); } @@ -124,11 +123,40 @@ export class TouchEventEngine extends InputEv ); }); + /* + We create Promises that can be set and retrieved synchronously with the actual event handlers + in order to prevent issues from tricky asynchronous identifier-to-source mapping attempts. + + As these Promises are set (and thus, retrievable) synchronously with the actual event handlers, + we can closure-capture them for use in the internally-asynchronous processing closures. + + `capturedSourcePromises` will be useful for closure-capture binding the new Promise(s) to + the closure to be queued. `this.pendingSourcePromises` facilitates similar closure-capture + patterns within the touchMove and touchEnd handlers for their queued closures. + */ + const capturedSourcePromises = new Map>>(); + for(let i=0; i < event.changedTouches.length; i++) { + const touch = event.changedTouches.item(i); + const promise = new ManagedPromise>(); + this.pendingSourcePromises.set(touch.identifier, promise); + capturedSourcePromises.set(touch.identifier, promise); + } + + /* + When multiple touchpoints are active, we need to ensure a specific order of events. + The easiest way to ensure the exact order involves programmatic delay of their + processing, essentially "sequentializing" the events into a deterministic order. + + It also helps to ensure that any path updates are only emitted when all listeners + for that path have been prepared - and other parts of the engine cause that to happen + asynchronously in certain situations. Within KMW, one such case is when a simple-tap + with `nextLayer` defined is auto-completed by a new incoming touch, triggering an + instant layer-change. + */ this.eventDispatcher.runAsync(() => { // Ensure the same timestamp is used for all touches being updated. const timestamp = performance.now(); - let touchpoint: GestureSource = null; - const uniqueObject = {}; + let touchpoint: GestureSource = null; // During a touch-start, only _new_ touch contact points are listed here; // we shouldn't signal "input start" for any previously-existing touch points, @@ -136,9 +164,7 @@ export class TouchEventEngine extends InputEv for(let i=0; i < event.changedTouches.length; i++) { const touch = event.changedTouches.item(i); const touchId = touch.identifier; - const sample = this.buildSampleFromTouch(touch, timestamp); - - this.pendingSourceIdentifiers.set(touchId, uniqueObject); + const sample = this.buildSampleFromTouch(touch, timestamp, null); if(!ZoneBoundaryChecker.inputStartOutOfBoundsCheck(sample, this.config)) { // If we started very close to a safe zone border, remember which one(s). @@ -146,18 +172,39 @@ export class TouchEventEngine extends InputEv this.safeBoundMaskMap[touchId] = ZoneBoundaryChecker.inputStartSafeBoundProximityCheck(sample, this.config); } else { // This touchpoint shouldn't be considered; do not signal a touchstart for it. + let sourcePromise = capturedSourcePromises.get(touchId); + sourcePromise.resolve(null); continue; } touchpoint = this.onInputStart(touchId, sample, event.target, true); - // Ensure we only do the cleanup if and when it hasn't already been replaced by new events later. - // - // Must be done for EACH source - we can't risk leaving a lingering entry once we've dismissed - // processing for the source. + /* + We use the closure-captured version bound to this specific closure, rather than the + most recent one for the touch-identifier - under heavy rapid typing, it's possible that + the touch-identifier has been reused. + + The resolved Promise may then be used to retrieve the correct source in the other event + handlers' closures. + */ + let sourcePromise = capturedSourcePromises.get(touchId); + sourcePromise.resolve(touchpoint); + + /* + Ensure we only do the cleanup if and when it hasn't already been replaced by new events later. + + Must be done for EACH source - we can't risk leaving a lingering entry once we've dismissed + processing for the source. Failure to do so may result in blocking touch events that should + no longer be manipulated by this engine by affecting `hasActiveTouchpoint`. + */ const cleanup = () => { - if(this.pendingSourceIdentifiers.get(touchId) == uniqueObject) { - this.pendingSourceIdentifiers.delete(touchId); + /* + If delays accumulate significantly, it is possible that when this queued closure is run, + a different touchpoint is reusing the same identifier. Don't delete the entry if our + entry has been replaced. + */ + if(this.pendingSourcePromises.get(touchId) == sourcePromise) { + this.pendingSourcePromises.delete(touchId); } } @@ -185,13 +232,34 @@ export class TouchEventEngine extends InputEv } } + /* + Using the Promise map built in touchStart, we can retrieve a Promise for the source linked + to this event and closure-capture it for the closure queued below. + */ + const capturedSourcePromises = new Map>>(); + for(let i = 0; i < event.touches.length; i++) { + const touchId = event.touches.item(i).identifier; + capturedSourcePromises.set(touchId, this.pendingSourcePromises.get(touchId).corePromise); + } + this.eventDispatcher.runAsync(() => { this.maintainTouchpointsWithIds(touchListToArray(event.touches) .map((touch) => touch.identifier) ); }); - this.eventDispatcher.runAsync(() => { + /* + When multiple touchpoints are active, we need to ensure a specific order of events. + The easiest way to ensure the exact order involves programmatic delay of their + processing, essentially "sequentializing" the events into a deterministic order. + + It also helps to ensure that any path updates are only emitted when all listeners + for that path have been prepared - and other parts of the engine cause that to happen + asynchronously in certain situations. Within KMW, one such case is when a simple-tap + with `nextLayer` defined is auto-completed by a new incoming touch, triggering an + instant layer-change. + */ + this.eventDispatcher.runAsync(async () => { // Ensure the same timestamp is used for all touches being updated. const timestamp = performance.now(); @@ -201,27 +269,30 @@ export class TouchEventEngine extends InputEv // May be worth doing changedTouches _first_ though. for(let i=0; i < event.touches.length; i++) { const touch = event.touches.item(i); + const touchId = touch.identifier; - // Requires that the `pendingSourceIdentifiers` map is properly maintained; - // any lingering entries from a completed source could prevent this guard - // from blocking further processing. - if(!this.hasActiveTouchpoint(touch.identifier)) { + const source = await capturedSourcePromises.get(touchId); + if(!source || source.isPathComplete) { continue; } - // This method expects that processing for the corresponding source is - // NOT completed. - const config = this.getConfigForId(touch.identifier); - const sample = this.buildSampleFromTouch(touch, timestamp); + const config = source.currentRecognizerConfig; + const sample = this.buildSampleFromTouch(touch, timestamp, source); - if(!ZoneBoundaryChecker.inputMoveCancellationCheck(sample, config, this.safeBoundMaskMap[touch.identifier])) { - this.onInputMove(touch.identifier, sample, touch.target); + if(!ZoneBoundaryChecker.inputMoveCancellationCheck(sample, config, this.safeBoundMaskMap[touchId])) { + this.onInputMove(source, sample, touch.target); } else { - this.onInputMoveCancel(touch.identifier, sample, touch.target); + this.onInputMoveCancel(source, sample, touch.target); } } - }) + /* + Since we're operating within an async function, a Promise return-type + is implied. That cancels out the default wait, but we want to ensure + that the default wait is applied here. + */ + return this.eventDispatcher.defaultWait; + }); } onTouchEnd(event: TouchEvent) { @@ -233,17 +304,30 @@ export class TouchEventEngine extends InputEv } } - this.eventDispatcher.runAsync(() => { + /* + Using the Promise map built in touchStart, we can retrieve a Promise for the source linked + to this event and closure-capture it for the closure queued below. + */ + const capturedSourcePromises = new Map>>(); + for(let i = 0; i < event.touches.length; i++) { + const touchId = event.touches.item(i).identifier; + capturedSourcePromises.set(touchId, this.pendingSourcePromises.get(touchId).corePromise); + } + + this.eventDispatcher.runAsync(async () => { // Only lists touch contact points that have been lifted; touchmove is raised separately if any movement occurred. for(let i=0; i < event.changedTouches.length; i++) { const touch = event.changedTouches.item(i); - if(!this.hasActiveTouchpoint(touch.identifier)) { + const source = await capturedSourcePromises.get(touch.identifier); + if(!source || source.isPathComplete) { continue; } - this.onInputEnd(touch.identifier, event.target); + this.onInputEnd(source, event.target); } + + return this.eventDispatcher.defaultWait; }); } } \ No newline at end of file diff --git a/common/web/gesture-recognizer/src/test/auto/browser/cases/recordedCoordSequences.js b/common/web/gesture-recognizer/src/test/auto/browser/cases/recordedCoordSequences.js index 28d865a8ef3..b1d35549c24 100644 --- a/common/web/gesture-recognizer/src/test/auto/browser/cases/recordedCoordSequences.js +++ b/common/web/gesture-recognizer/src/test/auto/browser/cases/recordedCoordSequences.js @@ -91,12 +91,12 @@ describe("Layer one - DOM -> InputSequence", function() { const sampleResult = resultContactPath[j]; const sampleOriginal = originalContactPath[j]; - assert.isOk(sampleResult, `An expected sample was missing during simulation - failed at path entry ${j}`); - assert.isOk(sampleOriginal, `An extra sample was generated during simulation - failed at path entry ${j}`); + assert.isOk(sampleResult, `An expected sample was missing during simulation - failed at path entry ${j}, path ${i}`); + assert.isOk(sampleOriginal, `An extra sample was generated during simulation - failed at path entry ${j}, path ${i}`); // During test runs against a real Android device, we tend to get almost, but not-quite, integer targetX and targetY values. - expect(sampleResult.targetX).to.be.closeTo(sampleOriginal.targetX, 1e-4, `Mismatch in x-coord at path entry ${j}`); - expect(sampleResult.targetY).to.be.closeTo(sampleOriginal.targetY, 1e-4, `Mismatch in y-coord at path entry ${j}`); + expect(sampleResult.targetX).to.be.closeTo(sampleOriginal.targetX, 1e-4, `Mismatch in x-coord at path entry ${j}, path ${i}`); + expect(sampleResult.targetY).to.be.closeTo(sampleOriginal.targetY, 1e-4, `Mismatch in y-coord at path entry ${j}, path ${i}`); } } From 727a3cf14435f5b3569bc704c635606059625673 Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Thu, 7 Mar 2024 15:21:58 +0700 Subject: [PATCH 028/170] fix(web): robustness handling with new source-track style --- .../src/engine/headless/inputEngineBase.ts | 6 ++--- .../src/engine/touchEventEngine.ts | 26 ++++++++++++------- .../src/test/auto/browser/cases/canary.js | 4 ++- .../src/headlessInputEngine.ts | 2 +- 4 files changed, 24 insertions(+), 14 deletions(-) diff --git a/common/web/gesture-recognizer/src/engine/headless/inputEngineBase.ts b/common/web/gesture-recognizer/src/engine/headless/inputEngineBase.ts index db442541a29..683bee0b6b5 100644 --- a/common/web/gesture-recognizer/src/engine/headless/inputEngineBase.ts +++ b/common/web/gesture-recognizer/src/engine/headless/inputEngineBase.ts @@ -63,10 +63,10 @@ export abstract class InputEngineBase extends * Designed to facilitate recovery from error cases and peculiar states that sometimes arise when debugging. * @param identifiers */ - maintainTouchpointsWithIds(identifiers: number[]) { - const identifiersToMaintain = identifiers.map((internal_id) => this.identifierMap[internal_id]); + maintainTouchpoints(touchpoints: GestureSource[]) { + touchpoints ||= []; this._activeTouchpoints - .filter((source) => !identifiersToMaintain.includes(source.rawIdentifier)) + .filter((source) => !touchpoints.includes(source)) // Will trigger `.dropTouchpoint` later in the event chain. .forEach((source) => source.terminate(true)); } diff --git a/common/web/gesture-recognizer/src/engine/touchEventEngine.ts b/common/web/gesture-recognizer/src/engine/touchEventEngine.ts index baef76cebe9..5d737ae5766 100644 --- a/common/web/gesture-recognizer/src/engine/touchEventEngine.ts +++ b/common/web/gesture-recognizer/src/engine/touchEventEngine.ts @@ -114,13 +114,20 @@ export class TouchEventEngine extends InputEventEngi // during a touchstart.) const allTouches = touchListToArray(event.touches); const newTouches = touchListToArray(event.changedTouches); + const oldTouches = allTouches.filter((touch1) => { + return newTouches.findIndex(touch2 => touch1.identifier == touch2.identifier) == -1; + }); - this.eventDispatcher.runAsync(() => { + // Any 'old touches' should have pre-existing entries in our promise-map that are still current, as + // the promise-map is maintained 100% synchronously with incoming events. + const oldSourcePromises = oldTouches.map((touch) => this.pendingSourcePromises.get(touch.identifier)); + + this.eventDispatcher.runAsync(async () => { + const oldSources = await Promise.all(oldSourcePromises); // Maintain all touches in the `.touches` array that are NOT marked as `.changedTouches` (and therefore, new) - this.maintainTouchpointsWithIds(allTouches - .filter((touch1) => newTouches.findIndex(touch2 => touch1.identifier == touch2.identifier) == -1) - .map((touch) => touch.identifier) - ); + this.maintainTouchpoints(oldSources); + + return this.eventDispatcher.defaultWait; }); /* @@ -242,10 +249,11 @@ export class TouchEventEngine extends InputEventEngi capturedSourcePromises.set(touchId, this.pendingSourcePromises.get(touchId).corePromise); } - this.eventDispatcher.runAsync(() => { - this.maintainTouchpointsWithIds(touchListToArray(event.touches) - .map((touch) => touch.identifier) - ); + this.eventDispatcher.runAsync(async () => { + const touches = await Promise.all(capturedSourcePromises.values()); + this.maintainTouchpoints(touches); + + return this.eventDispatcher.defaultWait; }); /* diff --git a/common/web/gesture-recognizer/src/test/auto/browser/cases/canary.js b/common/web/gesture-recognizer/src/test/auto/browser/cases/canary.js index 2c7f885c528..746fc409cf3 100644 --- a/common/web/gesture-recognizer/src/test/auto/browser/cases/canary.js +++ b/common/web/gesture-recognizer/src/test/auto/browser/cases/canary.js @@ -83,7 +83,9 @@ describe("'Canary' checks", function() { await new Promise((resolve) => { window.setTimeout(resolve, 0); - }); + }).then(() => new Promise((resolve) => { + window.setTimeout(resolve, 0); + })); assert.isTrue(fakeHandler.called, "Unit test attempt failed: handler was not called successfully."); }); diff --git a/common/web/gesture-recognizer/src/tools/unit-test-resources/src/headlessInputEngine.ts b/common/web/gesture-recognizer/src/tools/unit-test-resources/src/headlessInputEngine.ts index 7252436929b..85c65c90970 100644 --- a/common/web/gesture-recognizer/src/tools/unit-test-resources/src/headlessInputEngine.ts +++ b/common/web/gesture-recognizer/src/tools/unit-test-resources/src/headlessInputEngine.ts @@ -85,7 +85,7 @@ export class HeadlessInputEngine extends InputEngineBase { const playbackTerminations = recordedObj.inputs.map((recording, index) => this.playbackTerminations(sources[index], recording)); playbackStarts.forEach((promise) => promise.then(() => { - this.maintainTouchpointsWithIds(playbackStartTuples.map((tuple) => tuple.internal_id)); + this.maintainTouchpoints(playbackStartTuples.map((tuple) => tuple.source)); }) ); From 26a99f07ac5376c8194d50a7ec70921c7c5ddb8e Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Fri, 8 Mar 2024 08:33:35 +0700 Subject: [PATCH 029/170] fix(web): unit test patchup - had incorrect touchend .touches values --- .../tools/unit-test-resources/src/inputSequenceSimulator.ts | 3 ++- web/src/tools/testing/recorder/browserDriver.ts | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/common/web/gesture-recognizer/src/tools/unit-test-resources/src/inputSequenceSimulator.ts b/common/web/gesture-recognizer/src/tools/unit-test-resources/src/inputSequenceSimulator.ts index dfc65bfaafb..d698ad6b5dd 100644 --- a/common/web/gesture-recognizer/src/tools/unit-test-resources/src/inputSequenceSimulator.ts +++ b/common/web/gesture-recognizer/src/tools/unit-test-resources/src/inputSequenceSimulator.ts @@ -111,7 +111,8 @@ export class InputSequenceSimulator { let touchEventDict: TouchEventInit = { bubbles: true, - touches: changedTouches.concat(otherTouches), + // Ending touchpoints should NOT show up in `touches`. + touches: state == 'end' ? otherTouches : changedTouches.concat(otherTouches), changedTouches: changedTouches, } diff --git a/web/src/tools/testing/recorder/browserDriver.ts b/web/src/tools/testing/recorder/browserDriver.ts index 8467681fde1..e13d17a7827 100644 --- a/web/src/tools/testing/recorder/browserDriver.ts +++ b/web/src/tools/testing/recorder/browserDriver.ts @@ -100,8 +100,10 @@ export class BrowserDriver { downEvent = new Event(BrowserDriver.oskDownTouchType); upEvent = new Event(BrowserDriver.oskUpTouchType); downEvent['touches'] = asTouchList([{"target": oskKeyElement, ...center}]); - upEvent['touches'] = asTouchList([{"target": oskKeyElement, ...center}]); + // The touch should NOT show up in event.touches when a touch ends. + upEvent['touches'] = asTouchList([]); downEvent['changedTouches'] = asTouchList([{"target": oskKeyElement, ...center}]); + // It should still show up in .changedTouches, though. upEvent['changedTouches'] = asTouchList([{"target": oskKeyElement, ...center}]); } else { downEvent = new Event(BrowserDriver.oskDownMouseType); From b6df9ac510626a1b63c76f3c182184c7753fa1a6 Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Fri, 8 Mar 2024 08:34:11 +0700 Subject: [PATCH 030/170] fix(web): touchend handler should use .changedTouches, not .touches --- .../web/gesture-recognizer/src/engine/touchEventEngine.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/common/web/gesture-recognizer/src/engine/touchEventEngine.ts b/common/web/gesture-recognizer/src/engine/touchEventEngine.ts index 5d737ae5766..29ff2137cb3 100644 --- a/common/web/gesture-recognizer/src/engine/touchEventEngine.ts +++ b/common/web/gesture-recognizer/src/engine/touchEventEngine.ts @@ -317,9 +317,11 @@ export class TouchEventEngine extends InputEventEngi to this event and closure-capture it for the closure queued below. */ const capturedSourcePromises = new Map>>(); - for(let i = 0; i < event.touches.length; i++) { - const touchId = event.touches.item(i).identifier; - capturedSourcePromises.set(touchId, this.pendingSourcePromises.get(touchId).corePromise); + // Any ending touches don't show up in event.touches - only in event.changedTouches! + for(let i = 0; i < event.changedTouches.length; i++) { + const touchId = event.changedTouches.item(i).identifier; + const promiseToCapture = this.pendingSourcePromises.get(touchId).corePromise; + capturedSourcePromises.set(touchId, promiseToCapture); } this.eventDispatcher.runAsync(async () => { From 9b2417d238fd15a6a2e694f8e9b65b54e173f91f Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Fri, 8 Mar 2024 23:01:43 +0700 Subject: [PATCH 031/170] fix(web): null guards for source Promise closure-capture source when already cleared --- .../src/engine/touchEventEngine.ts | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/common/web/gesture-recognizer/src/engine/touchEventEngine.ts b/common/web/gesture-recognizer/src/engine/touchEventEngine.ts index 29ff2137cb3..611e205b5e4 100644 --- a/common/web/gesture-recognizer/src/engine/touchEventEngine.ts +++ b/common/web/gesture-recognizer/src/engine/touchEventEngine.ts @@ -246,7 +246,9 @@ export class TouchEventEngine extends InputEventEngi const capturedSourcePromises = new Map>>(); for(let i = 0; i < event.touches.length; i++) { const touchId = event.touches.item(i).identifier; - capturedSourcePromises.set(touchId, this.pendingSourcePromises.get(touchId).corePromise); + // If the source's gesture is finalized or cancelled but touch events are ongoing, + // with no delay between event and its processing, the map entry here will be cleared. + capturedSourcePromises.set(touchId, this.pendingSourcePromises.get(touchId)?.corePromise); } this.eventDispatcher.runAsync(async () => { @@ -279,6 +281,12 @@ export class TouchEventEngine extends InputEventEngi const touch = event.touches.item(i); const touchId = touch.identifier; + + // Only lists touch contact points that have been lifted; touchmove is + // raised separately if any movement occurred. + // + // If the promise object could not be assigned, we `await undefined` - + // which JS converts to `await Promise.resolve(undefined)`. It's safe. const source = await capturedSourcePromises.get(touchId); if(!source || source.isPathComplete) { continue; @@ -320,12 +328,18 @@ export class TouchEventEngine extends InputEventEngi // Any ending touches don't show up in event.touches - only in event.changedTouches! for(let i = 0; i < event.changedTouches.length; i++) { const touchId = event.changedTouches.item(i).identifier; - const promiseToCapture = this.pendingSourcePromises.get(touchId).corePromise; + // If the source's gesture is finalized or cancelled but touch events are ongoing, + // with no delay between event and its processing, the map entry here will be cleared. + const promiseToCapture = this.pendingSourcePromises.get(touchId)?.corePromise; capturedSourcePromises.set(touchId, promiseToCapture); } this.eventDispatcher.runAsync(async () => { - // Only lists touch contact points that have been lifted; touchmove is raised separately if any movement occurred. + // Only lists touch contact points that have been lifted; touchmove is + // raised separately if any movement occurred. + // + // If the promise object could not be assigned, we `await undefined` - + // which JS converts to `await Promise.resolve(undefined)`. It's safe. for(let i=0; i < event.changedTouches.length; i++) { const touch = event.changedTouches.item(i); From 7a9b47b394f0c4d0229e871ff7478fd5ee78035b Mon Sep 17 00:00:00 2001 From: "Dr Mark C. Sinclair" Date: Thu, 14 Mar 2024 13:35:05 +0000 Subject: [PATCH 032/170] chore(developer): add preinit test for keyboard info compiler --- .../src/keyboard-info-compiler.ts | 3 +- .../test/test-keyboard-info-compiler.ts | 32 ++++++++++++++++++- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/developer/src/kmc-keyboard-info/src/keyboard-info-compiler.ts b/developer/src/kmc-keyboard-info/src/keyboard-info-compiler.ts index f374d208442..c26b5f68391 100644 --- a/developer/src/kmc-keyboard-info/src/keyboard-info-compiler.ts +++ b/developer/src/kmc-keyboard-info/src/keyboard-info-compiler.ts @@ -16,7 +16,7 @@ import { getFontFamily } from "./font-family.js"; const regionNames = new Intl.DisplayNames(['en'], { type: "region" }); const scriptNames = new Intl.DisplayNames(['en'], { type: "script" }); -const langtagsByTag = {}; +export const langtagsByTag = {}; /** * Build a dictionary of language tags from langtags.json @@ -608,4 +608,3 @@ export class KeyboardInfoCompiler implements KeymanCompiler { } } - diff --git a/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts b/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts index ab47b39871b..ead1dec6c60 100644 --- a/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts +++ b/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts @@ -3,7 +3,8 @@ import { assert } from 'chai'; import 'mocha'; import { TestCompilerCallbacks } from '@keymanapp/developer-test-helpers'; import { makePathToFixture } from './helpers/index.js'; -import { KeyboardInfoCompiler, KeyboardInfoCompilerResult } from '../src/keyboard-info-compiler.js'; +import { KeyboardInfoCompiler, KeyboardInfoCompilerResult, langtagsByTag } from '../src/keyboard-info-compiler.js'; +import langtags from "../src/imports/langtags.js"; const callbacks = new TestCompilerCallbacks(); @@ -11,6 +12,26 @@ beforeEach(function() { callbacks.clear(); }); +const ENLANGTAG = { + "full": "en-Latn-US", + "iana": [ "English" ], + "iso639_3": "eng", + "localname": "American English", + "localnames": [ "English" ], + "name": "English", + "names": [ "Anglais", "Angleščina", "Anglisy", "Angličtina", "Anglų", "Angol", "Angļu", "Engels", "Engelsk", "Engelska", "Engelski", "Englaisa", "Englanti", "Englesch", "Engleză", "Englisch", "Ingilizce", "Inglese", "Ingliż", "Inglés", "Inglês", "Język angielski", "Kiingereza", "anglais" ], + "region": "US", + "regionname": "United States", + "regions": [ "AD", "AF", "AR", "AS", "AW", "BD", "BG", "BH", "BL", "BN", "BQ", "BT", "BY", "CL", "CN", "CO", "CR", "CW", "CY", "CZ", "DO", "EC", "EE", "ES", "ET", "FM", "FR", "GQ", "GR", "GW", "HN", "HR", "HU", "ID", "IS", "IT", "JP", "KH", "KR", "KW", "LB", "LK", "LT", "LU", "LV", "LY", "MC", "ME", "MF", "MX", "NO", "NP", "OM", "PA", "PL", "PM", "PR", "PT", "RO", "RS", "RU", "SA", "SK", "SO", "SR", "ST", "SV", "TC", "TH", "TN", "TW", "UA", "UM", "UY", "VE", "VG", "VI" ], + "script": "Latn", + "sldr": true, + "suppress": true, + "tag": "en", + "tags": [ "en-Latn", "en-US" ], + "variants": [ "basiceng", "boont", "cornu", "emodeng", "oxendict", "scotland", "scouse", "spanglis", "unifon" ], + "windows": "en-US" +}; + describe('keyboard-info-compiler', function () { it('compile a .keyboard_info file correctly', async function() { const kpjFilename = makePathToFixture('khmer_angkor', 'khmer_angkor.kpj'); @@ -50,4 +71,13 @@ describe('keyboard-info-compiler', function () { assert.deepEqual(actual, expected); }); + + it('check preinit creates langtagsByTag correctly', async function() { + const enLangTag = langtags.find(({ tag }) => tag === 'en'); + assert.deepEqual(enLangTag, ENLANGTAG); + assert.deepEqual((langtagsByTag)['en'], ENLANGTAG); + assert.deepEqual((langtagsByTag)['en-Latn-US'], ENLANGTAG); + assert.deepEqual((langtagsByTag)['en-Latn'], ENLANGTAG); + assert.deepEqual((langtagsByTag)['en-US'], ENLANGTAG); + }); }); From c1b7f0053d762a3b5cd8e0e3c710992290994224 Mon Sep 17 00:00:00 2001 From: "Dr Mark C. Sinclair" Date: Thu, 14 Mar 2024 13:50:00 +0000 Subject: [PATCH 033/170] chore(developer): use unitTestEndpoints to export langtagsByTag --- .../kmc-keyboard-info/src/keyboard-info-compiler.ts | 9 ++++++++- .../test/test-keyboard-info-compiler.ts | 10 +++++----- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/developer/src/kmc-keyboard-info/src/keyboard-info-compiler.ts b/developer/src/kmc-keyboard-info/src/keyboard-info-compiler.ts index c26b5f68391..aa0f84666ac 100644 --- a/developer/src/kmc-keyboard-info/src/keyboard-info-compiler.ts +++ b/developer/src/kmc-keyboard-info/src/keyboard-info-compiler.ts @@ -16,7 +16,7 @@ import { getFontFamily } from "./font-family.js"; const regionNames = new Intl.DisplayNames(['en'], { type: "region" }); const scriptNames = new Intl.DisplayNames(['en'], { type: "script" }); -export const langtagsByTag = {}; +const langtagsByTag = {}; /** * Build a dictionary of language tags from langtags.json @@ -608,3 +608,10 @@ export class KeyboardInfoCompiler implements KeymanCompiler { } } + +/** + * these are exported only for unit tests, do not use + */ +export const unitTestEndpoints = { + langtagsByTag, +}; \ No newline at end of file diff --git a/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts b/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts index ead1dec6c60..4b2d1dee7a7 100644 --- a/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts +++ b/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts @@ -3,7 +3,7 @@ import { assert } from 'chai'; import 'mocha'; import { TestCompilerCallbacks } from '@keymanapp/developer-test-helpers'; import { makePathToFixture } from './helpers/index.js'; -import { KeyboardInfoCompiler, KeyboardInfoCompilerResult, langtagsByTag } from '../src/keyboard-info-compiler.js'; +import { KeyboardInfoCompiler, KeyboardInfoCompilerResult, unitTestEndpoints } from '../src/keyboard-info-compiler.js'; import langtags from "../src/imports/langtags.js"; const callbacks = new TestCompilerCallbacks(); @@ -75,9 +75,9 @@ describe('keyboard-info-compiler', function () { it('check preinit creates langtagsByTag correctly', async function() { const enLangTag = langtags.find(({ tag }) => tag === 'en'); assert.deepEqual(enLangTag, ENLANGTAG); - assert.deepEqual((langtagsByTag)['en'], ENLANGTAG); - assert.deepEqual((langtagsByTag)['en-Latn-US'], ENLANGTAG); - assert.deepEqual((langtagsByTag)['en-Latn'], ENLANGTAG); - assert.deepEqual((langtagsByTag)['en-US'], ENLANGTAG); + assert.deepEqual((unitTestEndpoints.langtagsByTag)['en'], ENLANGTAG); + assert.deepEqual((unitTestEndpoints.langtagsByTag)['en-Latn-US'], ENLANGTAG); + assert.deepEqual((unitTestEndpoints.langtagsByTag)['en-Latn'], ENLANGTAG); + assert.deepEqual((unitTestEndpoints.langtagsByTag)['en-US'], ENLANGTAG); }); }); From badc47dbd47bdc4d38523b3f0856b6c242eb5def Mon Sep 17 00:00:00 2001 From: "Dr Mark C. Sinclair" Date: Thu, 14 Mar 2024 13:58:13 +0000 Subject: [PATCH 034/170] chore(developer): ensure preinit is called indirectly (maintaining test independence) --- .../kmc-keyboard-info/test/test-keyboard-info-compiler.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts b/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts index 4b2d1dee7a7..0d461d8d40c 100644 --- a/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts +++ b/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts @@ -73,8 +73,9 @@ describe('keyboard-info-compiler', function () { }); it('check preinit creates langtagsByTag correctly', async function() { - const enLangTag = langtags.find(({ tag }) => tag === 'en'); - assert.deepEqual(enLangTag, ENLANGTAG); + const compiler = new KeyboardInfoCompiler(); // indirectly call preinit() + assert.isNotNull(compiler); + assert.deepEqual(langtags.find(({ tag }) => tag === 'en'), ENLANGTAG); assert.deepEqual((unitTestEndpoints.langtagsByTag)['en'], ENLANGTAG); assert.deepEqual((unitTestEndpoints.langtagsByTag)['en-Latn-US'], ENLANGTAG); assert.deepEqual((unitTestEndpoints.langtagsByTag)['en-Latn'], ENLANGTAG); From ca084c1e00508036f695e5a24339cf2a07c43cc4 Mon Sep 17 00:00:00 2001 From: "Dr Mark C. Sinclair" Date: Mon, 18 Mar 2024 12:35:52 +0000 Subject: [PATCH 035/170] chore(developer): add KeyboardInfoCompiler init test --- .../test/test-keyboard-info-compiler.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts b/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts index 0d461d8d40c..ea268c6b05e 100644 --- a/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts +++ b/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts @@ -81,4 +81,23 @@ describe('keyboard-info-compiler', function () { assert.deepEqual((unitTestEndpoints.langtagsByTag)['en-Latn'], ENLANGTAG); assert.deepEqual((unitTestEndpoints.langtagsByTag)['en-US'], ENLANGTAG); }); + + it('check init initialises KeyboardInfoCompiler correctly', async function() { + const jsFilename = makePathToFixture('khmer_angkor', 'build', 'khmer_angkor.js'); + const kpsFilename = makePathToFixture('khmer_angkor', 'source', 'khmer_angkor.kps'); + const kmpFilename = makePathToFixture('khmer_angkor', 'build', 'khmer_angkor.kmp'); + + const sources = { + kmpFilename, + sourcePath: 'release/k/khmer_angkor', + kpsFilename, + jsFilename: jsFilename, + forPublishing: true, + }; + + const compiler = new KeyboardInfoCompiler(); + assert.isTrue(await compiler.init(callbacks, {sources})); + assert.deepEqual(compiler['callbacks'], callbacks); + assert.deepEqual(compiler['options'], {sources}); + }); }); From daa6384da5487243cad07e98ed21a1151c5920dd Mon Sep 17 00:00:00 2001 From: "Dr Mark C. Sinclair" Date: Mon, 18 Mar 2024 15:48:21 +0000 Subject: [PATCH 036/170] chore(developer): add KeyboardInfoCompiler returns null if KmpCompiler.init fails test --- .../test/test-keyboard-info-compiler.ts | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts b/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts index ea268c6b05e..203775884fc 100644 --- a/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts +++ b/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts @@ -5,6 +5,8 @@ import { TestCompilerCallbacks } from '@keymanapp/developer-test-helpers'; import { makePathToFixture } from './helpers/index.js'; import { KeyboardInfoCompiler, KeyboardInfoCompilerResult, unitTestEndpoints } from '../src/keyboard-info-compiler.js'; import langtags from "../src/imports/langtags.js"; +import { KmpCompiler, KmpCompilerOptions } from '@keymanapp/kmc-package'; +import { CompilerCallbacks } from '@keymanapp/common-types'; const callbacks = new TestCompilerCallbacks(); @@ -100,4 +102,27 @@ describe('keyboard-info-compiler', function () { assert.deepEqual(compiler['callbacks'], callbacks); assert.deepEqual(compiler['options'], {sources}); }); + + it('check run returns null if KmpCompiler.init fails', async function() { + const kpjFilename = makePathToFixture('khmer_angkor', 'khmer_angkor.kpj'); + const jsFilename = makePathToFixture('khmer_angkor', 'build', 'khmer_angkor.js'); + const kpsFilename = makePathToFixture('khmer_angkor', 'source', 'khmer_angkor.kps'); + const kmpFilename = makePathToFixture('khmer_angkor', 'build', 'khmer_angkor.kmp'); + + const sources = { + kmpFilename, + sourcePath: 'release/k/khmer_angkor', + kpsFilename, + jsFilename: jsFilename, + forPublishing: true, + }; + + const compiler = new KeyboardInfoCompiler(); + assert.isTrue(await compiler.init(callbacks, {sources})); + const origKmpCompilerInit = KmpCompiler.prototype.init; + KmpCompiler.prototype.init = (callbacks: CompilerCallbacks, options: KmpCompilerOptions) => { return null; } + const result = await compiler.run(kpjFilename, null); + KmpCompiler.prototype.init = origKmpCompilerInit; + assert.isNull(result); + }); }); From 79cd02169c4f8b1c8ec1915315b3ef4dfd4bde3e Mon Sep 17 00:00:00 2001 From: "Dr Mark C. Sinclair" Date: Mon, 18 Mar 2024 16:05:38 +0000 Subject: [PATCH 037/170] chore(developer): add KeyboardInfoCompiler returns null if KmpCompiler.transformKpsToKmpObject fails test --- .../test/test-keyboard-info-compiler.ts | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts b/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts index 203775884fc..3fdc6e1bd7e 100644 --- a/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts +++ b/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts @@ -124,5 +124,28 @@ describe('keyboard-info-compiler', function () { const result = await compiler.run(kpjFilename, null); KmpCompiler.prototype.init = origKmpCompilerInit; assert.isNull(result); - }); + }); + + it('check run returns null if KmpCompiler.transformKpsToKmpObject fails', async function() { + const kpjFilename = makePathToFixture('khmer_angkor', 'khmer_angkor.kpj'); + const jsFilename = makePathToFixture('khmer_angkor', 'build', 'khmer_angkor.js'); + const kpsFilename = makePathToFixture('khmer_angkor', 'source', 'khmer_angkor.kps'); + const kmpFilename = makePathToFixture('khmer_angkor', 'build', 'khmer_angkor.kmp'); + + const sources = { + kmpFilename, + sourcePath: 'release/k/khmer_angkor', + kpsFilename, + jsFilename: jsFilename, + forPublishing: true, + }; + + const compiler = new KeyboardInfoCompiler(); + assert.isTrue(await compiler.init(callbacks, {sources})); + const origKmpCompilerTransformKpsToKmpObject = KmpCompiler.prototype.transformKpsToKmpObject; + KmpCompiler.prototype.transformKpsToKmpObject = (kpsFilename: string) => { return null; } + const result = await compiler.run(kpjFilename, null); + KmpCompiler.prototype.transformKpsToKmpObject = origKmpCompilerTransformKpsToKmpObject; + assert.isNull(result); + }); }); From 8a43aa56eff19b60c86de79673a6263934c2c134 Mon Sep 17 00:00:00 2001 From: "Dr Mark C. Sinclair" Date: Mon, 18 Mar 2024 16:14:01 +0000 Subject: [PATCH 038/170] chore(developer): add KeyboardInfoCompiler returns null if loadJsFile fails test --- .../test/test-keyboard-info-compiler.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts b/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts index 3fdc6e1bd7e..22839dc3fce 100644 --- a/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts +++ b/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts @@ -147,5 +147,26 @@ describe('keyboard-info-compiler', function () { const result = await compiler.run(kpjFilename, null); KmpCompiler.prototype.transformKpsToKmpObject = origKmpCompilerTransformKpsToKmpObject; assert.isNull(result); + }); + + it('check run returns null if loadJsFile fails', async function() { + const kpjFilename = makePathToFixture('khmer_angkor', 'khmer_angkor.kpj'); + const jsFilename = makePathToFixture('khmer_angkor', 'build', 'khmer_angkor.js'); + const kpsFilename = makePathToFixture('khmer_angkor', 'source', 'khmer_angkor.kps'); + const kmpFilename = makePathToFixture('khmer_angkor', 'build', 'khmer_angkor.kmp'); + + const sources = { + kmpFilename, + sourcePath: 'release/k/khmer_angkor', + kpsFilename, + jsFilename: jsFilename, + forPublishing: true, + }; + + const compiler = new KeyboardInfoCompiler(); + assert.isTrue(await compiler.init(callbacks, {sources})); + compiler['loadJsFile'] = (filename: string): string => { return null; } + const result = await compiler.run(kpjFilename, null); + assert.isNull(result); }); }); From dbc1e42ded05b7d026b5221223f2f5c4626ca10c Mon Sep 17 00:00:00 2001 From: "Dr Mark C. Sinclair" Date: Mon, 18 Mar 2024 16:20:52 +0000 Subject: [PATCH 039/170] chore(developer): add KeyboardInfoCompiler returns null if license is not MIT test --- .../test/test-keyboard-info-compiler.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts b/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts index 22839dc3fce..fa7249bdf22 100644 --- a/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts +++ b/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts @@ -169,4 +169,25 @@ describe('keyboard-info-compiler', function () { const result = await compiler.run(kpjFilename, null); assert.isNull(result); }); + + it('check run returns null if license is not MIT', async function() { + const kpjFilename = makePathToFixture('khmer_angkor', 'khmer_angkor.kpj'); + const jsFilename = makePathToFixture('khmer_angkor', 'build', 'khmer_angkor.js'); + const kpsFilename = makePathToFixture('khmer_angkor', 'source', 'khmer_angkor.kps'); + const kmpFilename = makePathToFixture('khmer_angkor', 'build', 'khmer_angkor.kmp'); + + const sources = { + kmpFilename, + sourcePath: 'release/k/khmer_angkor', + kpsFilename, + jsFilename: jsFilename, + forPublishing: true, + }; + + const compiler = new KeyboardInfoCompiler(); + assert.isTrue(await compiler.init(callbacks, {sources})); + compiler['isLicenseMIT'] = (filename: string): boolean => { return false; } + const result = await compiler.run(kpjFilename, null); + assert.isNull(result); + }); }); From e2088fd65fda6004bc2225af7ff4ee6782139544 Mon Sep 17 00:00:00 2001 From: "Dr Mark C. Sinclair" Date: Mon, 18 Mar 2024 16:29:14 +0000 Subject: [PATCH 040/170] chore(developer): add KeyboardInfoCompiler returns null if fillLanguages fails test --- .../test/test-keyboard-info-compiler.ts | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts b/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts index fa7249bdf22..eba1b4c9091 100644 --- a/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts +++ b/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts @@ -6,7 +6,8 @@ import { makePathToFixture } from './helpers/index.js'; import { KeyboardInfoCompiler, KeyboardInfoCompilerResult, unitTestEndpoints } from '../src/keyboard-info-compiler.js'; import langtags from "../src/imports/langtags.js"; import { KmpCompiler, KmpCompilerOptions } from '@keymanapp/kmc-package'; -import { CompilerCallbacks } from '@keymanapp/common-types'; +import { CompilerCallbacks, KmpJsonFile } from '@keymanapp/common-types'; +import { KeyboardInfoFile } from './keyboard-info-file.js'; const callbacks = new TestCompilerCallbacks(); @@ -189,5 +190,26 @@ describe('keyboard-info-compiler', function () { compiler['isLicenseMIT'] = (filename: string): boolean => { return false; } const result = await compiler.run(kpjFilename, null); assert.isNull(result); + }); + + it('check run returns null if fillLanguages fails', async function() { + const kpjFilename = makePathToFixture('khmer_angkor', 'khmer_angkor.kpj'); + const jsFilename = makePathToFixture('khmer_angkor', 'build', 'khmer_angkor.js'); + const kpsFilename = makePathToFixture('khmer_angkor', 'source', 'khmer_angkor.kps'); + const kmpFilename = makePathToFixture('khmer_angkor', 'build', 'khmer_angkor.kmp'); + + const sources = { + kmpFilename, + sourcePath: 'release/k/khmer_angkor', + kpsFilename, + jsFilename: jsFilename, + forPublishing: true, + }; + + const compiler = new KeyboardInfoCompiler(); + assert.isTrue(await compiler.init(callbacks, {sources})); + compiler['fillLanguages'] = (kpsFilename: string, keyboard_info: KeyboardInfoFile, kmpJsonData: KmpJsonFile.KmpJsonFile): Promise => { return null; } + const result = await compiler.run(kpjFilename, null); + assert.isNull(result); }); }); From 5c1733e718ec810216d2a9708c4e1d680842f30b Mon Sep 17 00:00:00 2001 From: "Dr Mark C. Sinclair" Date: Mon, 18 Mar 2024 16:40:28 +0000 Subject: [PATCH 041/170] chore(developer): tighten up stub definitions in tests --- .../test/test-keyboard-info-compiler.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts b/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts index eba1b4c9091..77ae40f4c66 100644 --- a/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts +++ b/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts @@ -121,7 +121,7 @@ describe('keyboard-info-compiler', function () { const compiler = new KeyboardInfoCompiler(); assert.isTrue(await compiler.init(callbacks, {sources})); const origKmpCompilerInit = KmpCompiler.prototype.init; - KmpCompiler.prototype.init = (callbacks: CompilerCallbacks, options: KmpCompilerOptions) => { return null; } + KmpCompiler.prototype.init = async (_callbacks: CompilerCallbacks, _options: KmpCompilerOptions): Promise => false; const result = await compiler.run(kpjFilename, null); KmpCompiler.prototype.init = origKmpCompilerInit; assert.isNull(result); @@ -144,7 +144,7 @@ describe('keyboard-info-compiler', function () { const compiler = new KeyboardInfoCompiler(); assert.isTrue(await compiler.init(callbacks, {sources})); const origKmpCompilerTransformKpsToKmpObject = KmpCompiler.prototype.transformKpsToKmpObject; - KmpCompiler.prototype.transformKpsToKmpObject = (kpsFilename: string) => { return null; } + KmpCompiler.prototype.transformKpsToKmpObject = (_kpsFilename: string): KmpJsonFile.KmpJsonFile => null; const result = await compiler.run(kpjFilename, null); KmpCompiler.prototype.transformKpsToKmpObject = origKmpCompilerTransformKpsToKmpObject; assert.isNull(result); @@ -166,7 +166,7 @@ describe('keyboard-info-compiler', function () { const compiler = new KeyboardInfoCompiler(); assert.isTrue(await compiler.init(callbacks, {sources})); - compiler['loadJsFile'] = (filename: string): string => { return null; } + compiler['loadJsFile'] = (_filename: string): string => null; const result = await compiler.run(kpjFilename, null); assert.isNull(result); }); @@ -187,7 +187,7 @@ describe('keyboard-info-compiler', function () { const compiler = new KeyboardInfoCompiler(); assert.isTrue(await compiler.init(callbacks, {sources})); - compiler['isLicenseMIT'] = (filename: string): boolean => { return false; } + compiler['isLicenseMIT'] = (_filename: string): boolean => false; const result = await compiler.run(kpjFilename, null); assert.isNull(result); }); @@ -208,7 +208,7 @@ describe('keyboard-info-compiler', function () { const compiler = new KeyboardInfoCompiler(); assert.isTrue(await compiler.init(callbacks, {sources})); - compiler['fillLanguages'] = (kpsFilename: string, keyboard_info: KeyboardInfoFile, kmpJsonData: KmpJsonFile.KmpJsonFile): Promise => { return null; } + compiler['fillLanguages'] = async (_kpsFilename: string, _keyboard_info: KeyboardInfoFile, _kmpJsonData: KmpJsonFile.KmpJsonFile): Promise => false; const result = await compiler.run(kpjFilename, null); assert.isNull(result); }); From 5d4a56f6d013ea85c4dc8492d3c9aafcb302eabf Mon Sep 17 00:00:00 2001 From: "Dr Mark C. Sinclair" Date: Tue, 19 Mar 2024 11:11:00 +0000 Subject: [PATCH 042/170] chore(developer): add write artifacts to disk test --- developer/src/kmc-keyboard-info/.gitignore | 3 +- .../test/test-keyboard-info-compiler.ts | 39 +++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/developer/src/kmc-keyboard-info/.gitignore b/developer/src/kmc-keyboard-info/.gitignore index fb2891a0a23..f118f231af2 100644 --- a/developer/src/kmc-keyboard-info/.gitignore +++ b/developer/src/kmc-keyboard-info/.gitignore @@ -1 +1,2 @@ -src/imports/ \ No newline at end of file +src/imports/ +test/fixtures/khmer_angkor/build/actual.keyboard_info \ No newline at end of file diff --git a/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts b/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts index 77ae40f4c66..8708acceb68 100644 --- a/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts +++ b/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts @@ -212,4 +212,43 @@ describe('keyboard-info-compiler', function () { const result = await compiler.run(kpjFilename, null); assert.isNull(result); }); + + it('should write artifacts to disk', async function() { + const kpjFilename = makePathToFixture('khmer_angkor', 'khmer_angkor.kpj'); + const jsFilename = makePathToFixture('khmer_angkor', 'build', 'khmer_angkor.js'); + const kpsFilename = makePathToFixture('khmer_angkor', 'source', 'khmer_angkor.kps'); + const kmpFilename = makePathToFixture('khmer_angkor', 'build', 'khmer_angkor.kmp'); + const actualFilename = makePathToFixture('khmer_angkor', 'build', 'actual.keyboard_info'); + const expectedFilename = makePathToFixture('khmer_angkor', 'build', 'khmer_angkor.keyboard_info'); + + const sources = { + kmpFilename, + sourcePath: 'release/k/khmer_angkor', + kpsFilename, + jsFilename: jsFilename, + forPublishing: true, + }; + + const compiler = new KeyboardInfoCompiler(); + assert.isTrue(await compiler.init(callbacks, {sources})); + const result = await compiler.run(kpjFilename, null); + assert.isNotNull(result); + + if(fs.existsSync(actualFilename)) { + fs.rmSync(actualFilename); + } + + result.artifacts.keyboard_info.filename = actualFilename; + assert.isTrue(await compiler.write(result.artifacts)); + assert(fs.existsSync(actualFilename)) + + const actual = JSON.parse(fs.readFileSync(actualFilename, 'utf-8')); + const expected = JSON.parse(fs.readFileSync(expectedFilename, 'utf-8')); + + // `lastModifiedDate` is dependent on time of run (not worth mocking) + delete actual['lastModifiedDate']; + delete expected['lastModifiedDate']; + + assert.deepEqual(actual, expected); + }); }); From 0e2ff2bdfe602b8cfd216c228a7443b179ffa300 Mon Sep 17 00:00:00 2001 From: "Dr Mark C. Sinclair" Date: Tue, 19 Mar 2024 11:51:57 +0000 Subject: [PATCH 043/170] chore(developer): add mapKeymanTargetToPlatform returns correct platforms test --- .../test/test-keyboard-info-compiler.ts | 25 +++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts b/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts index 8708acceb68..2fe129f9f90 100644 --- a/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts +++ b/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts @@ -6,8 +6,8 @@ import { makePathToFixture } from './helpers/index.js'; import { KeyboardInfoCompiler, KeyboardInfoCompilerResult, unitTestEndpoints } from '../src/keyboard-info-compiler.js'; import langtags from "../src/imports/langtags.js"; import { KmpCompiler, KmpCompilerOptions } from '@keymanapp/kmc-package'; -import { CompilerCallbacks, KmpJsonFile } from '@keymanapp/common-types'; -import { KeyboardInfoFile } from './keyboard-info-file.js'; +import { CompilerCallbacks, KeymanTargets, KmpJsonFile } from '@keymanapp/common-types'; +import { KeyboardInfoFile, KeyboardInfoFilePlatform } from './keyboard-info-file.js'; const callbacks = new TestCompilerCallbacks(); @@ -251,4 +251,25 @@ describe('keyboard-info-compiler', function () { assert.deepEqual(actual, expected); }); + + it('check mapKeymanTargetToPlatform returns correct platforms', async function() { + const compiler = new KeyboardInfoCompiler(); + const map: {[index in KeymanTargets.KeymanTarget]: KeyboardInfoFilePlatform[]} = { + any: [], + androidphone: ['android'], + androidtablet: ['android'], + desktop: [], + ipad: ['ios'], + iphone: ['ios'], + linux: ['linux'], + macosx: ['macos'], + mobile: [], + tablet: [], + web: ['desktopWeb'], + windows: ['windows'] + } + for (const [target, platform] of Object.entries(map)) { + assert.deepEqual(compiler['mapKeymanTargetToPlatform'](target), platform); + } + }); }); From 628ac27d08070864b071de2e1cf1098dc6a75306 Mon Sep 17 00:00:00 2001 From: "Dr Mark C. Sinclair" Date: Tue, 19 Mar 2024 12:25:49 +0000 Subject: [PATCH 044/170] chore(developer): add kmxFileVersionToString returns correct strings test --- .../test/test-keyboard-info-compiler.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts b/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts index 2fe129f9f90..27163fe7c7a 100644 --- a/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts +++ b/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts @@ -272,4 +272,18 @@ describe('keyboard-info-compiler', function () { assert.deepEqual(compiler['mapKeymanTargetToPlatform'](target), platform); } }); + + it('check kmxFileVersionToString returns correct strings', async function() { + const compiler = new KeyboardInfoCompiler(); + const convs = [ + {num: 0x0000, str: '0.0'}, + {num: 0x0001, str: '0.1'}, + {num: 0x0100, str: '1.0'}, + {num: 0x0101, str: '1.1'}, + {num: 0x0A0A, str: '10.10'}, + ]; + convs.forEach((conv) => { + assert.equal(compiler['kmxFileVersionToString'](conv.num), conv.str); + }); + }); }); From 9c3e55e5119d9daa670d13f4184ae7e5c0774154 Mon Sep 17 00:00:00 2001 From: "Dr Mark C. Sinclair" Date: Wed, 20 Mar 2024 11:45:59 +0000 Subject: [PATCH 045/170] chore(developer): add loadKmxFiles returns empty array if .kmx file is missing from .kmp test --- .../test/test-keyboard-info-compiler.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts b/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts index 27163fe7c7a..7513a7c5cc6 100644 --- a/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts +++ b/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts @@ -6,7 +6,7 @@ import { makePathToFixture } from './helpers/index.js'; import { KeyboardInfoCompiler, KeyboardInfoCompilerResult, unitTestEndpoints } from '../src/keyboard-info-compiler.js'; import langtags from "../src/imports/langtags.js"; import { KmpCompiler, KmpCompilerOptions } from '@keymanapp/kmc-package'; -import { CompilerCallbacks, KeymanTargets, KmpJsonFile } from '@keymanapp/common-types'; +import { CompilerCallbacks, KMX, KeymanFileTypes, KeymanTargets, KmpJsonFile } from '@keymanapp/common-types'; import { KeyboardInfoFile, KeyboardInfoFilePlatform } from './keyboard-info-file.js'; const callbacks = new TestCompilerCallbacks(); @@ -285,5 +285,20 @@ describe('keyboard-info-compiler', function () { convs.forEach((conv) => { assert.equal(compiler['kmxFileVersionToString'](conv.num), conv.str); }); - }); + }); + + it('check loadKmxFiles returns empty array if .kmx file is missing from .kmp', async function() { + const kpsFilename = makePathToFixture('khmer_angkor', 'source', 'khmer_angkor.kps'); + const compiler = new KeyboardInfoCompiler(); + const kmpCompiler = new KmpCompiler(); + assert.isTrue(await kmpCompiler.init(callbacks, {})); + const kmpJsonData = kmpCompiler.transformKpsToKmpObject(kpsFilename); + assert.isNotNull(kmpJsonData); + kmpJsonData.files = kmpJsonData.files.filter(file => !KeymanFileTypes.filenameIs(file.name, KeymanFileTypes.Binary.Keyboard)); + const kmxFiles: { + filename: string, + data: KMX.KEYBOARD + }[] = compiler['loadKmxFiles'](kpsFilename, kmpJsonData); + assert.deepEqual(kmxFiles, []); + }); }); From 7353c3049aedc1ac495f90b4829c8ecd821acac7 Mon Sep 17 00:00:00 2001 From: "Dr Mark C. Sinclair" Date: Wed, 20 Mar 2024 12:24:03 +0000 Subject: [PATCH 046/170] chore(developer): add loadKmxFiles throws error if .kmx file is missing from disk test --- .../test/test-keyboard-info-compiler.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts b/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts index 7513a7c5cc6..31e353e6bf5 100644 --- a/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts +++ b/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts @@ -294,11 +294,26 @@ describe('keyboard-info-compiler', function () { assert.isTrue(await kmpCompiler.init(callbacks, {})); const kmpJsonData = kmpCompiler.transformKpsToKmpObject(kpsFilename); assert.isNotNull(kmpJsonData); + // remove .kps file kmpJsonData.files = kmpJsonData.files.filter(file => !KeymanFileTypes.filenameIs(file.name, KeymanFileTypes.Binary.Keyboard)); const kmxFiles: { filename: string, data: KMX.KEYBOARD }[] = compiler['loadKmxFiles'](kpsFilename, kmpJsonData); assert.deepEqual(kmxFiles, []); - }); + }); + + it('check loadKmxFiles throws error if .kmx file is missing from disk', async function() { + const kpsFilename = makePathToFixture('khmer_angkor', 'source', 'khmer_angkor.kps'); + const compiler = new KeyboardInfoCompiler(); + const kmpCompiler = new KmpCompiler(); + assert.isTrue(await kmpCompiler.init(callbacks, {})); + const kmpJsonData = kmpCompiler.transformKpsToKmpObject(kpsFilename); + assert.isNotNull(kmpJsonData); + // rename .kmx file in files list so it cannot be loaded from disk + const kmpIndex = kmpJsonData.files.findIndex(file => KeymanFileTypes.filenameIs(file.name, KeymanFileTypes.Binary.Keyboard)); + kmpJsonData.files[kmpIndex].name = '../build/throw_error.kmx'; + assert.throws(() => compiler['loadKmxFiles'](kpsFilename, kmpJsonData)); + //assert.deepEqual(kmxFiles, []); + }); }); From 8abe3f65830c6dce94f17784f15cefd7869cd4cf Mon Sep 17 00:00:00 2001 From: "Dr Mark C. Sinclair" Date: Wed, 20 Mar 2024 12:24:03 +0000 Subject: [PATCH 047/170] chore(developer): add loadKmxFiles throws error if .kmx file is missing from disk test --- .../test/test-keyboard-info-compiler.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts b/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts index 7513a7c5cc6..c7cf7555137 100644 --- a/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts +++ b/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts @@ -294,11 +294,25 @@ describe('keyboard-info-compiler', function () { assert.isTrue(await kmpCompiler.init(callbacks, {})); const kmpJsonData = kmpCompiler.transformKpsToKmpObject(kpsFilename); assert.isNotNull(kmpJsonData); + // remove .kps file kmpJsonData.files = kmpJsonData.files.filter(file => !KeymanFileTypes.filenameIs(file.name, KeymanFileTypes.Binary.Keyboard)); const kmxFiles: { filename: string, data: KMX.KEYBOARD }[] = compiler['loadKmxFiles'](kpsFilename, kmpJsonData); assert.deepEqual(kmxFiles, []); - }); + }); + + it('check loadKmxFiles throws error if .kmx file is missing from disk', async function() { + const kpsFilename = makePathToFixture('khmer_angkor', 'source', 'khmer_angkor.kps'); + const compiler = new KeyboardInfoCompiler(); + const kmpCompiler = new KmpCompiler(); + assert.isTrue(await kmpCompiler.init(callbacks, {})); + const kmpJsonData = kmpCompiler.transformKpsToKmpObject(kpsFilename); + assert.isNotNull(kmpJsonData); + // rename .kmx file in files list so it cannot be loaded from disk + const kmpIndex = kmpJsonData.files.findIndex(file => KeymanFileTypes.filenameIs(file.name, KeymanFileTypes.Binary.Keyboard)); + kmpJsonData.files[kmpIndex].name = '../build/throw_error.kmx'; + assert.throws(() => compiler['loadKmxFiles'](kpsFilename, kmpJsonData)); + }); }); From 305e6f54dc13c698e946507f8554065a67a574ea Mon Sep 17 00:00:00 2001 From: "Dr Mark C. Sinclair" Date: Wed, 20 Mar 2024 12:37:59 +0000 Subject: [PATCH 048/170] chore(developer): recover from failed merge --- .../src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts b/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts index 6578b8d91c2..c7cf7555137 100644 --- a/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts +++ b/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts @@ -314,9 +314,5 @@ describe('keyboard-info-compiler', function () { const kmpIndex = kmpJsonData.files.findIndex(file => KeymanFileTypes.filenameIs(file.name, KeymanFileTypes.Binary.Keyboard)); kmpJsonData.files[kmpIndex].name = '../build/throw_error.kmx'; assert.throws(() => compiler['loadKmxFiles'](kpsFilename, kmpJsonData)); -<<<<<<< HEAD -======= - //assert.deepEqual(kmxFiles, []); ->>>>>>> 7353c3049aedc1ac495f90b4829c8ecd821acac7 }); }); From a54053bec6c079ee8ad7d54c83d179f9b40615e9 Mon Sep 17 00:00:00 2001 From: Marc Durdin Date: Thu, 21 Mar 2024 10:08:23 +0700 Subject: [PATCH 049/170] fix(web): prevent layer switch key from erasing selection Fixes #7866. When the transform generated by a key event results in no changes to the text, this will no longer trigger a change event in the apps. This means that layer switch keys will no longer erase the selection. Fix proposed by @jahorton. --- .../web/keyboard-processor/src/text/outputTarget.ts | 12 +++++++----- web/src/app/webview/src/contextManager.ts | 4 +++- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/common/web/keyboard-processor/src/text/outputTarget.ts b/common/web/keyboard-processor/src/text/outputTarget.ts index ab9656b6999..6ad579d1fab 100644 --- a/common/web/keyboard-processor/src/text/outputTarget.ts +++ b/common/web/keyboard-processor/src/text/outputTarget.ts @@ -21,15 +21,17 @@ export function isEmptyTransform(transform: Transform) { export class TextTransform implements Transform { readonly insert: string; readonly deleteLeft: number; - readonly deleteRight?: number; + readonly deleteRight: number; + readonly erasedSelection: boolean; - constructor(insert: string, deleteLeft: number, deleteRight?: number) { + constructor(insert: string, deleteLeft: number, deleteRight: number, erasedSelection: boolean) { this.insert = insert; this.deleteLeft = deleteLeft; - this.deleteRight = deleteRight || 0; + this.deleteRight = deleteRight; + this.erasedSelection = erasedSelection; } - public static readonly nil = new TextTransform('', 0, 0); + public static readonly nil = new TextTransform('', 0, 0, false); } export class Transcription { @@ -138,7 +140,7 @@ export default abstract class OutputTarget { // caret mid-word.. const deletedRight = fromRight.substring(0, rightDivergenceIndex + 1)._kmwLength(); - return new TextTransform(insertedText, deletedLeft, deletedRight); + return new TextTransform(insertedText, deletedLeft, deletedRight, original.getSelectedText() && !this.getSelectedText()); } buildTranscriptionFrom(original: OutputTarget, keyEvent: KeyEvent, readonly: boolean, alternates?: Alternate[]): Transcription { diff --git a/web/src/app/webview/src/contextManager.ts b/web/src/app/webview/src/contextManager.ts index d9ef2c6e52b..04fda6fe770 100644 --- a/web/src/app/webview/src/contextManager.ts +++ b/web/src/app/webview/src/contextManager.ts @@ -41,7 +41,9 @@ export class ContextHost extends Mock { // Signal the necessary text changes to the embedding app, if it exists. if(this.oninserttext) { - this.oninserttext(transform.deleteLeft, transform.insert, transform.deleteRight); + if(transform.deleteLeft > 0 || transform.insert != '' || transform.deleteRight > 0 || transform.erasedSelection) { + this.oninserttext(transform.deleteLeft, transform.insert, transform.deleteRight); + } } } From 8fbb0c3281f60012396366613b272d4cc4f91843 Mon Sep 17 00:00:00 2001 From: Marc Durdin Date: Thu, 21 Mar 2024 10:38:43 +0700 Subject: [PATCH 050/170] chore(web): update tests --- common/web/keyboard-processor/tests/node/transcriptions.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/common/web/keyboard-processor/tests/node/transcriptions.js b/common/web/keyboard-processor/tests/node/transcriptions.js index 6d6be7b540f..7fc940d63e8 100644 --- a/common/web/keyboard-processor/tests/node/transcriptions.js +++ b/common/web/keyboard-processor/tests/node/transcriptions.js @@ -448,7 +448,8 @@ but not himself.`; // Sheev Palpatine, in the Star Wars prequels. assert.deepEqual(transform, { insert: '', deleteLeft: 0, - deleteRight: 0 + deleteRight: 0, + erasedSelection: false }); }); @@ -459,7 +460,8 @@ but not himself.`; // Sheev Palpatine, in the Star Wars prequels. const transform = { insert: '', deleteLeft: 0, - deleteRight: 0 + deleteRight: 0, + erasedSelection: false }; target.apply(transform); From ea8366455cba1d91e7ce63c616ad513cf5a949b4 Mon Sep 17 00:00:00 2001 From: Marc Durdin Date: Thu, 21 Mar 2024 14:03:40 +0700 Subject: [PATCH 051/170] chore(web): update test fixture --- common/web/keyboard-processor/tests/node/transcriptions.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/common/web/keyboard-processor/tests/node/transcriptions.js b/common/web/keyboard-processor/tests/node/transcriptions.js index 7fc940d63e8..b071532bf2a 100644 --- a/common/web/keyboard-processor/tests/node/transcriptions.js +++ b/common/web/keyboard-processor/tests/node/transcriptions.js @@ -449,7 +449,7 @@ but not himself.`; // Sheev Palpatine, in the Star Wars prequels. insert: '', deleteLeft: 0, deleteRight: 0, - erasedSelection: false + erasedSelection: true }); }); @@ -461,7 +461,7 @@ but not himself.`; // Sheev Palpatine, in the Star Wars prequels. insert: '', deleteLeft: 0, deleteRight: 0, - erasedSelection: false + erasedSelection: true }; target.apply(transform); From b542a8d133081c6dba464143ef28033df14f6bd7 Mon Sep 17 00:00:00 2001 From: "Dr Mark C. Sinclair" Date: Mon, 25 Mar 2024 15:12:15 +0000 Subject: [PATCH 052/170] chore(developer): add loadKmxFiles can handle two .kmx files test --- .../build/k_001___basic_input_unicodei.kmx | Bin 0 -> 440 bytes .../build/k_002___basic_input_unicode.kmx | Bin 0 -> 438 bytes .../test/test-keyboard-info-compiler.ts | 41 +++++++++++++++++- 3 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 developer/src/kmc-keyboard-info/test/fixtures/two-kmx/build/k_001___basic_input_unicodei.kmx create mode 100644 developer/src/kmc-keyboard-info/test/fixtures/two-kmx/build/k_002___basic_input_unicode.kmx diff --git a/developer/src/kmc-keyboard-info/test/fixtures/two-kmx/build/k_001___basic_input_unicodei.kmx b/developer/src/kmc-keyboard-info/test/fixtures/two-kmx/build/k_001___basic_input_unicodei.kmx new file mode 100644 index 0000000000000000000000000000000000000000..82f60cd001d0ba9bf984253ea81e87a41d1a3df4 GIT binary patch literal 440 zcmZ9HF-t;G7=~X#s>MWBCP z8vP9}_MDHCLLWTmzVG{;^TMs)Hg2>fr4OQln3y{lBGVIk)(}~Ho@S}WHp|`6I;9h`%D+yj7?UpGZ{i4NH}A^0 tgkQh28bbxY>$3^|15WXKKAYk{p^8uWY=-}W(0t3zww>>f%4I09+K_tXM8lC(K z?S6npKZBF?+{3RLJn)?RzVG>+1GiMYtSBONuRr%ic|oIyOds=25Pnn0znZ#-yCkph z0(&rk9ze*tUJ}zGM^#ODXtXMNxN$52Hj-d(W4&F zSLh&Z>y~XPsj~9A&>8wbX}g@%6SI5UZ`tTQ0kDyNNzkUEQ)Gq!2 literal 0 HcmV?d00001 diff --git a/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts b/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts index c7cf7555137..7cd1ea7a8e5 100644 --- a/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts +++ b/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts @@ -8,6 +8,8 @@ import langtags from "../src/imports/langtags.js"; import { KmpCompiler, KmpCompilerOptions } from '@keymanapp/kmc-package'; import { CompilerCallbacks, KMX, KeymanFileTypes, KeymanTargets, KmpJsonFile } from '@keymanapp/common-types'; import { KeyboardInfoFile, KeyboardInfoFilePlatform } from './keyboard-info-file.js'; +//import { PackageVersionValidator } from '../../kmc-package/src/compiler/package-version-validator.js'; +//import { KeyboardMetadataCollection } from '../../kmc-package/src/compiler/package-metadata-collector.js'; const callbacks = new TestCompilerCallbacks(); @@ -304,7 +306,7 @@ describe('keyboard-info-compiler', function () { }); it('check loadKmxFiles throws error if .kmx file is missing from disk', async function() { - const kpsFilename = makePathToFixture('khmer_angkor', 'source', 'khmer_angkor.kps'); + const kpsFilename = makePathToFixture('khmer_angkor', 'source', 'khmer_angkor.kps'); const compiler = new KeyboardInfoCompiler(); const kmpCompiler = new KmpCompiler(); assert.isTrue(await kmpCompiler.init(callbacks, {})); @@ -315,4 +317,41 @@ describe('keyboard-info-compiler', function () { kmpJsonData.files[kmpIndex].name = '../build/throw_error.kmx'; assert.throws(() => compiler['loadKmxFiles'](kpsFilename, kmpJsonData)); }); + + it('check loadKmxFiles can handle two .kmx files', async function() { + const jsFilename = makePathToFixture('two-kmx', 'build', 'two-kmx.js'); + const kpsFilename = makePathToFixture('two-kmx', 'source', 'two-kmx.kps'); + const kmpFilename = makePathToFixture('two-kmx', 'build', 'two-kmx.kmp'); + + const sources = { + kmpFilename, + sourcePath: 'release/k/two-kmx', + kpsFilename, + jsFilename: jsFilename, + forPublishing: true, + }; + + const kmx_filename_001 = 'k_001___basic_input_unicodei.kmx'; + const kmx_filename_002 = 'k_002___basic_input_unicode.kmx'; + + const compiler = new KeyboardInfoCompiler(); + assert.isTrue(await compiler.init(callbacks, {sources})); + const kmpJsonData: KmpJsonFile.KmpJsonFile = { + system: { fileVersion: '', keymanDeveloperVersion: '' }, + options: null, + files: [ + { name: '../build/' + kmx_filename_001, description: 'Keyboard 001' }, + { name: '../build/' + kmx_filename_002, description: 'Keyboard 002' }, + ] + }; + const kmxFiles: { + filename: string, + data: KMX.KEYBOARD + }[] = compiler['loadKmxFiles'](kpsFilename, kmpJsonData); + assert.equal(kmxFiles.length, 2); + assert.deepEqual(kmxFiles[0].filename, kmx_filename_001); + assert.deepEqual(kmxFiles[1].filename, kmx_filename_002); + assert.isNotNull(kmxFiles[0].data); + assert.isNotNull(kmxFiles[1].data); + }); }); From 18ba46d8746ff4622406ed4565e45c5051e3e7e2 Mon Sep 17 00:00:00 2001 From: "Steven R. Loomis" Date: Fri, 22 Mar 2024 16:21:05 -0500 Subject: [PATCH 053/170] fix(common): fix an additional issue on illegal UnicodeSets - similar fix to https://github.com/keymanapp/keyman/pull/9492 - again may be due to a wasm version variant - also additional rangechecking on the input side --- common/web/types/src/kmx/element-string.ts | 3 +++ developer/src/kmc-kmn/src/compiler/compiler.ts | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/common/web/types/src/kmx/element-string.ts b/common/web/types/src/kmx/element-string.ts index 578e0f9e1ae..185a24ae86b 100644 --- a/common/web/types/src/kmx/element-string.ts +++ b/common/web/types/src/kmx/element-string.ts @@ -74,6 +74,9 @@ export class ElementString extends Array { typeFlag |= constants.elem_flags_type_uset; // TODO-LDML: err on max buffer size const needRanges = sections.usetparser.sizeUnicodeSet(item.segment); + if (needRanges < 0) { + return null; // UnicodeSet error + } const uset = sections.usetparser.parseUnicodeSet(item.segment, needRanges); if (!uset) { return null; // UnicodeSet error already thrown diff --git a/developer/src/kmc-kmn/src/compiler/compiler.ts b/developer/src/kmc-kmn/src/compiler/compiler.ts index 109ec0cfa57..e2b2b10eda1 100644 --- a/developer/src/kmc-kmn/src/compiler/compiler.ts +++ b/developer/src/kmc-kmn/src/compiler/compiler.ts @@ -556,6 +556,12 @@ export class KmnCompiler implements KeymanCompiler, UnicodeSetParser { // fix \u1234 pattern format pattern = KmnCompiler.fixNewPattern(pattern); /** If <= 0: return code. If positive: range count */ + if (buf < 0) { + throw new RangeError(`Internal error: wasm malloc() returned ${buf}`); + } + if ((rangeCount*2) < 0) { + throw new RangeError(`Internal error: negative rangeCount * 2 = ${rangeCount * 2}`); + } const rc = Module.kmcmp_parseUnicodeSet(pattern, buf, rangeCount * 2); if (rc >= 0) { const ranges = []; From 28bdbbcfd7b524d50abeb1bf18e642ad51b229eb Mon Sep 17 00:00:00 2001 From: "Steven R. Loomis" Date: Fri, 22 Mar 2024 14:54:51 -0500 Subject: [PATCH 054/170] bug(core): repro for assertion - test case with a set of markers --- core/tests/unit/ldml/keyboards/k_210_marker-test.xml | 7 +++++++ core/tests/unit/ldml/keyboards/k_210_marker.xml | 8 ++++++++ 2 files changed, 15 insertions(+) diff --git a/core/tests/unit/ldml/keyboards/k_210_marker-test.xml b/core/tests/unit/ldml/keyboards/k_210_marker-test.xml index df6b65422b6..347e679581c 100644 --- a/core/tests/unit/ldml/keyboards/k_210_marker-test.xml +++ b/core/tests/unit/ldml/keyboards/k_210_marker-test.xml @@ -50,4 +50,11 @@ + + + + + + + diff --git a/core/tests/unit/ldml/keyboards/k_210_marker.xml b/core/tests/unit/ldml/keyboards/k_210_marker.xml index 4d3d66ce986..2a8135c1003 100644 --- a/core/tests/unit/ldml/keyboards/k_210_marker.xml +++ b/core/tests/unit/ldml/keyboards/k_210_marker.xml @@ -28,8 +28,16 @@ + + + + + + + + From 67838e60a224712079e4c8d8957b4eb4e92544b0 Mon Sep 17 00:00:00 2001 From: "Steven R. Loomis" Date: Mon, 25 Mar 2024 12:00:55 -0500 Subject: [PATCH 055/170] fix(developer,core): fix for marker substitution in sets - for bug(core): assert when matching markers #11045 - track raw and marker-substituted sets --- common/web/types/src/kmx/kmx-plus.ts | 13 +++++++++---- .../unit/ldml/keyboards/k_210_marker-test.xml | 7 ++++++- core/tests/unit/ldml/keyboards/k_210_marker.xml | 4 ++-- developer/src/kmc-ldml/src/compiler/vars.ts | 16 ++++++++++++---- 4 files changed, 29 insertions(+), 11 deletions(-) diff --git a/common/web/types/src/kmx/kmx-plus.ts b/common/web/types/src/kmx/kmx-plus.ts index 6b485a2b2fe..adfdd917081 100644 --- a/common/web/types/src/kmx/kmx-plus.ts +++ b/common/web/types/src/kmx/kmx-plus.ts @@ -291,9 +291,10 @@ export class Vars extends Section { // try as set const set = Vars.findVariable(this.sets, id); if (set !== null) { - const { items } = set; - const inner = items.map(i => i.value.value).join('|'); - return `(?:${inner})`; // TODO-LDML: need to escape here + const items = set.rawItems; + const inner = items.join('|'); + // TODO-LDML: string substitution here, #11037 + return `(?:${inner})`; } // try as unicodeset @@ -372,11 +373,15 @@ export class UnicodeSetItem extends VarsItem { }; export class SetVarItem extends VarsItem { - constructor(id: string, value: string[], sections: DependencySections) { + constructor(id: string, value: string[], sections: DependencySections, rawItems: string[]) { super(id, value.join(' '), sections); this.items = sections.elem.allocElementString(sections, value); + this.rawItems = rawItems; } + // element string array items: ElementString; + // like items, but with unprocessed marker strings + rawItems: string[]; valid() : boolean { return !!this.items; } diff --git a/core/tests/unit/ldml/keyboards/k_210_marker-test.xml b/core/tests/unit/ldml/keyboards/k_210_marker-test.xml index 347e679581c..5a36d1941b3 100644 --- a/core/tests/unit/ldml/keyboards/k_210_marker-test.xml +++ b/core/tests/unit/ldml/keyboards/k_210_marker-test.xml @@ -52,9 +52,14 @@ + - + + + + + diff --git a/core/tests/unit/ldml/keyboards/k_210_marker.xml b/core/tests/unit/ldml/keyboards/k_210_marker.xml index 2a8135c1003..0d25de75ac6 100644 --- a/core/tests/unit/ldml/keyboards/k_210_marker.xml +++ b/core/tests/unit/ldml/keyboards/k_210_marker.xml @@ -24,7 +24,7 @@ - + @@ -37,7 +37,7 @@ - + diff --git a/developer/src/kmc-ldml/src/compiler/vars.ts b/developer/src/kmc-ldml/src/compiler/vars.ts index dbbb07d06ed..0e12242b8b9 100644 --- a/developer/src/kmc-ldml/src/compiler/vars.ts +++ b/developer/src/kmc-ldml/src/compiler/vars.ts @@ -192,6 +192,9 @@ export class VarsCompiler extends SectionCompiler { validateSubstitutions(keyboard: LDMLKeyboard.LKKeyboard, st : Substitutions) : boolean { keyboard?.variables?.string?.forEach(({value}) => st.markers.add(SubstitutionUse.variable, MarkerParser.allReferences(value))); + // get markers mentioned in a set + keyboard?.variables?.set?.forEach(({ value }) => + VariableParser.setSplitter(value).forEach(v => st.markers.add(SubstitutionUse.match, MarkerParser.allReferences(v)))); return true; } @@ -208,8 +211,6 @@ export class VarsCompiler extends SectionCompiler { // first, strings. variables?.string?.forEach((e) => this.addString(result, e, sections)); - variables?.set?.forEach((e) => - this.addSet(result, e, sections)); variables?.uset?.forEach((e) => this.addUnicodeSet(result, e, sections)); @@ -222,6 +223,10 @@ export class VarsCompiler extends SectionCompiler { const allMarkers : string[] = Array.from(mt.all).filter(m => m !== MarkerParser.ANY_MARKER_ID).sort(); result.markers = sections.list.allocList(allMarkers, {}, sections); + // sets need to be added late, because they can refer to markers + variables?.set?.forEach((e) => + this.addSet(result, e, sections)); + return result.valid() ? result : null; } @@ -240,8 +245,11 @@ export class VarsCompiler extends SectionCompiler { value = result.substituteStrings(value, sections); // OK to do this as a substitute, because we've already validated the set above. value = result.substituteSets(value, sections); - const items : string[] = VariableParser.setSplitter(value); - result.sets.push(new SetVarItem(id, items, sections)); + // raw items - without marker substitution + const rawItems: string[] = VariableParser.setSplitter(value); + // cooked items - has substutition of markers + const cookedItems: string[] = rawItems.map(v => result.substituteMarkerString(v, false)); + result.sets.push(new SetVarItem(id, cookedItems, sections, rawItems)); } addUnicodeSet(result: Vars, e: LDMLKeyboard.LKUSet, sections: DependencySections): void { const { id } = e; From 5a5877ae7e64a10fd280c78bc5fffa3f62e204d6 Mon Sep 17 00:00:00 2001 From: "Dr Mark C. Sinclair" Date: Mon, 25 Mar 2024 17:20:49 +0000 Subject: [PATCH 056/170] chore(developer): add loadJsFile throws error if .js file is invalid test --- .../invalid-js-file/build/invalid_js_file.js | 1 + .../test/test-keyboard-info-compiler.ts | 27 ++++++++++++++++--- 2 files changed, 25 insertions(+), 3 deletions(-) create mode 100644 developer/src/kmc-keyboard-info/test/fixtures/invalid-js-file/build/invalid_js_file.js diff --git a/developer/src/kmc-keyboard-info/test/fixtures/invalid-js-file/build/invalid_js_file.js b/developer/src/kmc-keyboard-info/test/fixtures/invalid-js-file/build/invalid_js_file.js new file mode 100644 index 00000000000..816f909dfa8 --- /dev/null +++ b/developer/src/kmc-keyboard-info/test/fixtures/invalid-js-file/build/invalid_js_file.js @@ -0,0 +1 @@ +// This is a file used to test the loadJsFile throws error if .js file is invalid error (see keyboard-info-compiler.ts) \ No newline at end of file diff --git a/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts b/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts index 7cd1ea7a8e5..99f860d5b58 100644 --- a/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts +++ b/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts @@ -319,9 +319,9 @@ describe('keyboard-info-compiler', function () { }); it('check loadKmxFiles can handle two .kmx files', async function() { - const jsFilename = makePathToFixture('two-kmx', 'build', 'two-kmx.js'); - const kpsFilename = makePathToFixture('two-kmx', 'source', 'two-kmx.kps'); - const kmpFilename = makePathToFixture('two-kmx', 'build', 'two-kmx.kmp'); + const jsFilename = makePathToFixture('two-kmx', 'build', 'two_kmx.js'); + const kpsFilename = makePathToFixture('two-kmx', 'source', 'two_kmx.kps'); + const kmpFilename = makePathToFixture('two-kmx', 'build', 'two_kmx.kmp'); const sources = { kmpFilename, @@ -354,4 +354,25 @@ describe('keyboard-info-compiler', function () { assert.isNotNull(kmxFiles[0].data); assert.isNotNull(kmxFiles[1].data); }); + + it('check loadJsFile throws error if .js file is invalid', async function() { + const jsFilename = makePathToFixture('invalid-js-file', 'build', 'invalid_js_file.js'); + const kpsFilename = makePathToFixture('invalid-js-file', 'source', 'invalid_js_file.kps'); + const kmpFilename = makePathToFixture('invalid-js-file', 'build', 'invalid_js_file.kmp'); + + const sources = { + kmpFilename, + sourcePath: 'release/k/invalid-js-file', + kpsFilename, + jsFilename: jsFilename, + forPublishing: true, + }; + + const compiler = new KeyboardInfoCompiler(); + assert.isTrue(await compiler.init(callbacks, {sources})); + const origTextDecoderDecode = TextDecoder.prototype.decode; + TextDecoder.prototype.decode = () => { throw new TypeError(); } + assert.throws(() => compiler['loadJsFile'](jsFilename)); + TextDecoder.prototype.decode = origTextDecoderDecode; + }); }); From f773066fa0d80d4a23e9a9f40fb519dfe19bd47c Mon Sep 17 00:00:00 2001 From: "Steven R. Loomis" Date: Mon, 25 Mar 2024 12:46:55 -0500 Subject: [PATCH 057/170] fix(developer): store set variables in literal form, unescape in tran - update the tran compiler to call escapeStringForRegex() on set items being substituted into an expression --- common/web/types/src/kmx/kmx-plus.ts | 8 ++++---- common/web/types/src/util/util.ts | 7 +++++++ developer/src/kmc-ldml/src/compiler/tran.ts | 5 +++-- developer/src/kmc-ldml/src/compiler/vars.ts | 2 ++ 4 files changed, 16 insertions(+), 6 deletions(-) diff --git a/common/web/types/src/kmx/kmx-plus.ts b/common/web/types/src/kmx/kmx-plus.ts index adfdd917081..21b12732196 100644 --- a/common/web/types/src/kmx/kmx-plus.ts +++ b/common/web/types/src/kmx/kmx-plus.ts @@ -2,7 +2,7 @@ import { constants } from '@keymanapp/ldml-keyboard-constants'; import * as r from 'restructure'; import { ElementString } from './element-string.js'; import { ListItem } from './string-list.js'; -import { isOneChar, toOneChar, unescapeString } from '../util/util.js'; +import { isOneChar, toOneChar, unescapeString, escapeStringForRegex } from '../util/util.js'; import { KMXFile } from './kmx.js'; import { UnicodeSetParser, UnicodeSet } from '@keymanapp/common-types'; import { VariableParser } from '../ldml-keyboard/pattern-parser.js'; @@ -291,9 +291,9 @@ export class Vars extends Section { // try as set const set = Vars.findVariable(this.sets, id); if (set !== null) { - const items = set.rawItems; - const inner = items.join('|'); - // TODO-LDML: string substitution here, #11037 + const { items } = set; + const escapedStrings = items.map(v => escapeStringForRegex(v.value.value)); + const inner = escapedStrings.join('|'); return `(?:${inner})`; } diff --git a/common/web/types/src/util/util.ts b/common/web/types/src/util/util.ts index 13057bc3384..83e28dc21b1 100644 --- a/common/web/types/src/util/util.ts +++ b/common/web/types/src/util/util.ts @@ -148,6 +148,13 @@ function regexOne(hex: string): string { // re-escape as 16 or 32 bit code units return Array.from(unescaped).map(ch => escapeRegexCharIfSyntax(ch)).join(''); } +/** + * Escape a string (\uxxxx form) if there are any problematic codepoints + */ +export function escapeStringForRegex(s: string) : string { + return s.split('').map(ch => escapeRegexCharIfSyntax(ch)).join(''); +} + /** * Unescapes a string according to UTS#18§1.1, see * @param s escaped string diff --git a/developer/src/kmc-ldml/src/compiler/tran.ts b/developer/src/kmc-ldml/src/compiler/tran.ts index bf27fb2fea5..cc72bcb86b1 100644 --- a/developer/src/kmc-ldml/src/compiler/tran.ts +++ b/developer/src/kmc-ldml/src/compiler/tran.ts @@ -146,9 +146,10 @@ export abstract class TransformCompiler result.substituteMarkerString(v, false)); result.sets.push(new SetVarItem(id, cookedItems, sections, rawItems)); } From f2ad7d4e68b3e13424b3405f423835a28af99b30 Mon Sep 17 00:00:00 2001 From: "Steven R. Loomis" Date: Mon, 25 Mar 2024 14:52:32 -0500 Subject: [PATCH 058/170] fix(common): further improvement to string/set substitution - tran: check for \uXXXX earlier in the chain, because we emit this format further down - add test cases for syntax chars being used in variables Fixes: bug(core): assert when matching markers #11045 Fixes: bug(developer): escape on string substitution if syntax char #11037 --- common/web/types/src/kmx/kmx-plus.ts | 3 +- common/web/types/src/util/util.ts | 2 +- .../keyboards/k_007_transform_rgx-test.xml | 52 +++++++++++++++++++ .../ldml/keyboards/k_007_transform_rgx.xml | 17 +++++- developer/src/kmc-ldml/src/compiler/tran.ts | 12 +++-- 5 files changed, 78 insertions(+), 8 deletions(-) diff --git a/common/web/types/src/kmx/kmx-plus.ts b/common/web/types/src/kmx/kmx-plus.ts index 21b12732196..6e3c73d33c8 100644 --- a/common/web/types/src/kmx/kmx-plus.ts +++ b/common/web/types/src/kmx/kmx-plus.ts @@ -272,7 +272,7 @@ export class Vars extends Section { return v.value.value; // string value }); } - substituteStrings(str: string, sections: DependencySections): string { + substituteStrings(str: string, sections: DependencySections, forMatch?: boolean): string { if (!str) return str; return str.replaceAll(VariableParser.STRING_REFERENCE, (_entire, id) => { const val = this.findStringVariableValue(id); @@ -280,6 +280,7 @@ export class Vars extends Section { // Should have been caught during validation. throw Error(`Internal Error: reference to missing string variable ${id}`); } + if (forMatch) return escapeStringForRegex(val); return val; }); } diff --git a/common/web/types/src/util/util.ts b/common/web/types/src/util/util.ts index 83e28dc21b1..c6a6fa55820 100644 --- a/common/web/types/src/util/util.ts +++ b/common/web/types/src/util/util.ts @@ -127,7 +127,7 @@ export function escapeRegexChar(ch: string) { } /** chars that must be escaped: syntax, C0 + C1 controls */ -const REGEX_SYNTAX_CHAR = /^[\u0000-\u001F\u007F-\u009F{}\[\]\\?.^$*()/-]$/; +const REGEX_SYNTAX_CHAR = /^[\u0000-\u001F\u007F-\u009F{}\[\]\\?|.^$*()/-]$/; function escapeRegexCharIfSyntax(ch: string) { // escape if syntax or not valid diff --git a/core/tests/unit/ldml/keyboards/k_007_transform_rgx-test.xml b/core/tests/unit/ldml/keyboards/k_007_transform_rgx-test.xml index 80f3bbafabe..f4024275e8d 100644 --- a/core/tests/unit/ldml/keyboards/k_007_transform_rgx-test.xml +++ b/core/tests/unit/ldml/keyboards/k_007_transform_rgx-test.xml @@ -88,4 +88,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/core/tests/unit/ldml/keyboards/k_007_transform_rgx.xml b/core/tests/unit/ldml/keyboards/k_007_transform_rgx.xml index 18356b2c3e3..cc79b826de6 100644 --- a/core/tests/unit/ldml/keyboards/k_007_transform_rgx.xml +++ b/core/tests/unit/ldml/keyboards/k_007_transform_rgx.xml @@ -12,6 +12,9 @@ from https://github.com/unicode-org/cldr/blob/keyboard-preview/docs/ldml/tr35-ke + + + @@ -26,7 +29,7 @@ from https://github.com/unicode-org/cldr/blob/keyboard-preview/docs/ldml/tr35-ke - + @@ -34,6 +37,13 @@ from https://github.com/unicode-org/cldr/blob/keyboard-preview/docs/ldml/tr35-ke + + + + + + + @@ -45,6 +55,11 @@ from https://github.com/unicode-org/cldr/blob/keyboard-preview/docs/ldml/tr35-ke + + + + + diff --git a/developer/src/kmc-ldml/src/compiler/tran.ts b/developer/src/kmc-ldml/src/compiler/tran.ts index cc72bcb86b1..9d401ffbc83 100644 --- a/developer/src/kmc-ldml/src/compiler/tran.ts +++ b/developer/src/kmc-ldml/src/compiler/tran.ts @@ -139,7 +139,10 @@ export abstract class TransformCompiler Date: Mon, 25 Mar 2024 15:18:35 -0500 Subject: [PATCH 059/170] =?UTF-8?q?fix(developer):=20error=20if=20a=20rege?= =?UTF-8?q?x=20pattern=20matches=20the=20empty=20string=20=F0=9F=99=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - add message and check if the regex matches '' (empty string) and error if so - add test cases Fixes: bug(core): assert with vertical pipe #11062 --- developer/src/kmc-ldml/src/compiler/messages.ts | 5 ++++- developer/src/kmc-ldml/src/compiler/tran.ts | 7 ++++++- .../sections/tran/fail-matches-nothing-1.xml | 13 +++++++++++++ .../sections/tran/fail-matches-nothing-2.xml | 13 +++++++++++++ .../sections/tran/fail-matches-nothing-3.xml | 13 +++++++++++++ developer/src/kmc-ldml/test/test-tran.ts | 10 ++++++++++ 6 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 developer/src/kmc-ldml/test/fixtures/sections/tran/fail-matches-nothing-1.xml create mode 100644 developer/src/kmc-ldml/test/fixtures/sections/tran/fail-matches-nothing-2.xml create mode 100644 developer/src/kmc-ldml/test/fixtures/sections/tran/fail-matches-nothing-3.xml diff --git a/developer/src/kmc-ldml/src/compiler/messages.ts b/developer/src/kmc-ldml/src/compiler/messages.ts index a4f19f170a6..27c369f010c 100644 --- a/developer/src/kmc-ldml/src/compiler/messages.ts +++ b/developer/src/kmc-ldml/src/compiler/messages.ts @@ -49,7 +49,9 @@ export class CompilerMessages { static Error_GestureKeyNotFoundInKeyBag = (o:{keyId: string, parentKeyId: string, attribute: string}) => m(this.ERROR_GestureKeyNotFoundInKeyBag, `Key '${def(o.keyId)}' not found in key bag, referenced from other '${def(o.parentKeyId)}' in ${def(o.attribute)}`); - // 0x000C - available + static ERROR_TransformFromMatchesNothing = SevError | 0x000C; + static Error_TransformFromMatchesNothing = (o: { from: string }) => + m(this.ERROR_TransformFromMatchesNothing, `Invalid transfom from="${def(o.from)}": Matches an empty string.`); static ERROR_InvalidVersion = SevError | 0x000D; static Error_InvalidVersion = (o:{version: string}) => @@ -178,6 +180,7 @@ export class CompilerMessages { static ERROR_InvalidQuadEscape = SevError | 0x0030; static Error_InvalidQuadEscape = (o: { cp: number }) => m(this.ERROR_InvalidQuadEscape, `Invalid escape "\\u${util.hexQuad(o?.cp || 0)}", use "\\u{${def(o?.cp?.toString(16))}}" instead.`); + } diff --git a/developer/src/kmc-ldml/src/compiler/tran.ts b/developer/src/kmc-ldml/src/compiler/tran.ts index 9d401ffbc83..1f531223c07 100644 --- a/developer/src/kmc-ldml/src/compiler/tran.ts +++ b/developer/src/kmc-ldml/src/compiler/tran.ts @@ -174,7 +174,12 @@ export abstract class TransformCompiler + + + + + + + + + + + + diff --git a/developer/src/kmc-ldml/test/fixtures/sections/tran/fail-matches-nothing-2.xml b/developer/src/kmc-ldml/test/fixtures/sections/tran/fail-matches-nothing-2.xml new file mode 100644 index 00000000000..8b932fc579e --- /dev/null +++ b/developer/src/kmc-ldml/test/fixtures/sections/tran/fail-matches-nothing-2.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/developer/src/kmc-ldml/test/fixtures/sections/tran/fail-matches-nothing-3.xml b/developer/src/kmc-ldml/test/fixtures/sections/tran/fail-matches-nothing-3.xml new file mode 100644 index 00000000000..98ff0dc828f --- /dev/null +++ b/developer/src/kmc-ldml/test/fixtures/sections/tran/fail-matches-nothing-3.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/developer/src/kmc-ldml/test/test-tran.ts b/developer/src/kmc-ldml/test/test-tran.ts index 2ee82fd2d20..4e41b10c0f0 100644 --- a/developer/src/kmc-ldml/test/test-tran.ts +++ b/developer/src/kmc-ldml/test/test-tran.ts @@ -334,6 +334,16 @@ describe('tran', function () { CompilerMessages.Error_MissingStringVariable({ id: "missingstr" }), ], }, + // three cases that share the same error message + ...[1, 2, 3].map(n => ({ + subpath: `sections/tran/fail-matches-nothing-${n}.xml`, + errors: [ + { + code: CompilerMessages.ERROR_TransformFromMatchesNothing, + matchMessage: /.*/, + } + ], + })), // escaping { subpath: `sections/tran/tran-escape.xml`, From 54e59e47bccbda79bfe156e7a9a95383d748e971 Mon Sep 17 00:00:00 2001 From: "Steven R. Loomis" Date: Mon, 25 Mar 2024 15:57:44 -0500 Subject: [PATCH 060/170] fix(core): dx: don't choke on embedded non-ascii in ldml .xml test files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ldml_test_source had a trim() implementation that was causing asserts in std::isspace() which turned out to be due to negative integers being passed - The actual line that caused trouble was, quote, '«" />' --- core/tests/unit/ldml/ldml_test_source.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/core/tests/unit/ldml/ldml_test_source.cpp b/core/tests/unit/ldml/ldml_test_source.cpp index b638379bb6d..233b86a9f08 100644 --- a/core/tests/unit/ldml/ldml_test_source.cpp +++ b/core/tests/unit/ldml/ldml_test_source.cpp @@ -126,7 +126,9 @@ bool LdmlTestSource::get_expected_beep() const { // trim from start (in place) static inline void ltrim(std::string &s) { - s.erase(s.begin(), std::find_if(s.begin(), s.end(), [](int ch) { return !std::isspace(ch); })); + // std::isspace chokes on negative (!) ints here, so spare it the trouble if ch is negative + // (possibly unsigned char to integer cast?) + s.erase(s.begin(), std::find_if(s.begin(), s.end(), [](int ch) { return (ch < 0) || !std::isspace(ch); })); } // trim from end (in place) From 18c6ecfc088e4044affa313b3c3062c9a3f040ca Mon Sep 17 00:00:00 2001 From: "Steven R. Loomis" Date: Mon, 25 Mar 2024 16:45:01 -0500 Subject: [PATCH 061/170] =?UTF-8?q?fix(core):=20fix=202=20marker=20cases?= =?UTF-8?q?=20in=20ldml=5Fevent=5Fstate::emit=5Fdifference=20=F0=9F=99=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - handle the case where old and new context strings are the same (i.e. no work to do). - fix pointer arithmetic error in #10356 - the special case where one marker is being replaced by another. The named regression test didn't actually hit this case. Fixes: bug(core): 'string too long' #11057 --- core/src/ldml/ldml_processor.cpp | 10 +- .../keyboards/k_212_marker_11057-test.xml | 23 ++ .../ldml/keyboards/k_212_marker_11057.xml | 30 +++ .../ldml/keyboards/k_213_marker_11057.xml | 225 ++++++++++++++++++ core/tests/unit/ldml/keyboards/meson.build | 5 +- 5 files changed, 288 insertions(+), 5 deletions(-) create mode 100644 core/tests/unit/ldml/keyboards/k_212_marker_11057-test.xml create mode 100644 core/tests/unit/ldml/keyboards/k_212_marker_11057.xml create mode 100644 core/tests/unit/ldml/keyboards/k_213_marker_11057.xml diff --git a/core/src/ldml/ldml_processor.cpp b/core/src/ldml/ldml_processor.cpp index 166b8002b86..5ec0b900d57 100644 --- a/core/src/ldml/ldml_processor.cpp +++ b/core/src/ldml/ldml_processor.cpp @@ -377,6 +377,10 @@ ldml_event_state::emit_difference(const std::u32string &old_ctxt, const std::u32 // So the BBBBB needs to be removed and then CCC added. auto ctxt_prefix = mismatch(old_ctxt.begin(), old_ctxt.end(), new_ctxt.begin(), new_ctxt.end()); + if(ctxt_prefix.first == old_ctxt.end() && ctxt_prefix.second == new_ctxt.end()) { + return; // No mismatch. We can just exit, there's nothing to do. + } + // handle a special case where we're simply changing from one marker to another. // Example: // 0. old_ctxtstr_changed ends with … U+FFFF U+0008 | U+0001 … @@ -390,14 +394,14 @@ ldml_event_state::emit_difference(const std::u32string &old_ctxt, const std::u32 // marker change. // We can detect this because the unchanged_prefix will end with u+FFFF U+0008 // - // Oh, and yes, test case 'regex-test-8a-0' hits this. + // k_212* and k_213* hit this case. std::u32string common_prefix(old_ctxt.begin(), ctxt_prefix.first); if (common_prefix.length() >= 2) { auto iter = common_prefix.rbegin(); if (*(iter++) == LDML_MARKER_CODE && *(iter++) == UC_SENTINEL) { // adjust the iterator so that the "U+FFFF U+0008" is not a part of the common prefix. - ctxt_prefix.first -= 2; - ctxt_prefix.second += 2; + ctxt_prefix.first -= 2; // backup the 'mismatch' point to before the FFFF + ctxt_prefix.second -= 2; // backup the 'mismatch' point to before the FFFF // Now, old_ctxtstr_changed and new_ctxtstr_changed will start with U+FFFF U+0008 … } } diff --git a/core/tests/unit/ldml/keyboards/k_212_marker_11057-test.xml b/core/tests/unit/ldml/keyboards/k_212_marker_11057-test.xml new file mode 100644 index 00000000000..92e2abbf3f0 --- /dev/null +++ b/core/tests/unit/ldml/keyboards/k_212_marker_11057-test.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/core/tests/unit/ldml/keyboards/k_212_marker_11057.xml b/core/tests/unit/ldml/keyboards/k_212_marker_11057.xml new file mode 100644 index 00000000000..7bbd2bb94ac --- /dev/null +++ b/core/tests/unit/ldml/keyboards/k_212_marker_11057.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/core/tests/unit/ldml/keyboards/k_213_marker_11057.xml b/core/tests/unit/ldml/keyboards/k_213_marker_11057.xml new file mode 100644 index 00000000000..f137e8d2a48 --- /dev/null +++ b/core/tests/unit/ldml/keyboards/k_213_marker_11057.xml @@ -0,0 +1,225 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/core/tests/unit/ldml/keyboards/meson.build b/core/tests/unit/ldml/keyboards/meson.build index ca047a67bc6..4e0b35803be 100644 --- a/core/tests/unit/ldml/keyboards/meson.build +++ b/core/tests/unit/ldml/keyboards/meson.build @@ -28,10 +28,10 @@ tests_without_testdata = [ 'k_100_keytest', 'k_101_keytest', 'k_102_keytest', - + 'k_213_marker_11057', ] -# These tests have a k_001_tiny-test.xml file as well. +# These tests have a *-test.xml file as well. tests_with_testdata = [ 'ldml_test', 'k_001_tiny', @@ -44,6 +44,7 @@ tests_with_testdata = [ 'k_201_reorder_esk', 'k_210_marker', 'k_211_marker_escape', + 'k_212_marker_11057', ] tests = tests_without_testdata From 72de9bed9fd078916800ffbb885e317b9bea5568 Mon Sep 17 00:00:00 2001 From: "Steven R. Loomis" Date: Mon, 25 Mar 2024 17:48:31 -0500 Subject: [PATCH 062/170] =?UTF-8?q?feat(developer):=20scaffolding=20for=20?= =?UTF-8?q?comma=20in=20modifiers=20=F0=9F=99=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - modifiers is a list, not a single entity - test cases For: feat(developer) ldml: comma in modifiers #11040 --- developer/src/kmc-ldml/src/compiler/keys.ts | 15 ++++---- developer/src/kmc-ldml/src/compiler/layr.ts | 35 ++++++++++-------- developer/src/kmc-ldml/src/util/util.ts | 13 ++++--- .../fixtures/sections/keys/many-modifiers.xml | 23 ++++++++++++ developer/src/kmc-ldml/test/test-keys.ts | 9 +++++ developer/src/kmc-ldml/test/test-layr.ts | 8 +++++ developer/src/kmc-ldml/test/test-utils.ts | 36 ++++++++++--------- 7 files changed, 98 insertions(+), 41 deletions(-) create mode 100644 developer/src/kmc-ldml/test/fixtures/sections/keys/many-modifiers.xml diff --git a/developer/src/kmc-ldml/src/compiler/keys.ts b/developer/src/kmc-ldml/src/compiler/keys.ts index 15cbc2cd98e..7d97df41265 100644 --- a/developer/src/kmc-ldml/src/compiler/keys.ts +++ b/developer/src/kmc-ldml/src/compiler/keys.ts @@ -487,7 +487,7 @@ export class KeysCompiler extends SectionCompiler { sect: Keys, hardware: string ): Keys { - const mod = translateLayerAttrToModifier(layer); + const mods = translateLayerAttrToModifier(layer); const keymap = this.getKeymapFromForm(hardware); // Iterate over rows (y) and cols (x) of the scancodes table. @@ -513,11 +513,14 @@ export class KeysCompiler extends SectionCompiler { if (x < keys.length) { key = keys[x]; } - sect.kmap.push({ - vkey, - mod, - key, // key id, to be changed into key index at finalization - }); + // push every combination + for (const mod of mods) { + sect.kmap.push({ + vkey, + mod, + key, // key id, to be changed into key index at finalization + }); + } } } return sect; diff --git a/developer/src/kmc-ldml/src/compiler/layr.ts b/developer/src/kmc-ldml/src/compiler/layr.ts index 6b1aa6d0285..8056144b25b 100644 --- a/developer/src/kmc-ldml/src/compiler/layr.ts +++ b/developer/src/kmc-ldml/src/compiler/layr.ts @@ -7,7 +7,6 @@ import { translateLayerAttrToModifier, validModifier } from '../util/util.js'; import DependencySections = KMXPlus.DependencySections; import Layr = KMXPlus.Layr; -import LayrEntry = KMXPlus.LayrEntry; import LayrList = KMXPlus.LayrList; import LayrRow = KMXPlus.LayrRow; @@ -41,7 +40,7 @@ export class LayrCompiler extends SectionCompiler { const { modifiers, id } = layer; totalLayerCount++; if (!validModifier(modifiers)) { - this.callbacks.reportMessage(CompilerMessages.Error_InvalidModifier({ modifiers, layer: id })); + this.callbacks.reportMessage(CompilerMessages.Error_InvalidModifier({ modifiers, layer: id || '' })); valid = false; } }); @@ -60,22 +59,28 @@ export class LayrCompiler extends SectionCompiler { sect.lists = this.keyboard3.layers.map((layers) => { const hardware = sections.strs.allocString(layers.formId); // Already validated in validate + const layerEntries = []; + for (const layer of layers.layer) { + const rows = layer.row.map((row) => { + const erow: LayrRow = { + keys: row.keys.split(' ').map((id) => sections.strs.allocString(id)), + }; + return erow; + }); + const mods = translateLayerAttrToModifier(layer); + // push a layer entry for each modifier set + for (const mod of mods) { + layerEntries.push({ + id: sections.strs.allocString(layer.id), + mod, + rows, + }); + } + } const list: LayrList = { hardware, minDeviceWidth: layers.minDeviceWidth || 0, - layers: layers.layer.map((layer) => { - const entry: LayrEntry = { - id: sections.strs.allocString(layer.id), - mod: translateLayerAttrToModifier(layer), - rows: layer.row.map((row) => { - const erow: LayrRow = { - keys: row.keys.split(' ').map((id) => sections.strs.allocString(id)), - }; - return erow; - }), - }; - return entry; - }), + layers: layerEntries, }; return list; }); diff --git a/developer/src/kmc-ldml/src/util/util.ts b/developer/src/kmc-ldml/src/util/util.ts index a3f43b1b6ee..1080b1299cd 100644 --- a/developer/src/kmc-ldml/src/util/util.ts +++ b/developer/src/kmc-ldml/src/util/util.ts @@ -156,14 +156,19 @@ export function verifyValidAndUnique( /** * Determine modifier from layer info * @param layer layer obj - * @returns modifier + * @returns modifier array */ -export function translateLayerAttrToModifier(layer: LDMLKeyboard.LKLayer) : number { +export function translateLayerAttrToModifier(layer: LDMLKeyboard.LKLayer) : number[] { const { modifiers } = layer; + if (!modifiers) return [constants.keys_mod_none]; + return modifiers.split(',').map(m => translateModifierSubsetToLayer(m)).sort(); +} + +function translateModifierSubsetToLayer(modifiers: string) : number { + // TODO-LDML: Default #11072 if (modifiers) { - // TODO-LDML if (modifiers.indexOf(',') !== -1) { - throw Error(`TODO-LDML #9838: ”,” in modifiers not supported yet.`); + throw Error(`This function only takes a single subset of the modifiers`); } let mod = constants.keys_mod_none; for (let str of modifiers.split(' ')) { diff --git a/developer/src/kmc-ldml/test/fixtures/sections/keys/many-modifiers.xml b/developer/src/kmc-ldml/test/fixtures/sections/keys/many-modifiers.xml new file mode 100644 index 00000000000..83c9d86b357 --- /dev/null +++ b/developer/src/kmc-ldml/test/fixtures/sections/keys/many-modifiers.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + diff --git a/developer/src/kmc-ldml/test/test-keys.ts b/developer/src/kmc-ldml/test/test-keys.ts index a2906d0e58d..729faa31d26 100644 --- a/developer/src/kmc-ldml/test/test-keys.ts +++ b/developer/src/kmc-ldml/test/test-keys.ts @@ -403,6 +403,15 @@ describe('keys.kmap', function () { CompilerMessages.Error_MissingStringVariable({id: "varsok"}), ], }, + // modifiers test + { + subpath: 'sections/keys/many-modifiers.xml', + callback(sect) { + const keys = sect; + assert.ok(keys); + console.dir(keys); + }, + }, ], keysDependencies); it('should reject layouts with too many hardware rows', async function() { diff --git a/developer/src/kmc-ldml/test/test-layr.ts b/developer/src/kmc-ldml/test/test-layr.ts index 04bf461aa40..2c47595a89c 100644 --- a/developer/src/kmc-ldml/test/test-layr.ts +++ b/developer/src/kmc-ldml/test/test-layr.ts @@ -110,5 +110,13 @@ describe('layr', function () { subpath: 'sections/layr/invalid-missing-layer.xml', errors: [CompilerMessages.Error_MustBeAtLeastOneLayerElement()], }, + { + subpath: 'sections/keys/many-modifiers.xml', + callback(sect) { + const layr = sect; + assert.ok(layr); + console.dir(layr); + }, + }, ]); }); diff --git a/developer/src/kmc-ldml/test/test-utils.ts b/developer/src/kmc-ldml/test/test-utils.ts index 015af09e1d7..fcf1f786839 100644 --- a/developer/src/kmc-ldml/test/test-utils.ts +++ b/developer/src/kmc-ldml/test/test-utils.ts @@ -174,29 +174,33 @@ describe('test of util/util.ts', () => { }); describe('translateLayerAttrToModifier', () => { it('should map from layer info to modifier number', () => { - assert.equal(translateLayerAttrToModifier({ + assert.sameDeepMembers(translateLayerAttrToModifier({ id: 'base', - }), constants.keys_mod_none); - assert.equal(translateLayerAttrToModifier({ + }), [constants.keys_mod_none]); + assert.sameDeepMembers(translateLayerAttrToModifier({ id: 'base', modifiers: '', - }), constants.keys_mod_none); - assert.equal(translateLayerAttrToModifier({ + }), [constants.keys_mod_none]); + assert.sameDeepMembers(translateLayerAttrToModifier({ id: 'base', modifiers: 'none', - }), constants.keys_mod_none); - assert.equal(translateLayerAttrToModifier({ + }), [constants.keys_mod_none]); + assert.sameDeepMembers(translateLayerAttrToModifier({ id: 'shift', modifiers: 'shift', - }), constants.keys_mod_shift); - assert.equal(translateLayerAttrToModifier({ + }), [constants.keys_mod_shift]); + assert.sameDeepMembers(translateLayerAttrToModifier({ id: 'shift', modifiers: 'shift', - }), constants.keys_mod_shift); - assert.equal(translateLayerAttrToModifier({ + }), [constants.keys_mod_shift]); + assert.sameDeepMembers(translateLayerAttrToModifier({ + id: 'shiftOrCtrl', + modifiers: 'shift,ctrlL', + }), [constants.keys_mod_shift,constants.keys_mod_ctrlL]); + assert.sameDeepMembers(translateLayerAttrToModifier({ id: 'altshift', modifiers: 'alt shift', - }), constants.keys_mod_alt | constants.keys_mod_shift); + }), [constants.keys_mod_alt | constants.keys_mod_shift]); }); it('should round trip each possible modifier', () => { for(let str of constants.keys_mod_map.keys()) { @@ -204,8 +208,8 @@ describe('test of util/util.ts', () => { id: str, modifiers: `${str}`, }; - assert.equal(translateLayerAttrToModifier(layer), - constants.keys_mod_map.get(str), str); + assert.sameDeepMembers(translateLayerAttrToModifier(layer), + [constants.keys_mod_map.get(str)], str); } }); it('should round trip each possible modifier with altL', () => { @@ -214,8 +218,8 @@ describe('test of util/util.ts', () => { id: str, modifiers: `${str} altL`, }; - assert.equal(translateLayerAttrToModifier(layer), - constants.keys_mod_map.get(str) | constants.keys_mod_altL, str); + assert.sameDeepMembers(translateLayerAttrToModifier(layer), + [constants.keys_mod_map.get(str) | constants.keys_mod_altL], str); } }); }); From d6e080dd0dacd3e819524cf0bff7080a891aa52d Mon Sep 17 00:00:00 2001 From: "Steven R. Loomis" Date: Mon, 25 Mar 2024 18:49:13 -0500 Subject: [PATCH 063/170] =?UTF-8?q?feat(developer):=20implement=20comma=20?= =?UTF-8?q?in=20modifiers=20=F0=9F=99=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - improve modifier parsing - test cases for keys and layr For: feat(developer) ldml: comma in modifiers #11040 --- developer/src/kmc-ldml/src/util/util.ts | 4 ++-- .../test/fixtures/sections/keys/many-modifiers.xml | 2 +- developer/src/kmc-ldml/test/test-keys.ts | 12 +++++++++++- developer/src/kmc-ldml/test/test-layr.ts | 13 ++++++++++++- 4 files changed, 26 insertions(+), 5 deletions(-) diff --git a/developer/src/kmc-ldml/src/util/util.ts b/developer/src/kmc-ldml/src/util/util.ts index 1080b1299cd..4536e6fc7bd 100644 --- a/developer/src/kmc-ldml/src/util/util.ts +++ b/developer/src/kmc-ldml/src/util/util.ts @@ -188,8 +188,8 @@ function translateModifierSubsetToLayer(modifiers: string) : number { export function validModifier(modifier?: string) : boolean { if (!modifier) return true; // valid to have no modifier, == none // TODO-LDML: enforce illegal combinations per spec. - for (let sub of modifier.split(',')) { - for (let str of sub.split(' ')) { + for (let sub of modifier.trim().split(',')) { + for (let str of sub.trim().split(' ')) { if (!constants.keys_mod_map.has(str)) { return false; } diff --git a/developer/src/kmc-ldml/test/fixtures/sections/keys/many-modifiers.xml b/developer/src/kmc-ldml/test/fixtures/sections/keys/many-modifiers.xml index 83c9d86b357..5c380cd7370 100644 --- a/developer/src/kmc-ldml/test/fixtures/sections/keys/many-modifiers.xml +++ b/developer/src/kmc-ldml/test/fixtures/sections/keys/many-modifiers.xml @@ -10,7 +10,7 @@ - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/core/tests/unit/ldml/keyboards/meson.build b/core/tests/unit/ldml/keyboards/meson.build index 4e0b35803be..e06c64a8bd3 100644 --- a/core/tests/unit/ldml/keyboards/meson.build +++ b/core/tests/unit/ldml/keyboards/meson.build @@ -28,7 +28,6 @@ tests_without_testdata = [ 'k_100_keytest', 'k_101_keytest', 'k_102_keytest', - 'k_213_marker_11057', ] # These tests have a *-test.xml file as well. diff --git a/core/tests/unit/ldml/ldml_test_source.cpp b/core/tests/unit/ldml/ldml_test_source.cpp index 6753586f8af..b638379bb6d 100644 --- a/core/tests/unit/ldml/ldml_test_source.cpp +++ b/core/tests/unit/ldml/ldml_test_source.cpp @@ -126,10 +126,7 @@ bool LdmlTestSource::get_expected_beep() const { // trim from start (in place) static inline void ltrim(std::string &s) { - s.erase(s.begin(), std::find_if(s.begin(), s.end(), [](char ch) { - // 's' is a string of utf-8 code units, so we consider all bytes with the high bit set as non-space. - return ((unsigned int)ch & 0x80) || !std::isspace((int)ch); - })); + s.erase(s.begin(), std::find_if(s.begin(), s.end(), [](int ch) { return !std::isspace(ch); })); } // trim from end (in place) From 8b5723028c3d5be645f7d36262fa50c2aa999fe8 Mon Sep 17 00:00:00 2001 From: "Steven R. Loomis" Date: Wed, 27 Mar 2024 08:18:03 -0500 Subject: [PATCH 072/170] chore(core): optimize ldml_event_state::emit_difference() when no difference #11057 --- core/src/ldml/ldml_processor.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/core/src/ldml/ldml_processor.cpp b/core/src/ldml/ldml_processor.cpp index eaac7d358f9..9667a2275af 100644 --- a/core/src/ldml/ldml_processor.cpp +++ b/core/src/ldml/ldml_processor.cpp @@ -377,6 +377,11 @@ ldml_event_state::emit_difference(const std::u32string &old_ctxt, const std::u32 // So the BBBBB needs to be removed and then CCC added. auto ctxt_prefix = mismatch(old_ctxt.begin(), old_ctxt.end(), new_ctxt.begin(), new_ctxt.end()); + // is the 'mismatch' at the end (i.e., no mismatch)? + if(ctxt_prefix.first == old_ctxt.end() && ctxt_prefix.second == new_ctxt.end()) { + return; // Optimization: We can just exit, there's nothing to do. + } + // handle a special case where we're simply changing from one marker to another. // Example: // 0. old_ctxtstr_changed ends with … U+FFFF U+0008 | U+0001 … From 193d7d077ce6a8de77376578896c417b1f6c3248 Mon Sep 17 00:00:00 2001 From: "Steven R. Loomis" Date: Wed, 27 Mar 2024 08:25:52 -0500 Subject: [PATCH 073/170] fix(developer): fix an additional issue on illegal UnicodeSets - improve comments yet again, as they didn't match the code. --- developer/src/kmc-kmn/src/compiler/compiler.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/developer/src/kmc-kmn/src/compiler/compiler.ts b/developer/src/kmc-kmn/src/compiler/compiler.ts index 8e3f215fc4c..a7b9a73c280 100644 --- a/developer/src/kmc-kmn/src/compiler/compiler.ts +++ b/developer/src/kmc-kmn/src/compiler/compiler.ts @@ -562,8 +562,8 @@ export class KmnCompiler implements KeymanCompiler, UnicodeSetParser { // fix \u1234 pattern format pattern = KmnCompiler.fixNewPattern(pattern); const rc = Module.kmcmp_parseUnicodeSet(pattern, buf, rangeCount * 2); - /** If <= 0: error return code. If positive: it's a range count */ if (rc >= 0) { + // If >= 0: it's a range count (which could be zero, an empty set). const ranges = []; const startu = (buf / Module.HEAPU32.BYTES_PER_ELEMENT); for (let i = 0; i < rc; i++) { @@ -574,6 +574,7 @@ export class KmnCompiler implements KeymanCompiler, UnicodeSetParser { this.wasmExports.free(buf); return new UnicodeSet(pattern, ranges); } else { + // rc is negative: it's an error code. this.wasmExports.free(buf); // translate error code into callback this.callbacks.reportMessage(getUnicodeSetError(rc)); From 0bf30d3ef9dcbebb7f60edded3e137237e1c4d47 Mon Sep 17 00:00:00 2001 From: Eberhard Beilharz Date: Wed, 27 Mar 2024 17:14:55 +0100 Subject: [PATCH 074/170] chore(linux): Update debian changelog --- linux/debian/changelog | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/linux/debian/changelog b/linux/debian/changelog index 562d3c67db5..d8fa1d681f1 100644 --- a/linux/debian/changelog +++ b/linux/debian/changelog @@ -1,8 +1,10 @@ -keyman (17.0.279-2) UNRELEASED; urgency=medium +keyman (17.0.295-1) unstable; urgency=medium * Remove ibus-keyman.post{inst,rm} (closes: #1034040) + * New upstream release. + * Re-release to Debian - -- Eberhard Beilharz Tue, 05 Mar 2024 10:41:54 +0100 + -- Eberhard Beilharz Wed, 27 Mar 2024 17:14:32 +0100 keyman (17.0.279-1) unstable; urgency=medium From 3f42b0b831dae6f8c97850f701834fe5f1012a51 Mon Sep 17 00:00:00 2001 From: "Steven R. Loomis" Date: Wed, 27 Mar 2024 19:13:01 -0500 Subject: [PATCH 075/170] fix(core): fix actions_normalize() use of UnicodeString - output[0] was used as a boolean, but needed to check output.isEmpty() instead - dead store to actions.code_points_to_delete For: #11067 parts 1 and 2 --- core/src/actions_normalize.cpp | 29 +++++++++++++---------------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/core/src/actions_normalize.cpp b/core/src/actions_normalize.cpp index a2f3db5433a..820694294d9 100644 --- a/core/src/actions_normalize.cpp +++ b/core/src/actions_normalize.cpp @@ -119,25 +119,22 @@ bool km::core::actions_normalize( normalization in our output, we now need to look for a normalization boundary prior to the intersection of the cached_context and the output. */ + if(!output.isEmpty()) { + while(n > 0 && !nfd->hasBoundaryBefore(output[0])) { + // The output may interact with the context further in normalization. We + // need to copy characters back further until we reach a normalization + // boundary. - while(n > 0 && output[0] && !nfd->hasBoundaryBefore(output[0])) { - // The output may interact with the context further in normalization. We - // need to copy characters back further until we reach a normalization - // boundary. + // Remove last code point from the context ... - // Remove last code point from the context ... + n = cached_context_string.moveIndex32(n, -1); + UChar32 chr = cached_context_string.char32At(n); + cached_context_string.remove(n); - n = cached_context_string.moveIndex32(n, -1); - UChar32 chr = cached_context_string.char32At(n); - cached_context_string.remove(n); + // And prepend it to the output ... - // And prepend it to the output ... - - output.insert(0, chr); - - // And finally remember that we now need to delete an additional NFD codepoint - - actions.code_points_to_delete++; + output.insert(0, chr); + } } /* @@ -319,4 +316,4 @@ bool km::core::actions_update_app_context_nfu( delete [] items; return status == KM_CORE_STATUS_OK; -} \ No newline at end of file +} From ca34b5f00fe697363975deb02827d54093ea8eb7 Mon Sep 17 00:00:00 2001 From: "Steven R. Loomis" Date: Wed, 27 Mar 2024 19:37:08 -0500 Subject: [PATCH 076/170] fix(core): fix actions_normalize() UChar32 calculation - UnicodeString is UTF-16, but can be used with UTF-32 boundaries. - fix calculation of code_points_to_delete Fixes: #11067 --- core/src/actions_normalize.cpp | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/core/src/actions_normalize.cpp b/core/src/actions_normalize.cpp index 820694294d9..3f2206f7997 100644 --- a/core/src/actions_normalize.cpp +++ b/core/src/actions_normalize.cpp @@ -141,14 +141,14 @@ bool km::core::actions_normalize( At this point, our output and cached_context are coherent and normalization will be complete at the edit boundary. - Now, we need to adjust the delete_back to match the number of characters + Now, we need to adjust the delete_back to match the number of code units that must actually be deleted from the applications's NFU context - To adjust, we remove one character at a time from the app_context until + To adjust, we remove one code unit at a time from the app_context until its normalized form matches the cached_context normalized form. */ - while(app_context_string.length()) { + while(app_context_string.countChar32()) { icu::UnicodeString app_context_nfd; nfd->normalize(app_context_string, app_context_nfd, icu_status); assert(U_SUCCESS(icu_status)); @@ -160,7 +160,13 @@ bool km::core::actions_normalize( if(app_context_nfd.compare(cached_context_string) == 0) { break; } - app_context_string.remove(app_context_string.length()-1); + + // remove the last UChar32 + int32_t lastUChar32 = app_context_string.length()-1; + // adjust pointer to get the entire char (i.e. so we don't slice a non-BMP char) + lastUChar32 = app_context_string.getChar32Start(lastUChar32); + // remove the UChar32 (1 or 2 code units) + app_context_string.remove(lastUChar32); nfu_to_delete++; } From 677b249b6f2ac39460d167a46dc589a37c0ac7b1 Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Thu, 28 Mar 2024 10:29:34 +0700 Subject: [PATCH 077/170] change(web): keyboard swap keeps old kbd active until new kbd is ready --- .../engine/osk/src/views/floatingOskView.ts | 1 + web/src/engine/osk/src/views/oskView.ts | 70 ++++++++----------- 2 files changed, 29 insertions(+), 42 deletions(-) diff --git a/web/src/engine/osk/src/views/floatingOskView.ts b/web/src/engine/osk/src/views/floatingOskView.ts index 2399eefe258..7748d463b83 100644 --- a/web/src/engine/osk/src/views/floatingOskView.ts +++ b/web/src/engine/osk/src/views/floatingOskView.ts @@ -64,6 +64,7 @@ export default class FloatingOSKView extends OSKView { this.resizeBar.on('showbuild', () => this.emit('showbuild')); this.headerView = this.titleBar; + this._Box.insertBefore(this.headerView.element, this._Box.firstChild); const onListenedEvent = (eventName: keyof EventMap | keyof LegacyOSKEventMap) => { // As the following title bar buttons (for desktop / FloatingOSKView) do nothing unless a site diff --git a/web/src/engine/osk/src/views/oskView.ts b/web/src/engine/osk/src/views/oskView.ts index 4f0d6c75044..2af932827cf 100644 --- a/web/src/engine/osk/src/views/oskView.ts +++ b/web/src/engine/osk/src/views/oskView.ts @@ -233,10 +233,22 @@ export default abstract class OSKView // Initializes the two constant OSKComponentView fields. this.bannerView = new BannerView(); this.bannerView.events.on('bannerchange', () => this.refreshLayout()); + this._Box.appendChild(this.bannerView.element); this._bannerController = new BannerController(this.bannerView, this.hostDevice, this.config.predictionContextManager); - this.keyboardView = null; + this.keyboardView = this._GenerateKeyboardView(null, null); + this._Box.appendChild(this.keyboardView.element); + + // Install the default OSK stylesheets - but don't have it managed by the keyboard-specific stylesheet manager. + // We wish to maintain kmwosk.css whenever keyboard-specific styles are reset/removed. + // Temp-hack: embedded products prefer their stylesheet, etc linkages without the /osk path component. + const resourcePath = getResourcePath(this.config); + + for(let sheetFile of OSKView.STYLESHEET_FILES) { + const sheetHref = `${resourcePath}${sheetFile}`; + this.uiStyleSheetManager.linkExternalSheet(sheetHref); + } this.setBaseMouseEventListeners(); if(this.hostDevice.touchable) { @@ -700,52 +712,30 @@ export default abstract class OSKView private loadActiveKeyboard() { this.setBoxStyling(); - - // Do not erase / 'shutdown' the banner-controller; we simply re-use its elements. - if(this.vkbd) { - this.vkbd.shutdown(); - } - this.keyboardView = null; this.needsLayout = true; - // Instantly resets the OSK container, erasing / delinking the previously-loaded keyboard. - this._Box.innerHTML = ''; - - // Since we cleared all inner HTML, that means we cleared the stylesheets, too. - this.uiStyleSheetManager.unlinkAll(); - this.kbdStyleSheetManager.unlinkAll(); - - // Install the default OSK stylesheets - but don't have it managed by the keyboard-specific stylesheet manager. - // We wish to maintain kmwosk.css whenever keyboard-specific styles are reset/removed. - // Temp-hack: embedded products prefer their stylesheet, etc linkages without the /osk path component. - const resourcePath = getResourcePath(this.config); - - for(let sheetFile of OSKView.STYLESHEET_FILES) { - const sheetHref = `${resourcePath}${sheetFile}`; - this.uiStyleSheetManager.linkExternalSheet(sheetHref); - } - - // Any event-cancelers would go here, after the innerHTML reset. - - // Add header element to OSK only for desktop browsers - if(this.headerView) { - this._Box.appendChild(this.headerView.element); - } + this.bannerController?.configureForKeyboard(this.keyboardData?.keyboard, this.keyboardData?.metadata); - // Add suggestion banner bar to OSK - this._Box.appendChild(this.banner.element); + // Save references to the old kbd & its styles for shutdown after replacement. + const oldKbd = this.keyboardView; + const oldKbdStyleManager = this.kbdStyleSheetManager; - this.bannerController?.configureForKeyboard(this.keyboardData?.keyboard, this.keyboardData?.metadata); + // Create new ones for the new, incoming kbd. + this.kbdStyleSheetManager = new StylesheetManager(this._Box, this.config.doCacheBusting || false); + const kbdView = this.keyboardView = this._GenerateKeyboardView(this.keyboardData?.keyboard, this.keyboardData?.metadata); - let kbdView: KeyboardView = this.keyboardView = this._GenerateKeyboardView(this.keyboardData?.keyboard, this.keyboardData?.metadata); - this._Box.appendChild(kbdView.element); + // Perform the replacement. + this._Box.replaceChild(kbdView.element, oldKbd.element); kbdView.postInsert(); - // Add footer element to OSK only for desktop browsers - if(this.footerView) { - this._Box.appendChild(this.footerView.element); + // Now that the swap has occurred, it's safe to shutdown the old VisualKeyboard and any related stylesheets. + if(oldKbd instanceof VisualKeyboard) { + oldKbd.shutdown(); } + oldKbdStyleManager.unlinkAll(); + // END: construction of the actual internal layout for the overall OSK + // Footer element management is handled within FloatingOSKView. this.banner.appendStyles(); @@ -770,10 +760,6 @@ export default abstract class OSKView private _GenerateKeyboardView(keyboard: Keyboard, keyboardMetadata: KeyboardProperties): KeyboardView { let device = this.targetDevice; - if(this.vkbd) { - this.vkbd.shutdown(); - } - this._Box.className = ""; // Case 1: since we hide the system keyboard on touch devices, we need From 76eeb9c0d4c5415d6752d102e3175c4c01a9c183 Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Thu, 28 Mar 2024 10:31:46 +0700 Subject: [PATCH 078/170] change(web): timing of banner configuration --- web/src/engine/osk/src/views/oskView.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/web/src/engine/osk/src/views/oskView.ts b/web/src/engine/osk/src/views/oskView.ts index 2af932827cf..760a664e123 100644 --- a/web/src/engine/osk/src/views/oskView.ts +++ b/web/src/engine/osk/src/views/oskView.ts @@ -713,9 +713,6 @@ export default abstract class OSKView private loadActiveKeyboard() { this.setBoxStyling(); this.needsLayout = true; - - this.bannerController?.configureForKeyboard(this.keyboardData?.keyboard, this.keyboardData?.metadata); - // Save references to the old kbd & its styles for shutdown after replacement. const oldKbd = this.keyboardView; const oldKbdStyleManager = this.kbdStyleSheetManager; @@ -727,6 +724,7 @@ export default abstract class OSKView // Perform the replacement. this._Box.replaceChild(kbdView.element, oldKbd.element); kbdView.postInsert(); + this.bannerController?.configureForKeyboard(this.keyboardData?.keyboard, this.keyboardData?.metadata); // Now that the swap has occurred, it's safe to shutdown the old VisualKeyboard and any related stylesheets. if(oldKbd instanceof VisualKeyboard) { From 9e369227b26b546480779b94b3cb93ba0657ec7f Mon Sep 17 00:00:00 2001 From: Marc Durdin Date: Thu, 28 Mar 2024 10:42:19 +0700 Subject: [PATCH 079/170] fix(developer): prevent error when scrolling touch layout editor with no selected key Fixes #11107. --- developer/src/tike/xml/layoutbuilder/builder.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/developer/src/tike/xml/layoutbuilder/builder.js b/developer/src/tike/xml/layoutbuilder/builder.js index 652baacdf8b..3a8caef1f98 100644 --- a/developer/src/tike/xml/layoutbuilder/builder.js +++ b/developer/src/tike/xml/layoutbuilder/builder.js @@ -18,8 +18,8 @@ $(function() { this.saveSelection = function() { let key = builder.selectedKey(), subKey = builder.selectedSubKey(); return { - id: key ? $(key).data('id') : null, - subId: subKey ? $(subKey).data('id') : null + id: key.length ? $(key).data('id') : null, + subId: subKey.length ? $(subKey).data('id') : null }; } @@ -971,7 +971,7 @@ $(function() { $('#kbd-scroll-container').on('scroll', function () { const key = builder.selectedKey(); - if(key) { + if(key.length) { builder.moveWedgesAround(key[0]); } }); From 1bc5db3527d0cff9e2a93873f687f6f68467a991 Mon Sep 17 00:00:00 2001 From: Marc Durdin Date: Thu, 28 Mar 2024 10:45:08 +0700 Subject: [PATCH 080/170] fix(common): make `isEmptyTransform` return true if passed a nullish transform Fixes #11084. --- common/web/keyboard-processor/src/text/outputTarget.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/web/keyboard-processor/src/text/outputTarget.ts b/common/web/keyboard-processor/src/text/outputTarget.ts index ab9656b6999..c62abb369cc 100644 --- a/common/web/keyboard-processor/src/text/outputTarget.ts +++ b/common/web/keyboard-processor/src/text/outputTarget.ts @@ -13,7 +13,7 @@ import { Deadkey, DeadkeyTracker } from "./deadkeys.js"; export function isEmptyTransform(transform: Transform) { if(!transform) { - return false; + return true; } return transform.insert === '' && transform.deleteLeft === 0 && (transform.deleteRight ?? 0) === 0; } From d894db78f6103143d10a262d21388dbef58cd7f8 Mon Sep 17 00:00:00 2001 From: "Steven R. Loomis" Date: Fri, 22 Mar 2024 17:02:01 -0500 Subject: [PATCH 081/170] fix(core): add an assert on code_points_to_delete --- core/src/km_core_action_api.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/core/src/km_core_action_api.cpp b/core/src/km_core_action_api.cpp index 98ee0f11e7b..6026bad0e54 100644 --- a/core/src/km_core_action_api.cpp +++ b/core/src/km_core_action_api.cpp @@ -56,7 +56,9 @@ void km::core::actions_dispose( km_core_usv const *km::core::get_deleted_context(context const &app_context, unsigned int code_points_to_delete) { auto p = app_context.end(); - for(size_t i = code_points_to_delete; i > 0; i--, p--); + for(size_t i = code_points_to_delete; i > 0; i--, p--) { + assert(p != app_context.begin()); + } auto deleted_context = new km_core_usv[code_points_to_delete + 1]; for(size_t i = 0; i < code_points_to_delete; i++) { From adc421974cc89e11b686c616f644c779ffd0bd1c Mon Sep 17 00:00:00 2001 From: "Steven R. Loomis" Date: Wed, 27 Mar 2024 23:55:01 -0500 Subject: [PATCH 082/170] chore(core): fix actions_normalize() pointer math - tests - fix comments yet again - add regression test in test_actions_normalize.cpp For: #11067 --- core/src/actions_normalize.cpp | 4 ++-- .../unit/kmnkbd/test_actions_normalize.cpp | 19 ++++++++++++++++++- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/core/src/actions_normalize.cpp b/core/src/actions_normalize.cpp index 3f2206f7997..3384fda9757 100644 --- a/core/src/actions_normalize.cpp +++ b/core/src/actions_normalize.cpp @@ -141,10 +141,10 @@ bool km::core::actions_normalize( At this point, our output and cached_context are coherent and normalization will be complete at the edit boundary. - Now, we need to adjust the delete_back to match the number of code units + Now, we need to adjust the delete_back to match the number of codepoints that must actually be deleted from the applications's NFU context - To adjust, we remove one code unit at a time from the app_context until + To adjust, we remove one codepoint at a time from the app_context until its normalized form matches the cached_context normalized form. */ diff --git a/core/tests/unit/kmnkbd/test_actions_normalize.cpp b/core/tests/unit/kmnkbd/test_actions_normalize.cpp index 8ae556c7e66..02bf604ee35 100644 --- a/core/tests/unit/kmnkbd/test_actions_normalize.cpp +++ b/core/tests/unit/kmnkbd/test_actions_normalize.cpp @@ -152,7 +152,7 @@ void test_actions_normalize( * this is initial_cached_context - * actions_code_points_to_delete + * actions_output) - no markers supported. - * If specified, final_cached_context_items + * If specified, final_cached_context_items * must be nullptr. * @param final_cached_context_items cached context _after_ actions have been * applied -- NFU (essentially, @@ -440,6 +440,23 @@ void run_actions_normalize_tests() { /* app_context: */ u"a\U0001F607bca\U0001F60E" ); + km_core_context_item items_11067[] = { + { KM_CORE_CT_CHAR, {0,}, { U'𐒻' } }, + { KM_CORE_CT_CHAR, {0,}, { U'𐒷' } }, + KM_CORE_CONTEXT_ITEM_END + }; + + // regression #11067 + test_actions_normalize( + "A non-BMP char in context (#11067)", + /* app context pre transform: */ u"𐒻", + /* cached context post transform: */ u"𐒻𐒷", + /* cached context post transform: */ &items_11067[0], + /* action del, output: */ 0, U"𐒻𐒷", + // ---- results ---- + /* action del, output: */ 1, U"𐒻𐒷", + /* app_context: */ u"𐒻𐒷" + ); } void run_actions_update_app_context_nfu_tests() { From 53224cb083c65ff6b9b61f4e35446062356ab0e8 Mon Sep 17 00:00:00 2001 From: "Steven R. Loomis" Date: Thu, 28 Mar 2024 00:12:50 -0500 Subject: [PATCH 083/170] Update developer/src/kmc-ldml/src/util/util.ts Co-authored-by: Marc Durdin --- developer/src/kmc-ldml/src/util/util.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/developer/src/kmc-ldml/src/util/util.ts b/developer/src/kmc-ldml/src/util/util.ts index 4536e6fc7bd..21409e72990 100644 --- a/developer/src/kmc-ldml/src/util/util.ts +++ b/developer/src/kmc-ldml/src/util/util.ts @@ -168,7 +168,7 @@ function translateModifierSubsetToLayer(modifiers: string) : number { // TODO-LDML: Default #11072 if (modifiers) { if (modifiers.indexOf(',') !== -1) { - throw Error(`This function only takes a single subset of the modifiers`); + throw Error(`translateModifierSubsetToLayer only takes a single subset of the modifiers`); } let mod = constants.keys_mod_none; for (let str of modifiers.split(' ')) { From 3e6ba6566bae258d04b7c265861872a2b3dd978a Mon Sep 17 00:00:00 2001 From: Joshua Horton Date: Thu, 28 Mar 2024 12:28:30 +0700 Subject: [PATCH 084/170] change(ios): better directional-mark trimming --- .../KeymanEngine/Classes/Extension/String+Helpers.swift | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/ios/engine/KMEI/KeymanEngine/Classes/Extension/String+Helpers.swift b/ios/engine/KMEI/KeymanEngine/Classes/Extension/String+Helpers.swift index c14b7a508c0..d09cd35561e 100644 --- a/ios/engine/KMEI/KeymanEngine/Classes/Extension/String+Helpers.swift +++ b/ios/engine/KMEI/KeymanEngine/Classes/Extension/String+Helpers.swift @@ -53,10 +53,9 @@ func trimDirectionalMarkPrefix(_ str: String?) -> String { // If the first intended char in context is a diacritic (such as U+0300), Swift's // standard string-handling will treat it and a preceding directional character // as a single unit. This code block exists to avoid the issue. - if text.utf16.count > 0 { - let head = UnicodeScalar(text.utf16[text.utf16.startIndex])! - let tail = text.utf16.count > 1 ? String(text.utf16[text.utf16.index(text.utf16.startIndex, offsetBy: 1).. 0 { + let head = text.unicodeScalars.first! + let tail = String(text.unicodeScalars.dropFirst(1)) // // "%02X" - to hex-format the integer for the string conversion. // let headEncoding = String(format:"%02X", text.utf16[text.utf16.startIndex]) From ad64bdf163bb040f1f90bf03fca3f6e76f1d6bfc Mon Sep 17 00:00:00 2001 From: "Steven R. Loomis" Date: Thu, 28 Mar 2024 01:17:18 -0500 Subject: [PATCH 085/170] chore(developer): failing test for invalid variable name for #11044 --- .../fixtures/sections/vars/fail-badref-7.xml | 20 +++++++++++++++++++ developer/src/kmc-ldml/test/test-vars.ts | 4 ++++ 2 files changed, 24 insertions(+) create mode 100644 developer/src/kmc-ldml/test/fixtures/sections/vars/fail-badref-7.xml diff --git a/developer/src/kmc-ldml/test/fixtures/sections/vars/fail-badref-7.xml b/developer/src/kmc-ldml/test/fixtures/sections/vars/fail-badref-7.xml new file mode 100644 index 00000000000..bc9cc365ba7 --- /dev/null +++ b/developer/src/kmc-ldml/test/fixtures/sections/vars/fail-badref-7.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/developer/src/kmc-ldml/test/test-vars.ts b/developer/src/kmc-ldml/test/test-vars.ts index e3ac53c4a92..501c2949612 100644 --- a/developer/src/kmc-ldml/test/test-vars.ts +++ b/developer/src/kmc-ldml/test/test-vars.ts @@ -187,6 +187,10 @@ describe('vars', function () { CompilerMessages.Error_MissingStringVariable({id: 'missingStringInSet'}) ], }, + { + subpath: 'sections/vars/fail-badref-7.xml', + errors: true, + }, ], varsDependencies); describe('should match some marker constants', () => { // neither of these live here, but, common/web/types does not import ldml-keyboard-constants otherwise. From ce1eba6271efbe8c7015a0f2292b5cdb5bd1db6e Mon Sep 17 00:00:00 2001 From: "Dr Mark C. Sinclair" Date: Thu, 28 Mar 2024 10:25:05 +0000 Subject: [PATCH 086/170] chore(developer): add const variables for khmer_angor fixtures --- .../test/test-keyboard-info-compiler.ts | 83 ++++++++++--------- 1 file changed, 44 insertions(+), 39 deletions(-) diff --git a/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts b/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts index 99f860d5b58..ae4dc81fdea 100644 --- a/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts +++ b/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts @@ -17,7 +17,12 @@ beforeEach(function() { callbacks.clear(); }); -const ENLANGTAG = { +const KHMER_ANGKOR_KPJ = makePathToFixture('khmer_angkor', 'khmer_angkor.kpj'); +const KHMER_ANGKOR_JS = makePathToFixture('khmer_angkor', 'build', 'khmer_angkor.js'); +const KHMER_ANGKOR_KPS = makePathToFixture('khmer_angkor', 'source', 'khmer_angkor.kps'); +const KHMER_ANGKOR_KMP = makePathToFixture('khmer_angkor', 'build', 'khmer_angkor.kmp'); + +const EN_LANGTAG = { "full": "en-Latn-US", "iana": [ "English" ], "iso639_3": "eng", @@ -39,10 +44,10 @@ const ENLANGTAG = { describe('keyboard-info-compiler', function () { it('compile a .keyboard_info file correctly', async function() { - const kpjFilename = makePathToFixture('khmer_angkor', 'khmer_angkor.kpj'); - const jsFilename = makePathToFixture('khmer_angkor', 'build', 'khmer_angkor.js'); - const kpsFilename = makePathToFixture('khmer_angkor', 'source', 'khmer_angkor.kps'); - const kmpFilename = makePathToFixture('khmer_angkor', 'build', 'khmer_angkor.kmp'); + const kpjFilename = KHMER_ANGKOR_KPJ; + const jsFilename = KHMER_ANGKOR_JS; + const kpsFilename = KHMER_ANGKOR_KPS; + const kmpFilename = KHMER_ANGKOR_KMP; const buildKeyboardInfoFilename = makePathToFixture('khmer_angkor', 'build', 'khmer_angkor.keyboard_info'); const sources = { @@ -80,17 +85,17 @@ describe('keyboard-info-compiler', function () { it('check preinit creates langtagsByTag correctly', async function() { const compiler = new KeyboardInfoCompiler(); // indirectly call preinit() assert.isNotNull(compiler); - assert.deepEqual(langtags.find(({ tag }) => tag === 'en'), ENLANGTAG); - assert.deepEqual((unitTestEndpoints.langtagsByTag)['en'], ENLANGTAG); - assert.deepEqual((unitTestEndpoints.langtagsByTag)['en-Latn-US'], ENLANGTAG); - assert.deepEqual((unitTestEndpoints.langtagsByTag)['en-Latn'], ENLANGTAG); - assert.deepEqual((unitTestEndpoints.langtagsByTag)['en-US'], ENLANGTAG); + assert.deepEqual(langtags.find(({ tag }) => tag === 'en'), EN_LANGTAG); + assert.deepEqual((unitTestEndpoints.langtagsByTag)['en'], EN_LANGTAG); + assert.deepEqual((unitTestEndpoints.langtagsByTag)['en-Latn-US'], EN_LANGTAG); + assert.deepEqual((unitTestEndpoints.langtagsByTag)['en-Latn'], EN_LANGTAG); + assert.deepEqual((unitTestEndpoints.langtagsByTag)['en-US'], EN_LANGTAG); }); it('check init initialises KeyboardInfoCompiler correctly', async function() { - const jsFilename = makePathToFixture('khmer_angkor', 'build', 'khmer_angkor.js'); - const kpsFilename = makePathToFixture('khmer_angkor', 'source', 'khmer_angkor.kps'); - const kmpFilename = makePathToFixture('khmer_angkor', 'build', 'khmer_angkor.kmp'); + const jsFilename = KHMER_ANGKOR_JS; + const kpsFilename = KHMER_ANGKOR_KPS; + const kmpFilename = KHMER_ANGKOR_KMP; const sources = { kmpFilename, @@ -107,10 +112,10 @@ describe('keyboard-info-compiler', function () { }); it('check run returns null if KmpCompiler.init fails', async function() { - const kpjFilename = makePathToFixture('khmer_angkor', 'khmer_angkor.kpj'); - const jsFilename = makePathToFixture('khmer_angkor', 'build', 'khmer_angkor.js'); - const kpsFilename = makePathToFixture('khmer_angkor', 'source', 'khmer_angkor.kps'); - const kmpFilename = makePathToFixture('khmer_angkor', 'build', 'khmer_angkor.kmp'); + const kpjFilename = KHMER_ANGKOR_KPJ; + const jsFilename = KHMER_ANGKOR_JS; + const kpsFilename = KHMER_ANGKOR_KPS; + const kmpFilename = KHMER_ANGKOR_KMP; const sources = { kmpFilename, @@ -130,10 +135,10 @@ describe('keyboard-info-compiler', function () { }); it('check run returns null if KmpCompiler.transformKpsToKmpObject fails', async function() { - const kpjFilename = makePathToFixture('khmer_angkor', 'khmer_angkor.kpj'); - const jsFilename = makePathToFixture('khmer_angkor', 'build', 'khmer_angkor.js'); - const kpsFilename = makePathToFixture('khmer_angkor', 'source', 'khmer_angkor.kps'); - const kmpFilename = makePathToFixture('khmer_angkor', 'build', 'khmer_angkor.kmp'); + const kpjFilename = KHMER_ANGKOR_KPJ; + const jsFilename = KHMER_ANGKOR_JS; + const kpsFilename = KHMER_ANGKOR_KPS; + const kmpFilename = KHMER_ANGKOR_KMP; const sources = { kmpFilename, @@ -153,10 +158,10 @@ describe('keyboard-info-compiler', function () { }); it('check run returns null if loadJsFile fails', async function() { - const kpjFilename = makePathToFixture('khmer_angkor', 'khmer_angkor.kpj'); - const jsFilename = makePathToFixture('khmer_angkor', 'build', 'khmer_angkor.js'); - const kpsFilename = makePathToFixture('khmer_angkor', 'source', 'khmer_angkor.kps'); - const kmpFilename = makePathToFixture('khmer_angkor', 'build', 'khmer_angkor.kmp'); + const kpjFilename = KHMER_ANGKOR_KPJ; + const jsFilename = KHMER_ANGKOR_JS; + const kpsFilename = KHMER_ANGKOR_KPS; + const kmpFilename = KHMER_ANGKOR_KMP; const sources = { kmpFilename, @@ -174,10 +179,10 @@ describe('keyboard-info-compiler', function () { }); it('check run returns null if license is not MIT', async function() { - const kpjFilename = makePathToFixture('khmer_angkor', 'khmer_angkor.kpj'); - const jsFilename = makePathToFixture('khmer_angkor', 'build', 'khmer_angkor.js'); - const kpsFilename = makePathToFixture('khmer_angkor', 'source', 'khmer_angkor.kps'); - const kmpFilename = makePathToFixture('khmer_angkor', 'build', 'khmer_angkor.kmp'); + const kpjFilename = KHMER_ANGKOR_KPJ; + const jsFilename = KHMER_ANGKOR_JS; + const kpsFilename = KHMER_ANGKOR_KPS; + const kmpFilename = KHMER_ANGKOR_KMP; const sources = { kmpFilename, @@ -195,10 +200,10 @@ describe('keyboard-info-compiler', function () { }); it('check run returns null if fillLanguages fails', async function() { - const kpjFilename = makePathToFixture('khmer_angkor', 'khmer_angkor.kpj'); - const jsFilename = makePathToFixture('khmer_angkor', 'build', 'khmer_angkor.js'); - const kpsFilename = makePathToFixture('khmer_angkor', 'source', 'khmer_angkor.kps'); - const kmpFilename = makePathToFixture('khmer_angkor', 'build', 'khmer_angkor.kmp'); + const kpjFilename = KHMER_ANGKOR_KPJ; + const jsFilename = KHMER_ANGKOR_JS; + const kpsFilename = KHMER_ANGKOR_KPS; + const kmpFilename = KHMER_ANGKOR_KMP; const sources = { kmpFilename, @@ -216,10 +221,10 @@ describe('keyboard-info-compiler', function () { }); it('should write artifacts to disk', async function() { - const kpjFilename = makePathToFixture('khmer_angkor', 'khmer_angkor.kpj'); - const jsFilename = makePathToFixture('khmer_angkor', 'build', 'khmer_angkor.js'); - const kpsFilename = makePathToFixture('khmer_angkor', 'source', 'khmer_angkor.kps'); - const kmpFilename = makePathToFixture('khmer_angkor', 'build', 'khmer_angkor.kmp'); + const kpjFilename = KHMER_ANGKOR_KPJ; + const jsFilename = KHMER_ANGKOR_JS; + const kpsFilename = KHMER_ANGKOR_KPS; + const kmpFilename = KHMER_ANGKOR_KMP; const actualFilename = makePathToFixture('khmer_angkor', 'build', 'actual.keyboard_info'); const expectedFilename = makePathToFixture('khmer_angkor', 'build', 'khmer_angkor.keyboard_info'); @@ -290,7 +295,7 @@ describe('keyboard-info-compiler', function () { }); it('check loadKmxFiles returns empty array if .kmx file is missing from .kmp', async function() { - const kpsFilename = makePathToFixture('khmer_angkor', 'source', 'khmer_angkor.kps'); + const kpsFilename = KHMER_ANGKOR_KPS; const compiler = new KeyboardInfoCompiler(); const kmpCompiler = new KmpCompiler(); assert.isTrue(await kmpCompiler.init(callbacks, {})); @@ -306,7 +311,7 @@ describe('keyboard-info-compiler', function () { }); it('check loadKmxFiles throws error if .kmx file is missing from disk', async function() { - const kpsFilename = makePathToFixture('khmer_angkor', 'source', 'khmer_angkor.kps'); + const kpsFilename = KHMER_ANGKOR_KPS; const compiler = new KeyboardInfoCompiler(); const kmpCompiler = new KmpCompiler(); assert.isTrue(await kmpCompiler.init(callbacks, {})); From 41f5e7f5005a10cf635e30499c1774916de949cc Mon Sep 17 00:00:00 2001 From: "Dr Mark C. Sinclair" Date: Thu, 28 Mar 2024 10:40:59 +0000 Subject: [PATCH 087/170] chore(developer): add const variable for khmer angkor sources --- .../test/test-keyboard-info-compiler.ts | 114 +++--------------- 1 file changed, 20 insertions(+), 94 deletions(-) diff --git a/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts b/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts index ae4dc81fdea..122ddd5cd20 100644 --- a/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts +++ b/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts @@ -22,6 +22,14 @@ const KHMER_ANGKOR_JS = makePathToFixture('khmer_angkor', 'build', 'khmer_angko const KHMER_ANGKOR_KPS = makePathToFixture('khmer_angkor', 'source', 'khmer_angkor.kps'); const KHMER_ANGKOR_KMP = makePathToFixture('khmer_angkor', 'build', 'khmer_angkor.kmp'); +const KHMER_ANGKOR_SOURCES = { + kmpFilename: KHMER_ANGKOR_KMP, + sourcePath: 'release/k/khmer_angkor', + kpsFilename: KHMER_ANGKOR_KPS, + jsFilename: KHMER_ANGKOR_JS, + forPublishing: true, +}; + const EN_LANGTAG = { "full": "en-Latn-US", "iana": [ "English" ], @@ -45,18 +53,8 @@ const EN_LANGTAG = { describe('keyboard-info-compiler', function () { it('compile a .keyboard_info file correctly', async function() { const kpjFilename = KHMER_ANGKOR_KPJ; - const jsFilename = KHMER_ANGKOR_JS; - const kpsFilename = KHMER_ANGKOR_KPS; - const kmpFilename = KHMER_ANGKOR_KMP; const buildKeyboardInfoFilename = makePathToFixture('khmer_angkor', 'build', 'khmer_angkor.keyboard_info'); - - const sources = { - kmpFilename, - sourcePath: 'release/k/khmer_angkor', - kpsFilename, - jsFilename: jsFilename, - forPublishing: true, - }; + const sources = KHMER_ANGKOR_SOURCES; const compiler = new KeyboardInfoCompiler(); assert.isTrue(await compiler.init(callbacks, {sources})); @@ -93,18 +91,7 @@ describe('keyboard-info-compiler', function () { }); it('check init initialises KeyboardInfoCompiler correctly', async function() { - const jsFilename = KHMER_ANGKOR_JS; - const kpsFilename = KHMER_ANGKOR_KPS; - const kmpFilename = KHMER_ANGKOR_KMP; - - const sources = { - kmpFilename, - sourcePath: 'release/k/khmer_angkor', - kpsFilename, - jsFilename: jsFilename, - forPublishing: true, - }; - + const sources = KHMER_ANGKOR_SOURCES; const compiler = new KeyboardInfoCompiler(); assert.isTrue(await compiler.init(callbacks, {sources})); assert.deepEqual(compiler['callbacks'], callbacks); @@ -113,18 +100,7 @@ describe('keyboard-info-compiler', function () { it('check run returns null if KmpCompiler.init fails', async function() { const kpjFilename = KHMER_ANGKOR_KPJ; - const jsFilename = KHMER_ANGKOR_JS; - const kpsFilename = KHMER_ANGKOR_KPS; - const kmpFilename = KHMER_ANGKOR_KMP; - - const sources = { - kmpFilename, - sourcePath: 'release/k/khmer_angkor', - kpsFilename, - jsFilename: jsFilename, - forPublishing: true, - }; - + const sources = KHMER_ANGKOR_SOURCES; const compiler = new KeyboardInfoCompiler(); assert.isTrue(await compiler.init(callbacks, {sources})); const origKmpCompilerInit = KmpCompiler.prototype.init; @@ -136,18 +112,7 @@ describe('keyboard-info-compiler', function () { it('check run returns null if KmpCompiler.transformKpsToKmpObject fails', async function() { const kpjFilename = KHMER_ANGKOR_KPJ; - const jsFilename = KHMER_ANGKOR_JS; - const kpsFilename = KHMER_ANGKOR_KPS; - const kmpFilename = KHMER_ANGKOR_KMP; - - const sources = { - kmpFilename, - sourcePath: 'release/k/khmer_angkor', - kpsFilename, - jsFilename: jsFilename, - forPublishing: true, - }; - + const sources = KHMER_ANGKOR_SOURCES; const compiler = new KeyboardInfoCompiler(); assert.isTrue(await compiler.init(callbacks, {sources})); const origKmpCompilerTransformKpsToKmpObject = KmpCompiler.prototype.transformKpsToKmpObject; @@ -159,18 +124,7 @@ describe('keyboard-info-compiler', function () { it('check run returns null if loadJsFile fails', async function() { const kpjFilename = KHMER_ANGKOR_KPJ; - const jsFilename = KHMER_ANGKOR_JS; - const kpsFilename = KHMER_ANGKOR_KPS; - const kmpFilename = KHMER_ANGKOR_KMP; - - const sources = { - kmpFilename, - sourcePath: 'release/k/khmer_angkor', - kpsFilename, - jsFilename: jsFilename, - forPublishing: true, - }; - + const sources = KHMER_ANGKOR_SOURCES const compiler = new KeyboardInfoCompiler(); assert.isTrue(await compiler.init(callbacks, {sources})); compiler['loadJsFile'] = (_filename: string): string => null; @@ -180,18 +134,7 @@ describe('keyboard-info-compiler', function () { it('check run returns null if license is not MIT', async function() { const kpjFilename = KHMER_ANGKOR_KPJ; - const jsFilename = KHMER_ANGKOR_JS; - const kpsFilename = KHMER_ANGKOR_KPS; - const kmpFilename = KHMER_ANGKOR_KMP; - - const sources = { - kmpFilename, - sourcePath: 'release/k/khmer_angkor', - kpsFilename, - jsFilename: jsFilename, - forPublishing: true, - }; - + const sources = KHMER_ANGKOR_SOURCES; const compiler = new KeyboardInfoCompiler(); assert.isTrue(await compiler.init(callbacks, {sources})); compiler['isLicenseMIT'] = (_filename: string): boolean => false; @@ -201,18 +144,7 @@ describe('keyboard-info-compiler', function () { it('check run returns null if fillLanguages fails', async function() { const kpjFilename = KHMER_ANGKOR_KPJ; - const jsFilename = KHMER_ANGKOR_JS; - const kpsFilename = KHMER_ANGKOR_KPS; - const kmpFilename = KHMER_ANGKOR_KMP; - - const sources = { - kmpFilename, - sourcePath: 'release/k/khmer_angkor', - kpsFilename, - jsFilename: jsFilename, - forPublishing: true, - }; - + const sources = KHMER_ANGKOR_SOURCES; const compiler = new KeyboardInfoCompiler(); assert.isTrue(await compiler.init(callbacks, {sources})); compiler['fillLanguages'] = async (_kpsFilename: string, _keyboard_info: KeyboardInfoFile, _kmpJsonData: KmpJsonFile.KmpJsonFile): Promise => false; @@ -222,19 +154,9 @@ describe('keyboard-info-compiler', function () { it('should write artifacts to disk', async function() { const kpjFilename = KHMER_ANGKOR_KPJ; - const jsFilename = KHMER_ANGKOR_JS; - const kpsFilename = KHMER_ANGKOR_KPS; - const kmpFilename = KHMER_ANGKOR_KMP; const actualFilename = makePathToFixture('khmer_angkor', 'build', 'actual.keyboard_info'); const expectedFilename = makePathToFixture('khmer_angkor', 'build', 'khmer_angkor.keyboard_info'); - - const sources = { - kmpFilename, - sourcePath: 'release/k/khmer_angkor', - kpsFilename, - jsFilename: jsFilename, - forPublishing: true, - }; + const sources = KHMER_ANGKOR_SOURCES; const compiler = new KeyboardInfoCompiler(); assert.isTrue(await compiler.init(callbacks, {sources})); @@ -257,6 +179,10 @@ describe('keyboard-info-compiler', function () { delete expected['lastModifiedDate']; assert.deepEqual(actual, expected); + + if(fs.existsSync(actualFilename)) { // tidy up + fs.rmSync(actualFilename); + } }); it('check mapKeymanTargetToPlatform returns correct platforms', async function() { From 484316c1bc19d6afd3145d50322398f6582411aa Mon Sep 17 00:00:00 2001 From: "Dr Mark C. Sinclair" Date: Thu, 28 Mar 2024 10:44:35 +0000 Subject: [PATCH 088/170] chore(developer): remove two commented out imports --- .../src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts b/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts index 122ddd5cd20..fa6e732924e 100644 --- a/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts +++ b/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts @@ -8,8 +8,6 @@ import langtags from "../src/imports/langtags.js"; import { KmpCompiler, KmpCompilerOptions } from '@keymanapp/kmc-package'; import { CompilerCallbacks, KMX, KeymanFileTypes, KeymanTargets, KmpJsonFile } from '@keymanapp/common-types'; import { KeyboardInfoFile, KeyboardInfoFilePlatform } from './keyboard-info-file.js'; -//import { PackageVersionValidator } from '../../kmc-package/src/compiler/package-version-validator.js'; -//import { KeyboardMetadataCollection } from '../../kmc-package/src/compiler/package-metadata-collector.js'; const callbacks = new TestCompilerCallbacks(); From 1e21b243351c8e95975a0e90f26d682f36d30f69 Mon Sep 17 00:00:00 2001 From: "Dr Mark C. Sinclair" Date: Thu, 28 Mar 2024 11:45:41 +0000 Subject: [PATCH 089/170] chore(developer): add check fillLanguages returns false for display font fail test --- .../test/test-keyboard-info-compiler.ts | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts b/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts index fa6e732924e..0e25a4b80f5 100644 --- a/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts +++ b/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts @@ -48,6 +48,28 @@ const EN_LANGTAG = { "windows": "en-US" }; +const KHMER_ANGKOR_KEYBOARD = { + displayFont: "Mondulkiri-R.ttf", + oskFont: "khmer_busra_kbd.ttf", + name: "Khmer Angkor", + id: "khmer_angkor", + version: "1.3", + languages: [ + { + name: "Central Khmer (Khmer, Cambodia)", + id: "km", + }, + ], + examples: [ + { + id: "km", + keys: "x j m E r", + text: "ខ្មែរ", + note: "Name of language", + }, + ], +}; + describe('keyboard-info-compiler', function () { it('compile a .keyboard_info file correctly', async function() { const kpjFilename = KHMER_ANGKOR_KPJ; @@ -304,4 +326,21 @@ describe('keyboard-info-compiler', function () { assert.throws(() => compiler['loadJsFile'](jsFilename)); TextDecoder.prototype.decode = origTextDecoderDecode; }); + + it('check fillLanguages returns false if fontSourceToKeyboardInfoFont fails for display font', async function() { + const kpsFilename = KHMER_ANGKOR_KPS; + const keyboard_info: KeyboardInfoFile = {}; + const kmpJsonData: KmpJsonFile.KmpJsonFile = { + system: { fileVersion: '', keymanDeveloperVersion: '' }, + options: null, + keyboards: [KHMER_ANGKOR_KEYBOARD], + }; + + const sources = KHMER_ANGKOR_SOURCES; + const compiler = new KeyboardInfoCompiler(); + assert.isTrue(await compiler.init(callbacks, {sources})); + compiler['fontSourceToKeyboardInfoFont'] = async (_kpsFilename: string, _kmpJsonData: KmpJsonFile.KmpJsonFile, _source: string[]) => null; + const result = await compiler['fillLanguages'](kpsFilename, keyboard_info, kmpJsonData); + assert.isFalse(result); + }); }); From e70220e1f6260bd10103b5a5b36f02a2e66c477a Mon Sep 17 00:00:00 2001 From: "Dr Mark C. Sinclair" Date: Thu, 28 Mar 2024 12:22:37 +0000 Subject: [PATCH 090/170] chore(developer): add check fillLanguages returns false for osk font fail test --- .../test/test-keyboard-info-compiler.ts | 40 ++++++++++++++++--- 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts b/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts index 0e25a4b80f5..2cb38a8477f 100644 --- a/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts +++ b/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts @@ -48,9 +48,12 @@ const EN_LANGTAG = { "windows": "en-US" }; +const KHMER_ANGKOR_DISPLAY_FONT = "Mondulkiri-R.ttf"; +const KHMER_ANGKOR_OSK_FONT = "khmer_busra_kbd.ttf"; + const KHMER_ANGKOR_KEYBOARD = { - displayFont: "Mondulkiri-R.ttf", - oskFont: "khmer_busra_kbd.ttf", + displayFont: KHMER_ANGKOR_DISPLAY_FONT, + oskFont: KHMER_ANGKOR_OSK_FONT, name: "Khmer Angkor", id: "khmer_angkor", version: "1.3", @@ -328,8 +331,6 @@ describe('keyboard-info-compiler', function () { }); it('check fillLanguages returns false if fontSourceToKeyboardInfoFont fails for display font', async function() { - const kpsFilename = KHMER_ANGKOR_KPS; - const keyboard_info: KeyboardInfoFile = {}; const kmpJsonData: KmpJsonFile.KmpJsonFile = { system: { fileVersion: '', keymanDeveloperVersion: '' }, options: null, @@ -339,8 +340,35 @@ describe('keyboard-info-compiler', function () { const sources = KHMER_ANGKOR_SOURCES; const compiler = new KeyboardInfoCompiler(); assert.isTrue(await compiler.init(callbacks, {sources})); - compiler['fontSourceToKeyboardInfoFont'] = async (_kpsFilename: string, _kmpJsonData: KmpJsonFile.KmpJsonFile, _source: string[]) => null; - const result = await compiler['fillLanguages'](kpsFilename, keyboard_info, kmpJsonData); + compiler['fontSourceToKeyboardInfoFont'] = async (_kpsFilename: string, _kmpJsonData: KmpJsonFile.KmpJsonFile, _source: string[]) => { + if (_source[0] == KHMER_ANGKOR_DISPLAY_FONT) { + return null; + } else { // osk font + return { family: '', source: _source }; + } + } + const result = await compiler['fillLanguages'](KHMER_ANGKOR_KPS, {}, kmpJsonData); + assert.isFalse(result); + }); + + it('check fillLanguages returns false if fontSourceToKeyboardInfoFont fails for osk font', async function() { + const kmpJsonData: KmpJsonFile.KmpJsonFile = { + system: { fileVersion: '', keymanDeveloperVersion: '' }, + options: null, + keyboards: [KHMER_ANGKOR_KEYBOARD], + }; + + const sources = KHMER_ANGKOR_SOURCES; + const compiler = new KeyboardInfoCompiler(); + assert.isTrue(await compiler.init(callbacks, {sources})); + compiler['fontSourceToKeyboardInfoFont'] = async (_kpsFilename: string, _kmpJsonData: KmpJsonFile.KmpJsonFile, _source: string[]) => { + if (_source[0] == KHMER_ANGKOR_DISPLAY_FONT) { + return { family: '', source: _source }; + } else { // osk font + return null; + } + } + const result = await compiler['fillLanguages'](KHMER_ANGKOR_KPS, {}, kmpJsonData); assert.isFalse(result); }); }); From 876c0e74881a718cb4551fd7f64ac73fc0489412 Mon Sep 17 00:00:00 2001 From: "Steven R. Loomis" Date: Thu, 28 Mar 2024 11:43:55 -0500 Subject: [PATCH 091/170] fix(developer): error on missing variable name - disallow any stray $ in the from pattern (unless escaped) Fixes: #11044 --- developer/src/kmc-ldml/src/compiler/tran.ts | 41 ++++++++++++++----- .../fail-bad-from-1.xml} | 2 +- developer/src/kmc-ldml/test/test-tran.ts | 12 +++++- developer/src/kmc-ldml/test/test-vars.ts | 4 -- 4 files changed, 42 insertions(+), 17 deletions(-) rename developer/src/kmc-ldml/test/fixtures/sections/{vars/fail-badref-7.xml => tran/fail-bad-from-1.xml} (94%) diff --git a/developer/src/kmc-ldml/src/compiler/tran.ts b/developer/src/kmc-ldml/src/compiler/tran.ts index 1f531223c07..f2006e59e62 100644 --- a/developer/src/kmc-ldml/src/compiler/tran.ts +++ b/developer/src/kmc-ldml/src/compiler/tran.ts @@ -172,17 +172,9 @@ export abstract class TransformCompiler - + diff --git a/developer/src/kmc-ldml/test/test-tran.ts b/developer/src/kmc-ldml/test/test-tran.ts index 4e41b10c0f0..5b3d1195de0 100644 --- a/developer/src/kmc-ldml/test/test-tran.ts +++ b/developer/src/kmc-ldml/test/test-tran.ts @@ -334,7 +334,17 @@ describe('tran', function () { CompilerMessages.Error_MissingStringVariable({ id: "missingstr" }), ], }, - // three cases that share the same error message + // cases that share the same error code + ...[1].map(n => ({ + subpath: `sections/tran/fail-bad-from-${n}.xml`, + errors: [ + { + code: CompilerMessages.ERROR_UnparseableTransformFrom, + matchMessage: /.*/, + } + ], + })), + // cases that share the same error code ...[1, 2, 3].map(n => ({ subpath: `sections/tran/fail-matches-nothing-${n}.xml`, errors: [ diff --git a/developer/src/kmc-ldml/test/test-vars.ts b/developer/src/kmc-ldml/test/test-vars.ts index 501c2949612..e3ac53c4a92 100644 --- a/developer/src/kmc-ldml/test/test-vars.ts +++ b/developer/src/kmc-ldml/test/test-vars.ts @@ -187,10 +187,6 @@ describe('vars', function () { CompilerMessages.Error_MissingStringVariable({id: 'missingStringInSet'}) ], }, - { - subpath: 'sections/vars/fail-badref-7.xml', - errors: true, - }, ], varsDependencies); describe('should match some marker constants', () => { // neither of these live here, but, common/web/types does not import ldml-keyboard-constants otherwise. From 313bbd32a7c83c011e47c644943c367f3d9d1498 Mon Sep 17 00:00:00 2001 From: "Steven R. Loomis" Date: Thu, 28 Mar 2024 12:19:41 -0500 Subject: [PATCH 092/170] feat(core): kmx+ scaffolding for modifiers=default - add a new value, 0x10000 to indicate 'default' For: #11072 --- common/include/kmx_file.h | 1 + core/include/ldml/keyman_core_ldml.h | 3 ++- core/include/ldml/keyman_core_ldml.ts | 6 ++++++ core/src/ldml/C7043_ldml.md | 23 ++++++++++++----------- 4 files changed, 21 insertions(+), 12 deletions(-) diff --git a/common/include/kmx_file.h b/common/include/kmx_file.h index cd379489895..2fd4d2152b9 100644 --- a/common/include/kmx_file.h +++ b/common/include/kmx_file.h @@ -302,6 +302,7 @@ namespace kmx { #define K_MODIFIERFLAG 0x007F #define K_NOTMODIFIERFLAG 0xFF00 // I4548 +#define K_DEFAULTMODFLAG 0x10000 // used by KMX+ for the default modifier struct COMP_STORE { KMX_DWORD_unaligned dwSystemID; diff --git a/core/include/ldml/keyman_core_ldml.h b/core/include/ldml/keyman_core_ldml.h index b498065acaa..896e6841903 100644 --- a/core/include/ldml/keyman_core_ldml.h +++ b/core/include/ldml/keyman_core_ldml.h @@ -34,7 +34,7 @@ #define LDML_FINL_FLAGS_ERROR 0x1 #define LDML_KEYS_KEY_FLAGS_EXTEND 0x1 #define LDML_KEYS_KEY_FLAGS_GAP 0x2 -#define LDML_KEYS_MOD_ALL 0x17F +#define LDML_KEYS_MOD_ALL 0x1017F #define LDML_KEYS_MOD_ALT 0x40 #define LDML_KEYS_MOD_ALTL 0x4 #define LDML_KEYS_MOD_ALTR 0x8 @@ -42,6 +42,7 @@ #define LDML_KEYS_MOD_CTRL 0x20 #define LDML_KEYS_MOD_CTRLL 0x1 #define LDML_KEYS_MOD_CTRLR 0x2 +#define LDML_KEYS_MOD_DEFAULT 0x10000 #define LDML_KEYS_MOD_NONE 0x0 #define LDML_KEYS_MOD_SHIFT 0x10 #define LDML_LAYR_LIST_HARDWARE_TOUCH "touch" diff --git a/core/include/ldml/keyman_core_ldml.ts b/core/include/ldml/keyman_core_ldml.ts index ca9654587b5..06314c4a3d5 100644 --- a/core/include/ldml/keyman_core_ldml.ts +++ b/core/include/ldml/keyman_core_ldml.ts @@ -270,6 +270,11 @@ class Constants { */ readonly keys_mod_shift = 0x0010; + /** + * bitmask for 'default'. + */ + readonly keys_mod_default = 0x10000; + /** * Convenience map for modifiers */ @@ -284,6 +289,7 @@ class Constants { ["ctrlL", this.keys_mod_ctrlL], ["ctrlR", this.keys_mod_ctrlR], ["shift", this.keys_mod_shift], + ["default", this.keys_mod_default], ] ); diff --git a/core/src/ldml/C7043_ldml.md b/core/src/ldml/C7043_ldml.md index 8714e01f023..a3f69d66bf6 100644 --- a/core/src/ldml/C7043_ldml.md +++ b/core/src/ldml/C7043_ldml.md @@ -461,17 +461,18 @@ For each key: by the compiler. - `mod`: 32-bit bitfield defined as below. Little endian values. -| Value | Meaning |`kmx_file.h` | Comment | -|----------|----------|---------------|---------------------------------------------| -| 0x0000 | `none` | | All zeros = no modifiers | -| 0x0001 | `ctrlL` | `LCTRLFLAG` | Left Control | -| 0x0002 | `ctrlR` | `RCTRLFLAG` | Right Control | -| 0x0004 | `altL` | `LALTFLAG` | Left Alt | -| 0x0008 | `altR` | `RALTFLAG` | Right Alt | -| 0x0010 | `shift` | `K_SHIFTFLAG` | Either Shift | -| 0x0020 | `ctrl` | `K_CTRLFLAG` | Either Control | -| 0x0040 | `alt` | `K_ALTFLAG` | Either Alt | -| 0x0100 | `caps` | `CAPITALFLAG` | Caps lock | +| Value | Meaning |`kmx_file.h` | Comment | +|----------|-----------|--------------------|-----------------------------------------------| +| 0x0000 | `none` | | All zeros = no modifiers | +| 0x0001 | `ctrlL` | `LCTRLFLAG` | Left Control | +| 0x0002 | `ctrlR` | `RCTRLFLAG` | Right Control | +| 0x0004 | `altL` | `LALTFLAG` | Left Alt | +| 0x0008 | `altR` | `RALTFLAG` | Right Alt | +| 0x0010 | `shift` | `K_SHIFTFLAG` | Either Shift | +| 0x0020 | `ctrl` | `K_CTRLFLAG` | Either Control | +| 0x0040 | `alt` | `K_ALTFLAG` | Either Alt | +| 0x0100 | `caps` | `CAPITALFLAG` | Caps lock | +| 0x10000 | `default` | `K_DEFAULTMODFLAG` | Default (not used in conjunction with others) | TODO-LDML: Note that conforming to other keyman values, left versus right shift cannot be distinguished. From 4881d1aab0845f572536c0e06e53de43e32e828e Mon Sep 17 00:00:00 2001 From: "Steven R. Loomis" Date: Thu, 28 Mar 2024 12:41:42 -0500 Subject: [PATCH 093/170] chore(core): dx better err message on embedded test vkeys --- core/tests/unit/ldml/ldml_test_source.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/core/tests/unit/ldml/ldml_test_source.cpp b/core/tests/unit/ldml/ldml_test_source.cpp index b638379bb6d..2ab85368763 100644 --- a/core/tests/unit/ldml/ldml_test_source.cpp +++ b/core/tests/unit/ldml/ldml_test_source.cpp @@ -372,7 +372,10 @@ LdmlEmbeddedTestSource::vkey_to_event(std::string const &vk_event) { } // The string should be empty at this point - assert(!std::getline(f, s, ' ')); + if (std::getline(f, s, ' ')) { + std::cerr << "Error parsing vkey ["< Date: Thu, 28 Mar 2024 13:12:01 -0500 Subject: [PATCH 094/170] fix(core): support default lookup for modifiers Fixes: #11072 --- core/src/kmx/kmx_plus.cpp | 1 + core/src/ldml/ldml_vkeys.cpp | 10 +++++++ .../unit/ldml/keyboards/k_012_default.xml | 27 +++++++++++++++++++ core/tests/unit/ldml/keyboards/meson.build | 2 ++ 4 files changed, 40 insertions(+) create mode 100644 core/tests/unit/ldml/keyboards/k_012_default.xml diff --git a/core/src/kmx/kmx_plus.cpp b/core/src/kmx/kmx_plus.cpp index 295d92a4b24..af2062d1226 100644 --- a/core/src/kmx/kmx_plus.cpp +++ b/core/src/kmx/kmx_plus.cpp @@ -42,6 +42,7 @@ static_assert(LALTFLAG == LDML_KEYS_MOD_ALTL, "LDML modifier bitfield vs. kmx_fi static_assert(K_ALTFLAG == LDML_KEYS_MOD_ALT, "LDML modifier bitfield vs. kmx_file.h #define mismatch"); static_assert(CAPITALFLAG == LDML_KEYS_MOD_CAPS, "LDML modifier bitfield vs. kmx_file.h #define mismatch"); static_assert(K_SHIFTFLAG == LDML_KEYS_MOD_SHIFT, "LDML modifier bitfield vs. kmx_file.h #define mismatch"); // "either" shift +static_assert(K_DEFAULTMODFLAG == LDML_KEYS_MOD_DEFAULT, "LDML modifier bitfield vs. kmx_file.h #define mismatch"); /** * \def LDML_IS_VALID_MODIFIER_BITS test whether x is a valid modifier bitfield diff --git a/core/src/ldml/ldml_vkeys.cpp b/core/src/ldml/ldml_vkeys.cpp index 156a4c3247e..95cf935133f 100644 --- a/core/src/ldml/ldml_vkeys.cpp +++ b/core/src/ldml/ldml_vkeys.cpp @@ -65,6 +65,16 @@ vkeys::lookup(km_core_virtual_key vk, uint16_t modifier_state, bool &found) cons return ret; } } + + // look for a layer with "default" + { + const vkey_id id_default(vk, (K_DEFAULTMODFLAG)); + ret = lookup(id_default, found); + if (found) { + return ret; + } + } + // default: return failure. found=false. return ret; } diff --git a/core/tests/unit/ldml/keyboards/k_012_default.xml b/core/tests/unit/ldml/keyboards/k_012_default.xml new file mode 100644 index 00000000000..ef7b21d175c --- /dev/null +++ b/core/tests/unit/ldml/keyboards/k_012_default.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/core/tests/unit/ldml/keyboards/meson.build b/core/tests/unit/ldml/keyboards/meson.build index e06c64a8bd3..90aef564585 100644 --- a/core/tests/unit/ldml/keyboards/meson.build +++ b/core/tests/unit/ldml/keyboards/meson.build @@ -16,6 +16,7 @@ tests_from_cldr = [ 'bn', ] +# these have 'embedded' (@@) testdata instead of a separate file tests_without_testdata = [ # disabling 000 until we have updates to core or to the keyboard so that it passes # 'k_000_null_keyboard', @@ -25,6 +26,7 @@ tests_without_testdata = [ 'k_005_modbittest', 'k_010_mt', 'k_011_mt_iso', + 'k_012_default', 'k_100_keytest', 'k_101_keytest', 'k_102_keytest', From 49de9c5dda43812122088bac3eb3013491d8d651 Mon Sep 17 00:00:00 2001 From: Keyman Build Agent Date: Thu, 28 Mar 2024 14:16:39 -0400 Subject: [PATCH 095/170] auto: increment beta version to 17.0.298 --- HISTORY.md | 13 +++++++++++++ VERSION.md | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/HISTORY.md b/HISTORY.md index 69c68f1408f..947671570cb 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,5 +1,18 @@ # Keyman Version History +## 17.0.297 beta 2024-03-28 + +* fix(common): properly handle illegal UnicodeSets to prevent crash in kmc-ldml compiler (#11065) +* fix(core,developer): variable/marker substitution in sets and strings (#11059) +* fix(developer): in ldml compiler, generate compiler error if `from=` regex matches empty string (#11070) +* fix(core): calculate offset correctly when replacing marker in transform (fixes crash) (#11071) +* feat(developer): support comma in modifiers (#11075) +* fix(core): actions_normalize() length and dead store fix (#11100) +* chore(core): optimize ldml_event_state::emit_difference() when no diff (#11094) +* fix(ios): bad initial in-app layout, delayed banner, deprecated banner toggle (#10929) +* feat(developer/compilers): better unit test for suggestion accessibility (#11085) +* fix(core): fix pointer math in actions_normalize() (#11101) + ## 17.0.296 beta 2024-03-27 * fix(developer): in model compiler, give correct key to shorter prefix words when a longer, higher-frequency word is also present (#11074) diff --git a/VERSION.md b/VERSION.md index dde4e952e0e..b24cba435eb 100644 --- a/VERSION.md +++ b/VERSION.md @@ -1 +1 @@ -17.0.297 \ No newline at end of file +17.0.298 \ No newline at end of file From 054d9fd07cd140bea28dfe5646f95f27e88574c8 Mon Sep 17 00:00:00 2001 From: "Steven R. Loomis" Date: Thu, 28 Mar 2024 13:21:30 -0500 Subject: [PATCH 096/170] chore(core): dx even better err message on embedded test vkeys - report on vk-not-found - separate message for excess string --- core/tests/unit/ldml/ldml_test_source.cpp | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/core/tests/unit/ldml/ldml_test_source.cpp b/core/tests/unit/ldml/ldml_test_source.cpp index 2ab85368763..71c5a7ee4e0 100644 --- a/core/tests/unit/ldml/ldml_test_source.cpp +++ b/core/tests/unit/ldml/ldml_test_source.cpp @@ -367,13 +367,17 @@ LdmlEmbeddedTestSource::vkey_to_event(std::string const &vk_event) { modifier_state |= modifier; } else { vk = get_vk(s); - break; + if (vk == 0) { + std::cerr << "Error parsing [" << vk_event << "] - could not find vkey or modifier: " << s << std::endl; + } + assert(vk != 0); + break; // only one vkey allowed } } // The string should be empty at this point if (std::getline(f, s, ' ')) { - std::cerr << "Error parsing vkey ["< Date: Fri, 29 Mar 2024 11:57:04 -0500 Subject: [PATCH 097/170] fix(developer): error on missing variable name - improve unit tests - split out message for dollarsign, create numberspace for transform errs Fixes: #11044 --- .../src/kmc-ldml/src/compiler/messages.ts | 30 ++++++++++++++----- developer/src/kmc-ldml/src/compiler/tran.ts | 9 ++++-- ... => fail-IllegalTransformDollarsign-1.xml} | 2 +- .../fail-IllegalTransformDollarsign-2.xml | 12 ++++++++ .../fail-IllegalTransformDollarsign-3.xml | 12 ++++++++ .../test/fixtures/sections/tran/ok-1.xml | 12 ++++++++ developer/src/kmc-ldml/test/test-tran.ts | 11 +++++-- 7 files changed, 74 insertions(+), 14 deletions(-) rename developer/src/kmc-ldml/test/fixtures/sections/tran/{fail-bad-from-1.xml => fail-IllegalTransformDollarsign-1.xml} (93%) create mode 100644 developer/src/kmc-ldml/test/fixtures/sections/tran/fail-IllegalTransformDollarsign-2.xml create mode 100644 developer/src/kmc-ldml/test/fixtures/sections/tran/fail-IllegalTransformDollarsign-3.xml create mode 100644 developer/src/kmc-ldml/test/fixtures/sections/tran/ok-1.xml diff --git a/developer/src/kmc-ldml/src/compiler/messages.ts b/developer/src/kmc-ldml/src/compiler/messages.ts index 27c369f010c..2757960f429 100644 --- a/developer/src/kmc-ldml/src/compiler/messages.ts +++ b/developer/src/kmc-ldml/src/compiler/messages.ts @@ -5,6 +5,9 @@ const SevWarn = CompilerErrorSeverity.Warn | CompilerErrorNamespace.LdmlKeyboard const SevError = CompilerErrorSeverity.Error | CompilerErrorNamespace.LdmlKeyboardCompiler; // const SevFatal = CompilerErrorSeverity.Fatal | CompilerErrorNamespace.LdmlKeyboardCompiler; +// sub-numberspace for transform errors +const SevErrorTransform = SevError | 0xF00; + /** * @internal */ @@ -49,9 +52,7 @@ export class CompilerMessages { static Error_GestureKeyNotFoundInKeyBag = (o:{keyId: string, parentKeyId: string, attribute: string}) => m(this.ERROR_GestureKeyNotFoundInKeyBag, `Key '${def(o.keyId)}' not found in key bag, referenced from other '${def(o.parentKeyId)}' in ${def(o.attribute)}`); - static ERROR_TransformFromMatchesNothing = SevError | 0x000C; - static Error_TransformFromMatchesNothing = (o: { from: string }) => - m(this.ERROR_TransformFromMatchesNothing, `Invalid transfom from="${def(o.from)}": Matches an empty string.`); + // 0x000C - available static ERROR_InvalidVersion = SevError | 0x000D; static Error_InvalidVersion = (o:{version: string}) => @@ -173,14 +174,27 @@ export class CompilerMessages { static Error_UnparseableReorderSet = (o: { from: string, set: string }) => m(this.ERROR_UnparseableReorderSet, `Illegal UnicodeSet "${def(o.set)}" in reorder "${def(o.from)}`); - static ERROR_UnparseableTransformFrom = SevError | 0x0029; - static Error_UnparseableTransformFrom = (o: { from: string, message: string }) => - m(this.ERROR_UnparseableTransformFrom, `Invalid transfom from "${def(o.from)}": "${def(o.message)}"`); + // Available: 0x029 static ERROR_InvalidQuadEscape = SevError | 0x0030; static Error_InvalidQuadEscape = (o: { cp: number }) => - m(this.ERROR_InvalidQuadEscape, `Invalid escape "\\u${util.hexQuad(o?.cp || 0)}", use "\\u{${def(o?.cp?.toString(16))}}" instead.`); + m(this.ERROR_InvalidQuadEscape, `Invalid escape "\\u${util.hexQuad(o?.cp || 0)}". Hint: Use "\\u{${def(o?.cp?.toString(16))}}"`); -} + // + // Transform syntax errors begin at ...F00 (SevErrorTransform) + + // This is a bit of a catch-all and represents messages bubbling up from the underlying regex engine + static ERROR_UnparseableTransformFrom = SevErrorTransform | 0x00; + static Error_UnparseableTransformFrom = (o: { from: string, message: string }) => + m(this.ERROR_UnparseableTransformFrom, `Invalid transform from="${def(o.from)}": "${def(o.message)}"`); + static ERROR_IllegalTransformDollarsign = SevErrorTransform | 0x01; + static Error_IllegalTransformDollarsign = (o: { from: string }) => + m(this.ERROR_IllegalTransformDollarsign, `Invalid transform from="${def(o.from)}": Unescaped dollar-sign ($) is not valid transform syntax.`, + '**Hint**: Use `\\$` to match a literal dollar-sign.'); + static ERROR_TransformFromMatchesNothing = SevErrorTransform | 0x02; + static Error_TransformFromMatchesNothing = (o: { from: string }) => + m(this.ERROR_TransformFromMatchesNothing, `Invalid transfom from="${def(o.from)}": Matches an empty string.`); + +} diff --git a/developer/src/kmc-ldml/src/compiler/tran.ts b/developer/src/kmc-ldml/src/compiler/tran.ts index f2006e59e62..17b2bdc62f3 100644 --- a/developer/src/kmc-ldml/src/compiler/tran.ts +++ b/developer/src/kmc-ldml/src/compiler/tran.ts @@ -199,19 +199,24 @@ export abstract class TransformCompiler - + diff --git a/developer/src/kmc-ldml/test/fixtures/sections/tran/fail-IllegalTransformDollarsign-2.xml b/developer/src/kmc-ldml/test/fixtures/sections/tran/fail-IllegalTransformDollarsign-2.xml new file mode 100644 index 00000000000..0b962e1f8ce --- /dev/null +++ b/developer/src/kmc-ldml/test/fixtures/sections/tran/fail-IllegalTransformDollarsign-2.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/developer/src/kmc-ldml/test/fixtures/sections/tran/fail-IllegalTransformDollarsign-3.xml b/developer/src/kmc-ldml/test/fixtures/sections/tran/fail-IllegalTransformDollarsign-3.xml new file mode 100644 index 00000000000..7aa8ced0bfe --- /dev/null +++ b/developer/src/kmc-ldml/test/fixtures/sections/tran/fail-IllegalTransformDollarsign-3.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/developer/src/kmc-ldml/test/fixtures/sections/tran/ok-1.xml b/developer/src/kmc-ldml/test/fixtures/sections/tran/ok-1.xml new file mode 100644 index 00000000000..fd9a456c2b0 --- /dev/null +++ b/developer/src/kmc-ldml/test/fixtures/sections/tran/ok-1.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/developer/src/kmc-ldml/test/test-tran.ts b/developer/src/kmc-ldml/test/test-tran.ts index 5b3d1195de0..a6ba8e8025a 100644 --- a/developer/src/kmc-ldml/test/test-tran.ts +++ b/developer/src/kmc-ldml/test/test-tran.ts @@ -335,15 +335,20 @@ describe('tran', function () { ], }, // cases that share the same error code - ...[1].map(n => ({ - subpath: `sections/tran/fail-bad-from-${n}.xml`, + ...[1, 2].map(n => ({ + subpath: `sections/tran/fail-IllegalTransformDollarsign-${n}.xml`, errors: [ { - code: CompilerMessages.ERROR_UnparseableTransformFrom, + code: CompilerMessages.ERROR_IllegalTransformDollarsign, matchMessage: /.*/, } ], })), + // successful compile + ...[1].map(n => ({ + subpath: `sections/tran/ok-${n}.xml`, + errors: false, + })), // cases that share the same error code ...[1, 2, 3].map(n => ({ subpath: `sections/tran/fail-matches-nothing-${n}.xml`, From bca037cbf3fa3cc42c30823a2071700218f5ec85 Mon Sep 17 00:00:00 2001 From: "Steven R. Loomis" Date: Fri, 29 Mar 2024 10:29:43 -0500 Subject: [PATCH 098/170] chore(core): support default lookup for modifiers - remove default flag from kmx_file.h Fixes: #11072 --- common/include/kmx_file.h | 3 ++- core/src/kmx/kmx_plus.cpp | 2 +- core/src/ldml/ldml_vkeys.cpp | 3 ++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/common/include/kmx_file.h b/common/include/kmx_file.h index 2fd4d2152b9..3a6c281948f 100644 --- a/common/include/kmx_file.h +++ b/common/include/kmx_file.h @@ -302,7 +302,8 @@ namespace kmx { #define K_MODIFIERFLAG 0x007F #define K_NOTMODIFIERFLAG 0xFF00 // I4548 -#define K_DEFAULTMODFLAG 0x10000 // used by KMX+ for the default modifier +// Note: DEFAULT_MODIFIER = 0x10000, used by KMX+ for the +// default modifier flag in layers, > 16 bit so not available here struct COMP_STORE { KMX_DWORD_unaligned dwSystemID; diff --git a/core/src/kmx/kmx_plus.cpp b/core/src/kmx/kmx_plus.cpp index af2062d1226..00611a419af 100644 --- a/core/src/kmx/kmx_plus.cpp +++ b/core/src/kmx/kmx_plus.cpp @@ -42,7 +42,7 @@ static_assert(LALTFLAG == LDML_KEYS_MOD_ALTL, "LDML modifier bitfield vs. kmx_fi static_assert(K_ALTFLAG == LDML_KEYS_MOD_ALT, "LDML modifier bitfield vs. kmx_file.h #define mismatch"); static_assert(CAPITALFLAG == LDML_KEYS_MOD_CAPS, "LDML modifier bitfield vs. kmx_file.h #define mismatch"); static_assert(K_SHIFTFLAG == LDML_KEYS_MOD_SHIFT, "LDML modifier bitfield vs. kmx_file.h #define mismatch"); // "either" shift -static_assert(K_DEFAULTMODFLAG == LDML_KEYS_MOD_DEFAULT, "LDML modifier bitfield vs. kmx_file.h #define mismatch"); +// LDML_KEYS_MOD_DEFAULT is not present in kmx_file.h (>16 bit) /** * \def LDML_IS_VALID_MODIFIER_BITS test whether x is a valid modifier bitfield diff --git a/core/src/ldml/ldml_vkeys.cpp b/core/src/ldml/ldml_vkeys.cpp index 95cf935133f..803c9248db3 100644 --- a/core/src/ldml/ldml_vkeys.cpp +++ b/core/src/ldml/ldml_vkeys.cpp @@ -7,6 +7,7 @@ #include "ldml_vkeys.hpp" #include "kmx_file.h" +#include namespace km { namespace core { @@ -68,7 +69,7 @@ vkeys::lookup(km_core_virtual_key vk, uint16_t modifier_state, bool &found) cons // look for a layer with "default" { - const vkey_id id_default(vk, (K_DEFAULTMODFLAG)); + const vkey_id id_default(vk, (LDML_KEYS_MOD_DEFAULT)); ret = lookup(id_default, found); if (found) { return ret; From ae6f7b5d78da013ba63f85272c631f8ced40f9c9 Mon Sep 17 00:00:00 2001 From: "Steven R. Loomis" Date: Fri, 29 Mar 2024 12:38:18 -0500 Subject: [PATCH 099/170] chore(core): support default lookup for modifiers - fix spec Fixes: #11072 --- core/src/ldml/C7043_ldml.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/ldml/C7043_ldml.md b/core/src/ldml/C7043_ldml.md index a3f69d66bf6..700f9b2ddde 100644 --- a/core/src/ldml/C7043_ldml.md +++ b/core/src/ldml/C7043_ldml.md @@ -472,7 +472,7 @@ For each key: | 0x0020 | `ctrl` | `K_CTRLFLAG` | Either Control | | 0x0040 | `alt` | `K_ALTFLAG` | Either Alt | | 0x0100 | `caps` | `CAPITALFLAG` | Caps lock | -| 0x10000 | `default` | `K_DEFAULTMODFLAG` | Default (not used in conjunction with others) | +| 0x10000 | `default` | n/a | Default (not used in conjunction with others) | TODO-LDML: Note that conforming to other keyman values, left versus right shift cannot be distinguished. From 32b885fe748290404f0ae79380aa89b584bd2c2d Mon Sep 17 00:00:00 2001 From: Keyman Build Agent Date: Fri, 29 Mar 2024 14:12:31 -0400 Subject: [PATCH 100/170] auto: increment beta version to 17.0.299 --- HISTORY.md | 7 +++++++ VERSION.md | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/HISTORY.md b/HISTORY.md index 947671570cb..25141d2b978 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,5 +1,12 @@ # Keyman Version History +## 17.0.298 beta 2024-03-29 + +* chore(linux): Update debian changelog (#11096) +* fix(web): prevent layer switch key from erasing selection (#11032) +* fix(developer): prevent error when scrolling touch layout editor with no selected key (#11109) +* fix(common): make `isEmptyTransform` return true if passed a nullish transform (#11110) + ## 17.0.297 beta 2024-03-28 * fix(common): properly handle illegal UnicodeSets to prevent crash in kmc-ldml compiler (#11065) diff --git a/VERSION.md b/VERSION.md index b24cba435eb..9941cf40a82 100644 --- a/VERSION.md +++ b/VERSION.md @@ -1 +1 @@ -17.0.298 \ No newline at end of file +17.0.299 \ No newline at end of file From ec81a88f4e4f4c2859666e64ea56aea42736a667 Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Mon, 1 Apr 2024 11:01:08 +0700 Subject: [PATCH 101/170] change(web): reworks nearest-key detection to avoid layout reflow --- .../osk/src/keyboard-layout/oskLayerGroup.ts | 137 ++++++++++-------- .../engine/osk/src/keyboard-layout/oskRow.ts | 2 + web/src/engine/osk/src/visualKeyboard.ts | 4 + 3 files changed, 83 insertions(+), 60 deletions(-) diff --git a/web/src/engine/osk/src/keyboard-layout/oskLayerGroup.ts b/web/src/engine/osk/src/keyboard-layout/oskLayerGroup.ts index 044af641fb7..cd3fba90d39 100644 --- a/web/src/engine/osk/src/keyboard-layout/oskLayerGroup.ts +++ b/web/src/engine/osk/src/keyboard-layout/oskLayerGroup.ts @@ -1,4 +1,4 @@ -import { ActiveLayer, type DeviceSpec, Keyboard, LayoutLayer, ActiveLayout } from '@keymanapp/keyboard-processor'; +import { ActiveLayer, type DeviceSpec, Keyboard, LayoutLayer, ActiveLayout, ButtonClasses } from '@keymanapp/keyboard-processor'; import { ManagedPromise } from '@keymanapp/web-utils'; import { InputSample } from '@keymanapp/gesture-recognizer'; @@ -7,12 +7,20 @@ import { KeyElement } from '../keyElement.js'; import OSKLayer from './oskLayer.js'; import VisualKeyboard from '../visualKeyboard.js'; import OSKRow from './oskRow.js'; +import OSKBaseKey from './oskBaseKey.js'; + +const NEAREST_KEY_HORIZ_FUDGE_FACTOR = 0.6; +const NEAREST_KEY_MIN_HORIZONTAL_FUDGE_PIXELS = 24; export default class OSKLayerGroup { public readonly element: HTMLDivElement; public readonly layers: {[layerID: string]: OSKLayer} = {}; public readonly spec: ActiveLayout; + // Exist as local copies of the VisualKeyboard values, updated via refreshLayout. + private computedWidth: number; + private computedHeight: number; + private _activeLayerId: string = 'default'; public constructor(vkbd: VisualKeyboard, keyboard: Keyboard, formFactor: DeviceSpec.FormFactor) { @@ -107,8 +115,6 @@ export default class OSKLayerGroup { throw new Error(`Layer id ${layerId} could not be found`); } - this.blinkLayer(layer); - return this.nearestKey(coord, layer); } @@ -165,86 +171,97 @@ export default class OSKLayerGroup { } private nearestKey(coord: Omit, 'item'>, layer: OSKLayer): KeyElement { - const baseRect = this.element.getBoundingClientRect(); + // If there are no rows, there are no keys; return instantly. + if(layer.rows.length == 0) { + return null; + } + + // Our pre-processed layout info maps whatever shape the keyboard is in into a unit square. + // So, we map our coord to find its location within that square. + const proportionalCoords = { + // Note: need to cache these two values in some manner; if the keyboard is swapped, + // we need them if any keypresses are registered before the swap completes. + x: coord.targetX / this.computedWidth, + y: coord.targetY / this.computedHeight + }; + + // If our computed width and/or height are 0, it's best to abort; key distance + // calculations are not viable. + if(!isFinite(proportionalCoords.x) || !isFinite(proportionalCoords.y)) { + return null; + } + // Step 1: find the nearest row. let row: OSKRow = null; let bestMatchDistance = Number.MAX_VALUE; + // Max distance from the key's center to consider, vertically. + // Rows aren't variable-height - this value is "one size fits all." + const rowRadius = layer.rows[0].heightFraction / 2; + // Find the row that the touch-coordinate lies within. for(const r of layer.rows) { - const rowRect = r.element.getBoundingClientRect(); - if(rowRect.top <= coord.clientY && coord.clientY < rowRect.bottom) { + const distance = Math.abs(proportionalCoords.y - r.spec.proportionalY); + if(distance <= rowRadius) { row = r; break; - } else { - const distance = rowRect.top > coord.clientY ? rowRect.top - coord.clientY : coord.clientY - rowRect.bottom; - - if(distance < bestMatchDistance) { - bestMatchDistance = distance; - row = r; - } + } else if(distance < bestMatchDistance) { + bestMatchDistance = distance; + row = r; } } - // Assertion: row no longer `null`. + // (We already prevented the no-rows available scenario, anyway.) - // Warning: am not 100% sure that what follows is actually fully correct. - - // Find minimum distance from any key - let closestKeyIndex = 0; - let dx: number; - let dxMax = 24; - let dxMin = 100000; + // Step 2: Find minimum distance from any key + // - If the coord is within a key's square, go ahead and return it. + let closestKey: OSKBaseKey = null; + let minDistance = Number.MAX_VALUE; - const x = coord.clientX; + for (let key of row.keys) { + const keySpec = key.spec; + if(keySpec.sp == ButtonClasses.blank || keySpec.sp == ButtonClasses.spacer) { + continue; + } - for (let k = 0; k < row.keys.length; k++) { - // Second-biggest, though documentation suggests this is probably right. - const keySquare = row.keys[k].square as HTMLElement; // gets the .kmw-key-square containing a key - const squareRect = keySquare.getBoundingClientRect(); + // Max distance from the key's center to consider, horizontally. + const keyRadius = keySpec.proportionalWidth / 2; + const distanceFromCenter = Math.abs(proportionalCoords.x - keySpec.proportionalX); // Find the actual key element. - let childNode = keySquare.firstChild ? keySquare.firstChild as HTMLElement : keySquare; - - if (childNode.className !== undefined - && (childNode.className.indexOf('key-hidden') >= 0 - || childNode.className.indexOf('key-blank') >= 0)) { - continue; - } - const x1 = squareRect.left; - const x2 = squareRect.right; - if (x >= x1 && x <= x2) { - // Within the key square - return childNode; - } - dx = x1 - x; - if (dx >= 0 && dx < dxMin) { - // To right of key - closestKeyIndex = k; dxMin = dx; - } - dx = x - x2; - if (dx >= 0 && dx < dxMin) { - // To left of key - closestKeyIndex = k; dxMin = dx; + if(distanceFromCenter - keyRadius <= 0) { + // As noted above: if we land within a key's square, match instantly! + return key.btn; + } else { + const distance = distanceFromCenter - keyRadius; + if(distance < minDistance) { + minDistance = distance; + closestKey = key; + } } } - if (dxMin < 100000) { - const t = row.keys[closestKeyIndex].square; - const squareRect = t.getBoundingClientRect(); + // Step 3: If the input coordinate wasn't within any valid key's "square", + // determine if the nearest valid key is acceptable. + const minHorizFudge = NEAREST_KEY_MIN_HORIZONTAL_FUDGE_PIXELS / this.element.offsetWidth; - const x1 = squareRect.left; - const x2 = squareRect.right; - - // Limit extended touch area to the larger of 0.6 of key width and 24 px - if (squareRect.width > 40) { - dxMax = 0.6 * squareRect.width; - } + // If the condition is not met, there are no valid keys within this row. + if (minDistance < Number.MAX_VALUE) { + let fudgeFactor = closestKey.spec.proportionalWidth * NEAREST_KEY_HORIZ_FUDGE_FACTOR; + fudgeFactor = fudgeFactor > minHorizFudge ? fudgeFactor : minHorizFudge; - if (((x1 - x) >= 0 && (x1 - x) < dxMax) || ((x - x2) >= 0 && (x - x2) < dxMax)) { - return t.firstChild; + if(minDistance <= fudgeFactor) { + return closestKey.btn; } } + + // Step 4: no matches => return null. The caller should be able to handle such cases, + // anyway. return null; } + + public refreshLayout(computedWidth: number, computedHeight: number) { + this.computedWidth = computedWidth; + this.computedHeight = computedHeight; + } } \ No newline at end of file diff --git a/web/src/engine/osk/src/keyboard-layout/oskRow.ts b/web/src/engine/osk/src/keyboard-layout/oskRow.ts index 75494f5921c..6e0b3147408 100644 --- a/web/src/engine/osk/src/keyboard-layout/oskRow.ts +++ b/web/src/engine/osk/src/keyboard-layout/oskRow.ts @@ -11,6 +11,7 @@ export default class OSKRow { public readonly element: HTMLDivElement; public readonly keys: OSKBaseKey[]; public readonly heightFraction: number; + public readonly spec: ActiveRow; public constructor(vkbd: VisualKeyboard, layerSpec: ActiveLayer, @@ -23,6 +24,7 @@ export default class OSKRow { // Apply defaults, setting the width and other undefined properties for each key const keys=rowSpec.key; + this.spec = rowSpec; this.keys = []; // Calculate actual key widths by multiplying by the OSK's width and rounding appropriately, diff --git a/web/src/engine/osk/src/visualKeyboard.ts b/web/src/engine/osk/src/visualKeyboard.ts index 0c42a7422fc..bb90538f299 100644 --- a/web/src/engine/osk/src/visualKeyboard.ts +++ b/web/src/engine/osk/src/visualKeyboard.ts @@ -1288,6 +1288,10 @@ export default class VisualKeyboard extends EventEmitter implements Ke return; } + // Set layer-group copies of the computed-size values; they are used by nearest-key + // detection. + this.layerGroup.refreshLayout(this._computedWidth, this._computedHeight); + // Step 3: recalculate gesture parameter values // Skip for doc-keyboards, since they don't do gestures. if(!this.isStatic) { From 849a45c0e13819a4e5956de493fdf71a628c54b1 Mon Sep 17 00:00:00 2001 From: Darcy Wong Date: Mon, 1 Apr 2024 11:16:54 +0700 Subject: [PATCH 102/170] fix(android/engine): Swap selection range if reversed --- .../app/src/main/java/com/keyman/engine/KMManager.java | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) 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 a2b0baa7930..c113ab313d4 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 @@ -2139,15 +2139,21 @@ public static boolean updateText(KeyboardType kbType, String text) { public static boolean updateSelectionRange(KeyboardType kbType, int selStart, int selEnd) { 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(selStart, selEnd); + result = InAppKeyboard.updateSelectionRange(selMin, selMax); } InAppKeyboard.setShouldIgnoreSelectionChange(false); } else if (kbType == KeyboardType.KEYBOARD_TYPE_SYSTEM) { if (isKeyboardLoaded(KeyboardType.KEYBOARD_TYPE_SYSTEM) && !SystemKeyboard.shouldIgnoreSelectionChange()) { - result = SystemKeyboard.updateSelectionRange(selStart, selEnd); + result = SystemKeyboard.updateSelectionRange(selMin, selMax); } SystemKeyboard.setShouldIgnoreSelectionChange(false); From 6034033dae04a18414083aa359b116cd167c3476 Mon Sep 17 00:00:00 2001 From: sgschantz Date: Mon, 1 Apr 2024 13:25:45 +0700 Subject: [PATCH 103/170] get a maximum of 80 characters from current context --- .../Keyman4MacIM/KMInputMethodEventHandler.m | 30 +++++++++++++------ 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m b/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m index 2ebda0470a6..697c7e8adfe 100644 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m +++ b/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m @@ -306,21 +306,33 @@ -(void)reportContext:(NSEvent *)event forClient:(id) client { } -(NSString*)readContext:(NSEvent *)event forClient:(id) client { - NSString *contextString = nil; + NSString *contextString = @""; NSAttributedString *attributedString = nil; - // if we can read the text, then get the context + // if we can read the text, then get the context for up to kMaxContent characters if (self.apiCompliance.canReadText) { NSRange selectionRange = [client selectedRange]; - NSRange contextRange = NSMakeRange(0, selectionRange.location); - attributedString = [client attributedSubstringFromRange:contextRange]; + NSUInteger contextLength = MIN(kMaxContext, selectionRange.location); + NSUInteger contextStart = selectionRange.location - contextLength; + + if (contextLength > 0) { + [self.appDelegate logDebugMessage:@" *** InputMethodEventHandler readContext, %d characters", contextLength]; + NSRange contextRange = NSMakeRange(contextStart, contextLength); + attributedString = [client attributedSubstringFromRange:contextRange]; + + // adjust string in case that we receive half of a surrogate pair at context start + // the API appears to always return a full code point, but this could vary by app + if (attributedString.length > 0) { + if (CFStringIsSurrogateLowCharacter([attributedString.string characterAtIndex:0])) { + [self.appDelegate logDebugMessage:@" *** InputMethodEventHandler readContext, first char is low surrogate, reducing context by one character"]; + contextString = [attributedString.string substringFromIndex:1]; + } else { + contextString = attributedString.string; + } + } + } } - if(attributedString == nil) { - contextString = @""; - } else { - contextString = attributedString.string; - } return contextString; } From 48d516dd14b6de2468586d7de8a78f8804a2882f Mon Sep 17 00:00:00 2001 From: "Dr Mark C. Sinclair" Date: Mon, 1 Apr 2024 10:42:43 +0100 Subject: [PATCH 104/170] chore(developer): add check fillLanguages constructs keyboard_info.languages correctly test --- .../test/test-keyboard-info-compiler.ts | 54 ++++++++++++++++++- 1 file changed, 52 insertions(+), 2 deletions(-) diff --git a/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts b/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts index 2cb38a8477f..1808454cc45 100644 --- a/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts +++ b/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts @@ -73,6 +73,9 @@ const KHMER_ANGKOR_KEYBOARD = { ], }; +const KHMER_ANGKOR_DISPLAY_FONT_INFO = { family: "Khmer Mondulkiri", source: [ KHMER_ANGKOR_DISPLAY_FONT ] }; +const KHMER_ANGKOR_OSK_FONT_INFO = { family: "Khmer Busra Kbd", source: [ KHMER_ANGKOR_OSK_FONT ] }; + describe('keyboard-info-compiler', function () { it('compile a .keyboard_info file correctly', async function() { const kpjFilename = KHMER_ANGKOR_KPJ; @@ -330,6 +333,27 @@ describe('keyboard-info-compiler', function () { TextDecoder.prototype.decode = origTextDecoderDecode; }); + it('check fillLanguages constructs keyboard_info.languages correctly', async function() { + const kmpJsonData: KmpJsonFile.KmpJsonFile = { + system: { fileVersion: '', keymanDeveloperVersion: '' }, + options: null, + keyboards: [KHMER_ANGKOR_KEYBOARD], + }; + + const sources = KHMER_ANGKOR_SOURCES; + const compiler = new KeyboardInfoCompiler(); + assert.isTrue(await compiler.init(callbacks, {sources})); + compiler['fontSourceToKeyboardInfoFont'] = async (_kpsFilename: string, _kmpJsonData: KmpJsonFile.KmpJsonFile, _source: string[]) => { + if (_source[0] == KHMER_ANGKOR_DISPLAY_FONT) { + return KHMER_ANGKOR_DISPLAY_FONT_INFO; + } else { // osk font + return KHMER_ANGKOR_OSK_FONT_INFO; + } + } + const result = await compiler['fillLanguages'](KHMER_ANGKOR_KPS, {}, kmpJsonData); + assert.isTrue(result); + }); + it('check fillLanguages returns false if fontSourceToKeyboardInfoFont fails for display font', async function() { const kmpJsonData: KmpJsonFile.KmpJsonFile = { system: { fileVersion: '', keymanDeveloperVersion: '' }, @@ -344,7 +368,7 @@ describe('keyboard-info-compiler', function () { if (_source[0] == KHMER_ANGKOR_DISPLAY_FONT) { return null; } else { // osk font - return { family: '', source: _source }; + return KHMER_ANGKOR_OSK_FONT_INFO; } } const result = await compiler['fillLanguages'](KHMER_ANGKOR_KPS, {}, kmpJsonData); @@ -363,7 +387,7 @@ describe('keyboard-info-compiler', function () { assert.isTrue(await compiler.init(callbacks, {sources})); compiler['fontSourceToKeyboardInfoFont'] = async (_kpsFilename: string, _kmpJsonData: KmpJsonFile.KmpJsonFile, _source: string[]) => { if (_source[0] == KHMER_ANGKOR_DISPLAY_FONT) { - return { family: '', source: _source }; + return KHMER_ANGKOR_DISPLAY_FONT_INFO; } else { // osk font return null; } @@ -372,3 +396,29 @@ describe('keyboard-info-compiler', function () { assert.isFalse(result); }); }); + +// { +// examples: [ +// { +// keys: "x j m E r", +// note: "Name of language", +// text: "ខ្មែរ", +// }, +// ], +// font: { +// family: "Khmer Mondulkiri", +// source: [ +// "Mondulkiri-R.ttf", +// ], +// }, +// oskFont: { +// family: "Khmer Busra Kbd", +// source: [ +// "khmer_busra_kbd.ttf", +// ], +// }, +// languageName: "Khmer", +// regionName: undefined, +// scriptName: undefined, +// displayName: "Khmer", +// } \ No newline at end of file From a50b8214eaba8929074f9294ef3931654637aa84 Mon Sep 17 00:00:00 2001 From: "Dr Mark C. Sinclair" Date: Mon, 1 Apr 2024 10:52:23 +0100 Subject: [PATCH 105/170] chore(developer): add keyboard_info.languages assert --- .../test/test-keyboard-info-compiler.ts | 29 +++++++++---------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts b/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts index 1808454cc45..37e594cadba 100644 --- a/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts +++ b/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts @@ -50,6 +50,7 @@ const EN_LANGTAG = { const KHMER_ANGKOR_DISPLAY_FONT = "Mondulkiri-R.ttf"; const KHMER_ANGKOR_OSK_FONT = "khmer_busra_kbd.ttf"; +const KHMER_ANGKOR_EXAMPLES_NO_ID = { keys: "x j m E r", text: "ខ្មែរ", note: "Name of language" }; const KHMER_ANGKOR_KEYBOARD = { displayFont: KHMER_ANGKOR_DISPLAY_FONT, @@ -57,20 +58,8 @@ const KHMER_ANGKOR_KEYBOARD = { name: "Khmer Angkor", id: "khmer_angkor", version: "1.3", - languages: [ - { - name: "Central Khmer (Khmer, Cambodia)", - id: "km", - }, - ], - examples: [ - { - id: "km", - keys: "x j m E r", - text: "ខ្មែរ", - note: "Name of language", - }, - ], + languages: [ { name: "Central Khmer (Khmer, Cambodia)", id: "km" } ], + examples: [ { id: "km", ...KHMER_ANGKOR_EXAMPLES_NO_ID } ] }; const KHMER_ANGKOR_DISPLAY_FONT_INFO = { family: "Khmer Mondulkiri", source: [ KHMER_ANGKOR_DISPLAY_FONT ] }; @@ -350,8 +339,18 @@ describe('keyboard-info-compiler', function () { return KHMER_ANGKOR_OSK_FONT_INFO; } } - const result = await compiler['fillLanguages'](KHMER_ANGKOR_KPS, {}, kmpJsonData); + const keyboard_info: KeyboardInfoFile = {}; + const result = await compiler['fillLanguages'](KHMER_ANGKOR_KPS, keyboard_info, kmpJsonData); assert.isTrue(result); + assert.deepEqual(keyboard_info.languages, {km: { + examples: [ KHMER_ANGKOR_EXAMPLES_NO_ID ], + font: KHMER_ANGKOR_DISPLAY_FONT_INFO, + oskFont: KHMER_ANGKOR_OSK_FONT_INFO, + languageName: "Khmer", + regionName: undefined, + scriptName: undefined, + displayName: "Khmer", + }}); }); it('check fillLanguages returns false if fontSourceToKeyboardInfoFont fails for display font', async function() { From f2fdee1ee1cde618b903a396d126fada791db9fa Mon Sep 17 00:00:00 2001 From: "Dr Mark C. Sinclair" Date: Mon, 1 Apr 2024 12:00:26 +0100 Subject: [PATCH 106/170] chore(developer): add check fillLanguages can handle two keyboards correctly test --- .../test/test-keyboard-info-compiler.ts | 78 ++++++++++++------- 1 file changed, 52 insertions(+), 26 deletions(-) diff --git a/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts b/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts index 37e594cadba..80c158aaadd 100644 --- a/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts +++ b/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts @@ -65,6 +65,23 @@ const KHMER_ANGKOR_KEYBOARD = { const KHMER_ANGKOR_DISPLAY_FONT_INFO = { family: "Khmer Mondulkiri", source: [ KHMER_ANGKOR_DISPLAY_FONT ] }; const KHMER_ANGKOR_OSK_FONT_INFO = { family: "Khmer Busra Kbd", source: [ KHMER_ANGKOR_OSK_FONT ] }; +const SECOND_DISPLAY_FONT = "second.ttf"; +const SECOND_OSK_FONT = "second_osk.ttf"; +const SECOND_EXAMPLES_NO_ID = { keys: "t w o", text: "two", note: "The number 2" }; + +const SECOND_KEYBOARD = { + displayFont: SECOND_DISPLAY_FONT, + oskFont: SECOND_OSK_FONT, + name: "Second Lang", + id: "second_lang", + version: "0.1", + languages: [ { name: "Second Language", id: "en" } ], + examples: [ { id: "en", ...SECOND_EXAMPLES_NO_ID } ] +}; + +const SECOND_DISPLAY_FONT_INFO = { family: "Second", source: [ SECOND_DISPLAY_FONT ] }; +const SECOND_OSK_FONT_INFO = { family: "Second Kbd", source: [ SECOND_OSK_FONT ] }; + describe('keyboard-info-compiler', function () { it('compile a .keyboard_info file correctly', async function() { const kpjFilename = KHMER_ANGKOR_KPJ; @@ -353,6 +370,41 @@ describe('keyboard-info-compiler', function () { }}); }); + it('check fillLanguages can handle two keyboards correctly', async function() { + const kmpJsonData: KmpJsonFile.KmpJsonFile = { + system: { fileVersion: '', keymanDeveloperVersion: '' }, + options: null, + keyboards: [KHMER_ANGKOR_KEYBOARD, SECOND_KEYBOARD], + }; + + const sources = KHMER_ANGKOR_SOURCES; + const compiler = new KeyboardInfoCompiler(); + assert.isTrue(await compiler.init(callbacks, {sources})); + compiler['fontSourceToKeyboardInfoFont'] = async (_kpsFilename: string, _kmpJsonData: KmpJsonFile.KmpJsonFile, _source: string[]) => { + if (_source[0] == KHMER_ANGKOR_DISPLAY_FONT) { + return KHMER_ANGKOR_DISPLAY_FONT_INFO; + } else if (_source[0] == KHMER_ANGKOR_OSK_FONT) { + return KHMER_ANGKOR_OSK_FONT_INFO; + } else if (_source[0] == SECOND_DISPLAY_FONT) { + return SECOND_DISPLAY_FONT_INFO; + }else { // second osk font + return SECOND_OSK_FONT_INFO; + } + } + const keyboard_info: KeyboardInfoFile = {}; + const result = await compiler['fillLanguages'](KHMER_ANGKOR_KPS, keyboard_info, kmpJsonData); + assert.isTrue(result); + // assert.deepEqual(keyboard_info.languages, {km: { + // examples: [ KHMER_ANGKOR_EXAMPLES_NO_ID ], + // font: KHMER_ANGKOR_DISPLAY_FONT_INFO, + // oskFont: KHMER_ANGKOR_OSK_FONT_INFO, + // languageName: "Khmer", + // regionName: undefined, + // scriptName: undefined, + // displayName: "Khmer", + // }}); + }); + it('check fillLanguages returns false if fontSourceToKeyboardInfoFont fails for display font', async function() { const kmpJsonData: KmpJsonFile.KmpJsonFile = { system: { fileVersion: '', keymanDeveloperVersion: '' }, @@ -395,29 +447,3 @@ describe('keyboard-info-compiler', function () { assert.isFalse(result); }); }); - -// { -// examples: [ -// { -// keys: "x j m E r", -// note: "Name of language", -// text: "ខ្មែរ", -// }, -// ], -// font: { -// family: "Khmer Mondulkiri", -// source: [ -// "Mondulkiri-R.ttf", -// ], -// }, -// oskFont: { -// family: "Khmer Busra Kbd", -// source: [ -// "khmer_busra_kbd.ttf", -// ], -// }, -// languageName: "Khmer", -// regionName: undefined, -// scriptName: undefined, -// displayName: "Khmer", -// } \ No newline at end of file From 3eb8705e58ac3bcbb999c31512dcf8a448b6aca1 Mon Sep 17 00:00:00 2001 From: "Dr Mark C. Sinclair" Date: Mon, 1 Apr 2024 13:54:26 +0100 Subject: [PATCH 107/170] chore(developer): add detailed assert for two keyboards --- .../test/test-keyboard-info-compiler.ts | 41 ++++++++++--------- 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts b/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts index 80c158aaadd..fb3156090b5 100644 --- a/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts +++ b/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts @@ -380,29 +380,32 @@ describe('keyboard-info-compiler', function () { const sources = KHMER_ANGKOR_SOURCES; const compiler = new KeyboardInfoCompiler(); assert.isTrue(await compiler.init(callbacks, {sources})); + let callCount = 0; compiler['fontSourceToKeyboardInfoFont'] = async (_kpsFilename: string, _kmpJsonData: KmpJsonFile.KmpJsonFile, _source: string[]) => { - if (_source[0] == KHMER_ANGKOR_DISPLAY_FONT) { - return KHMER_ANGKOR_DISPLAY_FONT_INFO; - } else if (_source[0] == KHMER_ANGKOR_OSK_FONT) { - return KHMER_ANGKOR_OSK_FONT_INFO; - } else if (_source[0] == SECOND_DISPLAY_FONT) { - return SECOND_DISPLAY_FONT_INFO; - }else { // second osk font - return SECOND_OSK_FONT_INFO; - } - } + callCount++; + const info = [KHMER_ANGKOR_DISPLAY_FONT_INFO, KHMER_ANGKOR_OSK_FONT_INFO, SECOND_DISPLAY_FONT_INFO, SECOND_OSK_FONT_INFO]; + return info[callCount-1]; + }; const keyboard_info: KeyboardInfoFile = {}; const result = await compiler['fillLanguages'](KHMER_ANGKOR_KPS, keyboard_info, kmpJsonData); assert.isTrue(result); - // assert.deepEqual(keyboard_info.languages, {km: { - // examples: [ KHMER_ANGKOR_EXAMPLES_NO_ID ], - // font: KHMER_ANGKOR_DISPLAY_FONT_INFO, - // oskFont: KHMER_ANGKOR_OSK_FONT_INFO, - // languageName: "Khmer", - // regionName: undefined, - // scriptName: undefined, - // displayName: "Khmer", - // }}); + assert.deepEqual(keyboard_info.languages, {km: { + examples: [ KHMER_ANGKOR_EXAMPLES_NO_ID ], + font: KHMER_ANGKOR_DISPLAY_FONT_INFO, + oskFont: KHMER_ANGKOR_OSK_FONT_INFO, + languageName: "Khmer", + regionName: undefined, + scriptName: undefined, + displayName: "Khmer", + },en: { + examples: [ SECOND_EXAMPLES_NO_ID ], + font: SECOND_DISPLAY_FONT_INFO, + oskFont: SECOND_OSK_FONT_INFO, + languageName: "English", + regionName: undefined, + scriptName: undefined, + displayName: "English", + }}); }); it('check fillLanguages returns false if fontSourceToKeyboardInfoFont fails for display font', async function() { From 688fe657bc0360e32ae8232309910e5470b22300 Mon Sep 17 00:00:00 2001 From: "Dr Mark C. Sinclair" Date: Mon, 1 Apr 2024 15:08:16 +0100 Subject: [PATCH 108/170] chore(developer): adjust spacing on two keyboatds test expected result --- .../src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts b/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts index fb3156090b5..4100684e0fe 100644 --- a/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts +++ b/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts @@ -397,7 +397,7 @@ describe('keyboard-info-compiler', function () { regionName: undefined, scriptName: undefined, displayName: "Khmer", - },en: { + }, en: { examples: [ SECOND_EXAMPLES_NO_ID ], font: SECOND_DISPLAY_FONT_INFO, oskFont: SECOND_OSK_FONT_INFO, From 5615a5eb4c22863b602d4893868fb43de4ee80ca Mon Sep 17 00:00:00 2001 From: "Steven R. Loomis" Date: Mon, 1 Apr 2024 12:26:00 -0500 Subject: [PATCH 109/170] chore(common): add more references to 0x10000 - at least add comments pointing back to keyman_core_ldml.ts Fixes: #11072 --- common/include/kmx_file.h | 3 ++- common/web/keyboard-processor/src/text/codes.ts | 10 +++++++--- common/web/types/src/kmx/kmx.ts | 6 +++++- common/windows/cpp/include/legacy_kmx_file.h | 6 +++++- common/windows/delphi/keyboards/kmxfileconsts.pas | 4 ++++ 5 files changed, 23 insertions(+), 6 deletions(-) diff --git a/common/include/kmx_file.h b/common/include/kmx_file.h index 3a6c281948f..6081a666e8a 100644 --- a/common/include/kmx_file.h +++ b/common/include/kmx_file.h @@ -303,7 +303,8 @@ namespace kmx { #define K_MODIFIERFLAG 0x007F #define K_NOTMODIFIERFLAG 0xFF00 // I4548 // Note: DEFAULT_MODIFIER = 0x10000, used by KMX+ for the -// default modifier flag in layers, > 16 bit so not available here +// default modifier flag in layers, > 16 bit so not available here. +// See keys_mod_default in keyman_core_ldml.ts struct COMP_STORE { KMX_DWORD_unaligned dwSystemID; diff --git a/common/web/keyboard-processor/src/text/codes.ts b/common/web/keyboard-processor/src/text/codes.ts index 11490f32f2d..2305d1eed2f 100644 --- a/common/web/keyboard-processor/src/text/codes.ts +++ b/common/web/keyboard-processor/src/text/codes.ts @@ -1,7 +1,7 @@ // TODO: Move to separate folder: 'codes' // We should start splitting off code needed by keyboards even without a KeyboardProcessor active. -// There's an upcoming `/common/web/types` package that 'codes' and 'keyboards' may fit well within. -// In fact, there's a file there (on its branch) that should be merged with this one! + +// see also: common/web/types/src/kmx/kmx.ts const Codes = { // Define Keyman Developer modifier bit-flags (exposed for use by other modules) @@ -25,6 +25,10 @@ const Codes = { "NO_SCROLL_LOCK":0x2000, // NOTSCROLLFLAG "VIRTUAL_KEY":0x4000, // ISVIRTUALKEY "VIRTUAL_CHAR_KEY":0x8000 // VIRTUALCHARKEY // Unused by KMW, but reserved for use by other Keyman engines. + + // Note: keys_mod_default = 0x10000, used by KMX+ for the + // default modifier flag in layers, > 16 bit so not available here. + // See keys_mod_default in keyman_core_ldml.ts }, modifierBitmasks: { @@ -168,4 +172,4 @@ const Codes = { } } -export default Codes; \ No newline at end of file +export default Codes; diff --git a/common/web/types/src/kmx/kmx.ts b/common/web/types/src/kmx/kmx.ts index 6102a69cf55..07d0330ccb2 100644 --- a/common/web/types/src/kmx/kmx.ts +++ b/common/web/types/src/kmx/kmx.ts @@ -345,6 +345,10 @@ export class KMXFile { public static readonly ISVIRTUALKEY = 0x4000; // It is a Virtual Key Sequence public static readonly VIRTUALCHARKEY = 0x8000; // Keyman 6.0: Virtual Key Cap Sequence NOT YET + // Note: DEFAULT_MODIFIER = 0x10000, used by KMX+ for the + // default modifier flag in layers, > 16 bit so not available here. + // See keys_mod_default in keyman_core_ldml.ts + public static readonly MASK_MODIFIER_CHIRAL = KMXFile.LCTRLFLAG | KMXFile.RCTRLFLAG | KMXFile.LALTFLAG | KMXFile.RALTFLAG; public static readonly MASK_MODIFIER_SHIFT = KMXFile.K_SHIFTFLAG; public static readonly MASK_MODIFIER_NONCHIRAL = KMXFile.K_CTRLFLAG | KMXFile.K_ALTFLAG; @@ -457,4 +461,4 @@ export class KMXFile { throw "COMP_KEYBOARD size is "+this.COMP_KEYBOARD.size()+" but should be "+KMXFile.COMP_KEYBOARD_SIZE+" bytes"; } } -} \ No newline at end of file +} diff --git a/common/windows/cpp/include/legacy_kmx_file.h b/common/windows/cpp/include/legacy_kmx_file.h index 9b8f6a59fb1..081defd38d9 100644 --- a/common/windows/cpp/include/legacy_kmx_file.h +++ b/common/windows/cpp/include/legacy_kmx_file.h @@ -303,7 +303,7 @@ #define RALTFLAG 0x0008 // Right Alt flag #define K_SHIFTFLAG 0x0010 // Either shift flag #define K_CTRLFLAG 0x0020 // Either ctrl flag -#define K_ALTFLAG 0x0040 // Either alt flag +#define K_ALTFLAG 0x0040 // Either alt lag //#define K_METAFLAG 0x0080 // Either Meta-key flag (tentative). Not usable in keyboard rules; // Used internally (currently, only by KMW) to ensure Meta-key // shortcuts safely bypass rules @@ -320,6 +320,10 @@ #define K_MODIFIERFLAG 0x007F #define K_NOTMODIFIERFLAG 0xFF00 // I4548 +// Note: DEFAULT_MODIFIER = 0x10000, used by KMX+ for the +// default modifier flag in layers, > 16 bit so not available here. +// See keys_mod_default in keyman_core_ldml.ts + /* These sanity checks help ensure we don't break on-disk struct sizes when we cross diff --git a/common/windows/delphi/keyboards/kmxfileconsts.pas b/common/windows/delphi/keyboards/kmxfileconsts.pas index 966dada8827..96215ad8627 100644 --- a/common/windows/delphi/keyboards/kmxfileconsts.pas +++ b/common/windows/delphi/keyboards/kmxfileconsts.pas @@ -129,6 +129,10 @@ interface KMX_ISVIRTUALKEY = $4000; // It is a Virtual Key Sequence KMX_VIRTUALCHARKEY = $8000; // It is a virtual character key sequence - mnemonic layouts + // Note: KMX_DEFAULT_MODIFIER = $10000, used by KMX+ for the + // default modifier flag in layers, > 16 bit so not available here. + // See keys_mod_default in keyman_core_ldml.ts + // Combinations of key masks KMX_MASK_MODIFIER_CHIRAL = KMX_LCTRLFLAG or KMX_RCTRLFLAG or KMX_LALTFLAG or KMX_RALTFLAG; KMX_MASK_MODIFIER_SHIFT = KMX_SHIFTFLAG; From 9b0cd1388f8b196f6e5cc63085f1ac3b1d8c5809 Mon Sep 17 00:00:00 2001 From: Keyman Build Agent Date: Mon, 1 Apr 2024 14:24:39 -0400 Subject: [PATCH 110/170] auto: increment beta version to 17.0.300 --- HISTORY.md | 6 ++++++ VERSION.md | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/HISTORY.md b/HISTORY.md index 25141d2b978..1c0edda578c 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,5 +1,11 @@ # Keyman Version History +## 17.0.299 beta 2024-04-01 + +* fix(ios): address crash by reading full code point rather than code unit when trimming initial directional-mark (#11113) +* fix(mac): delete correct number of characters from current context when processing BMP or SMP deletes (#11086) +* feat(developer): disallow stray dollarsign in from pattern (#11117) + ## 17.0.298 beta 2024-03-29 * chore(linux): Update debian changelog (#11096) diff --git a/VERSION.md b/VERSION.md index 9941cf40a82..a28de76ead1 100644 --- a/VERSION.md +++ b/VERSION.md @@ -1 +1 @@ -17.0.299 \ No newline at end of file +17.0.300 \ No newline at end of file From 5b1c853557b4412bf3d9c456fac8deb264dfde90 Mon Sep 17 00:00:00 2001 From: "Steven R. Loomis" Date: Mon, 1 Apr 2024 23:21:33 -0500 Subject: [PATCH 111/170] fix(common,core): support other lookup for modifiers - the 'other' keyword was incorrectly called 'default' Fixes: #11072 --- common/include/kmx_file.h | 6 +++--- common/web/keyboard-processor/src/text/codes.ts | 6 +++--- common/web/types/src/kmx/kmx.ts | 6 +++--- common/windows/cpp/include/legacy_kmx_file.h | 8 ++++---- common/windows/delphi/keyboards/kmxfileconsts.pas | 6 +++--- core/include/ldml/keyman_core_ldml.h | 2 +- core/include/ldml/keyman_core_ldml.ts | 6 +++--- core/src/kmx/kmx_plus.cpp | 2 +- core/src/ldml/C7043_ldml.md | 2 +- core/src/ldml/ldml_vkeys.cpp | 4 ++-- .../ldml/keyboards/{k_012_default.xml => k_012_other.xml} | 2 +- core/tests/unit/ldml/keyboards/meson.build | 2 +- 12 files changed, 26 insertions(+), 26 deletions(-) rename core/tests/unit/ldml/keyboards/{k_012_default.xml => k_012_other.xml} (93%) diff --git a/common/include/kmx_file.h b/common/include/kmx_file.h index 6081a666e8a..748ba32d920 100644 --- a/common/include/kmx_file.h +++ b/common/include/kmx_file.h @@ -302,9 +302,9 @@ namespace kmx { #define K_MODIFIERFLAG 0x007F #define K_NOTMODIFIERFLAG 0xFF00 // I4548 -// Note: DEFAULT_MODIFIER = 0x10000, used by KMX+ for the -// default modifier flag in layers, > 16 bit so not available here. -// See keys_mod_default in keyman_core_ldml.ts +// Note: OTHER_MODIFIER = 0x10000, used by KMX+ for the +// other modifier flag in layers, > 16 bit so not available here. +// See keys_mod_other in keyman_core_ldml.ts struct COMP_STORE { KMX_DWORD_unaligned dwSystemID; diff --git a/common/web/keyboard-processor/src/text/codes.ts b/common/web/keyboard-processor/src/text/codes.ts index 2305d1eed2f..3f1ebe7afe8 100644 --- a/common/web/keyboard-processor/src/text/codes.ts +++ b/common/web/keyboard-processor/src/text/codes.ts @@ -26,9 +26,9 @@ const Codes = { "VIRTUAL_KEY":0x4000, // ISVIRTUALKEY "VIRTUAL_CHAR_KEY":0x8000 // VIRTUALCHARKEY // Unused by KMW, but reserved for use by other Keyman engines. - // Note: keys_mod_default = 0x10000, used by KMX+ for the - // default modifier flag in layers, > 16 bit so not available here. - // See keys_mod_default in keyman_core_ldml.ts + // Note: keys_mod_other = 0x10000, used by KMX+ for the + // other modifier flag in layers, > 16 bit so not available here. + // See keys_mod_other in keyman_core_ldml.ts }, modifierBitmasks: { diff --git a/common/web/types/src/kmx/kmx.ts b/common/web/types/src/kmx/kmx.ts index 07d0330ccb2..c5989ec1157 100644 --- a/common/web/types/src/kmx/kmx.ts +++ b/common/web/types/src/kmx/kmx.ts @@ -345,9 +345,9 @@ export class KMXFile { public static readonly ISVIRTUALKEY = 0x4000; // It is a Virtual Key Sequence public static readonly VIRTUALCHARKEY = 0x8000; // Keyman 6.0: Virtual Key Cap Sequence NOT YET - // Note: DEFAULT_MODIFIER = 0x10000, used by KMX+ for the - // default modifier flag in layers, > 16 bit so not available here. - // See keys_mod_default in keyman_core_ldml.ts + // Note: OTHER_MODIFIER = 0x10000, used by KMX+ for the + // other modifier flag in layers, > 16 bit so not available here. + // See keys_mod_other in keyman_core_ldml.ts public static readonly MASK_MODIFIER_CHIRAL = KMXFile.LCTRLFLAG | KMXFile.RCTRLFLAG | KMXFile.LALTFLAG | KMXFile.RALTFLAG; public static readonly MASK_MODIFIER_SHIFT = KMXFile.K_SHIFTFLAG; diff --git a/common/windows/cpp/include/legacy_kmx_file.h b/common/windows/cpp/include/legacy_kmx_file.h index 081defd38d9..d3bc3aed0f6 100644 --- a/common/windows/cpp/include/legacy_kmx_file.h +++ b/common/windows/cpp/include/legacy_kmx_file.h @@ -303,7 +303,7 @@ #define RALTFLAG 0x0008 // Right Alt flag #define K_SHIFTFLAG 0x0010 // Either shift flag #define K_CTRLFLAG 0x0020 // Either ctrl flag -#define K_ALTFLAG 0x0040 // Either alt lag +#define K_ALTFLAG 0x0040 // Either alt flag //#define K_METAFLAG 0x0080 // Either Meta-key flag (tentative). Not usable in keyboard rules; // Used internally (currently, only by KMW) to ensure Meta-key // shortcuts safely bypass rules @@ -320,9 +320,9 @@ #define K_MODIFIERFLAG 0x007F #define K_NOTMODIFIERFLAG 0xFF00 // I4548 -// Note: DEFAULT_MODIFIER = 0x10000, used by KMX+ for the -// default modifier flag in layers, > 16 bit so not available here. -// See keys_mod_default in keyman_core_ldml.ts +// Note: OTHER_MODIFIER = 0x10000, used by KMX+ for the +// other modifier flag in layers, > 16 bit so not available here. +// See keys_mod_other in keyman_core_ldml.ts /* These sanity checks help ensure we don't diff --git a/common/windows/delphi/keyboards/kmxfileconsts.pas b/common/windows/delphi/keyboards/kmxfileconsts.pas index 96215ad8627..2a32184ec04 100644 --- a/common/windows/delphi/keyboards/kmxfileconsts.pas +++ b/common/windows/delphi/keyboards/kmxfileconsts.pas @@ -129,9 +129,9 @@ interface KMX_ISVIRTUALKEY = $4000; // It is a Virtual Key Sequence KMX_VIRTUALCHARKEY = $8000; // It is a virtual character key sequence - mnemonic layouts - // Note: KMX_DEFAULT_MODIFIER = $10000, used by KMX+ for the - // default modifier flag in layers, > 16 bit so not available here. - // See keys_mod_default in keyman_core_ldml.ts + // Note: KMX_OTHER_MODIFIER = $10000, used by KMX+ for the + // other modifier flag in layers, > 16 bit so not available here. + // See keys_mod_other in keyman_core_ldml.ts // Combinations of key masks KMX_MASK_MODIFIER_CHIRAL = KMX_LCTRLFLAG or KMX_RCTRLFLAG or KMX_LALTFLAG or KMX_RALTFLAG; diff --git a/core/include/ldml/keyman_core_ldml.h b/core/include/ldml/keyman_core_ldml.h index 896e6841903..1d8aaa2575d 100644 --- a/core/include/ldml/keyman_core_ldml.h +++ b/core/include/ldml/keyman_core_ldml.h @@ -42,8 +42,8 @@ #define LDML_KEYS_MOD_CTRL 0x20 #define LDML_KEYS_MOD_CTRLL 0x1 #define LDML_KEYS_MOD_CTRLR 0x2 -#define LDML_KEYS_MOD_DEFAULT 0x10000 #define LDML_KEYS_MOD_NONE 0x0 +#define LDML_KEYS_MOD_OTHER 0x10000 #define LDML_KEYS_MOD_SHIFT 0x10 #define LDML_LAYR_LIST_HARDWARE_TOUCH "touch" #define LDML_LENGTH_BKSP 0xC diff --git a/core/include/ldml/keyman_core_ldml.ts b/core/include/ldml/keyman_core_ldml.ts index 06314c4a3d5..b649d9424e7 100644 --- a/core/include/ldml/keyman_core_ldml.ts +++ b/core/include/ldml/keyman_core_ldml.ts @@ -271,9 +271,9 @@ class Constants { readonly keys_mod_shift = 0x0010; /** - * bitmask for 'default'. + * bitmask for 'other'. */ - readonly keys_mod_default = 0x10000; + readonly keys_mod_other = 0x10000; /** * Convenience map for modifiers @@ -289,7 +289,7 @@ class Constants { ["ctrlL", this.keys_mod_ctrlL], ["ctrlR", this.keys_mod_ctrlR], ["shift", this.keys_mod_shift], - ["default", this.keys_mod_default], + ["other", this.keys_mod_other], ] ); diff --git a/core/src/kmx/kmx_plus.cpp b/core/src/kmx/kmx_plus.cpp index 00611a419af..007a87cdf7d 100644 --- a/core/src/kmx/kmx_plus.cpp +++ b/core/src/kmx/kmx_plus.cpp @@ -42,7 +42,7 @@ static_assert(LALTFLAG == LDML_KEYS_MOD_ALTL, "LDML modifier bitfield vs. kmx_fi static_assert(K_ALTFLAG == LDML_KEYS_MOD_ALT, "LDML modifier bitfield vs. kmx_file.h #define mismatch"); static_assert(CAPITALFLAG == LDML_KEYS_MOD_CAPS, "LDML modifier bitfield vs. kmx_file.h #define mismatch"); static_assert(K_SHIFTFLAG == LDML_KEYS_MOD_SHIFT, "LDML modifier bitfield vs. kmx_file.h #define mismatch"); // "either" shift -// LDML_KEYS_MOD_DEFAULT is not present in kmx_file.h (>16 bit) +// LDML_KEYS_MOD_OTHER is not present in kmx_file.h (>16 bit) /** * \def LDML_IS_VALID_MODIFIER_BITS test whether x is a valid modifier bitfield diff --git a/core/src/ldml/C7043_ldml.md b/core/src/ldml/C7043_ldml.md index 700f9b2ddde..b1d2223d556 100644 --- a/core/src/ldml/C7043_ldml.md +++ b/core/src/ldml/C7043_ldml.md @@ -472,7 +472,7 @@ For each key: | 0x0020 | `ctrl` | `K_CTRLFLAG` | Either Control | | 0x0040 | `alt` | `K_ALTFLAG` | Either Alt | | 0x0100 | `caps` | `CAPITALFLAG` | Caps lock | -| 0x10000 | `default` | n/a | Default (not used in conjunction with others) | +| 0x10000 | `other` | n/a | Other (not used in conjunction with others) | TODO-LDML: Note that conforming to other keyman values, left versus right shift cannot be distinguished. diff --git a/core/src/ldml/ldml_vkeys.cpp b/core/src/ldml/ldml_vkeys.cpp index 803c9248db3..07a2ff08ad9 100644 --- a/core/src/ldml/ldml_vkeys.cpp +++ b/core/src/ldml/ldml_vkeys.cpp @@ -67,9 +67,9 @@ vkeys::lookup(km_core_virtual_key vk, uint16_t modifier_state, bool &found) cons } } - // look for a layer with "default" + // look for a layer with "other" { - const vkey_id id_default(vk, (LDML_KEYS_MOD_DEFAULT)); + const vkey_id id_default(vk, (LDML_KEYS_MOD_OTHER)); ret = lookup(id_default, found); if (found) { return ret; diff --git a/core/tests/unit/ldml/keyboards/k_012_default.xml b/core/tests/unit/ldml/keyboards/k_012_other.xml similarity index 93% rename from core/tests/unit/ldml/keyboards/k_012_default.xml rename to core/tests/unit/ldml/keyboards/k_012_other.xml index ef7b21d175c..186cfd88699 100644 --- a/core/tests/unit/ldml/keyboards/k_012_default.xml +++ b/core/tests/unit/ldml/keyboards/k_012_other.xml @@ -20,7 +20,7 @@ will match the default layer - + diff --git a/core/tests/unit/ldml/keyboards/meson.build b/core/tests/unit/ldml/keyboards/meson.build index 90aef564585..e27fb39b3ef 100644 --- a/core/tests/unit/ldml/keyboards/meson.build +++ b/core/tests/unit/ldml/keyboards/meson.build @@ -26,7 +26,7 @@ tests_without_testdata = [ 'k_005_modbittest', 'k_010_mt', 'k_011_mt_iso', - 'k_012_default', + 'k_012_other', 'k_100_keytest', 'k_101_keytest', 'k_102_keytest', From 2b9ab8c27d0426d4773d96c93c0bdc72d3e8562d Mon Sep 17 00:00:00 2001 From: Darcy Wong Date: Tue, 2 Apr 2024 13:03:26 +0700 Subject: [PATCH 112/170] fix(android/app): Track previous device orientation for SystemKeyboard --- .../src/main/java/com/keyman/android/SystemKeyboard.java | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) 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 7e705869308..e9cab39752e 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 @@ -42,6 +42,7 @@ public class SystemKeyboard extends InputMethodService implements OnKeyboardEven private static View inputView = null; private static ExtractedText exText = null; private KMHardwareKeyboardInterpreter interpreter = null; + private int lastOrientation = Configuration.ORIENTATION_UNDEFINED; private static final String TAG = "SystemKeyboard"; @@ -197,7 +198,10 @@ public void onUpdateExtractingVisibility(EditorInfo ei) { @Override public void onConfigurationChanged(Configuration newConfig) { super.onConfigurationChanged(newConfig); - KMManager.onConfigurationChanged(newConfig); + if (newConfig.orientation != lastOrientation) { + lastOrientation = newConfig.orientation; + KMManager.onConfigurationChanged(newConfig); + } } @Override @@ -220,8 +224,9 @@ public void onComputeInsets(InputMethodService.Insets outInsets) { wm.getDefaultDisplay().getSize(size); int inputViewHeight = 0; - if (inputView != null) + if (inputView != null) { inputViewHeight = inputView.getHeight(); + } int bannerHeight = KMManager.getBannerHeight(this); int kbHeight = KMManager.getKeyboardHeight(this); From 02f9ce15c4cbc550d1ccff977ff6a43cd8860bfe Mon Sep 17 00:00:00 2001 From: Marc Durdin Date: Tue, 2 Apr 2024 14:51:33 +0700 Subject: [PATCH 113/170] fix(developer): handle buffer boundaries in four cases Fixes #11092. Addresses buffer boundary tests for four cases, so a fatal error is not returned to the user: * character range too long (U+1234 .. U+2468) * extended string too long ('abcde...xxxxx') * outs too long (store(foo) .... outs(bar)) * virtual key expansion too long ([K_A] .. [K_Z] ...) See #11136 for additional work arising. --- .../src/common/include/kmn_compiler_errors.h | 5 ++++ .../src/compiler/kmn-compiler-messages.ts | 13 +++++++++ .../error_character_range_too_long.kmn | 15 ++++++++++ .../error_extended_string_too_long.kmn | 16 +++++++++++ .../invalid-keyboards/error_outs_too_long.kmn | 18 ++++++++++++ .../error_virtual_key_expansion_too_long.kmn | 17 +++++++++++ developer/src/kmc-kmn/test/test-messages.ts | 28 +++++++++++++++++++ developer/src/kmcmplib/src/CompMsg.cpp | 4 +++ developer/src/kmcmplib/src/Compiler.cpp | 27 +++++++++++++----- 9 files changed, 136 insertions(+), 7 deletions(-) create mode 100644 developer/src/kmc-kmn/test/fixtures/invalid-keyboards/error_character_range_too_long.kmn create mode 100644 developer/src/kmc-kmn/test/fixtures/invalid-keyboards/error_extended_string_too_long.kmn create mode 100644 developer/src/kmc-kmn/test/fixtures/invalid-keyboards/error_outs_too_long.kmn create mode 100644 developer/src/kmc-kmn/test/fixtures/invalid-keyboards/error_virtual_key_expansion_too_long.kmn diff --git a/developer/src/common/include/kmn_compiler_errors.h b/developer/src/common/include/kmn_compiler_errors.h index dc6423af6aa..4973f58abe5 100644 --- a/developer/src/common/include/kmn_compiler_errors.h +++ b/developer/src/common/include/kmn_compiler_errors.h @@ -175,6 +175,11 @@ #define CERR_RepeatedBegin 0x00004073 #define CERR_VirtualKeyInContext 0x00004074 +#define CERR_OutsTooLong 0x00004075 +#define CERR_ExtendedStringTooLong 0x00004076 +#define CERR_VirtualKeyExpansionTooLong 0x00004077 +#define CERR_CharacterRangeTooLong 0x00004078 + #define CWARN_TooManyWarnings 0x00002080 #define CWARN_OldVersion 0x00002081 #define CWARN_BitmapNotUsed 0x00002082 diff --git a/developer/src/kmc-kmn/src/compiler/kmn-compiler-messages.ts b/developer/src/kmc-kmn/src/compiler/kmn-compiler-messages.ts index 5a5208009af..32dac9fb661 100644 --- a/developer/src/kmc-kmn/src/compiler/kmn-compiler-messages.ts +++ b/developer/src/kmc-kmn/src/compiler/kmn-compiler-messages.ts @@ -559,6 +559,19 @@ export class KmnCompilerMessages { static ERROR_VirtualKeyInContext = SevError | 0x074; static Error_VirtualKeyInContext = () => m(this.ERROR_VirtualKeyInContext, `Virtual keys are not permitted in context`); + static ERROR_OutsTooLong = SevError | 0x075; + static Error_OutsTooLong = () => m(this.ERROR_OutsTooLong, `Store cannot be inserted with outs() as it makes the extended string too long`); + + static ERROR_ExtendedStringTooLong = SevError | 0x076; + static Error_ExtendedStringTooLong = () => m(this.ERROR_ExtendedStringTooLong, `Extended string is too long`); + + static ERROR_VirtualKeyExpansionTooLong = SevError | 0x077; + static Error_VirtualKeyExpansionTooLong = () => m(this.ERROR_VirtualKeyExpansionTooLong, `Virtual key expansion is too large`); + + static ERROR_CharacterRangeTooLong = SevError | 0x078; + static Error_CharacterRangeTooLong = () => m(this.ERROR_CharacterRangeTooLong, `Character range is too large and cannot be expanded`); + + static WARN_TooManyWarnings = SevWarn | 0x080; static Warn_TooManyWarnings = () => m(this.WARN_TooManyWarnings, `Too many warnings or errors`); diff --git a/developer/src/kmc-kmn/test/fixtures/invalid-keyboards/error_character_range_too_long.kmn b/developer/src/kmc-kmn/test/fixtures/invalid-keyboards/error_character_range_too_long.kmn new file mode 100644 index 00000000000..e19e3ab9f87 --- /dev/null +++ b/developer/src/kmc-kmn/test/fixtures/invalid-keyboards/error_character_range_too_long.kmn @@ -0,0 +1,15 @@ +store(&NAME) 'error_character_range_too_long' +store(&VERSION) '9.0' + +begin unicode > use(main) + +group(main) using keys + +c maximum store length is 4096 UTF-16 code units, including U+0000 terminator +c #define GLOBAL_BUFSIZE 4096 // compfile.h +c so we need 0x101E - 0x0020 + 1 = 0x0FFF --> 4095 words +c See #11136 for calculation adjustment TODO + +store(x) U+0020 .. U+101E + +any(x) + 'x' > 'x' context diff --git a/developer/src/kmc-kmn/test/fixtures/invalid-keyboards/error_extended_string_too_long.kmn b/developer/src/kmc-kmn/test/fixtures/invalid-keyboards/error_extended_string_too_long.kmn new file mode 100644 index 00000000000..fcae6a5d758 --- /dev/null +++ b/developer/src/kmc-kmn/test/fixtures/invalid-keyboards/error_extended_string_too_long.kmn @@ -0,0 +1,16 @@ +store(&NAME) 'error_extended_string_too_long' +store(&VERSION) '9.0' + +begin unicode > use(main) + +group(main) using keys + +c +c maximum store length is 4096 UTF-16 code units, including U+0000 terminator +c #define GLOBAL_BUFSIZE 4096 // compfile.h +c so we need 0x101B - 0x0020 + 1 = 0x0FFD --> 4092 words, + 4 = 4096 = too long +c See #11136 for calculation adjustment TODO + +store(x) U+0020 .. U+101B + +outs(x) 'abcd' + 'x' > 'x' context diff --git a/developer/src/kmc-kmn/test/fixtures/invalid-keyboards/error_outs_too_long.kmn b/developer/src/kmc-kmn/test/fixtures/invalid-keyboards/error_outs_too_long.kmn new file mode 100644 index 00000000000..750e45d3bad --- /dev/null +++ b/developer/src/kmc-kmn/test/fixtures/invalid-keyboards/error_outs_too_long.kmn @@ -0,0 +1,18 @@ +store(&NAME) 'error_outs_too_long' +store(&VERSION) '9.0' + +begin unicode > use(main) + +group(main) using keys + +c maximum store length is 4096 UTF-16 code units, including U+0000 terminator +c #define GLOBAL_BUFSIZE 4096 // compfile.h +c so we need 0x101C - 0x0020 + 1 = 0x0FFD --> 4093 words +c + 1, for 'a' in the rule below = 4094, which triggers the buffer boundary check. +c Noting that this is conservative and losing 2 possible chars, but not fixing +c in compiler.cpp at this time. +c See #11136 for calculation adjustment TODO + +store(x) U+0020 .. U+101C + +'a' outs(x) + 'x' > 'x' context diff --git a/developer/src/kmc-kmn/test/fixtures/invalid-keyboards/error_virtual_key_expansion_too_long.kmn b/developer/src/kmc-kmn/test/fixtures/invalid-keyboards/error_virtual_key_expansion_too_long.kmn new file mode 100644 index 00000000000..a617f294349 --- /dev/null +++ b/developer/src/kmc-kmn/test/fixtures/invalid-keyboards/error_virtual_key_expansion_too_long.kmn @@ -0,0 +1,17 @@ +store(&NAME) 'error_virtual_key_expansion_too_long' +store(&VERSION) '9.0' + +begin unicode > use(main) + +group(main) using keys + +c maximum store length is 4096 UTF-16 code units, including U+0000 terminator +c #define GLOBAL_BUFSIZE 4096 // compfile.h +c so we need 0x101E - 0x0020 + 1 = 0x0FFF --> 4095 words +c each vk is 5 words long UC_SENTINEL CODE_EXTENDED shift key CODE_EXTENDEDEND (some long history here!) +c we start filling the buffer with 4066 words and then the remaining 30 bytes = 6 VKs A-F +c See #11136 for calculation adjustment TODO + +store(x) U+0020 .. U+1000 [K_A] .. [K_F] + +any(x) + 'x' > 'x' context diff --git a/developer/src/kmc-kmn/test/test-messages.ts b/developer/src/kmc-kmn/test/test-messages.ts index 674dd17e344..e338cbe521f 100644 --- a/developer/src/kmc-kmn/test/test-messages.ts +++ b/developer/src/kmc-kmn/test/test-messages.ts @@ -94,4 +94,32 @@ describe('KmnCompilerMessages', function () { assert.equal(callbacks.messages[0].message, "Virtual keys are not supported in output"); }); + // ERROR_OutsTooLong + + it('should generate ERROR_OutsTooLong if a store referenced in outs() is too long (more than GLOBAL_BUFSIZE elements)', async function() { + await testForMessage(this, ['invalid-keyboards', 'error_outs_too_long.kmn'], KmnCompilerMessages.ERROR_OutsTooLong); + // callbacks.printMessages(); + }); + + // ERROR_ExtendedStringTooLong + + it('should generate ERROR_ExtendedStringTooLong if an extended string is too long (more than GLOBAL_BUFSIZE elements)', async function() { + await testForMessage(this, ['invalid-keyboards', 'error_extended_string_too_long.kmn'], KmnCompilerMessages.ERROR_ExtendedStringTooLong); + // callbacks.printMessages(); + }); + + // ERROR_VirtualKeyExpansionTooLong + + it('should generate ERROR_VirtualKeyExpansionTooLong if a virtual key expansion is too long (more than GLOBAL_BUFSIZE elements)', async function() { + await testForMessage(this, ['invalid-keyboards', 'error_virtual_key_expansion_too_long.kmn'], KmnCompilerMessages.ERROR_VirtualKeyExpansionTooLong); + // callbacks.printMessages(); + }); + + // ERROR_CharacterRangeTooLong + + it('should generate ERROR_CharacterRangeTooLong if a character range would expand to be too long (more than GLOBAL_BUFSIZE elements)', async function() { + await testForMessage(this, ['invalid-keyboards', 'error_character_range_too_long.kmn'], KmnCompilerMessages.ERROR_CharacterRangeTooLong); + // callbacks.printMessages(); + }); + }); diff --git a/developer/src/kmcmplib/src/CompMsg.cpp b/developer/src/kmcmplib/src/CompMsg.cpp index 0c1adfa6e6d..86a9b4d4757 100644 --- a/developer/src/kmcmplib/src/CompMsg.cpp +++ b/developer/src/kmcmplib/src/CompMsg.cpp @@ -111,6 +111,10 @@ const struct CompilerError CompilerErrors[] = { { CERR_DuplicateStore , "A store with this name has already been defined."}, { CERR_RepeatedBegin , "Begin has already been set"}, { CERR_VirtualKeyInContext , "Virtual keys are not permitted in context"}, + { CERR_OutsTooLong , "Store cannot be inserted with outs() as it makes the extended string too long" }, + { CERR_ExtendedStringTooLong , "Extended string is too long" }, + { CERR_VirtualKeyExpansionTooLong , "Virtual key expansion is too large" }, + { CERR_CharacterRangeTooLong , "Character range is too large and cannot be expanded" }, { CHINT_UnreachableRule , "This rule will never be matched as another rule takes precedence"}, { CHINT_NonUnicodeFile , "Keyman Developer has detected that the file has ANSI encoding. Consider converting this file to UTF-8"}, diff --git a/developer/src/kmcmplib/src/Compiler.cpp b/developer/src/kmcmplib/src/Compiler.cpp index 045b1c39ad1..75409f51413 100644 --- a/developer/src/kmcmplib/src/Compiler.cpp +++ b/developer/src/kmcmplib/src/Compiler.cpp @@ -1827,6 +1827,12 @@ KMX_DWORD GetXStringImpl(PKMX_WCHAR tstr, PFILE_KEYBOARD fk, PKMX_WCHAR str, KMX p = str; do { + if (mx >= max) { + // This is an error condition, we want the compiler + // to crash if we reach this + return CERR_BufferOverflow; + } + tokenFound = FALSE; while (iswspace(*p) && !u16chr(token, *p)) p++; if (!*p) break; @@ -1905,7 +1911,7 @@ KMX_DWORD GetXStringImpl(PKMX_WCHAR tstr, PFILE_KEYBOARD fk, PKMX_WCHAR str, KMX case 1: q = (PKMX_WCHAR) u16chr(p + 1, '\"'); if (!q) return CERR_UnterminatedString; - if ((int)(q - p) - 1 + mx > max) return CERR_UnterminatedString; + if ((int)(q - p) - 1 + mx > max) return CERR_ExtendedStringTooLong; if (sFlag) return CERR_StringInVirtualKeySection; u16ncat(tstr, p + 1, (int)(q - p) - 1); // I3481 mx += (int)(q - p) - 1; @@ -1915,7 +1921,7 @@ KMX_DWORD GetXStringImpl(PKMX_WCHAR tstr, PFILE_KEYBOARD fk, PKMX_WCHAR str, KMX case 2: q = (PKMX_WCHAR) u16chr(p + 1, '\''); if (!q) return CERR_UnterminatedString; - if ((int)(q - p) - 1 + mx > max) return CERR_UnterminatedString; + if ((int)(q - p) - 1 + mx > max) return CERR_ExtendedStringTooLong; if (sFlag) return CERR_StringInVirtualKeySection; u16ncat(tstr, p + 1, (int)(q - p) - 1); // I3481 mx += (int)(q - p) - 1; @@ -2031,7 +2037,9 @@ KMX_DWORD GetXStringImpl(PKMX_WCHAR tstr, PFILE_KEYBOARD fk, PKMX_WCHAR str, KMX for (q = fk->dpStoreArray[i].dpString; *q; q++) { tstr[mx++] = *q; - if (mx >= max - 1) return CERR_BufferOverflow; + if (mx >= max - 1) { + return CERR_OutsTooLong; + } } tstr[mx] = 0; continue; @@ -2421,7 +2429,6 @@ KMX_DWORD GetXStringImpl(PKMX_WCHAR tstr, PFILE_KEYBOARD fk, PKMX_WCHAR str, KMX ErrChr = 0; return CERR_None; } - if (mx >= max) return CERR_BufferOverflow; } while (*p); if (!*token) @@ -2630,7 +2637,9 @@ KMX_DWORD process_expansion(PFILE_KEYBOARD fk, PKMX_WCHAR q, PKMX_WCHAR tstr, in return CERR_ExpansionMustBePositive; } // Verify space in buffer - if (*mx + (HighKey - BaseKey) * 5 + 1 >= max) return CERR_BufferOverflow; + if (*mx + (HighKey - BaseKey) * 5 + 1 >= max) { + return CERR_VirtualKeyExpansionTooLong; + } // Inject an expansion. for (BaseKey++; BaseKey < HighKey; BaseKey++) { // < HighKey because caller will add HighKey to output @@ -2657,12 +2666,16 @@ KMX_DWORD process_expansion(PFILE_KEYBOARD fk, PKMX_WCHAR q, PKMX_WCHAR tstr, in // < HighChar because caller will add HighChar to output if (Uni_IsSMP(BaseChar)) { // We'll test on each char to avoid complex calculations crossing SMP boundary - if (*mx + 3 >= max) return CERR_BufferOverflow; + if (*mx + 3 >= max) { + return CERR_CharacterRangeTooLong; + } tstr[(*mx)++] = (KMX_WCHAR) Uni_UTF32ToSurrogate1(BaseChar); tstr[(*mx)++] = (KMX_WCHAR) Uni_UTF32ToSurrogate2(BaseChar); } else { - if (*mx + 2 >= max) return CERR_BufferOverflow; + if (*mx + 2 >= max) { + return CERR_CharacterRangeTooLong; + } tstr[(*mx)++] = (KMX_WCHAR) BaseChar; } } From 707c7ee2927a52209652f080f86a7fd3b712f945 Mon Sep 17 00:00:00 2001 From: "Dr Mark C. Sinclair" Date: Tue, 2 Apr 2024 13:27:29 +0100 Subject: [PATCH 114/170] chore(developer): remove EN_LANGTAG and replace with dynamic tag from langtags --- .../test/test-keyboard-info-compiler.ts | 30 ++++--------------- 1 file changed, 5 insertions(+), 25 deletions(-) diff --git a/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts b/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts index 4100684e0fe..5d7c5181229 100644 --- a/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts +++ b/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts @@ -28,26 +28,6 @@ const KHMER_ANGKOR_SOURCES = { forPublishing: true, }; -const EN_LANGTAG = { - "full": "en-Latn-US", - "iana": [ "English" ], - "iso639_3": "eng", - "localname": "American English", - "localnames": [ "English" ], - "name": "English", - "names": [ "Anglais", "Angleščina", "Anglisy", "Angličtina", "Anglų", "Angol", "Angļu", "Engels", "Engelsk", "Engelska", "Engelski", "Englaisa", "Englanti", "Englesch", "Engleză", "Englisch", "Ingilizce", "Inglese", "Ingliż", "Inglés", "Inglês", "Język angielski", "Kiingereza", "anglais" ], - "region": "US", - "regionname": "United States", - "regions": [ "AD", "AF", "AR", "AS", "AW", "BD", "BG", "BH", "BL", "BN", "BQ", "BT", "BY", "CL", "CN", "CO", "CR", "CW", "CY", "CZ", "DO", "EC", "EE", "ES", "ET", "FM", "FR", "GQ", "GR", "GW", "HN", "HR", "HU", "ID", "IS", "IT", "JP", "KH", "KR", "KW", "LB", "LK", "LT", "LU", "LV", "LY", "MC", "ME", "MF", "MX", "NO", "NP", "OM", "PA", "PL", "PM", "PR", "PT", "RO", "RS", "RU", "SA", "SK", "SO", "SR", "ST", "SV", "TC", "TH", "TN", "TW", "UA", "UM", "UY", "VE", "VG", "VI" ], - "script": "Latn", - "sldr": true, - "suppress": true, - "tag": "en", - "tags": [ "en-Latn", "en-US" ], - "variants": [ "basiceng", "boont", "cornu", "emodeng", "oxendict", "scotland", "scouse", "spanglis", "unifon" ], - "windows": "en-US" -}; - const KHMER_ANGKOR_DISPLAY_FONT = "Mondulkiri-R.ttf"; const KHMER_ANGKOR_OSK_FONT = "khmer_busra_kbd.ttf"; const KHMER_ANGKOR_EXAMPLES_NO_ID = { keys: "x j m E r", text: "ខ្មែរ", note: "Name of language" }; @@ -115,11 +95,11 @@ describe('keyboard-info-compiler', function () { it('check preinit creates langtagsByTag correctly', async function() { const compiler = new KeyboardInfoCompiler(); // indirectly call preinit() assert.isNotNull(compiler); - assert.deepEqual(langtags.find(({ tag }) => tag === 'en'), EN_LANGTAG); - assert.deepEqual((unitTestEndpoints.langtagsByTag)['en'], EN_LANGTAG); - assert.deepEqual((unitTestEndpoints.langtagsByTag)['en-Latn-US'], EN_LANGTAG); - assert.deepEqual((unitTestEndpoints.langtagsByTag)['en-Latn'], EN_LANGTAG); - assert.deepEqual((unitTestEndpoints.langtagsByTag)['en-US'], EN_LANGTAG); + const en_langtag = langtags.find(({ tag }) => tag === 'en'); + assert.deepEqual((unitTestEndpoints.langtagsByTag)['en'], en_langtag); + assert.deepEqual((unitTestEndpoints.langtagsByTag)['en-Latn-US'], en_langtag); + assert.deepEqual((unitTestEndpoints.langtagsByTag)['en-Latn'], en_langtag); + assert.deepEqual((unitTestEndpoints.langtagsByTag)['en-US'], en_langtag); }); it('check init initialises KeyboardInfoCompiler correctly', async function() { From ebeef5f2a87223b21552ee7ff5f78b820ef75c63 Mon Sep 17 00:00:00 2001 From: "Dr Mark C. Sinclair" Date: Tue, 2 Apr 2024 13:57:37 +0100 Subject: [PATCH 115/170] chore(developer): wrap two uses of stubs in try-catch-finally --- .../test/test-keyboard-info-compiler.ts | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts b/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts index 5d7c5181229..2956dbeb12b 100644 --- a/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts +++ b/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts @@ -116,9 +116,15 @@ describe('keyboard-info-compiler', function () { const compiler = new KeyboardInfoCompiler(); assert.isTrue(await compiler.init(callbacks, {sources})); const origKmpCompilerInit = KmpCompiler.prototype.init; - KmpCompiler.prototype.init = async (_callbacks: CompilerCallbacks, _options: KmpCompilerOptions): Promise => false; - const result = await compiler.run(kpjFilename, null); - KmpCompiler.prototype.init = origKmpCompilerInit; + let result: KeyboardInfoCompilerResult; + try { + KmpCompiler.prototype.init = async (_callbacks: CompilerCallbacks, _options: KmpCompilerOptions): Promise => false; + result = await compiler.run(kpjFilename, null); + } catch(e) { + assert.fail(e); + } finally { + KmpCompiler.prototype.init = origKmpCompilerInit; + } assert.isNull(result); }); @@ -128,9 +134,15 @@ describe('keyboard-info-compiler', function () { const compiler = new KeyboardInfoCompiler(); assert.isTrue(await compiler.init(callbacks, {sources})); const origKmpCompilerTransformKpsToKmpObject = KmpCompiler.prototype.transformKpsToKmpObject; - KmpCompiler.prototype.transformKpsToKmpObject = (_kpsFilename: string): KmpJsonFile.KmpJsonFile => null; - const result = await compiler.run(kpjFilename, null); - KmpCompiler.prototype.transformKpsToKmpObject = origKmpCompilerTransformKpsToKmpObject; + let result: KeyboardInfoCompilerResult; + try { + KmpCompiler.prototype.transformKpsToKmpObject = (_kpsFilename: string): KmpJsonFile.KmpJsonFile => null; + result = await compiler.run(kpjFilename, null); + } catch(e) { + assert.fail(e); + } finally { + KmpCompiler.prototype.transformKpsToKmpObject = origKmpCompilerTransformKpsToKmpObject; + } assert.isNull(result); }); From 4aa950744cc6e89adce9ec4ab4d4aca4db23c416 Mon Sep 17 00:00:00 2001 From: "Dr Mark C. Sinclair" Date: Tue, 2 Apr 2024 14:01:01 +0100 Subject: [PATCH 116/170] chore(developer): correct a comment --- .../src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts b/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts index 2956dbeb12b..271e0457856 100644 --- a/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts +++ b/developer/src/kmc-keyboard-info/test/test-keyboard-info-compiler.ts @@ -251,7 +251,7 @@ describe('keyboard-info-compiler', function () { assert.isTrue(await kmpCompiler.init(callbacks, {})); const kmpJsonData = kmpCompiler.transformKpsToKmpObject(kpsFilename); assert.isNotNull(kmpJsonData); - // remove .kps file + // remove .kmx file kmpJsonData.files = kmpJsonData.files.filter(file => !KeymanFileTypes.filenameIs(file.name, KeymanFileTypes.Binary.Keyboard)); const kmxFiles: { filename: string, From 3d870362d564500d4de232615825476a99a55991 Mon Sep 17 00:00:00 2001 From: Keyman Build Agent Date: Tue, 2 Apr 2024 14:05:05 -0400 Subject: [PATCH 117/170] auto: increment beta version to 17.0.301 --- HISTORY.md | 6 ++++++ VERSION.md | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/HISTORY.md b/HISTORY.md index 1c0edda578c..e476b02119a 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,5 +1,11 @@ # Keyman Version History +## 17.0.300 beta 2024-04-02 + +* change(web): keyboard swaps keep original keyboards active until fully ready (#11108) +* fix(android/engine): Swap selection range if reversed (#11127) +* test(developer): keyboard info compiler unit tests (#11000) + ## 17.0.299 beta 2024-04-01 * fix(ios): address crash by reading full code point rather than code unit when trimming initial directional-mark (#11113) diff --git a/VERSION.md b/VERSION.md index a28de76ead1..86bc9bf5557 100644 --- a/VERSION.md +++ b/VERSION.md @@ -1 +1 @@ -17.0.300 \ No newline at end of file +17.0.301 \ No newline at end of file From a273cbfd58daa0ef1484750cf951fa2cffa4da3f Mon Sep 17 00:00:00 2001 From: rc-swag <58423624+rc-swag@users.noreply.github.com> Date: Wed, 3 Apr 2024 10:27:46 +1000 Subject: [PATCH 118/170] fix(windows): decode uri for packageid filename The uri for the downloaded filename need is decoded to turn %20 back to spaces etc. For the download location it encoding is maintained. --- .../install/Keyman.Configuration.UI.KeymanProtocolHandler.pas | 3 ++- .../src/desktop/kmshell/install/UfrmInstallKeyboardFromWeb.pas | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/windows/src/desktop/kmshell/install/Keyman.Configuration.UI.KeymanProtocolHandler.pas b/windows/src/desktop/kmshell/install/Keyman.Configuration.UI.KeymanProtocolHandler.pas index 8b78fa7601d..feadd809453 100644 --- a/windows/src/desktop/kmshell/install/Keyman.Configuration.UI.KeymanProtocolHandler.pas +++ b/windows/src/desktop/kmshell/install/Keyman.Configuration.UI.KeymanProtocolHandler.pas @@ -39,6 +39,7 @@ implementation UfrmInstallKeyboard, Upload_Settings, utildir, + IdURI, utilfiletypes; { TKeymanProtocolHandler } @@ -84,7 +85,7 @@ function TKeymanProtocolHandler.DoHandle(Owner: TComponent; // TODO: refactor into separate unit together with code from UfrmInstallKeyboardFromWeb FTempDir := IncludeTrailingPathDelimiter(CreateTempPath); // I1679 try - FDownloadFilename := FTempDir + PackageID + Ext_PackageFile; + FDownloadFilename := FTempDir + TIdURI.URLDecode(PackageID) + Ext_PackageFile; FDownloadURL := KeymanCom_Protocol_Server + URLPath_PackageDownload(PackageID, BCP47, False); frmDownloadProgress := TfrmDownloadProgress.Create(nil); diff --git a/windows/src/desktop/kmshell/install/UfrmInstallKeyboardFromWeb.pas b/windows/src/desktop/kmshell/install/UfrmInstallKeyboardFromWeb.pas index 31e6eafb67d..14921a80066 100644 --- a/windows/src/desktop/kmshell/install/UfrmInstallKeyboardFromWeb.pas +++ b/windows/src/desktop/kmshell/install/UfrmInstallKeyboardFromWeb.pas @@ -91,6 +91,7 @@ implementation Upload_Settings, utilfiletypes, utildir, + IdURI, utilexecute, VersionInfo; @@ -215,7 +216,7 @@ procedure TfrmInstallKeyboardFromWeb.DownloadAndInstallPackage(const PackageID, begin FTempDir := IncludeTrailingPathDelimiter(CreateTempPath); // I1679 try - FDownloadFilename := FTempDir + PackageID + Ext_PackageFile; + FDownloadFilename := FTempDir + TIdURI.URLDecode(PackageID) + Ext_PackageFile; FDownloadURL := KeymanCom_Protocol_Server + URLPath_PackageDownload(PackageID, BCP47, False); frmDownloadProgress := TfrmDownloadProgress.Create(Self); From debb799caa47deba6903d62b80f79014a1f0abbd Mon Sep 17 00:00:00 2001 From: Marc Durdin Date: Wed, 3 Apr 2024 10:48:35 +0700 Subject: [PATCH 119/170] fix(common): upgrade sentry-cli to 2.31.0 Fixes #11091. Updating to latest sentry-cli on all projects, reviewed release notes and there do not appear to be any breaking changes. sentry-cli 2.19.4 had a bug with uploading wasm symbols: https://github.com/getsentry/sentry-cli/issues/1682 It was fixed in 2.20.0: https://github.com/getsentry/sentry-cli/releases/tag/2.20.0 --- developer/src/kmc/package.json | 2 +- developer/src/tools/sentry-upload-difs.sh | 4 +- package-lock.json | 192 +++++++++++++++++----- web/package.json | 2 +- 4 files changed, 152 insertions(+), 48 deletions(-) diff --git a/developer/src/kmc/package.json b/developer/src/kmc/package.json index 23def4adb07..ff425002091 100644 --- a/developer/src/kmc/package.json +++ b/developer/src/kmc/package.json @@ -55,7 +55,7 @@ "build/unicode-license.txt" ], "devDependencies": { - "@sentry/cli": "^2.19.4", + "@sentry/cli": "^2.31.0", "@types/chai": "^4.1.7", "@types/mocha": "^5.2.7", "@types/node": "^20.4.1", diff --git a/developer/src/tools/sentry-upload-difs.sh b/developer/src/tools/sentry-upload-difs.sh index a7ae63933d5..e2ba41123ee 100755 --- a/developer/src/tools/sentry-upload-difs.sh +++ b/developer/src/tools/sentry-upload-difs.sh @@ -65,7 +65,7 @@ sourcemap_paths=( ) echo "Uploading symbols for developer/" -./src/kmc/node_modules/.bin/sentry-cli upload-dif \ +sentry-cli upload-dif \ --project keyman-developer \ --include-sources \ --no-zips \ @@ -73,7 +73,7 @@ echo "Uploading symbols for developer/" upload_sourcemap() { local smpath="$1" - "$KEYMAN_ROOT/developer/src/kmc/node_modules/.bin/sentry-cli" sourcemaps upload \ + sentry-cli sourcemaps upload \ --no-dedupe \ --org keyman \ --project keyman-developer \ diff --git a/package-lock.json b/package-lock.json index 7f5508bf3e3..67ce85d1db4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -995,7 +995,7 @@ "kmlmp": "build/src/kmlmp.js" }, "devDependencies": { - "@sentry/cli": "^2.19.4", + "@sentry/cli": "^2.31.0", "@types/chai": "^4.1.7", "@types/mocha": "^5.2.7", "@types/node": "^20.4.1", @@ -2547,26 +2547,6 @@ "node": ">=0.3.1" } }, - "developer/src/kmc/node_modules/@sentry/cli": { - "version": "2.19.4", - "resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-2.19.4.tgz", - "integrity": "sha512-wsSr2O/GVgr/i+DYtie+DNhODyI+HL7F5/0t1HwWMjHJWm4+5XTEauznYgbh2mewkzfUk9+t0CPecA82lEgspg==", - "dev": true, - "hasInstallScript": true, - "dependencies": { - "https-proxy-agent": "^5.0.0", - "node-fetch": "^2.6.7", - "progress": "^2.0.3", - "proxy-from-env": "^1.1.0", - "which": "^2.0.2" - }, - "bin": { - "sentry-cli": "bin/sentry-cli" - }, - "engines": { - "node": ">= 10" - } - }, "developer/src/kmc/node_modules/@types/mocha": { "version": "5.2.7", "dev": true, @@ -3792,14 +3772,14 @@ "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" }, "node_modules/@sentry/cli": { - "version": "2.2.0", + "version": "2.31.0", + "resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-2.31.0.tgz", + "integrity": "sha512-nCESoXAG3kRUO5n3QbDYAqX6RU3z1ORjnd7a3sqijYsCGHfOpcjGdS7JYLVg5if+tXMEF5529BPXFe5Kg/J9tw==", "dev": true, "hasInstallScript": true, - "license": "BSD-3-Clause", "dependencies": { "https-proxy-agent": "^5.0.0", "node-fetch": "^2.6.7", - "npmlog": "^6.0.1", "progress": "^2.0.3", "proxy-from-env": "^1.1.0", "which": "^2.0.2" @@ -3808,7 +3788,131 @@ "sentry-cli": "bin/sentry-cli" }, "engines": { - "node": ">= 12" + "node": ">= 10" + }, + "optionalDependencies": { + "@sentry/cli-darwin": "2.31.0", + "@sentry/cli-linux-arm": "2.31.0", + "@sentry/cli-linux-arm64": "2.31.0", + "@sentry/cli-linux-i686": "2.31.0", + "@sentry/cli-linux-x64": "2.31.0", + "@sentry/cli-win32-i686": "2.31.0", + "@sentry/cli-win32-x64": "2.31.0" + } + }, + "node_modules/@sentry/cli-darwin": { + "version": "2.31.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-2.31.0.tgz", + "integrity": "sha512-VM5liyxMnm4K2g0WsrRPXRCMLhaT09C7gK5Fz/CxKYh9sbMZB7KA4hV/3klkyuyw1+ECF1J66cefhNkFZepUig==", + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-linux-arm": { + "version": "2.31.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-2.31.0.tgz", + "integrity": "sha512-AZoCN3waXEfXGCd3YSrikcX/y63oQe0Tiyapkeoifq/0QhI+2MOOrAQb60gthsXwb0UDK/XeFi3PaxyUCphzxA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux", + "freebsd" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-linux-arm64": { + "version": "2.31.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.31.0.tgz", + "integrity": "sha512-eENJTmXoFX3uNr8xRW7Bua2Sw3V1tylQfdtS85pNjZPdbm3U8wYQSWu2VoZkK2ASOoC+17YC8jTQxq62KWnSeQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux", + "freebsd" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-linux-i686": { + "version": "2.31.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-2.31.0.tgz", + "integrity": "sha512-cQUFb3brhLaNSIoNzjU/YASnTM1I3TDJP9XXzH0eLK9sSopCcDcc6OrYEYvdjJXZKzFv5sbc9UNMsIDbh4+rYg==", + "cpu": [ + "x86", + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux", + "freebsd" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-linux-x64": { + "version": "2.31.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-2.31.0.tgz", + "integrity": "sha512-z1zTNg91nZJRdcGHC/bCU1KwIaifV0MLJteip9KrFDprzhJk1HtMxFOS0+OZ5/UH21CjAFmg9Pj6IAGqm3BYjA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux", + "freebsd" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-win32-i686": { + "version": "2.31.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-2.31.0.tgz", + "integrity": "sha512-+K7fdk57aUd4CmYrQfDGYPzVyxsTnVro6IPb5QSSLpP03dL7ko5208epu4m2SyN/MkFvscy9Di3n3DTvIfDU2w==", + "cpu": [ + "x86", + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-win32-x64": { + "version": "2.31.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-2.31.0.tgz", + "integrity": "sha512-w5cvpZ6VVlhlyleY8TYHmrP7g48vKHnoVt5xFccfxT+HqQI/AxodvzgVvBTM2kB/sh/kHwexp6bJGWCdkGftww==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" } }, "node_modules/@sentry/core": { @@ -4775,13 +4879,13 @@ }, "node_modules/aproba": { "version": "2.0.0", - "devOptional": true, - "license": "ISC" + "license": "ISC", + "optional": true }, "node_modules/are-we-there-yet": { "version": "3.0.1", - "devOptional": true, "license": "ISC", + "optional": true, "dependencies": { "delegates": "^1.0.0", "readable-stream": "^3.6.0" @@ -4792,8 +4896,8 @@ }, "node_modules/are-we-there-yet/node_modules/readable-stream": { "version": "3.6.0", - "devOptional": true, "license": "MIT", + "optional": true, "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", @@ -5454,8 +5558,8 @@ }, "node_modules/color-support": { "version": "1.1.3", - "devOptional": true, "license": "ISC", + "optional": true, "bin": { "color-support": "bin.js" } @@ -5570,8 +5674,8 @@ }, "node_modules/console-control-strings": { "version": "1.1.0", - "devOptional": true, - "license": "ISC" + "license": "ISC", + "optional": true }, "node_modules/content-disposition": { "version": "0.5.4", @@ -5861,8 +5965,8 @@ }, "node_modules/delegates": { "version": "1.0.0", - "devOptional": true, - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/depd": { "version": "1.1.2", @@ -7581,8 +7685,8 @@ }, "node_modules/gauge": { "version": "4.0.4", - "devOptional": true, "license": "ISC", + "optional": true, "dependencies": { "aproba": "^1.0.3 || ^2.0.0", "color-support": "^1.1.3", @@ -7599,24 +7703,24 @@ }, "node_modules/gauge/node_modules/ansi-regex": { "version": "5.0.1", - "devOptional": true, "license": "MIT", + "optional": true, "engines": { "node": ">=8" } }, "node_modules/gauge/node_modules/is-fullwidth-code-point": { "version": "3.0.0", - "devOptional": true, "license": "MIT", + "optional": true, "engines": { "node": ">=8" } }, "node_modules/gauge/node_modules/string-width": { "version": "4.2.3", - "devOptional": true, "license": "MIT", + "optional": true, "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -7628,8 +7732,8 @@ }, "node_modules/gauge/node_modules/strip-ansi": { "version": "6.0.1", - "devOptional": true, "license": "MIT", + "optional": true, "dependencies": { "ansi-regex": "^5.0.1" }, @@ -7639,8 +7743,8 @@ }, "node_modules/gauge/node_modules/wide-align": { "version": "1.1.5", - "devOptional": true, "license": "ISC", + "optional": true, "dependencies": { "string-width": "^1.0.2 || 2 || 3 || 4" } @@ -7996,8 +8100,8 @@ }, "node_modules/has-unicode": { "version": "2.0.1", - "devOptional": true, - "license": "ISC" + "license": "ISC", + "optional": true }, "node_modules/he": { "version": "1.2.0", @@ -10252,8 +10356,8 @@ }, "node_modules/npmlog": { "version": "6.0.2", - "devOptional": true, "license": "ISC", + "optional": true, "dependencies": { "are-we-there-yet": "^3.0.0", "console-control-strings": "^1.1.0", @@ -12719,7 +12823,7 @@ "devDependencies": { "@keymanapp/resources-gosh": "*", "@keymanapp/web-sentry-manager": "*", - "@sentry/cli": "2.2.0", + "@sentry/cli": "^2.31.0", "c8": "^7.12.0", "chai": "^4.3.4", "jsdom": "^23.0.1", diff --git a/web/package.json b/web/package.json index 5c2f6dcc401..683cd5c9d42 100644 --- a/web/package.json +++ b/web/package.json @@ -82,7 +82,7 @@ "devDependencies": { "@keymanapp/resources-gosh": "*", "@keymanapp/web-sentry-manager": "*", - "@sentry/cli": "2.2.0", + "@sentry/cli": "^2.31.0", "c8": "^7.12.0", "chai": "^4.3.4", "jsdom": "^23.0.1", From 4c881a13c81433990b9111a22612a13e90673921 Mon Sep 17 00:00:00 2001 From: Shawn Schantz <89134789+sgschantz@users.noreply.github.com> Date: Wed, 3 Apr 2024 11:48:16 +0700 Subject: [PATCH 120/170] fix typo in comment Co-authored-by: Darcy Wong --- mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m b/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m index 697c7e8adfe..f090492f4b1 100644 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m +++ b/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m @@ -309,7 +309,7 @@ -(NSString*)readContext:(NSEvent *)event forClient:(id) client { NSString *contextString = @""; NSAttributedString *attributedString = nil; - // if we can read the text, then get the context for up to kMaxContent characters + // if we can read the text, then get the context for up to kMaxContext characters if (self.apiCompliance.canReadText) { NSRange selectionRange = [client selectedRange]; NSUInteger contextLength = MIN(kMaxContext, selectionRange.location); From 268332b608634562e442e6635e620630a9534522 Mon Sep 17 00:00:00 2001 From: Marc Durdin Date: Wed, 3 Apr 2024 11:58:39 +0700 Subject: [PATCH 121/170] chore(developer): fix sentry-cli path --- developer/src/kmc/build.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/developer/src/kmc/build.sh b/developer/src/kmc/build.sh index a8986d1213b..03884c37566 100755 --- a/developer/src/kmc/build.sh +++ b/developer/src/kmc/build.sh @@ -126,7 +126,7 @@ if builder_start_action bundle; then mkdir -p build/dist node build-bundler.js - ./node_modules/.bin/sentry-cli sourcemaps inject \ + sentry-cli sourcemaps inject \ --org keyman \ --project keyman-developer \ --release "$VERSION_GIT_TAG" \ From aae25884b51d490ad236063676c90dfaf4a930ef Mon Sep 17 00:00:00 2001 From: Eberhard Beilharz Date: Tue, 2 Apr 2024 17:29:00 +0200 Subject: [PATCH 122/170] chore(linux): Build packages for next Ubuntu version separately This change splits the package builds into building packages for the released Ubuntu versions and for the next version. A failure to build packages for the next version will no longer fail the packaging GHA. Closes #11143. (cherry picked from commit 043f2c65f5f43d570b1d3e818f0aae8c248ca251) --- .../actions/build-binary-packages/action.yml | 58 ++++++++++++ .github/workflows/deb-packaging.yml | 92 ++++++++++++------- 2 files changed, 117 insertions(+), 33 deletions(-) create mode 100644 .github/actions/build-binary-packages/action.yml diff --git a/.github/actions/build-binary-packages/action.yml b/.github/actions/build-binary-packages/action.yml new file mode 100644 index 00000000000..79863764270 --- /dev/null +++ b/.github/actions/build-binary-packages/action.yml @@ -0,0 +1,58 @@ +name: build-binary-packages +description: | + Build binary packages +inputs: + dist: + description: 'dist to build binary packages for' + required: true + arch: + description: 'the architecture' + required: false + default: 'amd64' + version: + description: 'The Keyman version' + required: true + prerelease_tag: + description: 'The prerelease tag' + required: true + deb_fullname: + description: 'The full name used for the packages' + required: true + deb_email: + description: 'The email address used for the packages' + required: true +runs: + using: 'composite' + steps: + - name: Download Artifacts + uses: actions/download-artifact@6b208ae046db98c579e8a3aa621ab581ff575935 # v4.1.1 + with: + name: keyman-srcpkg + path: artifacts/keyman-srcpkg + + - name: Build + uses: sillsdev/gha-ubuntu-packaging@1f4b7e7eacb8c82a4d874ee2c371b9bfef7e16ea # v1.0 + with: + dist: "${{ inputs.dist }}" + platform: "${{ inputs.arch }}" + source_dir: "artifacts/keyman-srcpkg" + sourcepackage: "keyman_${{ inputs.version }}-1.dsc" + deb_fullname: ${{inputs.deb_fullname}} + deb_email: ${{inputs.deb_email}} + prerelease_tag: ${{ inputs.prerelease_tag }} + + - name: Output resulting .deb files + shell: bash + run: | + echo '```' >> $GITHUB_STEP_SUMMARY + echo "$(find artifacts/ -name \*.deb)" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + + - name: Store binary packages + uses: actions/upload-artifact@26f96dfa697d77e81fd5907df203aa23a56210a8 # v4.3.0 + with: + name: keyman-binarypkgs-${{ inputs.dist }}_${{ inputs.arch }} + path: | + artifacts/* + !artifacts/keyman-srcpkg/ + if: always() diff --git a/.github/workflows/deb-packaging.yml b/.github/workflows/deb-packaging.yml index 834ef4b892f..08add36f057 100644 --- a/.github/workflows/deb-packaging.yml +++ b/.github/workflows/deb-packaging.yml @@ -109,52 +109,77 @@ jobs: debian/***/* if: always() - binary_packages: - name: Build binary packages + binary_packages_released: + name: Build binary packages for released versions needs: sourcepackage strategy: fail-fast: true matrix: - dist: [focal, jammy, mantic, noble] - arch: [amd64] + dist: [focal, jammy, mantic] runs-on: ubuntu-latest steps: - - name: Download Artifacts - uses: actions/download-artifact@6b208ae046db98c579e8a3aa621ab581ff575935 # v4.1.1 + - name: Build + uses: ./.github/actions/build-binary-package with: - name: keyman-srcpkg - path: artifacts/keyman-srcpkg + dist: ${{ matrix.dist }} + version: ${{ needs.sourcepackage.outputs.VERSION }} + prerelease_tag: ${{ needs.sourcepackage.outputs.PRERELEASE_TAG }} + deb_fullname: ${{env.DEBFULLNAME}} + deb_email: ${{env.DEBEMAIL}} + + binary_packages_unreleased: + name: Build binary packages for next Ubuntu version + needs: sourcepackage + strategy: + fail-fast: true + matrix: + dist: [noble] + runs-on: ubuntu-latest + steps: - name: Build - uses: sillsdev/gha-ubuntu-packaging@1f4b7e7eacb8c82a4d874ee2c371b9bfef7e16ea # v1.0 + continue-on-error: true + uses: ./.github/actions/build-binary-package with: - dist: "${{ matrix.dist }}" - platform: "${{ matrix.arch }}" - source_dir: "artifacts/keyman-srcpkg" - sourcepackage: "keyman_${{ needs.sourcepackage.outputs.VERSION }}-1.dsc" + dist: ${{ matrix.dist }} + version: ${{ needs.sourcepackage.outputs.VERSION }} + prerelease_tag: ${{ needs.sourcepackage.outputs.PRERELEASE_TAG }} deb_fullname: ${{env.DEBFULLNAME}} deb_email: ${{env.DEBEMAIL}} - prerelease_tag: ${{ needs.sourcepackage.outputs.PRERELEASE_TAG }} - - name: Output resulting .deb files - run: | - echo '```' >> $GITHUB_STEP_SUMMARY - echo "$(find artifacts/ -name \*.deb)" >> $GITHUB_STEP_SUMMARY - echo '```' >> $GITHUB_STEP_SUMMARY + autopkg_test: + name: Run autopkgtests + needs: [binary_packages_released] + runs-on: ubuntu-latest - - name: Store binary packages - uses: actions/upload-artifact@26f96dfa697d77e81fd5907df203aa23a56210a8 # v4.3.0 - with: - name: keyman-binarypkgs-${{ matrix.dist }}_${{ matrix.arch }} - path: | - artifacts/* - !artifacts/keyman-srcpkg/ - if: always() + steps: + # - name: Download Artifacts + # uses: actions/download-artifact@6b208ae046db98c579e8a3aa621ab581ff575935 # v4.1.1 + # with: + # path: artifacts + # merge-multiple: true + + # - name: Install dependencies + # run: | + # sudo DEBIAN_FRONTEND=noninteractive apt-get -q -y install autopkgtest qemu-system qemu-utils autodep8 genisoimage python3-distro-info + + # - name: Build test image + # run: | + # cd "${GITHUB_WORKSPACE}/artifacts" + # autopkgtest-buildvm-ubuntu-cloud -v --release=jammy + + # - name: Run tests + # run: | + # cd "${GITHUB_WORKSPACE}/artifacts" + # autopkgtest -B *.deb keyman_*.dsc -- qemu autopkgtest-jammy-amd64.img + - name: Ignore + run: | + echo "Ignored for now - until working solution is in place" deb_signing: name: Sign source and binary packages - needs: [sourcepackage, binary_packages] + needs: [sourcepackage, binary_packages_released, binary_packages_unreleased] runs-on: ubuntu-latest environment: "deploy (linux)" if: github.event.client_payload.isTestBuild == 'false' @@ -256,7 +281,7 @@ jobs: api_verification: name: Verify API for libkeymancore.so - needs: [sourcepackage, binary_packages] + needs: [sourcepackage, binary_packages_released] runs-on: ubuntu-latest steps: @@ -295,26 +320,27 @@ jobs: path: linux/debian/tmp/DEBIAN/symbols if: always() + # We intentionally ignore the results of binary_packages_unreleased set_status: name: Set result status on PR builds - needs: [sourcepackage, binary_packages, api_verification] + needs: [sourcepackage, binary_packages_released, api_verification, autopkg_test] runs-on: ubuntu-latest if: ${{ always() && github.event.client_payload.isTestBuild == 'true' }} steps: - name: Set success - if: needs.sourcepackage.result == 'success' && needs.binary_packages.result == 'success' && needs.api_verification.result == 'success' + if: needs.sourcepackage.result == 'success' && needs.binary_packages_released.result == 'success' && needs.api_verification.result == 'success' && needs.autopkg_test.result == 'success' run: | echo "RESULT=success" >> $GITHUB_ENV echo "MSG=Package build succeeded" >> $GITHUB_ENV - name: Set cancelled - if: needs.sourcepackage.result == 'cancelled' || needs.binary_packages.result == 'cancelled' || needs.api_verification.result == 'cancelled' + if: needs.sourcepackage.result == 'cancelled' || needs.binary_packages_released.result == 'cancelled' || needs.api_verification.result == 'cancelled' || needs.autopkg_test.result == 'cancelled' run: | echo "RESULT=error" >> $GITHUB_ENV echo "MSG=Package build cancelled" >> $GITHUB_ENV - name: Set failure - if: needs.sourcepackage.result == 'failure' || needs.binary_packages.result == 'failure' || needs.api_verification.result == 'failure' + if: needs.sourcepackage.result == 'failure' || needs.binary_packages_released.result == 'failure' || needs.api_verification.result == 'failure' || needs.autopkg_test.result == 'failure' run: | echo "RESULT=failure" >> $GITHUB_ENV echo "MSG=Package build failed" >> $GITHUB_ENV From 005b27984b3320baf620a9635439aef1b3280798 Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Wed, 3 Apr 2024 13:55:59 +0700 Subject: [PATCH 123/170] chore(web): addresses PR review concerns --- .../osk/src/keyboard-layout/oskLayerGroup.ts | 52 ++++++++----------- 1 file changed, 21 insertions(+), 31 deletions(-) diff --git a/web/src/engine/osk/src/keyboard-layout/oskLayerGroup.ts b/web/src/engine/osk/src/keyboard-layout/oskLayerGroup.ts index cd3fba90d39..94dcb1931cf 100644 --- a/web/src/engine/osk/src/keyboard-layout/oskLayerGroup.ts +++ b/web/src/engine/osk/src/keyboard-layout/oskLayerGroup.ts @@ -9,8 +9,7 @@ import VisualKeyboard from '../visualKeyboard.js'; import OSKRow from './oskRow.js'; import OSKBaseKey from './oskBaseKey.js'; -const NEAREST_KEY_HORIZ_FUDGE_FACTOR = 0.6; -const NEAREST_KEY_MIN_HORIZONTAL_FUDGE_PIXELS = 24; +const NEAREST_KEY_TOUCH_MARGIN_PERCENT = 0.06; export default class OSKLayerGroup { public readonly element: HTMLDivElement; @@ -179,8 +178,6 @@ export default class OSKLayerGroup { // Our pre-processed layout info maps whatever shape the keyboard is in into a unit square. // So, we map our coord to find its location within that square. const proportionalCoords = { - // Note: need to cache these two values in some manner; if the keyboard is swapped, - // we need them if any keypresses are registered before the swap completes. x: coord.targetX / this.computedWidth, y: coord.targetY / this.computedHeight }; @@ -192,30 +189,25 @@ export default class OSKLayerGroup { } // Step 1: find the nearest row. - let row: OSKRow = null; - let bestMatchDistance = Number.MAX_VALUE; - - // Max distance from the key's center to consider, vertically. // Rows aren't variable-height - this value is "one size fits all." - const rowRadius = layer.rows[0].heightFraction / 2; - - // Find the row that the touch-coordinate lies within. - for(const r of layer.rows) { - const distance = Math.abs(proportionalCoords.y - r.spec.proportionalY); - if(distance <= rowRadius) { - row = r; - break; - } else if(distance < bestMatchDistance) { - bestMatchDistance = distance; - row = r; - } - } + + /* + If 4 rows, y = .2 x 4 = .8 - still within the row with index 0 (spanning from 0 to .25) + y = .6 x 4 = 2.4 - within row with index 2 (third row, spanning .5 to .75) + + 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 row = layer.rows[rowIndex]; + // Assertion: row no longer `null`. // (We already prevented the no-rows available scenario, anyway.) // Step 2: Find minimum distance from any key // - If the coord is within a key's square, go ahead and return it. let closestKey: OSKBaseKey = null; + // Is percentage-based! let minDistance = Number.MAX_VALUE; for (let key of row.keys) { @@ -241,18 +233,16 @@ export default class OSKLayerGroup { } } - // Step 3: If the input coordinate wasn't within any valid key's "square", - // determine if the nearest valid key is acceptable. - const minHorizFudge = NEAREST_KEY_MIN_HORIZONTAL_FUDGE_PIXELS / this.element.offsetWidth; + /* + Step 3: If the input coordinate wasn't within any valid key's "square", + determine if the nearest valid key is acceptable - if it's within 60% of + a standard key's width from the touch location. + */ + const keyTouchMarginPc = NEAREST_KEY_TOUCH_MARGIN_PERCENT; // If the condition is not met, there are no valid keys within this row. - if (minDistance < Number.MAX_VALUE) { - let fudgeFactor = closestKey.spec.proportionalWidth * NEAREST_KEY_HORIZ_FUDGE_FACTOR; - fudgeFactor = fudgeFactor > minHorizFudge ? fudgeFactor : minHorizFudge; - - if(minDistance <= fudgeFactor) { - return closestKey.btn; - } + if (minDistance /* %age-based! */ <= keyTouchMarginPc) { + return closestKey.btn; } // Step 4: no matches => return null. The caller should be able to handle such cases, From e07e5ff64a74f08bddb7de7ca283040d8661ba35 Mon Sep 17 00:00:00 2001 From: Eberhard Beilharz Date: Wed, 3 Apr 2024 09:05:35 +0200 Subject: [PATCH 124/170] chore(linux): Fix typo --- .github/workflows/deb-packaging.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deb-packaging.yml b/.github/workflows/deb-packaging.yml index 08add36f057..1ee9b180345 100644 --- a/.github/workflows/deb-packaging.yml +++ b/.github/workflows/deb-packaging.yml @@ -120,7 +120,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Build - uses: ./.github/actions/build-binary-package + uses: ./.github/actions/build-binary-packages with: dist: ${{ matrix.dist }} version: ${{ needs.sourcepackage.outputs.VERSION }} @@ -140,7 +140,7 @@ jobs: steps: - name: Build continue-on-error: true - uses: ./.github/actions/build-binary-package + uses: ./.github/actions/build-binary-packages with: dist: ${{ matrix.dist }} version: ${{ needs.sourcepackage.outputs.VERSION }} From f5bb539b82b21427540b87e41262358c3cf8beea Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Wed, 3 Apr 2024 14:37:42 +0700 Subject: [PATCH 125/170] chore(web): final PR suggestion --- web/src/engine/osk/src/keyboard-layout/oskLayerGroup.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/web/src/engine/osk/src/keyboard-layout/oskLayerGroup.ts b/web/src/engine/osk/src/keyboard-layout/oskLayerGroup.ts index 94dcb1931cf..1803269ef75 100644 --- a/web/src/engine/osk/src/keyboard-layout/oskLayerGroup.ts +++ b/web/src/engine/osk/src/keyboard-layout/oskLayerGroup.ts @@ -237,11 +237,10 @@ export default class OSKLayerGroup { Step 3: If the input coordinate wasn't within any valid key's "square", determine if the nearest valid key is acceptable - if it's within 60% of a standard key's width from the touch location. - */ - const keyTouchMarginPc = NEAREST_KEY_TOUCH_MARGIN_PERCENT; - // If the condition is not met, there are no valid keys within this row. - if (minDistance /* %age-based! */ <= keyTouchMarginPc) { + If the condition is not met, there are no valid keys within this row. + */ + if (minDistance /* %age-based! */ <= NEAREST_KEY_TOUCH_MARGIN_PERCENT) { return closestKey.btn; } From 31ad98df3ad3851a66ed4c840723875004d1aa10 Mon Sep 17 00:00:00 2001 From: Keyman Build Agent Date: Wed, 3 Apr 2024 14:06:37 -0400 Subject: [PATCH 126/170] auto: increment beta version to 17.0.302 --- HISTORY.md | 16 ++++++++++++++++ VERSION.md | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/HISTORY.md b/HISTORY.md index e476b02119a..20aab4080bd 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,5 +1,21 @@ # Keyman Version History +## 17.0.301 beta 2024-04-03 + +* feat(core): support modifiers=other (#11118) +* chore(core): dx better err message on embedded test vkeys (#11119) +* fix(web): key preview stickiness 🪠 (#10778) +* fix(web): early gesture-match abort when unable to extend existing gestures 🪠 (#10836) +* fix(web): infinite model-match replacement looping 🪠 (#10838) +* fix(web): proper gesture-match sequencing 🪠 (#10840) +* change(web): input-event sequentialization 🪠 (#10843) +* fix(web): proper linkage of sources to events 🪠 (#10960) +* fix(developer): handle buffer boundaries in four cases (#11137) +* chore(linux): Build packages for next Ubuntu version separately :cherries: (#11153) +* fix(common): upgrade sentry-cli to 2.31.0 (#11151) +* fix(android/app): Track previous device orientation for SystemKeyboard (#11134) +* (#11129) + ## 17.0.300 beta 2024-04-02 * change(web): keyboard swaps keep original keyboards active until fully ready (#11108) diff --git a/VERSION.md b/VERSION.md index 86bc9bf5557..b6aeb789112 100644 --- a/VERSION.md +++ b/VERSION.md @@ -1 +1 @@ -17.0.301 \ No newline at end of file +17.0.302 \ No newline at end of file From 636d21fe4a4df4511f5299eeacbca39a49f33427 Mon Sep 17 00:00:00 2001 From: rc-swag <58423624+rc-swag@users.noreply.github.com> Date: Thu, 4 Apr 2024 09:14:44 +1000 Subject: [PATCH 127/170] fix(windows): decode packge id at assignment encode uri creation decode the package_id when assinged. encode it again when the uri is formated. --- common/windows/delphi/general/Upload_Settings.pas | 3 ++- .../install/Keyman.Configuration.UI.KeymanProtocolHandler.pas | 4 ++-- .../desktop/kmshell/install/UfrmInstallKeyboardFromWeb.pas | 4 ++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/common/windows/delphi/general/Upload_Settings.pas b/common/windows/delphi/general/Upload_Settings.pas index f38cc078903..34a379565da 100644 --- a/common/windows/delphi/general/Upload_Settings.pas +++ b/common/windows/delphi/general/Upload_Settings.pas @@ -94,6 +94,7 @@ implementation DebugPaths, ErrorControlledRegistry, RegistryKeys, + IdURI, VersionInfo; const @@ -169,7 +170,7 @@ function URLPath_PackageDownload(const PackageID, BCP47: string; IsUpdate: Boole begin if IsUpdate then IsUpdateInt := 1 else IsUpdateInt := 0; - Result := Format(URLPath_PackageDownload_Format, [PackageID, CKeymanVersionInfo.Tier, BCP47, IsUpdateInt]); + Result := Format(URLPath_PackageDownload_Format, [TIdURI.URLEncode(PackageID), CKeymanVersionInfo.Tier, BCP47, IsUpdateInt]); end; end. diff --git a/windows/src/desktop/kmshell/install/Keyman.Configuration.UI.KeymanProtocolHandler.pas b/windows/src/desktop/kmshell/install/Keyman.Configuration.UI.KeymanProtocolHandler.pas index feadd809453..301629504bf 100644 --- a/windows/src/desktop/kmshell/install/Keyman.Configuration.UI.KeymanProtocolHandler.pas +++ b/windows/src/desktop/kmshell/install/Keyman.Configuration.UI.KeymanProtocolHandler.pas @@ -75,7 +75,7 @@ function TKeymanProtocolHandler.DoHandle(Owner: TComponent; if not m.Success then Exit(False); - PackageID := m.Groups[1].Value; + PackageID := TIdURI.URLDecode(m.Groups[1].Value); if m.Groups.Count > 2 then BCP47 := m.Groups[2].Value else BCP47 := ''; @@ -85,7 +85,7 @@ function TKeymanProtocolHandler.DoHandle(Owner: TComponent; // TODO: refactor into separate unit together with code from UfrmInstallKeyboardFromWeb FTempDir := IncludeTrailingPathDelimiter(CreateTempPath); // I1679 try - FDownloadFilename := FTempDir + TIdURI.URLDecode(PackageID) + Ext_PackageFile; + FDownloadFilename := FTempDir + PackageID + Ext_PackageFile; FDownloadURL := KeymanCom_Protocol_Server + URLPath_PackageDownload(PackageID, BCP47, False); frmDownloadProgress := TfrmDownloadProgress.Create(nil); diff --git a/windows/src/desktop/kmshell/install/UfrmInstallKeyboardFromWeb.pas b/windows/src/desktop/kmshell/install/UfrmInstallKeyboardFromWeb.pas index 14921a80066..f209c3b9a7e 100644 --- a/windows/src/desktop/kmshell/install/UfrmInstallKeyboardFromWeb.pas +++ b/windows/src/desktop/kmshell/install/UfrmInstallKeyboardFromWeb.pas @@ -191,7 +191,7 @@ procedure TfrmInstallKeyboardFromWeb.cefBeforeBrowseEx(Sender: TObject; const Ur if m.Success then begin // We want to install the keyboard found in the path. - PackageID := m.Groups[1].Value; + PackageID := TIdURI.URLDecode(m.Groups[1].Value); uri := TURI.Create(url); try @@ -216,7 +216,7 @@ procedure TfrmInstallKeyboardFromWeb.DownloadAndInstallPackage(const PackageID, begin FTempDir := IncludeTrailingPathDelimiter(CreateTempPath); // I1679 try - FDownloadFilename := FTempDir + TIdURI.URLDecode(PackageID) + Ext_PackageFile; + FDownloadFilename := FTempDir + PackageID + Ext_PackageFile; FDownloadURL := KeymanCom_Protocol_Server + URLPath_PackageDownload(PackageID, BCP47, False); frmDownloadProgress := TfrmDownloadProgress.Create(Self); From 653cb3b4b3f37be5d672f41943e93236300b3e4b Mon Sep 17 00:00:00 2001 From: rc-swag <58423624+rc-swag@users.noreply.github.com> Date: Thu, 4 Apr 2024 14:26:23 +1000 Subject: [PATCH 128/170] fix(windows): ditch the Indy library use own URLDecode --- common/windows/delphi/general/Upload_Settings.pas | 4 ++-- .../install/Keyman.Configuration.UI.KeymanProtocolHandler.pas | 4 ++-- .../desktop/kmshell/install/UfrmInstallKeyboardFromWeb.pas | 4 ++-- windows/src/desktop/setup/setup.dpr | 3 ++- windows/src/desktop/setup/setup.dproj | 3 ++- 5 files changed, 10 insertions(+), 8 deletions(-) diff --git a/common/windows/delphi/general/Upload_Settings.pas b/common/windows/delphi/general/Upload_Settings.pas index 34a379565da..d5347f4fb9e 100644 --- a/common/windows/delphi/general/Upload_Settings.pas +++ b/common/windows/delphi/general/Upload_Settings.pas @@ -94,7 +94,7 @@ implementation DebugPaths, ErrorControlledRegistry, RegistryKeys, - IdURI, + utilhttp, VersionInfo; const @@ -170,7 +170,7 @@ function URLPath_PackageDownload(const PackageID, BCP47: string; IsUpdate: Boole begin if IsUpdate then IsUpdateInt := 1 else IsUpdateInt := 0; - Result := Format(URLPath_PackageDownload_Format, [TIdURI.URLEncode(PackageID), CKeymanVersionInfo.Tier, BCP47, IsUpdateInt]); + Result := Format(URLPath_PackageDownload_Format, [URLEncode(PackageID), URLEncode(CKeymanVersionInfo.Tier), URLEncode(BCP47), IsUpdateInt]); end; end. diff --git a/windows/src/desktop/kmshell/install/Keyman.Configuration.UI.KeymanProtocolHandler.pas b/windows/src/desktop/kmshell/install/Keyman.Configuration.UI.KeymanProtocolHandler.pas index 301629504bf..390645ca247 100644 --- a/windows/src/desktop/kmshell/install/Keyman.Configuration.UI.KeymanProtocolHandler.pas +++ b/windows/src/desktop/kmshell/install/Keyman.Configuration.UI.KeymanProtocolHandler.pas @@ -39,7 +39,7 @@ implementation UfrmInstallKeyboard, Upload_Settings, utildir, - IdURI, + utilhttp, utilfiletypes; { TKeymanProtocolHandler } @@ -75,7 +75,7 @@ function TKeymanProtocolHandler.DoHandle(Owner: TComponent; if not m.Success then Exit(False); - PackageID := TIdURI.URLDecode(m.Groups[1].Value); + PackageID := URLDecode(m.Groups[1].Value); if m.Groups.Count > 2 then BCP47 := m.Groups[2].Value else BCP47 := ''; diff --git a/windows/src/desktop/kmshell/install/UfrmInstallKeyboardFromWeb.pas b/windows/src/desktop/kmshell/install/UfrmInstallKeyboardFromWeb.pas index f209c3b9a7e..aff32510372 100644 --- a/windows/src/desktop/kmshell/install/UfrmInstallKeyboardFromWeb.pas +++ b/windows/src/desktop/kmshell/install/UfrmInstallKeyboardFromWeb.pas @@ -91,7 +91,7 @@ implementation Upload_Settings, utilfiletypes, utildir, - IdURI, + utilhttp, utilexecute, VersionInfo; @@ -191,7 +191,7 @@ procedure TfrmInstallKeyboardFromWeb.cefBeforeBrowseEx(Sender: TObject; const Ur if m.Success then begin // We want to install the keyboard found in the path. - PackageID := TIdURI.URLDecode(m.Groups[1].Value); + PackageID := URLDecode(m.Groups[1].Value); uri := TURI.Create(url); try diff --git a/windows/src/desktop/setup/setup.dpr b/windows/src/desktop/setup/setup.dpr index 0d9a16b875a..f1a27cf560b 100644 --- a/windows/src/desktop/setup/setup.dpr +++ b/windows/src/desktop/setup/setup.dpr @@ -45,7 +45,8 @@ uses Keyman.System.MITLicense in '..\..\global\delphi\general\Keyman.System.MITLicense.pas', Keyman.Setup.System.Locales in 'Keyman.Setup.System.Locales.pas', Keyman.System.UILanguageManager in '..\..\global\delphi\general\Keyman.System.UILanguageManager.pas', - Keyman.Setup.System.SetupUILanguageManager in 'Keyman.Setup.System.SetupUILanguageManager.pas'; + Keyman.Setup.System.SetupUILanguageManager in 'Keyman.Setup.System.SetupUILanguageManager.pas', + utilhttp in '..\..\..\..\common\windows\delphi\general\utilhttp.pas'; {$R icons.res} {$R version.res} diff --git a/windows/src/desktop/setup/setup.dproj b/windows/src/desktop/setup/setup.dproj index 33a9d6f4a5d..fd533bb0b40 100644 --- a/windows/src/desktop/setup/setup.dproj +++ b/windows/src/desktop/setup/setup.dproj @@ -149,6 +149,7 @@ + Cfg_2 Base @@ -209,7 +210,7 @@ False - + setup.exe true From db9e80776fc8382b25fd4ecdcc6a09a041b9b78f Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Thu, 4 Apr 2024 14:58:51 +0700 Subject: [PATCH 129/170] fix(common/models): suggestion stability after multiple whitespaces --- .../src/main/correction/context-tracker.ts | 35 ++++++++++++------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/common/web/lm-worker/src/main/correction/context-tracker.ts b/common/web/lm-worker/src/main/correction/context-tracker.ts index 16e4d627805..b25f7274401 100644 --- a/common/web/lm-worker/src/main/correction/context-tracker.ts +++ b/common/web/lm-worker/src/main/correction/context-tracker.ts @@ -406,6 +406,22 @@ export class ContextTracker extends CircularArray { * - For languages using whitespace to word-break, said keystroke would have to include said whitespace to break the assumption. */ + function maintainLastToken() { + if(isWhitespace && editPath[tailIndex] == 'match') { + /* + We can land here if there are multiple whitespaces in a row. + There's already an implied whitespace to the left, so we conceptually + merge the new whitespace with that one. + */ + return state; + } else if(isBackspace) { + // Consider backspace entry for this case? + state.replaceTailForBackspace(finalToken, primaryInput.id); + } else { + state.updateTail(primaryInput ? transformDistribution : null, finalToken); + } + } + // If there is/was more than one context token available... if(editPath.length > 1) { // We're removing a context token, but at least one remains. @@ -438,14 +454,11 @@ export class ContextTracker extends CircularArray { } state.pushTail(pushedToken); - } else { // We're editing the final context token. + } else { + // We're editing the final context token. // TODO: Assumption: we didn't 'miss' any inputs somehow. // As is, may be prone to fragility should the lm-layer's tracked context 'desync' from its host's. - if(isBackspace) { - state.replaceTailForBackspace(finalToken, primaryInput.id); - } else { - state.updateTail(primaryInput ? transformDistribution : null, finalToken); - } + maintainLastToken(); } // There is only one word in the context. } else { @@ -458,13 +471,9 @@ export class ContextTracker extends CircularArray { token.raw = tokenizedContext[0]; token.transformDistributions = [transformDistribution]; state.pushTail(token); - } else { // Edit the lone context token. - // Consider backspace entry for this case? - if(isBackspace) { - state.replaceTailForBackspace(finalToken, primaryInput.id); - } else { - state.updateTail(primaryInput ? transformDistribution : null, finalToken); - } + } else { + // Edit the lone context token. + maintainLastToken(); } } return state; From e85074a6753de958a6cc7bca3d89660be97ec5fe Mon Sep 17 00:00:00 2001 From: rc-swag <58423624+rc-swag@users.noreply.github.com> Date: Thu, 4 Apr 2024 19:21:02 +1000 Subject: [PATCH 130/170] fix(windows): add utilhttp to developer projects --- developer/src/kmconvert/kmconvert.dpr | 3 ++- developer/src/kmconvert/kmconvert.dproj | 1 + developer/src/setup/setup.dpr | 3 ++- developer/src/setup/setup.dproj | 3 ++- .../install/Keyman.Configuration.UI.KeymanProtocolHandler.pas | 2 +- 5 files changed, 8 insertions(+), 4 deletions(-) diff --git a/developer/src/kmconvert/kmconvert.dpr b/developer/src/kmconvert/kmconvert.dpr index d8fffd53c58..ee650baf2fa 100644 --- a/developer/src/kmconvert/kmconvert.dpr +++ b/developer/src/kmconvert/kmconvert.dpr @@ -88,7 +88,8 @@ uses Keyman.System.LexicalModelUtils in '..\common\delphi\lexicalmodels\Keyman.System.LexicalModelUtils.pas', KeymanDeveloperOptions in '..\tike\main\KeymanDeveloperOptions.pas', Keyman.Developer.System.KeymanDeveloperPaths in '..\tike\main\Keyman.Developer.System.KeymanDeveloperPaths.pas', - Keyman.Developer.System.LdmlKeyboardProjectTemplate in 'Keyman.Developer.System.LdmlKeyboardProjectTemplate.pas'; + Keyman.Developer.System.LdmlKeyboardProjectTemplate in 'Keyman.Developer.System.LdmlKeyboardProjectTemplate.pas', + utilhttp in '..\..\..\common\windows\delphi\general\utilhttp.pas'; {$R icons.RES} {$R version.res} diff --git a/developer/src/kmconvert/kmconvert.dproj b/developer/src/kmconvert/kmconvert.dproj index 5314ceda6b4..0fed3d1be3f 100644 --- a/developer/src/kmconvert/kmconvert.dproj +++ b/developer/src/kmconvert/kmconvert.dproj @@ -195,6 +195,7 @@ + Cfg_2 Base diff --git a/developer/src/setup/setup.dpr b/developer/src/setup/setup.dpr index 16231f553ad..867a42fd1e3 100644 --- a/developer/src/setup/setup.dpr +++ b/developer/src/setup/setup.dpr @@ -25,7 +25,8 @@ uses utilexecute in '..\..\..\common\windows\delphi\general\utilexecute.pas', KeymanVersion in '..\..\..\common\windows\delphi\general\KeymanVersion.pas', SFX in '..\..\..\common\windows\delphi\setup\SFX.pas', - Keyman.System.UpdateCheckResponse in '..\..\..\common\windows\delphi\general\Keyman.System.UpdateCheckResponse.pas'; + Keyman.System.UpdateCheckResponse in '..\..\..\common\windows\delphi\general\Keyman.System.UpdateCheckResponse.pas', + utilhttp in '..\..\..\common\windows\delphi\general\utilhttp.pas'; {$R icons.res} {$R version.res} diff --git a/developer/src/setup/setup.dproj b/developer/src/setup/setup.dproj index 3c0ac53ec08..e702c1d017d 100644 --- a/developer/src/setup/setup.dproj +++ b/developer/src/setup/setup.dproj @@ -124,6 +124,7 @@ + Cfg_2 Base @@ -184,7 +185,7 @@ False - + setup.exe true diff --git a/windows/src/desktop/kmshell/install/Keyman.Configuration.UI.KeymanProtocolHandler.pas b/windows/src/desktop/kmshell/install/Keyman.Configuration.UI.KeymanProtocolHandler.pas index 390645ca247..6f13f6777bd 100644 --- a/windows/src/desktop/kmshell/install/Keyman.Configuration.UI.KeymanProtocolHandler.pas +++ b/windows/src/desktop/kmshell/install/Keyman.Configuration.UI.KeymanProtocolHandler.pas @@ -77,7 +77,7 @@ function TKeymanProtocolHandler.DoHandle(Owner: TComponent; PackageID := URLDecode(m.Groups[1].Value); if m.Groups.Count > 2 - then BCP47 := m.Groups[2].Value + then BCP47 := URLDecode(m.Groups[2].Value) else BCP47 := ''; // Download the package From 2c4d8e34e38a152576e9d1715318729b3180cc50 Mon Sep 17 00:00:00 2001 From: "Steven R. Loomis" Date: Thu, 4 Apr 2024 12:00:09 -0500 Subject: [PATCH 131/170] fix(core): skip leading trail surrogate char in km_core_state_context_set_if_needed() --- core/src/km_core_state_context_set_if_needed.cpp | 7 +++++++ core/tests/unit/ldml/test_context_normalization.cpp | 10 +++++----- 2 files changed, 12 insertions(+), 5 deletions(-) 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..6a26c198ed1 100644 --- a/core/tests/unit/ldml/test_context_normalization.cpp +++ b/core/tests/unit/ldml/test_context_normalization.cpp @@ -109,12 +109,12 @@ void test_context_normalization_hefty() { } void test_context_normalization_invalid_unicode() { - // unpaired surrogate illegal - km_core_cp const application_context[] = { 0xDC01, 0x0020, 0x0020, 0xFFFF, 0x0000 }; - km_core_cp const cached_context[] = { 0xDC01, 0x0020, 0x0020, 0xFFFF, 0x0000 }; + // unpaired surrogate illegal + km_core_cp const application_context[] = { 0xDC01, 0x0020, 0x0020, 0xFFFF, 0x0000 }; + km_core_cp const cached_context[] = {/*skipped*/ 0x0020, 0x0020, 0xFFFF, 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, KM_CORE_DEBUG_CONTEXT_APP)); + assert(is_identical_context(application_context+1, KM_CORE_DEBUG_CONTEXT_APP)); // skip first char assert(is_identical_context(cached_context, KM_CORE_DEBUG_CONTEXT_CACHED)); teardown(); } @@ -123,7 +123,7 @@ 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 + test_context_normalization_invalid_unicode(); // -- unpaired surrogate, illegals } //------------------------------------------------------------------------------------- From 4141bfef2f5665a0ed027cfe6e5b2445b3da8786 Mon Sep 17 00:00:00 2001 From: Keyman Build Agent Date: Thu, 4 Apr 2024 14:08:21 -0400 Subject: [PATCH 132/170] auto: increment beta version to 17.0.303 --- HISTORY.md | 4 ++++ VERSION.md | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/HISTORY.md b/HISTORY.md index 20aab4080bd..879970b2530 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,5 +1,9 @@ # Keyman Version History +## 17.0.302 beta 2024-04-04 + +* fix(mac): load only 80 characters from context when processing keystrokes (#11141) + ## 17.0.301 beta 2024-04-03 * feat(core): support modifiers=other (#11118) diff --git a/VERSION.md b/VERSION.md index b6aeb789112..d43a7b030b7 100644 --- a/VERSION.md +++ b/VERSION.md @@ -1 +1 @@ -17.0.302 \ No newline at end of file +17.0.303 \ No newline at end of file From fdc1db4c78b54a2754d92964964b95ca09e76a89 Mon Sep 17 00:00:00 2001 From: rc-swag <58423624+rc-swag@users.noreply.github.com> Date: Fri, 5 Apr 2024 08:41:32 +1000 Subject: [PATCH 133/170] fix(windows): IDE introduce project file deployment path --- developer/src/setup/setup.dproj | 2 +- windows/src/desktop/setup/setup.dproj | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/developer/src/setup/setup.dproj b/developer/src/setup/setup.dproj index e702c1d017d..c7bf52ca25b 100644 --- a/developer/src/setup/setup.dproj +++ b/developer/src/setup/setup.dproj @@ -185,7 +185,7 @@ False - + setup.exe true diff --git a/windows/src/desktop/setup/setup.dproj b/windows/src/desktop/setup/setup.dproj index fd533bb0b40..d1e4c212cf7 100644 --- a/windows/src/desktop/setup/setup.dproj +++ b/windows/src/desktop/setup/setup.dproj @@ -210,7 +210,7 @@ False - + setup.exe true From 02812af97f6fdc312dd83bf0c8b529ed15f28229 Mon Sep 17 00:00:00 2001 From: Marc Durdin Date: Fri, 5 Apr 2024 09:20:23 +0700 Subject: [PATCH 134/170] fix(common): specify title explicitly when opening PR with hub Fixes #11125. Discussion in #11125; looks like it may be due to a race in hub? --- resources/build/ci/pull-requests.inc.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/build/ci/pull-requests.inc.sh b/resources/build/ci/pull-requests.inc.sh index 65fe0efee66..09c0903fe5f 100644 --- a/resources/build/ci/pull-requests.inc.sh +++ b/resources/build/ci/pull-requests.inc.sh @@ -107,7 +107,7 @@ function ci_open_pull_request { git push origin "$branch" builder_echo "Push complete" - hub pull-request -f --no-edit -l auto + hub pull-request --force --message "$commit_message" --labels auto builder_echo "Pull request created" git switch "$current_branch" From 3a3d2a81779f852947d6d9d5b5b1d8ffa71c1726 Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Tue, 2 Apr 2024 16:23:34 +0700 Subject: [PATCH 135/170] change(web): merges split async method in gesture engine --- .../headless/asyncClosureDispatchQueue.ts | 72 ++++++++++--------- 1 file changed, 37 insertions(+), 35 deletions(-) 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. From 062537b094e1ae23d01f234084f6a487416ddda3 Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Fri, 5 Apr 2024 11:42:09 +0700 Subject: [PATCH 136/170] refactor(web): OSK spacebar-label updates now managed by layer object --- .../osk/src/keyboard-layout/oskLayer.ts | 50 ++++++++++++++++++- web/src/engine/osk/src/views/oskView.ts | 11 +--- web/src/engine/osk/src/visualKeyboard.ts | 38 ++------------ 3 files changed, 55 insertions(+), 44 deletions(-) diff --git a/web/src/engine/osk/src/keyboard-layout/oskLayer.ts b/web/src/engine/osk/src/keyboard-layout/oskLayer.ts index 9f9486e28e1..a721f3ed480 100644 --- a/web/src/engine/osk/src/keyboard-layout/oskLayer.ts +++ b/web/src/engine/osk/src/keyboard-layout/oskLayer.ts @@ -4,6 +4,11 @@ import OSKRow from './oskRow.js'; import OSKBaseKey from './oskBaseKey.js'; import VisualKeyboard from '../visualKeyboard.js'; +export interface LayerLayoutParams { + keyboardHeight: number; + spacebarText: string; +} + export default class OSKLayer { public readonly element: HTMLDivElement; public readonly rows: OSKRow[]; @@ -76,6 +81,21 @@ export default class OSKLayer { this.capsKey = this.findKey('K_CAPS'); this.numKey = this.findKey('K_NUMLOCK'); this.scrollKey = this.findKey('K_SCROLL'); + + if(this.spaceBarKey) { + const spacebarLabel = this.spaceBarKey.label; + let tParent = spacebarLabel.parentNode; + + if (typeof (tParent.className) == 'undefined' || tParent.className == '') { + tParent.className = 'kmw-spacebar'; + } else if (tParent.className.indexOf('kmw-spacebar') == -1) { + tParent.className += ' kmw-spacebar'; + } + + if (spacebarLabel.className != 'kmw-spacebar-caption') { + spacebarLabel.className = 'kmw-spacebar-caption'; + } + } } /** @@ -96,7 +116,35 @@ export default class OSKLayer { return null; } - public refreshLayout(vkbd: VisualKeyboard, layerHeight: number) { + /** + * Indicate the current language and keyboard on the space bar + **/ + showLanguage(displayName: string) { + if(!this.spaceBarKey) { + return () => {}; + } + + try { + const spacebarLabel = this.spaceBarKey.label; + + // The key can read the text from here during the display update without us + // needing to trigger a reflow by running the closure below early. + this.spaceBarKey.spec.text = displayName; + + // It sounds redundant, but this dramatically cuts down on browser DOM processing; + // but sometimes innerText is reported empty when it actually isn't, so set it + // anyway in that case (Safari, iOS 14.4) + if (spacebarLabel.innerText != displayName || displayName == '') { + spacebarLabel.innerText = displayName; + } + } + catch (ex) { } + } + + public refreshLayout(vkbd: VisualKeyboard, layoutParams: LayerLayoutParams) { + const layerHeight = layoutParams.keyboardHeight; + this.showLanguage(layoutParams.spacebarText); + // Check the heights of each row, in case different layers have different row counts. const nRows = this.rows.length; const rowHeight = this._rowHeight = Math.floor(layerHeight/(nRows == 0 ? 1 : nRows)); diff --git a/web/src/engine/osk/src/views/oskView.ts b/web/src/engine/osk/src/views/oskView.ts index 760a664e123..e7c6ecdd0d6 100644 --- a/web/src/engine/osk/src/views/oskView.ts +++ b/web/src/engine/osk/src/views/oskView.ts @@ -658,6 +658,7 @@ export default abstract class OSKView if(this.bannerView.height > 0) { availableHeight -= this.bannerView.height + 5; } + // Triggers the VisualKeyboard.refreshLayout() method, which includes a showLanguage() call. this.vkbd.setSize(this.computedWidth, availableHeight, pending); const bs = this._Box.style; @@ -665,9 +666,6 @@ export default abstract class OSKView // visualizations, not to help text or empty views. bs.width = bs.maxWidth = this.computedWidth + 'px'; bs.height = bs.maxHeight = this.computedHeight + 'px'; - - // Ensure that the layer's spacebar is properly captioned. - this.vkbd.showLanguage(); } else { const bs = this._Box.style; bs.width = 'auto'; @@ -855,9 +853,8 @@ export default abstract class OSKView // Also, only change the layer ID itself if there is an actual corresponding layer // in the OSK. if(this.vkbd?.layerGroup.layers[newValue] && !this.vkbd?.layerLocked) { + // triggers state-update + layer refresh automatically. this.vkbd.layerId = newValue; - // Ensure that the layer's spacebar is properly captioned. - this.vkbd.showLanguage(); } // Ensure the keyboard view is modeling the correct state. (Correct layer, etc.) @@ -892,10 +889,6 @@ export default abstract class OSKView // First thing after it's made visible. this.refreshLayoutIfNeeded(); - if(this.keyboardView instanceof VisualKeyboard) { - this.keyboardView.showLanguage(); - } - this._Visible=true; /* In case it's still '0' from a hide() operation. diff --git a/web/src/engine/osk/src/visualKeyboard.ts b/web/src/engine/osk/src/visualKeyboard.ts index bb90538f299..89253ea8f5d 100644 --- a/web/src/engine/osk/src/visualKeyboard.ts +++ b/web/src/engine/osk/src/visualKeyboard.ts @@ -1115,38 +1115,6 @@ export default class VisualKeyboard extends EventEmitter implements Ke //#endregion - /** - * Indicate the current language and keyboard on the space bar - **/ - showLanguage() { - let activeStub = this.layoutKeyboardProperties; - let displayName: string = activeStub?.displayName ?? '(System keyboard)'; - - try { - var t = this.spaceBar.key.label; - let tParent = t.parentNode; - if (typeof (tParent.className) == 'undefined' || tParent.className == '') { - tParent.className = 'kmw-spacebar'; - } else if (tParent.className.indexOf('kmw-spacebar') == -1) { - tParent.className += ' kmw-spacebar'; - } - - if (t.className != 'kmw-spacebar-caption') { - t.className = 'kmw-spacebar-caption'; - } - - // It sounds redundant, but this dramatically cuts down on browser DOM processing; - // but sometimes innerText is reported empty when it actually isn't, so set it - // anyway in that case (Safari, iOS 14.4) - if (t.innerText != displayName || displayName == '') { - t.innerText = displayName; - } - - this.spaceBar.key.refreshLayout(this); - } - catch (ex) { } - } - /** * Add or remove a class from a keyboard key (when touched or clicked) * or add a key preview for phone devices @@ -1269,7 +1237,6 @@ export default class VisualKeyboard extends EventEmitter implements Ke const isInDOM = computedStyle.height != '' && computedStyle.height != 'auto'; // Step 2: determine basic layout geometry, refresh things that might update. - this.showLanguage(); // In case the spacebar-text mode setting has changed. if (fixedSize) { this._computedWidth = this.width; @@ -1307,7 +1274,10 @@ export default class VisualKeyboard extends EventEmitter implements Ke // Step 4: perform layout operations. // Needs the refreshed layout info to work correctly. if(this.currentLayer) { - this.currentLayer.refreshLayout(this, this._computedHeight - this.getVerticalLayerGroupPadding()); + this.currentLayer.refreshLayout(this, { + keyboardHeight: this._computedHeight - this.getVerticalLayerGroupPadding(), + spacebarText: this.layoutKeyboardProperties?.displayName ?? '(System keyboard)' + }); } } From 9a016bb65b600ad807c689f65c2fda8f6cc56314 Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Fri, 5 Apr 2024 12:11:35 +0700 Subject: [PATCH 137/170] refactor(web): better centralizes layer-group functionality and properties --- .../osk/src/keyboard-layout/oskLayer.ts | 1 + .../osk/src/keyboard-layout/oskLayerGroup.ts | 46 ++++++++++++++++--- web/src/engine/osk/src/visualKeyboard.ts | 33 ++++--------- 3 files changed, 50 insertions(+), 30 deletions(-) diff --git a/web/src/engine/osk/src/keyboard-layout/oskLayer.ts b/web/src/engine/osk/src/keyboard-layout/oskLayer.ts index a721f3ed480..6fbcb4a509e 100644 --- a/web/src/engine/osk/src/keyboard-layout/oskLayer.ts +++ b/web/src/engine/osk/src/keyboard-layout/oskLayer.ts @@ -5,6 +5,7 @@ import OSKBaseKey from './oskBaseKey.js'; import VisualKeyboard from '../visualKeyboard.js'; export interface LayerLayoutParams { + keyboardWidth: number; keyboardHeight: number; spacebarText: string; } diff --git a/web/src/engine/osk/src/keyboard-layout/oskLayerGroup.ts b/web/src/engine/osk/src/keyboard-layout/oskLayerGroup.ts index 1803269ef75..b471d132d0c 100644 --- a/web/src/engine/osk/src/keyboard-layout/oskLayerGroup.ts +++ b/web/src/engine/osk/src/keyboard-layout/oskLayerGroup.ts @@ -1,12 +1,10 @@ import { ActiveLayer, type DeviceSpec, Keyboard, LayoutLayer, ActiveLayout, ButtonClasses } from '@keymanapp/keyboard-processor'; -import { ManagedPromise } from '@keymanapp/web-utils'; import { InputSample } from '@keymanapp/gesture-recognizer'; import { KeyElement } from '../keyElement.js'; -import OSKLayer from './oskLayer.js'; +import OSKLayer, { LayerLayoutParams } from './oskLayer.js'; import VisualKeyboard from '../visualKeyboard.js'; -import OSKRow from './oskRow.js'; import OSKBaseKey from './oskBaseKey.js'; const NEAREST_KEY_TOUCH_MARGIN_PERCENT = 0.06; @@ -21,6 +19,7 @@ export default class OSKLayerGroup { private computedHeight: number; private _activeLayerId: string = 'default'; + private _heightPadding: number; public constructor(vkbd: VisualKeyboard, keyboard: Keyboard, formFactor: DeviceSpec.FormFactor) { let layout = keyboard.layout(formFactor); @@ -71,6 +70,14 @@ export default class OSKLayerGroup { } } + public get activeLayer(): OSKLayer { + if(!this.activeLayerId) { + return null; + } + + return this.layers[this.activeLayerId]; + } + public get activeLayerId(): string { return this._activeLayerId; } @@ -249,8 +256,35 @@ export default class OSKLayerGroup { return null; } - public refreshLayout(computedWidth: number, computedHeight: number) { - this.computedWidth = computedWidth; - this.computedHeight = computedHeight; + + public refreshLayout(vkbd: VisualKeyboard, layoutParams: LayerLayoutParams) { + // Set layer-group copies of relevant computed-size values; they are used by nearest-key + // detection. + this.computedWidth = layoutParams.keyboardWidth; + this.computedHeight = layoutParams.keyboardHeight; + + // Assumption: this styling value will not change once the keyboard and + // related stylesheets are loaded and applied. + if(this._heightPadding === undefined) { + // Should not trigger a new layout reflow; VisualKeyboard should have made + // no further DOM style changes since the last one. + + // For touch-based OSK layouts, kmwosk.css may include top & bottom + // padding on the layer-group element. + const computedGroupStyle = getComputedStyle(this.element); + + // parseInt('') => NaN, which is falsy; we want to fallback to zero. + let pt = parseInt(computedGroupStyle.paddingTop, 10) || 0; + let pb = parseInt(computedGroupStyle.paddingBottom, 10) || 0; + this._heightPadding = pt + pb; + } + + if(this.activeLayer) { + this.activeLayer.refreshLayout(vkbd, layoutParams); + } + } + + public get verticalPadding() { + return this._heightPadding ?? 0; } } \ No newline at end of file diff --git a/web/src/engine/osk/src/visualKeyboard.ts b/web/src/engine/osk/src/visualKeyboard.ts index 89253ea8f5d..9d9bcfeb017 100644 --- a/web/src/engine/osk/src/visualKeyboard.ts +++ b/web/src/engine/osk/src/visualKeyboard.ts @@ -231,11 +231,11 @@ export default class VisualKeyboard extends EventEmitter implements Ke public readonly layoutKeyboardProperties: KeyboardProperties; get layerId(): string { - return this._layerId; + return this.layerGroup?.activeLayerId ?? 'default'; } set layerId(value: string) { - const changedLayer = value != this._layerId; + const changedLayer = value != this.layerId; if(!this.layerGroup.layers[value]) { throw new Error(`Keyboard ${this.layoutKeyboard.id} does not have a layer with id ${value}`); } else { @@ -255,7 +255,7 @@ export default class VisualKeyboard extends EventEmitter implements Ke } get currentLayer(): OSKLayer { - return this.layerId ? this.layerGroup?.layers[this.layerId] : null; + return this.layerGroup?.activeLayer; } // Special keys (for the currently-visible layer) @@ -811,7 +811,7 @@ export default class VisualKeyboard extends EventEmitter implements Ke get internalHeight(): ParsedLengthStyle { if (this.usesFixedHeightScaling) { // Touch OSKs may apply internal padding to prevent row cropping at the edges. - return ParsedLengthStyle.inPixels(this.layoutHeight.val - this.getVerticalLayerGroupPadding()); + return ParsedLengthStyle.inPixels(this.layoutHeight.val - this.layerGroup.verticalPadding); } else { return ParsedLengthStyle.forScalar(1); } @@ -1255,10 +1255,6 @@ export default class VisualKeyboard extends EventEmitter implements Ke return; } - // Set layer-group copies of the computed-size values; they are used by nearest-key - // detection. - this.layerGroup.refreshLayout(this._computedWidth, this._computedHeight); - // Step 3: recalculate gesture parameter values // Skip for doc-keyboards, since they don't do gestures. if(!this.isStatic) { @@ -1273,22 +1269,11 @@ export default class VisualKeyboard extends EventEmitter implements Ke // Step 4: perform layout operations. // Needs the refreshed layout info to work correctly. - if(this.currentLayer) { - this.currentLayer.refreshLayout(this, { - keyboardHeight: this._computedHeight - this.getVerticalLayerGroupPadding(), - spacebarText: this.layoutKeyboardProperties?.displayName ?? '(System keyboard)' - }); - } - } - - private getVerticalLayerGroupPadding(): number { - // For touch-based OSK layouts, kmwosk.css may include top & bottom padding on the layer-group element. - const computedGroupStyle = getComputedStyle(this.layerGroup.element); - - // parseInt('') => NaN, which is falsy; we want to fallback to zero. - let pt = parseInt(computedGroupStyle.paddingTop, 10) || 0; - let pb = parseInt(computedGroupStyle.paddingBottom, 10) || 0; - return pt + pb; + this.layerGroup.refreshLayout(this, { + keyboardWidth: this._computedWidth, + keyboardHeight: this._computedHeight - this.layerGroup.verticalPadding, + spacebarText: this.layoutKeyboardProperties?.displayName ?? '(System keyboard)' + }); } /*private*/ computedAdjustedOskHeight(allottedHeight: number): number { From a6093c2c06e6dd6d8fa537dc67f160e0aba40b75 Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Fri, 5 Apr 2024 10:57:03 +0700 Subject: [PATCH 138/170] feat(web): VisualKeyboard layout-reflow optimization --- .../osk/src/input/gestures/browser/keytip.ts | 20 +- .../osk/src/keyboard-layout/oskBaseKey.ts | 37 ++-- .../engine/osk/src/keyboard-layout/oskKey.ts | 174 ++++++++++-------- .../osk/src/keyboard-layout/oskLayer.ts | 55 ++++-- .../osk/src/keyboard-layout/oskLayerGroup.ts | 22 ++- .../engine/osk/src/keyboard-layout/oskRow.ts | 77 ++++++-- web/src/engine/osk/src/views/oskView.ts | 6 - web/src/engine/osk/src/visualKeyboard.ts | 108 ++++++----- 8 files changed, 303 insertions(+), 196 deletions(-) diff --git a/web/src/engine/osk/src/input/gestures/browser/keytip.ts b/web/src/engine/osk/src/input/gestures/browser/keytip.ts index a679093db02..5ebac04e1e2 100644 --- a/web/src/engine/osk/src/input/gestures/browser/keytip.ts +++ b/web/src/engine/osk/src/input/gestures/browser/keytip.ts @@ -3,6 +3,7 @@ import { KeyElement } from '../../../keyElement.js'; import KeyTipInterface from '../../../keytip.interface.js'; import VisualKeyboard from '../../../visualKeyboard.js'; import { GesturePreviewHost } from '../../../keyboard-layout/gesturePreviewHost.js'; +import { ParsedLengthStyle } from '../../../lengthStyle.js'; const CSS_PREFIX = 'kmw-'; const DEFAULT_TIP_ORIENTATION: PhoneKeyTipOrientation = 'top'; @@ -111,16 +112,15 @@ export default class KeyTip implements KeyTipInterface { let canvasWidth = xWidth + Math.ceil(xWidth * 0.3) * 2; let canvasHeight = Math.ceil(2.3 * xHeight) + (ySubPixelPadding); // - kts.top = 'auto'; + if(orientation == 'bottom') { + y += canvasHeight - xHeight; + } + kts.top = 'auto'; const unselectedOrientation = orientation == 'top' ? 'bottom' : 'top'; this.tip.classList.remove(`${CSS_PREFIX}${unselectedOrientation}`); this.tip.classList.add(`${CSS_PREFIX}${orientation}`); - if(orientation == 'bottom') { - y += canvasHeight - xHeight; - } - kts.bottom = Math.floor(_BoxRect.height - y) + 'px'; kts.textAlign = 'center'; kts.overflow = 'visible'; @@ -141,14 +141,14 @@ export default class KeyTip implements KeyTipInterface { } if(px != 0) { - let popupFS = previewFontScale * px; let scaleStyle = { - fontFamily: kts.fontFamily, - fontSize: popupFS + 'px', - height: 1.6 * xHeight + 'px' // as opposed to the canvas height of 2.3 * xHeight. + keyWidth: 1.6 * xWidth, + keyHeight: 1.6 * xHeight, // as opposed to the canvas height of 2.3 * xHeight. + baseEmFontSize: vkbd.getKeyEmFontSize(), + layoutFontSize: new ParsedLengthStyle(vkbd.kbdDiv.style.fontSize) }; - kts.fontSize = key.key.getIdealFontSize(vkbd, key.key.keyText, scaleStyle, true); + kts.fontSize = key.key.getIdealFontSize(key.key.keyText, scaleStyle, previewFontScale).styleString; } // Adjust shape if at edges diff --git a/web/src/engine/osk/src/keyboard-layout/oskBaseKey.ts b/web/src/engine/osk/src/keyboard-layout/oskBaseKey.ts index 16522a9ad39..2a273c17aba 100644 --- a/web/src/engine/osk/src/keyboard-layout/oskBaseKey.ts +++ b/web/src/engine/osk/src/keyboard-layout/oskBaseKey.ts @@ -1,14 +1,13 @@ import { ActiveKey, Codes, DeviceSpec } from '@keymanapp/keyboard-processor'; import { landscapeView } from 'keyman/engine/dom-utils'; -import OSKKey, { renameSpecialKey } from './oskKey.js'; +import OSKKey, { KeyLayoutParams, renameSpecialKey } from './oskKey.js'; import { KeyData, KeyElement, link } from '../keyElement.js'; import OSKRow from './oskRow.js'; import VisualKeyboard from '../visualKeyboard.js'; import { ParsedLengthStyle } from '../lengthStyle.js'; import { GesturePreviewHost } from './gesturePreviewHost.js'; - export default class OSKBaseKey extends OSKKey { private capLabel: HTMLDivElement; private previewHost: GesturePreviewHost; @@ -197,29 +196,23 @@ export default class OSKBaseKey extends OSKKey { this.btn.replaceChild(this.preview, oldPreview); } - public refreshLayout(vkbd: VisualKeyboard) { - let key = this.spec as ActiveKey; - this.square.style.width = vkbd.layoutWidth.scaledBy(key.proportionalWidth).styleString; - this.square.style.marginLeft = vkbd.layoutWidth.scaledBy(key.proportionalPad).styleString; - this.btn.style.width = vkbd.usesFixedWidthScaling ? this.square.style.width : '100%'; + public refreshLayout(layoutParams: KeyLayoutParams) { + const keyTextClosure = super.refreshLayout(layoutParams); // key labels in particular. - if(vkbd.usesFixedHeightScaling) { - // Matches its row's height. - this.square.style.height = vkbd.internalHeight.scaledBy(this.row.heightFraction).styleString; - } else { - this.square.style.height = '100%'; // use the full row height - } + return () => { + // part 2: key internals - these do depend on recalculating internal layout. - super.refreshLayout(vkbd); + // Ideally, the rest would be in yet another calculation layer... need to figure out a good design for this. + keyTextClosure(); // we're already in that phase, so go ahead and run it. - const device = vkbd.device; - const resizeLabels = (device.OS == DeviceSpec.OperatingSystem.iOS && - device.formFactor == DeviceSpec.FormFactor.Phone - && landscapeView()); - - // Rescale keycap labels on iPhone (iOS 7) - if(resizeLabels && this.capLabel) { - this.capLabel.style.fontSize = '6px'; + const emFont = layoutParams.baseEmFontSize; + // Rescale keycap labels on small phones + if(emFont.val < 12) { + this.capLabel.style.fontSize = '6px'; + } else { + // The default value set within kmwosk.css. + this.capLabel.style.fontSize = ParsedLengthStyle.forScalar(0.5).styleString; + } } } diff --git a/web/src/engine/osk/src/keyboard-layout/oskKey.ts b/web/src/engine/osk/src/keyboard-layout/oskKey.ts index cbf06eaf735..60b60a65379 100644 --- a/web/src/engine/osk/src/keyboard-layout/oskKey.ts +++ b/web/src/engine/osk/src/keyboard-layout/oskKey.ts @@ -1,17 +1,20 @@ -import { ActiveKey, ActiveSubKey, ButtonClass, DeviceSpec, LayoutKey } from '@keymanapp/keyboard-processor'; -import { TouchLayout } from '@keymanapp/common-types'; -import TouchLayoutFlick = TouchLayout.TouchLayoutFlick; +import { ActiveKey, ActiveSubKey, ButtonClass, ButtonClasses, DeviceSpec } from '@keymanapp/keyboard-processor'; // At present, we don't use @keymanapp/keyman. Just `keyman`. (Refer to /web/package.json.) -import { getAbsoluteX, getAbsoluteY } from 'keyman/engine/dom-utils'; - -import { getFontSizeStyle } from '../fontSizeUtils.js'; import specialChars from '../specialCharacters.js'; import buttonClassNames from '../buttonClassNames.js'; import { KeyElement } from '../keyElement.js'; import VisualKeyboard from '../visualKeyboard.js'; import { getTextMetrics } from './getTextMetrics.js'; +import { ParsedLengthStyle } from '../lengthStyle.js'; + +export interface KeyLayoutParams { + keyWidth: number; + keyHeight: number; + baseEmFontSize: ParsedLengthStyle; + layoutFontSize: ParsedLengthStyle; +} /** * Replace default key names by special font codes for modifier keys @@ -59,6 +62,9 @@ export default abstract class OSKKey { label: HTMLSpanElement; square: HTMLDivElement; + private _fontSize: ParsedLengthStyle; + private _fontFamily: string; + /** * The layer of the OSK on which the key is displayed. */ @@ -169,47 +175,53 @@ export default abstract class OSKKey { /** * Calculate the font size required for a key cap, scaling to fit longer text - * @param vkbd * @param text - * @param style specification for the desired base font size - * @param override if true, don't use the font spec from the button, just use the passed in spec + * @param layoutParams specification for the key + * @param scale additional scaling to apply for the font-size calculation (used by keytips) * @returns font size as a style string */ - getIdealFontSize(vkbd: VisualKeyboard, text: string, style: {height?: string, fontFamily?: string, fontSize: string}, override?: boolean): string { - let buttonStyle: typeof style & {width?: string} = getComputedStyle(this.btn); - let keyWidth = parseFloat(buttonStyle.width); - let emScale = vkbd.getKeyEmFontSize(); + getIdealFontSize(text: string, layoutParams: KeyLayoutParams, scale?: number): ParsedLengthStyle { + // Fallback in case not all style info is currently ready. + if(!this._fontFamily) { + return new ParsedLengthStyle('1em'); + } + + scale ??= 1; + + /* + Properties needed: + - this.btn.style.width (computed & set in this.refreshLayout) + - this.btn.style.height (likewise / in parent row element - is available either way) + - baseEmFontSize (vkbd.getKeyEmFontSize()) - obtainable once, prob at layer-group level + */ + const keyWidth = layoutParams.keyWidth; + const keyHeight = layoutParams.keyHeight; + const emScale = layoutParams.baseEmFontSize.scaledBy(layoutParams.layoutFontSize.val); // Among other things, ensures we use SpecialOSK styling for special key text. // It's set on the key-span, not on the button. // // Also helps ensure that the stub's font-family name is used for keys, should // that mismatch the font-family name specified within the keyboard's touch layout. - const capFont = !this.label ? undefined: getComputedStyle(this.label).fontFamily; - if(capFont) { - buttonStyle = { - fontFamily: capFont, - fontSize: buttonStyle.fontSize, - height: buttonStyle.height - } - } - const originalSize = getFontSizeStyle(style.fontSize || '1em'); + let originalSize = this._fontSize.scaledBy(scale); + if(!originalSize.absolute) { + originalSize = emScale.scaledBy(originalSize.val); + } - // Not yet available; it'll be handled in a later layout pass. - if(!override) { - // When available, just use computedStyle instead. - style = buttonStyle; + const style = { + fontFamily: this._fontFamily, + fontSize: originalSize.styleString, + height: layoutParams.keyHeight } - let fontSpec = getFontSizeStyle(style.fontSize || '1em'); - let metrics = getTextMetrics(text, emScale, style); + let metrics = getTextMetrics(text, emScale.val, style); const MAX_X_PROPORTION = 0.90; const MAX_Y_PROPORTION = 0.90; const X_PADDING = 2; - var fontHeight: number, keyHeight: number; + var fontHeight: number; if(metrics.fontBoundingBoxAscent) { fontHeight = metrics.fontBoundingBoxAscent + metrics.fontBoundingBoxDescent; } @@ -217,10 +229,6 @@ export default abstract class OSKKey { // Don't add extra padding to height - multiplying with MAX_Y_PROPORTION already gives // padding let textHeight = fontHeight ?? 0; - if(style.height && style.height.indexOf('px') != -1) { - keyHeight = Number.parseFloat(style.height.substring(0, style.height.indexOf('px'))); - } - let xProportion = (keyWidth * MAX_X_PROPORTION) / (metrics.width + X_PADDING); // How much of the key does the text want to take? let yProportion = textHeight && keyHeight ? (keyHeight * MAX_Y_PROPORTION) / textHeight : undefined; @@ -232,25 +240,7 @@ export default abstract class OSKKey { // Never upscale keys past the default - only downscale them. // Proportion < 1: ratio of key width to (padded [loosely speaking]) text width // maxProportion determines the 'padding' involved. - // - if(proportion < 1) { - if(originalSize.absolute) { - return proportion * fontSpec.val + 'px'; - } else { - return proportion * originalSize.val + 'em'; - } - } else { - if(originalSize.absolute) { - return fontSpec.val + 'px'; - } else { - return originalSize.val + 'em'; - } - } - } - - getKeyWidth(vkbd: VisualKeyboard): number { - let key = this.spec as ActiveKey; - return key.proportionalWidth * vkbd.width; + return ParsedLengthStyle.forScalar(Math.min(proportion, 1)); } public get keyText(): string { @@ -310,47 +300,73 @@ export default abstract class OSKKey { } // Check the key's display width - does the key visualize well? - let emScale = vkbd.getKeyEmFontSize(); - var width: number = getTextMetrics(keyText, emScale, styleSpec).width; - if(width == 0 && keyText != '' && keyText != '\xa0') { - // Add the Unicode 'empty circle' as a base support for needy diacritics. - - // Disabled by mcdurdin 2020-10-19; dotted circle display is inconsistent on iOS/Safari - // at least and doesn't combine with diacritic marks. For consistent display, it may be - // necessary to build a custom font that does not depend on renderer choices for base - // mark display -- e.g. create marks with custom base included, potentially even on PUA - // code points and use those in rendering the OSK. See #3039 for more details. - // keyText = '\u25cc' + keyText; - - if(vkbd.isRTL) { - // Add the RTL marker to ensure it displays properly. - keyText = '\u200f' + keyText; - } + if(vkbd.isRTL) { + // Add the RTL marker to ensure it displays properly. + keyText = '\u200f' + keyText; } - ts.fontSize = this.getIdealFontSize(vkbd, keyText, styleSpec); - // Finalize the key's text. t.innerText = keyText; return t; } - public refreshLayout(vkbd: VisualKeyboard) { + public resetFontPrecalc() { + this._fontFamily = undefined; + this._fontSize = undefined; + this.label.style.fontSize = ''; + } + + public refreshLayout(layoutParams: KeyLayoutParams) { + // Avoid doing any font-size related calculations if there's no text to display. + if(this.spec.sp == ButtonClasses.spacer || this.spec.sp == ButtonClasses.blank) { + return () => {};; + } + + // Attempt to detect static but key-specific style properties if they haven't yet + // been detected. + if(this._fontFamily === undefined) { + const lblStyle = getComputedStyle(this.label); + + // Abort if the element is not currently in the DOM; we can't get any info this way. + if(!lblStyle.fontFamily) { + return; + } + this._fontFamily = lblStyle.fontFamily; + + // Detect any difference in base (em) font size and that which is computed for the key itself. + const computedFontSize = new ParsedLengthStyle(lblStyle.fontSize); + const layoutFontSize = layoutParams.layoutFontSize; + if(layoutFontSize.absolute) { + // rather than just straight-up taking .layoutFontSize + this._fontSize = computedFontSize; + } else { + const baseEmFontSize = layoutParams.baseEmFontSize; + const baseFontSize = layoutFontSize.scaledBy(baseEmFontSize.val); + const localFontScaling = computedFontSize.val / baseFontSize.val; + this._fontSize = ParsedLengthStyle.forScalar(localFontScaling); + } + } + // space bar may not define the text span! if(this.label) { if(!this.label.classList.contains('kmw-spacebar-caption')) { // Do not use `this.keyText` - it holds *___* codes for special keys, not the actual glyph! const keyCapText = this.label.textContent; - this.label.style.fontSize = this.getIdealFontSize(vkbd, keyCapText, this.btn.style); + const fontSize = this.getIdealFontSize(keyCapText, layoutParams); + return () => { + this.label.style.fontSize = fontSize.styleString; + }; } else { - // Remove any custom setting placed on it before recomputing its inherited style info. - this.label.style.fontSize = ''; - const fontSize = this.getIdealFontSize(vkbd, this.label.textContent, this.btn.style); - - // Since the kmw-spacebar-caption version uses !important, we must specify - // it directly on the element too; otherwise, scaling gets ignored. - this.label.style.setProperty("font-size", fontSize, "important"); + // Spacebar text, on the other hand, is available via this.keyText. + // Using this field helps prevent layout reflow during updates. + const fontSize = this.getIdealFontSize(this.keyText, layoutParams); + + return () => { + // Since the kmw-spacebar-caption version uses !important, we must specify + // it directly on the element too; otherwise, scaling gets ignored. + this.label.style.setProperty("font-size", fontSize.styleString, "important"); + }; } } } diff --git a/web/src/engine/osk/src/keyboard-layout/oskLayer.ts b/web/src/engine/osk/src/keyboard-layout/oskLayer.ts index 6fbcb4a509e..4e9968e77c8 100644 --- a/web/src/engine/osk/src/keyboard-layout/oskLayer.ts +++ b/web/src/engine/osk/src/keyboard-layout/oskLayer.ts @@ -3,13 +3,17 @@ import { ActiveLayer, ActiveLayout } from '@keymanapp/keyboard-processor'; import OSKRow from './oskRow.js'; import OSKBaseKey from './oskBaseKey.js'; import VisualKeyboard from '../visualKeyboard.js'; +import { ParsedLengthStyle } from '../lengthStyle.js'; export interface LayerLayoutParams { keyboardWidth: number; keyboardHeight: number; + widthStyle: ParsedLengthStyle; + heightStyle: ParsedLengthStyle; + baseEmFontSize: ParsedLengthStyle; + layoutFontSize: ParsedLengthStyle; spacebarText: string; } - export default class OSKLayer { public readonly element: HTMLDivElement; public readonly rows: OSKRow[]; @@ -132,42 +136,63 @@ export default class OSKLayer { // needing to trigger a reflow by running the closure below early. this.spaceBarKey.spec.text = displayName; - // It sounds redundant, but this dramatically cuts down on browser DOM processing; - // but sometimes innerText is reported empty when it actually isn't, so set it - // anyway in that case (Safari, iOS 14.4) - if (spacebarLabel.innerText != displayName || displayName == '') { - spacebarLabel.innerText = displayName; + return () => { + // It sounds redundant, but this dramatically cuts down on browser DOM processing; + // but sometimes innerText is reported empty when it actually isn't, so set it + // anyway in that case (Safari, iOS 14.4) + if (spacebarLabel.innerText != displayName || displayName == '') { + spacebarLabel.innerText = displayName; + } } } catch (ex) { } } - public refreshLayout(vkbd: VisualKeyboard, layoutParams: LayerLayoutParams) { - const layerHeight = layoutParams.keyboardHeight; - this.showLanguage(layoutParams.spacebarText); - + public refreshLayout(layoutParams: LayerLayoutParams) { // Check the heights of each row, in case different layers have different row counts. + const layerHeight = layoutParams.keyboardHeight; const nRows = this.rows.length; const rowHeight = this._rowHeight = Math.floor(layerHeight/(nRows == 0 ? 1 : nRows)); - if(vkbd.usesFixedHeightScaling) { + const usesFixedWidthScaling = layoutParams.widthStyle.absolute; + if(usesFixedWidthScaling) { this.element.style.height=(layerHeight)+'px'; } + const spacebarTextClosure = this.showLanguage(layoutParams.spacebarText); + + // Update row layout properties + const rowClosures: (() => void)[] = []; for(let nRow=0; nRow { + oldRowClosure(); oskRow.element.style.bottom = '1px'; - } + }; } + rowClosures.push(rowClosure); + } - oskRow.refreshLayout(vkbd); + const rowKeyClosures: (() => void)[] = []; + for(const row of this.rows) { + const batchedUpdates = row.refreshKeyLayouts(layoutParams); + rowKeyClosures.push(batchedUpdates); } + + // After row layout properties have been updated, _then_ update key internals. + // Doing this separately like this helps to reduce layout reflow. + spacebarTextClosure(); + rowClosures.forEach((closure) => closure()); + rowKeyClosures.forEach((closure) => closure()); } } diff --git a/web/src/engine/osk/src/keyboard-layout/oskLayerGroup.ts b/web/src/engine/osk/src/keyboard-layout/oskLayerGroup.ts index b471d132d0c..32512da70a4 100644 --- a/web/src/engine/osk/src/keyboard-layout/oskLayerGroup.ts +++ b/web/src/engine/osk/src/keyboard-layout/oskLayerGroup.ts @@ -6,6 +6,7 @@ import { KeyElement } from '../keyElement.js'; import OSKLayer, { LayerLayoutParams } from './oskLayer.js'; import VisualKeyboard from '../visualKeyboard.js'; import OSKBaseKey from './oskBaseKey.js'; +import { ParsedLengthStyle } from '../lengthStyle.js'; const NEAREST_KEY_TOUCH_MARGIN_PERCENT = 0.06; @@ -256,8 +257,21 @@ export default class OSKLayerGroup { return null; } + public resetPrecalcFontSizes() { + for(const layer of Object.values(this.layers)) { + for(const row of layer.rows) { + for(const key of row.keys) { + key.resetFontPrecalc(); + } + } + } + + // This method is called whenever all related stylesheets are fully loaded and applied. + // The actual padding data may not have been available until now. + this._heightPadding = undefined; + } - public refreshLayout(vkbd: VisualKeyboard, layoutParams: LayerLayoutParams) { + public refreshLayout(layoutParams: LayerLayoutParams) { // Set layer-group copies of relevant computed-size values; they are used by nearest-key // detection. this.computedWidth = layoutParams.keyboardWidth; @@ -266,8 +280,8 @@ export default class OSKLayerGroup { // Assumption: this styling value will not change once the keyboard and // related stylesheets are loaded and applied. if(this._heightPadding === undefined) { - // Should not trigger a new layout reflow; VisualKeyboard should have made - // no further DOM style changes since the last one. + // Should not trigger a new layout reflow; VisualKeyboard should have made no further DOM + // style changes since the last one. // For touch-based OSK layouts, kmwosk.css may include top & bottom // padding on the layer-group element. @@ -280,7 +294,7 @@ export default class OSKLayerGroup { } if(this.activeLayer) { - this.activeLayer.refreshLayout(vkbd, layoutParams); + this.activeLayer.refreshLayout(layoutParams); } } diff --git a/web/src/engine/osk/src/keyboard-layout/oskRow.ts b/web/src/engine/osk/src/keyboard-layout/oskRow.ts index 6e0b3147408..f9f8057506e 100644 --- a/web/src/engine/osk/src/keyboard-layout/oskRow.ts +++ b/web/src/engine/osk/src/keyboard-layout/oskRow.ts @@ -3,6 +3,8 @@ import { ActiveKey, ActiveLayer, ActiveRow } from '@keymanapp/keyboard-processor import OSKBaseKey from './oskBaseKey.js'; import { ParsedLengthStyle } from '../lengthStyle.js'; import VisualKeyboard from '../visualKeyboard.js'; +import { KeyLayoutParams } from './oskKey.js'; +import { LayerLayoutParams } from './oskLayer.js'; /** * Models one row of one layer of the OSK (`VisualKeyboard`) for a keyboard. @@ -57,34 +59,79 @@ export default class OSKRow { } } - public refreshLayout(vkbd: VisualKeyboard) { + public refreshLayout(layoutParams: LayerLayoutParams) { const rs = this.element.style; - const rowHeight = vkbd.internalHeight.scaledBy(this.heightFraction); - rs.maxHeight=rs.lineHeight=rs.height=rowHeight.styleString; + const rowHeight = layoutParams.heightStyle.scaledBy(this.heightFraction); + const executeRowStyleUpdates = () => { + rs.maxHeight=rs.lineHeight=rs.height=rowHeight.styleString; + } // Only used for fixed-height scales at present. const padRatio = 0.15; - const keyHeightBase = vkbd.usesFixedHeightScaling ? rowHeight : ParsedLengthStyle.forScalar(1); + const keyHeightBase = layoutParams.widthStyle.absolute ? rowHeight : ParsedLengthStyle.forScalar(1); const padTop = keyHeightBase.scaledBy(padRatio / 2); const keyHeight = keyHeightBase.scaledBy(1 - padRatio); - for(const key of this.keys) { - const keySquare = key.btn.parentElement; + // Update all key-square layouts. + const keyStyleUpdates = this.keys.map((key) => { + return () => { + const keySquare = key.btn.parentElement; + const keyElement = key.btn; + + // Set the kmw-key-square position + const kss = keySquare.style; + kss.height=kss.minHeight=keyHeightBase.styleString; + + const kes = keyElement.style; + kes.top = padTop.styleString; + kes.height=kes.lineHeight=kes.minHeight=keyHeight.styleString; + } + }) + + return () => { + executeRowStyleUpdates(); + keyStyleUpdates.forEach((closure) => closure()); + } + } + + public refreshKeyLayouts(layoutParams: LayerLayoutParams) { + const updateClosures = this.keys.map((key) => { + // Calculate changes to be made... const keyElement = key.btn; - // Set the kmw-key-square position - const kss = keySquare.style; - kss.height=kss.minHeight=keyHeightBase.styleString; + const widthStyle = layoutParams.widthStyle; + const heightStyle = layoutParams.heightStyle; + + const keyWidth = widthStyle.scaledBy(key.spec.proportionalWidth); + const keyPad = widthStyle.scaledBy(key.spec.proportionalPad); + + const keyHeight = heightStyle.scaledBy(this.heightFraction); + + // Match the row height (if fixed-height) or use full row height (if percent-based) + const styleHeight = widthStyle.absolute ? keyHeight.styleString : '100%'; - const kes = keyElement.style; - kes.top = padTop.styleString; - kes.height=kes.lineHeight=kes.minHeight=keyHeight.styleString; + const keyStyle: KeyLayoutParams = { + keyWidth: keyWidth.val * (keyWidth.absolute ? 1 : layoutParams.keyboardWidth), + keyHeight: keyHeight.val * (keyWidth.absolute ? 1 : layoutParams.keyboardHeight), + baseEmFontSize: layoutParams.baseEmFontSize, + layoutFontSize: layoutParams.layoutFontSize + }; + //return keyElement.key ? keyElement.key.refreshLayout(keyStyle) : () => {}; + const keyFontClosure = keyElement.key ? keyElement.key.refreshLayout(keyStyle) : () => {}; - if(keyElement.key) { - keyElement.key.refreshLayout(vkbd); + // And queue them to be run in a single batch later. This helps us avoid layout reflow thrashing. + return () => { + key.square.style.width = keyWidth.styleString; + key.square.style.marginLeft = keyPad.styleString; + + key.btn.style.width = widthStyle.absolute ? keyWidth.styleString : '100%'; + key.square.style.height = styleHeight; + keyFontClosure(); } - } + }); + + return () => updateClosures.forEach((closure) => closure()); } } \ No newline at end of file diff --git a/web/src/engine/osk/src/views/oskView.ts b/web/src/engine/osk/src/views/oskView.ts index e7c6ecdd0d6..85b63067ab7 100644 --- a/web/src/engine/osk/src/views/oskView.ts +++ b/web/src/engine/osk/src/views/oskView.ts @@ -856,12 +856,6 @@ export default abstract class OSKView // triggers state-update + layer refresh automatically. this.vkbd.layerId = newValue; } - - // Ensure the keyboard view is modeling the correct state. (Correct layer, etc.) - this.keyboardView.updateState(); // will also need the stateKeys. - // We need to recalc the font size here because the layer did not have - // calculated dimensions available before it was visible - this.refreshLayout(); } return false; diff --git a/web/src/engine/osk/src/visualKeyboard.ts b/web/src/engine/osk/src/visualKeyboard.ts index 9d9bcfeb017..cd0c8ee12f6 100644 --- a/web/src/engine/osk/src/visualKeyboard.ts +++ b/web/src/engine/osk/src/visualKeyboard.ts @@ -40,11 +40,11 @@ import KeyboardView from './components/keyboardView.interface.js'; import { type KeyElement, getKeyFrom } from './keyElement.js'; import KeyTip from './keytip.interface.js'; import OSKKey from './keyboard-layout/oskKey.js'; -import OSKLayer from './keyboard-layout/oskLayer.js'; +import OSKLayer, { LayerLayoutParams } from './keyboard-layout/oskLayer.js'; import OSKLayerGroup from './keyboard-layout/oskLayerGroup.js'; import OSKView from './views/oskView.js'; import { LengthStyle, ParsedLengthStyle } from './lengthStyle.js'; -import { defaultFontSize, getFontSizeStyle } from './fontSizeUtils.js'; +import { defaultFontSize } from './fontSizeUtils.js'; import PhoneKeyTip from './input/gestures/browser/keytip.js'; import { TabletKeyTip } from './input/gestures/browser/tabletPreview.js'; import CommonConfiguration from './config/commonConfiguration.js'; @@ -158,7 +158,6 @@ export default class VisualKeyboard extends EventEmitter implements Ke readonly config: VisualKeyboardConfiguration; - private _layerId: string = "default"; layerLocked: boolean = false; layerIndex: number = 0; // the index of the default layer readonly isRTL: boolean; @@ -183,6 +182,13 @@ export default class VisualKeyboard extends EventEmitter implements Ke */ private _height: number; + /** + * The main VisualKeyboard element's border-width styling. + * + * Assumption: is a fixed, uniform length that doesn't vary between refreshLayout() calls. + */ + private _borderWidth: number = 0; + /** * The computed width for this VisualKeyboard. May be null if auto sizing * is allowed and the VisualKeyboard is not currently in the DOM hierarchy. @@ -239,7 +245,6 @@ export default class VisualKeyboard extends EventEmitter implements Ke if(!this.layerGroup.layers[value]) { throw new Error(`Keyboard ${this.layoutKeyboard.id} does not have a layer with id ${value}`); } else { - this._layerId = value; this.layerGroup.activeLayerId = value; // Does not exist for documentation keyboards! @@ -250,7 +255,7 @@ export default class VisualKeyboard extends EventEmitter implements Ke if(changedLayer) { this.updateState(); - this.refreshLayout(); + this.currentLayer.refreshLayout(this.constructLayoutParams()); } } @@ -783,11 +788,7 @@ export default class VisualKeyboard extends EventEmitter implements Ke get layoutWidth(): ParsedLengthStyle { if (this.usesFixedWidthScaling) { let baseWidth = this.width; - let cs = getComputedStyle(this.element); - if (cs.border) { - let borderWidth = new ParsedLengthStyle(cs.borderWidth).val; - baseWidth -= borderWidth * 2; - } + baseWidth -= this._borderWidth * 2; return ParsedLengthStyle.inPixels(baseWidth); } else { return ParsedLengthStyle.forScalar(1); @@ -797,11 +798,7 @@ export default class VisualKeyboard extends EventEmitter implements Ke get layoutHeight(): ParsedLengthStyle { if (this.usesFixedHeightScaling) { let baseHeight = this.height; - let cs = getComputedStyle(this.element); - if (cs.border) { - let borderHeight = new ParsedLengthStyle(cs.borderWidth).val; - baseHeight -= borderHeight * 2; - } + baseHeight -= this._borderWidth * 2; return ParsedLengthStyle.inPixels(baseHeight); } else { return ParsedLengthStyle.forScalar(1); @@ -811,7 +808,9 @@ export default class VisualKeyboard extends EventEmitter implements Ke get internalHeight(): ParsedLengthStyle { if (this.usesFixedHeightScaling) { // Touch OSKs may apply internal padding to prevent row cropping at the edges. - return ParsedLengthStyle.inPixels(this.layoutHeight.val - this.layerGroup.verticalPadding); + // ... why not precompute both, rather than recalculate each time? + // - appears to contribute to layout reflow costs on layer swaps! + return ParsedLengthStyle.inPixels(this.layoutHeight.val - this._borderWidth * 2 - this.layerGroup.verticalPadding); } else { return ParsedLengthStyle.forScalar(1); } @@ -1154,28 +1153,28 @@ export default class VisualKeyboard extends EventEmitter implements Ke * Use of `getComputedStyle` is ideal, but in many of our use cases its preconditions are not met. * This function allows us to calculate the font size in those situations. */ - getKeyEmFontSize(): number { + getKeyEmFontSize(): ParsedLengthStyle { if (!this.fontSize) { - return 0; + return new ParsedLengthStyle('0px'); } if (this.device.formFactor == 'desktop') { let keySquareScale = 0.8; // Set in kmwosk.css, is relative. - return this.fontSize.scaledBy(keySquareScale).val; + return this.fontSize.scaledBy(keySquareScale); } else { - let emSizeStr = getComputedStyle(document.body).fontSize; - let emSize = getFontSizeStyle(emSizeStr).val; + const emSizeStr = getComputedStyle(document.body).fontSize; + const emSize = new ParsedLengthStyle(emSizeStr); - var emScale = 1; + let emScale = 1; if (!this.isStatic) { // Double-check against the font scaling applied to the _Box element. if (this.fontSize.absolute) { - return this.fontSize.val; + return this.fontSize; } else { emScale = this.fontSize.val; } } - return emSize * emScale; + return emSize.scaledBy(emScale); } } @@ -1187,7 +1186,6 @@ export default class VisualKeyboard extends EventEmitter implements Ke return; } - var n, b = this.kbdDiv.childNodes[0].childNodes; this.nextLayer = this.layerId; if (this.currentLayer.nextlayer) { @@ -1207,7 +1205,11 @@ export default class VisualKeyboard extends EventEmitter implements Ke * when needed. */ refreshLayout() { - //let keyman = com.keyman.singleton; + /* + Phase 1: calculations possible at the start without triggering _any_ additional layout reflow. + (A single, initial reflow may happen depending on DOM manipulations before this method..., + but no extras until phase 2.) + */ let device = this.device; var fs = 1.0; @@ -1216,11 +1218,9 @@ export default class VisualKeyboard extends EventEmitter implements Ke fs = fs / getViewportScale(this.device.formFactor); } - let paddedHeight: number; - if (this.height) { - paddedHeight = this.computedAdjustedOskHeight(this.height); - } - + /* + Phase 2: first self-triggered reflow - locking in the keyboard's base property styling. + */ let gs = this.kbdDiv.style; if (this.usesFixedHeightScaling && this.height) { // Sets the layer group to the correct height. @@ -1231,11 +1231,19 @@ export default class VisualKeyboard extends EventEmitter implements Ke // Layer-group font-scaling is applied separately. gs.fontSize = this.fontSize.scaledBy(fs).styleString; + // Phase 3: reflow from top-level getComputedStyle calls + // Step 1: have the necessary conditions been met? const fixedSize = this.width && this.height; const computedStyle = getComputedStyle(this.kbdDiv); + const groupStyle = getComputedStyle(this.kbdDiv.firstElementChild); + const isInDOM = computedStyle.height != '' && computedStyle.height != 'auto'; + if (computedStyle.border) { + this._borderWidth = new ParsedLengthStyle(computedStyle.borderWidth).val; + } + // Step 2: determine basic layout geometry, refresh things that might update. if (fixedSize) { @@ -1244,9 +1252,6 @@ export default class VisualKeyboard extends EventEmitter implements Ke } else if (isInDOM) { this._computedWidth = parseInt(computedStyle.width, 10); if (!this._computedWidth) { - // For touch keyboards, the width _was_ specified on the layer group, - // not the root element (`kbdDiv`). - const groupStyle = getComputedStyle(this.kbdDiv.firstElementChild); this._computedWidth = parseInt(groupStyle.width, 10); } this._computedHeight = parseInt(computedStyle.height, 10); @@ -1267,15 +1272,23 @@ export default class VisualKeyboard extends EventEmitter implements Ke this.gestureParams.flick.triggerDist = 0.75 * this.currentLayer.rowHeight; } - // Step 4: perform layout operations. - // Needs the refreshed layout info to work correctly. - this.layerGroup.refreshLayout(this, { - keyboardWidth: this._computedWidth, - keyboardHeight: this._computedHeight - this.layerGroup.verticalPadding, + // Phase 4: Refresh the layout of the layer-group and active layer. + this.layerGroup.refreshLayout(this.constructLayoutParams()); + } + + private constructLayoutParams(): LayerLayoutParams { + return { + keyboardWidth: this._computedWidth - 2 * this._borderWidth, + keyboardHeight: this._computedHeight - 2 * this._borderWidth - this.layerGroup.verticalPadding, + widthStyle: this.layoutWidth, + heightStyle: this.internalHeight, + baseEmFontSize: this.getKeyEmFontSize(), + layoutFontSize: new ParsedLengthStyle(this.layerGroup.element.style.fontSize), spacebarText: this.layoutKeyboardProperties?.displayName ?? '(System keyboard)' - }); + }; } + // Appears to be abandoned now - candidate for removal in future. /*private*/ computedAdjustedOskHeight(allottedHeight: number): number { if (!this.layerGroup) { return allottedHeight; @@ -1348,7 +1361,12 @@ export default class VisualKeyboard extends EventEmitter implements Ke } // Once any related fonts are loaded, we can re-adjust key-cap scaling. - this.styleSheetManager.allLoadedPromise().then(() => this.refreshLayout()); + this.styleSheetManager.allLoadedPromise().then(() => { + // All existing font-precalculations will need to be reset, as the font + // was previously unavailable. + this.layerGroup.resetPrecalcFontSizes(); + this.refreshLayout() + }); } /** @@ -1585,10 +1603,10 @@ export default class VisualKeyboard extends EventEmitter implements Ke showGesturePreview(key: KeyElement) { const tip = this.keytip; - const keyCS = getComputedStyle(key); - const parsedHeight = Number.parseInt(keyCS.height, 10); - const parsedWidth = Number.parseInt(keyCS.width, 10); - const previewHost = new GesturePreviewHost(key, this.device.formFactor == 'phone', parsedWidth, parsedHeight); + const layoutParams = this.constructLayoutParams(); + const keyWidth = layoutParams.keyboardWidth * key.key.spec.proportionalWidth; + const keyHeight = layoutParams.keyboardHeight / this.currentLayer.rows.length; + const previewHost = new GesturePreviewHost(key, this.device.formFactor == 'phone', keyWidth, keyHeight); if (tip == null) { const baseKey = key.key as OSKBaseKey; From f2dba20c0d3056ffee08d80cbc652121fa3e0e52 Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Fri, 5 Apr 2024 12:44:52 +0700 Subject: [PATCH 139/170] fix(web): fixed width vs fixed height references --- web/src/engine/osk/src/keyboard-layout/oskRow.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/engine/osk/src/keyboard-layout/oskRow.ts b/web/src/engine/osk/src/keyboard-layout/oskRow.ts index f9f8057506e..ae0c979f786 100644 --- a/web/src/engine/osk/src/keyboard-layout/oskRow.ts +++ b/web/src/engine/osk/src/keyboard-layout/oskRow.ts @@ -110,11 +110,11 @@ export default class OSKRow { const keyHeight = heightStyle.scaledBy(this.heightFraction); // Match the row height (if fixed-height) or use full row height (if percent-based) - const styleHeight = widthStyle.absolute ? keyHeight.styleString : '100%'; + const styleHeight = heightStyle.absolute ? keyHeight.styleString : '100%'; const keyStyle: KeyLayoutParams = { keyWidth: keyWidth.val * (keyWidth.absolute ? 1 : layoutParams.keyboardWidth), - keyHeight: keyHeight.val * (keyWidth.absolute ? 1 : layoutParams.keyboardHeight), + keyHeight: keyHeight.val * (heightStyle.absolute ? 1 : layoutParams.keyboardHeight), baseEmFontSize: layoutParams.baseEmFontSize, layoutFontSize: layoutParams.layoutFontSize }; From 8a21fbcbcafdf73bfbcdc008c57e42b8b0a914b6 Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Fri, 5 Apr 2024 12:49:11 +0700 Subject: [PATCH 140/170] change(web): layer-specific refresh tweak --- web/src/engine/osk/src/visualKeyboard.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/engine/osk/src/visualKeyboard.ts b/web/src/engine/osk/src/visualKeyboard.ts index cd0c8ee12f6..587c60d1237 100644 --- a/web/src/engine/osk/src/visualKeyboard.ts +++ b/web/src/engine/osk/src/visualKeyboard.ts @@ -255,7 +255,7 @@ export default class VisualKeyboard extends EventEmitter implements Ke if(changedLayer) { this.updateState(); - this.currentLayer.refreshLayout(this.constructLayoutParams()); + this.layerGroup.refreshLayout(this.constructLayoutParams()); } } From f21afb1476e3267f40235cb5d47f750154dc2a19 Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Tue, 2 Apr 2024 16:10:16 +0700 Subject: [PATCH 141/170] docs(web): comment on recent code --- web/src/engine/osk/src/visualKeyboard.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/web/src/engine/osk/src/visualKeyboard.ts b/web/src/engine/osk/src/visualKeyboard.ts index 587c60d1237..34d267805cf 100644 --- a/web/src/engine/osk/src/visualKeyboard.ts +++ b/web/src/engine/osk/src/visualKeyboard.ts @@ -255,6 +255,7 @@ export default class VisualKeyboard extends EventEmitter implements Ke if(changedLayer) { this.updateState(); + // We changed the active layer, but not any layout property of the keyboard as a whole. this.layerGroup.refreshLayout(this.constructLayoutParams()); } } From 04df7f76c8d6dc0b1e1ea115a5db39ca09029d3e Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Wed, 3 Apr 2024 11:40:15 +0700 Subject: [PATCH 142/170] feat(web): high-level layout op batching Affects change of keyboards and context resets - particularly when new-context rules trigger a non-default layer --- web/src/engine/main/src/keymanEngine.ts | 40 +++++++++++++++++++----- web/src/engine/osk/src/views/oskView.ts | 19 ++++++++++- web/src/engine/osk/src/visualKeyboard.ts | 15 ++++++++- 3 files changed, 64 insertions(+), 10 deletions(-) diff --git a/web/src/engine/main/src/keymanEngine.ts b/web/src/engine/main/src/keymanEngine.ts index 1400c82e9cd..b7a593d3f8f 100644 --- a/web/src/engine/main/src/keymanEngine.ts +++ b/web/src/engine/main/src/keymanEngine.ts @@ -152,16 +152,32 @@ export default class KeymanEngine< this.osk.startHide(false); } - if(this.osk) { - this.osk.setNeedsLayout(); - this.osk.activeKeyboard = kbd; - this.osk.present(); - } - // Needed to ensure the correct layer is displayed. // Needs to be after the OSK has loaded for the keyboard in case the default // layer should be something other than "default" for the current context. - this.core.resetContext(this.contextManager.activeTarget); + const doContextReset = () => { + this.contextManager.resetContext(); + } + + /* + This pattern is designed to minimize layout reflow during the keyboard-swap process. + The 'default' layer is loaded by default, but some keyboards will start on different + layers depending on the current state of the context. + + If possible, we want to only perform layout operations once the correct layer is + set to active. + */ + if(this.osk) { + this.osk.batchLayoutAfter(() => { + this.osk.activeKeyboard = kbd; + // Note: when embedded within the mobile apps, the keyboard will still be visible + // at this time. + doContextReset(); + this.osk.present(); + }); + } else { + doContextReset(); + } }); this.contextManager.on('keyboardasyncload', (metadata) => { @@ -216,7 +232,15 @@ export default class KeymanEngine< resetContext: (target) => { // Could reset the target's deadkeys here, but it's really more of a 'core' task. // So we delegate that to keyboard-processor. - this.core.resetContext(target); + const doReset = () => this.core.resetContext(target); + + if(this.osk) { + this.osk.batchLayoutAfter(() => { + doReset(); + }) + } else { + doReset(); + } }, predictionContext: new PredictionContext(this.core.languageProcessor, this.core.keyboardProcessor), keyboardCache: this.keyboardRequisitioner.cache diff --git a/web/src/engine/osk/src/views/oskView.ts b/web/src/engine/osk/src/views/oskView.ts index 85b63067ab7..2b67d3d1659 100644 --- a/web/src/engine/osk/src/views/oskView.ts +++ b/web/src/engine/osk/src/views/oskView.ts @@ -159,6 +159,7 @@ export default abstract class OSKView private uiStyleSheetManager: StylesheetManager; private config: Configuration; + private deferLayout: boolean; private _boxBaseMouseDown: (e: MouseEvent) => boolean; private _boxBaseTouchStart: (e: TouchEvent) => boolean; @@ -595,8 +596,24 @@ export default abstract class OSKView this.needsLayout = true; } + public batchLayoutAfter(closure: () => void) { + try { + this.deferLayout = true; + if(this.vkbd) { + this.vkbd.deferLayout = true; + } + closure(); + } finally { + this.deferLayout = false; + if(this.vkbd) { + this.vkbd.deferLayout = false; + } + this.refreshLayout(); + } + } + public refreshLayout(pending?: boolean): void { - if(!this.keyboardView) { + if(!this.keyboardView || this.deferLayout) { return; } diff --git a/web/src/engine/osk/src/visualKeyboard.ts b/web/src/engine/osk/src/visualKeyboard.ts index 34d267805cf..67ae9f48fc0 100644 --- a/web/src/engine/osk/src/visualKeyboard.ts +++ b/web/src/engine/osk/src/visualKeyboard.ts @@ -231,11 +231,20 @@ export default class VisualKeyboard extends EventEmitter implements Ke activeGestures: GestureHandler[] = []; activeModipress: Modipress = null; + private _deferLayout: boolean; // The keyboard object corresponding to this VisualKeyboard. public readonly layoutKeyboard: Keyboard; public readonly layoutKeyboardProperties: KeyboardProperties; + get deferLayout(): boolean { + return this._deferLayout; + } + + set deferLayout(value: boolean) { + this._deferLayout = value; + } + get layerId(): string { return this.layerGroup?.activeLayerId ?? 'default'; } @@ -253,7 +262,7 @@ export default class VisualKeyboard extends EventEmitter implements Ke } } - if(changedLayer) { + if(changedLayer && !this._deferLayout) { this.updateState(); // We changed the active layer, but not any layout property of the keyboard as a whole. this.layerGroup.refreshLayout(this.constructLayoutParams()); @@ -1206,6 +1215,10 @@ export default class VisualKeyboard extends EventEmitter implements Ke * when needed. */ refreshLayout() { + if(this._deferLayout) { + return; + } + /* Phase 1: calculations possible at the start without triggering _any_ additional layout reflow. (A single, initial reflow may happen depending on DOM manipulations before this method..., From 1a59f9b90e153dad6cce3212abade153ae057f2a Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Wed, 3 Apr 2024 11:57:53 +0700 Subject: [PATCH 143/170] fix(web): handling of nested layout deferrals --- web/src/engine/osk/src/views/oskView.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/web/src/engine/osk/src/views/oskView.ts b/web/src/engine/osk/src/views/oskView.ts index 2b67d3d1659..984ba63d856 100644 --- a/web/src/engine/osk/src/views/oskView.ts +++ b/web/src/engine/osk/src/views/oskView.ts @@ -597,6 +597,15 @@ export default abstract class OSKView } public batchLayoutAfter(closure: () => void) { + /* + Is there already an ongoing batch? If so, just run the closure and don't + adjust the tracking variables. The outermost call will finalize layout. + */ + if(this.deferLayout) { + closure(); + return; + } + try { this.deferLayout = true; if(this.vkbd) { From 8cf0da476e19f34e8db417a6e84944fe3dda71c4 Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Thu, 4 Apr 2024 11:30:18 +0700 Subject: [PATCH 144/170] change(web): prevents one small reflow during kbd swap --- web/src/engine/main/src/keymanEngine.ts | 34 ++++++++++++++++--------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/web/src/engine/main/src/keymanEngine.ts b/web/src/engine/main/src/keymanEngine.ts index b7a593d3f8f..e6dfb063148 100644 --- a/web/src/engine/main/src/keymanEngine.ts +++ b/web/src/engine/main/src/keymanEngine.ts @@ -2,7 +2,7 @@ import { type Keyboard, KeyboardKeymanGlobal, ProcessorInitOptions } from "@keym import { DOMKeyboardLoader as KeyboardLoader } from "@keymanapp/keyboard-processor/dom-keyboard-loader"; import { InputProcessor, PredictionContext } from "@keymanapp/input-processor"; import { OSKView } from "keyman/engine/osk"; -import { KeyboardRequisitioner, ModelCache, ModelSpec, toUnprefixedKeyboardId as unprefixed } from "keyman/engine/package-cache"; +import { KeyboardRequisitioner, KeyboardStub, ModelCache, ModelSpec, toUnprefixedKeyboardId as unprefixed } from "keyman/engine/package-cache"; import { EngineConfiguration, InitOptionSpec } from "./engineConfiguration.js"; import KeyboardInterface from "./keyboardInterface.js"; @@ -137,14 +137,6 @@ export default class KeymanEngine< }); this.contextManager.on('keyboardchange', (kbd) => { - this.refreshModel(); - this.core.activeKeyboard = kbd?.keyboard; - - this.legacyAPIEvents.callEvent('keyboardchange', { - internalName: kbd?.metadata.id ?? '', - languageCode: kbd?.metadata.langId ?? '' - }); - // Hide OSK and do not update keyboard list if using internal keyboard (desktops). // Condition will not be met for touch form-factors; they force selection of a // default keyboard. @@ -152,9 +144,25 @@ export default class KeymanEngine< this.osk.startHide(false); } - // Needed to ensure the correct layer is displayed. - // Needs to be after the OSK has loaded for the keyboard in case the default - // layer should be something other than "default" for the current context. + const earlyBatchClosure = () => { + this.refreshModel(); + // Triggers context resets that can trigger layout stuff. + // It's not the final such context-reset, though. + this.core.activeKeyboard = kbd?.keyboard; + + this.legacyAPIEvents.callEvent('keyboardchange', { + internalName: kbd?.metadata.id ?? '', + languageCode: kbd?.metadata.langId ?? '' + }); + } + + /* + Needed to ensure the correct layer is displayed AND that deadkeys from + the old keyboard have been wiped. + + Needs to be after the OSK has loaded for the keyboard in case the default + layer should be something other than "default" for the current context. + */ const doContextReset = () => { this.contextManager.resetContext(); } @@ -169,6 +177,7 @@ export default class KeymanEngine< */ if(this.osk) { this.osk.batchLayoutAfter(() => { + earlyBatchClosure(); this.osk.activeKeyboard = kbd; // Note: when embedded within the mobile apps, the keyboard will still be visible // at this time. @@ -176,6 +185,7 @@ export default class KeymanEngine< this.osk.present(); }); } else { + earlyBatchClosure(); doContextReset(); } }); From c5953036e6fdc7756b2ebc3b7b8152a655c22cfe Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Thu, 4 Apr 2024 15:35:56 +0700 Subject: [PATCH 145/170] change(web): eliminates early redundant context reset This one occurred during Promise fulfillment synchronously but outside OSK batching mode... with another call later _within_ OSK batching mode. --- web/src/engine/main/src/contextManagerBase.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/web/src/engine/main/src/contextManagerBase.ts b/web/src/engine/main/src/contextManagerBase.ts index 54cfc7e8dd2..df6542aebcc 100644 --- a/web/src/engine/main/src/contextManagerBase.ts +++ b/web/src/engine/main/src/contextManagerBase.ts @@ -274,8 +274,6 @@ export abstract class ContextManagerBase // (!wasNull || !!keyboard) - blocks events for `null` -> `null` transitions. // (keyman/keymanweb.com#96) if(this.currentKeyboardSrcTarget() == originalKeyboardTarget && (!wasNull || !!keyboard)) { - // Perform standard context-reset ops, including the processing of new-context events. - this.resetContext(); // Will trigger KeymanEngine handler that passes keyboard to the OSK, displays it. this.emit('keyboardchange', this.activeKeyboard); } From bf130e36e08dfbf5731c0e6fb84c38e37126baec Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Fri, 5 Apr 2024 14:09:14 +0700 Subject: [PATCH 146/170] fix(web): nearest-key row lookup when touch moves out-of-bounds --- web/src/engine/osk/src/keyboard-layout/oskLayerGroup.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/engine/osk/src/keyboard-layout/oskLayerGroup.ts b/web/src/engine/osk/src/keyboard-layout/oskLayerGroup.ts index 1803269ef75..589ca96f8c8 100644 --- a/web/src/engine/osk/src/keyboard-layout/oskLayerGroup.ts +++ b/web/src/engine/osk/src/keyboard-layout/oskLayerGroup.ts @@ -198,7 +198,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`. From e43aa3c737a5da29f52f9e221f605fe61795bb23 Mon Sep 17 00:00:00 2001 From: "Steven R. Loomis" Date: Fri, 5 Apr 2024 11:07:07 -0500 Subject: [PATCH 147/170] fix(core): skip leading trail surrogate char - update tests per review --- .../unit/ldml/test_context_normalization.cpp | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/core/tests/unit/ldml/test_context_normalization.cpp b/core/tests/unit/ldml/test_context_normalization.cpp index 6a26c198ed1..07fe82fdbd6 100644 --- a/core/tests/unit/ldml/test_context_normalization.cpp +++ b/core/tests/unit/ldml/test_context_normalization.cpp @@ -109,12 +109,23 @@ void test_context_normalization_hefty() { } void test_context_normalization_invalid_unicode() { - // unpaired surrogate illegal - km_core_cp const application_context[] = { 0xDC01, 0x0020, 0x0020, 0xFFFF, 0x0000 }; - km_core_cp const cached_context[] = {/*skipped*/ 0x0020, 0x0020, 0xFFFF, 0x0000 }; + // unpaired surrogate illegal + km_core_cp const application_context[] = { 0xDC01, 0x0020, 0x0020, 0xFFFF, 0x0000 }; + km_core_cp const cached_context[] = { 0xDC01, 0x0020, 0x0020, 0xFFFF, 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)); // skip first char + assert(is_identical_context(application_context, KM_CORE_DEBUG_CONTEXT_APP)); + assert(is_identical_context(cached_context, KM_CORE_DEBUG_CONTEXT_CACHED)); + 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(); } @@ -123,7 +134,8 @@ void test_context_normalization() { test_context_normalization_already_nfd(); test_context_normalization_basic(); test_context_normalization_hefty(); - 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(); } //------------------------------------------------------------------------------------- From edd5b54040bbb031b51f7990a39393833e819875 Mon Sep 17 00:00:00 2001 From: Keyman Build Agent Date: Fri, 5 Apr 2024 14:05:24 -0400 Subject: [PATCH 148/170] auto: increment beta version to 17.0.304 --- HISTORY.md | 5 +++++ VERSION.md | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/HISTORY.md b/HISTORY.md index 879970b2530..1aa044f8021 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,5 +1,10 @@ # Keyman Version History +## 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..29d39b71943 100644 --- a/VERSION.md +++ b/VERSION.md @@ -1 +1 @@ -17.0.303 \ No newline at end of file +17.0.304 \ No newline at end of file From bee64b62017f7bcf29b7b2095c88548155ccadf7 Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Mon, 8 Apr 2024 09:12:33 +0700 Subject: [PATCH 149/170] fix(web): key-height calc should be based on btn, not square --- .../engine/osk/src/keyboard-layout/oskKey.ts | 6 ----- .../engine/osk/src/keyboard-layout/oskRow.ts | 22 ++++++++++++------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/web/src/engine/osk/src/keyboard-layout/oskKey.ts b/web/src/engine/osk/src/keyboard-layout/oskKey.ts index 60b60a65379..3dfea966f77 100644 --- a/web/src/engine/osk/src/keyboard-layout/oskKey.ts +++ b/web/src/engine/osk/src/keyboard-layout/oskKey.ts @@ -188,12 +188,6 @@ export default abstract class OSKKey { scale ??= 1; - /* - Properties needed: - - this.btn.style.width (computed & set in this.refreshLayout) - - this.btn.style.height (likewise / in parent row element - is available either way) - - baseEmFontSize (vkbd.getKeyEmFontSize()) - obtainable once, prob at layer-group level - */ const keyWidth = layoutParams.keyWidth; const keyHeight = layoutParams.keyHeight; const emScale = layoutParams.baseEmFontSize.scaledBy(layoutParams.layoutFontSize.val); diff --git a/web/src/engine/osk/src/keyboard-layout/oskRow.ts b/web/src/engine/osk/src/keyboard-layout/oskRow.ts index ae0c979f786..777b8e76583 100644 --- a/web/src/engine/osk/src/keyboard-layout/oskRow.ts +++ b/web/src/engine/osk/src/keyboard-layout/oskRow.ts @@ -6,6 +6,13 @@ import VisualKeyboard from '../visualKeyboard.js'; import { KeyLayoutParams } from './oskKey.js'; import { LayerLayoutParams } from './oskLayer.js'; +/* + The total proportion of key-square height used as key-button padding. + The 'padding' is visible to users as the vertical space between keys + and exists both in "fixed" and "absolute" sizing modes. +*/ +export const KEY_BTN_Y_PAD_RATIO = 0.15; + /** * Models one row of one layer of the OSK (`VisualKeyboard`) for a keyboard. */ @@ -67,17 +74,14 @@ export default class OSKRow { rs.maxHeight=rs.lineHeight=rs.height=rowHeight.styleString; } - // Only used for fixed-height scales at present. - const padRatio = 0.15; - - const keyHeightBase = layoutParams.widthStyle.absolute ? rowHeight : ParsedLengthStyle.forScalar(1); - const padTop = keyHeightBase.scaledBy(padRatio / 2); - const keyHeight = keyHeightBase.scaledBy(1 - padRatio); + const keyHeightBase = layoutParams.heightStyle.absolute ? rowHeight : ParsedLengthStyle.forScalar(1); + const padTop = keyHeightBase.scaledBy(KEY_BTN_Y_PAD_RATIO / 2); + const keyHeight = keyHeightBase.scaledBy(1 - KEY_BTN_Y_PAD_RATIO); // Update all key-square layouts. const keyStyleUpdates = this.keys.map((key) => { return () => { - const keySquare = key.btn.parentElement; + const keySquare = key.square; const keyElement = key.btn; // Set the kmw-key-square position @@ -107,7 +111,9 @@ export default class OSKRow { const keyWidth = widthStyle.scaledBy(key.spec.proportionalWidth); const keyPad = widthStyle.scaledBy(key.spec.proportionalPad); - const keyHeight = heightStyle.scaledBy(this.heightFraction); + // We maintain key-btn padding within the key-square - the latter `scaledBy` + // adjusts for that, providing the final key-btn height. + const keyHeight = heightStyle.scaledBy(this.heightFraction).scaledBy(1 - KEY_BTN_Y_PAD_RATIO); // Match the row height (if fixed-height) or use full row height (if percent-based) const styleHeight = heightStyle.absolute ? keyHeight.styleString : '100%'; From 5ca37943121cad9c9ef6fea12d7ddee7e57bbef6 Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Mon, 8 Apr 2024 09:18:16 +0700 Subject: [PATCH 150/170] chore(web): better ref for spacebar key btn --- web/src/engine/osk/src/keyboard-layout/oskLayer.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/web/src/engine/osk/src/keyboard-layout/oskLayer.ts b/web/src/engine/osk/src/keyboard-layout/oskLayer.ts index a721f3ed480..3f73620949b 100644 --- a/web/src/engine/osk/src/keyboard-layout/oskLayer.ts +++ b/web/src/engine/osk/src/keyboard-layout/oskLayer.ts @@ -84,12 +84,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') { From 328900cf3405be83f59f70366ae62e7ff617819c Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Mon, 8 Apr 2024 10:08:23 +0700 Subject: [PATCH 151/170] fix(web): refixes bad return case --- web/src/engine/osk/src/keyboard-layout/oskKey.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/engine/osk/src/keyboard-layout/oskKey.ts b/web/src/engine/osk/src/keyboard-layout/oskKey.ts index 3dfea966f77..1d4bcdc0c7a 100644 --- a/web/src/engine/osk/src/keyboard-layout/oskKey.ts +++ b/web/src/engine/osk/src/keyboard-layout/oskKey.ts @@ -314,7 +314,7 @@ export default abstract class OSKKey { public refreshLayout(layoutParams: KeyLayoutParams) { // Avoid doing any font-size related calculations if there's no text to display. if(this.spec.sp == ButtonClasses.spacer || this.spec.sp == ButtonClasses.blank) { - return () => {};; + return () => {}; } // Attempt to detect static but key-specific style properties if they haven't yet @@ -324,7 +324,7 @@ export default abstract class OSKKey { // Abort if the element is not currently in the DOM; we can't get any info this way. if(!lblStyle.fontFamily) { - return; + return () => {}; } this._fontFamily = lblStyle.fontFamily; From b6bd677e1410738423c71326e522f52605ee022f Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Mon, 8 Apr 2024 13:31:42 +0700 Subject: [PATCH 152/170] fix(android): atomically updates selection with text --- .../java/com/keyman/android/SystemKeyboard.java | 6 ++---- .../main/java/com/keyman/engine/KMKeyboard.java | 17 +++++++++++++++-- .../main/java/com/keyman/engine/KMManager.java | 13 ++++--------- .../main/java/com/keyman/engine/KMTextView.java | 6 ++---- 4 files changed, 23 insertions(+), 19 deletions(-) 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..5381d77dbbe 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,17 @@ protected boolean updateSelectionRange(int selStart, int selEnd) { String rawText = icText.text.toString(); updateText(rawText.toString()); + // To determine: may need `icText.startOffset +`? + 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. @@ -187,14 +198,16 @@ protected boolean updateSelectionRange(int selStart, int selEnd) { */ // Count the number of characters which are surrogate pairs. + int stringLen = rawText.length(); + int pairsAtStart = CharSequenceUtil.countSurrogatePairs(rawText.substring(0, selStart), rawText.length()); String selectedText = rawText.substring(selStart, selEnd); int pairsSelected = CharSequenceUtil.countSurrogatePairs(selectedText, selectedText.length()); 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..50365aaba0c 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,18 @@ public static boolean updateText(KeyboardType kbType, String text) { return result; } - public static boolean updateSelectionRange(KeyboardType kbType, int selStart, int selEnd) { + 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); } } From 37b8c1ccb7bd6e0a672350760c8ad59a69cd33f3 Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Mon, 8 Apr 2024 13:32:51 +0700 Subject: [PATCH 153/170] docs(android): cleans in-dev comment --- android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboard.java | 1 - 1 file changed, 1 deletion(-) 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 5381d77dbbe..d781e54452a 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 @@ -175,7 +175,6 @@ protected boolean updateSelectionRange() { String rawText = icText.text.toString(); updateText(rawText.toString()); - // To determine: may need `icText.startOffset +`? int selStart = icText.selectionStart; int selEnd = icText.selectionEnd; From 75bde80a054df1c0eaee61e9bfad4ed030b6cb2b Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Mon, 8 Apr 2024 14:44:32 +0700 Subject: [PATCH 154/170] fix(web): quick multitap-modipress use --- .../gestures/matchers/gestureMatcher.ts | 30 +++++++++++-------- 1 file changed, 18 insertions(+), 12 deletions(-) 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. From 522d929522512ea446bfa00f22072306a31b741c Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Mon, 8 Apr 2024 15:10:33 +0700 Subject: [PATCH 155/170] fix(android): restores API method, deprecates + docs old form --- .../java/com/keyman/engine/KMManager.java | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) 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 50365aaba0c..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,6 +2137,28 @@ 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; From 9a1ef32e1567beb478fac787648aca341f820fe3 Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Tue, 9 Apr 2024 08:30:35 +0700 Subject: [PATCH 156/170] chore(android): removal of new but unused var --- .../KMEA/app/src/main/java/com/keyman/engine/KMKeyboard.java | 2 -- 1 file changed, 2 deletions(-) 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 d781e54452a..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 @@ -197,8 +197,6 @@ protected boolean updateSelectionRange() { */ // Count the number of characters which are surrogate pairs. - int stringLen = rawText.length(); - int pairsAtStart = CharSequenceUtil.countSurrogatePairs(rawText.substring(0, selStart), rawText.length()); String selectedText = rawText.substring(selStart, selEnd); int pairsSelected = CharSequenceUtil.countSurrogatePairs(selectedText, selectedText.length()); From a0b7ede32af9ef4bc98334e718ece9fb47c3ac1b Mon Sep 17 00:00:00 2001 From: Keyman Build Agent Date: Tue, 9 Apr 2024 14:04:23 -0400 Subject: [PATCH 157/170] auto: increment beta version to 17.0.305 --- HISTORY.md | 5 +++++ VERSION.md | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/HISTORY.md b/HISTORY.md index 1aa044f8021..80aba04e025 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,5 +1,10 @@ # 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) diff --git a/VERSION.md b/VERSION.md index 29d39b71943..f9d9a9bbd8f 100644 --- a/VERSION.md +++ b/VERSION.md @@ -1 +1 @@ -17.0.304 \ No newline at end of file +17.0.305 \ No newline at end of file From 875413d62fce554dbb36a3d6683ca60ba2c80637 Mon Sep 17 00:00:00 2001 From: Darcy Wong Date: Wed, 10 Apr 2024 08:40:49 +0700 Subject: [PATCH 158/170] chore(oem/fv): Add fv_sguuxs --- oem/firstvoices/keyboards.csv | 1 + 1 file changed, 1 insertion(+) diff --git a/oem/firstvoices/keyboards.csv b/oem/firstvoices/keyboards.csv index e11dfab50b2..4bd3045c28d 100644 --- a/oem/firstvoices/keyboards.csv +++ b/oem/firstvoices/keyboards.csv @@ -25,6 +25,7 @@ fv,fv_nisgaa,Nisg̱a'a,BC Coast,fv_nisgaa_kmw-9.0.js,9.1.2,ncg-Latn,Nisga'a (Lat fv,fv_nuucaanul,Nuučaan̓uł,BC Coast,fv_nuucaanul_kmw-9.0.js,9.1.4,nuk-Latn,Nuu-chah-nulth (Latin) fv,fv_nuxalk,Nuxalk,BC Coast,fv_nuxalk_kmw-9.0.js,10.0,blc-Latn,Bella Coola (Latin) fv,fv_sencoten,SENĆOŦEN,BC Coast,fv_sencoten_kmw-9.0.js,9.2.1,str,Straits Salish +fv,fv_sguuxs,Sgüüx̱s,BC Coast,fv_sguuxs.js,1.0,tsi,Tsimshian fv,fv_shashishalhem,Shashishalhem,BC Coast,fv_shashishalhem_kmw-9.0.js,9.1.3,sec-Latn,Sechelt (Latin) fv,fv_skwxwumesh_snichim,Sḵwx̱wúmesh sníchim,BC Coast,fv_skwxwumesh_snichim_kmw-9.0.js,9.2.1,squ-Latn,Squamish (Latin) fv,fv_smalgyax,Sm'algya̱x,BC Coast,fv_smalgyax_kmw-9.0.js,9.1.3,tsi-Latn,Tsimshian (Latin) From 17006429e10f395b3bc95005f24fe66a603612af Mon Sep 17 00:00:00 2001 From: Darcy Wong Date: Wed, 10 Apr 2024 08:40:59 +0700 Subject: [PATCH 159/170] chore(oem/fv): Update keyboard versions --- oem/firstvoices/keyboards.csv | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/oem/firstvoices/keyboards.csv b/oem/firstvoices/keyboards.csv index 4bd3045c28d..1bc76fd58ed 100644 --- a/oem/firstvoices/keyboards.csv +++ b/oem/firstvoices/keyboards.csv @@ -11,11 +11,11 @@ fv,fv_uwikala,'Uwik̓ala,BC Coast,fv_uwikala_kmw-9.0.js,9.3,hei,Heiltsuk fv,fv_dexwlesucid,Dəxʷləšucid,BC Coast,fv_dexwlesucid_kmw-9.0.js,9.2.1,lut-Latn,Lushootseed (Latin) fv,fv_diitiidatx,Diidiitidq,BC Coast,fv_diitiidatx_kmw-9.0.js,9.1.3,nuk-Latn,Nuu-chah-nulth (Latin) fv,fv_gitsenimx,Gitsenimx̱,BC Coast,fv_gitsenimx_kmw-9.0.js,10.0.1,git,Gitxsan (Latin) -fv,fv_hailzaqvla,Haiɫzaqvla,BC Coast,fv_hailzaqvla_kmw-9.0.js,9.5,hei,Heiltsuk (Latin) -fv,fv_haisla,Haisla,BC Coast,fv_haisla.js,1.0,has-Latn,Haisla (Latin) +fv,fv_hailzaqvla,Haiɫzaqvla,BC Coast,fv_hailzaqvla_kmw-9.0.js,9.5.1,hei,Heiltsuk (Latin) +fv,fv_haisla,Haisla,BC Coast,fv_haisla.js,2.0.1,has-Latn,Haisla (Latin) fv,fv_halqemeylem,Halq'eméylem,BC Coast,fv_halqemeylem_kmw-9.0.js,9.1.3,hur-Latn,Halkomelem (Latin) -fv,fv_henqeminem,Hǝn̓q̓ǝmin̓ǝm,BC Coast,fv_henqeminem_kmw-9.0.js,9.1.3,hur-Latn,Halkomelem (Latin) -fv,fv_klahoose,Homalco-Klahoose-Sliammon,BC Coast,fv_klahoose_kmw-9.0.js,10.0,coo,Comox +fv,fv_henqeminem,Hǝn̓q̓ǝmin̓ǝm,BC Coast,fv_henqeminem_kmw-9.0.js,10.0.1,hur-Latn,Halkomelem (Latin) +fv,fv_klahoose,Homalco-Klahoose-Sliammon,BC Coast,fv_klahoose_kmw-9.0.js,10.1,coo,Comox fv,fv_hulquminum,Hul’q’umi’num’,BC Coast,fv_hulquminum_kmw-9.0.js,9.1,hur-Latn,Halkomelem (Latin) fv,fv_hulquminum_combine,Hul̓q̓umin̓um̓,BC Coast,fv_hulquminum_combine_kmw-9.0.js,1.0,hur-Latn,Halkomelem (Latin) fv,fv_kwakwala_liqwala,Kʷak̓ʷala,BC Coast,fv_kwakwala_liqwala_kmw-9.0.js,9.2.5,kwk-Latn,Kwakiutl (Latin) @@ -27,7 +27,7 @@ fv,fv_nuxalk,Nuxalk,BC Coast,fv_nuxalk_kmw-9.0.js,10.0,blc-Latn,Bella Coola (Lat fv,fv_sencoten,SENĆOŦEN,BC Coast,fv_sencoten_kmw-9.0.js,9.2.1,str,Straits Salish fv,fv_sguuxs,Sgüüx̱s,BC Coast,fv_sguuxs.js,1.0,tsi,Tsimshian fv,fv_shashishalhem,Shashishalhem,BC Coast,fv_shashishalhem_kmw-9.0.js,9.1.3,sec-Latn,Sechelt (Latin) -fv,fv_skwxwumesh_snichim,Sḵwx̱wúmesh sníchim,BC Coast,fv_skwxwumesh_snichim_kmw-9.0.js,9.2.1,squ-Latn,Squamish (Latin) +fv,fv_skwxwumesh_snichim,Sḵwx̱wúmesh sníchim,BC Coast,fv_skwxwumesh_snichim_kmw-9.0.js,9.3,squ-Latn,Squamish (Latin) fv,fv_smalgyax,Sm'algya̱x,BC Coast,fv_smalgyax_kmw-9.0.js,9.1.3,tsi-Latn,Tsimshian (Latin) fv,fv_xaislakala,X̄a'ʼislak̓ala,BC Coast,fv_xaislakala_kmw-9.0.js,9.1.1,has-Latn,Haisla (Latin) fv,fv_hlgaagilda_xaayda_kil,X̱aayda-X̱aad Kil,BC Coast,fv_hlgaagilda_xaayda_kil_kmw-9.0.js,9.3,hax,Southern Haida @@ -38,13 +38,13 @@ fv,fv_natwits,Nedut’en-Witsuwit'en,BC Interior,fv_natwits_kmw-9.0.js,9.1.3,caf fv,fv_nlekepmxcin,Nłeʔkepmxcin,BC Interior,fv_nlekepmxcin_kmw-9.0.js,9.2.3,thp-Latn,Thompson (Latin) fv,fv_nlha7kapmxtsin,Nlha7kapmxtsin,BC Interior,fv_nlha7kapmxtsin_kmw-9.0.js,9.1.1,thp-Latn,Thompson (Latin) fv,fv_nsilxcen,Nsilxcən,BC Interior,fv_nsilxcen_kmw-9.0.js,9.3,oka,Okanagan -fv,fv_secwepemctsin,Secwepemctsín,BC Interior,fv_secwepemctsin_kmw-9.0.js,9.1.2,shs-Latn,Shuswap (Latin) +fv,fv_secwepemctsin,Secwepemctsín,BC Interior,fv_secwepemctsin_kmw-9.0.js,9.2,shs-Latn,Shuswap (Latin) fv,fv_stlatlimxec,Sƛ̓aƛ̓imxəc,BC Interior,fv_stlatlimxec_kmw-9.0.js,9.2.3,lil-Latn,Lillooet (Latin) -fv,fv_statimcets,St̓át̓imcets,BC Interior,fv_statimcets_kmw-9.0.js,9.1.3,lil-Latn,Lillooet (Latin) +fv,fv_statimcets,St̓át̓imcets,BC Interior,fv_statimcets_kmw-9.0.js,9.1.4,lil-Latn,Lillooet (Latin) fv,fv_taltan,Tāłtān,BC Interior,fv_taltan_kmw-9.0.js,9.1.5,tht-Latn,Tahltan (Latin) fv,fv_tsekehne,Tsek'ehne,BC Interior,fv_tsekehne_kmw-9.0.js,9.1.2,sek-Latn,Sekani (Latin) fv,fv_tsilhqotin,Tŝilhqot'in,BC Interior,fv_tsilhqotin_kmw-9.0.js,9.1.3,clc-Latn,Chilcotin (Latin) -fv,fv_southern_carrier,ᑐᑊᘁᗕᑋᗸ (Southern Carrier),BC Interior,fv_southern_carrier_kmw-9.0.js,9.2.1,caf-Cans,Southern Carrier (Unified Canadian Aboriginal Syllabics) +fv,fv_southern_carrier,ᑐᑊᘁᗕᑋᗸ (Southern Carrier),BC Interior,fv_southern_carrier_kmw-9.0.js,10.0,caf-Cans,Southern Carrier (Unified Canadian Aboriginal Syllabics) fv,fv_anicinapemi8in,Anicinapemi8in/Anishinàbemiwin,Eastern Subarctic,fv_anicinapemi8in_kmw-9.0.js,9.1.1,alq-Latn,Algonquin (Latin) fv,fv_atikamekw,Atikamekw,Eastern Subarctic,fv_atikamekw_kmw-9.0.js,9.1.1,atj-Latn,Atikamekw (Latin) fv,fv_ilnu_innu_aimun,Ilnu-Innu Aimun,Eastern Subarctic,fv_ilnu_innu_aimun_kmw-9.0.js,9.1.1,moe-Latn,Montagnais (Latin) @@ -70,14 +70,14 @@ fv,fv_wobanakiodwawogan,Wôbanakiôdwawôgan,Great Lakes - St. Lawrence,fv_woban fv,fv_australian,Australian,Pacific,fv_australian_kmw-9.0.js,9.3.1,pjt-Latn,Pitjantjatjara (Latin) fv,fv_maori,Māori,Pacific,fv_maori_kmw-9.0.js,9.1.1,mi-Latn,Maori (Latin) fv,fv_blackfoot,Blackfoot,Prairies,fv_blackfoot_kmw-9.0.js,9.2.1,bla-Latn,Siksika (Latin) -fv,fv_cree_latin,Cree - Roman Orthography,Prairies,fv_cree_latin_kmw-9.0.js,10.0,cr-Latn,Cree (Latin) +fv,fv_cree_latin,Cree - Roman Orthography,Prairies,fv_cree_latin_kmw-9.0.js,10.0.1,cr-Latn,Cree (Latin) fv,fv_dakota_mb,Dakota,Prairies,fv_dakota_mb_kmw-9.0.js,9.1.1,dak-Latn,Dakota (Latin) fv,fv_dakota_sk,Dakot̄a,Prairies,fV_dakota_sk_kmw-9.0.js,9.1.1,dak-Latn,Dakota (Latin) fv,fv_isga_iabi,Isga Iʔabi,Prairies,fv_isga_iabi_kmw-9.0.js,9.1.1,sto-Latn,Stoney (Latin) fv,fv_lakota,Lak̇ot̄a,Prairies,fv_lakota-9.0.js,9.1.1,lkt-Latn,Lakota (Latin) fv,fv_nakoda,Nakoda,Prairies,fv_nakoda_kmw-9.0.js,9.1.1,asb-Latn,Assiniboine (Latin) fv,fv_tsuutina,Tsúùt'ínà,Prairies,fv_tsuutina_kmw-9.0.js,9.1.1,srs-Latn,Sarsi (Latin) -fv,fv_plains_cree,ᓀᐦᐃᔭᐍᐏᐣ (Plains Cree),Prairies,fv_plains_cree_kmw-9.0.js,10.0.2,crk-Cans,ᓀᐦᐃᔭᐍᐏᐣ (Cree syllabics) +fv,fv_plains_cree,ᓀᐦᐃᔭᐍᐏᐣ (Plains Cree),Prairies,fv_plains_cree_kmw-9.0.js,11.0,crk-Cans,ᓀᐦᐃᔭᐍᐏᐣ (Cree syllabics) fv,fv_dine_bizaad,Diné Bizaad,South West,fv_dine_bizaad_kmw-9.0.js,9.1.1,nv-Latn,Navajo (Latin) fv,fv_dane_zaa_zaage,Dane-Z̲aa Z̲áágéʔ,Western Subarctic,fv_dane_zaa_zaage_kmw-9.0.js,9.3,bea,Beaver fv,fv_dene_dzage,Dene Dzage,Western Subarctic,fv_dene_dzage_kmw-9.0.js,9.1.1,kkz-Latn,Kaska (Latin) From 82e7ddfd71a5f0e592882dd8a1437365bc66dccf Mon Sep 17 00:00:00 2001 From: Joshua Horton Date: Wed, 10 Apr 2024 09:23:54 +0700 Subject: [PATCH 160/170] docs(ios): adds new section about the banner (#10630) --- ios/help/basic/index.md | 1 + ios/help/basic/using-the-banner.md | 29 ++++++++++++++++++ ios/help/index.md | 1 + .../ios_images/settings-suggestions-i.png | Bin 0 -> 85996 bytes ios/help/ios_images/themed-banner.png | Bin 0 -> 88418 bytes 5 files changed, 31 insertions(+) create mode 100644 ios/help/basic/using-the-banner.md create mode 100644 ios/help/ios_images/settings-suggestions-i.png create mode 100644 ios/help/ios_images/themed-banner.png diff --git a/ios/help/basic/index.md b/ios/help/basic/index.md index e2c9e82d6d2..b67688e03ce 100644 --- a/ios/help/basic/index.md +++ b/ios/help/basic/index.md @@ -1,6 +1,7 @@ --- title: Basic Help --- +* [Using the Banner on the Keyboard](using-the-banner) * [Switching Between Keyboards](switching-between-keyboards) diff --git a/ios/help/basic/using-the-banner.md b/ios/help/basic/using-the-banner.md new file mode 100644 index 00000000000..dc1edf2479c --- /dev/null +++ b/ios/help/basic/using-the-banner.md @@ -0,0 +1,29 @@ +--- +title: Using the Banner on the Keyboard - Keyman for iPhone and iPad Help +--- + +## About the Keyboard Banner + +Keyman keyboards now always display a banner above the keyboard for one of the following functionalities: + +* Display suggestions (See "Using the Suggestion Banner" below) +* Display a Keyman-themed banner so popups and gestures for the top row of keys are visible +* Reserved for future functionality + +## Using the Suggestion Banner + +If a [dictionary is installed](installing-custom-keyboards-dictionaries) and enabled for the active Keyman +keyboard, the banner will display suggestions that can be selected. + +![](../ios_images/settings-suggestions-i.png) + +* Drag the banner horizontally to see more suggestions +* Overly-long suggestions are partially hidden, but expand when a finger is held on them +* The banner will display up to 8 suggestions at a time. + +## The Keyman-Themed Banner + +When suggestions are disabled or unavailable, the Keyman-themed banner will display instead. +This is displayed so that key-popups and gestures for the top row of keys are visible. + +![](../ios_images/themed-banner.png) \ No newline at end of file diff --git a/ios/help/index.md b/ios/help/index.md index 739195dd6e5..a40cadafaf2 100644 --- a/ios/help/index.md +++ b/ios/help/index.md @@ -15,6 +15,7 @@ title: Keyman for iPhone and iPad 17.0 Help ## [Using Keyman for iPhone and iPad](basic/) +* [Using the Banner on the Keyboard](using-the-banner) * [Switching Between Keyboards](basic/switching-between-keyboards) * [Using a Keyboard](basic/keyboard-usage) * [Using the Menu](context/) diff --git a/ios/help/ios_images/settings-suggestions-i.png b/ios/help/ios_images/settings-suggestions-i.png new file mode 100644 index 0000000000000000000000000000000000000000..7018a16bbd9221f6201456bed6531412005b4df3 GIT binary patch literal 85996 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 t7qk>O7qDqbc@u7DXIYXxPIM+TeMK>EPB_&NH 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

hSaP^Hnc*YVY8kfX}hj-ejN9Oq` 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 literal 0 HcmV?d00001 diff --git a/ios/help/ios_images/themed-banner.png b/ios/help/ios_images/themed-banner.png new file mode 100644 index 0000000000000000000000000000000000000000..a8fd5e2daa0abcd2909b46a6680eb2971e5204f6 GIT binary patch literal 88418 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 Ay#N3J literal 0 HcmV?d00001 From b1efaaab12a76093fcc16bec3f55201eaf42d626 Mon Sep 17 00:00:00 2001 From: Joshua Horton Date: Wed, 10 Apr 2024 09:28:10 +0700 Subject: [PATCH 161/170] docs(ios): removes old Show Banner toggle section --- ios/help/basic/config/index.md | 11 ----------- ios/help/ios_images/banner.png | Bin 51953 -> 0 bytes ios/help/ios_images/no-banner.png | Bin 21622 -> 0 bytes 3 files changed, 11 deletions(-) delete mode 100644 ios/help/ios_images/banner.png delete mode 100644 ios/help/ios_images/no-banner.png diff --git a/ios/help/basic/config/index.md b/ios/help/basic/config/index.md index 22468a4021c..64ea47eac59 100644 --- a/ios/help/basic/config/index.md +++ b/ios/help/basic/config/index.md @@ -26,17 +26,6 @@ screen where you may view the languages you have installed and, by clicking any Click on this to [search for a keyboard or language](../../start/searching-for-keyboards). -## Show Banner -This adds a banner to the top of the keyboard to allow space for pop-up keys to render above the top row of keys. - -![](../../ios_images/banner.png) - -When this is off, - -![](../../ios_images/no-banner.png) - -pop-up keys in the top row render horizontally. - ## Show "Get Started" on startup When enabled, the Keyman app will display the 'Get Started' screen on app startup. diff --git a/ios/help/ios_images/banner.png b/ios/help/ios_images/banner.png deleted file mode 100644 index e7d38e8a2ee9de8ce55335e35e982fd05a3faffb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 51953 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®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 Date: Wed, 10 Apr 2024 09:46:18 +0700 Subject: [PATCH 162/170] docs(ios): general help-image updates --- ios/help/ios_images/dist-install1-i.png | Bin 25155 -> 153426 bytes ios/help/ios_images/key-submenu.png | Bin 160946 -> 345733 bytes ios/help/ios_images/lang-list.png | Bin 10465 -> 0 bytes ios/help/ios_images/language-settings.png | Bin 18626 -> 372629 bytes ios/help/ios_images/system-keyboard-cal.png | Bin 98593 -> 404228 bytes ios/help/ios_images/system-picker-i.png | Bin 99757 -> 99510 bytes 6 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 ios/help/ios_images/lang-list.png diff --git a/ios/help/ios_images/dist-install1-i.png b/ios/help/ios_images/dist-install1-i.png index e4632eafc93b5f6d132aa511fb117b97ea0d52dc..93257b27ca45438c0082d45a8ea7e3391851812c 100644 GIT binary patch literal 153426 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

hM^*Rf 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