// Defines a 'polyfill' of sorts for NPM's events module /// /// /// /// namespace com.keyman.text { export class InputProcessor { public static readonly DEFAULT_OPTIONS: ProcessorInitOptions = { baseLayout: 'us' } private device: 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.device = 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.device, 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 isOnlyLayerShift = 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 could cause an issue 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) { isOnlyLayerShift = true; } const keepRuleBehavior = ruleBehavior != null; // Should we swallow any further processing of keystroke events for this keydown-keypress sequence? if(keepRuleBehavior && !isOnlyLayerShift) { let alternates = 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 if(ruleBehavior == null) { // 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(keyEvent.device, 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) { 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. } }());