Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(web): prototype KMX+ TouchLayout generator (in TS) #12305

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions docs/file-formats/kmx-plus-file-format.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,8 +173,8 @@ Then for each string:

| ∆ | Bits | Name | Description |
|---|------|---------------|-----------------------------------------------|
|16+| 32 | offset | off: Offset to string |
|20+| 32 | length | int: Length of string in UTF-16LE code units |
|12+| 32 | offset | off: Offset to string |
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oops, good catch!

|16+| 32 | length | int: Length of string in UTF-16LE code units |

After the string offset table comes the actual UTF-16LE data. There is a null
(\u0000) after each string, which is _not_ included in the string length.
Expand Down Expand Up @@ -362,9 +362,9 @@ For each element:

| ∆ | Bits | Name | Description |
|---|------|---------|------------------------------------------|
|32+| 32 | to | str: to string |
|36+| 32 | id | str: id string |
|40+| 32 | display | str: output display string |
|16+| 32 | to | str: to string |
|20+| 32 | id | str: id string |
|24+| 32 | display | str: output display string |

Either `to` or `id` must be set, not both.
Entries with an `to` field are sorted in a binary codepoint sort on the `to` field,
Expand Down Expand Up @@ -421,8 +421,8 @@ For each flicks in the flick list:
| ∆ | Bits | Name | Description |
|---|------|---------------- |----------------------------------------------------------|
| 0+| 32 | count | int: number of flick elements in this flick |
|12+| 32 | flick | int: index into `flick` subtable for first flick element |
|16+| 32 | id | str: flick id |
| 4+| 32 | flick | int: index into `flick` subtable for first flick element |
| 8+| 32 | id | str: flick id |

- `id`: The original string id from XML. This may be 0 to save space (i.e. omit the string id).

Expand All @@ -435,8 +435,8 @@ For each flick element:

| ∆ | Bits | Name | Description |
|---|------|---------------- |----------------------------------------------------------|
| 0+| 32 | directions | list: index into `list` section with direction list |
| 8+| 32 | keyId | str: id of key |
| 0+| 32 | directions | list: index into `list` section with direction sequence |
| 4+| 32 | keyId | str: id of key |

If this section is present, it must have a 'flick element' at position zero with directions=0, flags=0, and to=0 meaning 'no flick'.

Expand Down
3 changes: 3 additions & 0 deletions web/src/engine/keyboard/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ export * from "./keyEvent.js";
export { default as KeyMapping } from "./keyMapping.js";
export { OutputTarget } from "./outputTarget.interface.js";

export { LdmlKeyboardObject } from './kmxPlus/ldmlKeyboardObject.js';
export { convertLayerList } from './kmxPlus/convertLayerList.js';

export * from "@keymanapp/web-utils";

// At the top level, there should be no default export.
Expand Down
122 changes: 122 additions & 0 deletions web/src/engine/keyboard/src/kmxPlus/convertLayerList.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { Layer, LayerList } from './layr.js';
import { TouchLayout } from '@keymanapp/common-types';
import TouchLayoutLayer = TouchLayout.TouchLayoutLayer;
import TouchLayoutRow = TouchLayout.TouchLayoutRow;
import TouchLayoutKey = TouchLayout.TouchLayoutKey;
import TouchLayoutSubKey = TouchLayout.TouchLayoutSubKey;
import TouchLayoutFlick = TouchLayout.TouchLayoutFlick;

import Codes from '../codes.js';
import { KeySpec, Keys } from './keys.js';
import { ButtonClasses } from '../keyboards/defaultLayouts.js';

const keyNames = Object.keys(Codes.keyCodes);

export function convertLayerList(layerList: LayerList, keys: Keys) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function converts one layer list (one pre-parsed entry of the layr.lists subtable) into the OSK-friendly TouchLayout format.

return layerList.layers.map((layer) => convertLayer(layer, keys));
}

function nameFromModifier(code: number) {
const modNames = Object.keys(Codes.modifierCodes);

for(let mod of modNames) {
if(Codes.modifierCodes[mod] == code) {
return mod.toLowerCase();
}
}

if(code == 0) {
return 'default';
}

return '';
}

export function convertLayer(layer: Layer, keys: Keys): TouchLayoutLayer {
const modName = nameFromModifier(layer.modifiers);
const name = layer.id == '' ? modName : layer.id;

const rows: TouchLayoutRow[] = layer.keys.map((row, index) => {
return {
id: index,
key: row.map((keySpec) => convertKey(keySpec, keys, modName))
//
} as TouchLayoutRow
});

// TODO: add frame keys as needed
// like shift, etc.

return {
id: name,
row: rows
};
}

export function convertKey(keySpec: KeySpec, keys: Keys, defaultModifier: string): TouchLayoutKey | TouchLayoutSubKey {
if(keySpec.isGap) {
return {
sp: ButtonClasses.spacer,
width: keySpec.width
}
}

let id = keySpec.id;
for(let keyId of keyNames) {
if(Codes.keyCodes[keyId] == keySpec.keyCode) {
id = keyId;
}
}

// TODO: if unmatched, we need a synthetic key ID (or similar) for
// custom keys.
//
// MD recommendation: build a `T_` id for such keys, keeping them unique.
// May need a table with matching codes (per JS-keyboard VKDictionary) to
// use within Core as a numeric key ID for rules... or maybe the string itself
// will work within Core? I leave that to y'all.

let obj: TouchLayoutKey = {
id: id as unknown as any, // may not actually match Keyman keyboard id restrictions!
text: keySpec.to, // TODO: Look up correct value from `disp` table.
width: keySpec.width
//
};

if(keySpec.switch) {
obj.nextlayer = keySpec.switch;
}

if(keySpec.longpress && keySpec.longpress.length > 0) {
obj.sk = keySpec.longpress.map((id) => convertKey(keys.keys.get(id), keys, defaultModifier) as TouchLayoutSubKey);
}

if(keySpec.multitap && keySpec.multitap.length > 0) {
obj.multitap = keySpec.multitap.map((id) => convertKey(keys.keys.get(id), keys, defaultModifier) as TouchLayoutSubKey);
}

if(keySpec.flicks) {
const flickObj: TouchLayoutFlick = {};

for(let flick of keySpec.flicks) {
if(flick.dirSequence.length == 1) {
const dir = flick.dirSequence[0] as keyof TouchLayoutFlick;
const key = convertKey(keys.keys.get(flick.keyId), keys, defaultModifier) as TouchLayoutSubKey;

flickObj[dir] = key;
}
// else skip - we don't support multisegment flicks.
}

obj.flick = flickObj;
}

// Should match the modifier
if(keySpec.modifiers) {
// [JH] Wait... but the key always has a modifier value on it... right?
// I don't see where the KMX+ spec allows that to be undefined.
obj.layer = keySpec.modifiers !== undefined ? nameFromModifier(keySpec.modifiers) : defaultModifier;
}

return obj;
}
185 changes: 185 additions & 0 deletions web/src/engine/keyboard/src/kmxPlus/keys.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import { List } from './list.js';
import { Strs } from './strs.js';
import { decodeNumber } from './utils.js';

type Flick = { dirSequence: readonly string[], keyId: string };
type FlickSet = { flicks: Flick[], flickId: string };
type VkeyMapping = { code: number, modifiers: number, keyIndex: number };
export type KeySpec = {
to: string;
isGap: boolean;
id: string;
switch: string;
width: number;
longpress: readonly string[];
defaultLongpressId: string;
multitap: readonly string[];
flicks: Flick[];
keyCode: number,
modifiers: number
};

