Skip to content

Commit

Permalink
feat(web): generalized multitap, prep for modipress vs multitap resol…
Browse files Browse the repository at this point in the history
…ution
  • Loading branch information
jahorton committed Oct 11, 2023
1 parent 70a2889 commit bba5473
Show file tree
Hide file tree
Showing 19 changed files with 381 additions and 152 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { Nonoptional } from "../nonoptional.js";
import { PaddedZoneSource } from "./paddedZoneSource.js";
import { RecognitionZoneSource } from "./recognitionZoneSource.js";

export type ItemIdentifier<ItemType, StateToken> = (coord: Omit<InputSample<any, StateToken>, 'item'>, target: EventTarget) => ItemType;

// For example, customization of a longpress timer's length need not be readonly.
export interface GestureRecognizerConfiguration<HoveredItemType, StateToken = any> {
/**
Expand Down Expand Up @@ -84,10 +86,11 @@ export interface GestureRecognizerConfiguration<HoveredItemType, StateToken = an
* Its `stateToken` will match the most recently set value for its corresponding
* `GestureSource` if continuing one; otherwise, it'll use the one currently set
* at the gesture-engine level.
* @param target The `EventTarget` (`Node` or `Element`) provided by the corresponding input event.
* @param target The `EventTarget` (`Node` or `Element`) provided by the corresponding input event,
* if available. May be `null/undefined`.
* @returns
*/
readonly itemIdentifier?: (coord: Omit<InputSample<any, StateToken>, 'item'>, target: EventTarget) => HoveredItemType;
readonly itemIdentifier?: ItemIdentifier<HoveredItemType, StateToken>;
}

