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): generalized multitap 🐵 #9740

Merged
merged 21 commits into from
Nov 13, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
45d3b1e
fix(web): first-pass pred text fix for multitaps
jahorton Oct 26, 2023
06d4a76
fix(web): second pass - search space cloning for calc reuse
jahorton Oct 26, 2023
f7e90a5
feat(web): generalized multitap, prep for modipress vs multitap resol…
jahorton Oct 11, 2023
2fa51fc
feat(web): retrieval of base transcription for multitaps
jahorton Oct 16, 2023
c2dd4f0
fix(web): better method name pt 2
jahorton Oct 18, 2023
ee99d94
chore(web): dead/replaced code removal
jahorton Oct 24, 2023
16bc0d9
feat(web): fancier multitap - ends if key held too long
jahorton Oct 25, 2023
e3ddbe4
chore(web): reserves caps-multitap key
jahorton Oct 25, 2023
c34cc13
fix(web): deadkey restoration for multitap-rewinds
jahorton Oct 25, 2023
9152d91
feat(web): simple, baseline multitap correction
jahorton Oct 26, 2023
ddf2a3d
fix(web): cross-layer multitap corrections
jahorton Oct 26, 2023
f8ae8c9
fix(web): funky internal race condition from unneeded check
jahorton Oct 26, 2023
d73a2d9
chore(web): minor comment cleanup
jahorton Oct 26, 2023
743a16d
fix(web): dropped a bit too much a couple commits ago
jahorton Oct 26, 2023
2cd0c18
fix(web): unit-test patchup
jahorton Oct 26, 2023
ccc4c00
fix(web): multitaps with heterogenous nextLayer use
jahorton Nov 1, 2023
cc29c89
chore(common/models): Merge branch 'feat/web/test-kbd-prototyping' in…
jahorton Nov 1, 2023
338af8f
chore(web): Merge branch 'change/common/models/pred-text-context-trac…
jahorton Nov 1, 2023
fd585e5
chore(web): Apply suggestions from code review
jahorton Nov 3, 2023
924e761
change(web): remembered that the outputTarget.restoreTo func exists
jahorton Nov 3, 2023
c9ec3e2
fix(web): removes debugging console statement
jahorton Nov 7, 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
19 changes: 17 additions & 2 deletions common/models/templates/src/priority-queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,20 +18,35 @@ export default class PriorityQueue<Type> {
private comparator: Comparator<Type>;
private heap: Type[];

/**
* Shallow-copy / clone constructor.
* @param instance
*/
constructor(instance: PriorityQueue<Type>);
/**
* Constructs an empty priority queue.
* @param comparator A `Comparator` returning negative values when and only when
* the first parameter should precede the second parameter.
* @param initialEntries
*/
constructor(comparator: Comparator<Type>, initialEntries: Type[] = []) {
constructor(comparator: Comparator<Type>, initialEntries?: Type[]);
constructor(arg1: Comparator<Type> | PriorityQueue<Type>, initialEntries?: Type[]) {
if(typeof arg1 != 'function') {
this.comparator = arg1.comparator;
// Shallow-copies are fine.
this.heap = ([] as Type[]).concat(arg1.heap);
return;
}

const comparator = arg1;
// TODO: We may wish to allow options specifying a limit or threshold for adding
// items to the priority queue. Possibly both.
//
// When that time comes, consider a min-max heap.
// https://en.wikipedia.org/wiki/Min-max_heap
this.comparator = comparator;

this.heap = Array.from(initialEntries);
this.heap = Array.from(initialEntries ?? []);
this.heapify();
}

Expand Down
63 changes: 31 additions & 32 deletions common/web/gesture-recognizer/src/engine/headless/gestureSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,20 +272,16 @@ export class GestureSourceSubview<HoveredItemType, StateToken = any> extends Ges
preserveBaseItem: boolean,
stateTokenOverride?: StateToken
) {
let mayUpdate = true;
let start = 0;
let length = source.path.coords.length;
if(source instanceof GestureSourceSubview) {
start = source._baseStartIndex;
const expectedLength = start + length;
// Check against the full remaining length of the original source; does
// the subview provided to us include its source's most recent point?
const sampleCountSinceStart = source.baseSource.path.coords.length;
if(expectedLength != sampleCountSinceStart) {
mayUpdate = false;
}
}

// While it'd be nice to validate that a previous subview, if used, has all 'current'
// entries, this gets tricky; race conditions are possible in which an extra input event
// occurs before subviews can be spun up when starting a model-matcher in some scenarios.

super(source.rawIdentifier, configStack, source.isFromTouch);

const baseSource = this._baseSource = source instanceof GestureSourceSubview ? source._baseSource : source;
Expand All @@ -299,18 +295,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.
const transformedSample = {...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(this.stateToken) {
transformedSample.stateToken = this.stateToken;
}

// 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(this.stateToken != baseSource.stateToken) {
if(this.stateToken != baseSource.stateToken || this.stateToken != source.stateToken) {
transformedSample.item = this.currentRecognizerConfig.itemIdentifier(
{
...sample,
stateToken: this.stateToken
},
transformedSample,
null
);
}
Expand Down Expand Up @@ -353,24 +354,22 @@ export class GestureSourceSubview<HoveredItemType, StateToken = any> extends Ges
this._baseItem = lastSample?.item;
}

if(mayUpdate) {
// Ensure that this 'subview' is updated whenever the "source of truth" is.
const completeHook = () => this.path.terminate(false);
const invalidatedHook = () => this.path.terminate(true);
const stepHook = (sample: InputSample<HoveredItemType, StateToken>) => {
super.update(translateSample(sample));
};
baseSource.path.on('complete', completeHook);
baseSource.path.on('invalidated', invalidatedHook);
baseSource.path.on('step', stepHook);

// But make sure we can "disconnect" it later once the gesture being matched
// with the subview has fully matched; it's good to have a snapshot left over.
this.subviewDisconnector = () => {
baseSource.path.off('complete', completeHook);
baseSource.path.off('invalidated', invalidatedHook);
baseSource.path.off('step', stepHook);
}
// Ensure that this 'subview' is updated whenever the "source of truth" is.
const completeHook = () => this.path.terminate(false);
const invalidatedHook = () => this.path.terminate(true);
const stepHook = (sample: InputSample<HoveredItemType, StateToken>) => {
super.update(translateSample(sample));
};
baseSource.path.on('complete', completeHook);
baseSource.path.on('invalidated', invalidatedHook);
baseSource.path.on('step', stepHook);

// But make sure we can "disconnect" it later once the gesture being matched
// with the subview has fully matched; it's good to have a snapshot left over.
this.subviewDisconnector = () => {
baseSource.path.off('complete', completeHook);
baseSource.path.off('invalidated', invalidatedHook);
baseSource.path.off('step', stepHook);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -389,13 +389,12 @@ export class GestureMatcher<Type, StateToken = any> implements PredecessorMatch<
// should reflect this.
simpleSource.baseItem = baseItem ?? simpleSource.baseItem;
simpleSource.stateToken = baseStateToken;
simpleSource.currentSample.stateToken = baseStateToken;

// May be missing during unit tests.
if(simpleSource.currentRecognizerConfig) {
simpleSource.currentSample.item = simpleSource.currentRecognizerConfig.itemIdentifier({
...simpleSource.currentSample,
stateToken: baseStateToken
},
simpleSource.currentSample.item = simpleSource.currentRecognizerConfig.itemIdentifier(
simpleSource.currentSample,
null
);
}
Expand Down Expand Up @@ -423,12 +422,6 @@ export class GestureMatcher<Type, StateToken = any> implements PredecessorMatch<
}
}

// Now that we've done the initial-state check, we can check for instantly-matching path models.
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 @@ -5,6 +5,8 @@ import { ManagedPromise, timedPromise } from "@keymanapp/web-utils";
import { GestureSource, GestureSourceSubview } from "../../gestureSource.js";
import { GestureMatcher, MatchResult, PredecessorMatch } from "./gestureMatcher.js";
import { GestureModel } from "../specs/gestureModel.js";
import { GestureSequence } from "./index.js";
import { ItemIdentifier } from "../../../configuration/gestureRecognizerConfiguration.js";

interface GestureSourceTracker<Type, StateToken> {
/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -185,30 +185,6 @@ describe("GestureSource", function() {
assert.deepEqual(subview.currentSample, worldSample);
});

it('is not updated if constructed from old, not-up-to-date subview', () => {
let source = new GestureSource<string>(0, null, true);
let updatingSubview = source.constructSubview(false, true);
let oldSubview = source.constructSubview(false, true);
oldSubview.disconnect();

source.update(helloSample);

let subview = oldSubview.constructSubview(false, true);

assert.equal(subview.path.coords.length, 0);

source.update(worldSample);

// Only the 'updating' one should update in this scenario.
//
// It's an error if the subview based on the non-updated subview updates,
// as there would be a gap in the path!
assert.equal(updatingSubview.path.coords.length, 2);
assert.equal(subview.path.coords.length, 0);
assert.deepEqual(updatingSubview.currentSample, worldSample);
assert.isNotOk(subview.currentSample);
});

it("propagate path termination (complete)", () => {
let source = new GestureSource<string>(0, null, true);
let subview = source.constructSubview(true, true);
Expand Down
12 changes: 12 additions & 0 deletions common/web/input-processor/src/text/inputProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,18 @@ export default class InputProcessor {
this.keyboardInterface.activeKeyboard = keyEvent.srcKeyboard;
}

// Support for multitap context reversion; multitap keys should act as if they were
// the first thing typed since `preInput`, the state before the original base key.
if(keyEvent.baseTranscriptionToken) {
const transcription = this.contextCache.get(keyEvent.baseTranscriptionToken);
if(transcription) {
// Restores full context, including deadkeys in their exact pre-keystroke state.
outputTarget.restoreTo(transcription.preInput);
} else {
console.warn('The base context for the multitap could not be found');
}
}

return this._processKeyEvent(keyEvent, outputTarget);
} finally {
if(kbdMismatch) {
Expand Down
23 changes: 23 additions & 0 deletions common/web/keyboard-processor/src/keyboards/activeLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,27 @@ export class ActiveKeyBase {
hasMultitaps: false
}

// The default-layer shift key on mobile platforms should have a default multitap under
// select conditions.
//
// Note: whether or not any other key has multitaps doesn't matter here. Just THIS one.
if(key.id == 'K_SHIFT' && displayLayer == 'default' && layout.formFactor != 'desktop') {
/* Extra requirements:
*
* 1. The SHIFT key must not specify longpress keys or have already-specified multitaps.
*
* Note: touch layouts specified on desktop layouts often do specify longpress keys;
* utilized modifiers aside from 'shift' become longpress keys under K_SHIFT)
*
* 2. There exists a specified 'caps' layer. Otherwise, there's no destination for
* the default multitap.
*
*/
if(!key.sk && !key.multitap && !!layout.layer.find((entry) => entry.id == 'caps')) {
key.multitap = [Layouts.dfltShiftMultitap];
}
}

// Add class functions to the existing layout object, allowing it to act as an ActiveLayout.
let dummy = new ActiveKeyBase();
let proto = Object.getPrototypeOf(dummy);
Expand Down Expand Up @@ -804,9 +825,11 @@ export class ActiveLayout implements LayoutFormFactor{
* @param formFactor
*/
static polyfill(layout: LayoutFormFactor, keyboard: Keyboard, formFactor: DeviceSpec.FormFactor): ActiveLayout {
/* c8 ignore start */
if(layout == null) {
throw new Error("Cannot build an ActiveLayout for a null specification.");
}
/* c8 ignore end */

const analysisMetadata: AnalysisMetadata = {
hasFlicks: false,
Expand Down
9 changes: 9 additions & 0 deletions common/web/keyboard-processor/src/keyboards/defaultLayouts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -526,6 +526,15 @@ export class Layouts {
return KLS;
}

static dfltShiftMultitap: LayoutSubKey = {
// Needs to be something special and unique. Typing restricts us from
// using a reserved key-id prefix, though.
id: "T_*_MT_SHIFT_TO_CAPS",
text: '*ShiftLock*',
sp: 1,
nextlayer: 'caps'
}

// Defines the default visual layout for a keyboard.
/* c8 ignore start */
static dfltLayout: LayoutSpec = {
Expand Down
4 changes: 2 additions & 2 deletions common/web/keyboard-processor/src/keyboards/keyboard.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import Codes from "../text/codes.js";
import { Layouts, type LayoutFormFactor } from "./defaultLayouts.js";
import { ActiveKey, ActiveKeyBase, ActiveLayout } from "./activeLayout.js";
import { ActiveKey, ActiveLayout, ActiveSubKey } from "./activeLayout.js";
import KeyEvent from "../text/keyEvent.js";
import type OutputTarget from "../text/outputTarget.js";

Expand Down Expand Up @@ -468,7 +468,7 @@ export default class Keyboard {
return keyEvent;
}

constructKeyEvent(key: ActiveKeyBase, device: DeviceSpec, stateKeys: StateKeyMap): KeyEvent {
constructKeyEvent(key: ActiveKey | ActiveSubKey, device: DeviceSpec, stateKeys: StateKeyMap): KeyEvent {
// Make a deep copy of our preconstructed key event, filling it out from there.
const Lkc = key.baseKeyEvent;
Lkc.device = device;
Expand Down
1 change: 1 addition & 0 deletions common/web/keyboard-processor/src/text/keyEvent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export default class KeyEvent implements KeyEventSpec {
kLayer?: string; // The key's layer property
kbdLayer?: string; // The virtual keyboard's active layer
kNextLayer?: string;
baseTranscriptionToken?: number;

/**
* Marks the active keyboard at the time that this KeyEvent was generated by the user.
Expand Down
Loading