change(web): keyboard activation as part of ContextManager, related odds and ends

This commit is contained in:
Joshua A. Horton 2023-03-23 14:57:47 +07:00
parent c55ad3f5a0
commit acca3f0dbe
12 changed files with 401 additions and 573 deletions

View file

@ -1,5 +1,6 @@
import Keyboard from "./keyboard.js";
import { KeyboardHarness } from "./keyboardHarness.js";
import KeyboardProperties from "./keyboardProperties.js";
export default abstract class KeyboardLoaderBase {
private _harness: KeyboardHarness;
@ -19,5 +20,48 @@ export default abstract class KeyboardLoaderBase {
return promise;
}
protected abstract loadKeyboardInternal(uri: string): Promise<Keyboard>;
public loadKeyboardFromStub(stub: KeyboardProperties & { filename: string }) {
this.harness.install();
let promise = this.loadKeyboardInternal(stub.filename, stub.id);
promise = promise.catch((err: Error) => {
if(err == KeyboardLoaderBase.scriptErrorMessage(stub.filename)) {
// Enhance the error message.
err.message = KeyboardLoaderBase.scriptErrorMessage(stub);
} else if(err == KeyboardLoaderBase.missingErrorMessage(stub.filename)) {
// Same thing here.
err.message = KeyboardLoaderBase.scriptErrorMessage(stub);
}
throw err;
})
return promise;
}
protected abstract loadKeyboardInternal(uri: string, id?: string): Promise<Keyboard>;
protected static scriptErrorMessage(uri: string);
protected static scriptErrorMessage(metadata: KeyboardProperties & { filename: string });
protected static scriptErrorMessage(arg: string | (KeyboardProperties & { filename: string })) {
if(typeof arg == "string") {
const uri = arg;
return `Error registering the keyboard script at ${uri}; it may contain an error.`
} else {
const stub = arg;
return `Error registering the ${stub.name} keyboard for ${stub.langName}; keyboard script at ${stub.filename} may contain an error.`
}
}
protected static missingErrorMessage(uri: string);
protected static missingErrorMessage(metadata: KeyboardProperties & { filename: string });
protected static missingErrorMessage(arg: string | (KeyboardProperties & { filename: string })) {
if(typeof arg == "string") {
const uri = arg;
return `Cannot find the keyboard at ${uri}.`
} else {
const stub = arg;
return `Cannot find the ${stub.name} keyboard for ${stub.langName} at ${stub.filename}.`;
}
}
}

View file

