-
-
Notifications
You must be signed in to change notification settings - Fork 103
/
inputProcessor.ts
347 lines (291 loc) · 16.2 KB
/
inputProcessor.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
// Defines a 'polyfill' of sorts for NPM's events module
/// <reference path="../includes/events.ts" />
/// <reference path="contextWindow.ts" />
/// <reference path="prediction/languageProcessor.ts" />
/// <reference types="@keymanapp/models-types" />
namespace com.keyman.text {
export class InputProcessor {
public static readonly DEFAULT_OPTIONS: ProcessorInitOptions = {
baseLayout: 'us'
}
/**
* Indicates the device (platform) to be used for non-keystroke events,
* such as those sent to `begin postkeystroke` and `begin newcontext`
* entry points.
*/
private contextDevice: utils.DeviceSpec;
private kbdProcessor: KeyboardProcessor;
private lngProcessor: prediction.LanguageProcessor;
constructor(device: utils.DeviceSpec, options?: ProcessorInitOptions) {
if(!device) {
throw new Error('device must be defined');
}
if(!options) {
options = InputProcessor.DEFAULT_OPTIONS;
}
this.contextDevice = device;
this.kbdProcessor = new KeyboardProcessor(device, options);
this.lngProcessor = new prediction.LanguageProcessor();
}
public get languageProcessor(): prediction.LanguageProcessor {
return this.lngProcessor;
}
public get keyboardProcessor(): KeyboardProcessor {
return this.kbdProcessor;
}
public get keyboardInterface(): text.KeyboardInterface {
return this.keyboardProcessor.keyboardInterface;
}
public get activeKeyboard(): keyboards.Keyboard {
return this.keyboardInterface.activeKeyboard;
}
public set activeKeyboard(keyboard: keyboards.Keyboard) {
this.keyboardInterface.activeKeyboard = keyboard;
// All old deadkeys and keyboard-specific cache should immediately be invalidated
// on a keyboard change.
this.resetContext();
}
public get activeModel(): prediction.ModelSpec {
return this.languageProcessor.activeModel;
}
/**
* Tell the currently active keyboard that a new context has been selected,
* e.g. by focus change, selection change, keyboard change, etc.
*
* @param {Object} outputTarget The OutputTarget that has focus
* @returns {Object} A RuleBehavior object describing the cumulative effects of
* all matched keyboard rules
*/
processNewContextEvent(outputTarget: OutputTarget): RuleBehavior {
const ruleBehavior = this.keyboardProcessor.processNewContextEvent(this.contextDevice, outputTarget);
if(ruleBehavior) {
ruleBehavior.finalize(this.keyboardProcessor, outputTarget, true);
}
return ruleBehavior;
}
/**
* Simulate a keystroke according to the touched keyboard button element
*
* Handles default output and keyboard processing for both OSK and physical keystrokes.
*
* @param {Object} keyEvent The abstracted KeyEvent to use for keystroke processing
* @param {Object} outputTarget The OutputTarget receiving the KeyEvent
* @returns {Object} A RuleBehavior object describing the cumulative effects of
* all matched keyboard rules.
*/
processKeyEvent(keyEvent: KeyEvent, outputTarget: OutputTarget): RuleBehavior {
let formFactor = keyEvent.device.formFactor;
let fromOSK = keyEvent.isSynthetic;
// The default OSK layout for desktop devices does not include nextlayer info, relying on modifier detection here.
// It's the OSK equivalent to doModifierPress on 'desktop' form factors.
if((formFactor == utils.FormFactor.Desktop || !this.activeKeyboard || this.activeKeyboard.usesDesktopLayoutOnDevice(keyEvent.device)) && fromOSK) {
// If it's a desktop OSK style and this triggers a layer change,
// a modifier key was clicked. No output expected, so it's safe to instantly exit.
if(this.keyboardProcessor.selectLayer(keyEvent)) {
return new RuleBehavior();
}
}
// Will handle keystroke-based non-layer change modifier & state keys, mapping them through the physical keyboard's version
// of state management. `doModifierPress` must always run.
if(this.keyboardProcessor.doModifierPress(keyEvent, outputTarget, !fromOSK)) {
// If run on a desktop platform, we know that modifier & state key presses may not
// produce output, so we may make an immediate return safely.
if(!fromOSK) {
return new RuleBehavior();
}
}
// If suggestions exist AND space is pressed, accept the suggestion and do not process the keystroke.
// If a suggestion was just accepted AND backspace is pressed, revert the change and do not process the backspace.
// We check the first condition here, while the prediction UI handles the second through the try__() methods below.
if(this.languageProcessor.isActive) {
// The following code relies on JS's logical operator "short-circuit" properties to prevent unwanted triggering of the second condition.
// Can the suggestion UI revert a recent suggestion? If so, do that and swallow the backspace.
if((keyEvent.kName == "K_BKSP" || keyEvent.Lcode == Codes.keyCodes["K_BKSP"]) && this.languageProcessor.tryRevertSuggestion()) {
return new RuleBehavior();
// Can the suggestion UI accept an existing suggestion? If so, do that and swallow the space character.
} else if((keyEvent.kName == "K_SPACE" || keyEvent.Lcode == Codes.keyCodes["K_SPACE"]) && this.languageProcessor.tryAcceptSuggestion('space')) {
return new RuleBehavior();
}
}
// // ...end I3363 (Build 301)
// Create a "mock" backup of the current outputTarget in its pre-input state.
// Current, long-existing assumption - it's DOM-backed.
let preInputMock = Mock.from(outputTarget, true);
const startingLayerId = this.keyboardProcessor.layerId;
// We presently need the true keystroke to run on the FULL context. That index is still
// needed for some indexing operations when comparing two different output targets.
let ruleBehavior = this.keyboardProcessor.processKeystroke(keyEvent, outputTarget);
// Swap layer as appropriate.
if(keyEvent.kNextLayer) {
this.keyboardProcessor.selectLayer(keyEvent);
}
// If it's a key that we 'optimize out' of our fat-finger correction algorithm,
// we MUST NOT trigger it for this keystroke.
let isOnlyLayerSwitchKey = text.Codes.isKnownOSKModifierKey(keyEvent.kName);
// Best-guess stopgap for possible custom modifier keys.
// If a key (1) does not affect the context and (2) shifts the active layer,
// we assume it's a modifier key. (Touch keyboards may define custom modifier keys.)
//
// Note: this will mean we won't generate alternates in the niche scenario where:
// 1. Keypress does not alter the actual context
// 2. It DOES emit a deadkey with an earlier processing rule.
// 3. The FINAL processing rule does not match.
// 4. The key ALSO signals a layer shift.
// If any of the four above conditions aren't met - no problem!
// So it's a pretty niche scenario.
if((ruleBehavior?.transcription?.transform as TextTransform)?.isNoOp() && keyEvent.kNextLayer) {
isOnlyLayerSwitchKey = true;
}
const keepRuleBehavior = ruleBehavior != null;
// Should we swallow any further processing of keystroke events for this keydown-keypress sequence?
if(keepRuleBehavior) {
// alternates are our fat-finger alternate outputs. We don't build these for keys we detect as
// layer switch keys
let alternates = isOnlyLayerSwitchKey ? null : this.buildAlternates(ruleBehavior, keyEvent, preInputMock);
// Now that we've done all the keystroke processing needed, ensure any extra effects triggered
// by the actual keystroke occur.
ruleBehavior.finalize(this.keyboardProcessor, outputTarget, false);
// -- All keystroke (and 'alternate') processing is now complete. Time to finalize everything! --
// Notify the ModelManager of new input - it's predictive text time!
if(alternates && alternates.length > 0) {
ruleBehavior.transcription.alternates = alternates;
}
} else {
// We need a dummy RuleBehavior for keys which have no output (e.g. Shift)
ruleBehavior = new RuleBehavior();
ruleBehavior.transcription = outputTarget.buildTranscriptionFrom(outputTarget, null, false);
ruleBehavior.triggersDefaultCommand = true;
}
// The keyboard may want to take an action after all other keystroke processing is
// finished, for example to switch layers. This action may not have any output
// but may change system store or variable store values. Given this, we don't need to
// save anything about the post behavior, after finalizing it
// We need to tell the keyboard if the layer has been changed, either by a keyboard rule itself,
// or by the touch layout 'nextlayer' control.
const hasLayerChanged = ruleBehavior.setStore[KeyboardInterface.TSS_LAYER] || keyEvent.kNextLayer;
this.keyboardProcessor.newLayerStore.set(hasLayerChanged ? this.keyboardProcessor.layerId : '');
this.keyboardProcessor.oldLayerStore.set(hasLayerChanged ? startingLayerId : '');
let postRuleBehavior = this.keyboardProcessor.processPostKeystroke(this.contextDevice, outputTarget);
if(postRuleBehavior) {
postRuleBehavior.finalize(this.keyboardProcessor, outputTarget, true);
}
// Yes, even for ruleBehavior.triggersDefaultCommand. Those tend to change the context.
ruleBehavior.predictionPromise = this.languageProcessor.predict(ruleBehavior.transcription, this.keyboardProcessor.layerId);
// Text did not change (thus, no text "input") if we tabbed or merely moved the caret.
if(!ruleBehavior.triggersDefaultCommand) {
// For DOM-aware targets, this will trigger a DOM event page designers may listen for.
outputTarget.doInputEvent();
}
return keepRuleBehavior ? ruleBehavior : null;
}
private buildAlternates(ruleBehavior: RuleBehavior, keyEvent: KeyEvent, preInputMock: Mock): Alternate[] {
let alternates: Alternate[];
// If we're performing a 'default command', it's not a standard 'typing' event - don't do fat-finger stuff.
// Also, don't do fat-finger stuff if predictive text isn't enabled.
if(this.languageProcessor.isActive && !ruleBehavior.triggersDefaultCommand) {
let keyDistribution = keyEvent.keyDistribution;
// We don't need to track absolute indexing during alternate-generation;
// only position-relative, so it's better to use a sliding window for context
// when making alternates. (Slightly worse for short text, matters greatly
// for long text.)
let contextWindow = new ContextWindow(preInputMock, ContextWindow.ENGINE_RULE_WINDOW, this.keyboardProcessor.layerId);
let windowedMock = contextWindow.toMock();
// Note - we don't yet do fat-fingering with longpress keys.
if(keyDistribution && keyEvent.kbdLayer) {
// Tracks a 'deadline' for fat-finger ops, just in case both context is long enough
// and device is slow enough that the calculation takes too long.
//
// Consider use of https://developer.mozilla.org/en-US/docs/Web/API/Performance/now instead?
// Would allow finer-tuned control.
let TIMEOUT_THRESHOLD: number = Number.MAX_VALUE;
let _globalThis = com.keyman.utils.getGlobalObject();
let timer: () => number;
// Available by default on `window` in browsers, but _not_ on `global` in Node,
// surprisingly. Since we can't use code dependent on `require` statements
// at present, we have to condition upon it actually existing.
if(_globalThis['performance'] && _globalThis['performance']['now']) {
timer = function() {
return _globalThis['performance']['now']();
};
TIMEOUT_THRESHOLD = timer() + 16; // + 16ms.
} // else {
// We _could_ just use Date.now() as a backup... but that (probably) only matters
// when unit testing. So... we actually don't _need_ time thresholding when in
// a Node environment.
// }
// Tracks a minimum probability for keystroke probability. Anything less will not be
// included in alternate calculations.
//
// Seek to match SearchSpace.EDIT_DISTANCE_COST_SCALE from the predictive-text engine.
// Reasoning for the selected value may be seen there. Short version - keystrokes
// that _appear_ very precise may otherwise not even consider directly-neighboring keys.
let KEYSTROKE_EPSILON = Math.exp(-5);
// Sort the distribution into probability-descending order.
keyDistribution.sort((a, b) => b.p - a.p);
let activeLayout = this.activeKeyboard.layout(keyEvent.device.formFactor);
alternates = [];
let totalMass = 0; // Tracks sum of non-error probabilities.
for(let pair of keyDistribution) {
if(pair.p < KEYSTROKE_EPSILON) {
totalMass += pair.p;
break;
} else if(timer && timer() >= TIMEOUT_THRESHOLD) {
// Note: it's always possible that the thread _executing_ our JS
// got paused by the OS, even if JS itself is single-threaded.
//
// The case where `alternates` is initialized (line 167) but empty
// (because of net-zero loop iterations) MUST be handled.
break;
}
let mock = Mock.from(windowedMock, false);
let altKey = activeLayout.getLayer(keyEvent.kbdLayer).getKey(pair.keyId);
if(!altKey) {
console.warn("Potential fat-finger key could not be found in layer!");
continue;
}
let altEvent = altKey.constructKeyEvent(this.keyboardProcessor, keyEvent.device);
let alternateBehavior = this.keyboardProcessor.processKeystroke(altEvent, mock);
// If alternateBehavior.beep == true, ignore it. It's a disallowed key sequence,
// so we expect users to never intend their use.
//
// Also possible that this set of conditions fail for all evaluated alternates.
if(alternateBehavior && !alternateBehavior.beep && pair.p > 0) {
let transform: Transform = alternateBehavior.transcription.transform;
// Ensure that the alternate's token id matches that of the current keystroke, as we only
// record the matched rule's context (since they match)
transform.id = ruleBehavior.transcription.token;
alternates.push({sample: transform, 'p': pair.p});
totalMass += pair.p;
}
}
// Renormalizes the distribution, as any error (beep) results
// will result in a distribution that doesn't sum to 1 otherwise.
// All `.p` values are strictly positive, so totalMass is
// guaranteed to be > 0 if the array has entries.
alternates.forEach(function(alt) {
alt.p /= totalMass;
});
}
}
return alternates;
}
public resetContext(outputTarget?: OutputTarget) {
this.keyboardProcessor.resetContext();
this.languageProcessor.invalidateContext(outputTarget, this.keyboardProcessor.layerId);
// Let the keyboard do its initial group processing
//console.log('processNewContextEvent called from resetContext');
if(outputTarget) {
this.processNewContextEvent(outputTarget);
}
}
}
}
(function () {
let ns = com.keyman.text;
// Let the InputProcessor be available both in the browser and in Node.
if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') {
module.exports = ns.InputProcessor;
//@ts-ignore
ns.InputProcessor.com = com; // Export the root namespace so that all InputProcessor classes are accessible by unit tests.
}
}());