diff --git a/common/web/input-processor/src/text/inputProcessor.ts b/common/web/input-processor/src/text/inputProcessor.ts index c5d679d8ac..4bdb20b614 100644 --- a/common/web/input-processor/src/text/inputProcessor.ts +++ b/common/web/input-processor/src/text/inputProcessor.ts @@ -85,7 +85,7 @@ export default class InputProcessor { * @returns {Object} A RuleBehavior object describing the cumulative effects of * all matched keyboard rules */ - processNewContextEvent(outputTarget: OutputTarget): RuleBehavior { + processNewContextEvent(outputTarget: OutputTarget): RuleBehavior { const ruleBehavior = this.keyboardProcessor.processNewContextEvent(this.contextDevice, outputTarget); if(ruleBehavior) { @@ -365,13 +365,7 @@ export default class InputProcessor { } public resetContext(outputTarget?: OutputTarget) { - this.keyboardProcessor.resetContext(); + this.keyboardProcessor.resetContext(outputTarget); 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); - } } } \ No newline at end of file diff --git a/common/web/input-processor/src/text/prediction/predictionContext.ts b/common/web/input-processor/src/text/prediction/predictionContext.ts index 9fc5dcd003..4206079390 100644 --- a/common/web/input-processor/src/text/prediction/predictionContext.ts +++ b/common/web/input-processor/src/text/prediction/predictionContext.ts @@ -50,7 +50,7 @@ export default class PredictionContext extends EventEmitter { + const target = this.currentTarget; + + if(target) { + // Note: should be triggered after the corresponding new-context event rule has been processed, + // as that may affect the value of layerId here. + return this.langProcessor.invalidateContext(target, this.kbdProcessor.layerId); + } else { + return Promise.resolve([]); + } + } } \ No newline at end of file diff --git a/common/web/keyboard-processor/src/text/keyboardProcessor.ts b/common/web/keyboard-processor/src/text/keyboardProcessor.ts index 14d1359f05..9c43c241bc 100644 --- a/common/web/keyboard-processor/src/text/keyboardProcessor.ts +++ b/common/web/keyboard-processor/src/text/keyboardProcessor.ts @@ -554,9 +554,15 @@ export default class KeyboardProcessor extends EventEmitter { return false; } - resetContext() { + resetContext(target?: OutputTarget) { this.layerId = 'default'; this.keyboardInterface.resetContextCache(); + + // May be null if it's a keyboard swap. + if(target) { + this.processNewContextEvent(this.contextDevice, target); + } + if(!this.contextDevice.touchable) { this._UpdateVKShift(null); } diff --git a/web/src/app/browser/src/contextManager.ts b/web/src/app/browser/src/contextManager.ts index 726fa6f152..32b450e293 100644 --- a/web/src/app/browser/src/contextManager.ts +++ b/web/src/app/browser/src/contextManager.ts @@ -1,10 +1,15 @@ -import { OutputTarget } from '@keymanapp/keyboard-processor'; +import { type Keyboard, Mock, OutputTarget } from '@keymanapp/keyboard-processor'; +import { type KeyboardStub } from 'keyman/engine/keyboard-cache'; import { ContextManager as ContextManagerBase, type KeyboardInterface } from 'keyman/engine/main'; +import { BrowserConfiguration } from './configuration.js'; export default class ContextManager extends ContextManagerBase { + private _activeKeyboard: {keyboard: Keyboard, metadata: KeyboardStub}; + private config: BrowserConfiguration; + initialize(): void { // TBD: keyman.domManager.init (the page-integration parts) // CTRL+F: `// Exit initialization here if we're using an embedded code path.` @@ -18,6 +23,17 @@ export default class ContextManager extends ContextManagerBase { throw new Error('Method not implemented.'); } + get activeKeyboard() { + return this._activeKeyboard; + } + + set activeKeyboard(kbd: {keyboard: Keyboard, metadata: KeyboardStub}) { + throw new Error('Method not implemented.'); + // depends on the target + // if not set with an "independent keyboard", changes the global. + // if set with an "independent keyboard", changes only the active target's keyboard. + } + insertText(kbdInterface: KeyboardInterface, Ptext: string, PdeadKey: number) { // Find the correct output target to manipulate. const outputTarget = this.activeTarget; diff --git a/web/src/app/webview/src/configuration.ts b/web/src/app/webview/src/configuration.ts index 1ab46e4fa2..226ee50119 100644 --- a/web/src/app/webview/src/configuration.ts +++ b/web/src/app/webview/src/configuration.ts @@ -1,7 +1,10 @@ import { EngineConfiguration, InitOptionSpec, InitOptionDefaults } from "keyman/engine/main"; +import { type OnInsertTextFunc } from "./contextManager.js"; + export class WebviewConfiguration extends EngineConfiguration { private _embeddingApp: string; + private _oninserttext: OnInsertTextFunc; initialize(options: Required) { this.initialize(options); @@ -13,6 +16,10 @@ export class WebviewConfiguration extends EngineConfiguration { return this._embeddingApp; } + get oninserttext() { + return this._oninserttext; + } + debugReport(): Record { const baseReport = super.debugReport(); baseReport.embeddingApp = this.embeddingApp; @@ -26,10 +33,16 @@ export interface WebviewInitOptionSpec extends InitOptionSpec { /** * May be used to denote the name of the embedding application */ - embeddingApp?: string; + embeddingApp: string; + + /** + * Accepts a callback used for updating the host application's context. + */ + oninserttext?: OnInsertTextFunc; } export const WebviewInitOptionDefaults: Required = { embeddingApp: '', + oninserttext: null, ...InitOptionDefaults } \ No newline at end of file diff --git a/web/src/app/webview/src/contextManager.ts b/web/src/app/webview/src/contextManager.ts index cc8b0c171e..61d1083afa 100644 --- a/web/src/app/webview/src/contextManager.ts +++ b/web/src/app/webview/src/contextManager.ts @@ -1,24 +1,69 @@ -import { Mock } from '@keymanapp/keyboard-processor'; -import { ContextManager as ContextManagerBase } from 'keyman/engine/main'; +import { type Keyboard, Mock } from '@keymanapp/keyboard-processor'; +import { type KeyboardStub } from 'keyman/engine/keyboard-cache'; +import { ContextManagerBase, ContextManagerConfiguration } from 'keyman/engine/main'; +import { WebviewConfiguration } from './configuration.js'; + +export type OnInsertTextFunc = (deleteLeft: number, text: string, deleteRight: number) => void; + +class ContextHost extends Mock { + readonly oninserttext?: OnInsertTextFunc; + + constructor(oninserttext: OnInsertTextFunc) { + super(); + this.oninserttext = oninserttext; + } + + apply(transform: Transform): void { + super.apply(transform); + + // Signal the necessary text changes to the embedding app, if it exists. + if(this.oninserttext) { + this.oninserttext(transform.deleteLeft, transform.insert, transform.deleteRight); + } + } + + // ... selected text is actually looking kinda tricky here. +} export default class ContextManager extends ContextManagerBase { // Change of context? Just replace the Mock. Context will be ENTIRELY controlled // by whatever is hosting the WebView. (Some aspects of this context replacement have // yet to be modularized at this time, though.) - private _rawContext: Mock; + private _rawContext: ContextHost; + private config: WebviewConfiguration; - constructor() { + private _activeKeyboard: {keyboard: Keyboard, metadata: KeyboardStub}; + + constructor(engineConfig: WebviewConfiguration) { super(); - this._rawContext = new Mock(); + + this.config = engineConfig; } initialize(): void { - // There's little distinct to do on page-load for the WebView-hosted version of KMW. - // We don't do page integration here. That said... - // TBD: keyman.domManager.init (there probably are a few embedding-specific aspects worth note) + this._rawContext = new ContextHost(this.config.oninserttext); + this.resetContext(); } get activeTarget(): Mock { return this._rawContext; } + + get activeKeyboard() { + return this._activeKeyboard; + } + + set activeKeyboard(kbd: {keyboard: Keyboard, metadata: KeyboardStub}) { + const priorEntry = this._activeKeyboard; + this._activeKeyboard = kbd; + + if(priorEntry.keyboard != kbd.keyboard || priorEntry.metadata != kbd.metadata) { + this.emit('keyboardchange', kbd); + + // Differs from the standard 'resetContext' by not using new-context rules; this IS + // a keyboard change, not a true context change. + this.resetKeyState(); + this.predictionContext.resetContext(); + } + } } \ No newline at end of file diff --git a/web/src/app/webview/src/keymanEngine.ts b/web/src/app/webview/src/keymanEngine.ts index 71cccc6e3d..462c37f516 100644 --- a/web/src/app/webview/src/keymanEngine.ts +++ b/web/src/app/webview/src/keymanEngine.ts @@ -23,7 +23,7 @@ export class KeymanEngine extends KeymanEngineBase void; -} - -export default abstract class OutputTarget extends OutputTargetBase { - // JS/TS can't do multiple inheritance, so we maintain class events on a readonly field. +export default abstract class OutputTarget extends OutputTargetBase { + // JS/TS can't do true multiple inheritance, so we maintain class events on a readonly field. public readonly events: EventEmitter = new EventEmitter(); /** @@ -53,10 +36,5 @@ export default abstract class OutputTarget); - baseEvents.emit('oninserttext', transform.deleteLeft, transform.insert, transform.deleteRight); } } \ No newline at end of file diff --git a/web/src/engine/main/src/contextManager.ts b/web/src/engine/main/src/contextManager.ts deleted file mode 100644 index 8cfc51c402..0000000000 --- a/web/src/engine/main/src/contextManager.ts +++ /dev/null @@ -1,44 +0,0 @@ -import EventEmitter from 'eventemitter3'; -import { type Keyboard, type KeyboardInterface, type KeyboardProperties, type OutputTarget } from '@keymanapp/keyboard-processor'; - -interface EventMap { - 'changedcontext': (target: OutputTarget, keyboard: Keyboard) => void; -} - -export default abstract class ContextManager extends EventEmitter { - private _activeKeyboard: {keyboard: Keyboard, metadata: KeyboardProperties}; - - abstract initialize(): void; - - abstract get activeTarget(): OutputTarget; - // activeKeyboard: Keyboard; // probably belongs here. - - insertText(kbdInterface: KeyboardInterface, Ptext: string, PdeadKey: number) { - // Find the correct output target to manipulate. - const outputTarget = this.activeTarget; - - if(outputTarget != null) { - if(Ptext != null) { - kbdInterface.output(0, outputTarget, Ptext); - } - - if((typeof(PdeadKey)!=='undefined') && (PdeadKey !== null)) { - kbdInterface.deadkeyOutput(0, outputTarget, PdeadKey); - } - - outputTarget.invalidateSelection(); - return true; - } - return false; - } - - get activeKeyboard(): {keyboard: Keyboard, metadata: KeyboardProperties} { - return this._activeKeyboard; - } -} - -// Intended design: -// - SiteContextManager - for website, document-aware context management -// - app/embed -// - PassthroughContextManager - for WebView-hosted, app-embedded context management. -// - app/web \ No newline at end of file diff --git a/web/src/engine/main/src/contextManagerBase.ts b/web/src/engine/main/src/contextManagerBase.ts new file mode 100644 index 0000000000..d0093ea48b --- /dev/null +++ b/web/src/engine/main/src/contextManagerBase.ts @@ -0,0 +1,88 @@ +import EventEmitter from 'eventemitter3'; +import { type Keyboard, type KeyboardInterface, type OutputTarget } from '@keymanapp/keyboard-processor'; +import { type KeyboardStub } from 'keyman/engine/keyboard-cache'; +import { PredictionContext } from '@keymanapp/input-processor'; + +interface EventMap { + // target, then keyboard. + 'targetchange': (target: OutputTarget) => void; + 'keyboardchange': (kbd: {keyboard: Keyboard, metadata: KeyboardStub}) => void; +} + +export interface ContextManagerConfiguration { + /** + * A function that resets any state-dependent keyboard key-state information such as + * emulated modifier state and layer id. Also purges the context cache. + * If an `outputTarget` is specified, it will also trigger new-context rule processing. + * + * Does not reset option-stores, variable-stores, etc. + */ + readonly resetKeyState: (outputTarget?: OutputTarget) => void; + + /** + * A predictive-state management object that interfaces the predictive-text banner + * with the active context. + */ + readonly predictionContext: PredictionContext; +} + +export abstract class ContextManagerBase extends EventEmitter { + abstract initialize(): void; + + abstract get activeTarget(): OutputTarget; + + private _predictionContext: PredictionContext; + private _resetKeyState: (outputTarget?: OutputTarget) => void; + + get predictionContext(): PredictionContext { + return this._predictionContext; + } + + protected get resetKeyState(): (outputTarget?: OutputTarget) => void { + return this._resetKeyState; + } + + constructor() { + super(); + } + + configure(config: ContextManagerConfiguration) { + // TODO: Set in followup configuration method. Part of initialization? + this._resetKeyState = config.resetKeyState; + this._predictionContext = config.predictionContext; + } + + insertText(kbdInterface: KeyboardInterface, Ptext: string, PdeadKey: number) { + // Find the correct output target to manipulate. + const outputTarget = this.activeTarget; + + if(outputTarget != null) { + if(Ptext != null) { + kbdInterface.output(0, outputTarget, Ptext); + } + + if((typeof(PdeadKey)!=='undefined') && (PdeadKey !== null)) { + kbdInterface.deadkeyOutput(0, outputTarget, PdeadKey); + } + + outputTarget.invalidateSelection(); + + return true; + } + return false; + } + + resetContext() { + this._resetKeyState(this.activeTarget); + this.predictionContext.resetContext(); + } + + abstract get activeKeyboard(): {keyboard: Keyboard, metadata: KeyboardStub}; + abstract set activeKeyboard(kbd: {keyboard: Keyboard, metadata: KeyboardStub}); +} + +// Intended design: +// - SiteContextManager - for website, document-aware context management +// - app/embed +// - PassthroughContextManager - for WebView-hosted, app-embedded context management. +// - app/web \ No newline at end of file diff --git a/web/src/engine/main/src/index.ts b/web/src/engine/main/src/index.ts index 9c24c50598..65c7174b30 100644 --- a/web/src/engine/main/src/index.ts +++ b/web/src/engine/main/src/index.ts @@ -1,5 +1,5 @@ export { EngineConfiguration, InitOptionDefaults, InitOptionSpec } from './engineConfiguration.js'; -export { default as ContextManager } from './contextManager.js'; +export { ContextManagerBase, ContextManagerConfiguration } from './contextManagerBase.js'; export { default as HardKeyboard } from './hardKeyboard.js'; export { default as KeyboardInterface } from './keyboardInterface.js'; export { default as KeymanEngine } from './keymanEngine.js'; diff --git a/web/src/engine/main/src/keyboardInterface.ts b/web/src/engine/main/src/keyboardInterface.ts index 89592be249..1d0758d0cf 100644 --- a/web/src/engine/main/src/keyboardInterface.ts +++ b/web/src/engine/main/src/keyboardInterface.ts @@ -5,18 +5,18 @@ import { } from "@keymanapp/keyboard-processor"; import { KeyboardStub, RawKeyboardStub, StubAndKeyboardCache } from 'keyman/engine/keyboard-cache'; -import ContextManager from './contextManager.js'; +import { ContextManagerBase } from './contextManagerBase.js'; import { VariableStoreCookieSerializer } from "./variableStoreCookieSerializer.js"; export default class KeyboardInterface extends KeyboardInterfaceBase { - private readonly contextManager: ContextManager; + private readonly contextManager: ContextManagerBase; private stubAndKeyboardCache: StubAndKeyboardCache; private stubNamespacer?: (stub: RawKeyboardStub) => void; constructor( _jsGlobal: any, keymanGlobal: KeyboardKeymanGlobal, - contextManager: ContextManager, + contextManager: ContextManagerBase, stubNamespacer?: (stub: RawKeyboardStub) => void ) { super(_jsGlobal, keymanGlobal, new VariableStoreCookieSerializer()); diff --git a/web/src/engine/main/src/keymanEngine.ts b/web/src/engine/main/src/keymanEngine.ts index f7dcb9d64f..14b590a10e 100644 --- a/web/src/engine/main/src/keymanEngine.ts +++ b/web/src/engine/main/src/keymanEngine.ts @@ -6,7 +6,7 @@ import { KeyboardRequisitioner } from "keyman/engine/keyboard-cache"; import { EngineConfiguration, InitOptionDefaults, InitOptionSpec } from "./engineConfiguration.js"; import KeyboardInterface from "./keyboardInterface.js"; -import ContextManagerBase from "./contextManager.js"; +import { ContextManagerBase } from "./contextManagerBase.js"; import { KeyEventHandler } from './keyEventSource.interface.js'; import HardKeyboardBase from "./hardKeyboard.js"; import { LegacyAPIEventEngine } from "./legacyAPIEvents.js"; @@ -66,6 +66,9 @@ export default class KeymanEngine { + this.processor.keyboardProcessor.resetContext(target); + }, + predictionContext: new PredictionContext(this.processor.languageProcessor, this.processor.keyboardProcessor) + }); + + // TODO: configure that context-manager! + + // #region Event handler wiring cache.on('stubAdded', (stub) => { let eventRaiser = () => { // The corresponding event is needed in order to update UI modules as new keyboard stubs "come online". @@ -121,6 +134,13 @@ export default class KeymanEngine { + if(this.osk) { + this.osk.activeKeyboard = kbd; + } + }); + // #endregion } init(optionSpec: Required): void { diff --git a/web/src/engine/osk/src/banner/banner.ts b/web/src/engine/osk/src/banner/banner.ts index c2c4d0024d..fa4af77ca8 100644 --- a/web/src/engine/osk/src/banner/banner.ts +++ b/web/src/engine/osk/src/banner/banner.ts @@ -423,8 +423,10 @@ export class SuggestionBanner extends Banner { // connect the new one! this._predictionContext = context; - context.on('update', this.onSuggestionUpdate); - this.onSuggestionUpdate(context.currentSuggestions); + if(context) { + context.on('update', this.onSuggestionUpdate); + this.onSuggestionUpdate(context.currentSuggestions); + } } public onSuggestionUpdate = (suggestions: Suggestion[]): void => { diff --git a/web/src/engine/osk/src/banner/bannerView.ts b/web/src/engine/osk/src/banner/bannerView.ts index 43312a6a96..7af814387c 100644 --- a/web/src/engine/osk/src/banner/bannerView.ts +++ b/web/src/engine/osk/src/banner/bannerView.ts @@ -5,7 +5,7 @@ import OSKViewComponent from '../components/oskViewComponent.interface.js'; import { ParsedLengthStyle } from '../lengthStyle.js'; import { DeviceSpec } from '@keymanapp/web-utils'; -import type { StateChangeEnum } from '@keymanapp/input-processor'; +import type { PredictionContext, StateChangeEnum } from '@keymanapp/input-processor'; import { createUnselectableElement } from 'keyman/engine/dom-utils'; /** @@ -158,6 +158,8 @@ export class BannerController { private alwaysShow: boolean; private imagePath?: string = ""; + private predictionContext?: PredictionContext; + private readonly hostDevice: DeviceSpec; public static readonly DEFAULT_OPTIONS: BannerOptions = { @@ -165,10 +167,11 @@ export class BannerController { imagePath: "" } - constructor(bannerView: BannerView, hostDevice: DeviceSpec) { + constructor(bannerView: BannerView, hostDevice: DeviceSpec, predictionContext?: PredictionContext) { // Step 1 - establish the container element. Must come before this.setOptions. this.hostDevice = hostDevice; this.container = bannerView; + this.predictionContext = predictionContext; // Initialize with the default options - any 'manually set' options come post-construction. // This will also automatically set the default banner in place. @@ -281,8 +284,8 @@ export class BannerController { } else { this.setBanner('blank'); } - } else if(state == 'configured') { - // TODO: refresh PredictionContext once available. + } else if(state == 'configured' && this.activeBanner instanceof SuggestionBanner) { + this.activeBanner.predictionContext = this.predictionContext || null; } } diff --git a/web/src/engine/osk/src/config/viewConfiguration.ts b/web/src/engine/osk/src/config/viewConfiguration.ts index e7263c6e80..789bc11341 100644 --- a/web/src/engine/osk/src/config/viewConfiguration.ts +++ b/web/src/engine/osk/src/config/viewConfiguration.ts @@ -1,3 +1,4 @@ +import { type PredictionContext } from "@keymanapp/input-processor"; import type Activator from "../views/activator.js"; import CommonConfiguration from "./commonConfiguration.js"; @@ -33,4 +34,10 @@ export default interface Configuration extends CommonConfiguration { * Defaults to 'false'. */ doCacheBusting?: boolean; + + /** + * A predictive-state management object that interfaces the predictive-text banner + * with the active context. + */ + predictionContextManager?: PredictionContext; } \ No newline at end of file diff --git a/web/src/engine/osk/src/views/oskView.ts b/web/src/engine/osk/src/views/oskView.ts index 92a309d41c..a0f32f3000 100644 --- a/web/src/engine/osk/src/views/oskView.ts +++ b/web/src/engine/osk/src/views/oskView.ts @@ -322,7 +322,7 @@ export default abstract class OSKView extends EventEmitter implements this.bannerView = new BannerView(); this.bannerView.events.on('bannerchange', () => this.refreshLayout()); - this._bannerController = new BannerController(this.bannerView, this.hostDevice); + this._bannerController = new BannerController(this.bannerView, this.hostDevice, this.config.predictionContextManager); this.keyboardView = null; @@ -337,7 +337,7 @@ export default abstract class OSKView extends EventEmitter implements } public get bannerController(): BannerController { - return this.bannerController; + return this._bannerController; } public get hostDevice(): DeviceSpec {