@ -28,7 +28,7 @@ export class DOMKeyboardLoader extends KeyboardLoaderBase {
this.performCacheBusting = cacheBust || false;
}
protected loadKeyboardInternal(uri: string): Promise<Keyboard> {
protected loadKeyboardInternal(uri: string, id?: string): Promise<Keyboard> {
const promise = new ManagedPromise<Keyboard>();
if(this.performCacheBusting) {
@ -38,15 +38,20 @@ export class DOMKeyboardLoader extends KeyboardLoaderBase {
try {
const document = this.harness._jsGlobal.document;
const script = document.createElement('script');
if(id) {
script.id = id;
}
document.head.appendChild(script);
script.onerror = promise.reject;
script.onerror = () => {
promise.reject(new Error(KeyboardLoaderBase.missingErrorMessage(uri)));
}
script.onload = () => {
if(this.harness.loadedKeyboard) {
const keyboard = this.harness.loadedKeyboard;
this.harness.loadedKeyboard = null;
promise.resolve(keyboard);
} else {
promise.reject();
promise.reject(new Error(KeyboardLoaderBase.scriptErrorMessage(uri)));
}
}

View file

@ -23,15 +23,21 @@ export class NodeKeyboardLoader extends KeyboardLoaderBase {
}
protected loadKeyboardInternal(uri: string): Promise<Keyboard> {
// `fs` does not like 'file:///'; it IS "File System" oriented, after all, and wants a path, not a URI.
if(uri.indexOf('file:///') == 0) {
uri = uri.substring('file:///'.length);
}
let script;
try {
script = new vm.Script(fs.readFileSync(uri).toString());
} catch (err) {
return Promise.reject(new Error(KeyboardLoaderBase.missingErrorMessage(uri)));
}
try {
// `fs` does not like 'file:///'; it IS "File System" oriented, after all, and wants a path, not a URI.
if(uri.indexOf('file:///') == 0) {
uri = uri.substring('file:///'.length);
}
const script = new vm.Script(fs.readFileSync(uri).toString());
script.runInContext(this.harness._jsGlobal);
} catch (err) {
return Promise.reject(err);
return Promise.reject(new Error(KeyboardLoaderBase.scriptErrorMessage(uri)));
}
const keyboard = this.harness.loadedKeyboard;

View file

@ -4,12 +4,13 @@ import fs from 'fs';
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
import { KeyboardHarness, KeyboardInterface, MinimalKeymanGlobal, Mock } from '@keymanapp/keyboard-processor';
import { KeyboardHarness, KeyboardInterface, KeyboardLoaderBase, MinimalKeymanGlobal, Mock } from '@keymanapp/keyboard-processor';
import { NodeKeyboardLoader } from '@keymanapp/keyboard-processor/node-keyboard-loader';
describe('Headless keyboard loading', function() {
const laoPath = require.resolve('@keymanapp/common-test-resources/keyboards/lao_2008_basic.js');
const khmerPath = require.resolve('@keymanapp/common-test-resources/keyboards/khmer_angkor.js');
const nonKeyboardPath = require.resolve('@keymanapp/common-test-resources/index.mjs');
// Common test suite setup.
let device = {
@ -101,5 +102,28 @@ describe('Headless keyboard loading', function() {
assert.equal(khmer_keyboard.id, "Keyboard_khmer_angkor");
});
it('throws distinct errors', async function() {
const invalidPath = 'totally_invalid_path.js';
let harness = new KeyboardInterface({}, MinimalKeymanGlobal);
let keyboardLoader = new NodeKeyboardLoader(harness);
let missingError;
try {
await keyboardLoader.loadKeyboardFromPath(invalidPath);
} catch (err) {
missingError = err;
assert.equal(missingError.message, KeyboardLoaderBase.missingErrorMessage(invalidPath));
}
let scriptLoadError;
try {
await keyboardLoader.loadKeyboardFromPath(nonKeyboardPath);
} catch (err) {
scriptLoadError = err;
}
assert.notEqual(scriptLoadError.message, KeyboardLoaderBase.scriptErrorMessage(nonKeyboardPath));
});
})
});

View file

@ -1,16 +1,31 @@
import { type Keyboard, Mock, OutputTarget } from '@keymanapp/keyboard-processor';
import { type KeyboardStub } from 'keyman/engine/package-cache';
import { CookieSerializer } from 'keyman/engine/dom-utils';
import {
ContextManagerBase,
type KeyboardInterface
} from 'keyman/engine/main';
import { BrowserConfiguration } from './configuration.js';
interface KeyboardCookie {
current: string;
}
export default class ContextManager extends ContextManagerBase {
private _activeKeyboard: {keyboard: Keyboard, metadata: KeyboardStub};
private config: BrowserConfiguration;
private cookieManager = new CookieSerializer<KeyboardCookie>('KeymanWeb_Keyboard');
initialize(): void {
this.on('keyboardasyncload', (stub, completion) => {
// TODO: app/browser - display the loader UI if configured?
// util.wait('Installing keyboard<br/>' + kbdName);
completion.then(() => {
// Cancel the loader UI.
});
});
// TBD: keyman.domManager.init (the page-integration parts)
// CTRL+F: `// Exit initialization here if we're using an embedded code path.`
// EVERYTHING after that block will likely go here - DOMManager's role always
@ -27,7 +42,7 @@ export default class ContextManager extends ContextManagerBase {
return this._activeKeyboard;
}
set activeKeyboard(kbd: {keyboard: Keyboard, metadata: KeyboardStub}) {
setKeyboardActiveForTarget(kbd: {keyboard: Keyboard, metadata: KeyboardStub}, target: OutputTarget) {
throw new Error('Method not implemented.');
// depends on the target
// if not set with an "independent keyboard", changes the global.
@ -64,7 +79,7 @@ export default class ContextManager extends ContextManagerBase {
* When `null`, such operations will affect the global default; otherwise, such operations
* affect only the specified `target`.
*/
private get keyboardTarget(): OutputTarget {
protected get keyboardTarget(): OutputTarget {
// Remove `&& false` once the inlined section below is implemented.
if(this.activeTarget /* has 'independent keyboard mode activated' */ && false) {
return this.activeTarget;
@ -73,39 +88,46 @@ export default class ContextManager extends ContextManagerBase {
}
}
async setTargetActiveKeyboardAsync(kbd: Promise<Keyboard>, metadata: KeyboardStub, target: OutputTarget): Promise<boolean> {
if(!await this.deferredKeyboardActivationValid(kbd, metadata, target)) {
return false;
} else {
let activatingKeyboard = {
keyboard: await kbd,
metadata: metadata
};
if(target == this.keyboardTarget) {
// TODO: 'beforekeyboardchange' event
public async activateKeyboard(keyboardId: string, languageCode?: string, saveCookie?: boolean): Promise<boolean> {
saveCookie ||= false;
const originalKeyboardTarget = this.keyboardTarget;
try {
let result = await super.activateKeyboard(keyboardId, languageCode, saveCookie);
if(saveCookie && !originalKeyboardTarget) { // if the active target uses global keyboard settings
this.cookieManager.save({current: `${keyboardId}:${languageCode}`});
}
// TODO: set THAT TARGET's active keyboard. May or may not be active!
if(target == this.keyboardTarget) {
// TODO: 'keyboardchange' event
// Only do these if the active keyboard-target still matches the original keyboard-target;
// otherwise, maintain what's correct for the currently active one.
if(originalKeyboardTarget == this.keyboardTarget) {
// TODO: app/browser - _SetTargDir (within its ContextManager)
// util.addStyleSheet(domManager.setAttachmentFontStyle(kbdStub.KF));
// uiManager.justActivated = true; // TODO: Resolve without need for the cast.
}
/*
* Alternative to the three above TODOs:
* if (same condition met)
* this.activeKeyboard = activatingKeyboard
* else
* // manually set it for that one control; no events.
*
*/
return result;
} catch(err) {
// non-embedded: if keyboard activation failed, deactivate the keyboard.
return true;
// Make sure we don't infinite-recursion should the deactivate somehow fail.
if(this.config.hostDevice.touchable) {
// Fallback behavior - if on a touch device, we need to keep a keyboard visible.
const defaultStub = this.keyboardCache.defaultStub;
await this.activateKeyboard(defaultStub.id, defaultStub.langId, true).catch(() => {});
} else {
// Fallback behavior - if on a desktop device, the user still has a physical keyboard.
// Just clear out the active keyboard & OSK.
await this.activateKeyboard('', '', false).catch(() => {});
}
if((this.config as BrowserConfiguration).shouldAlert) {
// TODO: util.alert error report
}
throw err; // since the site-dev consumer may want to do their own error-handling.
}
}
async setActiveKeyboardAsync(kbd: Promise<Keyboard>, metadata: KeyboardStub): Promise<boolean> {
return this.setTargetActiveKeyboardAsync(kbd, metadata, this.keyboardTarget);
}
}

View file

@ -1,6 +1,5 @@
import { KeymanEngine as KeymanEngineBase } from 'keyman/engine/main';
import { ProcessorInitOptions } from "@keymanapp/keyboard-processor";
import { type KeyboardStub } from "keyman/engine/package-cache";
import { BrowserConfiguration } from './configuration.js';
import ContextManager from './contextManager.js';
@ -18,41 +17,4 @@ export class KeymanEngine extends KeymanEngineBase<ContextManager, KeyEventKeybo
defaultOutputRules: new DefaultBrowserRules(this.contextManager)
};
};
protected async activateKeyboard(keyboardId: string, languageCode?: string, saveCookie?: boolean): Promise<void> {
saveCookie ||= false;
try {
await super.activateKeyboard(keyboardId, languageCode, saveCookie);
if(saveCookie /* && this.contextManager.activeTarget does not have independent-keyboard mode active */) {
// TODO: persist the newly-activating keyboard's IDs for use as default upon page reload
}
// TODO: app/browser - _SetTargDir (within its ContextManager)
} catch(err) {
// non-embedded: if keyboard activation failed, deactivate the keyboard.
// Make sure we don't infinite-recursion should the deactivate somehow fail.
if(this.config.hostDevice.touchable) {
// Fallback behavior - if on a touch device, we need to keep a keyboard visible.
const defaultStub = this.keyboardRequisitioner.cache.defaultStub;
await this.activateKeyboard(defaultStub.id, defaultStub.langId, true).catch(() => {});
} else {
// Fallback behavior - if on a desktop device, the user still has a physical keyboard.
// Just clear out the active keyboard & OSK.
await this.activateKeyboard('', '', false).catch(() => {});
}
if((this.config as BrowserConfiguration).shouldAlert) {
// TODO: util.alert error report
}
throw err; // since the site-dev consumer may want to do their own error-handling.
}
}
protected onKeyboardAsyncLoadStart(requestedStub: KeyboardStub) {
// TODO: app/browser - display the loader UI if configured?
}
}

View file

@ -1,4 +1,4 @@
import { type Keyboard, Mock } from '@keymanapp/keyboard-processor';
import { type Keyboard, Mock, OutputTarget } from '@keymanapp/keyboard-processor';
import { KeyboardStub } from 'keyman/engine/package-cache';
import { ContextManagerBase, ContextManagerConfiguration } from 'keyman/engine/main';
import { WebviewConfiguration } from './configuration.js';
@ -51,42 +51,50 @@ export default class ContextManager extends ContextManagerBase {
return this._activeKeyboard;
}
set activeKeyboard(kbd: {keyboard: Keyboard, metadata: KeyboardStub}) {
const priorEntry = this._activeKeyboard;
// Clone the stub before exposing it...
if(!this.confirmKeyboardChange(new KeyboardStub(kbd.metadata))) {
return;
}
setKeyboardActiveForTarget(kbd: {keyboard: Keyboard, metadata: KeyboardStub}, target: OutputTarget) {
// `target` is irrelevant for `app/webview`, as it'll only ever use 'global' keyboard settings.
// Clone the object to prevent accidental by-reference changes.
this._activeKeyboard = {...kbd};
}
if(priorEntry.keyboard != kbd.keyboard || priorEntry.metadata != kbd.metadata) {
this.emit('keyboardchange', kbd);
this.resetContext();
/**
* Reflects the active 'target' upon which any `set activeKeyboard` operation will take place.
* For app/webview... there's only one target, thus only a "global default" matters.
*/
protected get keyboardTarget(): Mock {
return null;
}
public async activateKeyboard(keyboardId: string, languageCode?: string, saveCookie?: boolean): Promise<boolean> {
try {
return await super.activateKeyboard(keyboardId, languageCode, saveCookie);
} catch(err) {
// Fallback behavior - we're embedded in a touch-device's webview, so we need to keep a keyboard visible.
const defaultStub = this.keyboardCache.defaultStub;
await this.activateKeyboard(defaultStub.id, defaultStub.langId, true).catch(() => {});
throw err; // since the consumer may want to do its own error-handling.
}
}
async setActiveKeyboardAsync(kbd: Promise<Keyboard>, metadata: KeyboardStub): Promise<boolean> {
if(!this.confirmKeyboardChange) {
return false;
protected prepareKeyboardForActivation(
keyboardId: string,
languageCode?: string
): {keyboard: Promise<Keyboard>, metadata: KeyboardStub} {
const originalKeyboard = this.activeKeyboard;
const activatingKeyboard = super.prepareKeyboardForActivation(keyboardId, languageCode);
// Probably isn't necessary at this point - osk.refreshLayout() exists - but
// it's best to keep it around for now and verify later.
if(originalKeyboard.metadata.id == activatingKeyboard.metadata.id) {
activatingKeyboard.keyboard = activatingKeyboard.keyboard.then((kbd) => {
kbd.refreshLayouts()
return kbd;
});
}
// There is only the one target, so 'default global keyboard' use is fine.
if(!await this.deferredKeyboardActivationValid(kbd, metadata, null)) {
return false;
} else {
const activatingKeyboard = {
keyboard: await kbd,
metadata: metadata
};
this.activeKeyboard = activatingKeyboard;
// The change may silently fail due to `set activeKeyboard`'s `confirmKeyboardChange` call.
return this.activeKeyboard.keyboard == activatingKeyboard.keyboard
&& this.activeKeyboard.metadata == activatingKeyboard.metadata;
}
return activatingKeyboard;
}
}

View file

@ -52,36 +52,6 @@ export class KeymanEngine extends KeymanEngineBase<ContextManager, PassthroughKe
setupEmbeddedListeners(this, this.osk);
}
protected async activateKeyboard(keyboardId: string, languageCode?: string, saveCookie?: boolean): Promise<void> {
saveCookie ||= false;
try {
await super.activateKeyboard(keyboardId, languageCode, saveCookie);
} catch(err) {
// Fallback behavior - we're embedded in a touch-device's webview, so we need to keep a keyboard visible.
const defaultStub = this.keyboardRequisitioner.cache.defaultStub;
await this.activateKeyboard(defaultStub.id, defaultStub.langId, true).catch(() => {});
throw err; // since the consumer may want to do its own error-handling.
}
}
protected async prepareKeyboardForActivation(
keyboardId: string,
languageCode?: string
): Promise<{keyboard: Keyboard, metadata: KeyboardStub}> {
const originalKeyboard = this.contextManager.activeKeyboard;
const activatingKeyboard = await super.prepareKeyboardForActivation(keyboardId, languageCode);
// Probably isn't necessary at this point - osk.refreshLayout() exists - but
// it's best to keep it around for now and verify later.
if(originalKeyboard.keyboard == activatingKeyboard.keyboard) {
activatingKeyboard.keyboard.refreshLayouts();
}
return activatingKeyboard;
}
// Functions that the old 'app/webview' equivalent had always provided to the WebView
/**

View file

@ -1,12 +1,13 @@
import EventEmitter from 'eventemitter3';
import { type Keyboard, type KeyboardInterface, type OutputTarget } from '@keymanapp/keyboard-processor';
import { type KeyboardStub } from 'keyman/engine/package-cache';
import { DeviceSpec, ManagedPromise, type Keyboard, type KeyboardInterface, type OutputTarget } from '@keymanapp/keyboard-processor';
import { StubAndKeyboardCache, type KeyboardStub } from 'keyman/engine/package-cache';
import { PredictionContext } from '@keymanapp/input-processor';
interface EventMap {
// target, then keyboard.
'targetchange': (target: OutputTarget) => boolean;
'beforekeyboardchange': (metadata: KeyboardStub, abortChange: () => void) => void;
'keyboardasyncload': (metadata: KeyboardStub, onload: Promise<Error>) => void;
'keyboardchange': (kbd: {keyboard: Keyboard, metadata: KeyboardStub}) => void;
}
@ -18,13 +19,19 @@ export interface ContextManagerConfiguration {
*
* Does not reset option-stores, variable-stores, etc.
*/
readonly resetKeyState: (outputTarget?: OutputTarget) => void;
readonly resetContext: (outputTarget?: OutputTarget) => void;
/**
* A predictive-state management object that interfaces the predictive-text banner
* with the active context.
*/
readonly predictionContext: PredictionContext;
/**
* The stub & keyboard curation cache holding preloaded keyboards and metadata useable
* to load those not yet loaded.
*/
readonly keyboardCache: StubAndKeyboardCache;
}
interface PendingActivation {
@ -34,12 +41,16 @@ interface PendingActivation {
}
export abstract class ContextManagerBase extends EventEmitter<EventMap> {
public static readonly TIMEOUT_THRESHOLD = 10000;
abstract initialize(): void;
abstract get activeTarget(): OutputTarget;
private _predictionContext: PredictionContext;
private _resetKeyState: (outputTarget?: OutputTarget) => void;
protected keyboardCache: StubAndKeyboardCache;
private _resetContext: (outputTarget?: OutputTarget) => void;
private _hostDevice: DeviceSpec;
private pendingActivations: PendingActivation[] = [];
@ -47,18 +58,15 @@ export abstract class ContextManagerBase extends EventEmitter<EventMap> {
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._resetContext = config.resetContext;
this._predictionContext = config.predictionContext;
this.keyboardCache = config.keyboardCache;
}
insertText(kbdInterface: KeyboardInterface, Ptext: string, PdeadKey: number) {
@ -82,16 +90,20 @@ export abstract class ContextManagerBase extends EventEmitter<EventMap> {
}
resetContext() {
this._resetKeyState(this.activeTarget);
this._resetContext(this.activeTarget);
this.predictionContext.resetContext();
}
abstract get activeKeyboard(): {keyboard: Keyboard, metadata: KeyboardStub};
protected abstract get keyboardTarget(): OutputTarget;
// TODO: should `activateKeyboard` (on KeymanEngine) be relocated to within here?
// It seems to make sense, and was originally a goal.
abstract set activeKeyboard(kbd: {keyboard: Keyboard, metadata: KeyboardStub});
abstract setActiveKeyboardAsync(kbd: Promise<Keyboard>, metadata: KeyboardStub): Promise<boolean>;
/**
* Ensures that newly activated keyboards are set correctly within managed context, possibly
* against inactive output targets.
* @param kbd
* @param target
*/
protected abstract setKeyboardActiveForTarget(kbd: {keyboard: Keyboard, metadata: KeyboardStub}, target: OutputTarget);
/**
* Checks the pending keyboard-activation array for an entry corresponding to the specified
@ -100,7 +112,7 @@ export abstract class ContextManagerBase extends EventEmitter<EventMap> {
* May be `null`, which corresponds to the global default Keyboard.
* @returns `true` if pending activation is still valid, `false` otherwise.
*/
private findAndPopActivation(target: OutputTarget): boolean {
private findAndPopActivation(target: OutputTarget): PendingActivation {
// Array.findIndex requires Chrome 45+. :(
let activationIndex;
for(activationIndex = 0; activationIndex < this.pendingActivations.length; activationIndex++) {
@ -110,11 +122,10 @@ export abstract class ContextManagerBase extends EventEmitter<EventMap> {
}
if(activationIndex == this.pendingActivations.length) {
return false;
return null;
}
this.pendingActivations.splice(activationIndex, 1);
return true;
return this.pendingActivations.splice(activationIndex, 1)[0];
}
protected confirmKeyboardChange(metadata: KeyboardStub): boolean {
@ -127,7 +138,11 @@ export abstract class ContextManagerBase extends EventEmitter<EventMap> {
return eventReturn.continue;
}
protected async deferredKeyboardActivationValid(kbdPromise: Promise<Keyboard>, metadata: KeyboardStub, target: OutputTarget): Promise<boolean> {
protected async deferredKeyboardActivation(
kbdPromise: Promise<Keyboard>,
metadata: KeyboardStub,
target: OutputTarget
): Promise<PendingActivation> {
const activation: PendingActivation = {
target: target,
keyboard: kbdPromise,
@ -140,6 +155,155 @@ export abstract class ContextManagerBase extends EventEmitter<EventMap> {
await kbdPromise;
// The keyboard-load is complete; is the activation still desired?
return this.findAndPopActivation(target);
const activationAfterAwait = this.findAndPopActivation(target);
if(activationAfterAwait == activation) {
return activation;
} else {
return null;
}
}
/**
* Change active keyboard to keyboard selected by (internal) name and language code
*
* Test if selected keyboard already loaded, and simply update active stub if so.
* Otherwise, insert a script to download and insert the keyboard from the repository
* or user-indicated file location.
*
* TODO: The old 'recorder' tool stubbed the old _SetActiveKeyboard method, but should now stub this
* instead. Or, perhaps one of the newer internal events.
* @param keyboardId
* @param languageCode
* @param saveCookie
* @returns
*/
public async activateKeyboard(keyboardId: string, languageCode?: string, saveCookie?: boolean): Promise<boolean> {
const activatingKeyboard = this.prepareKeyboardForActivation(keyboardId, languageCode);
const originalKeyboardTarget = this.keyboardTarget;
// Triggers `beforeKeyboardChange` event
// - this.osk.startHide(false), when needed, could be called via handler here...
// and on 'onAsyncKeyboardLoad'
// Also include an 'abort' check based upon it.
if(!this.confirmKeyboardChange(activatingKeyboard.metadata)) {
return false;
}
const keyboard = await activatingKeyboard.keyboard;
if(keyboard == null && activatingKeyboard.metadata) {
// Cancelled - the activation was async and no longer valid.
return false;
}
this.setKeyboardActiveForTarget({
keyboard: keyboard,
metadata: activatingKeyboard.metadata
}, this.keyboardTarget);
// Only trigger `keyboardchange` events when they will affect the active context.
if(this.keyboardTarget == originalKeyboardTarget) {
// Perform standard context-reset ops, including processNewContextEvent.
this.resetContext();
// Will trigger KeymanEngine handler that passes keyboard to the OSK, displays it.
this.emit('keyboardchange', this.activeKeyboard);
}
return true;
}
/**
* Based on the provided keyboard id and language code, selects and (if necessary) loads the
* corresponding keyboard but does not activate it.
*
* This acts as a helper to `activateKeyboard`, helping to centralize and DRY out the actual
* activation of the requested keyboard.
* @param keyboardId
* @param languageCode
* @returns
*/
protected prepareKeyboardForActivation(
keyboardId: string,
languageCode?: string
): {keyboard: Promise<Keyboard>, metadata: KeyboardStub} {
// Set default language code
languageCode ||= '';
// Check that the saved keyboard is currently registered
let requestedStub = this.keyboardCache.getStub(keyboardId, languageCode);
// Mobile device addition: force selection of the first keyboard if none set
if(this._hostDevice.touchable && !requestedStub) {
// Pick the oldest-registered stub as default.
requestedStub = this.keyboardCache.defaultStub;
} else if(!requestedStub) {
return {
keyboard: Promise.resolve(null),
metadata: null
};
}
// Check if current keyboard matches requested keyboard, but not (necessarily) stub
if(keyboardId == this.activeKeyboard.metadata.id) {
const keyboard = this.activeKeyboard.keyboard;
// In this case, the keyboard is loaded; just update the stub.
return {
keyboard: Promise.resolve(keyboard),
metadata: requestedStub
};
}
// Determine if the keyboard was previously loaded but is not active; use the cached, pre-loaded version if so.
let keyboard: Keyboard;
if(keyboard = this.keyboardCache.getKeyboardForStub(requestedStub)) {
return {
keyboard: Promise.resolve(keyboard),
metadata: requestedStub
};
} else {
// It's async time - the keyboard is not preloaded within the cache. Use the stub's data to load it.
// Provide a Promise for completion of the async load process.
const completionPromise = new ManagedPromise<Error>();
this.emit('keyboardasyncload', requestedStub, completionPromise.corePromise);
let keyboardPromise = this.keyboardCache.fetchKeyboardForStub(requestedStub);
let timeoutPromise = new Promise<void>((resolve, reject) => {
const timeoutMsg = `Sorry, the ${requestedStub.name} keyboard for ${requestedStub.langName} is not currently available.`;
window.setTimeout(() => reject(new Error(timeoutMsg)), ContextManagerBase.TIMEOUT_THRESHOLD);
});
let combinedPromise = Promise.race([keyboardPromise, timeoutPromise]);
// Ensure the async-load Promise completes properly.
combinedPromise.then(() => completionPromise.resolve(null));
combinedPromise.catch((err) => {
completionPromise.resolve(err);
throw err;
});
// Now the fun part: note the original call's parameters as a pending activation.
let promise = this.deferredKeyboardActivation(keyboardPromise, requestedStub, this.keyboardTarget);
return {
keyboard: promise.then(async (activation) => {
// Is the activation we requested still pending, or was it cancelled in favor of a
// different activation in some manner?
if(!activation) {
// If the user chose to load a different keyboard afterward that would affect the same
// output target, the activation is no longer valid.
return Promise.resolve(null);
} else if(activation.target == this.keyboardTarget && !this.confirmKeyboardChange(requestedStub)) {
// If still valid, but it would affect the active output target, we provide another chance
// to cancel the keyboard change - after all, we're in an async op.
return Promise.resolve(null);
} else {
// If still valid but it won't affect the currently-active output target, we don't ask to verify.
// It wouldn't affect the active context, so a corresponding event would be too unclear / confusing.
return keyboardPromise;
}
}),
metadata: requestedStub
}
}
}
}

View file

@ -97,10 +97,11 @@ export default class KeymanEngine<
this.processor = new InputProcessor(config.hostDevice, worker, this.processorConfiguration());
this.contextManager.configure({
resetKeyState: (target) => {
resetContext: (target) => {
this.processor.keyboardProcessor.resetContext(target);
},
predictionContext: new PredictionContext(this.processor.languageProcessor, this.processor.keyboardProcessor)
predictionContext: new PredictionContext(this.processor.languageProcessor, this.processor.keyboardProcessor),
keyboardCache: this.keyboardRequisitioner.cache
});
// TODO: configure that context-manager!
@ -143,11 +144,44 @@ export default class KeymanEngine<
contextManager.on('keyboardchange', (kbd) => {
this.refreshModel();
// Hide OSK and do not update keyboard list if using internal keyboard (desktops).
// Condition will not be met for touch form-factors; they force selection of a
// default keyboard.
if(kbd.keyboard == null && kbd.metadata == null) {
this.osk.startHide(false);
}
if(this.osk) {
this.osk.setNeedsLayout();
this.osk.activeKeyboard = kbd;
this.osk.present();
}
});
contextManager.on('keyboardasyncload', (metadata) => {
/* Original implementation pre-modularization:
*
* > Force OSK display for CJK keyboards (keyboards using a pick list)
*
* A matching subcondition in the block below will ensure that the OSK activates pre-load
* for CJK keyboards. Yes, even before a CJK picker could ever show. We should be fine
* without the CJK check so long as a picker keyboard's OSK is kept activated post-load,
* when the picker actually needs to be kept persistently-active.
* `metadata` would be relevant a the CJK-check, which was based on language codes.
*
* Of course, as mobile devices don't have guaranteed physical keyboards... we need to
* keep the OSK visible for them, hence the actual block below.
*/
if(this.config.hostDevice.touchable && this.osk?.activationModel) {
this.osk.activationModel.enabled = true;
// Also note: the OSKView.mayDisable method returns false when hostDevice.touchable = false.
// The .startHide() call below will check that method before actually starting an OSK hide.
}
// Always (temporarily) hide the OSK when loading a new keyboard, to ensure
// that a failure to load doesn't leave the current OSK displayed
this.osk?.startHide(false);
})
// #endregion
}
@ -235,111 +269,9 @@ export default class KeymanEngine<
}
}
async setActiveKeyboard(keyboardId: string, languageCode?: string): Promise<void> {
return this.activateKeyboard(keyboardId, languageCode, true);
async setActiveKeyboard(keyboardId: string, languageCode?: string): Promise<boolean> {
return this.contextManager.activateKeyboard(keyboardId, languageCode, true);
}
protected async activateKeyboard(keyboardId: string, languageCode?: string, saveCookie?: boolean): Promise<void> {
saveCookie ||= false;
// TODO: beforeKeyboardChange
// - this.osk.startHide(false), when needed, could be called via handler here...
// and on 'onAsyncKeyboardLoad'
// Also include an 'abort' check based upon it.
this.contextManager.activeKeyboard = await this.prepareKeyboardForActivation(keyboardId, languageCode);
// TODO: keyboardChange
// - this.osk.present() could totally be part of the handler for the event.
this.osk.present();
}
/**
* Based on the provided keyboard id and language code, selects and (if necessary) loads the
* corresponding keyboard but does not activate it.
*
* This acts as a helper to `activateKeyboard`, helping to centralize and DRY out the actual
* activation of the requested keyboard.
* @param keyboardId
* @param languageCode
* @returns
*/
protected async prepareKeyboardForActivation(
keyboardId: string,
languageCode?: string
): Promise<{keyboard: Keyboard, metadata: KeyboardStub}> {
// Set default language code
languageCode ||= '';
// Check that the saved keyboard is currently registered
let requestedStub = this.keyboardRequisitioner.cache.getStub(keyboardId, languageCode);
// Mobile device addition: force selection of the first keyboard if none set
if(this.config.softDevice.touchable && !requestedStub) {
// Pick the oldest-registered stub as default.
requestedStub = this.keyboardRequisitioner.cache.defaultStub;
} else if(!requestedStub) {
// Hide OSK and do not update keyboard list if using internal keyboard (desktops)
this.osk?.startHide(false);
return Promise.resolve({
keyboard: null,
metadata: null
});
}
// Check if current keyboard matches requested keyboard, but not (necessarily) stub
if(keyboardId == this.contextManager.activeKeyboard.metadata.id) {
const keyboard = this.contextManager.activeKeyboard.keyboard;
// In this case, the keyboard is loaded; just update the stub.
return Promise.resolve({
keyboard: keyboard,
metadata: requestedStub
});
}
// Determine if the keyboard was previously loaded but is not active and use the prior load if so.
let keyboard: Keyboard;
if(keyboard = this.keyboardRequisitioner.cache.getKeyboardForStub(requestedStub)) {
return Promise.resolve({
keyboard: keyboard,
metadata: requestedStub
});
} else {
// async time - the keyboard has not yet been loaded.
// Original implementation: also checked for CJK and kept OSKs activated pre-load,
// before the picker could ever show. We should be fine without it so long as
// a picker keyboard's OSK is kept activated post-load.
if(this.config.hostDevice.touchable && this.osk?.activationModel) {
this.osk.activationModel.enabled = true;
}
this.osk?.startHide(false);
// TODO: Maybe make this an event of sorts? ... which means extending EventEmitter, etc.
// We aren't adding a 'new' event API set quite yet, though.
this.onKeyboardAsyncLoadStart(requestedStub);
let keyboardPromise = this.keyboardRequisitioner.cache.fetchKeyboardForStub(requestedStub);
let promise = this.contextManager.setActiveKeyboardAsync(keyboardPromise, requestedStub);
return promise.then(async (stillValid) => {
if(!stillValid) {
return Promise.resolve(null);
}
return {
keyboard: await keyboardPromise,
metadata: requestedStub
};
});
}
}
protected onKeyboardAsyncLoadStart(requestedStub: KeyboardStub) { }
}
// Intent: define common behaviors for both primary app types; each then subclasses & extends where needed.

View file

@ -189,315 +189,6 @@ namespace com.keyman.keyboards {
return p;
}
/**
* Change active keyboard to keyboard selected by (internal) name and language code
*
* Test if selected keyboard already loaded, and simply update active stub if so.
* Otherwise, insert a script to download and insert the keyboard from the repository
* or user-indicated file location.
*
* Note that the test-case oriented 'recorder' stubs this method to provide active
* keyboard stub information. If changing this function, please ensure the recorder is
* not affected.
*
* @param {string} PInternalName
* @param {string=} PLgCode
* @param {boolean=} saveCookie
*/
_SetActiveKeyboard(PInternalName: string, PLgCode?: string, saveCookie?: boolean): Promise<void> {
let n;
let keyman = com.keyman.singleton;
var util = keyman.util;
var domManager = keyman.domManager;
var osk = keyman.osk;
let activeKeyboard = keyman.core.activeKeyboard;
// Set default language code
if(arguments.length < 2 || (!PLgCode)) {
PLgCode='---';
}
// Check that the saved keyboard is currently registered
for(n=0; n < this.keyboardStubs.length; n++) {
if(PInternalName == this.keyboardStubs[n]['KI']) {
if(PLgCode == this.keyboardStubs[n]['KLC'] || PLgCode == '---') break;
// This 'n' is used in the condition for the block below... and that's all.
}
}
// Mobile device addition: force selection of the first keyboard if none set
if(util.device.touchable && (PInternalName == '' || PInternalName == null || n >= this.keyboardStubs.length)) {
if(this.keyboardStubs.length != 0) {
PInternalName=this.keyboardStubs[0]['KI'];
PLgCode=this.keyboardStubs[0]['KLC'];
}
}
// Save name of keyboard (with language code) as a cookie
if(arguments.length > 2 && saveCookie) {
this.saveCurrentKeyboard(PInternalName,PLgCode);
}
// Check if requested keyboard and stub are currently active
if(this.activeStub && activeKeyboard && activeKeyboard.id == PInternalName
&& this.activeStub['KI'] == PInternalName //this part of test should not be necessary, but keep anyway
&& this.activeStub['KLC'] == PLgCode && !this.keymanweb.mustReloadKeyboard
) return Promise.resolve();
// Check if current keyboard matches requested keyboard, but not stub
if(activeKeyboard && (activeKeyboard.id == PInternalName)) {
// If so, simply update the active stub
for(let Ln=0; Ln<this.keyboardStubs.length; Ln++) {
if((this.keyboardStubs[Ln]['KI'] == PInternalName)
&& (this.keyboardStubs[Ln]['KLC'] == PLgCode)) {
this.activeStub = this.keyboardStubs[Ln];
// Append a stylesheet for this keyboard for keyboard specific styles
// or if needed to specify an embedded font
osk.vkbd?.appendStyleSheet();
// Re-initializate OSK before returning if required
if(this.keymanweb.mustReloadKeyboard) {
activeKeyboard.refreshLayouts();
if(osk) {
osk._Load();
util.addStyleSheet(domManager.setAttachmentFontStyle(this.activeStub.KF));
}
}
return Promise.resolve();
}
}
// huh, does not verify that we matched a stub. Weird.
}
keyman.core.activeKeyboard = null;
this.activeStub = null;
// Hide OSK and do not update keyboard list if using internal keyboard (desktops)
if(PInternalName == '') {
osk.startHide(false);
if(!this.keymanweb.isEmbedded) {
util.wait(false);
}
return Promise.resolve();
}
// Determine if the keyboard was previously loaded but is not active and use the prior load if so.
for(let Ln=0; Ln<this.keyboards.length; Ln++) { // I1511 - array prototype extended
if(this.keyboards[Ln]['KI'] == PInternalName) {
keyman.core.activeKeyboard = new Keyboard(this.keyboards[Ln]);
// As a rotation may have occurred since the keyboard was swapped out,
// we should refresh its layouts.
keyman.core.activeKeyboard.refreshLayouts();
this.keymanweb.domManager._SetTargDir(this.keymanweb.domManager.lastActiveElement); // I2077 - LTR/RTL timing
// and update the active stub
for(var Ls=0; Ls<this.keyboardStubs.length; Ls++) {
if((this.keyboardStubs[Ls]['KI'] == PInternalName) &&
(this.keyboardStubs[Ls]['KLC'] == PLgCode || PLgCode == '---')) {
this.activeStub = this.keyboardStubs[Ls];
break;
}
}
break;
}
}
// If we've reached this point, this is the first load request for the requested keyboard.
if(keyman.core.activeKeyboard == null) {
for(let Ln=0; Ln<this.keyboardStubs.length; Ln++) { // I1511 - array prototype extended
if((this.keyboardStubs[Ln]['KI'] == PInternalName)
&& ((this.keyboardStubs[Ln]['KLC'] == PLgCode) || (PLgCode == '---'))) {
// Force OSK display for CJK keyboards (keyboards using a pick list)
if(this.isCJK(this.keyboardStubs[Ln]) || util.device.touchable) {
osk.displayIfActive = true;
}
// Create a script to load from the server - when it finishes loading, it will register itself,
// detect that it is active, and focus as appropriate. The second test is needed to allow recovery from a failed script load
// Ensure we're not already loading the keyboard.
if(!this.keyboardStubs[Ln].asyncLoader) {
// Always (temporarily) hide the OSK when loading a new keyboard, to ensure that a failure to load doesn't leave the current OSK displayed
if(osk) {
osk.startHide(false);
}
var loadingStub = this.keyboardStubs[Ln];
// Tag the stub so that we don't double-load the keyboard!
loadingStub.asyncLoader = {};
var kbdName = loadingStub['KN'];
var lngName = loadingStub['KL'];
kbdName = kbdName.replace(/\s*keyboard\s*/i, '');
// Setup our default error-messaging callback if it should be implemented.
loadingStub.asyncLoader.callback = function(altString, msgType) {
var msg = altString || 'Sorry, the '+kbdName+' keyboard for '+lngName+' is not currently available.';
// Thanks, Closure errors.
if(!this.keymanweb.isEmbedded) {
util.wait(false);
util.internalAlert(altString || msg, function() {
this.keymanweb['setActiveKeyboard'](''); // The API call!
}.bind(this));
}
switch(msgType) { // in case we extend this later.
case 'err':
console.error(msg);
break;
case 'warn':
default:
console.warn(msg);
break;
}
if(Ln > 0) {
var Ps = this.keyboardStubs[0];
this._SetActiveKeyboard(Ps['KI'], Ps['KLC'], true);
}
}.bind(this);
loadingStub.asyncLoader.timer = window.setTimeout(loadingStub.asyncLoader.callback, 10000);
//Display the loading delay bar (Note: only append 'keyboard' if not included in name.)
if(!this.keymanweb.isEmbedded) {
util.wait('Installing keyboard<br/>' + kbdName);
}
// Installing the script immediately does not work reliably if two keyboards are
// loaded in succession if there is any delay in downloading the script.
// It works much more reliably if deferred (KMEW-101, build 356)
// The effect of a delay can also be tested, for example, by setting the timeout to 5000
var manager = this;
loadingStub.asyncLoader.promise = new Promise<void>(function(resolve, reject) {
window.setTimeout(function(){
manager.installKeyboard(resolve, reject, loadingStub);
// To be modularized: activation after the keyboard's loaded.
},0);
});
}
this.activeStub=this.keyboardStubs[Ln];
return this.keyboardStubs[Ln].asyncLoader.promise;
}
}
this.keymanweb.domManager._SetTargDir(this.keymanweb.domManager.lastActiveElement); // I2077 - LTR/RTL timing
}
// Initialize the OSK (provided that the base code has been loaded)
if(osk) {
osk._Load();
util.addStyleSheet(domManager.setAttachmentFontStyle(this.activeStub.KF));
}
return Promise.resolve();
}
/**
* Install a keyboard script that has been downloaded from a keyboard server
* Operates as the core of a Promise, hence the 'resolve' and 'reject' parameters.
*
* @param {Object} kbdStub keyboard stub to be loaded.
*
**/
installKeyboard(resolve: () => void, reject: (message?: string) => void, kbdStub: KeyboardStub) {
var util = this.keymanweb.util;
var osk = this.keymanweb.osk;
var Lscript = util._CreateElement('script');
Lscript.charset="UTF-8"; // KMEW-89
Lscript.type = 'text/javascript';
// Preserve any namespaced IDs by use of the script's id tag attribute!
if(this.keymanweb.isEmbedded) {
Lscript.id = kbdStub['KI'];
}
var kbdFile = kbdStub['KF'];
var kbdLang = kbdStub['KL'];
var kbdName = kbdStub['KN'];
const scriptSrc = this.keymanweb.getKeyboardPath(kbdFile);
var manager = this;
let core = com.keyman.singleton.core;
let domManager = com.keyman.singleton.domManager;
// Add a handler for cases where the new <script> block fails to load.
Lscript.addEventListener('error', function() {
// Omitted: parts that are now handled modularly.
});
// The load event will activate a newly-loaded keyboard if successful and report an error if it is not.
Lscript.addEventListener('load', function() {
// Omitted: parts that are now handled modularly.
// To determine if the load was successful, we'll need to check the keyboard array for our desired keyboard.
// Test if keyboard already loaded
var kbd = manager.getKeyboardByID(kbdStub['KI']);
if(kbd) { // Is cleared upon a successful load.
//Activate keyboard, if it's still the active stub.
if(kbdStub == manager.activeStub) {
manager.doBeforeKeyboardChange(kbd['KI'],kbdStub['KLC']);
core.activeKeyboard=new Keyboard(kbd);
if(manager.keymanweb.domManager.lastActiveElement != null) { // TODO: Resolve without need for the cast.
manager.keymanweb.uiManager.justActivated = true; // TODO: Resolve without need for the cast.
manager.keymanweb.domManager._SetTargDir(manager.keymanweb.domManager.lastActiveElement);
}
manager.saveCurrentKeyboard(kbd['KI'], kbdStub['KLC']);
// Prepare and show the OSK for this keyboard
if(osk) {
osk._Load();
util.addStyleSheet(domManager.setAttachmentFontStyle(kbdStub.KF));
}
if(manager.keymanweb.domManager.lastActiveElement != null) {
core.processNewContextEvent(dom.Utils.getOutputTarget(manager.keymanweb.domManager.lastActiveElement));
}
}
// Remove the wait message, if defined
if(!manager.keymanweb.isEmbedded) {
util.wait(false);
}
kbdStub.asyncLoader = null;
resolve();
// A handler portion for cases where the new <script> block loads, but fails to process.
} else { // Output error messages even when embedded - they're useful when debugging the apps and KMEA/KMEI engines.
// Omitted: parts that are now handled modularly.
}
}, false);
// Some browsers may instantly start loading the file when assigned to an element, so we do this after the rest
// of our setup. This method is not relocated here (yet) b/c it varies based upon 'native' vs 'embedded'.
Lscript.src = scriptSrc;
try {
document.body.appendChild(Lscript);
}
catch(ex) {
try {
document.getElementsByTagName('head')[0].appendChild(Lscript);
} catch(ex2) {
reject('Error registering script ' + scriptSrc + ': ' + ex2);
return;
}
}
this.linkedScripts.push(Lscript);
}
/* TODO: why not use util.loadCookie and saveCookie?? */
/**

View file

@ -134,7 +134,7 @@ export default class StubAndKeyboardCache extends EventEmitter<EventMap> {
throw new Error(`The registered stub for ${withoutPrefix(keyboardID)} lacks a path to the main keyboard file`);
}
const promise = this.keyboardLoader.loadKeyboardFromPath(stub.filename);
const promise = this.keyboardLoader.loadKeyboardFromStub(stub);
this.keyboardTable[keyboardID] = promise;
promise.then((kbd) => {