mirror of
https://github.com/keymanapp/keyman.git
synced 2026-08-06 00:45:32 +00:00
339 lines
No EOL
16 KiB
TypeScript
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.
|
|
}
|
|
}()); |