feat(web): add Web engine support for auto-reverting whitespace appended to Suggestions on punctuation input

Relates-to: #7163
Relates-to: #12013

This does not outright _fix_ them because we still need to add the ability
to set language-specific punctuation mark sets within the model, and the
model needs to use those to return an appropriate configuration.  (This
commit sets defaults that are English-centric and do not generalize to
all languages.)
This commit is contained in:
Joshua Horton 2025-08-12 08:58:06 -05:00
parent c5892ab843
commit 2c1c46e0dd
17 changed files with 351 additions and 143 deletions

View file

@ -531,14 +531,28 @@ export interface Configuration {
rightContextCodeUnits?: number,
/**
* Whether or not the model appends characters to Suggestions for
* wordbreaking purposes. (These characters need not be whitespace
* or actual wordbreak characters.)
* Specifies behaviors related to transforms that the active model appends
* to Suggestions for wordbreaking purposes. (The Transforms need not apply
* whitespace or actual wordbreak characters.)
*
* If not specified, this will be auto-detected based on the model's
* punctuation properties (if they exist).
* punctuation properties (if they exist). If left null/undefined, the model
* does not append wordbreaking transforms to Suggestions.
*/
wordbreaksAfterSuggestions?: boolean
appendsWordbreaks?: {
/**
* Specifies strings that, when input, always act as word-boundaries on the
* input - both when typed after a manually-applied suggestion (replacing
* appended whitespace) and when typed with an auto-selected suggestion
* available (thus accepting it directly, as with whitespace).
*
* This is designed to allow language-appropriate punctuation marks to
* automatically remove whitespace (or other wordbreak characters) as
* appropriate, and to auto-accept for inputs that clearly signal intent
* to end the current word, both in order to improve user UX with autocorrect.
*/
breakingMarks?: string[];
}
}

View file

@ -166,6 +166,10 @@ builder_run_child_actions build:engine/keyboard
builder_run_child_actions build:engine/js-processor
builder_run_child_actions build:engine/element-wrappers
builder_run_child_actions build:engine/events
# Builds the predictive-text components
builder_run_child_actions build:engine/predictive-text
builder_run_child_actions build:engine/interfaces
# Uses engine/dom-utils and engine/interfaces
@ -177,9 +181,6 @@ builder_run_child_actions build:engine/attachment
# Uses engine/interfaces (due to resource-path config interface)
builder_run_child_actions build:engine/keyboard-storage
# Builds the predictive-text components
builder_run_child_actions build:engine/predictive-text
# Uses engine/interfaces, engine/keyboard-storage, engine/predictive-text, & engine/osk
builder_run_child_actions build:engine/main

View file

@ -70,7 +70,7 @@ export interface LanguageProcessorSpec extends EventEmitter<LanguageProcessorEve
applyReversion(reversion: LexicalModelTypes.Reversion, outputTarget: OutputTarget): Promise<LexicalModelTypes.Suggestion[]>;
get wordbreaksAfterSuggestions(): boolean;
get wordbreaksAfterSuggestions(): LexicalModelTypes.Configuration['appendsWordbreaks'];
get mayAutoCorrect(): boolean;
}

View file

