Skip to content

Commit

Permalink
feat(web): better locked-flick resetting behaviors
Browse files Browse the repository at this point in the history
  • Loading branch information
jahorton committed Nov 13, 2023
1 parent a9aea50 commit f477c10
Show file tree
Hide file tree
Showing 5 changed files with 89 additions and 29 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -190,22 +190,22 @@ export class CumulativePathStats<Type = any> {
*
* Refer to https://en.wikipedia.org/wiki/Catastrophic_cancellation.
*/
private baseSample?: InputSample<any>;
private baseSample?: InputSample<Type>;

/**
* The initial sample included by this instance's computed stats. Needed for
* the 'directness' properties.
*/
private _initialSample?: InputSample<any>;
private _initialSample?: InputSample<Type>;

private _lastSample?: InputSample<any>;
private followingSample?: InputSample<any>;
private _lastSample?: InputSample<Type>;
private followingSample?: InputSample<Type>;
private _sampleCount = 0;

constructor();
constructor(sample: InputSample<any>);
constructor(sample: InputSample<Type>);
constructor(instance: CumulativePathStats<Type>);
constructor(obj?: InputSample<any> | CumulativePathStats<Type>) {
constructor(obj?: InputSample<Type> | CumulativePathStats<Type>) {
if(!obj) {
return;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ type SimpleStringResult = 'resolve' | 'reject';
export type PointModelResolution = SimpleStringResult;

export interface ContactModel<Type, StateToken = any> {
pathModel: PathModel,
pathModel: PathModel<Type>,
pathResolutionAction: PointModelResolution,

// If multiple touchpoints are active, determines which point's item 'wins' for
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Type = any> {
/**
* Given a TrackedPath, indicates whether or not the path matches this PathModel.
*
Expand All @@ -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<any>, basePathStats: CumulativePathStats<any>): 'reject' | 'resolve' | undefined;
evaluate(path: GesturePath<Type>, basePathStats: CumulativePathStats<Type>): 'reject' | 'resolve' | undefined;
}
21 changes: 17 additions & 4 deletions web/src/engine/osk/src/input/gestures/browser/flick.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand All @@ -36,7 +36,9 @@ export function calcLockedDistance(pathStats: CumulativePathStats<any>, 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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
// Do not store a locked direction; the direction we WOULD lock has
// no valid flick available.
Expand Down
79 changes: 63 additions & 16 deletions web/src/engine/osk/src/input/gestures/specsForLayout.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import {
gestures,
GestureModelDefs,
InputSample
InputSample,
CumulativePathStats
} from '@keymanapp/gesture-recognizer';

import {
Expand All @@ -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;

Expand Down Expand Up @@ -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<KeyElement>) {
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<KeyElement, any> {
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';
}
Expand All @@ -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';
}
}
}
},
Expand Down Expand Up @@ -601,14 +640,22 @@ export function flickMidModel(params: GestureParams): GestureModel<any> {
}
}

// exists to trigger a reset
export function flickResetModel(params: GestureParams): GestureModel<any> {
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'
}
};
}
Expand Down

0 comments on commit f477c10

Please sign in to comment.