spiegel-keyman/common/web/input-processor/src/text/inputProcessor.ts
2022-05-27 13:11:23 +10:00

339 lines
No EOL
16 KiB
TypeScript

// 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'
}
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.
}
}());