export class Keys {
readonly name = 'keys';
readonly data: Uint8Array;
readonly keys: Readonly<Map<string, KeySpec>>;

constructor(rawData: Uint8Array, strs: Strs, list: List) {
this.data = rawData;

const keyCount = decodeNumber(rawData, 8);
const flicksCount = decodeNumber(rawData, 12);
const flickCount = decodeNumber(rawData, 16);

const FLICK_GROUP_LEN = 12;
const FLICK_LEN = 8;

const keyTableOffset = 24;
const flicksTableOffset = keyTableOffset + keyCount * 36; // 9 entries * 4 bytes ea
const flickTableOffset = flicksTableOffset + flicksCount * FLICK_GROUP_LEN;
const kmapTableOffset = flickTableOffset + flickCount * FLICK_LEN;

// keys.keys subtable wants to index within keys.flicks subtable.
// So let's build the latter first.

const flickSets = this.processFlickSubtables(strs, list, keyTableOffset);
const vkeyMap = this.processKeymap(kmapTableOffset);
const keybag = this.processKeybag(strs, list, flickSets, vkeyMap);

const keyMap: Map<string, KeySpec> = new Map();
for(let key of keybag) {
keyMap.set(key.id, key);
}
this.keys = keyMap;
}

private processFlickSubtables(strs: Strs, list: List, baseOffset: number) {
const rawData = this.data;

const keyCount = decodeNumber(rawData, 8);
const flicksCount = decodeNumber(rawData, 12);

const FLICK_GROUP_LEN = 12;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

now, shouldn't this file be able to use https://github.com/keymanapp/keyman/blob/master/core/include/ldml/keyman_core_ldml.ts ?
so:

Suggested change
const FLICK_GROUP_LEN = 12;
const FLICK_GROUP_LEN = Constants.length_keys_flick_list;

const FLICK_LEN = 8;

const flicksTableOffset = baseOffset + keyCount * 36; // 9 entries * 4 bytes ea
const flickTableOffset = flicksTableOffset + flicksCount * FLICK_GROUP_LEN;

// keys.keys subtable wants to index within keys.flicks subtable.
// So let's build the latter first.

const flickSets: FlickSet[] = [];
for(let i = 0; i < flicksCount; i++) {
const rowOffset = flicksTableOffset + i * FLICK_GROUP_LEN;
const elemCount = decodeNumber(rawData, rowOffset);
const firstIndex = decodeNumber(rawData, rowOffset + 4);
const stringIndex = decodeNumber(rawData, rowOffset + 8);

if(i == 0) {
if(elemCount == 0 && firstIndex == 0 && stringIndex == 0) {
continue;
} else {
throw new Error("Empty flick set expected at index 0 in `keys.flicks` subtable");
}
}

// TODO: no good current fixture available with data for this.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unit tests only

const id = strs.entries[decodeNumber(rawData, rowOffset + 8)];
const baseFlickOffset = flickTableOffset + firstIndex * FLICK_LEN;
const flicks: Flick[] = [];
for(let j = 0; j < elemCount; j++) {
const flickOffset = baseFlickOffset + j * FLICK_LEN;
const directions = list.entries[decodeNumber(rawData, flickOffset)];
const keyId = strs.entries[decodeNumber(rawData, flickOffset + 4)];
flicks.push({
dirSequence: directions,
keyId: keyId
});
}

flickSets.push({
flicks: flicks,
flickId: id
});
}

return flickSets;
}

private processKeymap(offset: number) {
const kmapCount = decodeNumber(this.data, 20);

const mappings: VkeyMapping[] = [];
for(let i=0; i < kmapCount; i++) {
const rowOffset = offset + 12 * i;

mappings.push({
code: decodeNumber(this.data, rowOffset),
modifiers: decodeNumber(this.data, rowOffset + 4),
keyIndex: decodeNumber(this.data, rowOffset + 8)
});
}

return mappings;
}

private processKeybag(strs: Strs, list: List, flickSets: FlickSet[], keyMapping: VkeyMapping[]) {
// | ∆ | Bits | Name | Description |
// |---|------|-------------|------------------------------------------|
// | 0 | 32 | ident | `key2` |
// | 4 | 32 | size | int: Length of section |
// | 8 | 32 | keyCount | int: Number of keys |
// |12 | 32 | flicksCount | int: Number of flick lists |
// |16 | 32 | flickCount | int: Number of flick elements |
// |20 | 32 | kmapCount | int: Number of kmap elements |
// |24 | var | keys | keys sub-table |
// | - | var | flicks | flick lists sub-table |
// | - | var | flick | flick elements sub-table |
// | - | var | kmap | key map sub-table |
const rawData = this.data;

const keyCount = decodeNumber(rawData, 8);
const keyTableOffset = 24;

const keys: KeySpec[] = [];
for(let i=0; i < keyCount; i++) {
const rowOffset = keyTableOffset + i * 36; // 9 cols per row in keys.keys subtable.

// is either an index into `strs` OR a UTF-32LE codepoint.
const toVal = decodeNumber(rawData, rowOffset);
const flags = decodeNumber(rawData, rowOffset + 4);
const to = (flags & 1) ? strs.entries[toVal] : String.fromCodePoint(toVal);

const isGap = !!(flags & 2);
const id = strs.entries[decodeNumber(rawData, rowOffset + 8)];
const switchLayer = strs.entries[decodeNumber(rawData, rowOffset + 12)];

const width = decodeNumber(rawData, rowOffset + 16);
const longpressListIndex = decodeNumber(rawData, rowOffset + 20);
const longpress = longpressListIndex == 0 ? [] : list.entries[longpressListIndex];

const defaultLongpressId = strs.entries[decodeNumber(rawData, rowOffset + 24)];

const multitapListIndex = decodeNumber(rawData, rowOffset + 28);
const multitap = multitapListIndex == 0 ? [] : list.entries[multitapListIndex];

const flickSet = flickSets[decodeNumber(rawData, rowOffset + 32)];

const keySpec: KeySpec = {
to, isGap, id, switch: switchLayer, width, longpress, defaultLongpressId, multitap, flicks: flickSet?.flicks,
keyCode: 0,
modifiers: 0
}

keys.push(keySpec);
}

for(let entry of keyMapping) {
const keyObj = keys[entry.keyIndex];
keyObj.keyCode = entry.code;
keyObj.modifiers = entry.modifiers;
}

return keys;
}
}
Loading
Loading