From 504eb53565ed8e015fc4bd7e120a4e8be1c75382 Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Wed, 30 Aug 2023 10:06:12 +0700 Subject: [PATCH 1/5] feat(web): adds support for pushed alt-configurations during gesture-source lifetime --- .../gestureRecognizerConfiguration.ts | 33 ++++ .../src/engine/gestureRecognizer.ts | 36 +--- .../src/engine/headless/gestureSource.ts | 160 +++++++++++++++--- .../src/engine/headless/inputEngineBase.ts | 12 ++ .../src/engine/inputEventEngine.ts | 2 +- .../src/engine/mouseEventEngine.ts | 3 +- .../src/engine/touchEventEngine.ts | 4 +- .../src/headlessInputEngine.ts | 2 +- 8 files changed, 189 insertions(+), 63 deletions(-) diff --git a/common/web/gesture-recognizer/src/engine/configuration/gestureRecognizerConfiguration.ts b/common/web/gesture-recognizer/src/engine/configuration/gestureRecognizerConfiguration.ts index 20ac75cab09..6849a2c29f8 100644 --- a/common/web/gesture-recognizer/src/engine/configuration/gestureRecognizerConfiguration.ts +++ b/common/web/gesture-recognizer/src/engine/configuration/gestureRecognizerConfiguration.ts @@ -3,6 +3,9 @@ // after configuration. import { InputSample } from "../headless/inputSample.js"; +import { Mutable } from "../mutable.js"; +import { Nonoptional } from "../nonoptional.js"; +import { PaddedZoneSource } from "./paddedZoneSource.js"; import { RecognitionZoneSource } from "./recognitionZoneSource.js"; // For example, customization of a longpress timer's length need not be readonly. @@ -81,4 +84,34 @@ export interface GestureRecognizerConfiguration { * @returns */ readonly itemIdentifier?: (coord: Omit, 'item'>, target: EventTarget) => HoveredItemType; +} + +export function preprocessRecognizerConfig( + config: GestureRecognizerConfiguration +): Nonoptional> { + // Allows configuration pre-processing during this method. + let processingConfig: Mutable>> = {...config} as Nonoptional>; + processingConfig.mouseEventRoot = processingConfig.mouseEventRoot ?? processingConfig.targetRoot; + processingConfig.touchEventRoot = processingConfig.touchEventRoot ?? processingConfig.targetRoot; + + processingConfig.inputStartBounds = processingConfig.inputStartBounds ?? processingConfig.targetRoot; + processingConfig.maxRoamingBounds = processingConfig.maxRoamingBounds ?? processingConfig.targetRoot; + processingConfig.safeBounds = processingConfig.safeBounds ?? new PaddedZoneSource([2]); + + processingConfig.itemIdentifier = processingConfig.itemIdentifier ?? (() => null); + + if(!config.paddedSafeBounds) { + let paddingArray = config.safeBoundPadding; + if(typeof paddingArray == 'number') { + paddingArray = [ paddingArray ]; + } + paddingArray = paddingArray ?? [3]; + + processingConfig.paddedSafeBounds = new PaddedZoneSource(processingConfig.safeBounds, paddingArray); + } else { + // processingConfig.paddedSafeBounds is already set via the spread operator above. + delete processingConfig.safeBoundPadding; + } + + return processingConfig; } \ No newline at end of file diff --git a/common/web/gesture-recognizer/src/engine/gestureRecognizer.ts b/common/web/gesture-recognizer/src/engine/gestureRecognizer.ts index 7e254ad019b..d847dc9b03c 100644 --- a/common/web/gesture-recognizer/src/engine/gestureRecognizer.ts +++ b/common/web/gesture-recognizer/src/engine/gestureRecognizer.ts @@ -1,8 +1,6 @@ -import { GestureRecognizerConfiguration } from "./configuration/gestureRecognizerConfiguration.js"; +import { GestureRecognizerConfiguration, preprocessRecognizerConfig } from "./configuration/gestureRecognizerConfiguration.js"; import { MouseEventEngine } from "./mouseEventEngine.js"; -import { Mutable } from "./mutable.js"; import { Nonoptional } from "./nonoptional.js"; -import { PaddedZoneSource } from "./configuration/paddedZoneSource.js"; import { TouchEventEngine } from "./touchEventEngine.js"; import { TouchpointCoordinator } from "./headless/touchpointCoordinator.js"; @@ -12,39 +10,9 @@ export class GestureRecognizer extends TouchpointCoordinator; private readonly touchEngine: TouchEventEngine; - protected static preprocessConfig( - config: GestureRecognizerConfiguration - ): Nonoptional> { - // Allows configuration pre-processing during this method. - let processingConfig: Mutable>> = {...config} as Nonoptional>; - processingConfig.mouseEventRoot = processingConfig.mouseEventRoot ?? processingConfig.targetRoot; - processingConfig.touchEventRoot = processingConfig.touchEventRoot ?? processingConfig.targetRoot; - - processingConfig.inputStartBounds = processingConfig.inputStartBounds ?? processingConfig.targetRoot; - processingConfig.maxRoamingBounds = processingConfig.maxRoamingBounds ?? processingConfig.targetRoot; - processingConfig.safeBounds = processingConfig.safeBounds ?? new PaddedZoneSource([2]); - - processingConfig.itemIdentifier = processingConfig.itemIdentifier ?? (() => null); - - if(!config.paddedSafeBounds) { - let paddingArray = config.safeBoundPadding; - if(typeof paddingArray == 'number') { - paddingArray = [ paddingArray ]; - } - paddingArray = paddingArray ?? [3]; - - processingConfig.paddedSafeBounds = new PaddedZoneSource(processingConfig.safeBounds, paddingArray); - } else { - // processingConfig.paddedSafeBounds is already set via the spread operator above. - delete processingConfig.safeBoundPadding; - } - - return processingConfig; - } - public constructor(config: GestureRecognizerConfiguration) { super(); - this.config = GestureRecognizer.preprocessConfig(config); + this.config = preprocessRecognizerConfig(config); this.mouseEngine = new MouseEventEngine(this.config); this.touchEngine = new TouchEventEngine(this.config); diff --git a/common/web/gesture-recognizer/src/engine/headless/gestureSource.ts b/common/web/gesture-recognizer/src/engine/headless/gestureSource.ts index 17476e949e8..00513699931 100644 --- a/common/web/gesture-recognizer/src/engine/headless/gestureSource.ts +++ b/common/web/gesture-recognizer/src/engine/headless/gestureSource.ts @@ -1,5 +1,7 @@ import { InputSample } from "./inputSample.js"; import { SerializedGesturePath, GesturePath } from "./gesturePath.js"; +import { GestureRecognizerConfiguration, preprocessRecognizerConfig } from "../configuration/gestureRecognizerConfiguration.js"; +import { Nonoptional } from "../nonoptional.js"; /** * Documents the expected typing of serialized versions of the `GestureSource` class. @@ -47,6 +49,9 @@ export class GestureSource { private static _jsonIdSeed: -1; + // Assertion: must always contain an index 0 - the base recognizer config. + protected recognizerConfigStack: Nonoptional>[]; + /** * Tracks the coordinates and timestamps of each update for the lifetime of this `GestureSource`. */ @@ -60,10 +65,16 @@ export class GestureSource { * @param initialHoveredItem The initiating event's original target element * @param isFromTouch `true` if sourced from a `TouchEvent`; `false` otherwise. */ - constructor(identifier: number, isFromTouch: boolean) { + constructor( + identifier: number, + recognizerConfig: Nonoptional> | Nonoptional>[], + isFromTouch: boolean + ) { this.rawIdentifier = identifier; this.isFromTouch = isFromTouch; this._path = new GesturePath(); + + this.recognizerConfigStack = Array.isArray(recognizerConfig) ? recognizerConfig : [recognizerConfig]; } /** @@ -76,7 +87,7 @@ export class GestureSource { const isFromTouch = jsonObj.isFromTouch; const path = GesturePath.deserialize(jsonObj.path); - const instance = new GestureSource(id, isFromTouch); + const instance = new GestureSource(id, null, isFromTouch); instance._path = path; return instance; } @@ -112,7 +123,7 @@ export class GestureSource { * @returns */ public constructSubview(startAtEnd: boolean, preserveBaseItem: boolean): GestureSourceSubview { - return new GestureSourceSubview(this, startAtEnd, preserveBaseItem); + return new GestureSourceSubview(this, this.recognizerConfigStack, startAtEnd, preserveBaseItem); } /** @@ -142,10 +153,31 @@ export class GestureSource { return `${prefix}:${this.rawIdentifier}`; } + public pushRecognizerConfig(config: Omit, 'touchEventRoot'| 'mouseEventRoot'>) { + const configToProcess = {...config, + mouseEventRoot: this.recognizerConfigStack[0].mouseEventRoot, + touchEventRoot: this.recognizerConfigStack[0].touchEventRoot + } + this.recognizerConfigStack.push(preprocessRecognizerConfig(configToProcess)); + } + + public popRecognizerConfig() { + if(this.recognizerConfigStack.length == 1) { + throw new Error("Cannot 'pop' the original recognizer-configuration for this GestureSource.") + } + + return this.recognizerConfigStack.pop(); + } + + public get currentRecognizerConfig() { + return this.recognizerConfigStack[this.recognizerConfigStack.length-1]; + } + /** * Creates a serialization-friendly version of this instance for use by * `JSON.stringify`. */ + /* c8 ignore start */ toJSON(): SerializedGestureSource { let jsonClone: SerializedGestureSource = { isFromTouch: this.isFromTouch, @@ -153,11 +185,15 @@ export class GestureSource { } return jsonClone; + /* c8 ignore stop */ + /* c8 ignore next 2 */ + // esbuild or tsc seems to mangle the 'ignore stop' if put outside the ending brace. } } export class GestureSourceSubview extends GestureSource { private _baseSource: GestureSource + private _baseStartIndex: number; private subviewDisconnector: () => void; /** @@ -167,11 +203,41 @@ export class GestureSourceSubview extends GestureSource, startAtEnd: boolean, preserveBaseItem: boolean) { - super(source.rawIdentifier, source.isFromTouch); + constructor( + source: GestureSource, + configStack: typeof GestureSource.prototype['recognizerConfigStack'], + startAtEnd: boolean, + preserveBaseItem: boolean + ) { + let mayUpdate = true; + let start = 0; + let length = source.path.coords.length; + if(source instanceof GestureSourceSubview) { + const expectedLength = source._baseStartIndex + source.path.coords.length; + start = source._baseStartIndex; + // Check against the full remaining length of the original source; does + // the subview provided to us include its source's most recent point? + const sampleCountSinceStart = source.baseSource.path.coords.length; + if(expectedLength != start + sampleCountSinceStart) { + mayUpdate = false; + } + } + + super(source.rawIdentifier, configStack, source.isFromTouch); const baseSource = this._baseSource = source instanceof GestureSourceSubview ? source._baseSource : source; + /** + * Provides a coordinate-system translation for source subviews. + * The base version still needs to use the original coord system, though. + */ + const translateSample = (sample: InputSample) => { + const translation = this.recognizerTranslation; + // Provide a coordinate-system translation for source subviews. + // The base version still needs to use the original coord system, though. + return {...sample, targetX: sample.targetX - translation.x, targetY: sample.targetY - translation.y}; + } + // Note: we don't particularly need subviews to track the actual coords aside from // tracking related stats data. But... we don't have an "off-switch" for that yet. let subpath: GesturePath; @@ -179,13 +245,19 @@ export class GestureSourceSubview extends GestureSource(); - if(lastSample) { - subpath.extend(lastSample); - } + this._baseStartIndex = start = Math.max(start + length - 1, 0); + length = length > 0 ? 1 : 0; } else { - subpath = source.path.clone(); + this._baseStartIndex = start; + } + + subpath = new GesturePath(); + for(let i=0; i < length; i++) { + const index = start + i; + subpath.extend(translateSample(baseSource.path.coords[index])); } this._path = subpath; @@ -196,25 +268,51 @@ export class GestureSourceSubview extends GestureSource this.path.terminate(false); - const invalidatedHook = () => this.path.terminate(true); - const stepHook = (sample) => this.update(sample); - baseSource.path.on('complete', completeHook); - baseSource.path.on('invalidated', invalidatedHook); - baseSource.path.on('step', stepHook); - - // But make sure we can "disconnect" it later once the gesture being matched - // with the subview has fully matched; it's good to have a snapshot left over. - this.subviewDisconnector = () => { - baseSource.path.off('complete', completeHook); - baseSource.path.off('invalidated', invalidatedHook); - baseSource.path.off('step', stepHook); + if(mayUpdate) { + // Ensure that this 'subview' is updated whenever the "source of truth" is. + const completeHook = () => this.path.terminate(false); + const invalidatedHook = () => this.path.terminate(true); + const stepHook = (sample: InputSample) => { + super.update(translateSample(sample)); + }; + baseSource.path.on('complete', completeHook); + baseSource.path.on('invalidated', invalidatedHook); + baseSource.path.on('step', stepHook); + + // But make sure we can "disconnect" it later once the gesture being matched + // with the subview has fully matched; it's good to have a snapshot left over. + this.subviewDisconnector = () => { + baseSource.path.off('complete', completeHook); + baseSource.path.off('invalidated', invalidatedHook); + baseSource.path.off('step', stepHook); + } + } + } + + private get recognizerTranslation() { + // Allowing a 'null' config greatly simplifies many of our unit-test specs. + if(this.recognizerConfigStack.length == 1 || !this.currentRecognizerConfig) { + return { + x: 0, + y: 0 + }; + } + + // Could compute all of this a single time & cache the value whenever a recognizer-config is pushed or popped. + const currentRecognizer = this.currentRecognizerConfig; + const currentClientRect = currentRecognizer.targetRoot.getBoundingClientRect(); + const baseClientRect = this.recognizerConfigStack[0].targetRoot.getBoundingClientRect(); + + return { + x: currentClientRect.x - baseClientRect.x, + y: currentClientRect.y - baseClientRect.y } } /** - * The original GestureSource this subview is based upon. + * The original GestureSource this subview is based upon. Note that the coordinate system may + * differ if a gesture stage/component has occurred that triggered a change to the active + * recognizer configuration. (e.g. a subkey menu is being displayed for a longpress interaction) */ public get baseSource() { return this._baseSource; @@ -231,6 +329,18 @@ export class GestureSourceSubview extends GestureSource, "touchEventRoot" | "mouseEventRoot">): void { + throw new Error("Pushing and popping of recognizer configurations should only be called on the base GestureSource"); + } + + public popRecognizerConfig(): Nonoptional> { + throw new Error("Pushing and popping of recognizer configurations should only be called on the base GestureSource"); + } + + public update(sample: InputSample): void { + throw new Error("Updates should be provided through the base GestureSource.") + } + /** * Like `disconnect`, but this will also terminate the baseSource and prevent further * updates for the true, original `GestureSource` instance. If the gesture-model diff --git a/common/web/gesture-recognizer/src/engine/headless/inputEngineBase.ts b/common/web/gesture-recognizer/src/engine/headless/inputEngineBase.ts index 32442ac39b2..272d20a9421 100644 --- a/common/web/gesture-recognizer/src/engine/headless/inputEngineBase.ts +++ b/common/web/gesture-recognizer/src/engine/headless/inputEngineBase.ts @@ -31,6 +31,18 @@ export abstract class InputEngineBase extends EventEmitter point.rawIdentifier == identifier); } + /** + * During the lifetime of a GestureSource (a continuous path for a single touchpoint), + * it is possible that the legal area for the path may change. This function allows + * us to find the appropriate set of constraints for the path if any changes have been + * requested - say, for a subkey menu after a longpress. + * @param identifier + * @returns + */ + protected getConfigForId(identifier: number) { + return this.getTouchpointWithId(identifier).currentRecognizerConfig; + } + public dropTouchpointWithId(identifier: number) { this._activeTouchpoints = this._activeTouchpoints.filter((point) => point.rawIdentifier != identifier); } diff --git a/common/web/gesture-recognizer/src/engine/inputEventEngine.ts b/common/web/gesture-recognizer/src/engine/inputEventEngine.ts index fa37e272c93..533c3778839 100644 --- a/common/web/gesture-recognizer/src/engine/inputEventEngine.ts +++ b/common/web/gesture-recognizer/src/engine/inputEventEngine.ts @@ -32,7 +32,7 @@ export abstract class InputEventEngine extends InputEngineBase< } protected onInputStart(identifier: number, sample: InputSample, target: EventTarget, isFromTouch: boolean) { - const touchpoint = new GestureSource(identifier, isFromTouch); + const touchpoint = new GestureSource(identifier, this.config, isFromTouch); touchpoint.update(sample); this.addTouchpoint(touchpoint); diff --git a/common/web/gesture-recognizer/src/engine/mouseEventEngine.ts b/common/web/gesture-recognizer/src/engine/mouseEventEngine.ts index f0475c9ccc6..5952f9cdbbd 100644 --- a/common/web/gesture-recognizer/src/engine/mouseEventEngine.ts +++ b/common/web/gesture-recognizer/src/engine/mouseEventEngine.ts @@ -128,8 +128,9 @@ export class MouseEventEngine extends InputEventEngine extends InputEventEngine extends InputEngineBase { const tailSamples = originalSamples.slice(1); const pathID = this.PATH_ID_SEED++; - let replayPoint = new GestureSource(pathID, recordedPoint.isFromTouch); + let replayPoint = new GestureSource(pathID, null, recordedPoint.isFromTouch); replayPoint.update(headSample); // is included before the point is made available. // Build promises designed to reproduce the events at the correct times. From 3aee911ea70df90089ca67ef9bb74acdaf0337b3 Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Wed, 30 Aug 2023 10:18:26 +0700 Subject: [PATCH 2/5] fix(web): patches up existing unit-tests --- .../test/auto/headless/gestureSource.spec.ts | 8 ++--- .../headless/gestures/gestureMatcher.spec.ts | 2 +- .../headless/gestures/pathMatcher.spec.ts | 36 +++++++++---------- .../resources/simulateMultiSourceInput.ts | 8 ++--- 4 files changed, 27 insertions(+), 27 deletions(-) diff --git a/common/web/gesture-recognizer/src/test/auto/headless/gestureSource.spec.ts b/common/web/gesture-recognizer/src/test/auto/headless/gestureSource.spec.ts index 42228eb51c0..9cba44abb31 100644 --- a/common/web/gesture-recognizer/src/test/auto/headless/gestureSource.spec.ts +++ b/common/web/gesture-recognizer/src/test/auto/headless/gestureSource.spec.ts @@ -5,7 +5,7 @@ import { GestureSource, InputSample } from '@keymanapp/gesture-recognizer'; describe("GestureSource", function() { describe(".constructSubview", function() { it("properly propagates updates", () => { - let source = new GestureSource(0, true); + let source = new GestureSource(0, null, true); let subview = source.constructSubview(true, true); assert.equal(subview.path.coords.length, 0); @@ -20,11 +20,11 @@ describe("GestureSource", function() { source.update(sample); assert.equal(subview.path.coords.length, 1); - assert.equal(subview.currentSample, sample); + assert.deepEqual(subview.currentSample, sample); }); it("propagates path termination (complete)", () => { - let source = new GestureSource(0, true); + let source = new GestureSource(0, null, true); let subview = source.constructSubview(true, true); let subview2 = source.constructSubview(true, true); @@ -41,7 +41,7 @@ describe("GestureSource", function() { subview.terminate(false); assert.equal(subview.path.coords.length, 1); - assert.equal(subview.currentSample, sample); + assert.deepEqual(subview.currentSample, sample); assert.equal(subview.isPathComplete, true); assert.equal(subview.path.wasCancelled, false); assert.equal(subview2.isPathComplete, true); diff --git a/common/web/gesture-recognizer/src/test/auto/headless/gestures/gestureMatcher.spec.ts b/common/web/gesture-recognizer/src/test/auto/headless/gestures/gestureMatcher.spec.ts index 45c9d12c740..e15da4c78dc 100644 --- a/common/web/gesture-recognizer/src/test/auto/headless/gestures/gestureMatcher.spec.ts +++ b/common/web/gesture-recognizer/src/test/auto/headless/gestures/gestureMatcher.spec.ts @@ -870,7 +870,7 @@ describe("GestureMatcher", function() { sources[0].path.on('invalidated', () => secondMatcher.update()); timedPromise(50).then(() => { - const secondContact = new GestureSource(5, true); + const secondContact = new GestureSource(5, null, true); sources.push(secondContact); secondMatcher.addContact(secondContact); secondContact.update({ diff --git a/common/web/gesture-recognizer/src/test/auto/headless/gestures/pathMatcher.spec.ts b/common/web/gesture-recognizer/src/test/auto/headless/gestures/pathMatcher.spec.ts index aaf0a9673da..063f157b53b 100644 --- a/common/web/gesture-recognizer/src/test/auto/headless/gestures/pathMatcher.spec.ts +++ b/common/web/gesture-recognizer/src/test/auto/headless/gestures/pathMatcher.spec.ts @@ -67,7 +67,7 @@ describe("PathMatcher", function() { describe("Instant fulfillment modeling", function() { it("resolve", async function() { - const emulatedContactPoint = new GestureSource(1, true); + const emulatedContactPoint = new GestureSource(1, null, true); const modelMatcher = new gestures.matchers.PathMatcher(InstantResolutionModel, emulatedContactPoint); const startSample = { @@ -86,7 +86,7 @@ describe("PathMatcher", function() { }); it("reject", async function() { - const emulatedContactPoint = new GestureSource(1, true); + const emulatedContactPoint = new GestureSource(1, null, true); const modelMatcher = new gestures.matchers.PathMatcher(InstantRejectionModel, emulatedContactPoint); const startSample = { @@ -107,7 +107,7 @@ describe("PathMatcher", function() { describe("Longpress: primary path modeling", function() { it("resolve: path completed (long wait)", async function() { - const emulatedContactPoint = new GestureSource(1, true); + const emulatedContactPoint = new GestureSource(1, null, true); const modelMatcher = new gestures.matchers.PathMatcher(MainLongpressSourceModel, emulatedContactPoint); const startSample = { @@ -133,7 +133,7 @@ describe("PathMatcher", function() { }); it("resolve: path not completed (long wait)", async function() { - const emulatedContactPoint = new GestureSource(1, true); + const emulatedContactPoint = new GestureSource(1, null, true); const modelMatcher = new gestures.matchers.PathMatcher(MainLongpressSourceModel, emulatedContactPoint); const startSample = { @@ -159,7 +159,7 @@ describe("PathMatcher", function() { }); it("reject: path completed (short wait)", async function() { - const emulatedContactPoint = new GestureSource(1, true); + const emulatedContactPoint = new GestureSource(1, null, true); const modelMatcher = new gestures.matchers.PathMatcher(MainLongpressSourceModel, emulatedContactPoint); const startSample = { @@ -185,7 +185,7 @@ describe("PathMatcher", function() { }); it("reject: path cancelled", async function() { - const emulatedContactPoint = new GestureSource(1, true); + const emulatedContactPoint = new GestureSource(1, null, true); const modelMatcher = new gestures.matchers.PathMatcher(MainLongpressSourceModel, emulatedContactPoint); const startSample = { @@ -218,7 +218,7 @@ describe("PathMatcher", function() { }); it("reject: distance moved", async function() { - const emulatedContactPoint = new GestureSource(1, true); + const emulatedContactPoint = new GestureSource(1, null, true); const modelMatcher = new gestures.matchers.PathMatcher(MainLongpressSourceModel, emulatedContactPoint); const startSample = { @@ -253,7 +253,7 @@ describe("PathMatcher", function() { }); it("reject: item changed", async function() { - const emulatedContactPoint = new GestureSource(1, true); + const emulatedContactPoint = new GestureSource(1, null, true); const modelMatcher = new gestures.matchers.PathMatcher(MainLongpressSourceModel, emulatedContactPoint); const startSample = { @@ -287,7 +287,7 @@ describe("PathMatcher", function() { }); it("reject: disabled up-flick shortcut", async function() { - const emulatedContactPoint = new GestureSource(1, true); + const emulatedContactPoint = new GestureSource(1, null, true); const modelMatcher = new gestures.matchers.PathMatcher(MainLongpressSourceModel, emulatedContactPoint); const startSample = { @@ -313,7 +313,7 @@ describe("PathMatcher", function() { }); it("resolve: enabled up-flick shortcut", async function() { - const emulatedContactPoint = new GestureSource(1, true); + const emulatedContactPoint = new GestureSource(1, null, true); const modelMatcher = new gestures.matchers.PathMatcher(MainLongpressSourceModelWithShortcut, emulatedContactPoint); const startSample = { @@ -341,7 +341,7 @@ describe("PathMatcher", function() { describe("Modipress: primary path modeling", function () { it("push: on path start", async function() { - const emulatedContactPoint = new GestureSource(1, true); + const emulatedContactPoint = new GestureSource(1, null, true); const modelMatcher = new gestures.matchers.PathMatcher(ModipressStartModel, emulatedContactPoint); const startSample = { @@ -360,7 +360,7 @@ describe("PathMatcher", function() { }); it("pop: released", async function() { - const emulatedContactPoint = new GestureSource(1, true); + const emulatedContactPoint = new GestureSource(1, null, true); const modelMatcher = new gestures.matchers.PathMatcher(ModipressEndModel, emulatedContactPoint); const startSample = { @@ -379,7 +379,7 @@ describe("PathMatcher", function() { }); it("pop: item changed", async function() { - const emulatedContactPoint = new GestureSource(1, true); + const emulatedContactPoint = new GestureSource(1, null, true); const modelMatcher = new gestures.matchers.PathMatcher(ModipressEndModel, emulatedContactPoint); const startSample = { @@ -407,7 +407,7 @@ describe("PathMatcher", function() { describe("Simple Tap: primary path modeling", function() { it("resolve: path completed (long wait)", async function() { - const emulatedContactPoint = new GestureSource(1, true); + const emulatedContactPoint = new GestureSource(1, null, true); const modelMatcher = new gestures.matchers.PathMatcher(SimpleTapModel, emulatedContactPoint); const startSample = { @@ -433,7 +433,7 @@ describe("PathMatcher", function() { }); it("resolve: path completed (short wait)", async function() { - const emulatedContactPoint = new GestureSource(1, true); + const emulatedContactPoint = new GestureSource(1, null, true); const modelMatcher = new gestures.matchers.PathMatcher(SimpleTapModel, emulatedContactPoint); const startSample = { @@ -459,7 +459,7 @@ describe("PathMatcher", function() { }); it("reject: path cancelled", async function() { - const emulatedContactPoint = new GestureSource(1, true); + const emulatedContactPoint = new GestureSource(1, null, true); const modelMatcher = new gestures.matchers.PathMatcher(SimpleTapModel, emulatedContactPoint); const startSample = { @@ -492,7 +492,7 @@ describe("PathMatcher", function() { }); it("resolve: significant movement, but no item change", async function() { - const emulatedContactPoint = new GestureSource(1, true); + const emulatedContactPoint = new GestureSource(1, null, true); const modelMatcher = new gestures.matchers.PathMatcher(SimpleTapModel, emulatedContactPoint); const startSample = { @@ -527,7 +527,7 @@ describe("PathMatcher", function() { }); it("reject: little movement, but item changed", async function() { - const emulatedContactPoint = new GestureSource(1, true); + const emulatedContactPoint = new GestureSource(1, null, true); const modelMatcher = new gestures.matchers.PathMatcher(SimpleTapModel, emulatedContactPoint); const startSample = { diff --git a/common/web/gesture-recognizer/src/test/resources/simulateMultiSourceInput.ts b/common/web/gesture-recognizer/src/test/resources/simulateMultiSourceInput.ts index a0fe87be662..7620ab1bd47 100644 --- a/common/web/gesture-recognizer/src/test/resources/simulateMultiSourceInput.ts +++ b/common/web/gesture-recognizer/src/test/resources/simulateMultiSourceInput.ts @@ -33,7 +33,7 @@ interface MockedPredecessor { sample: InputSample, baseItem: Type }, - sources: GestureSource[], + sources: GestureSource[], // FIX ME. _result: { action: { item: Type @@ -45,7 +45,7 @@ function mockedPredecessor( lastSample: InputSample, baseItem?: Type ): MockedPredecessor { - const mockedSrc = new GestureSource(simSourceIdSeed++, true); + const mockedSrc = new GestureSource(simSourceIdSeed++, null, true); mockedSrc.path.extend(lastSample); mockedSrc.terminate(false); @@ -97,7 +97,7 @@ function prepareSourceForTimer ( timerSpec: SimSpecTimer, startTime: number ): ReturnType> { - const simpleSource = new GestureSource(simSourceIdSeed++, true); + const simpleSource = new GestureSource(simSourceIdSeed++, null, true); const promise = timedPromise(startTime).then(() => { // We're simulating a previously-completed contact point's path.. @@ -124,7 +124,7 @@ function prepareSourceForRawSequence( contactSpec: SimSpecSequence, startTime: number ): ReturnType> { - const simpleSource = new GestureSource(simSourceIdSeed++, true); + const simpleSource = new GestureSource(simSourceIdSeed++, null, true); const promise = timedPromise(startTime).then(() => { if(startTime != 0) { From be196744fd1fef9baf138072b2a5e44c95da6d7b Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Wed, 30 Aug 2023 10:19:31 +0700 Subject: [PATCH 3/5] feat(web): adds numerous GestureSource unit tests --- .../test/auto/headless/gestureSource.spec.ts | 235 ++++++++++++++++-- 1 file changed, 214 insertions(+), 21 deletions(-) diff --git a/common/web/gesture-recognizer/src/test/auto/headless/gestureSource.spec.ts b/common/web/gesture-recognizer/src/test/auto/headless/gestureSource.spec.ts index 9cba44abb31..0386c8030e4 100644 --- a/common/web/gesture-recognizer/src/test/auto/headless/gestureSource.spec.ts +++ b/common/web/gesture-recognizer/src/test/auto/headless/gestureSource.spec.ts @@ -1,51 +1,244 @@ import { assert } from 'chai' -import { GestureSource, InputSample } from '@keymanapp/gesture-recognizer'; +import { GestureRecognizerConfiguration, GestureSource, InputSample } from '@keymanapp/gesture-recognizer'; + +const helloSample: InputSample = { + targetX: 1, + targetY: 2, + item: 'hello', + t: 101 +}; + +const worldSample: InputSample = { + targetX: 2, + targetY: 3, + item: 'world', + t: 121 +}; + +// 1 -> 4, 'a' -> 'd' +const simpleSampleSequence: InputSample[] = [1, 2, 3, 4].map((i) => { + return { + targetX: i, + targetY: i, + item: String.fromCharCode('a'.charCodeAt(0) + i - 1), + t: 20 * i + } +}); + +/** + * A minimalist mock for a recognizer-configuration based at <0, 0>. + */ +const mockedOriginConfig = { + targetRoot: { + getBoundingClientRect: () => { + return { + x: 0, + y: 0 + }; + } + } +}; + +/** + * A minimalist mock for a recognizer-configuration based at <-2, -2>. + */ +const mockedInitialConfig = { + targetRoot: { + getBoundingClientRect: () => { + return { + x: -2, + y: -2 + }; + } + } +}; + +/** + * A minimalist mock for a recognizer-configuration based at <2, 2>. + */ +const mockedShiftedConfig = { + targetRoot: { + getBoundingClientRect: () => { + return { + x: 2, + y: 2 + }; + } + } +}; // Should probably be a bit more thorough, but it's a start. describe("GestureSource", function() { - describe(".constructSubview", function() { - it("properly propagates updates", () => { + describe("Subviews", function() { + it("construction: preserve current path & base item", () => { + let source = new GestureSource(0, null, true); + for(let i=0; i < simpleSampleSequence.length; i++) { + source.update(simpleSampleSequence[i]); + } + + let subview = source.constructSubview(false, true); + assert.equal(subview.path.coords.length, simpleSampleSequence.length); + assert.deepEqual(subview.path.coords, simpleSampleSequence); + assert.equal(subview.baseItem, simpleSampleSequence[0].item); + }); + + it("construction: preserve only most recent sample & base item", () => { let source = new GestureSource(0, null, true); + for(let i=0; i < simpleSampleSequence.length; i++) { + source.update(simpleSampleSequence[i]); + } + let subview = source.constructSubview(true, true); + assert.equal(subview.path.coords.length, 1); + + const lastSample = simpleSampleSequence[simpleSampleSequence.length - 1]; + assert.deepEqual(subview.path.coords, [lastSample]); + assert.equal(subview.baseItem, simpleSampleSequence[0].item); + }); + + it("construction: preserve only most recent sample", () => { + let source = new GestureSource(0, null, true); + for(let i=0; i < simpleSampleSequence.length; i++) { + source.update(simpleSampleSequence[i]); + } + + let subview = source.constructSubview(true, false); + assert.equal(subview.path.coords.length, 1); + + const lastSample = simpleSampleSequence[simpleSampleSequence.length - 1]; + assert.deepEqual(subview.path.coords, [lastSample]); + assert.equal(subview.baseItem, lastSample.item); + }); + + it("construction: preserve current path & base item, with translation", () => { + let source = new GestureSource(0, mockedInitialConfig as any, true); + for(let i=0; i < simpleSampleSequence.length; i++) { + source.update(simpleSampleSequence[i]); + } + + source.pushRecognizerConfig(mockedShiftedConfig as any); + + let subview = source.constructSubview(false, true); + // When an alternate recognizer-config is pushed, the original source's version of + // the path should remain unchanged. + assert.deepEqual(source.path.coords, simpleSampleSequence); + + // Subviews, on the other hand, should use the most appropriate current recognizer-config. + // It'll be "locked in" for the subview. + assert.equal(subview.path.coords.length, simpleSampleSequence.length); + assert.deepEqual(subview.path.coords, simpleSampleSequence.map((value) => { + return { + ...value, + targetX: value.targetX - 4, + targetY: value.targetY - 4 + } + })); + assert.equal(subview.baseItem, simpleSampleSequence[0].item); + }); + + it("properly propagate updates", () => { + let source = new GestureSource(0, null, true); + let subview = source.constructSubview(false, true); assert.equal(subview.path.coords.length, 0); + source.update(helloSample); - let sample: InputSample = { - targetX: 1, - targetY: 2, - item: 'hello', - t: 101 - }; + assert.equal(subview.path.coords.length, 1); + assert.deepEqual(subview.currentSample, helloSample); + }); + + it("may be disconnected from further updates", () => { + let source = new GestureSource(0, null, true); + let subview = source.constructSubview(false, true); + + source.update(helloSample); + + subview.disconnect(); + + source.update(worldSample); + + assert.equal(source.path.coords.length, 2); + + // Should not receive sample2. + assert.equal(subview.path.coords.length, 1); + assert.deepEqual(subview.currentSample, helloSample); + }); + + it('is updated if constructed from "current" intermediate subview', () => { + let source = new GestureSource(0, null, true); + let baseSubview = source.constructSubview(false, true); - source.update(sample); + source.update(helloSample); + + // Is constructed from a 'current' subview - one including the base source's + // most recent sample. + let subview = baseSubview.constructSubview(false, true); assert.equal(subview.path.coords.length, 1); - assert.deepEqual(subview.currentSample, sample); + + source.update(worldSample); + + // BOTH should update in this scenario. + assert.equal(baseSubview.path.coords.length, 2); + assert.equal(subview.path.coords.length, 2); + assert.deepEqual(baseSubview.currentSample, worldSample); + assert.deepEqual(subview.currentSample, worldSample); }); - it("propagates path termination (complete)", () => { + it('is not updated if constructed from old, not-up-to-date subview', () => { + let source = new GestureSource(0, null, true); + let updatingSubview = source.constructSubview(false, true); + let oldSubview = source.constructSubview(false, true); + oldSubview.disconnect(); + + source.update(helloSample); + + let subview = oldSubview.constructSubview(false, true); + + assert.equal(subview.path.coords.length, 0); + + source.update(worldSample); + + // Only the 'updating' one should update in this scenario. + // + // It's an error if the subview based on the non-updated subview updates, + // as there would be a gap in the path! + assert.equal(updatingSubview.path.coords.length, 2); + assert.equal(subview.path.coords.length, 0); + assert.deepEqual(updatingSubview.currentSample, worldSample); + assert.isNotOk(subview.currentSample); + }); + + it("propagate path termination (complete)", () => { let source = new GestureSource(0, null, true); let subview = source.constructSubview(true, true); let subview2 = source.constructSubview(true, true); assert.equal(subview.path.coords.length, 0); - let sample: InputSample = { - targetX: 1, - targetY: 2, - item: 'hello', - t: 101 - }; - - source.update(sample); + source.update(helloSample); subview.terminate(false); assert.equal(subview.path.coords.length, 1); - assert.deepEqual(subview.currentSample, sample); + assert.deepEqual(subview.currentSample, helloSample); assert.equal(subview.isPathComplete, true); assert.equal(subview.path.wasCancelled, false); assert.equal(subview2.isPathComplete, true); assert.equal(subview2.path.wasCancelled, false); }); + + it("acts read-only - does not allow direct mutations", () => { + let source = new GestureSource(0, mockedInitialConfig as any, true); + let subview = source.constructSubview(false, true); + source.update(helloSample); + source.pushRecognizerConfig(mockedShiftedConfig as any); + + assert.throws(() => subview.update(worldSample)); + assert.throws(() => subview.pushRecognizerConfig(mockedOriginConfig as any)); + assert.throws(() => subview.popRecognizerConfig()); + + // Should NOT throw; this is on the base source. + source.popRecognizerConfig(); + }); }); }); \ No newline at end of file From 2a9aaa5364260108fafab80ab7e050e1085cfc2f Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Tue, 26 Sep 2023 13:09:02 +0700 Subject: [PATCH 4/5] chore(web): applies PR suggestions --- .../gesture-recognizer/src/engine/headless/gestureSource.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/common/web/gesture-recognizer/src/engine/headless/gestureSource.ts b/common/web/gesture-recognizer/src/engine/headless/gestureSource.ts index 00513699931..c95517c7051 100644 --- a/common/web/gesture-recognizer/src/engine/headless/gestureSource.ts +++ b/common/web/gesture-recognizer/src/engine/headless/gestureSource.ts @@ -213,8 +213,8 @@ export class GestureSourceSubview extends GestureSource extends GestureSource(); for(let i=0; i < length; i++) { - const index = start + i; - subpath.extend(translateSample(baseSource.path.coords[index])); + subpath.extend(translateSample(baseSource.path.coords[start + i])); } this._path = subpath; From bed8699383780a5f6ffc870fa93cd63e373655eb Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Tue, 26 Sep 2023 13:12:27 +0700 Subject: [PATCH 5/5] chore(web): FIX ME was already fine!? --- .../src/test/resources/simulateMultiSourceInput.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/web/gesture-recognizer/src/test/resources/simulateMultiSourceInput.ts b/common/web/gesture-recognizer/src/test/resources/simulateMultiSourceInput.ts index 7620ab1bd47..88c9db95a63 100644 --- a/common/web/gesture-recognizer/src/test/resources/simulateMultiSourceInput.ts +++ b/common/web/gesture-recognizer/src/test/resources/simulateMultiSourceInput.ts @@ -33,7 +33,7 @@ interface MockedPredecessor { sample: InputSample, baseItem: Type }, - sources: GestureSource[], // FIX ME. + sources: GestureSource[], _result: { action: { item: Type