From 1f8071b81671b70d035d2f302ee2aee24696e963 Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Wed, 20 Sep 2023 10:32:54 +0700 Subject: [PATCH 1/8] fix(web): gesture-model definition spec + gesture-sequence implementation --- .../src/engine/gestureRecognizer.ts | 1 + .../gestures/matchers/gestureMatcher.ts | 2 +- .../gestures/matchers/gestureSequence.ts | 224 ++++++++++++++++++ .../headless/gestures/matchers/index.ts | 1 + .../headless/gestures/specs/gestureModel.ts | 40 +++- .../gestures/specs/gestureModelDefs.ts | 45 ++++ .../engine/headless/gestures/specs/index.ts | 1 + .../engine/headless/touchpointCoordinator.ts | 22 ++ .../gesture-recognizer/src/engine/index.ts | 1 + .../gestures/gestureModelDefs.spec.ts | 41 ++++ common/web/utils/src/managedPromise.ts | 2 +- 11 files changed, 372 insertions(+), 8 deletions(-) create mode 100644 common/web/gesture-recognizer/src/engine/headless/gestures/matchers/gestureSequence.ts create mode 100644 common/web/gesture-recognizer/src/engine/headless/gestures/specs/gestureModelDefs.ts create mode 100644 common/web/gesture-recognizer/src/test/auto/headless/gestures/gestureModelDefs.spec.ts diff --git a/common/web/gesture-recognizer/src/engine/gestureRecognizer.ts b/common/web/gesture-recognizer/src/engine/gestureRecognizer.ts index d8207cf674..614c4dd136 100644 --- a/common/web/gesture-recognizer/src/engine/gestureRecognizer.ts +++ b/common/web/gesture-recognizer/src/engine/gestureRecognizer.ts @@ -3,6 +3,7 @@ import { MouseEventEngine } from "./mouseEventEngine.js"; import { Nonoptional } from "./nonoptional.js"; import { TouchEventEngine } from "./touchEventEngine.js"; import { TouchpointCoordinator } from "./headless/touchpointCoordinator.js"; +import { EMPTY_GESTURE_DEFS, GestureModelDefs } from "./headless/gestures/specs/index.js"; export class GestureRecognizer extends TouchpointCoordinator { public readonly config: Nonoptional>; 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 7ac4b8798f..9f6ccf02ce 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 @@ -19,7 +19,7 @@ export interface MatchResult { readonly action: GestureResolution } -export interface MatchResultSpec { +export interface MatchResultSpec { readonly matched: boolean, readonly action: GestureResolutionSpec } diff --git a/common/web/gesture-recognizer/src/engine/headless/gestures/matchers/gestureSequence.ts b/common/web/gesture-recognizer/src/engine/headless/gestures/matchers/gestureSequence.ts new file mode 100644 index 0000000000..ebb4f851ca --- /dev/null +++ b/common/web/gesture-recognizer/src/engine/headless/gestures/matchers/gestureSequence.ts @@ -0,0 +1,224 @@ +import EventEmitter from "eventemitter3"; + +import { GestureModelDefs, getGestureModel, getGestureModelSet } from "../specs/gestureModelDefs.js"; +import { GestureSource, GestureSourceSubview } from "../../gestureSource.js"; +import { GestureMatcher, MatchResult, PredecessorMatch } from "./gestureMatcher.js"; +import { GestureModel, GestureResolution } from "../specs/gestureModel.js"; +import { MatcherSelection, MatcherSelector } from "./matcherSelector.js"; +import { GestureRecognizerConfiguration, TouchpointCoordinator } from "../../../index.js"; + +// Definitely want to use this in some way, somewhere. +export class GestureStageReport { + public readonly matchedId: string; + public readonly linkType: MatchResult['action']['type']; + public readonly item: Type; + public readonly sources: GestureSourceSubview[]; + public readonly allSourceIds: string[]; + + constructor(selection: MatcherSelection) { + const { matcher, result } = selection; + if(matcher instanceof GestureMatcher) { + this.matchedId = matcher?.model.id; + } else { + this.matchedId = '(delegated)'; // TODO: consider - replace with some sort of delegation 'id'? + } + this.linkType = result.action.type; + this.item = result.action.item; + + // Assumption: GestureMatcher always builds the Subview type when constructing each PathMatcher. + // This assumption currently holds, though we could always do a quick instanceof-check to build a + // subview if it isn't already one. + // + // Each entry has a .baseSource property that may be used to refer to the non-snapshotted version + // of the source by consumers of this object. + this.sources = matcher?.sources as GestureSourceSubview[]; + + // Just to be extra-sure they don't continue to update. + // Alternatively, we could just make an extra copy and then instantly "disconnect" the new instance. + this.sources?.forEach((source) => source.disconnect()); + + this.allSourceIds = matcher?.allSourceIds || []; + } +} + +interface PushConfig { + type: 'push', + config: GestureRecognizerConfiguration +} + +// I don't think we currently need this option, but it fits as part of the overall conceptual +// model and is good for generality. +interface PopConfig { + type: 'pop', + count: number +} + +interface EventMap { + stage: ( + stageReport: GestureStageReport, + changeConfiguration: (configStackCommand: PushConfig | PopConfig) => void + ) => void; + complete: () => void; +} + +export class GestureSequence extends EventEmitter> { + public stageReports: GestureStageReport[]; + + // It's not specific to just this sequence... but it does have access to + // the potential next stages. + private selector: MatcherSelector; + + // We need this reference in order to properly handle 'setchange' resolution actions when staging. + private touchpointCoordinator: TouchpointCoordinator; + // Selectors have locked-in 'base gesture sets'; this is only non-null if + // in a 'setchange' action. + private pushedSelector?: MatcherSelector; + + private gestureConfig: GestureModelDefs; + + // Note: the first stage will be available under `stageReports` after awaiting a simple Promise.resolve(). + constructor( + firstSelectionMatch: MatcherSelection, + gestureModelDefinitions: GestureModelDefs, + selector: MatcherSelector, + touchpointCoordinator: TouchpointCoordinator + ) { + super(); + + this.stageReports = []; + this.selector = selector; + this.selector.on('rejectionwithaction', (this.modelResetHandler)); + this.gestureConfig = gestureModelDefinitions; + + // So that we can... + // 1. push a different selector as active (and restore it later) - say, for modipress + // - 'push' & corresponding pop-like resolution behaviors + // 2. push a different default gesture set ID (and restore it later) + this.touchpointCoordinator = touchpointCoordinator; + + // Adds a slight delay; a constructed Sequence will provide a brief window of time - + // until the event queue next 'ticks' - to receive data about the base stage via the + // same 'stage' event raised for all subsequent stages. + Promise.resolve().then(() => this.selectionHandler(firstSelectionMatch)); + } + + public get allSourceIds(): string[] { + return this.stageReports[this.stageReports.length - 1]?.allSourceIds; + } + + private get baseGestureSetId(): string { + return this.selector?.baseGestureSetId ?? null; + } + + private readonly selectionHandler = (selection: MatcherSelection) => { + const matchReport = new GestureStageReport(selection); + if(selection.matcher) { + this.stageReports.push(matchReport); + } + + const sourceTracker = selection.matcher ?? this.stageReports[this.stageReports.length-1]; + const sources = sourceTracker?.sources.map((matchSource) => { + return matchSource instanceof GestureSourceSubview ? matchSource.baseSource : matchSource; + }) ?? []; + + if(selection.result.action.type == 'complete' || selection.result.action.type == 'none') { + sources.forEach((source) => { + if(!source.isPathComplete) { + source.terminate(selection.result.action.type == 'none'); + } + }); + + if(!selection.result.matched) { + this.emit('complete'); + return; + } + } + + // Raise the event, providing a functor that allows the listener to specify an alt config for the next stage. + // Example case: longpress => subkey selection - the subkey menu has different boundary conditions. + this.emit('stage', matchReport, (command) => { + // Assertion: each Source may only be part of one GestureSequence. + // As such, pushed and popped configs may only come from one influence - the GestureSequence's + // staging transitions. + if(command.type == 'pop') { + sources.forEach((source) => source.popRecognizerConfig()); + } else /* if(command.type == 'push') */ { + sources.forEach((source) => source.pushRecognizerConfig(command.config)); + } + }); + + // ... right, the gesture-definitions. + const nextModels = modelSetForAction(selection.result.action, this.gestureConfig, this.baseGestureSetId); + if(nextModels.length > 0) { + // Note: if a 'push', that should be handled by an event listener from the main engine driver (or similar) + const promise = this.selector.matchGesture(selection.matcher, nextModels); + promise.then(this.selectionHandler); + + // Handling 'setchange' resolution actions (where one gesture enables a different gesture set for others + // while active. Example case: modipress.) + if(selection.result.action.type == 'setchange' && selection.result.action.allowedSet == this.pushedSelector?.baseGestureSetId) { + // do nothing; maintain the existing 'setchange' behavior + } else { + // pop the old one, if it exists - if it matches our expectations for a current one. + if(this.pushedSelector) { + this.touchpointCoordinator.popSelector(this.pushedSelector); + this.pushedSelector = null; + } + + if(selection.result.action.type == 'setchange') { + const targetSet = selection.result.action.allowedSet; + // push the new one. + const changedSetSelector = new MatcherSelector(targetSet); + this.touchpointCoordinator.pushSelector(changedSetSelector); + } + } + } else { + if(this.pushedSelector) { + this.touchpointCoordinator.popSelector(this.pushedSelector); + this.pushedSelector = null; + } + + // Dropping the reference here gives us two benefits: + // 1. Allows garbage collection to do its thing; this might be the last reference left to the selector instance. + // 2. Acts as an obvious flag / indicator of sequence completion. + this.selector = null; + + // TODO: anything else needed for finalization? + this.emit('complete'); + } + } + + private readonly modelResetHandler = (selection: MatcherSelection, replaceModelWith: (model: GestureModel) => void) => { + if(selection.result.action.type == 'optional-chain') { + replaceModelWith(getGestureModel(this.gestureConfig, selection.result.action.allowNext)); + } else { + throw new Error("Missed a case in implementation!"); + } + }; +} + +export function modelSetForAction( + action: GestureResolution, + gestureModelDefinitions: GestureModelDefs, + activeSetId: string +): GestureModel[] { + switch(action.type) { + case 'none': + case 'complete': + return []; + case 'optional-chain': + let nextModels = [getGestureModel(gestureModelDefinitions, action.allowNext)]; + + // If the resolved gesture-model was a match, it also cleared out all other model-specs; + // we should restart them -- they're also allowed for an 'optional-chain'. + const defaultGestureSpecSet = getGestureModelSet(gestureModelDefinitions, activeSetId); + nextModels = nextModels.concat(defaultGestureSpecSet); + + return nextModels; + case 'chain': + case 'setchange': + return [getGestureModel(gestureModelDefinitions, action.next)]; + default: + throw new Error("Unexpected case arose within `processGestureAction` method"); + } +} \ No newline at end of file diff --git a/common/web/gesture-recognizer/src/engine/headless/gestures/matchers/index.ts b/common/web/gesture-recognizer/src/engine/headless/gestures/matchers/index.ts index e1cfadc2bb..0b1c5a8df3 100644 --- a/common/web/gesture-recognizer/src/engine/headless/gestures/matchers/index.ts +++ b/common/web/gesture-recognizer/src/engine/headless/gestures/matchers/index.ts @@ -1,3 +1,4 @@ export { GestureMatcher } from './gestureMatcher.js'; +export { GestureSequence, GestureStageReport, modelSetForAction } from './gestureSequence.js'; export { MatcherSelection, MatcherSelector } from './matcherSelector.js'; export { PathMatcher } from './pathMatcher.js'; \ No newline at end of file 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 53247c4021..918ce2ee46 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 @@ -1,3 +1,5 @@ +import { MatchResult } from "../matchers/gestureMatcher.js"; +import { GestureSequence } from "../matchers/gestureSequence.js"; import { FulfillmentCause } from "../matchers/pathMatcher.js"; import { ContactModel } from "./contactModel.js"; @@ -10,19 +12,38 @@ export interface ResolutionItem { item: Type } -export interface ResolutionPush { - type: 'push', - allowedGestures: string[] +export interface ResolutionSetChange { + type: 'setchange', + // 'push' - for having NEW gestures (while still active) default to a different gesture set than the default + // - should probably rename (to avoid confusion with pushed configs) + // - modipress: the gesture itself is the same; it just enables an alt state for OTHER gestures + // - an engine 'listener' for push signals should be useful to ensure new gestures use the alt config. + // - but... how about the current sequence's behaviors in response? (the modipress particulars w release?) + // - waaaaaait. what if this state could listen to gestures started during the state? + // - so... internal state management? + // - if push-state selector has no active / locked sequences - detect by checking for ref equality with selector? + // - as in, the way to check for such a condition. + // - so, push-state stuff ought use its own selector. Which IS true, anyway - it may have a diff base set-id + // than the standard default - that's one of two possible reasons WHY the state would exist. + // - but... "sustaining" if the original, pushing sequence collapses? How does that get handled / modeled properly + // in relation to any active sequences? + // - perhaps that's it? "sustaining" - like the sustain timer supporting multitap? But now, not from a timer. + // - flag for "sustain if has active subgesture"? + // - ... blend with sustainTimer? to sustain: { timer: {}, subgesture: boolean }? + // - subgesturesDeferFinalization: boolean + // - ... wait a sec. During a push, the gesture itself may still continue!!! + allowedSet: string, + next: string } export interface ResolutionChain { - type: 'chain', + type: 'chain', // TODO: "lock"? next: string } // is not "locked-in" export interface OptionalChain { - type: 'optional-chain', // With spec-shift: 'reset'? + type: 'optional-chain', // TODO: a plain 'chain'? allowNext: string } @@ -40,7 +61,7 @@ export interface RejectionDefault { // non-chainable rejection will 'pop' to undo any existing prior 'push' resolutions // in the chain. As such, there is no need for a {type: 'pop'} variant. -type ResolutionStruct = ResolutionPush | ResolutionChain | OptionalChain | ResolutionComplete; +type ResolutionStruct = ResolutionSetChange | ResolutionChain | OptionalChain | ResolutionComplete; export type GestureResolutionSpec = ResolutionStruct & ResolutionItemSpec; export type GestureResolution = (ResolutionStruct | RejectionDefault) & ResolutionItem; @@ -89,6 +110,13 @@ export interface GestureModel { // upon completion of the chain. Optional-chaining can sustain the chain while the // potential child gesture is still a possibility. + // If we're locked-in on the gesture being matched and its detection occurs under the influence + // of another gesture, should that "another gesture" complete, this flag specifies if the + // locked-in "subgesture" should be maintained or auto-cancelled as a consequence. + // + // Default: cancelled. + readonly sustainWhenNested?: boolean; + // TODO: allow function for correlating multitouch paths (like for caret-pannning) // But that's something we'll likely defer past 17.0. // Probably: takes both paths' stat-objects. (Fortunately, the stats object holds diff --git a/common/web/gesture-recognizer/src/engine/headless/gestures/specs/gestureModelDefs.ts b/common/web/gesture-recognizer/src/engine/headless/gestures/specs/gestureModelDefs.ts new file mode 100644 index 0000000000..002b3cce07 --- /dev/null +++ b/common/web/gesture-recognizer/src/engine/headless/gestures/specs/gestureModelDefs.ts @@ -0,0 +1,45 @@ +import * as gestures from "../index.js"; + +// Prototype spec for the main gesture & gesture-set definitions. +// A work in-progress. Should probably land somewhere within headless/gestures/specs/. +// ... with the following two functions, as well. +export interface GestureModelDefs { + gestures: gestures.specs.GestureModel[], + sets: { + default: string[], + } & Record; +} + + +export function getGestureModel(defs: GestureModelDefs, id: string): gestures.specs.GestureModel { + const result = defs.gestures.find((spec) => spec.id == id); + if(!result) { + throw new Error(`Could not find spec for gesture with id '${id}'`); + } + + return result; +} + +export function getGestureModelSet(defs: GestureModelDefs, id: string): gestures.specs.GestureModel[] { + let idSet = defs.sets[id]; + if(!idSet) { + throw new Error(`Could not find a defined gesture-set with id '${id}'`); + } + + const set = defs.gestures.filter((spec) => !!idSet.find((id) => spec.id == id)); + const missing = idSet.filter((id) => !set.find((spec) => spec.id == id)); + + if(missing.length > 0) { + throw new Error(`Set '${id}' cannot find definitions for gestures with ids ${missing}`); + } + + return set; +} + +export const EMPTY_GESTURE_DEFS = { + gestures: [ + ], + sets: { + default: [] + } +} as GestureModelDefs \ No newline at end of file diff --git a/common/web/gesture-recognizer/src/engine/headless/gestures/specs/index.ts b/common/web/gesture-recognizer/src/engine/headless/gestures/specs/index.ts index d432da88d3..d568d97f44 100644 --- a/common/web/gesture-recognizer/src/engine/headless/gestures/specs/index.ts +++ b/common/web/gesture-recognizer/src/engine/headless/gestures/specs/index.ts @@ -1,3 +1,4 @@ export * from './contactModel.js'; export * from './gestureModel.js'; +export * from './gestureModelDefs.js'; export * from './pathModel.js'; \ 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 2f4a63ce2f..64768ff794 100644 --- a/common/web/gesture-recognizer/src/engine/headless/touchpointCoordinator.ts +++ b/common/web/gesture-recognizer/src/engine/headless/touchpointCoordinator.ts @@ -1,6 +1,7 @@ import EventEmitter from "eventemitter3"; import { InputEngineBase } from "./inputEngineBase.js"; import { GestureSource, GestureSourceSubview } from "./gestureSource.js"; +import { MatcherSelector } from "./gestures/matchers/matcherSelector.js"; interface EventMap { /** @@ -21,6 +22,7 @@ interface EventMap { */ export class TouchpointCoordinator extends EventEmitter> { private inputEngines: InputEngineBase[]; + private selectorStack: MatcherSelector[] = [new MatcherSelector()]; private _activeSources: GestureSource[] = []; @@ -29,6 +31,26 @@ export class TouchpointCoordinator extends EventEmitter) { + this.selectorStack.push(selector); + } + + public popSelector(selector: MatcherSelector) { + if(this.selectorStack.length <= 1) { + throw new Error("May not pop the original, base gesture selector."); + } + + const index = this.selectorStack.indexOf(selector); + if(index == -1) { + throw new Error("This selector has not been pushed onto the 'setChange' stack."); + } + this.selectorStack.splice(index, 1); + } + + public get currentSelector() { + return this.selectorStack[this.selectorStack.length-1]; + } + protected addEngine(engine: InputEngineBase) { engine.on('pointstart', this.onNewTrackedPath); this.inputEngines.push(engine); diff --git a/common/web/gesture-recognizer/src/engine/index.ts b/common/web/gesture-recognizer/src/engine/index.ts index 384168a3f0..02c07aa0ea 100644 --- a/common/web/gesture-recognizer/src/engine/index.ts +++ b/common/web/gesture-recognizer/src/engine/index.ts @@ -1,5 +1,6 @@ export { ConstructingSegment } from './headless/subsegmentation/constructingSegment.js'; export { CumulativePathStats } from './headless/cumulativePathStats.js'; +export { GestureModelDefs } from './headless/gestures/specs/gestureModelDefs.js'; export { GestureRecognizer } from "./gestureRecognizer.js"; export { GestureRecognizerConfiguration } from "./configuration/gestureRecognizerConfiguration.js"; export { InputEngineBase } from "./headless/inputEngineBase.js"; diff --git a/common/web/gesture-recognizer/src/test/auto/headless/gestures/gestureModelDefs.spec.ts b/common/web/gesture-recognizer/src/test/auto/headless/gestures/gestureModelDefs.spec.ts new file mode 100644 index 0000000000..bd7839c82f --- /dev/null +++ b/common/web/gesture-recognizer/src/test/auto/headless/gestures/gestureModelDefs.spec.ts @@ -0,0 +1,41 @@ +import { assert } from 'chai'; + +import { GestureModelDefs, gestures } from '@keymanapp/gesture-recognizer'; + +const getGestureModel = gestures.specs.getGestureModel; +const getGestureModelSet = gestures.specs.getGestureModelSet; + +import { + LongpressModel, + MultitapModel, + SimpleTapModel, + SubkeySelectModel +} from './isolatedGestureSpecs.js'; + +const TestGestureModelDefinitions: GestureModelDefs = { + gestures: [ + LongpressModel, + MultitapModel, + SimpleTapModel, + SubkeySelectModel, + // TODO: add something for a starting modipress. + ], + sets: { + default: [LongpressModel.id, SimpleTapModel.id, /* TODO: add a 'starting modipress' model */], + // TODO: modipress: [LongpressModel.id, SimpleTapModel.id], // no nested modipressing + malformed: [LongpressModel.id, 'unavailable-model'] + } +} + +describe("Gesture model definitions", () => { + it('getGestureModel', () => { + assert.equal(LongpressModel, getGestureModel(TestGestureModelDefinitions, LongpressModel.id)); + assert.throws(() => getGestureModel(TestGestureModelDefinitions, "unavailable-model")); + }); + + it('getGestureModelSet', () => { + assert.sameMembers([LongpressModel, SimpleTapModel], getGestureModelSet(TestGestureModelDefinitions, 'default')); + assert.throws(() => getGestureModelSet(TestGestureModelDefinitions, 'malformed')); + assert.throws(() => getGestureModelSet(TestGestureModelDefinitions, 'unavailable-set')); + }); +}); \ No newline at end of file diff --git a/common/web/utils/src/managedPromise.ts b/common/web/utils/src/managedPromise.ts index 2937068b33..e68cabbbfe 100644 --- a/common/web/utils/src/managedPromise.ts +++ b/common/web/utils/src/managedPromise.ts @@ -75,7 +75,7 @@ export default class ManagedPromise { return this._promise.then(onfulfilled, onrejected); } - catch(onrejected?: (reason: any) => PromiseLike): Promise { + catch(onrejected?: (reason: any) => TResult1 | PromiseLike): Promise { return this._promise.catch(onrejected); } From 636b9c700cb6b8ed66ba8b56a55cb661fc54ab16 Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Wed, 20 Sep 2023 11:37:01 +0700 Subject: [PATCH 2/8] chore(web): minor cleanup --- .../gestures/matchers/gestureSequence.ts | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/common/web/gesture-recognizer/src/engine/headless/gestures/matchers/gestureSequence.ts b/common/web/gesture-recognizer/src/engine/headless/gestures/matchers/gestureSequence.ts index ebb4f851ca..2555434841 100644 --- a/common/web/gesture-recognizer/src/engine/headless/gestures/matchers/gestureSequence.ts +++ b/common/web/gesture-recognizer/src/engine/headless/gestures/matchers/gestureSequence.ts @@ -7,7 +7,6 @@ import { GestureModel, GestureResolution } from "../specs/gestureModel.js"; import { MatcherSelection, MatcherSelector } from "./matcherSelector.js"; import { GestureRecognizerConfiguration, TouchpointCoordinator } from "../../../index.js"; -// Definitely want to use this in some way, somewhere. export class GestureStageReport { public readonly matchedId: string; public readonly linkType: MatchResult['action']['type']; @@ -17,11 +16,7 @@ export class GestureStageReport { constructor(selection: MatcherSelection) { const { matcher, result } = selection; - if(matcher instanceof GestureMatcher) { - this.matchedId = matcher?.model.id; - } else { - this.matchedId = '(delegated)'; // TODO: consider - replace with some sort of delegation 'id'? - } + this.matchedId = matcher?.model.id; this.linkType = result.action.type; this.item = result.action.item; @@ -165,6 +160,21 @@ export class GestureSequence extends EventEmitter> { this.pushedSelector = null; } + /* Note: we do not change the instance held by this class - it gets to maintain access + * to its original selector regardless. + * + * Example use-case: during subkey selection, which is the intended followup for a longpress, + * either... + * + * 1. No other gestures (new touch contact points) should be allowed and/or trigger interactions + * 2. OR such attempts should automatically cancel the subkey-selection process. + * + * For approach 1, we 'allow' an empty set of gestures, disabling all of them. + * + * For approach 2, we permit a single type of new gesture; when triggered, the gesture consumer + * can then use that to trigger cancellation of the subkey-selection mode. + */ + if(selection.result.action.type == 'setchange') { const targetSet = selection.result.action.allowedSet; // push the new one. @@ -183,7 +193,7 @@ export class GestureSequence extends EventEmitter> { // 2. Acts as an obvious flag / indicator of sequence completion. this.selector = null; - // TODO: anything else needed for finalization? + // Any extra finalization stuff should go here, before the event, if needed. this.emit('complete'); } } From 8d7989deb45fd32309c95b5786cae900d0d31bf1 Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Wed, 20 Sep 2023 11:52:53 +0700 Subject: [PATCH 3/8] change(web): optional-chain => replace, chain --- .../gestures/matchers/gestureMatcher.ts | 4 ++-- .../gestures/matchers/gestureSequence.ts | 8 +++---- .../headless/gestures/specs/gestureModel.ts | 22 ++++++++++--------- .../headless/gestures/gestureMatcher.spec.ts | 12 +++++----- .../headless/gestures/isolatedGestureSpecs.ts | 20 ++++++++--------- .../headless/gestures/matcherSelector.spec.ts | 14 ++++++------ 6 files changed, 41 insertions(+), 39 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 9f6ccf02ce..edeb9bf6b0 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 @@ -1,6 +1,6 @@ import { GestureSource, GestureSourceSubview } from "../../gestureSource.js"; -import { GestureModel, GestureResolution, GestureResolutionSpec, RejectionDefault, ResolutionItemSpec } from "../specs/gestureModel.js"; +import { GestureModel, GestureResolution, GestureResolutionSpec, RejectionDefault, RejectionReplace, ResolutionItemSpec } from "../specs/gestureModel.js"; import { ManagedPromise, TimeoutPromise } from "@keymanapp/web-utils"; import { FulfillmentCause, PathMatcher } from "./pathMatcher.js"; @@ -135,7 +135,7 @@ export class GestureMatcher implements PredecessorMatch { try { // Determine the correct action-spec that should result from the finalization. - let action: GestureResolutionSpec | (RejectionDefault & ResolutionItemSpec); + let action: GestureResolutionSpec | ((RejectionDefault | RejectionReplace) & ResolutionItemSpec); if(matched) { // Easy peasy - resolutions only need & have the one defined action type. action = this.model.resolutionAction; diff --git a/common/web/gesture-recognizer/src/engine/headless/gestures/matchers/gestureSequence.ts b/common/web/gesture-recognizer/src/engine/headless/gestures/matchers/gestureSequence.ts index 2555434841..291d2be076 100644 --- a/common/web/gesture-recognizer/src/engine/headless/gestures/matchers/gestureSequence.ts +++ b/common/web/gesture-recognizer/src/engine/headless/gestures/matchers/gestureSequence.ts @@ -199,8 +199,8 @@ export class GestureSequence extends EventEmitter> { } private readonly modelResetHandler = (selection: MatcherSelection, replaceModelWith: (model: GestureModel) => void) => { - if(selection.result.action.type == 'optional-chain') { - replaceModelWith(getGestureModel(this.gestureConfig, selection.result.action.allowNext)); + if(selection.result.action.type == 'replace') { + replaceModelWith(getGestureModel(this.gestureConfig, selection.result.action.replace)); } else { throw new Error("Missed a case in implementation!"); } @@ -216,8 +216,8 @@ export function modelSetForAction( case 'none': case 'complete': return []; - case 'optional-chain': - let nextModels = [getGestureModel(gestureModelDefinitions, action.allowNext)]; + case 'replace': + let nextModels = [getGestureModel(gestureModelDefinitions, action.replace)]; // If the resolved gesture-model was a match, it also cleared out all other model-specs; // we should restart them -- they're also allowed for an 'optional-chain'. 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 918ce2ee46..f159373dd0 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 @@ -37,16 +37,10 @@ export interface ResolutionSetChange { } export interface ResolutionChain { - type: 'chain', // TODO: "lock"? + type: 'chain', next: string } -// is not "locked-in" -export interface OptionalChain { - type: 'optional-chain', // TODO: a plain 'chain'? - allowNext: string -} - export interface ResolutionComplete { type: 'complete' } @@ -55,16 +49,24 @@ export interface RejectionDefault { type: 'none' } +/** + * Only permitted when rejecting a gesture match; certain models may specify a replacement + * or reset under certain conditions. + */ +export interface RejectionReplace { + type: 'replace', + replace: string +} // If there is a 'gesture stack' associated with the gesture chain, it's auto-popped // upon completion of the chain. So, either this resolution type or a final, // non-chainable rejection will 'pop' to undo any existing prior 'push' resolutions // in the chain. As such, there is no need for a {type: 'pop'} variant. -type ResolutionStruct = ResolutionSetChange | ResolutionChain | OptionalChain | ResolutionComplete; +type ResolutionStruct = ResolutionSetChange | ResolutionChain | ResolutionComplete; export type GestureResolutionSpec = ResolutionStruct & ResolutionItemSpec; -export type GestureResolution = (ResolutionStruct | RejectionDefault) & ResolutionItem; +export type GestureResolution = (ResolutionStruct | RejectionDefault | RejectionReplace) & ResolutionItem; export interface GestureModel { // Gestures may want to say "build gesture of type `id`" for a followup-gesture. @@ -105,7 +107,7 @@ export interface GestureModel { readonly resolutionAction: GestureResolutionSpec; - readonly rejectionActions?: Partial>>; + readonly rejectionActions?: Partial>; // 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. 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 f939b28f2a..461e138fb8 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 @@ -374,7 +374,7 @@ describe("GestureMatcher", function() { assert.equal(await promiseStatus(modelMatcher.promise), PromiseStatuses.PROMISE_RESOLVED); assert.equal(await promiseStatus(completion), PromiseStatusModule.PROMISE_PENDING); - assert.deepEqual(await modelMatcher.promise, {matched: false, action: { type: 'optional-chain', item: null, allowNext: 'longpress'}}); + assert.deepEqual(await modelMatcher.promise, {matched: false, action: { type: 'replace', item: null, replace: 'longpress'}}); assert.isFalse(sources[0].path.isComplete); const dist = (sample1: InputSample, sample2: InputSample) => { @@ -420,7 +420,7 @@ describe("GestureMatcher", function() { assert.equal(await promiseStatus(modelMatcher.promise), PromiseStatuses.PROMISE_RESOLVED); assert.equal(await promiseStatus(completion), PromiseStatusModule.PROMISE_PENDING); - assert.deepEqual(await modelMatcher.promise, {matched: false, action: { type: 'optional-chain', item: null, allowNext: 'longpress'}}); + assert.deepEqual(await modelMatcher.promise, {matched: false, action: { type: 'replace', item: null, replace: 'longpress'}}); assert.isFalse(sources[0].path.isComplete); // The sample at which the item changed from 'a' to 'b'. @@ -487,7 +487,7 @@ describe("GestureMatcher", function() { const modelMatcher = await modelMatcherPromise; assert.equal(await promiseStatus(modelMatcher.promise), PromiseStatuses.PROMISE_RESOLVED); - assert.deepEqual(await modelMatcher.promise, {matched: true, action: { type: 'optional-chain', item: 'a', allowNext: 'multitap' }}); + assert.deepEqual(await modelMatcher.promise, {matched: true, action: { type: 'chain', item: 'a', next: 'multitap' }}); // touchpoints[0] - a pre-completed path. assert.isTrue(sources[1].path.isComplete); }); @@ -642,7 +642,7 @@ describe("GestureMatcher", function() { } assert.equal(await promiseStatus(modelMatcher.promise), PromiseStatuses.PROMISE_RESOLVED); - assert.deepEqual(await modelMatcher.promise, {matched: true, action: { type: 'optional-chain', item: 'a', allowNext: 'multitap'}}); + assert.deepEqual(await modelMatcher.promise, {matched: true, action: { type: 'chain', item: 'a', next: 'multitap'}}); assert.isTrue(sources[1].path.isComplete); // Design note: as this one is _not_ complete, when gesture chaining tries to do a followup multitap match, @@ -676,7 +676,7 @@ describe("GestureMatcher", function() { await modelMatcher.promise; } assert.equal(await promiseStatus(modelMatcher.promise), PromiseStatuses.PROMISE_RESOLVED); - assert.deepEqual(await modelMatcher.promise, {matched: true, action: { type: 'optional-chain', item: 'a', allowNext: 'multitap'}}); + assert.deepEqual(await modelMatcher.promise, {matched: true, action: { type: 'chain', item: 'a', next: 'multitap'}}); assert.isTrue(sources[0].path.isComplete); const finalStats = modelMatcher.sources[0].path.stats; @@ -718,7 +718,7 @@ describe("GestureMatcher", function() { assert.equal(await promiseStatus(modelMatcher.promise), PromiseStatuses.PROMISE_RESOLVED); - assert.deepEqual(await modelMatcher.promise, {matched: true, action: { type: 'optional-chain', item: 'a', allowNext: 'multitap'}}); + assert.deepEqual(await modelMatcher.promise, {matched: true, action: { type: 'chain', item: 'a', next: 'multitap'}}); assert.isTrue(sources[0].path.isComplete); assert.isFalse(sources[1].path.isComplete); }); diff --git a/common/web/gesture-recognizer/src/test/auto/headless/gestures/isolatedGestureSpecs.ts b/common/web/gesture-recognizer/src/test/auto/headless/gestures/isolatedGestureSpecs.ts index 2b2a7d0a9d..3fae4eb99d 100644 --- a/common/web/gesture-recognizer/src/test/auto/headless/gestures/isolatedGestureSpecs.ts +++ b/common/web/gesture-recognizer/src/test/auto/headless/gestures/isolatedGestureSpecs.ts @@ -33,12 +33,12 @@ export const LongpressModel: GestureModel = { */ rejectionActions: { item: { - type: 'optional-chain', - allowNext: 'longpress' + type: 'replace', + replace: 'longpress' }, path: { - type: 'optional-chain', - allowNext: 'longpress' + type: 'replace', + replace: 'longpress' } } } @@ -68,8 +68,8 @@ export const MultitapModel: GestureModel = { baseItem: 'base' }, resolutionAction: { - type: 'optional-chain', - allowNext: 'multitap', + type: 'chain', + next: 'multitap', item: 'current' } } @@ -91,14 +91,14 @@ export const SimpleTapModel: GestureModel = { } ], resolutionAction: { - type: 'optional-chain', - allowNext: 'multitap', + type: 'chain', + next: 'multitap', item: 'current' }, rejectionActions: { item: { - type: 'optional-chain', - allowNext: 'simple-tap' + type: 'replace', + replace: 'simple-tap' } } } diff --git a/common/web/gesture-recognizer/src/test/auto/headless/gestures/matcherSelector.spec.ts b/common/web/gesture-recognizer/src/test/auto/headless/gestures/matcherSelector.spec.ts index 35697fcda0..4383e53a11 100644 --- a/common/web/gesture-recognizer/src/test/auto/headless/gestures/matcherSelector.spec.ts +++ b/common/web/gesture-recognizer/src/test/auto/headless/gestures/matcherSelector.spec.ts @@ -150,7 +150,7 @@ describe("MatcherSelector", function () { const rejectionData = rejectionStub.firstCall.args as [ MatcherSelection, (model: GestureModel) => void ]; assert.equal(rejectionData[0].matcher.model.id, 'longpress'); assert.equal(rejectionData[0].result.matched, false); - assert.deepEqual(rejectionData[0].result.action, { type: 'optional-chain', allowNext: 'longpress', item: null}); + assert.deepEqual(rejectionData[0].result.action, { type: 'replace', replace: 'longpress', item: null}); // ... we technically already have it, but this _is_ a convenient pattern to maintain for // consistency among all this suite's tests. @@ -300,7 +300,7 @@ describe("MatcherSelector", function () { const selection = await selectionPromises[0]; - assert.deepEqual(selection.result, {matched: true, action: { type: 'optional-chain', item: 'a', allowNext: 'multitap' }}); + assert.deepEqual(selection.result, {matched: true, action: { type: 'chain', item: 'a', next: 'multitap' }}); assert.deepEqual(selection.matcher.model, SimpleTapModel); assert.isTrue(sources[0].path.isComplete); @@ -354,7 +354,7 @@ describe("MatcherSelector", function () { const selection = await selectionPromises[0]; - assert.deepEqual(selection.result, {matched: true, action: { type: 'optional-chain', item: 'b', allowNext: 'multitap' }}); + assert.deepEqual(selection.result, {matched: true, action: { type: 'chain', item: 'b', next: 'multitap' }}); assert.deepEqual(selection.matcher.model, SimpleTapModel); assert.isTrue(sources[0].path.isComplete); assert.isAtLeast(resets, 1); @@ -476,7 +476,7 @@ describe("MatcherSelector", function () { const selection = await selectionPromises[0]; - assert.deepEqual(selection.result, {matched: true, action: { type: 'optional-chain', item: 'a', allowNext: 'multitap' }}); + assert.deepEqual(selection.result, {matched: true, action: { type: 'chain', item: 'a', next: 'multitap' }}); assert.deepEqual(selection.matcher.model, SimpleTapModel); assert.isTrue(sources[0].path.isComplete); assert.isAtMost(sources[0].path.stats.duration, 101); @@ -549,7 +549,7 @@ describe("MatcherSelector", function () { const selection = await selectionPromises[0]; - assert.deepEqual(selection.result, {matched: true, action: { type: 'optional-chain', item: 'a', allowNext: 'multitap' }}); + assert.deepEqual(selection.result, {matched: true, action: { type: 'chain', item: 'a', next: 'multitap' }}); assert.deepEqual(selection.matcher.model, MultitapModel); assert.isTrue(sources[0].path.isComplete); @@ -628,7 +628,7 @@ describe("MatcherSelector", function () { // Ignoring the multi-tap leadup and starting a new gesture-stage sequence instead... const selection2 = await selectionPromises[1]; - assert.deepEqual(selection2.result, {matched: true, action: { type: 'optional-chain', item: 'b', allowNext: 'multitap' }}); + assert.deepEqual(selection2.result, {matched: true, action: { type: 'chain', item: 'b', next: 'multitap' }}); assert.deepEqual(selection2.matcher.model, SimpleTapModel); assert.isTrue(sources[0].path.isComplete); @@ -712,7 +712,7 @@ describe("MatcherSelector", function () { assert.equal(await promiseStatus(selectionPromises[1]), PromiseStatuses.PROMISE_RESOLVED); const selection2 = await selectionPromises[1]; - assert.deepEqual(selection2.result, {matched: true, action: { type: 'optional-chain', item: 'a', allowNext: 'multitap' }}); + assert.deepEqual(selection2.result, {matched: true, action: { type: 'chain', item: 'a', next: 'multitap' }}); assert.deepEqual(selection2.matcher.model, SimpleTapModel); assert.isTrue(sources[0].path.isComplete); From dcb6aac072b84ad678d976cbdac60f79220620b6 Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Wed, 20 Sep 2023 15:14:30 +0700 Subject: [PATCH 4/8] change(web): 'setchange' merged into 'chain' --- .../gestures/matchers/gestureSequence.ts | 9 +++--- .../headless/gestures/specs/gestureModel.ts | 29 ++----------------- 2 files changed, 7 insertions(+), 31 deletions(-) diff --git a/common/web/gesture-recognizer/src/engine/headless/gestures/matchers/gestureSequence.ts b/common/web/gesture-recognizer/src/engine/headless/gestures/matchers/gestureSequence.ts index 291d2be076..6bc58444b8 100644 --- a/common/web/gesture-recognizer/src/engine/headless/gestures/matchers/gestureSequence.ts +++ b/common/web/gesture-recognizer/src/engine/headless/gestures/matchers/gestureSequence.ts @@ -151,8 +151,8 @@ export class GestureSequence extends EventEmitter> { // Handling 'setchange' resolution actions (where one gesture enables a different gesture set for others // while active. Example case: modipress.) - if(selection.result.action.type == 'setchange' && selection.result.action.allowedSet == this.pushedSelector?.baseGestureSetId) { - // do nothing; maintain the existing 'setchange' behavior + if(selection.result.action.type == 'chain' && selection.result.action.selectionMode == this.pushedSelector?.baseGestureSetId) { + // do nothing; maintain the existing 'selectionMode' behavior } else { // pop the old one, if it exists - if it matches our expectations for a current one. if(this.pushedSelector) { @@ -175,8 +175,8 @@ export class GestureSequence extends EventEmitter> { * can then use that to trigger cancellation of the subkey-selection mode. */ - if(selection.result.action.type == 'setchange') { - const targetSet = selection.result.action.allowedSet; + if(selection.result.action.type == 'chain') { + const targetSet = selection.result.action.selectionMode; // push the new one. const changedSetSelector = new MatcherSelector(targetSet); this.touchpointCoordinator.pushSelector(changedSetSelector); @@ -226,7 +226,6 @@ export function modelSetForAction( return nextModels; case 'chain': - case 'setchange': return [getGestureModel(gestureModelDefinitions, action.next)]; default: throw new Error("Unexpected case arose within `processGestureAction` method"); 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 f159373dd0..e6c0cb33f6 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 @@ -12,33 +12,10 @@ export interface ResolutionItem { item: Type } -export interface ResolutionSetChange { - type: 'setchange', - // 'push' - for having NEW gestures (while still active) default to a different gesture set than the default - // - should probably rename (to avoid confusion with pushed configs) - // - modipress: the gesture itself is the same; it just enables an alt state for OTHER gestures - // - an engine 'listener' for push signals should be useful to ensure new gestures use the alt config. - // - but... how about the current sequence's behaviors in response? (the modipress particulars w release?) - // - waaaaaait. what if this state could listen to gestures started during the state? - // - so... internal state management? - // - if push-state selector has no active / locked sequences - detect by checking for ref equality with selector? - // - as in, the way to check for such a condition. - // - so, push-state stuff ought use its own selector. Which IS true, anyway - it may have a diff base set-id - // than the standard default - that's one of two possible reasons WHY the state would exist. - // - but... "sustaining" if the original, pushing sequence collapses? How does that get handled / modeled properly - // in relation to any active sequences? - // - perhaps that's it? "sustaining" - like the sustain timer supporting multitap? But now, not from a timer. - // - flag for "sustain if has active subgesture"? - // - ... blend with sustainTimer? to sustain: { timer: {}, subgesture: boolean }? - // - subgesturesDeferFinalization: boolean - // - ... wait a sec. During a push, the gesture itself may still continue!!! - allowedSet: string, - next: string -} - export interface ResolutionChain { type: 'chain', - next: string + next: string, + selectionMode?: string } export interface ResolutionComplete { @@ -63,7 +40,7 @@ export interface RejectionReplace { // non-chainable rejection will 'pop' to undo any existing prior 'push' resolutions // in the chain. As such, there is no need for a {type: 'pop'} variant. -type ResolutionStruct = ResolutionSetChange | ResolutionChain | ResolutionComplete; +type ResolutionStruct = ResolutionChain | ResolutionComplete; export type GestureResolutionSpec = ResolutionStruct & ResolutionItemSpec; export type GestureResolution = (ResolutionStruct | RejectionDefault | RejectionReplace) & ResolutionItem; From 0580194147e25b6bcbb007286cc1ee3f0009cfe2 Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Wed, 20 Sep 2023 15:59:17 +0700 Subject: [PATCH 5/8] docs(web): better documentation on gesture-model spec shift --- .../headless/gestures/specs/gestureModel.ts | 40 ++++++++++++++++++- 1 file changed, 38 insertions(+), 2 deletions(-) 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 e6c0cb33f6..657488527c 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 @@ -1,5 +1,3 @@ -import { MatchResult } from "../matchers/gestureMatcher.js"; -import { GestureSequence } from "../matchers/gestureSequence.js"; import { FulfillmentCause } from "../matchers/pathMatcher.js"; import { ContactModel } from "./contactModel.js"; @@ -12,9 +10,41 @@ export interface ResolutionItem { item: Type } +/** + * Indicates that the matched gesture is but a component (or stage) of a + * multi-part gesture; there may be one or more follow-up components + * that will follow. + */ export interface ResolutionChain { type: 'chain', + // For consideration: string | string[]; // But we don't need the latter part for 17.0 gesture support. + /** + * The gesture ID for the next gesture component in sequence. + * + * E.g. longpress => subkey-select; that is, 'subkey-select' would be next after the 'longpress' model + * matches. + */ next: string, + + /** + * When specified, gesture-component selection for new GestureSources will use the specified + * set of models instead of the current default set for new sources. + * + * Example 1: longpresses, when transitioning to subkey-select mode, do not allow new incoming + * gestures during their lifetime. They should either cancel or block new gestures until + * subkey-selection is complete. + * + * Example 2: modipress operations should prevent secondary modipresses from occurring during + * their lifetime. + * + * Followup gesture-models must also specify the alternate model set in order to maintain it during + * transition between components. Leaving it `undefined` in a followup will fully cancel the + * alternate gesture-component selection mode and any gestures activated during the alternate + * selection mode (unless `sustainIfNested` is `true` for the processing gesture model). + * + * Changing to a different ID will do the likewise, then reactivate the alternate gesture-selection + * mode with the newly-specified gesture-model set target. + */ selectionMode?: string } @@ -32,6 +62,12 @@ export interface RejectionDefault { */ export interface RejectionReplace { type: 'replace', + + // For consideration: string | string[]; // But we don't need the latter part for 17.0 gesture support. + // Is trickier here for 'replace' than for 'chain'. + /** + * The ID of a gesture model to start matching as a replacement for the gesture-model that failed to match. + */ replace: string } From d3a72da1da9a49ec788e3fb64e3e28b0082d67be Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Wed, 20 Sep 2023 09:42:11 +0700 Subject: [PATCH 6/8] feat(web): implements GestureSequence --- .../headless/gestures/gestureSequence.spec.ts | 590 ++++++++++++++++++ .../src/test/resources/sequenceAssertions.ts | 79 +++ 2 files changed, 669 insertions(+) create mode 100644 common/web/gesture-recognizer/src/test/auto/headless/gestures/gestureSequence.spec.ts create mode 100644 common/web/gesture-recognizer/src/test/resources/sequenceAssertions.ts diff --git a/common/web/gesture-recognizer/src/test/auto/headless/gestures/gestureSequence.spec.ts b/common/web/gesture-recognizer/src/test/auto/headless/gestures/gestureSequence.spec.ts new file mode 100644 index 0000000000..aa87dd9de0 --- /dev/null +++ b/common/web/gesture-recognizer/src/test/auto/headless/gestures/gestureSequence.spec.ts @@ -0,0 +1,590 @@ +import { assert } from 'chai' +import sinon from 'sinon'; + +import * as PromiseStatusModule from 'promise-status-async'; +import { assertingPromiseStatus as promiseStatus } from '../../../resources/assertingPromiseStatus.js'; + +import { GestureModelDefs, GestureSource, gestures } from '@keymanapp/gesture-recognizer'; +const { matchers } = gestures; + +// Huh... gotta do BOTH here? One for constructor use, the other for generic-parameter use? +const { GestureSequence, GestureStageReport, MatcherSelector } = matchers; +type GestureSequence = gestures.matchers.GestureSequence; +type MatcherSelector = gestures.matchers.MatcherSelector; +type MatcherSelection = gestures.matchers.MatcherSelection; + +const getGestureModelSet = gestures.specs.getGestureModelSet; +const modelSetForAction = gestures.matchers.modelSetForAction; + +import { HeadlessInputEngine, TouchpathTurtle } from '#tools'; +import { ManagedPromise, timedPromise } from '@keymanapp/web-utils'; + +import { assertGestureSequence, SequenceAssertion } from "../../../resources/sequenceAssertions.js"; + +import { + LongpressModel, + MultitapModel, + SimpleTapModel, + SubkeySelectModel +} from './isolatedGestureSpecs.js'; + +const TestGestureModelDefinitions: GestureModelDefs = { + gestures: [ + LongpressModel, + MultitapModel, + SimpleTapModel, + SubkeySelectModel, + // TODO: add something for a starting modipress. + ], + sets: { + default: [LongpressModel.id, SimpleTapModel.id, /* TODO: add a 'starting modipress' model */], + // TODO: modipress: [LongpressModel.id, SimpleTapModel.id], // no nested modipressing + } +} + +describe("modelSetForAction", function() { + it('successful longpress', () => { + const nextModels = modelSetForAction({ + type: 'chain', + item: null, + next: 'subkey-select' + }, TestGestureModelDefinitions, 'default'); + + assert.sameMembers(nextModels, [SubkeySelectModel]); + }); + + // A cancelled longpress that is reset is handled by different mechanisms; + // no test is appropriate in this location. + + it('successful simple-tap or multi-tap', () => { + const nextModels = modelSetForAction({ + type: 'optional-chain', + item: 'a', + allowNext: 'multitap' + }, TestGestureModelDefinitions, 'default'); + + assert.sameMembers(nextModels, [SimpleTapModel, MultitapModel, LongpressModel]); + }); + + it('successful subkey-select', () => { + const nextModels = modelSetForAction({ + type: 'complete', + item: 'b', + }, TestGestureModelDefinitions, 'default'); + + assert.sameMembers(nextModels, []); + }); +}); + +let fakeClock: ReturnType; +async function sequenceEmulationAndAssertion(emulationEngine: HeadlessInputEngine, emulationCompletion: Promise, sequenceAssertions: SequenceAssertion[]) { + const selectionPromise = new ManagedPromise>; + const testPromise = new ManagedPromise(); + + // One selector for ALL sources, not per-source. + const selector = new MatcherSelector('default'); + + // Track pre-built sequences; we need to double-check that new touchpoints don't correspond to existing sequences. + const sequences: GestureSequence[] = []; + let indexSeed = 0; + + // Note: errors from async handlers do not get caught by Mocha if unhandled. + // The workaround: we build Promises for would-be async handlers that can sync; we pass caught errors + // to them so that they're reported by the automated test. + emulationEngine.on('pointstart', (source) => { + try { + // These parts should be handled by TouchpointCoordinator. This is a simplified mocked version + // of what lies there. + const matchPromise = selector.matchGesture(source, getGestureModelSet(TestGestureModelDefinitions, 'default')); + + matchPromise.then(async (selection) => { + if(!selectionPromise.isResolved) { + selectionPromise.resolve(selection); + } + + // Ensure that existing sequences have a chance to include the new GestureSource before proceeding. + // (This handler is called synchronously, while the Sequence updates asynchronously.) + await Promise.resolve(); + + if(sequences.find((sequence) => sequence.allSourceIds.find((identifier) => identifier == source.identifier))) { + // This touchpoint has already been included within an existing GestureSequence. + return; + } + + // And that should be enough to spin up a GestureSequence for continuation. + // The `null` bit is "cheating" a bit, but is "fine" for this test. + const sequence = new GestureSequence( + selection, + TestGestureModelDefinitions, + selector, + null // We're 'mocking out' the TouchpointCoordinator. + ); + sequences.push(sequence); + const sequenceIndex = indexSeed++; + + const assertion = sequenceAssertions[sequenceIndex]; + if(assertion) { + try { + await assertGestureSequence(sequence, emulationCompletion, assertion); + testPromise.resolve(); + } catch(err) { + testPromise.reject(err); + } + } else { + testPromise.reject(new Error(`Missing assertion for sequence ${sequenceIndex} of test`)); + } + }); + selectionPromise.catch((err) => testPromise.reject(err)); + } catch(err) { + testPromise.reject(err); + return; + } + }); + + //fakeClock.runToLastAsync(); + fakeClock.runAllAsync(); + + // Assert that an initial 'stage' (component of the sequence) is available - it's needed to + // build the sequence object. + await Promise.race([selectionPromise, emulationCompletion]); + assert.equal(await promiseStatus(selectionPromise.corePromise), PromiseStatusModule.PROMISE_RESOLVED); + await selectionPromise; + + // Other assertions are embedded in the simulation bit above. + + await testPromise; +} + +// TODO(?): right, simulation. Again. Yaaaaay. +// - Fortunately, the _start_ can just use selection-sim semantics; the GestureSequence +// constructor takes in an existing Selector & its selection, after all. +// - in fact... that should be 100% fine, right? There's only ever the one selector! +// - the issue: we do want to 'select' early, before later-stage timers are all run. + +// Later, in a different file: testing TouchpointCoordinator's integration with this. + +describe("GestureSequence", function() { + beforeEach(function() { + fakeClock = sinon.useFakeTimers(); + }); + + afterEach(function() { + fakeClock.restore(); + }); + + // TODO: author tests for (at least) the following + // Modipress - but it expects an actual TouchpointCoordinator instance. May not be testable on this level. + // Android longpress delegation? (Though... we _are_ killing this Android aspect, so maybe it's not worth explicitly testing anymore.) + // + // Defer: flick test - confirm => execute + // + // .on('complete') may be checked to validate if any further match attempts will be possible + // based on the condition; it may be worth 'extending' tests (via mocked timer) to + // double-check such scenarios. + + it('longpress -> subkey select', async () => { + const turtle = new TouchpathTurtle({ + targetX: 1, + targetY: 1, + t: 100, + item: 'a' + }); + turtle.wait(1000, 50); + turtle.move(0, 10, 100, 5); + turtle.hoveredItem = 'à'; + turtle.move(90, 10, 100, 5); + turtle.hoveredItem = 'â'; + turtle.commitPending(); + + const emulationEngine = new HeadlessInputEngine(); + const completionPromise = emulationEngine.playbackRecording({ + inputs: [ { + path: { + coords: turtle.path, + }, + isFromTouch: true + }], + config: null + }); + + const sequenceAssertion: SequenceAssertion = [ + { + matchedId: 'longpress', + item: null, + linkType: 'chain', + sources: (sources) => { + // Assert single-source + assert.equal(sources.length, 1); + + // Assert wait appropriate to the longpress threshold. Likely won't be the full 1000 ms. + const pathStats = sources[0].path.stats; + assert.isAtLeast(pathStats.duration, LongpressModel.contacts[0].model.timer.duration - 1); + assert.isAtMost(pathStats.rawDistance, 0.1); + return; + } + }, + { + matchedId: 'subkey-select', + item: 'â', + linkType: 'complete', + sources: (sources) => { + const pathStats = sources[0].path.stats; + assert.isAtLeast(pathStats.rawDistance, 19.9); + assert.isAtLeast(pathStats.duration, 1200 - LongpressModel.contacts[0].model.timer.duration - 2); + } + } + ]; + + await sequenceEmulationAndAssertion(emulationEngine, completionPromise, [sequenceAssertion]); + + // simulateSelectorInput should be sufficient for sequence emulation; just capture + // the first selection once kick-started, then add the "fun" hooks for the rest of the test. + // We do have to build a selector and complete the first pass to create a Sequence object, after all. + // + // For equivalent TouchpointCoordinator auto-tests, the HeadlessInputEngine class should work well. + // ... it might even be possible here, since we'll always be starting from the 'head' of the + // GestureSequence for these tests. (Later starts fall within the domain of MatcherSelector.) + }); + + it('a single, standalone simple tap', async () => { + const turtle = new TouchpathTurtle({ + targetX: 1, + targetY: 1, + t: 100, + item: 'a' + }); + turtle.wait(40, 2); + turtle.commitPending(); + + + const emulationEngine = new HeadlessInputEngine(); + const completionPromise = emulationEngine.playbackRecording({ + inputs: [ { + path: { + coords: turtle.path + }, + isFromTouch: true + }], + config: null + }).then(async () => { + // Ride out the multitap timer so we can achieve full completion. + let promise = timedPromise(MultitapModel.sustainTimer.duration+1).then(() => {}); + await fakeClock.runToLastAsync(); + await promise; + }); + + const sequenceAssertion: SequenceAssertion = [ + { + matchedId: 'simple-tap', + item: 'a', + linkType: 'optional-chain', + sources: (sources) => { + assert.equal(sources.length, 1); + assert.isTrue(sources[0].isPathComplete); + + // Assert wait appropriate to the longpress threshold. Likely won't be the full 1000 ms. + const pathStats = sources[0].path.stats; + assert.isAtLeast(pathStats.duration, 40); + return; + } + } + ]; + + await sequenceEmulationAndAssertion(emulationEngine, completionPromise, [sequenceAssertion]); + }); + + it('two overlapping simple taps', async () => { + const turtle0 = new TouchpathTurtle({ + targetX: 1, + targetY: 1, + t: 100, + item: 'a' + }); + turtle0.wait(40, 2); + turtle0.commitPending(); + + const turtle1 = new TouchpathTurtle({ + targetX: 101, + targetY: 101, + t: 120, + item: 'b' + }); + turtle1.wait(40, 2); + turtle1.commitPending(); + + const emulationEngine = new HeadlessInputEngine(); + const completionPromise = emulationEngine.playbackRecording({ + inputs: [ { + path: { + coords: turtle0.path, + }, + isFromTouch: true + }, { + path: { + coords: turtle1.path, + }, + isFromTouch: true + }], + config: null + }).then(async () => { + // Ride out the multitap timer so we can achieve full completion. + let promise = timedPromise(MultitapModel.sustainTimer.duration+1).then(() => {}); + await fakeClock.runToLastAsync(); + await promise; + }); + + /* The fact that the two simple-taps are treated as part of the same sequence, rather than distinct ones, + * is a consequence of the existing gesture-modeling infrastructure. The second tap is what triggers + * "early completion" of the first tap, thus it's considered part of the same sequence at present. + * + * Rough notes toward potential mitigation / fix in the future: + * // - could be mitigated with a special 'flag' on the gesture-model, perhaps? + * // - something to indicate "early-termination second-touchpoint should mark a sequence split-point" + * // - the GestureSequence class/instance does have a reference to TouchpointCoordinator; it should + * // be able to use that reference to facilitate a split if/when appropriate, like here. + * + * Obviously having a separate, second sequence would be 'nice', conceptually, for consumers... + * but I don't think it's worth prioritizing at the moment; got enough else to deal with for now. + */ + const sequenceAssertion: SequenceAssertion = [ + { + matchedId: 'simple-tap', + item: 'a', + linkType: 'optional-chain', + sources: (sources) => { + // Assert dual-source; the first tap was early-triggered because of the concurrent second tap. + assert.equal(sources.length, 2); + assert.isTrue(sources[0].isPathComplete); + assert.isFalse(sources[1].isPathComplete); + + // Assert wait appropriate to the longpress threshold. Likely won't be the full 1000 ms. + const pathStats = sources[0].path.stats; + assert.isAtMost(pathStats.duration, 21); + return; + } + }, + { + matchedId: 'simple-tap', + item: 'b', + linkType: 'optional-chain', + sources: (sources) => { + // Assert single-source; the first tap is not under consideration for this stage. + assert.equal(sources.length, 1); + const pathStats = sources[0].path.stats; + assert.isAtMost(pathStats.duration, 40); + } + } + ]; + + await sequenceEmulationAndAssertion(emulationEngine, completionPromise, [sequenceAssertion]); + }); + + it('2 consecutive simple taps', async () => { + const turtle0 = new TouchpathTurtle({ + targetX: 1, + targetY: 1, + t: 100, + item: 'a' + }); + turtle0.wait(40, 2); + turtle0.commitPending(); + + const turtle1 = new TouchpathTurtle({ + targetX: 11, + targetY: 11, + t: 200, + item: 'b' + }); + turtle1.wait(40, 2); + turtle1.commitPending(); + + const emulationEngine = new HeadlessInputEngine(); + const completionPromise = emulationEngine.playbackRecording({ + inputs: [ { + path: { + coords: turtle0.path, + }, + isFromTouch: true + }, { + path: { + coords: turtle1.path, + }, + isFromTouch: true + }], + config: null + }); + + const sequenceAssertions: SequenceAssertion[] = [ + [ + { + matchedId: 'simple-tap', + item: 'a', + linkType: 'optional-chain', + sources: (sources) => { + assert.equal(sources.length, 1); + assert.isTrue(sources[0].isPathComplete); + return; + } + } + // The second one is a separate sequence; no data for it should show up here. + ], + // The 'separate sequence'. + [ + { + matchedId: 'simple-tap', + item: 'b', + linkType: 'optional-chain', + sources: (sources) => { + assert.equal(sources.length, 1); + assert.isTrue(sources[0].isPathComplete); + return; + } + } + ] + ]; + + await sequenceEmulationAndAssertion(emulationEngine, completionPromise, sequenceAssertions); + }); + + it('simple tap followed by longpress', async () => { + const turtle0 = new TouchpathTurtle({ + targetX: 1, + targetY: 1, + t: 100, + item: 'a' + }); + turtle0.wait(40, 2); + turtle0.commitPending(); + + const turtle1 = new TouchpathTurtle({ + targetX: 11, + targetY: 11, + t: 200, + item: 'b' + }); + turtle1.wait(600, 30); + turtle1.commitPending(); + + const emulationEngine = new HeadlessInputEngine(); + const completionPromise = emulationEngine.playbackRecording({ + inputs: [ { + path: { + coords: turtle0.path, + }, + isFromTouch: true + }, { + path: { + coords: turtle1.path, + }, + isFromTouch: true + }], + config: null + }); + + const sequenceAssertions: SequenceAssertion[] = [ + [ + { + matchedId: 'simple-tap', + item: 'a', + linkType: 'optional-chain', + sources: (sources) => { + assert.equal(sources.length, 1); + assert.isTrue(sources[0].isPathComplete); + return; + } + } + // The second one is a separate sequence; no data for it should show up here. + ], + // The 'separate sequence'. + [ + { + matchedId: 'longpress', + item: null, + linkType: 'chain', + sources: (sources) => { + assert.equal(sources.length, 1); + assert.isFalse(sources[0].isPathComplete); + return; + } + }, + { + matchedId: 'subkey-select', + item: 'b', + linkType: 'complete', + sources: (sources) => { + assert.equal(sources.length, 1); + assert.isTrue(sources[0].isPathComplete); + return; + } + } + ] + ]; + + await sequenceEmulationAndAssertion(emulationEngine, completionPromise, sequenceAssertions); + }); + + it('basic multitap - 2 taps total', async () => { + const turtle0 = new TouchpathTurtle({ + targetX: 1, + targetY: 1, + t: 100, + item: 'a' + }); + turtle0.wait(40, 2); + turtle0.commitPending(); + + const turtle1 = new TouchpathTurtle({ + targetX: 1, + targetY: 1, + t: 200, + item: 'a' + }); + turtle1.wait(40, 2); + turtle1.commitPending(); + + const emulationEngine = new HeadlessInputEngine(); + const completionPromise = emulationEngine.playbackRecording({ + inputs: [ { + path: { + coords: turtle0.path, + }, + isFromTouch: true + }, { + path: { + coords: turtle1.path, + }, + isFromTouch: true + }], + config: null + }).then(async () => { + // Ride out the multitap timer so we can achieve full completion. + let promise = timedPromise(MultitapModel.sustainTimer.duration+1).then(() => {}); + await fakeClock.runToLastAsync(); + await promise; + }); + + const sequenceAssertion: SequenceAssertion = [ + { + matchedId: 'simple-tap', + item: 'a', + linkType: 'optional-chain', + sources: (sources) => { + assert.equal(sources.length, 1); + assert.isTrue(sources[0].isPathComplete); + return; + } + }, + { + matchedId: 'multitap', + item: 'a', + linkType: 'optional-chain', + sources: (sources) => { + // Assert single-source; the first tap is not under consideration for this stage. + assert.equal(sources.length, 1); + } + } + ]; + + await sequenceEmulationAndAssertion(emulationEngine, completionPromise, [sequenceAssertion]); + }); +}); \ No newline at end of file diff --git a/common/web/gesture-recognizer/src/test/resources/sequenceAssertions.ts b/common/web/gesture-recognizer/src/test/resources/sequenceAssertions.ts new file mode 100644 index 0000000000..259427fab1 --- /dev/null +++ b/common/web/gesture-recognizer/src/test/resources/sequenceAssertions.ts @@ -0,0 +1,79 @@ +import { assert } from 'chai' +import sinon from 'sinon'; + +import * as PromiseStatusModule from 'promise-status-async'; +const PromiseStatuses = PromiseStatusModule.PromiseStatuses; +import { assertingPromiseStatus as promiseStatus } from './assertingPromiseStatus.js'; + +import { GestureSource, gestures } from '@keymanapp/gesture-recognizer'; +const { matchers } = gestures; + +// Huh... gotta do BOTH here? One for constructor use, the other for generic-parameter use? +const { GestureSequence, GestureStageReport, MatcherSelector } = matchers; +type GestureSequence = gestures.matchers.GestureSequence; +type GestureStageReport = gestures.matchers.GestureStageReport; + +import { ManagedPromise, timedPromise } from '@keymanapp/web-utils'; + +export interface StageReportAssertion { + matchedId: string, + item?: Type, + linkType?: typeof GestureStageReport.prototype['linkType'] + sources?: (sources: GestureSource[]) => void; +} + +export type SequenceAssertion = StageReportAssertion[]; + +export async function assertGestureSequence( + sequence: GestureSequence, + emulationCompletion: Promise, + reportAssertions: StageReportAssertion[] +) { + const completionCheck = sinon.fake(); + const stagePromises: ManagedPromise>[] = [ + new ManagedPromise() + ]; + + sequence.on('stage', (report) => { + stagePromises[stagePromises.length - 1].resolve(report); + stagePromises.push(new ManagedPromise()); + }); + sequence.on('complete', completionCheck); + + let index: number; + for(index = 0; index < reportAssertions.length; index++) { + // Assert that the expected stage actually occurs for the simulated sequence. + await Promise.race([stagePromises[index].corePromise, emulationCompletion]); + assert.equal(await promiseStatus(stagePromises[index].corePromise), PromiseStatuses.PROMISE_RESOLVED, `Expected gesture stage with index ${index} did not occur`); + + // Assert that the detected stage has the expected properties for the simulated sequence. + const report = await stagePromises[index].corePromise; + const assertValue = reportAssertions[index]; + const expectation = `Expected stage (index ${index}, id ${assertValue.matchedId})`; + assert.equal(report.matchedId, assertValue.matchedId, `${expectation} did not match expected type`); + if(assertValue.item !== undefined) { + if(assertValue.item) { + assert.equal(report.item, assertValue.item, `${expectation} did not result in expected item`); + } else { + assert.equal(report.item, assertValue.item, `${expectation} resulted in unexpected item`); + } + } + if(assertValue.linkType !== undefined) { + assert.equal(report.linkType, assertValue.linkType, `${expectation} specified an unexpected stage transition type`); + } + if(assertValue.sources) { + assertValue.sources(report.sources); + } + } + + // There should be no unexpected stage in the sequence's analysis; we should reach completion + // with the last specified stage. + await Promise.race([stagePromises[index].corePromise, emulationCompletion]); + assert.equal(await promiseStatus(emulationCompletion), PromiseStatuses.PROMISE_RESOLVED, `Unexpected stage with index ${index}; sequence should have terminated`); + assert.equal(await promiseStatus(stagePromises[index].corePromise), PromiseStatuses.PROMISE_PENDING, `Unexpected stage with index ${index}; sequence should have terminated`); + + await emulationCompletion; + await Promise.resolve(); + + assert.isTrue(completionCheck.called, `Sequence ${index} did not reach completion by the end of emulation`); // issue: simple-tap tests could, in theory, still go multi-tap! +} \ No newline at end of file From 9638d716a8cba43547a87ad0567a1ff30666bd00 Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Thu, 21 Sep 2023 10:53:49 +0700 Subject: [PATCH 7/8] feat(web): shortcutted simple-taps now separate gesture sequences --- .../gestures/matchers/gestureMatcher.ts | 11 ++- .../gestures/matchers/matcherSelector.ts | 4 + .../headless/gestures/specs/gestureModel.ts | 14 +++ .../headless/gestures/gestureSequence.spec.ts | 94 +++++++++---------- .../headless/gestures/isolatedGestureSpecs.ts | 3 +- 5 files changed, 75 insertions(+), 51 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 edeb9bf6b0..b32df366bc 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 @@ -31,7 +31,13 @@ export class GestureMatcher implements PredecessorMatch { private readonly pathMatchers: PathMatcher[]; public get sources(): GestureSource[] { - return this.pathMatchers.map((pathMatch) => pathMatch.source); + return this.pathMatchers.map((pathMatch, index) => { + if(this.model.contacts[index].resetOnResolve) { + return undefined; + } else { + return pathMatch.source; + } + }).filter((entry) => !!entry); } private readonly predecessor?: PredecessorMatch; @@ -258,7 +264,8 @@ export class GestureMatcher implements PredecessorMatch { * 'all'... but that'd take a little extra work. */ public get allSourceIds(): string[] { - let currentIds = this.pathMatchers.map((entry) => entry.source.identifier); + // Do not include any to-be-reset (thus, excluded) sources here. + let currentIds = this.sources.map((entry) => entry.identifier); const predecessorIds = this.predecessor ? this.predecessor.allSourceIds : []; // Each ID should only be listed once, regardless of source. 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 6365e8f1ce..4606087297 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 @@ -245,6 +245,10 @@ export class MatcherSelector extends EventEmitter> { // We have a result for this matcher; go ahead and remove it from the 'potential' list. const matcherIndex = this.potentialMatchers.indexOf(matcher); + if(matcherIndex == -1) { + // It's already been handled; do not re-attempt. + return; + } this.potentialMatchers.splice(matcherIndex, 1); /* 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 657488527c..9a4f2d2c27 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 @@ -103,7 +103,21 @@ export interface GestureModel { // ordinal position. (Same order as in the TrackedInput) readonly contacts: { model: ContactModel, + /** + * Indicates that the corresponding GestureSource should not be considered part of the + * Gesture sequence being matched, acting more as a separate gesture that 'triggers' a state + * change in the current gesture being processed. + */ + resetOnResolve?: boolean, + /** + * Indicates that the corresponding GestureSource should be terminated whenever this GestureModel + * is successfully matched. + */ endOnResolve?: boolean, + /** + * Indicates that the corresponding GestureSource should be terminated whenever this GestureModel + * _fails_ to match. + */ endOnReject?: boolean }[]; diff --git a/common/web/gesture-recognizer/src/test/auto/headless/gestures/gestureSequence.spec.ts b/common/web/gesture-recognizer/src/test/auto/headless/gestures/gestureSequence.spec.ts index aa87dd9de0..5004352f42 100644 --- a/common/web/gesture-recognizer/src/test/auto/headless/gestures/gestureSequence.spec.ts +++ b/common/web/gesture-recognizer/src/test/auto/headless/gestures/gestureSequence.spec.ts @@ -58,12 +58,12 @@ describe("modelSetForAction", function() { it('successful simple-tap or multi-tap', () => { const nextModels = modelSetForAction({ - type: 'optional-chain', + type: 'chain', item: 'a', - allowNext: 'multitap' + next: 'multitap' }, TestGestureModelDefinitions, 'default'); - assert.sameMembers(nextModels, [SimpleTapModel, MultitapModel, LongpressModel]); + assert.sameMembers(nextModels, [MultitapModel]); }); it('successful subkey-select', () => { @@ -246,6 +246,9 @@ describe("GestureSequence", function() { // GestureSequence for these tests. (Later starts fall within the domain of MatcherSelector.) }); + // Note: cannot do longpress-blocking of secondary gestures here, since that requires TouchpointCoordinator + // integration. + it('a single, standalone simple tap', async () => { const turtle = new TouchpathTurtle({ targetX: 1, @@ -277,7 +280,7 @@ describe("GestureSequence", function() { { matchedId: 'simple-tap', item: 'a', - linkType: 'optional-chain', + linkType: 'chain', sources: (sources) => { assert.equal(sources.length, 1); assert.isTrue(sources[0].isPathComplete); @@ -333,50 +336,45 @@ describe("GestureSequence", function() { await promise; }); - /* The fact that the two simple-taps are treated as part of the same sequence, rather than distinct ones, - * is a consequence of the existing gesture-modeling infrastructure. The second tap is what triggers - * "early completion" of the first tap, thus it's considered part of the same sequence at present. - * - * Rough notes toward potential mitigation / fix in the future: - * // - could be mitigated with a special 'flag' on the gesture-model, perhaps? - * // - something to indicate "early-termination second-touchpoint should mark a sequence split-point" - * // - the GestureSequence class/instance does have a reference to TouchpointCoordinator; it should - * // be able to use that reference to facilitate a split if/when appropriate, like here. - * - * Obviously having a separate, second sequence would be 'nice', conceptually, for consumers... - * but I don't think it's worth prioritizing at the moment; got enough else to deal with for now. - */ - const sequenceAssertion: SequenceAssertion = [ - { - matchedId: 'simple-tap', - item: 'a', - linkType: 'optional-chain', - sources: (sources) => { - // Assert dual-source; the first tap was early-triggered because of the concurrent second tap. - assert.equal(sources.length, 2); - assert.isTrue(sources[0].isPathComplete); - assert.isFalse(sources[1].isPathComplete); + // The two will be treated as separate sequences. + const sequenceAssertions: SequenceAssertion[] = [ + [ + { + matchedId: 'simple-tap', + item: 'a', + linkType: 'chain', + sources: (sources) => { + // Assert dual-source; the first tap was early-triggered because of the concurrent second tap. + assert.equal(sources.length, 1); + assert.isTrue(sources[0].isPathComplete); + // assert.isFalse(sources[1].isPathComplete); - // Assert wait appropriate to the longpress threshold. Likely won't be the full 1000 ms. - const pathStats = sources[0].path.stats; - assert.isAtMost(pathStats.duration, 21); - return; + // Assert wait appropriate to the longpress threshold. Likely won't be the full 1000 ms. + const pathStats = sources[0].path.stats; + assert.isAtMost(pathStats.duration, 21); + return; + } } - }, - { - matchedId: 'simple-tap', - item: 'b', - linkType: 'optional-chain', - sources: (sources) => { - // Assert single-source; the first tap is not under consideration for this stage. - assert.equal(sources.length, 1); - const pathStats = sources[0].path.stats; - assert.isAtMost(pathStats.duration, 40); + ], [ + { + // OK... this one's not happening because it's not an allowed 'next' followup. Riiiight. + // Need a way for this to 'fall back' and not be included... might be best to move forward with + // that 'make it a separate sequence' idea given the spec shift. + matchedId: 'simple-tap', + item: 'b', + linkType: 'chain', + sources: (sources) => { + // Assert single-source; the first tap is not under consideration for this stage. + assert.equal(sources.length, 1); + assert.isTrue(sources[0].isPathComplete); + const pathStats = sources[0].path.stats; + assert.isAtMost(pathStats.duration, 40); + } } - } + ] ]; - await sequenceEmulationAndAssertion(emulationEngine, completionPromise, [sequenceAssertion]); + await sequenceEmulationAndAssertion(emulationEngine, completionPromise, sequenceAssertions); }); it('2 consecutive simple taps', async () => { @@ -419,7 +417,7 @@ describe("GestureSequence", function() { { matchedId: 'simple-tap', item: 'a', - linkType: 'optional-chain', + linkType: 'chain', sources: (sources) => { assert.equal(sources.length, 1); assert.isTrue(sources[0].isPathComplete); @@ -433,7 +431,7 @@ describe("GestureSequence", function() { { matchedId: 'simple-tap', item: 'b', - linkType: 'optional-chain', + linkType: 'chain', sources: (sources) => { assert.equal(sources.length, 1); assert.isTrue(sources[0].isPathComplete); @@ -486,7 +484,7 @@ describe("GestureSequence", function() { { matchedId: 'simple-tap', item: 'a', - linkType: 'optional-chain', + linkType: 'chain', sources: (sources) => { assert.equal(sources.length, 1); assert.isTrue(sources[0].isPathComplete); @@ -567,7 +565,7 @@ describe("GestureSequence", function() { { matchedId: 'simple-tap', item: 'a', - linkType: 'optional-chain', + linkType: 'chain', sources: (sources) => { assert.equal(sources.length, 1); assert.isTrue(sources[0].isPathComplete); @@ -577,7 +575,7 @@ describe("GestureSequence", function() { { matchedId: 'multitap', item: 'a', - linkType: 'optional-chain', + linkType: 'chain', sources: (sources) => { // Assert single-source; the first tap is not under consideration for this stage. assert.equal(sources.length, 1); diff --git a/common/web/gesture-recognizer/src/test/auto/headless/gestures/isolatedGestureSpecs.ts b/common/web/gesture-recognizer/src/test/auto/headless/gestures/isolatedGestureSpecs.ts index 3fae4eb99d..a842d4dabb 100644 --- a/common/web/gesture-recognizer/src/test/auto/headless/gestures/isolatedGestureSpecs.ts +++ b/common/web/gesture-recognizer/src/test/auto/headless/gestures/isolatedGestureSpecs.ts @@ -87,7 +87,8 @@ export const SimpleTapModel: GestureModel = { }, endOnResolve: true }, { - model: specs.InstantResolutionModel + model: specs.InstantResolutionModel, + resetOnResolve: true } ], resolutionAction: { From 5b6932a7f6a42337232bea8f70f1624d0936054c Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Thu, 21 Sep 2023 12:13:05 +0700 Subject: [PATCH 8/8] chore(web): forgot to fully remove old bit from what was 'optional-chain' --- .../engine/headless/gestures/matchers/gestureSequence.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/common/web/gesture-recognizer/src/engine/headless/gestures/matchers/gestureSequence.ts b/common/web/gesture-recognizer/src/engine/headless/gestures/matchers/gestureSequence.ts index 6bc58444b8..133c010691 100644 --- a/common/web/gesture-recognizer/src/engine/headless/gestures/matchers/gestureSequence.ts +++ b/common/web/gesture-recognizer/src/engine/headless/gestures/matchers/gestureSequence.ts @@ -217,14 +217,7 @@ export function modelSetForAction( case 'complete': return []; case 'replace': - let nextModels = [getGestureModel(gestureModelDefinitions, action.replace)]; - - // If the resolved gesture-model was a match, it also cleared out all other model-specs; - // we should restart them -- they're also allowed for an 'optional-chain'. - const defaultGestureSpecSet = getGestureModelSet(gestureModelDefinitions, activeSetId); - nextModels = nextModels.concat(defaultGestureSpecSet); - - return nextModels; + return [getGestureModel(gestureModelDefinitions, action.replace)]; case 'chain': return [getGestureModel(gestureModelDefinitions, action.next)]; default: