mirror of
https://github.com/keymanapp/keyman.git
synced 2026-08-10 10:55:33 +00:00
905 lines
28 KiB
TypeScript
905 lines
28 KiB
TypeScript
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<keys.length; j++) {
|
|
let key=keys[j];
|
|
for(var tp in ActiveKey.DEFAULT_KEY) {
|
|
if(typeof key[tp] != 'string' && typeof key[tp] != 'number') {
|
|
key[tp]=ActiveKey.DEFAULT_KEY[tp];
|
|
}
|
|
}
|
|
|
|
// Modify the key type for special keys with non-standard labels
|
|
// to allow the keyboard font to overide the SpecialOSK font.
|
|
// Blank keys are no longer reclassed - can use before/after CSS to add text
|
|
switch(key['sp']) {
|
|
case ButtonClasses.special:
|
|
if(!ActiveRow.SPECIAL_LABEL.test(key['text']) && key['text'] != '') {
|
|
key.sp=ButtonClasses.customSpecial;
|
|
}
|
|
break;
|
|
case ButtonClasses.specialActive:
|
|
if(!ActiveRow.SPECIAL_LABEL.test(key['text']) && key['text'] != '') {
|
|
key.sp=ButtonClasses.customSpecialActive;
|
|
}
|
|
break;
|
|
}
|
|
|
|
const processedKey = new ActiveKey(key, layout, displayLayer);
|
|
keys[j] = processedKey;
|
|
|
|
if(processedKey.sk) {
|
|
analysisFlagObj.hasLongpresses = true;
|
|
}
|
|
if(processedKey.multitap) {
|
|
analysisFlagObj.hasMultitaps = true;
|
|
}
|
|
if(processedKey.flick) {
|
|
analysisFlagObj.hasFlicks = true;
|
|
}
|
|
}
|
|
|
|
/* The calculations here are effectively 'virtualized'. When used with the OSK, the VisualKeyboard
|
|
* will overwrite these values with their true runtime geometry.
|
|
*
|
|
* These calculations approximate those of the actual OSK (without fitting to a specific resolution)
|
|
* and are intended for use with layout testing (while headless) in the future.
|
|
*/
|
|
|
|
let setProportions = function(key: ActiveKey, padPc: number, keyPc: number, totalPc: number) {
|
|
key.proportionalPad = padPc;
|
|
key.proportionalWidth = keyPc;
|
|
key.proportionalX = (totalPc + padPc + (keyPc/2));
|
|
}
|
|
|
|
// Calculate percentage-based scalings by summing defined widths and scaling each key to %.
|
|
// Save each percentage key width as a separate member (do *not* overwrite layout specified width!)
|
|
let totalPercent=0;
|
|
for(let j=0; j<keys.length-1; j++) {
|
|
const key = keys[j] as ActiveKey; // already 'polyfilled' in prior loop
|
|
|
|
// compute center's default x-coord (used in headless modes), assign 'proportional' props
|
|
setProportions(key, key.pad/totalWidth, key.width/totalWidth, totalPercent);
|
|
|
|
// These values are set on the key as part of the prior call.
|
|
totalPercent += key.proportionalPad;
|
|
totalPercent += key.proportionalWidth;
|
|
}
|
|
|
|
// Allow for right OSK margin (15 layout units)
|
|
let rightMargin = ActiveKey.DEFAULT_RIGHT_MARGIN/totalWidth;
|
|
|
|
if(keys.length > 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<rowCount; i++) {
|
|
// Calculate proportional y-coord of row. 0 is at top with highest y-coord.
|
|
let rowProportionalY = (i + 0.5) / rowCount;
|
|
ActiveRow.polyfill(layer.row[i], keyboard, layout, layer.id, totalWidth, rowProportionalY, analysisFlagObj);
|
|
}
|
|
|
|
// Add class functions and properties to the existing layout object, allowing it to act as an ActiveLayout.
|
|
let dummy = new ActiveLayer();
|
|
for(let key in dummy) {
|
|
if(!layer.hasOwnProperty(key)) {
|
|
layer[key] = dummy[key];
|
|
}
|
|
}
|
|
|
|
let aLayer = layer as ActiveLayer;
|
|
aLayer.totalWidth = totalWidth;
|
|
aLayer.defaultKeyProportionalWidth = ActiveKey.DEFAULT_KEY.width / totalWidth;
|
|
aLayer.rowProportionalHeight = 1.0 / rowCount;
|
|
aLayer.keyMap = aLayer.constructKeyMap();
|
|
}
|
|
|
|
@Enumerable
|
|
private constructKeyMap(): {[keyId: string]: ActiveKey} {
|
|
let map: {[keyId: string]: ActiveKey} = {};
|
|
this.row.forEach(function(row: ActiveRow) {
|
|
row.populateKeyMap(map);
|
|
});
|
|
|
|
return map;
|
|
}
|
|
|
|
@Enumerable
|
|
getKey(keyId: string) {
|
|
// Keys usually are specified in a "long form" prefixed with their layer's ID.
|
|
if(keyId.indexOf(this.id + '-') == 0) {
|
|
keyId = keyId.replace(this.id + '-', '');
|
|
}
|
|
|
|
let idComponents = keyId.split('::');
|
|
if(idComponents.length > 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<layers.length; n++) {
|
|
let layer=layers[n];
|
|
let rows=layer['row'];
|
|
let i: number;
|
|
for(i=rows.length-1; i>=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<layers.length; n++) {
|
|
ActiveLayer.polyfill(layers[n], keyboard, aLayout, analysisMetadata);
|
|
layerMap[layers[n].id] = layers[n] as ActiveLayer;
|
|
}
|
|
|
|
// After all layers are preprocessed...
|
|
|
|
// The default-layer shift key & shift-layer shift key on mobile platforms should have a
|
|
// default multitap re: a 'caps' layer under select conditions.
|
|
//
|
|
// Note: whether or not any other keys have multitaps doesn't matter here. Just THESE.
|
|
if(formFactor != 'desktop' && !!layout.layer.find((entry) => 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;
|
|
}
|
|
}
|