export function preprocessRecognizerConfig<HoveredItemType, StateToken = any>(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export class GestureRecognizer<HoveredItemType, StateToken = any> extends Touchp
private readonly mouseEngine: MouseEventEngine<HoveredItemType>;
private readonly touchEngine: TouchEventEngine<HoveredItemType>;

public constructor(gestureModelDefinitions: GestureModelDefs<HoveredItemType>, config: GestureRecognizerConfiguration<HoveredItemType, StateToken>) {
public constructor(gestureModelDefinitions: GestureModelDefs<HoveredItemType, StateToken>, config: GestureRecognizerConfiguration<HoveredItemType, StateToken>) {
const preprocessedConfig = preprocessRecognizerConfig(config);

// Possibly just a stop-gap measure... but this provides an empty gesture-spec set definition
Expand Down
23 changes: 20 additions & 3 deletions common/web/gesture-recognizer/src/engine/headless/gestureSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import { GestureRecognizerConfiguration, preprocessRecognizerConfig } from "../c
import { Nonoptional } from "../nonoptional.js";
import { MatcherSelector } from "./gestures/matchers/matcherSelector.js";

export function buildGestureMatchInspector<Type>(selector: MatcherSelector<Type>) {
return (source: GestureSource<Type, any>) => {
export function buildGestureMatchInspector<Type, StateToken>(selector: MatcherSelector<Type, StateToken>) {
return (source: GestureSource<Type, StateToken>) => {
return selector.potentialMatchersForSource(source).map((matcher) => matcher.model.id);
};
}
Expand Down Expand Up @@ -277,6 +277,7 @@ export class GestureSourceSubview<HoveredItemType, StateToken = any> extends Ges
super(source.rawIdentifier, configStack, source.isFromTouch);

const baseSource = this._baseSource = source instanceof GestureSourceSubview ? source._baseSource : source;
this.stateToken = source.stateToken;

/**
* Provides a coordinate-system translation for source subviews.
Expand All @@ -286,7 +287,23 @@ export class GestureSourceSubview<HoveredItemType, StateToken = any> extends Ges
const translation = this.recognizerTranslation;
// Provide a coordinate-system translation for source subviews.
// The base version still needs to use the original coord system, though.
return {...sample, targetX: sample.targetX - translation.x, targetY: sample.targetY - translation.y};
const transformedSample = {...sample, targetX: sample.targetX - translation.x, targetY: sample.targetY - translation.y};

// If the subview is operating from the perspective of a different state token than its base source,
// its samples' item fields will need correction.
//
// This can arise during multitap-like scenarios.
if(source.stateToken != baseSource.stateToken) {
transformedSample.item = this.currentRecognizerConfig.itemIdentifier(
{
...sample,
stateToken: this.stateToken
},
null
);
}

return transformedSample;
}

// Note: we don't particularly need subviews to track the actual coords aside from
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { GestureModel, GestureResolution, GestureResolutionSpec, RejectionDefaul

import { ManagedPromise, TimeoutPromise } from "@keymanapp/web-utils";
import { FulfillmentCause, PathMatcher } from "./pathMatcher.js";
import { ItemIdentifier } from "../../../configuration/gestureRecognizerConfiguration.js";

/**
* This interface specifies the minimal data necessary for setting up gesture-selection
Expand All @@ -18,12 +19,12 @@ import { FulfillmentCause, PathMatcher } from "./pathMatcher.js";
* of this interface, they can be integrated into the gesture-sequence staging system -
* even if not matched directly by the recognizer itself.
*/
export interface PredecessorMatch<Type> {
export interface PredecessorMatch<Type, StateToken> {
readonly sources: GestureSource<Type>[];
readonly allSourceIds: string[];
readonly primaryPath: GestureSource<Type>;
readonly primaryPath: GestureSource<Type, StateToken>;
readonly result: MatchResult<Type>;
readonly model?: GestureModel<Type>;
readonly model?: GestureModel<Type, any>;
readonly baseItem: Type;
}

Expand All @@ -37,9 +38,9 @@ export interface MatchResultSpec {
readonly action: GestureResolutionSpec
}

export class GestureMatcher<Type> implements PredecessorMatch<Type> {
export class GestureMatcher<Type, StateToken = any> implements PredecessorMatch<Type, StateToken> {
private sustainTimerPromise?: TimeoutPromise;
public readonly model: GestureModel<Type>;
public readonly model: GestureModel<Type, StateToken>;

private readonly pathMatchers: PathMatcher<Type>[];

Expand All @@ -55,7 +56,7 @@ export class GestureMatcher<Type> implements PredecessorMatch<Type> {

private _isCancelled: boolean = false;

private readonly predecessor?: PredecessorMatch<Type>;
private readonly predecessor?: PredecessorMatch<Type, StateToken>;

private readonly publishedPromise: ManagedPromise<MatchResult<Type>>; // unsure on the actual typing at the moment.
private _result: MatchResult<Type>;
Expand All @@ -64,7 +65,10 @@ export class GestureMatcher<Type> implements PredecessorMatch<Type> {
return this.publishedPromise.corePromise;
}

constructor(model: GestureModel<Type>, sourceObj: GestureSource<Type> | PredecessorMatch<Type>) {
constructor(
model: GestureModel<Type, StateToken>,
sourceObj: GestureSource<Type> | PredecessorMatch<Type, StateToken>
) {
/* c8 ignore next 5 */
if(!model || !sourceObj) {
throw new Error("Construction of GestureMatcher requires a gesture-model spec and a source for related contact points.");
Expand Down Expand Up @@ -244,6 +248,15 @@ export class GestureMatcher<Type> implements PredecessorMatch<Type> {
const matcher = this.pathMatchers[i];
const contactSpec = this.model.contacts[i];

/* Future TODO:
* This should probably include "committing" the state token and items used by the subview,
* should they differ from the base source's original values.
*
* That said, this is only a 'polish' task, as we aren't actually relying on the base source
* once we've started identifying gestures. It'll likely only matter if external users
* desire to utilize the recognizer.
*/

// If the path already terminated, no need to evaluate further for this contact point.
if(matcher.source.isPathComplete) {
continue;
Expand Down Expand Up @@ -348,44 +361,69 @@ export class GestureMatcher<Type> implements PredecessorMatch<Type> {
// Add it early, as we need it to be accessible for reference via .primaryPath stuff below.
this.pathMatchers.push(contactModel);

let ancestorSource: GestureSource<Type> = null;
let baseItem: Type = null;
// If there were no existing contacts but a predecessor exists and a sustain timer
// has been specified, it needs special base-item handling.
if(!existingContacts && this.predecessor && this.model.sustainTimer) {
ancestorSource = this.predecessor.primaryPath;
const baseItemMode = this.model.sustainTimer.baseItem ?? 'result';
let baseStateToken: StateToken;

switch(baseItemMode) {
case 'none':
baseItem = null;
break;
case 'base':
baseItem = this.predecessor.primaryPath.baseItem;
baseStateToken = this.predecessor.primaryPath.stateToken;
break;
case 'result':
baseItem = this.predecessor.result.action.item;
baseStateToken = this.predecessor.primaryPath.currentSample.stateToken;
break;
}

// Under 'sustain timer' mode, the concept is that the first new source is the
// continuation and successor to `predecessor.primaryPath`. Its base `item`
// should reflect this.
simpleSource.baseItem = baseItem ?? simpleSource.baseItem;
simpleSource.stateToken = baseStateToken;

// May be missing during unit tests.
if(simpleSource.currentRecognizerConfig) {
simpleSource.currentSample.item = simpleSource.currentRecognizerConfig.itemIdentifier({
...simpleSource.currentSample,
stateToken: baseStateToken
},
null
);
}
} else {
// just use the highest-priority item source's base item and call it a day.
// There's no need to refer to some previously-existing source for comparison.
baseItem = this.primaryPath.baseItem;
ancestorSource = this.primaryPath;
}

if(contactSpec.model.allowsInitialState) {
const initialStateCheck = contactSpec.model.allowsInitialState(simpleSource.currentSample, this.primaryPath?.currentSample, baseItem);
const initialStateCheck = contactSpec.model.allowsInitialState(
simpleSource.currentSample,
ancestorSource.currentSample,
baseItem,
ancestorSource.stateToken
);

if(!initialStateCheck) {
this.finalize(false, 'path');
}
}

// Now that we've done the initial-state check, we can check for instantly-matching path models.
contactModel.update();
const result = contactModel.update();
if(result.type == 'reject' && this.model.id == 'modipress-multitap-end') {
console.log('temp');
}

contactModel.promise.then((resolution) => {
this.finalize(resolution.type == 'resolve', resolution.cause);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { GestureModel, GestureResolution } from "../specs/gestureModel.js";
import { MatcherSelection, MatcherSelector } from "./matcherSelector.js";
import { GestureRecognizerConfiguration, TouchpointCoordinator } from "../../../index.js";

export class GestureStageReport<Type> {
export class GestureStageReport<Type, StateToken = any> {
/**
* The id of the GestureModel spec that was matched at this stage of the
* GestureSequence.
Expand All @@ -28,7 +28,7 @@ export class GestureStageReport<Type> {

public readonly allSourceIds: string[];

constructor(selection: MatcherSelection<Type>) {
constructor(selection: MatcherSelection<Type, StateToken>) {
const { matcher, result } = selection;
this.matchedId = matcher?.model.id;
this.linkType = result.action.type;
Expand Down Expand Up @@ -74,34 +74,34 @@ interface PopConfig {

export type ConfigChangeClosure<Type> = (configStackCommand: PushConfig<Type> | PopConfig) => void;

interface EventMap<Type> {
interface EventMap<Type, StateToken> {
stage: (
stageReport: GestureStageReport<Type>,
stageReport: GestureStageReport<Type, StateToken>,
changeConfiguration: ConfigChangeClosure<Type>
) => void;
complete: () => void;
}

export class GestureSequence<Type> extends EventEmitter<EventMap<Type>> {
public stageReports: GestureStageReport<Type>[];
export class GestureSequence<Type, StateToken = any> extends EventEmitter<EventMap<Type, StateToken>> {
public stageReports: GestureStageReport<Type, StateToken>[];

// It's not specific to just this sequence... but it does have access to
// the potential next stages.
private selector: MatcherSelector<Type>;
private selector: MatcherSelector<Type, StateToken>;

// 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 pushedSelector?: MatcherSelector<Type, StateToken>;

private gestureConfig: GestureModelDefs<Type>;
private gestureConfig: GestureModelDefs<Type, StateToken>;

// 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>,
firstSelectionMatch: MatcherSelection<Type, StateToken>,
gestureModelDefinitions: GestureModelDefs<Type, StateToken>,
selector: MatcherSelector<Type, StateToken>,
touchpointCoordinator: TouchpointCoordinator<Type>
) {
super();
Expand Down Expand Up @@ -174,8 +174,8 @@ export class GestureSequence<Type> extends EventEmitter<EventMap<Type>> {
return potentialMatches;
}

private readonly selectionHandler = (selection: MatcherSelection<Type>) => {
const matchReport = new GestureStageReport<Type>(selection);
private readonly selectionHandler = (selection: MatcherSelection<Type, StateToken>) => {
const matchReport = new GestureStageReport<Type, StateToken>(selection);
if(selection.matcher) {
this.stageReports.push(matchReport);
}
Expand Down Expand Up @@ -267,10 +267,12 @@ export class GestureSequence<Type> extends EventEmitter<EventMap<Type>> {

if(selection.result.action.type == 'chain') {
const targetSet = selection.result.action.selectionMode;
// push the new one.
const changedSetSelector = new MatcherSelector<Type>(targetSet);
this.pushedSelector = changedSetSelector;
this.touchpointCoordinator?.pushSelector(changedSetSelector);
if(targetSet) {
// push the new one.
const changedSetSelector = new MatcherSelector<Type, StateToken>(targetSet);
this.pushedSelector = changedSetSelector;
this.touchpointCoordinator?.pushSelector(changedSetSelector);
}
}
}
} else {
Expand All @@ -284,7 +286,10 @@ export class GestureSequence<Type> extends EventEmitter<EventMap<Type>> {
}
}

private readonly modelResetHandler = (selection: MatcherSelection<Type>, replaceModelWith: (model: GestureModel<Type>) => void) => {
private readonly modelResetHandler = (
selection: MatcherSelection<Type, StateToken>,
replaceModelWith: (model: GestureModel<Type, StateToken>) => void
) => {
const sourceIds = selection.matcher.allSourceIds;

// If none of the sources involved match a source already included in the sequence, bypass
Expand All @@ -304,11 +309,11 @@ export class GestureSequence<Type> extends EventEmitter<EventMap<Type>> {
};
}

export function modelSetForAction<Type>(
export function modelSetForAction<Type, StateToken>(
action: GestureResolution<Type>,
gestureModelDefinitions: GestureModelDefs<Type>,
gestureModelDefinitions: GestureModelDefs<Type, StateToken>,
activeSetId: string
): GestureModel<Type>[] {
): GestureModel<Type, StateToken>[] {
switch(action.type) {
case 'none':
case 'complete':
Expand Down
Loading

0 comments on commit bba5473

Please sign in to comment.