From 78dfec6a7381ad72b63538d1bc01f5a2fc73d756 Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Mon, 13 Nov 2023 10:48:37 +0700 Subject: [PATCH] feat(web): better locked-flick resetting behaviors --- .../engine/headless/cumulativePathStats.ts | 12 +-- .../headless/gestures/specs/contactModel.ts | 2 +- .../headless/gestures/specs/pathModel.ts | 4 +- .../osk/src/input/gestures/browser/flick.ts | 21 ++++- .../osk/src/input/gestures/specsForLayout.ts | 79 +++++++++++++++---- 5 files changed, 89 insertions(+), 29 deletions(-) diff --git a/common/web/gesture-recognizer/src/engine/headless/cumulativePathStats.ts b/common/web/gesture-recognizer/src/engine/headless/cumulativePathStats.ts index a22957c2a3..8f26dbb796 100644 --- a/common/web/gesture-recognizer/src/engine/headless/cumulativePathStats.ts +++ b/common/web/gesture-recognizer/src/engine/headless/cumulativePathStats.ts @@ -190,22 +190,22 @@ export class CumulativePathStats { * * Refer to https://en.wikipedia.org/wiki/Catastrophic_cancellation. */ - private baseSample?: InputSample; + private baseSample?: InputSample; /** * The initial sample included by this instance's computed stats. Needed for * the 'directness' properties. */ - private _initialSample?: InputSample; + private _initialSample?: InputSample; - private _lastSample?: InputSample; - private followingSample?: InputSample; + private _lastSample?: InputSample; + private followingSample?: InputSample; private _sampleCount = 0; constructor(); - constructor(sample: InputSample); + constructor(sample: InputSample); constructor(instance: CumulativePathStats); - constructor(obj?: InputSample | CumulativePathStats) { + constructor(obj?: InputSample | CumulativePathStats) { if(!obj) { return; } diff --git a/common/web/gesture-recognizer/src/engine/headless/gestures/specs/contactModel.ts b/common/web/gesture-recognizer/src/engine/headless/gestures/specs/contactModel.ts index e759429c74..473c40a33c 100644 --- a/common/web/gesture-recognizer/src/engine/headless/gestures/specs/contactModel.ts +++ b/common/web/gesture-recognizer/src/engine/headless/gestures/specs/contactModel.ts @@ -7,7 +7,7 @@ type SimpleStringResult = 'resolve' | 'reject'; export type PointModelResolution = SimpleStringResult; export interface ContactModel { - pathModel: PathModel, + pathModel: PathModel, pathResolutionAction: PointModelResolution, // If multiple touchpoints are active, determines which point's item 'wins' for diff --git a/common/web/gesture-recognizer/src/engine/headless/gestures/specs/pathModel.ts b/common/web/gesture-recognizer/src/engine/headless/gestures/specs/pathModel.ts index aba5c42a57..f222aeba98 100644 --- a/common/web/gesture-recognizer/src/engine/headless/gestures/specs/pathModel.ts +++ b/common/web/gesture-recognizer/src/engine/headless/gestures/specs/pathModel.ts @@ -4,7 +4,7 @@ import { GesturePath } from "../../gesturePath.js"; // The TrackedPath model only cares about if the path matches... not what that MEANS. // THAT is the role of the gesture Model (model.ts). -export interface PathModel { +export interface PathModel { /** * Given a TrackedPath, indicates whether or not the path matches this PathModel. * @@ -15,5 +15,5 @@ export interface PathModel { * @param basePathStats The stats for the path of the gesture's previous 'stage', if * one existed. */ - evaluate(path: GesturePath, basePathStats: CumulativePathStats): 'reject' | 'resolve' | undefined; + evaluate(path: GesturePath, basePathStats: CumulativePathStats): 'reject' | 'resolve' | undefined; } \ No newline at end of file diff --git a/web/src/engine/osk/src/input/gestures/browser/flick.ts b/web/src/engine/osk/src/input/gestures/browser/flick.ts index aa344164b4..144c71ab75 100644 --- a/web/src/engine/osk/src/input/gestures/browser/flick.ts +++ b/web/src/engine/osk/src/input/gestures/browser/flick.ts @@ -23,7 +23,7 @@ export const FlickNameCoordMap = (() => { return map; })(); -function lockedAngleForDir(lockedDir: typeof OrderedFlickDirections[number]) { +export function lockedAngleForDir(lockedDir: typeof OrderedFlickDirections[number]) { return Math.PI / 4 * OrderedFlickDirections.indexOf(lockedDir); } @@ -36,7 +36,9 @@ export function calcLockedDistance(pathStats: CumulativePathStats, lockedDi const projY = Math.max(0, -deltaY * Math.cos(lockedAngle)); const projX = Math.max(0, deltaX * Math.sin(lockedAngle)); - return Math.sqrt(projX * projX + projY * projY); + // For intercardinals, note that Math.cos and Math.sin essentially result in component factors of sqrt(2); + // essentially, we've already taken the sqrt of distance. + return projX + projY; } export function buildFlickScroller( @@ -76,7 +78,7 @@ export function buildFlickScroller( * north of the x-axis more likely than the base key - thus including * 'nw' and 'ne' and some 'w' and 'e' paths. */ -const MAX_TOLERANCE_ANGLE_SKEW = Math.PI / 3; +export const MAX_TOLERANCE_ANGLE_SKEW = Math.PI / 3; /** * Represents a flick gesture's implementation within KeymanWeb, including @@ -126,7 +128,18 @@ export default class Flick implements GestureHandler { if(result.matchedId == 'flick-reset-end') { this.emitKey(vkbd, this.baseSpec, baseSource.path.stats); return; - } else if(result.matchedId == 'flick-mid' || result.matchedId == 'flick-reset') { + } else if(result.matchedId == 'flick-reset') { + // Instant transitions to flick-mid state; entry indicates a lock "reset". + // Cancel the flick-viz bit. + if(this.flickScroller) { + this.flickScroller(baseSource.currentSample); + // Clear any previously-set scroller. + baseSource.path.off('step', this.flickScroller); + } + this.lockedDir = null; + this.lockedSelectable = null; + return; + } else if(result.matchedId == 'flick-mid') { if(baseSelection == this.baseSpec) { sequence.cancel(); this.cancel(); diff --git a/web/src/engine/osk/src/input/gestures/specsForLayout.ts b/web/src/engine/osk/src/input/gestures/specsForLayout.ts index a72c8562c7..f2f114c7ab 100644 --- a/web/src/engine/osk/src/input/gestures/specsForLayout.ts +++ b/web/src/engine/osk/src/input/gestures/specsForLayout.ts @@ -1,7 +1,8 @@ import { gestures, GestureModelDefs, - InputSample + InputSample, + CumulativePathStats } from '@keymanapp/gesture-recognizer'; import { @@ -16,7 +17,7 @@ import OSKLayerGroup from '../../keyboard-layout/oskLayerGroup.js'; import { type KeyElement } from '../../keyElement.js'; -import { calcLockedDistance } from './browser/flick.js'; +import { calcLockedDistance, lockedAngleForDir, MAX_TOLERANCE_ANGLE_SKEW, type OrderedFlickDirections } from './browser/flick.js'; import specs = gestures.specs; @@ -281,19 +282,54 @@ export function flickStartContactModel(params: GestureParams): ContactModel { } } -export function flickMidContactModel(params: GestureParams): ContactModel { +/* + * Determines the best direction to use for flick-locking and the total net distance + * traveled in that direction. + */ +function determineLockFromStats(pathStats: CumulativePathStats) { + const flickSpec = pathStats.initialSample.item.key.spec.flick; + + const supportedDirs = Object.keys(flickSpec) as (typeof OrderedFlickDirections[number])[]; + let bestDir: typeof supportedDirs[number]; + let bestLockedDist = 0; + + for(let i = 0; i < supportedDirs.length; i++) { + const dir = supportedDirs[i]; + const lockedDist = calcLockedDistance(pathStats, dir); + if(lockedDist > bestLockedDist) { + bestLockedDist = lockedDist; + bestDir = dir; + } + } + + return { + dir: bestDir, + dist: bestLockedDist + } +} + +export function flickMidContactModel(params: GestureParams): gestures.specs.ContactModel { return { itemPriority: 1, pathModel: { evaluate: (path) => { - // Since 'flick-end' depends on projection to a perfectly-aligned cardinal or - // intercardinal, we need to perform the projection here in order to avoid - // immediate rejection if the 'true' net distance isn't properly aligned. - if(calcLockedDistance(path.stats, path.stats.cardinalDirection as any) >= params.flick.dirLockDist) { - // We _could_ add other criteria if desired, such as for straightness. - // - What's the angle variance look like? - // - or, take a regression & look at the coefficient of determination. - return 'resolve'; + /* + * Check whether or not there is a valid flick for which the path crosses the flick-dist + * threshold while at a supported angle for flick-locking by the flick handler. + */ + const { dir, dist } = determineLockFromStats(path.stats); + + // If the best supported flick direction meets the 'direction lock' threshold criteria, + // only then do we allow transitioning to the 'locked flick' state. + if(dist > params.flick.dirLockDist) { + const trueAngle = path.stats.angle; + const lockAngle = lockedAngleForDir(dir); + const dist1 = Math.abs(trueAngle - lockAngle); + const dist2 = Math.abs(2 * Math.PI + lockAngle - trueAngle); // because of angle wrap-around. + + if(dist1 <= MAX_TOLERANCE_ANGLE_SKEW || dist2 <= MAX_TOLERANCE_ANGLE_SKEW) { + return 'resolve'; + } } else if(path.isComplete) { return 'reject'; } @@ -315,8 +351,11 @@ export function flickEndContactModel(params: GestureParams): ContactModel { // Note: if we wanted auto-triggering once the threshold distance were met, // we'd need to move its related logic into this method. return 'resolve'; - } else if(calcLockedDistance(path.stats, baseStats.cardinalDirection as any) < params.flick.dirLockDist) { - return 'reject'; + } else { + const { dir } = determineLockFromStats(baseStats); + if(calcLockedDistance(path.stats, dir) < params.flick.dirLockDist) { + return 'reject'; + } } } }, @@ -680,14 +719,22 @@ export function flickMidModel(params: GestureParams): GestureModel { } } +// exists to trigger a reset export function flickResetModel(params: GestureParams): GestureModel { - const base = flickMidModel(params); return { - ...base, id: 'flick-reset', + resolutionPriority: 1, + contacts: [ + { + model: { + ...InstantContactResolutionModel, + pathInheritance: 'full' + }, + } + ], resolutionAction: { type: 'chain', - next: 'flick-end' + next: 'flick-mid' } }; }