@ -23,11 +23,24 @@ export default class PredictionContext extends EventEmitter<PredictionContextEve
private initNewContext: boolean = true;
private _currentSuggestions: Suggestion[] = [];
private keepSuggestion: Keep;
private revertSuggestion: Reversion;
private _keepSuggestion: Keep;
public get keepSuggestion(): Keep {
return this._keepSuggestion;
}
private _revertSuggestion: Reversion;
public get revertSuggestion(): Reversion {
return this._revertSuggestion;
}
// Set to null/undefined if there was no recent acceptance.
private recentAcceptCause: 'key' | 'banner';
private _recentAcceptCause: 'key' | 'banner';
public get recentAcceptCause(): 'key' | 'banner' {
return this._recentAcceptCause;
}
private revertAcceptancePromise: Promise<Reversion>;
private swallowPrediction: boolean = false;
@ -161,8 +174,6 @@ export default class PredictionContext extends EventEmitter<PredictionContextEve
* @returns if `suggestion` is a `Suggestion`, will return a `Promise<Reversion>`; else, `null`.
*/
public accept(suggestion: Suggestion): Promise<Reversion> | Promise<null> {
const _this = this;
// Selecting a suggestion or a reversion should both clear selection
// and clear the reversion-displaying state of the banner.
this.selected = null;
@ -173,23 +184,23 @@ export default class PredictionContext extends EventEmitter<PredictionContextEve
// We get here either if suggestion acceptance fails or if it was a reversion.
if(suggestion && suggestion.tag == 'revert') {
// Reversion state management
this.recentAcceptCause = null;
this._recentAcceptCause = null;
this.recentRevert = true;
}
return Promise.resolve(null);
}
this.revertAcceptancePromise.then(function(suggestion) {
this.revertAcceptancePromise.then((suggestion) => {
// Always null-check!
if(suggestion) {
_this.revertSuggestion = suggestion;
this._revertSuggestion = suggestion;
}
});
// By default, we assume we were triggered by the banner.
// Acceptance by keystroke will overwrite this later (in `tryAccept`)
this.recentAcceptCause = 'banner';
this._recentAcceptCause = 'banner';
this.recentRevert = false;
this.swallowPrediction = true;
@ -219,9 +230,9 @@ export default class PredictionContext extends EventEmitter<PredictionContextEve
// doTryAccept is the path for keystroke-based auto-acceptance.
// Overwrite the cause to reflect this.
this.recentAcceptCause = 'key';
this._recentAcceptCause = 'key';
} else if(recentAcceptCause && source == 'space') {
this.recentAcceptCause = null;
this._recentAcceptCause = null;
if(recentAcceptCause == 'key') {
// No need to swallow the keystroke's whitespace; we triggered the prior acceptance
// FROM a space, so we've already aliased the suggestion's built-in space.
@ -251,7 +262,7 @@ export default class PredictionContext extends EventEmitter<PredictionContextEve
if(this.doRevert) {
// If so, clear the 'revert' option and start doing normal predictions again.
this.doRevert = false;
this.recentAcceptCause = null;
this._recentAcceptCause = null;
// Otherwise, did we just accept something before the revert signal was received?
} else if(this.recentAcceptCause) {
this.showRevert();
@ -274,7 +285,7 @@ export default class PredictionContext extends EventEmitter<PredictionContextEve
this.selected = null;
if(!this.swallowPrediction || source == 'context') {
this.recentAcceptCause = null;
this._recentAcceptCause = null;
this.doRevert = false;
this.recentRevert = false;
@ -318,10 +329,10 @@ export default class PredictionContext extends EventEmitter<PredictionContextEve
// Do we have a keep suggestion? If so, remove it from the list so that we can control its display position
// and prevent it from being hidden after reversion operations.
this.keepSuggestion = null;
this._keepSuggestion = null;
for (const s of suggestions) {
if(s.tag == 'keep') {
this.keepSuggestion = s as Keep;
this._keepSuggestion = s as Keep;
}
if (this.langProcessor.mayAutoCorrect && s.autoAccept && !this.selected) {
@ -335,7 +346,7 @@ export default class PredictionContext extends EventEmitter<PredictionContextEve
// If we've gotten an update request like this, it's almost always user-triggered and means the context has shifted.
if(!this.swallowPrediction) {
this.recentAcceptCause = null;
this._recentAcceptCause = null;
this.doRevert = false;
this.recentRevert = false;
} else { // This prediction was triggered by a recent 'accept.' Now that it's fulfilled, we clear the flag.

View file

@ -2,7 +2,7 @@
import ContextWindow from "./contextWindow.js";
import { LanguageProcessor } from "./languageProcessor.js";
import type { ModelSpec } from "keyman/engine/interfaces";
import type { ModelSpec, PredictionContext } from "keyman/engine/interfaces";
import { globalObject, DeviceSpec } from "@keymanapp/web-utils";
import { Codes, type Keyboard, type KeyEvent } from "keyman/engine/keyboard";
@ -15,13 +15,24 @@ import {
type OutputTarget,
RuleBehavior,
type ProcessorInitOptions,
SystemStoreIDs
SystemStoreIDs,
TextTransform
} from 'keyman/engine/js-processor';
import { TranscriptionCache } from "./transcriptionCache.js";
import { LexicalModelTypes } from '@keymanapp/common-types';
import { WorkerFactory } from "@keymanapp/lexical-model-layer";
// Only consider raw-insertion transforms. Delete-left and delete-right disqualify an
// incoming transform from reverting post-suggestion whitespace (or similar).
const transformMatchesPattern = (transform: TextTransform, breakingMarks: string[]) => {
if(transform.deleteLeft || transform.deleteRight) {
return false;
}
return breakingMarks.find((pattern) => transform.insert == pattern);
}
export class InputProcessor {
public static readonly DEFAULT_OPTIONS: ProcessorInitOptions = {
baseLayout: 'us'
@ -87,10 +98,12 @@ export class InputProcessor {
*
* @param {Object} keyEvent The abstracted KeyEvent to use for keystroke processing
* @param {Object} outputTarget The OutputTarget receiving the KeyEvent
* @param {Object} predictionContext Context for the state of all predictive-text
* interactions associated with `outputTarget`
* @returns {Object} A RuleBehavior object describing the cumulative effects of
* all matched keyboard rules.
*/
processKeyEvent(keyEvent: KeyEvent, outputTarget: OutputTarget): RuleBehavior {
processKeyEvent(keyEvent: KeyEvent, outputTarget: OutputTarget, predictionContext: PredictionContext): RuleBehavior {
const kbdMismatch = keyEvent.srcKeyboard && this.activeKeyboard != keyEvent.srcKeyboard;
const trueActiveKeyboard = this.activeKeyboard;
@ -127,7 +140,7 @@ export class InputProcessor {
}
}
return this._processKeyEvent(keyEvent, outputTarget);
return this._processKeyEvent(keyEvent, outputTarget, predictionContext);
} finally {
if(kbdMismatch) {
// Restore our "current" activeKeyboard to its setting before the mismatching KeyEvent.
@ -143,7 +156,7 @@ export class InputProcessor {
* @param outputTarget
* @returns
*/
private _processKeyEvent(keyEvent: KeyEvent, outputTarget: OutputTarget): RuleBehavior {
private _processKeyEvent(keyEvent: KeyEvent, outputTarget: OutputTarget, predictionContext: PredictionContext): RuleBehavior {
const formFactor = keyEvent.device.formFactor;
const fromOSK = keyEvent.isSynthetic;
@ -177,9 +190,11 @@ export class InputProcessor {
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.
// TODO: consider auto-application based on auto-reverting punctuation?
} else if((keyEvent.kName == "K_SPACE" || keyEvent.Lcode == Codes.keyCodes["K_SPACE"]) && this.languageProcessor.tryAcceptSuggestion('space')) {
return new RuleBehavior();
}
// checks to potentially cancel out previously-appended whitespace need to evaluate rules first.
}
// // ...end I3363 (Build 301)
@ -194,6 +209,11 @@ export class InputProcessor {
// needed for some indexing operations when comparing two different output targets.
let ruleBehavior = this.keyboardProcessor.processKeystroke(keyEvent, outputTarget);
// Check to see if the incoming keystroke should revert the appended component of an
// immediately-preceding applied Suggestion.
ruleBehavior = this.doPredictiveAutoBreaking(ruleBehavior, outputTarget, predictionContext) || ruleBehavior;
// TODO: if it doesn't revert something, could it accept something?
// Swap layer as appropriate.
if(keyEvent.kNextLayer) {
this.keyboardProcessor.selectLayer(keyEvent);
@ -219,9 +239,9 @@ export class InputProcessor {
isOnlyLayerSwitchKey = true;
}
const keepRuleBehavior = ruleBehavior != null;
const haveValidKeyRuleBehavior = ruleBehavior != null;
// Should we swallow any further processing of keystroke events for this keydown-keypress sequence?
if(keepRuleBehavior) {
if(haveValidKeyRuleBehavior) {
// alternates are our fat-finger alternate outputs. We don't build these for keys we detect as
// layer switch keys
const alternates = isOnlyLayerSwitchKey ? null : this.buildAlternates(ruleBehavior, keyEvent, preInputMock);
@ -272,7 +292,75 @@ export class InputProcessor {
outputTarget.doInputEvent();
}
return keepRuleBehavior ? ruleBehavior : null;
return haveValidKeyRuleBehavior ? ruleBehavior : null;
}
/**
* Implements one predictive-text behavior:
*
* When a suggestion is manually applied and then followed by input of a
* model-defined "wordbreaking mark", any usual appended (usually, whitespace)
* transform will be reverted and replaced with the "wordbreaking mark".
*
* @param ruleBehavior The rule behavior from the keyboard for the incoming keystroke
* @param outputTarget The context source affected by the incoming keystroke
* @param predictionContext The "prediction context" corresponding to the context source.
* @returns If an auto-correct behavior is triggered, returns a new `RuleBehavior` instance
* for the keystroke (as the location of its application may change). If no autocorrect
* behaviors are triggered, returns `null`.
*/
private doPredictiveAutoBreaking(
ruleBehavior: RuleBehavior,
outputTarget: OutputTarget,
predictionContext: PredictionContext
): RuleBehavior {
// If there's no prediction-context instance or predictive-text available, this function is a no-op.
if(!predictionContext || !this.languageProcessor.isActive) {
return null;
}
// Do we have an active model that appends wordbreaking transforms to suggestions...
// and might it support reverting them or auto-applying them? Finally, does the
// incoming Transform match a mark that would activate this behavior?
const breakingMarks = this.languageProcessor.wordbreaksAfterSuggestions?.breakingMarks;
const ruleTransform = ruleBehavior.transcription.transform;
if(!breakingMarks || !transformMatchesPattern(ruleTransform, breakingMarks)) {
return null;
}
const keyEvent = ruleBehavior.transcription.keystroke;
// ...and is this immediately after a Suggestion with an appended Transform was applied?
// (If not, don't consider reverting an appended transform.)
if(predictionContext.revertSuggestion?.appendedTransform && predictionContext.recentAcceptCause == 'banner') {
const reversion = predictionContext.revertSuggestion;
// For reversions, the appended transform exists (if it did for the applied suggestion)
// and has an ID set to the appended transform from the suggestion.
const base = this.contextCache.get(reversion.appendedTransform.id);
const postRevertBehavior = this.keyboardProcessor.processKeystroke(keyEvent, Mock.from(base.preInput));
const postRevertTransform = postRevertBehavior.transcription.transform;
// Does the rule produce the same text & still match one of the breaking-mark patterns?
// (with no deleteLeft, etc)
if(postRevertTransform.insert == ruleTransform.insert && transformMatchesPattern(postRevertTransform, breakingMarks)) {
// Then auto-revert the appended transform...
const targetContext = Mock.from(base.preInput);
targetContext.apply(postRevertBehavior.transcription.transform);
// Update the worker's context! (Which also wants to revert internally, so do it before
// applying the final editing transform to the true output target!)
this.languageProcessor.applyReversion(reversion, outputTarget, true);
// ... & use the new result as the final form of the context
const transform = targetContext.buildTransformFrom(outputTarget);
outputTarget.apply(transform);
// And overwrite the rule behavior transform with the form from the reverted context.
return postRevertBehavior;
}
}
return null;
}
private buildAlternates(ruleBehavior: RuleBehavior, keyEvent: KeyEvent, preInputMock: Mock): Alternate[] {

View file

@ -210,66 +210,76 @@ export class LanguageProcessor extends EventEmitter<LanguageProcessorEventMap> {
if(!original) {
console.warn("Could not apply the Suggestion!");
return null;
} else {
this.recentTranscriptions.rewindTo(suggestion.transformId);
// Apply the Suggestion!
// Step 1: determine the final output text
const final = Mock.from(original.preInput, false);
final.apply(suggestion.transform);
if(suggestion.appendedTransform) {
final.apply(suggestion.appendedTransform);
}
// Step 2: build a final, master Transform that will produce the desired results from the CURRENT state.
// In embedded mode, both Android and iOS are best served by calculating this transform and applying its
// values as needed for use with their IME interfaces.
const transform = final.buildTransformFrom(outputTarget);
outputTarget.apply(transform);
// Tell the banner that a suggestion was applied, so it can call the
// keyboard's PostKeystroke entry point as needed
this.emit('suggestionapplied', outputTarget);
// Build a 'reversion' Transcription that can be used to undo this apply() if needed,
// replacing the suggestion transform with the original input text.
const preApply = Mock.from(original.preInput, false);
preApply.apply(original.transform);
// Builds the reversion option according to the loaded lexical model's known
// syntactic properties.
const suggestionContext = new ContextWindow(original.preInput, this.configuration, getLayerId());
// We must accept the Suggestion from its original context, which was before
// `original.transform` was applied.
let reversionPromise: Promise<Reversion> = this.lmEngine.acceptSuggestion(suggestion, suggestionContext, original.transform);
// Also, request new prediction set based on the resulting context.
reversionPromise = reversionPromise.then((reversion) => {
const mappedReversion: Reversion = {
// By mapping back to the original Transcription that generated the Suggestion,
// the input will be automatically rewound to the preInput state.
transform: original.transform,
// The ID part is critical; the reversion can't be applied without it.
transformId: -original.token, // reversions use the additive inverse.
displayAs: reversion.displayAs, // The real reason we needed to call the LMLayer.
id: reversion.id,
tag: reversion.tag
}
// // If using the version from lm-layer:
// let mappedReversion = reversion;
// mappedReversion.transformId = reversionTranscription.token;
this.predictFromTarget(outputTarget, getLayerId());
return mappedReversion;
});
return reversionPromise;
}
this.recentTranscriptions.rewindTo(suggestion.transformId);
// Apply the Suggestion!
// Step 1: determine the final output text
const intermediate = Mock.from(original.preInput, false);
intermediate.apply(suggestion.transform);
let final = intermediate;
if(suggestion.appendedTransform) {
final = Mock.from(intermediate);
final.apply(suggestion.appendedTransform);
// Somewhere here, save-state the intermediate state!
const appendedTranscription = final.buildTranscriptionFrom(intermediate, null, false);
this.recordTranscription(appendedTranscription);
// We set the appended transform with its own ID before passing it off to the predictive-text worker.
suggestion.appendedTransform.id = appendedTranscription.token;
}
// Step 2: build a final, master Transform that will produce the desired results from the CURRENT state.
// In embedded mode, both Android and iOS are best served by calculating this transform and applying its
// values as needed for use with their IME interfaces.
const transform = final.buildTransformFrom(outputTarget);
outputTarget.apply(transform);
// Tell the banner that a suggestion was applied, so it can call the
// keyboard's PostKeystroke entry point as needed
this.emit('suggestionapplied', outputTarget);
// Build a 'reversion' Transcription that can be used to undo this apply() if needed,
// replacing the suggestion transform with the original input text.
const preApply = Mock.from(original.preInput, false);
preApply.apply(original.transform);
// Builds the reversion option according to the loaded lexical model's known
// syntactic properties.
const suggestionContext = new ContextWindow(original.preInput, this.configuration, getLayerId());
// We must accept the Suggestion from its original context, which was before
// `original.transform` was applied.
let reversionPromise: Promise<Reversion> = this.lmEngine.acceptSuggestion(suggestion, suggestionContext, original.transform);
// Also, request new prediction set based on the resulting context.
reversionPromise = reversionPromise.then((reversion) => {
const mappedReversion: Reversion = {
// By mapping back to the original Transcription that generated the Suggestion,
// the input will be automatically rewound to the preInput state.
transform: original.transform,
// The ID part is critical; the reversion can't be applied without it.
transformId: -original.token, // reversions use the additive inverse.
displayAs: reversion.displayAs, // The real reason we needed to call the LMLayer.
id: reversion.id,
tag: reversion.tag,
appendedTransform: reversion.appendedTransform
}
// // If using the version from lm-layer:
// let mappedReversion = reversion;
// mappedReversion.transformId = reversionTranscription.token;
this.predictFromTarget(outputTarget, getLayerId());
return mappedReversion;
});
return reversionPromise;
}
public applyReversion(reversion: Reversion, outputTarget: OutputTarget) {
public applyReversion(reversion: Reversion, outputTarget: OutputTarget, appendedOnly?: boolean) {
if(!outputTarget) {
throw "Accepting suggestions requires a destination OutputTarget instance."
throw new Error("Accepting suggestions requires a destination OutputTarget instance.");
}
if(!this.isActive) {
@ -282,22 +292,22 @@ export class LanguageProcessor extends EventEmitter<LanguageProcessorEventMap> {
//
// Reversions use the additive inverse of the id token of the Transcription being
// reverted to.
const original = this.getPredictionState(-reversion.transformId);
const reversionId = appendedOnly ? reversion.appendedTransform.id : -reversion.transformId;
const original = this.getPredictionState(reversionId);
if(!original) {
console.warn("Could not apply the Suggestion!");
return Promise.resolve([] as Suggestion[]);
}
this.recentTranscriptions.rewindTo(-reversion.transformId);
this.recentTranscriptions.rewindTo(reversionId);
// Apply the Reversion!
// Step 1: determine the final output text
const final = Mock.from(original.preInput, false);
final.apply(reversion.transform); // Should match original.transform, actually. (See applySuggestion)
if(reversion.appendedTransform) {
final.apply(reversion.appendedTransform);
}
if(!appendedOnly) {
final.apply(reversion.transform); // Should match original.transform, actually. (See applySuggestion)
} // else: the retrieved transcription matches the applied Suggestion's root, without the appended part.
// Step 2: build a final, master Transform that will produce the desired results from the CURRENT state.
// In embedded mode, both Android and iOS are best served by calculating this transform and applying its
@ -308,7 +318,8 @@ export class LanguageProcessor extends EventEmitter<LanguageProcessorEventMap> {
// The reason we need to preserve the additive-inverse 'transformId' property on Reversions.
const promise = this.currentPromise = this.lmEngine.revertSuggestion(
reversion,
new ContextWindow(final, this.configuration, null)
new ContextWindow(final, this.configuration, null),
appendedOnly
);
// If the "current Promise" is as set above, clear it.
// If another one has been triggered since... don't.
@ -455,7 +466,7 @@ export class LanguageProcessor extends EventEmitter<LanguageProcessorEventMap> {
}
public get wordbreaksAfterSuggestions() {
return this.configuration.wordbreaksAfterSuggestions;
return this.configuration?.appendsWordbreaks;
}
public tryAcceptSuggestion(source: string): boolean {

View file

@ -55,6 +55,7 @@ export class KeymanEngineBase<
private keyEventListener: KeyEventFullHandler = (event, callback) => {
const outputTarget = this.contextManager.activeTarget;
const predictionContext = this.contextManager.predictionContext;
if(!this.contextManager.activeKeyboard || !outputTarget) {
if(callback) {
@ -88,7 +89,7 @@ export class KeymanEngineBase<
this.core.keyboardProcessor.layerId = oskLayer;
}
}
const result = this.core.processKeyEvent(event, outputTarget);
const result = this.core.processKeyEvent(event, outputTarget, predictionContext);
if(result && result.transcription?.transform) {
this.config.onRuleFinalization(result, this.contextManager.activeTarget);
@ -249,6 +250,7 @@ export class KeymanEngineBase<
const keyboardProcessor = this.core.keyboardProcessor;
const predictionContext = new PredictionContext(this.core.languageProcessor, () => keyboardProcessor.layerId);
// Set the prediction context within languageProcessor (or InputProcessor / this.core)
this.contextManager.configure({
resetContext: (target) => {
// Could reset the target's deadkeys here, but it's really more of a 'core' task.

View file

@ -177,7 +177,7 @@ export default class LMLayer {
});
}
revertSuggestion(reversion: Reversion, context: Context): Promise<Suggestion[]> {
revertSuggestion(reversion: Reversion, context: Context, appendedOnly?: boolean): Promise<Suggestion[]> {
let token = this._nextToken++;
return new Promise((resolve, reject) => {
this._revertPromises.make(token, resolve, reject);
@ -185,7 +185,8 @@ export default class LMLayer {
message: 'revert',
token: token,
reversion: reversion,
context: context
context: context,
appendedOnly: appendedOnly
})
});
}

View file

@ -62,8 +62,10 @@ export class ContextState {
/**
* The full set of Suggestions produced for the transition to this context state.
*
* May be undefined if no suggestions were generated for this state.
*/
suggestions: Suggestion[];
suggestions?: Suggestion[];
/**
* If set, denotes the suggestion ID for the suggestion (from .suggestions) that
@ -71,6 +73,20 @@ export class ContextState {
*/
appliedSuggestionId?: number;
/**
* If a suggestion was applied, returns the transition ID associated with the
* applied suggestion.
*
* Otherwise, returns `undefined`.
*/
get appliedSuggestionTransitionId(): number | undefined {
if(!this.appliedSuggestionId) {
return undefined;
}
return this.suggestions[this.appliedSuggestionId]?.transformId;
}
/**
* Indicates whether or not the applied suggestion (if it exists) was applied
* directly by the user.
@ -145,6 +161,7 @@ export class ContextState {
baseTokens.push(new ContextToken(this.model));
}
this.tokenization = new ContextTokenization(baseTokens);
this.inputTransforms = new Map();
}
/**

View file

@ -107,7 +107,7 @@ export class ContextTracker {
if(transitionId !== undefined) {
// Special case: if base and final match, we should use the old Transition instance.
// This is currently used in some unit tests.
if(result.final != result.base) {
if(result.final.context != result.base.context) {
this.cache.add(transitionId, result);
} else {
return this.cache.peek(priorMatchState.transitionId);

View file

@ -14,7 +14,7 @@ import { ContextState } from './context-state.js';
import Distribution = LexicalModelTypes.Distribution;
import Suggestion = LexicalModelTypes.Suggestion;
import Transform = LexicalModelTypes.Transform;
import { buildMergedTransform } from '@keymanapp/models-templates';
// Mark affected tokens with the applied-suggestion transition ID
// for easy future reference.
@ -135,7 +135,10 @@ export class ContextTransition {
* @param suggestion
* @returns
*/
applySuggestion(suggestion: Suggestion) {
applySuggestion(suggestion: Suggestion): {
base: ContextTransition,
appended?: ContextTransition
} {
const preAppliedState = this.final;
if(!preAppliedState.suggestions?.find((s) => s.id == suggestion?.id)) {
throw new Error("Could not find matching suggestion to apply");
@ -162,22 +165,33 @@ export class ContextTransition {
state.suggestions = preAppliedState.suggestions;
}
const fullTransform = suggestion.appendedTransform
? buildMergedTransform(suggestion.transform, suggestion.appendedTransform)
: suggestion.transform;
// Start from a deep copy, then replace as needed to overwrite with the context
// state resulting from the suggestion while preserving suggestion + primary
// keystroke data.
const resultTransition = new ContextTransition(this);
buildAppliedTransition(resultTransition, this.base, fullTransform);
buildAppliedTransition(resultTransition, this.base, suggestion.transform);
// An applied suggestion should replace the original Transition's effects, though keeping
// the original input around.
resultTransition._transitionId = suggestion.transformId;
resultTransition.final.appliedInput = preAppliedState.appliedInput;
return resultTransition;
if(!suggestion.appendedTransform) {
return { base: resultTransition };
}
const finalTransition = new ContextTransition(resultTransition.final, suggestion.appendedTransform.id);
buildAppliedTransition(finalTransition, resultTransition.final, suggestion.appendedTransform);
// The appended transform is applied with no intermediate input.
finalTransition.final.appliedInput = { insert: '', deleteLeft: 0 };
finalTransition.inputDistribution = [];
return {
base: resultTransition,
appended: finalTransition
};
}
/**

View file

@ -239,8 +239,12 @@ export default class LMLayerWorker {
let compositor = this.transitionToReadyState(model);
// This test allows models to directly specify the property without it being auto-overridden by
// this default.
if(configuration.wordbreaksAfterSuggestions === undefined) {
configuration.wordbreaksAfterSuggestions = (compositor.punctuation.insertAfterWord != '');
if(configuration.appendsWordbreaks === undefined) {
if(compositor.punctuation.insertAfterWord != '') {
configuration.appendsWordbreaks = {
breakingMarks: ['.', ',', ';', ':', '?', '!']
};
} // else leave undefined (falsy)
}
compositor.setConfiguration(configuration);
this.cast('ready', { configuration });
@ -368,9 +372,9 @@ export default class LMLayerWorker {
});
break;
case 'revert':
var {reversion, context} = payload;
var {reversion, context, appendedOnly} = payload;
compositor.applyReversion(reversion, context).then((suggestions) => {
compositor.applyReversion(reversion, context, appendedOnly).then((suggestions) => {
this.cast('postrevert', {
token: payload.token,
suggestions: suggestions

View file

@ -215,9 +215,9 @@ export class ModelCompositor {
acceptSuggestion(suggestion: Suggestion, context: Context, postTransform?: Transform): Reversion {
// Step 1: generate and save the reversion's Transform.
let sourceTransform = models.buildMergedTransform(suggestion.transform, suggestion.appendedTransform ?? { insert: '', deleteLeft: 0});
let deletedLeftChars = KMWString.substr(context.left, -sourceTransform.deleteLeft, sourceTransform.deleteLeft);
let insertedLength = KMWString.length(sourceTransform.insert);
const sourceTransform = models.buildMergedTransform(suggestion.transform, suggestion.appendedTransform ?? { insert: '', deleteLeft: 0 });
const deletedLeftChars = KMWString.substr(context.left, -sourceTransform.deleteLeft, sourceTransform.deleteLeft);
const insertedLength = KMWString.length(sourceTransform.insert);
let reversionTransform: Transform = {
insert: deletedLeftChars,
@ -278,9 +278,18 @@ export class ModelCompositor {
originalTransition = this.contextTracker.latest;
}
const appliedTransition = originalTransition.applySuggestion(suggestion);
this.contextTracker.latest = appliedTransition;
const transitions = originalTransition.applySuggestion(suggestion);
this.contextTracker.latest = transitions.base;
this.contextTracker.saveLatest();
if(transitions.appended) {
this.contextTracker.latest = transitions.appended;
this.contextTracker.saveLatest();
reversion.appendedTransform = {
insert: '',
deleteLeft: suggestion.appendedTransform.insert.length,
id: suggestion.appendedTransform.id
}
}
}
return reversion;
@ -293,7 +302,7 @@ export class ModelCompositor {
* the original keystroke's effects.
* @returns
*/
async applyReversion(reversion: Reversion, context: Context): Promise<Suggestion[]> {
async applyReversion(reversion: Reversion, context: Context, appendedOnly?: boolean): Promise<Suggestion[]> {
// If we are unable to track context (because the model does not support LexiconTraversal),
// we need a "fallback" strategy.
let compositor = this;
@ -317,7 +326,10 @@ export class ModelCompositor {
}
// When the context is tracked, we prefer the tracked information.
let originalTransition = this.contextTracker.findAndRevert(-reversion.transformId);
// Note that the base reversion's .transformId will predate the appendedTransform id
// used to add whitespace (if one existed), so reverting to the base ID's associated
// context also reverts the appendedTransform.
let originalTransition = this.contextTracker.findAndRevert(appendedOnly ? reversion.appendedTransform.id : -reversion.transformId);
if(!originalTransition) {
this.contextTracker.reset(context, -reversion.transformId);
@ -325,7 +337,9 @@ export class ModelCompositor {
suggestions = fallbackSuggestions();
} else {
if(!suggestions) {
if(suggestions || appendedOnly) {
suggestions = Promise.resolve([]);
} else {
// Will need to be modified a bit if/when phrase-level suggestions are implemented.
// Those will be tracked on the first token of the phrase, which won't be the tail
// if they cover multiple tokens.

View file

@ -207,6 +207,12 @@ export interface RevertMessage {
* corresponding Suggestion.
*/
context: Context;
/**
* Indicates if only the appended transform should be reverted, and not the base component
* of the previously-applied suggestion.
*/
appendedOnly?: boolean;
}
export interface ResetContextMessage {

View file

@ -14,9 +14,6 @@ import { ModelCompositor, models } from '@keymanapp/lm-worker/test-index';
import TrieModel = models.TrieModel;
var emptyInput = (id: number) => [{sample: {insert: '', deleteLeft: 0, id: id}, p: 1}];
describe('ContextTracker', function() {
describe('suggestion acceptance tracking', function() {
let englishPunctuation = {
@ -34,7 +31,8 @@ describe('ContextTracker', function() {
},
appendedTransform: {
insert: ' ',
deleteLeft: 0
deleteLeft: 0,
id: 15
},
transformId: 2,
id: 1,
@ -62,7 +60,7 @@ describe('ContextTracker', function() {
compositor.initContextTracker(baseContext, 0);
const contextTracker = compositor.contextTracker;
let preAppliedTransition = contextTracker.analyzeState(model, baseContext, emptyInput(0));
let preAppliedTransition = contextTracker.latest;
// We'll ignore and overwrite the results. We do need the prediction round to occur, though.
await compositor.predict([{sample: postTransform, p: 1}], baseContext);
contextTracker.latest.final.suggestions = [baseSuggestion];
@ -74,8 +72,7 @@ describe('ContextTracker', function() {
contextTracker.unitTestEndPoints.cache().keys().forEach((key) => assert.isDefined(key));
// Next step - on the followup context, is the replacement still active?
let postContext = models.applyTransform(baseSuggestion.appendedTransform, models.applyTransform(baseSuggestion.transform, baseContext));
let postContextMatch = compositor.contextTracker.analyzeState(model, postContext, emptyInput(2));
let postContextMatch = contextTracker.unitTestEndPoints.cache().peek(baseSuggestion.appendedTransform.id);
assert.equal(postContextMatch.final.appliedSuggestionId, baseSuggestion.id);
// Penultimate token corresponds to whitespace, which does not have a 'raw' representation.

View file

@ -12,9 +12,12 @@ import { assert } from 'chai';
import { default as defaultBreaker } from '@keymanapp/models-wordbreakers';
import { jsonFixture } from '@keymanapp/common-test-resources/model-helpers.mjs';
import { LexicalModelTypes } from '@keymanapp/common-types';
import { ContextState, ContextTransition, models } from '@keymanapp/lm-worker/test-index';
import Distribution = LexicalModelTypes.Distribution;
import Transform = LexicalModelTypes.Transform;
import TrieModel = models.TrieModel;
var plainModel = new TrieModel(jsonFixture('models/tries/english-1000'),
@ -137,20 +140,43 @@ describe('ContextTransition', () => {
}];
const appliedTransition = transition.applySuggestion(suggestions[0]);
assert.notEqual(appliedTransition, transition);
assert.sameOrderedMembers(appliedTransition.final.tokenization.exampleInput, [
assert.notEqual(appliedTransition.base, transition);
assert.isOk(appliedTransition.appended);
assert.notEqual(appliedTransition.appended, transition);
assert.sameOrderedMembers(appliedTransition.base.final.tokenization.exampleInput, [
'hello', ' ', 'world'
]);
assert.sameOrderedMembers(appliedTransition.appended.final.tokenization.exampleInput, [
'hello', ' ', 'world', ' ', ''
]);
assert.equal(appliedTransition.final.appliedSuggestionId, suggestions[0].id);
appliedTransition.final.tokenization.tokens.forEach((token, index) => {
assert.equal(appliedTransition.base.final.appliedSuggestionId, suggestions[0].id);
assert.equal(appliedTransition.appended.final.appliedSuggestionId, suggestions[0].id);
// 3 long, only last token was edited.
appliedTransition.base.final.tokenization.tokens.forEach((token, index) => {
if(index >= 2) {
assert.equal(token.appliedTransitionId, suggestions[0].transformId);
} else {
assert.isUndefined(token.appliedTransitionId);
}
});
assert.deepEqual(appliedTransition.final.suggestions, transition.final.suggestions);
assert.deepEqual(appliedTransition.final.inputTransforms, transition.final.inputTransforms);
appliedTransition.appended.final.tokenization.tokens.forEach((token, index) => {
if(index >= 2) {
assert.equal(token.appliedTransitionId, suggestions[0].transformId);
} else {
assert.isUndefined(token.appliedTransitionId);
}
});
assert.deepEqual(appliedTransition.base.final.suggestions, transition.final.suggestions);
assert.deepEqual(appliedTransition.appended.final.suggestions, transition.final.suggestions);
assert.deepEqual(appliedTransition.base.final.inputTransforms, transition.final.inputTransforms);
assert.deepEqual(appliedTransition.base.inputDistribution, transition.inputDistribution);
const emptyTransformMap = new Map<number, Distribution<Transform>>();
emptyTransformMap.set(2, [{sample: {insert: '', deleteLeft: 0, id: 2}, p: 1}])
assert.deepEqual(appliedTransition.appended.final.appliedInput, {insert: '', deleteLeft: 0});
assert.isEmpty(appliedTransition.appended.inputDistribution);
});
describe('reproduceOriginal', () => {
@ -247,10 +273,10 @@ describe('ContextTransition', () => {
displayAs: 'won'
}];
const appliedTransition = transition.applySuggestion(suggestions[0]);
const appliedTransitions = transition.applySuggestion(suggestions[0]);
// To the point above, this matches the 'applySuggestion' test case.
const restoredTransition = appliedTransition.reproduceOriginal();
const restoredTransition = appliedTransitions.base.reproduceOriginal();
assertClonedTransitionMatch(restoredTransition, transition);
});
});

View file

@ -19,8 +19,6 @@ import Suggestion = LexicalModelTypes.Suggestion;
import Transform = LexicalModelTypes.Transform;
import TrieModel = models.TrieModel;
var emptyInput = (id: number) => [{sample: {insert: '', deleteLeft: 0, id: id}, p: 1}];
describe('ModelCompositor', function() {
describe('Prediction with 14.0+ models', function() {
describe('Basic suggestion generation', function() {
@ -851,6 +849,7 @@ describe('ModelCompositor', function() {
assert.equal(keepSuggestion.tag, 'keep'); // corresponds to `postTransform`, but the transform isn't equal.
let baseSuggestion = initialSuggestions[1];
baseSuggestion.appendedTransform.id = 15; // set an id for the applied transform.
let reversion = compositor.acceptSuggestion(baseSuggestion, baseContext, postTransform);
assert.equal(reversion.transformId, -baseSuggestion.transformId);
assert.equal(reversion.id, -baseSuggestion.id);
@ -892,18 +891,21 @@ describe('ModelCompositor', function() {
assert.equal(compositor.contextTracker.unitTestEndPoints.cache().size, 0);
let baseSuggestion = initialSuggestions[1];
baseSuggestion.appendedTransform.id = 15; // set an id for the applied transform.
let reversion = compositor.acceptSuggestion(baseSuggestion, baseContext, postTransform);
assert.equal(compositor.contextTracker.unitTestEndPoints.cache().size, 1);
// Two rewind states: one for the suggestion's base word text, one for
// appended post-suggestion whitespace.
assert.equal(compositor.contextTracker.unitTestEndPoints.cache().size, 2);
let contextIds = compositor.contextTracker.unitTestEndPoints.cache().keys();
assert.equal(reversion.transformId, -baseSuggestion.transformId);
assert.equal(reversion.id, -baseSuggestion.id);
let postContext = models.applyTransform(baseSuggestion.appendedTransform, models.applyTransform(baseSuggestion.transform, baseContext));
const appliedContextState = compositor.contextTracker.analyzeState(model, postContext, emptyInput(15));
const appliedContextState = compositor.contextTracker.unitTestEndPoints.cache().peek(15);
// Accepting the suggestion rewrites the latest context transition.
assert.equal(compositor.contextTracker.unitTestEndPoints.cache().size, 2);
assert.sameMembers(compositor.contextTracker.unitTestEndPoints.cache().keys(), [15, baseSuggestion.transformId]);
assert.sameMembers(compositor.contextTracker.unitTestEndPoints.cache().keys(), [...contextIds]);
// The replacement should be marked on the context-tracking token for the applied version of the results.
assert.equal(suggestionContextState.final.appliedSuggestionId, undefined);