Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(web): delayed modipress completion 🐵 #9973

Merged
merged 25 commits into from
Nov 24, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
a213531
feat(web): delayed modipress completion with nested subkeys
jahorton Nov 8, 2023
fabf5a4
feat(web): delayed modipress completion with nested flicks
jahorton Nov 8, 2023
1dc94fc
fix(web): modipress-flick robustness; properly updates layer for comp…
jahorton Nov 8, 2023
f47fd0d
fix(web): resetting locked flicks during a modipress (without cancell…
jahorton Nov 9, 2023
e44e5f3
chore(web): Merge branch 'change/web/polish-pass' into feat/web/delay…
jahorton Nov 14, 2023
0439565
fix(web): missed an optional-based null guard
jahorton Nov 17, 2023
762139b
fix(web): multitap not cancelling with alt press if involves modipress
jahorton Nov 20, 2023
d1a97ff
fix(web): rejection second-press in subkey now possible, is unwanted
jahorton Nov 20, 2023
df77d57
fix(web): sustain mode behavior when nested model has own sustain mode
jahorton Nov 20, 2023
c196f6c
chore(web): temporarily disables broken unit tests
jahorton Nov 20, 2023
568e85b
chore(web): reverts prior commit
jahorton Nov 20, 2023
2a21f9a
fix(web): app/webview multitap context-reset synchronization
jahorton Nov 20, 2023
f29888f
fix(web): modipress+multitap handling
jahorton Nov 20, 2023
f550490
change(web): changes headless subkey-select model, drops now-invalid …
jahorton Nov 21, 2023
8be3f09
fix(web): headless unit-test patchup, fixes non-awaitNested handling
jahorton Nov 21, 2023
83bc0da
fix(web): proper resolution on model replacement failure
jahorton Nov 21, 2023
b3714a0
fix(web): subview construction for terminated sources
jahorton Nov 21, 2023
8842463
chore(web): Merge branch 'feature-gestures' into feat/web/delayed-mod…
jahorton Nov 21, 2023
a0ea2b8
chore(web): Merge branch 'feature-gestures' into feat/web/delayed-mod…
jahorton Nov 21, 2023
b3ba87f
fix(web): key-preview behavior for flicks during multitap-modipress
jahorton Nov 21, 2023
bce1737
fix(web): incomplete layer remapping during modipress-multitap
jahorton Nov 21, 2023
a1bff2c
fix(web): forgot to sustain flick-reset (after divergence from flick-…
jahorton Nov 21, 2023
f94f98f
docs(web): minor doc on resetOnResolve
jahorton Nov 21, 2023
59870ff
fix(web): app/webview did not clear deadkeys on context-reset
jahorton Nov 22, 2023
31cc1f8
change(web): app/webview - single transform per keystroke, even with …
jahorton Nov 22, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,9 @@ export class GesturePath<Type, StateToken = any> extends EventEmitter<EventMap<T
terminate(cancel: boolean = false) {
/* c8 ignore next 3 */
if(this._isComplete) {
throw new Error("Invalid state: this GesturePath has already terminated.");
return;
}

this._wasCancelled = cancel;
this._isComplete = true;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,12 @@ export class GestureSourceSubview<HoveredItemType, StateToken = any> extends Ges
baseSource.path.off('invalidated', invalidatedHook);
baseSource.path.off('step', stepHook);
}

// If the path was already completed, that should be reflected here, too.
if(baseSource.isPathComplete) {
this.path.terminate((baseSource.path.wasCancelled));
this.disconnect();
}
}

private get recognizerTranslation() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,16 @@ export class GestureMatcher<Type, StateToken = any> implements PredecessorMatch<
: predecessor.sources;

const sourceTouchpoints = unfilteredSourceTouchpoints.map((entry) => {
return entry.isPathComplete ? null : entry;
if(source && entry == source) {
// Due to internal delays that can occur when an incoming tap triggers
// completion of a previously-existing gesture but is not included in it
// (`resetOnResolve` mechanics), it is technically possible for a very
// quick tap to be 'complete' by the time we start trying to match
// against it on some devices. We should still try in such cases.
return source;
} else {
return entry.isPathComplete ? null : entry;
}
}).reduce((cleansed, entry) => {
return entry ? cleansed.concat(entry) : cleansed;
}, [] as GestureSource<Type>[]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { GestureMatcher, MatchResult, PredecessorMatch } from "./gestureMatcher.
import { GestureModel, GestureResolution } from "../specs/gestureModel.js";
import { MatcherSelection, MatcherSelector } from "./matcherSelector.js";
import { GestureRecognizerConfiguration, TouchpointCoordinator } from "../../../index.js";
import { ManagedPromise, timedPromise } from "@keymanapp/web-utils";

export class GestureStageReport<Type, StateToken = any> {
/**
Expand Down Expand Up @@ -153,7 +154,7 @@ export class GestureSequence<Type, StateToken = any> extends EventEmitter<EventM
return this.selector?.baseGestureSetId ?? null;
}

/**
/**
* Returns an array of IDs for gesture models that are still valid for the `GestureSource`'s
* current state. They will be specified in descending `resolutionPriority` order.
*/
Expand All @@ -164,16 +165,23 @@ export class GestureSequence<Type, StateToken = any> extends EventEmitter<EventM
return [];
}

const selectors = [ this.selector ];
if(this.pushedSelector) {
selectors.push(this.pushedSelector);
}

// The new round of model-matching is based on the sources used by the previous round.
// This is important; 'sustainTimer' gesture models may rely on a now-terminated source
// from that previous round (like with multitaps).
const lastStageReport = this.stageReports[this.stageReports.length-1];
const trackedSources = lastStageReport.sources;

const potentialMatches = trackedSources.map((source) => {
return this.selector.potentialMatchersForSource(source)
return selectors.map((selector) => selector.potentialMatchersForSource(source)
.map((matcher) => matcher.model.id)
}).reduce((deduplicated, arr) => {
)
}).reduce((flattened, arr) => flattened.concat(arr))
.reduce((deduplicated, arr) => {
for(let entry of arr) {
if(deduplicated.indexOf(entry) == -1) {
deduplicated.push(entry);
Expand All @@ -185,7 +193,7 @@ export class GestureSequence<Type, StateToken = any> extends EventEmitter<EventM
return potentialMatches;
}

private readonly selectionHandler = (selection: MatcherSelection<Type, StateToken>) => {
private readonly selectionHandler = async (selection: MatcherSelection<Type, StateToken>) => {
const matchReport = new GestureStageReport<Type, StateToken>(selection);
if(selection.matcher) {
this.stageReports.push(matchReport);
Expand All @@ -196,10 +204,11 @@ export class GestureSequence<Type, StateToken = any> extends EventEmitter<EventM
return matchSource instanceof GestureSourceSubview ? matchSource.baseSource : matchSource;
}) ?? [];

if(selection.result.action.type == 'complete' || selection.result.action.type == 'none') {
const actionType = selection.result.action.type;
if(actionType == 'complete' || actionType == 'none') {
sources.forEach((source) => {
if(!source.isPathComplete) {
source.terminate(selection.result.action.type == 'none');
source.terminate(actionType == 'none');
}
});

Expand All @@ -212,6 +221,34 @@ export class GestureSequence<Type, StateToken = any> extends EventEmitter<EventM
}
}

if(actionType == 'complete' && this.touchpointCoordinator && this.pushedSelector) {
// Cascade-terminade all nested selectors, but don't remove / pop them yet.
// Their selection mode remains valid while their gestures are sustained.
const sustainedSources = this.touchpointCoordinator?.sustainSelectorSubstack(this.pushedSelector);

const sustainCompletionPromises = sustainedSources.map((source) => {
const promise = new ManagedPromise<void>();
source.path.on('invalidated', () => promise.resolve());
source.path.on('complete', () => promise.resolve());
return promise.corePromise;
});

if(sustainCompletionPromises.length > 0 && selection.result.action.awaitNested) {
await Promise.all(sustainCompletionPromises);
// Ensure all nested gestures finish resolving first before continuing by
// waiting against the macroqueue.
await timedPromise(0);
}

// Actually drops the selection-mode state once all is complete.
// The drop MUST come after the `await` above.
this.touchpointCoordinator?.popSelector(this.pushedSelector);

// May still need it active?
// this.pushedSelector.off('rejectionwithaction', this.modelResetHandler);
this.pushedSelector = null;
}

// 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) => {
Expand Down Expand Up @@ -244,17 +281,18 @@ export class GestureSequence<Type, StateToken = any> extends EventEmitter<EventM
}

if(nextModels.length > 0) {
// Note: if a 'push', that should be handled by an event listener from the main engine driver (or similar)
const modelingSpinupPromise = this.selector.matchGesture(selection.matcher, nextModels);
modelingSpinupPromise.then(async (selectionHost) => this.selectionHandler(await selectionHost.selectionPromise));
// Note: resolve selection-mode changes FIRST, before building the next GestureModel in the sequence.
// If a selection-mode change is triggered, any openings for new contacts on the next model can only
// be fulfilled if handled by the corresponding (pushed) selector, rather than the sequence's base selector.

// 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) {
if(actionType == '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.pushedSelector.off('rejectionwithaction', this.modelResetHandler);
this.touchpointCoordinator?.popSelector(this.pushedSelector);
this.pushedSelector = null;
}
Expand All @@ -274,16 +312,32 @@ export class GestureSequence<Type, StateToken = any> extends EventEmitter<EventM
* can then use that to trigger cancellation of the subkey-selection mode.
*/

if(selection.result.action.type == 'chain') {
if(actionType == 'chain') {
const targetSet = selection.result.action.selectionMode;
if(targetSet) {
// push the new one.
const changedSetSelector = new MatcherSelector<Type, StateToken>(targetSet);
changedSetSelector.on('rejectionwithaction', this.modelResetHandler);
this.pushedSelector = changedSetSelector;
this.touchpointCoordinator?.pushSelector(changedSetSelector);
}
}
}

/* If a selector has been pushed, we need to delegate the next gesture model in the chain
* to it in case it has extra contacts, as those will be processed under the pushed selector.
*
* Example case: a modipress + multitap key should prevent further multitap if a second,
* unrelated key is tapped. Detecting that second tap is only possible via the pushed
* selector.
*
* Future models in the chain are still drawn from the _current_ selector.
*/
const nextStageSelector = this.pushedSelector ?? this.selector;

// Note: if a 'push', that should be handled by an event listener from the main engine driver (or similar)
const modelingSpinupPromise = nextStageSelector.matchGesture(selection.matcher, nextModels);
modelingSpinupPromise.then(async (selectionHost) => this.selectionHandler(await selectionHost.selectionPromise));
} else {
// Any extra finalization stuff should go here, before the event, if needed.
if(!this.markedComplete) {
Expand All @@ -304,6 +358,10 @@ export class GestureSequence<Type, StateToken = any> extends EventEmitter<EventM
//
// This works even for multitaps because we include the most recent ancestor sources in
// `allSourceIds` - that one will match here.
//
// Also sufficiently handles cases where selection is delegated to the pushedSelector,
// since new gestures under the alternate state won't include a source id from the base
// sequence.
if(this.allSourceIds.find((a) => sourceIds.indexOf(a) == -1)) {
return;
}
Expand Down
Loading