mirror of
https://github.com/keymanapp/keyman.git
synced 2026-08-19 14:57:42 +00:00
Merge pull request #9455 from keymanapp/feat/web/gesture-staging
feat(web): gesture stage sequencing 🐵
This commit is contained in:
commit
de4054a703
17 changed files with 1117 additions and 43 deletions
|
|
@ -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<HoveredItemType> extends TouchpointCoordinator<HoveredItemType> {
|
||||
public readonly config: Nonoptional<GestureRecognizerConfiguration<HoveredItemType>>;
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
@ -44,7 +44,13 @@ export class GestureMatcher<Type> implements PredecessorMatch<Type> {
|
|||
private readonly pathMatchers: PathMatcher<Type>[];
|
||||
|
||||
public get sources(): GestureSource<Type>[] {
|
||||
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<Type>;
|
||||
|
|
@ -148,7 +154,7 @@ export class GestureMatcher<Type> implements PredecessorMatch<Type> {
|
|||
|
||||
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;
|
||||
|
|
@ -271,7 +277,8 @@ export class GestureMatcher<Type> implements PredecessorMatch<Type> {
|
|||
* '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.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,226 @@
|
|||
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";
|
||||
|
||||
export class GestureStageReport<Type> {
|
||||
public readonly matchedId: string;
|
||||
public readonly linkType: MatchResult<Type>['action']['type'];
|
||||
public readonly item: Type;
|
||||
public readonly sources: GestureSourceSubview<Type>[];
|
||||
public readonly allSourceIds: string[];
|
||||
|
||||
constructor(selection: MatcherSelection<Type>) {
|
||||
const { matcher, result } = selection;
|
||||
this.matchedId = matcher?.model.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<Type>[];
|
||||
|
||||
// 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> {
|
||||
type: 'push',
|
||||
config: GestureRecognizerConfiguration<Type>
|
||||
}
|
||||
|
||||
// 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<Type> {
|
||||
stage: (
|
||||
stageReport: GestureStageReport<Type>,
|
||||
changeConfiguration: (configStackCommand: PushConfig<Type> | PopConfig) => void
|
||||
) => void;
|
||||
complete: () => void;
|
||||
}
|
||||
|
||||
export class GestureSequence<Type> extends EventEmitter<EventMap<Type>> {
|
||||
public stageReports: GestureStageReport<Type>[];
|
||||
|
||||
// It's not specific to just this sequence... but it does have access to
|
||||
// the potential next stages.
|
||||
private selector: MatcherSelector<Type>;
|
||||
|
||||
// We need this reference in order to properly handle 'setchange' resolution actions when staging.
|
||||
private touchpointCoordinator: TouchpointCoordinator<Type>;
|
||||
// Selectors have locked-in 'base gesture sets'; this is only non-null if
|
||||
// in a 'setchange' action.
|
||||
private pushedSelector?: MatcherSelector<Type>;
|
||||
|
||||
private gestureConfig: GestureModelDefs<Type>;
|
||||
|
||||
// Note: the first stage will be available under `stageReports` after awaiting a simple Promise.resolve().
|
||||
constructor(
|
||||
firstSelectionMatch: MatcherSelection<Type>,
|
||||
gestureModelDefinitions: GestureModelDefs<Type>,
|
||||
selector: MatcherSelector<Type>,
|
||||
touchpointCoordinator: TouchpointCoordinator<Type>
|
||||
) {
|
||||
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<Type>) => {
|
||||
const matchReport = new GestureStageReport<Type>(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 == '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) {
|
||||
this.touchpointCoordinator.popSelector(this.pushedSelector);
|
||||
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 == 'chain') {
|
||||
const targetSet = selection.result.action.selectionMode;
|
||||
// push the new one.
|
||||
const changedSetSelector = new MatcherSelector<Type>(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;
|
||||
|
||||
// Any extra finalization stuff should go here, before the event, if needed.
|
||||
this.emit('complete');
|
||||
}
|
||||
}
|
||||
|
||||
private readonly modelResetHandler = (selection: MatcherSelection<Type>, replaceModelWith: (model: GestureModel<Type>) => void) => {
|
||||
if(selection.result.action.type == 'replace') {
|
||||
replaceModelWith(getGestureModel(this.gestureConfig, selection.result.action.replace));
|
||||
} else {
|
||||
throw new Error("Missed a case in implementation!");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function modelSetForAction<Type>(
|
||||
action: GestureResolution<Type>,
|
||||
gestureModelDefinitions: GestureModelDefs<Type>,
|
||||
activeSetId: string
|
||||
): GestureModel<Type>[] {
|
||||
switch(action.type) {
|
||||
case 'none':
|
||||
case 'complete':
|
||||
return [];
|
||||
case 'replace':
|
||||
return [getGestureModel(gestureModelDefinitions, action.replace)];
|
||||
case 'chain':
|
||||
return [getGestureModel(gestureModelDefinitions, action.next)];
|
||||
default:
|
||||
throw new Error("Unexpected case arose within `processGestureAction` method");
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
@ -245,6 +245,10 @@ export class MatcherSelector<Type> extends EventEmitter<EventMap<Type>> {
|
|||
|
||||
// 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);
|
||||
|
||||
/*
|
||||
|
|
|
|||
|
|
@ -10,20 +10,42 @@ export interface ResolutionItem<Type> {
|
|||
item: Type
|
||||
}
|
||||
|
||||
export interface ResolutionPush {
|
||||
type: 'push',
|
||||
allowedGestures: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 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',
|
||||
next: string
|
||||
}
|
||||
// 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,
|
||||
|
||||
// is not "locked-in"
|
||||
export interface OptionalChain {
|
||||
type: 'optional-chain', // With spec-shift: 'reset'?
|
||||
allowNext: 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
|
||||
}
|
||||
|
||||
export interface ResolutionComplete {
|
||||
|
|
@ -34,16 +56,30 @@ 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',
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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 = ResolutionPush | ResolutionChain | OptionalChain | ResolutionComplete;
|
||||
type ResolutionStruct = ResolutionChain | ResolutionComplete;
|
||||
|
||||
export type GestureResolutionSpec = ResolutionStruct & ResolutionItemSpec;
|
||||
export type GestureResolution<Type> = (ResolutionStruct | RejectionDefault) & ResolutionItem<Type>;
|
||||
export type GestureResolution<Type> = (ResolutionStruct | RejectionDefault | RejectionReplace) & ResolutionItem<Type>;
|
||||
|
||||
export interface GestureModel<Type> {
|
||||
// Gestures may want to say "build gesture of type `id`" for a followup-gesture.
|
||||
|
|
@ -67,7 +103,21 @@ export interface GestureModel<Type> {
|
|||
// ordinal position. (Same order as in the TrackedInput)
|
||||
readonly contacts: {
|
||||
model: ContactModel<Type>,
|
||||
/**
|
||||
* 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
|
||||
}[];
|
||||
|
||||
|
|
@ -84,11 +134,18 @@ export interface GestureModel<Type> {
|
|||
|
||||
readonly resolutionAction: GestureResolutionSpec;
|
||||
|
||||
readonly rejectionActions?: Partial<Record<FulfillmentCause, Omit<OptionalChain, 'item'>>>;
|
||||
readonly rejectionActions?: Partial<Record<FulfillmentCause, RejectionReplace>>;
|
||||
// If there is a 'gesture stack' associated with the gesture chain, it's auto-popped
|
||||
// upon completion of the chain. Optional-chaining can sustain the chain while the
|
||||
// potential child gesture is still a possibility.
|
||||
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -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<Type> {
|
||||
gestures: gestures.specs.GestureModel<Type>[],
|
||||
sets: {
|
||||
default: string[],
|
||||
} & Record<string, string[]>;
|
||||
}
|
||||
|
||||
|
||||
export function getGestureModel<Type>(defs: GestureModelDefs<Type>, id: string): gestures.specs.GestureModel<Type> {
|
||||
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<Type>(defs: GestureModelDefs<Type>, id: string): gestures.specs.GestureModel<Type>[] {
|
||||
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<any>
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
export * from './contactModel.js';
|
||||
export * from './gestureModel.js';
|
||||
export * from './gestureModelDefs.js';
|
||||
export * from './pathModel.js';
|
||||
|
|
@ -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<HoveredItemType> {
|
||||
/**
|
||||
|
|
@ -21,6 +22,7 @@ interface EventMap<HoveredItemType> {
|
|||
*/
|
||||
export class TouchpointCoordinator<HoveredItemType> extends EventEmitter<EventMap<HoveredItemType>> {
|
||||
private inputEngines: InputEngineBase<HoveredItemType>[];
|
||||
private selectorStack: MatcherSelector<HoveredItemType>[] = [new MatcherSelector()];
|
||||
|
||||
private _activeSources: GestureSource<HoveredItemType>[] = [];
|
||||
|
||||
|
|
@ -29,6 +31,26 @@ export class TouchpointCoordinator<HoveredItemType> extends EventEmitter<EventMa
|
|||
this.inputEngines = [];
|
||||
}
|
||||
|
||||
public pushSelector(selector: MatcherSelector<HoveredItemType>) {
|
||||
this.selectorStack.push(selector);
|
||||
}
|
||||
|
||||
public popSelector(selector: MatcherSelector<HoveredItemType>) {
|
||||
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<HoveredItemType>) {
|
||||
engine.on('pointstart', this.onNewTrackedPath);
|
||||
this.inputEngines.push(engine);
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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<any>, sample2: InputSample<any>) => {
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<string> = {
|
||||
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'));
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,588 @@
|
|||
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<Type> = gestures.matchers.GestureSequence<Type>;
|
||||
type MatcherSelector<Type> = gestures.matchers.MatcherSelector<Type>;
|
||||
type MatcherSelection<Type> = gestures.matchers.MatcherSelection<Type>;
|
||||
|
||||
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<string> = {
|
||||
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: 'chain',
|
||||
item: 'a',
|
||||
next: 'multitap'
|
||||
}, TestGestureModelDefinitions, 'default');
|
||||
|
||||
assert.sameMembers(nextModels, [MultitapModel]);
|
||||
});
|
||||
|
||||
it('successful subkey-select', () => {
|
||||
const nextModels = modelSetForAction({
|
||||
type: 'complete',
|
||||
item: 'b',
|
||||
}, TestGestureModelDefinitions, 'default');
|
||||
|
||||
assert.sameMembers(nextModels, []);
|
||||
});
|
||||
});
|
||||
|
||||
let fakeClock: ReturnType<typeof sinon.useFakeTimers>;
|
||||
async function sequenceEmulationAndAssertion(emulationEngine: HeadlessInputEngine, emulationCompletion: Promise<void>, sequenceAssertions: SequenceAssertion<string>[]) {
|
||||
const selectionPromise = new ManagedPromise<MatcherSelection<string>>;
|
||||
const testPromise = new ManagedPromise<void>();
|
||||
|
||||
// One selector for ALL sources, not per-source.
|
||||
const selector = new MatcherSelector<string>('default');
|
||||
|
||||
// Track pre-built sequences; we need to double-check that new touchpoints don't correspond to existing sequences.
|
||||
const sequences: GestureSequence<string>[] = [];
|
||||
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<string>(
|
||||
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<string> = [
|
||||
{
|
||||
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.)
|
||||
});
|
||||
|
||||
// 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,
|
||||
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<string> = [
|
||||
{
|
||||
matchedId: 'simple-tap',
|
||||
item: 'a',
|
||||
linkType: '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 two will be treated as separate sequences.
|
||||
const sequenceAssertions: SequenceAssertion<string>[] = [
|
||||
[
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
], [
|
||||
{
|
||||
// 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, sequenceAssertions);
|
||||
});
|
||||
|
||||
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<string>[] = [
|
||||
[
|
||||
{
|
||||
matchedId: 'simple-tap',
|
||||
item: 'a',
|
||||
linkType: '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: '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<string>[] = [
|
||||
[
|
||||
{
|
||||
matchedId: 'simple-tap',
|
||||
item: 'a',
|
||||
linkType: '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<string> = [
|
||||
{
|
||||
matchedId: 'simple-tap',
|
||||
item: 'a',
|
||||
linkType: 'chain',
|
||||
sources: (sources) => {
|
||||
assert.equal(sources.length, 1);
|
||||
assert.isTrue(sources[0].isPathComplete);
|
||||
return;
|
||||
}
|
||||
},
|
||||
{
|
||||
matchedId: 'multitap',
|
||||
item: 'a',
|
||||
linkType: '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]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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'
|
||||
}
|
||||
}
|
||||
|
|
@ -87,18 +87,19 @@ export const SimpleTapModel: GestureModel = {
|
|||
},
|
||||
endOnResolve: true
|
||||
}, {
|
||||
model: specs.InstantResolutionModel
|
||||
model: specs.InstantResolutionModel,
|
||||
resetOnResolve: true
|
||||
}
|
||||
],
|
||||
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'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -150,7 +150,7 @@ describe("MatcherSelector", function () {
|
|||
const rejectionData = rejectionStub.firstCall.args as [ MatcherSelection<string>, (model: GestureModel<string>) => 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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Type> = gestures.matchers.GestureSequence<Type>;
|
||||
type GestureStageReport<Type> = gestures.matchers.GestureStageReport<Type>;
|
||||
|
||||
import { ManagedPromise, timedPromise } from '@keymanapp/web-utils';
|
||||
|
||||
export interface StageReportAssertion<Type> {
|
||||
matchedId: string,
|
||||
item?: Type,
|
||||
linkType?: typeof GestureStageReport.prototype['linkType']
|
||||
sources?: (sources: GestureSource<Type>[]) => void;
|
||||
}
|
||||
|
||||
export type SequenceAssertion<Type> = StageReportAssertion<Type>[];
|
||||
|
||||
export async function assertGestureSequence<Type>(
|
||||
sequence: GestureSequence<Type>,
|
||||
emulationCompletion: Promise<void>,
|
||||
reportAssertions: StageReportAssertion<Type>[]
|
||||
) {
|
||||
const completionCheck = sinon.fake();
|
||||
const stagePromises: ManagedPromise<GestureStageReport<Type>>[] = [
|
||||
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!
|
||||
}
|
||||
|
|
@ -75,7 +75,7 @@ export default class ManagedPromise<Type> {
|
|||
return this._promise.then(onfulfilled, onrejected);
|
||||
}
|
||||
|
||||
catch(onrejected?: (reason: any) => PromiseLike<never>): Promise<Type> {
|
||||
catch<TResult1>(onrejected?: (reason: any) => TResult1 | PromiseLike<TResult1>): Promise<Type | TResult1> {
|
||||
return this._promise.catch(onrejected);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue