import Codes from "../text/codes.js"; import KeyEvent, { KeyEventSpec } from "../text/keyEvent.js"; import KeyMapping from "../text/keyMapping.js"; import type { KeyDistribution } from "../text/keyEvent.js"; import { ButtonClasses, Layouts } from "./defaultLayouts.js"; import type { LayoutKey, LayoutSubKey, LayoutRow, LayoutLayer, LayoutFormFactor, ButtonClass } from "./defaultLayouts.js"; import type Keyboard from "./keyboard.js"; import { TouchLayout } from "@keymanapp/common-types"; import TouchLayoutDefaultHint = TouchLayout.TouchLayoutDefaultHint; import TouchLayoutFlick = TouchLayout.TouchLayoutFlick; import TouchLayoutSpec = TouchLayout.TouchLayoutPlatform; import TouchLayerSpec = TouchLayout.TouchLayoutLayer; import { type DeviceSpec } from "@keymanapp/web-utils"; // TS 3.9 changed behavior of getters to make them // non-enumerable by default. This broke our 'polyfill' // functions which depended on enumeration to copy the // relevant props over. // https://github.com/microsoft/TypeScript/pull/32264#issuecomment-677718191 function Enumerable( target: unknown, propertyKey: string, descriptor: PropertyDescriptor ) { descriptor.enumerable = true; }; /** * Designed for use by call-by-reference objects during keyboard-load preprocessing * to note properties of a keyboard that are only specified at lower levels of the * layout object. */ interface AnalysisMetadata { hasFlicks: boolean; hasMultitaps: boolean; hasLongpresses: boolean; } /** A map of key field names with values matching the `typeof` the corresponding property * seen in keyman-touch-layout-file.ts from common/web/types. * * Make sure that when one is updated, the other also is. TS types are compile-time only, * so this run-time-accessible mapping cannot be auto-generated by TS. */ const KeyTypesOfKeyMap = { id: 'string', text: 'string', layer: 'string', nextlayer: 'string', font: 'string', fontsize: 'string', sp: 'number', pad: 'number', width: 'number', sk: 'subkeys', flick: 'flicks', multitap: 'subkeys', hint: 'string', default: 'boolean' } as const; // Keep in this specific order: it's the ordering of priority for default hint selection when // based on available hints. (i.e., `layout.defaultHint == 'flick'`) const KeyTypesOfFlickList = ['n', 'ne', 'e', 'se', 's', 'sw', 'w', 'nw'] as const; export class ActiveKeyBase { static readonly DEFAULT_PAD=15; // Padding to left of key, in virtual units static readonly DEFAULT_RIGHT_MARGIN=15; // Padding to right of right-most key, in virtual units static readonly DEFAULT_KEY_WIDTH=100; // Width of a key, if not specified, in virtual units // Defines key defaults static readonly DEFAULT_KEY = { text: '', width: ActiveKeyBase.DEFAULT_KEY_WIDTH, sp: ButtonClasses.normal, pad: ActiveKeyBase.DEFAULT_PAD }; /** WARNING - DO NOT USE DIRECTLY outside of @keymanapp/keyboard-processor! */ id: TouchLayout.TouchLayoutKeyId; text: string; hint?: string; hintSrc?: TouchLayout.TouchLayoutSubKey | TouchLayout.TouchLayoutKey; font?: string; fontsize?: string; // These are fine. width?: number; pad?: number; layer: string; displayLayer: string; nextlayer: string; sp?: ButtonClass; private _baseKeyEvent: KeyEvent | (() => KeyEvent); isMnemonic: boolean = false; /** * Only available on subkeys, but we don't distinguish between base keys and subkeys * at this level yet in KMW. */ default?: boolean; proportionalPad: number; proportionalX: number; proportionalWidth: number; // While they're only valid on ActiveKey, spec'ing them here makes references more concise within the OSK. sk?: ActiveSubKey[]; multitap?: ActiveSubKey[]; flick?: TouchLayout.TouchLayoutFlick; // Keeping things simple here, as this was added LATE in 14.0 beta. // Could definitely extend in the future to instead return an object // that denotes the 'nature' of the key. // - isUnicode // - isHardwareKey // - etc. // Reference for the terminology in the comments below: // https://help.keyman.com/developer/current-version/guides/develop/creating-a-touch-keyboard-layout-for-amharic-the-nitty-gritty /** * Matches the key code as set within Keyman Developer for the layout. * For example, K_R or U_0020. Denotes either physical keys or virtual keys with custom output, * with no additional metadata like layer or active modifiers. * * Is used to determine the keycode for input events, rule-matching, and keystroke processing. */ @Enumerable public get baseKeyID(): string { if(typeof this.id === 'undefined') { return undefined; } return this.id; } @Enumerable public get isPadding(): boolean { // Does not include 9 (class: blank) as that may be an intentional 'catch' for misplaced // keystrokes. return this.sp == ButtonClasses.spacer; // Button class: hidden. } /** * A unique identifier based on both the key ID & the 'desktop layer' to be used for the key. * * Allows diambiguation of scenarios where the same key ID is used twice within a layer, but * with different innate modifiers. (Refer to https://github.com/keymanapp/keyman/issues/4617) * The 'desktop layer' may be omitted if it matches the key's display layer. * * Examples, given a 'default' display layer, matching keys to Keyman keyboard language: * * ``` * "K_Q" * + [K_Q] * "K_Q+shift" * + [K_Q SHIFT] * ``` * * Useful when the active layer of an input-event is already known. */ @Enumerable public get coreID(): string { if(typeof this.id === 'undefined') { return undefined; } let baseID = this.id || ''; if(this.displayLayer != this.layer) { baseID = baseID + '+' + this.layer; } return baseID; } /** * A keyboard-unique identifier to be used for any display elements representing this key * in user interfaces and/or on-screen keyboards. * * Distinguishes between otherwise-identical keys on different layers of an OSK. * Includes identifying information about the key's display layer. * * Examples, given a 'default' display layer, matching keys to Keyman keyboard language: * * ``` * "default-K_Q" * + [K_Q] * "default-K_Q+shift" * + [K_Q SHIFT] * ``` * * Useful when only the active keyboard is known about an input event. */ @Enumerable public get elementID(): string { if(typeof this.id === 'undefined') { return undefined; } return this.displayLayer + '-' + this.coreID; } @Enumerable public get baseKeyEvent(): KeyEvent { let val = this._baseKeyEvent; if(typeof val == 'function') { val = val(); } return new KeyEvent(val); } constructor(); constructor(spec: LayoutKey | LayoutSubKey, layout: ActiveLayout, displayLayer: string); constructor(spec?: LayoutKey | LayoutSubKey, layout?: ActiveLayout, displayLayer?: string) { // First things first: this class's fields are designed to match that of the spec. Object.assign(this, spec); if(!this.text && typeof this.id == 'string') { this.text = ActiveKey.unicodeIDToText(this.id); } this.displayLayer = displayLayer; this.layer = this.layer || displayLayer; // Compute the key's base KeyEvent properties for use in future event generation // It's actually somewhat expensive to do this at the start, so we do a lazy-init. this._baseKeyEvent = () => this.constructBaseKeyEvent(layout, displayLayer); } /** * Converts key IDs of the U_* form to their corresponding UTF-16 text. * If an ID not matching the pattern is received, returns null. * @param id * @returns */ static unicodeIDToText(id: string, errorCallback?: (codeAsString: string) => void) { if(!id || id.substring(0,2) != 'U_') { return null; } let result = ''; const codePoints = id.substring(2).split('_'); for(let codePoint of codePoints) { const codePointValue = parseInt(codePoint, 16); if (((0x0 <= codePointValue) && (codePointValue <= 0x1F)) || ((0x80 <= codePointValue) && (codePointValue <= 0x9F)) || isNaN(codePointValue)) { if(errorCallback) { errorCallback(codePoint); } continue; } else { // String.fromCharCode() is inadequate to handle the entire range of Unicode // Someday after upgrading to ES2015, can use String.fromCodePoint() result += String.kmwFromCharCode(codePointValue); } } return result ? result : null; } static sanitize(rawKey: LayoutKey) { // In older versions of KeymanWeb, we specified these three properties as strings... // despite them holding a numerical value. if(typeof rawKey.width == 'string') { rawKey.width = parseInt(rawKey.width, 10); } // Handles NaN cases as well as 'set to 0' cases; both are intentional here. rawKey.width ||= ActiveKey.DEFAULT_KEY_WIDTH; if(typeof rawKey.pad == 'string') { rawKey.pad = parseInt(rawKey.pad, 10); } rawKey.pad ||= ActiveKey.DEFAULT_PAD; if(typeof rawKey.sp == 'string') { rawKey.sp = Number.parseInt(rawKey.sp, 10) as ButtonClass; } rawKey.sp ||= ActiveKey.DEFAULT_KEY.sp; // The default button class. // And now for generalized type validation. ----------------------------------------- // WARNING: Object.values and Object.entries is NOT polyfilled by es6-shim and thus // is NOT available within the Android app in extremely early APIs. // Object.entries requires Android 54. for(const key of Object.keys(KeyTypesOfKeyMap)) { const value = KeyTypesOfKeyMap[key as keyof typeof KeyTypesOfKeyMap]; switch(value) { case 'subkeys': const arr = rawKey[key] as LayoutSubKey[]; if(arr === undefined) { break; } else if(!Array.isArray(arr)) { delete rawKey[key]; } else { for(let i=0; i < arr.length; i++) { const sk = arr[i]; if(typeof sk != 'object') { arr.splice(i--, 1); } else { ActiveKey.sanitize(sk); } } } break; case 'flicks': const flickObj = rawKey[key]; if(flickObj === undefined) { break; } else if(typeof flickObj != 'object') { delete rawKey[key]; } else { for(const flickKey of KeyTypesOfFlickList) { const sk = flickObj[flickKey]; if(sk === undefined) { break; } else if(typeof sk != 'object') { delete flickObj[flickKey]; } else { ActiveKey.sanitize(sk); } } } break; default: const prop = rawKey[key]; if(prop !== undefined && typeof prop != value) { delete rawKey[key]; } } } rawKey.text ||= ActiveKey.DEFAULT_KEY.text; } @Enumerable private constructBaseKeyEvent(layout: ActiveLayout, displayLayer: string) { // Get key name and keyboard shift state (needed only for default layouts and physical keyboard handling) // Note - virtual keys should be treated case-insensitive, so we force uppercasing here. let layer = this.layer || displayLayer || ''; let keyName= this.id ? this.id.toUpperCase() : null; // Start: mirrors _GetKeyEventProperties // First check the virtual key, and process shift, control, alt or function keys let props: KeyEventSpec = { // Override key shift state if specified for key in layout (corrected for popup keys KMEW-93) Lmodifiers: Codes.getModifierState(layer), Lstates: Codes.getStateFromLayer(layer), Lcode: keyName ? Codes.keyCodes[keyName] : 0, LisVirtualKey: true, vkCode: 0, kName: keyName, kLayer: layer, kbdLayer: displayLayer, kNextLayer: this.nextlayer, device: null, isSynthetic: true }; let Lkc: KeyEvent = new KeyEvent(props); if(layout.keyboard) { let keyboard = layout.keyboard; // Include *limited* support for mnemonic keyboards (Sept 2012) // If a touch layout has been defined for a mnemonic keyout, do not perform mnemonic mapping for rules on touch devices. if(keyboard.isMnemonic && !(layout.isDefault && layout.formFactor != 'desktop')) { if(Lkc.Lcode != Codes.keyCodes['K_SPACE']) { // exception required, March 2013 // Jan 2019 - interesting that 'K_SPACE' also affects the caps-state check... Lkc.vkCode = Lkc.Lcode; this.isMnemonic = true; } } else { Lkc.vkCode=Lkc.Lcode; } // Support version 1.0 KeymanWeb keyboards that do not define positional vs mnemonic if(!keyboard.definesPositionalOrMnemonic) { Lkc.Lcode = KeyMapping._USKeyCodeToCharCode(Lkc); Lkc.LisVirtualKey=false; } } return Lkc; } } export class ActiveKey extends ActiveKeyBase implements LayoutKey { public getSubkey(coreID: string): ActiveSubKey { if(this.sk) { for(let key of this.sk) { if(key.coreID == coreID) { return key; } } } return null; } constructor(); constructor(spec: LayoutKey, layout: ActiveLayout, displayLayer: string); constructor(spec?: LayoutKey, layout?: ActiveLayout, displayLayer?: string) { super(spec, layout, displayLayer); // Ensure subkeys are also properly extended. const sk = this.sk; if(sk) { for(let i=0; i < sk.length; i++) { sk[i] = new ActiveSubKey(sk[i], layout, displayLayer); } } // Also multitap keys. const multitap = this.multitap; if(multitap) { for(let i=0; i < multitap.length; i++) { multitap[i] = new ActiveSubKey(multitap[i], layout, displayLayer); } } const flick = this.flick; if(flick) { for(let flickKey in flick) { flick[flickKey] = new ActiveSubKey(flick[flickKey], layout, displayLayer); } } ActiveKey.determineHint(this, layout.defaultHint); } private static determineHint(spec: ActiveKey, defaultHint: TouchLayout.TouchLayoutDefaultHint): void { // If a hint was directly specified, don't override it. if(spec.hint) { spec.hintSrc = spec; return; } // Is more compact than writing 8 separate cases. if(defaultHint?.includes('flick-')) { if(spec.flick) { // 6 = length of 'flick-' const dir = defaultHint.substring(6); if(spec.flick[dir]?.text) { spec.hintSrc = spec.flick[dir]; } } return; } switch(defaultHint) { case 'none': return; case 'multitap': if(spec.multitap) { spec.hintSrc = spec.multitap[0]; } return; case 'flick': if(spec.flick) { for(const key of KeyTypesOfFlickList) { if(spec.flick[key]) { spec.hintSrc = spec.flick[key]; return; } } } return; case 'longpress': if(spec.sk) { spec.hintSrc = spec.sk[0]; } return; case 'dot': default: if(spec.sk) { spec.hint = '\u2022'; spec.hintSrc = spec; } return; } } } export class ActiveSubKey extends ActiveKeyBase implements LayoutSubKey { // } export class ActiveRow implements LayoutRow { // Identify key labels (e.g. *Shift*) that require the special OSK font static readonly SPECIAL_LABEL=/\*\w+\*/; id: number; key: ActiveKey[]; /** * Used for calculating fat-fingering offsets. */ proportionalY: number; private constructor() { } static sanitize(rawRow: LayoutRow) { for(const key of rawRow.key) { // Test for a trailing comma included in spec, added as null object by IE // It has only ever appeared at the end of a row's spec. if(key == null) { rawRow.key.length = rawRow.key.length-1; } else { ActiveKey.sanitize(key); } } if(typeof rawRow.id == 'string') { rawRow.id = Number.parseInt(rawRow.id, 10); } } static polyfill( row: LayoutRow, keyboard: Keyboard, layout: ActiveLayout, displayLayer: string, totalWidth: number, proportionalY: number, analysisFlagObj: AnalysisMetadata ) { // Apply defaults, setting the width and other undefined properties for each key let keys=row['key']; for(let j=0; j 0) { const finalKey = keys[keys.length-1] as ActiveKey; // If a single key, and padding is negative, add padding to right align the key if(keys.length == 1 && finalKey.pad < 0) { const keyPercent = finalKey.width/totalWidth; const padPercent = 1-(totalPercent + keyPercent + rightMargin); // compute center's default x-coord (used in headless modes) setProportions(finalKey, padPercent, keyPercent, totalPercent); } else { const padPercent = finalKey.pad/totalWidth; const keyPercent = 1-(totalPercent + padPercent + rightMargin); // compute center's default x-coord (used in headless modes) setProportions(finalKey, padPercent, keyPercent, totalPercent); } } // Add class functions to the existing layout object, allowing it to act as an ActiveLayout. let dummy = new ActiveRow(); for(let key in dummy) { if(!row.hasOwnProperty(key)) { row[key] = dummy[key]; } } let aRow = row as ActiveRow; aRow.proportionalY = proportionalY; } @Enumerable populateKeyMap(map: {[keyId: string]: ActiveKey}) { this.key.forEach(function(key: ActiveKey) { if(key.coreID) { map[key.coreID] = key; } }); } } export class ActiveLayer implements LayoutLayer { row: ActiveRow[]; id: string; // These already exist on the objects, pre-polyfill... // but they still need to be proactively declared on this type. capsKey?: ActiveKey; numKey?: ActiveKey; scrollKey?: ActiveKey; totalWidth: number; defaultKeyProportionalWidth: number; rowProportionalHeight: number; /** * Facilitates mapping key id strings to their specification objects. */ keyMap: {[keyId: string]: ActiveKey}; constructor() { } static sanitize(rawLayer: LayoutLayer) { for(const row of rawLayer.row) { ActiveRow.sanitize(row); } } static polyfill(layer: LayoutLayer, keyboard: Keyboard, layout: ActiveLayout, analysisFlagObj: AnalysisMetadata) { layer.aligned=false; // Create a DIV for each row of the group let rows=layer['row']; // Calculate the maximum row width (in layout units) let totalWidth=0; for(const row of rows) { let width=0; const keys=row['key']; for(const key of keys) { // So long as `sanitize` is called first, these coercions are safe. width += (key.width as number) + (key.pad as number); } if(width > totalWidth) { totalWidth = width; } } // Add default right margin if(layout.formFactor == 'desktop') { totalWidth += 5; // TODO: resolve difference between touch and desktop; why don't we use ActiveKey.DEFAULT_RIGHT_MARGIN? } else { totalWidth += ActiveKey.DEFAULT_RIGHT_MARGIN; } let rowCount = layer.row.length; for(let i=0; i 1) { let baseKey = this.keyMap[idComponents[0]]; return baseKey.getSubkey(idComponents[1]); } else { return this.keyMap[keyId]; } } } export class ActiveLayout implements LayoutFormFactor{ layer: ActiveLayer[]; font: string; keyLabels: boolean; isDefault?: boolean; keyboard: Keyboard; formFactor: DeviceSpec.FormFactor; defaultHint: TouchLayoutDefaultHint; hasFlicks: boolean = false; hasLongpresses: boolean = false; hasMultitaps: boolean = false; /** * Facilitates mapping layer id strings to their specification objects. */ private layerMap: {[layerId: string]: ActiveLayer}; private constructor() { } @Enumerable getLayer(layerId: string): ActiveLayer { return this.layerMap[layerId]; } /** * Refer to https://github.com/keymanapp/keyman/issues/254, which mentions * KD-11 from a prior issue-tracking system from the closed-source days that * resulted in an unintended extra empty row. * * It'll be pretty rare to see a keyboard affected by the bug, but we don't * 100% control all keyboards out there, so it's best we make sure the edge * case is covered. * * @param layers The layer group to be loaded for the form factor. Will be * mutated by this operation. */ static correctLayerEmptyRowBug(layers: LayoutLayer[]) { for(let n=0; n=0; i--) { if(!Array.isArray(rows[i]['key']) || rows[i]['key'].length == 0) { rows.splice(i, 1) } } } } static sanitize(rawLayout: TouchLayoutSpec) { ActiveLayout.correctLayerEmptyRowBug(rawLayout.layer); for(const layer of rawLayout.layer) { ActiveLayer.sanitize(layer); } } /** * * @param layout * @param formFactor */ static polyfill(layout: TouchLayoutSpec, keyboard: Keyboard, formFactor: DeviceSpec.FormFactor): ActiveLayout { /* c8 ignore start */ if(layout == null) { throw new Error("Cannot build an ActiveLayout for a null specification."); } /* c8 ignore end */ const analysisMetadata: AnalysisMetadata = { hasFlicks: false, hasLongpresses: false, hasMultitaps: false }; /* Standardize the layout object's data types. * * In older versions of KMW, some numeric properties were long represented as strings instead, * and that lives on within a _lot_ of keyboards. The data should be sanitized before it * is processed by this method. */ this.sanitize(layout); // Create a separate OSK div for each OSK layer, only one of which will ever be visible var n: number; let layerMap: {[layerId: string]: ActiveLayer} = {}; let layers=layout.layer; // Add class functions to the existing layout object, allowing it to act as an ActiveLayout. let dummy = new ActiveLayout(); for(let key in dummy) { if(!layout.hasOwnProperty(key)) { layout[key] = dummy[key]; } } let aLayout = layout as ActiveLayout; aLayout.keyboard = keyboard; aLayout.formFactor = formFactor; for(n=0; n entry.id == 'caps')) { const defaultLayer = layout.layer.find((entry) => entry.id == 'default') as ActiveLayer; const shiftLayer = layout.layer.find((entry) => entry.id == 'shift') as ActiveLayer; const defaultShift = defaultLayer.getKey('K_SHIFT'); const shiftShift = shiftLayer ?.getKey('K_SHIFT'); // If BOTH default & shift layer SHIFT keys lack multitaps & longpresses, proceed. if(defaultShift && shiftShift && // doesn't make much sense if there's no shift layer or SHIFT on either !defaultShift.multitap && !shiftShift.multitap && !defaultShift.sk && !shiftShift.sk ) { // May cause the layout to gain its first multitaps, which does matter for the next lines after the block. analysisMetadata.hasMultitaps = true; defaultShift.multitap = [{...Layouts.dfltShiftToCaps}, {...Layouts.dfltShiftToDefault}] as ActiveSubKey[]; shiftShift.multitap = [{...Layouts.dfltShiftToCaps}, {...Layouts.dfltShiftToShift}] as ActiveSubKey[]; defaultShift.multitap.forEach((sk, index) => defaultShift.multitap[index] = new ActiveSubKey(sk, aLayout, 'default')); shiftShift .multitap.forEach((sk, index) => shiftShift.multitap[index] = new ActiveSubKey(sk, aLayout, 'shift')); } // else no default shift -> caps multitaps. } aLayout.hasFlicks = analysisMetadata.hasFlicks; aLayout.hasLongpresses = analysisMetadata.hasLongpresses; aLayout.hasMultitaps = analysisMetadata.hasMultitaps; aLayout.layerMap = layerMap; return aLayout; } }