open-standards/aac/aac-rs/tools/keyboard/build_layouts.py
Transaction Science 3a4c8f25eb fix(aac-core, aac-web): the keyboard review — letter names by ear, standard layouts, keys that keep their size
The alphabet by ear
- Says each letter by its name (keyboard/letter_names.json), never the bare
  key a voice reads as a word, and the app's own words in the language. The
  names are data with a review: drafted, checked by an independent
  back-reading and a register check, disputes decided and checked again
  (rounds 2-3), then a voice check with espeak-ng
  (tools/keyboard/check_letter_names.py). 43 of 45 languages agreed; Amharic
  (ኸ) and Tagalog (z, v, e) are not, and there it says it is not ready yet.
  The readers were AI models, not native speakers, and the table says so.
- Its own speech path (tts::say_cue): the device voice of the language, no
  text_for_speech, and never over or cancelling the person's words; after
  Speak the next cue waits until the message has been said. It does not
  start without a voice that speaks the language, and says why.
- Offers a letter's marks, a kana's forms and an Ethiopic consonant's orders
  after it; reads a lone suggestion as the word; Stop is in the last group;
  a key reaches it from every keyboard. Each choice records whether a tap
  relayed it; "only my own switch or look" is a setting; a message spelled
  with relayed choices is learned only when the person keeps it (Item::Learn)
  with their own yes. A panel open takes the switch first.

Keyboards
- National layouts from published standards, both layers, read from
  xkeyboard-config (tools/keyboard/build_layouts.py): InScript (IS 13194) for
  the nine Indic languages, Kedmanee (TIS 820-2538) for Thai, CRULP phonetic
  for Urdu; Shift's layer is a page at the same positions.
- Defaults follow the access method (kbd::suggested): most used letters first
  for a switch, colour groups (or alphabetical, large keys) for gaze; a key
  size setting. A Board key leaves text mode. Amharic syllable pages have
  Back. Malayalam signs shown bare (WebKit drew two circles).
- Pinyin: each candidate covers its own letters, ranked by the likeliest
  split (不 + 舒服, not 部署 + 副); ▸▸ pages through them; capitals read as
  the letters; no conversion during a device IME's composition; a failed
  download is said once and retried with back-off; the note says exactly what
  it does.

Access and fit
- With the on-screen keys, the box never takes a switch's Space or Enter, and
  a chosen suggestion no longer moves the focus into it.
- A look must leave a key before it can choose again.
- Keys never shrink below 24 x 32 px: on a phone on its side the box goes
  beside the keys, and the keyboard scrolls with the scan's highlight kept in
  view; the colour frame's text keeps its end in view; the notes fold to a
  line; the colour marks sit on badges with 4.5:1 ink; toggles show in dark
  and high-contrast; dir=auto on the box and what by-ear shows.
- Phrases page: Add to bar and Recent. Unreadable keyboard settings are not
  overwritten at start. First letters keeps where it reads from when text
  mode comes back.

Credits: Settings -> Keyboard lists every word list's source and licence; the
pinyin licence file carries pypinyin's, pinyin-data's, phrase-pinyin-data's
and the Unicode notices in full (the generator pins pypinyin 0.55.0); the
compose table names the Unicode data it comes from. SPEC §12 and
spec/keyboard.md updated.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 02:18:26 -04:00

128 lines
4.8 KiB
Python

#!/usr/bin/env python3
"""Write aac-core's keyboard/standard_layouts.rs: the national keyboards that
follow a published standard, read from xkeyboard-config's symbol files (the
X.Org keyboard database, MIT licence), with both layers — the keys as typed,
and with Shift:
InScript (IS 13194:1991, the Indian standard) for Hindi, Marathi, Bengali,
Punjabi, Gujarati, Tamil, Telugu, Kannada and Malayalam;
Kedmanee (TIS 820-2538) for Thai;
CRULP's phonetic layout (v1.1) for Urdu.
Rows are the keyboard's four rows (the number row, then Q, A and Z); the key
right of the A row's last (\\) is put at that row's end, so no row is more than
twelve keys. A row that has no letter in a layer is left out there.
usage: python3 tools/keyboard/build_layouts.py <xkeyboard-config/symbols> <xorgproto keysymdef.h>
"""
import re
import sys
import unicodedata
from pathlib import Path
OUT = Path(__file__).resolve().parents[2] / "crates/aac-core/src/keyboard/standard_layouts.rs"
# code, xkb file, xkb variant, the standard's name
LAYOUTS = [
("hi", "in", "deva", "InScript"),
("mr", "in", "marathi", "InScript"),
("bn", "in", "ben_inscript", "InScript"),
("pa", "in", "guru", "InScript"),
("gu", "in", "guj", "InScript"),
("ta", "in", "tam", "InScript"),
("te", "in", "tel", "InScript"),
("kn", "in", "kan", "InScript"),
("ml", "in", "mal", "InScript"),
("th", "th", "basic", "Kedmanee"),
("ur", "pk", "urd-crulp", "CRULP phonetic"),
]
ROWS = [
[f"AE{i:02d}" for i in range(1, 13)],
[f"AD{i:02d}" for i in range(1, 13)],
[f"AC{i:02d}" for i in range(1, 12)] + ["BKSL"],
[f"AB{i:02d}" for i in range(1, 11)],
]
def keysyms(path):
names = {}
for line in Path(path).read_text(encoding="latin-1").splitlines():
m = re.match(r"#define XK_(\w+)\s+0x([0-9a-fA-F]+)\s*/\*[ (]*U\+([0-9A-F]{4,6})", line)
if m:
names[m.group(1)] = chr(int(m.group(3), 16))
return names
def block(text, variant):
m = re.search(r'xkb_symbols\s+"' + re.escape(variant) + r'"\s*\{(.*?)\n\};', text, re.S)
if not m:
sys.exit(f"no variant {variant}")
return m.group(1)
def char_of(sym, names):
sym = sym.strip()
if re.fullmatch(r"U[0-9A-Fa-f]{4,6}", sym):
return chr(int(sym[1:], 16))
if re.fullmatch(r"0x1[0-9A-Fa-f]{6}", sym):
return chr(int(sym, 16) - 0x1000000)
if len(sym) == 1:
return sym
if sym in ("NoSymbol", "VoidSymbol"):
return ""
if sym in names:
return names[sym]
sys.exit(f"unknown keysym {sym}")
def layers(symbols_dir, names, file, variant):
text = (Path(symbols_dir) / file).read_text(encoding="utf-8")
body = block(text, variant)
keys = {}
for m in re.finditer(r"key\s+<(\w+)>\s*\{[^\[]*\[([^\]]*)\]", body):
syms = [s for s in m.group(2).split(",")]
keys[m.group(1)] = [char_of(s, names) for s in syms[:2]] + [""] * (2 - len(syms[:2]))
if not keys:
sys.exit(f"{file}({variant}): no keys (an include-only variant?)")
# Keys type NFC (a nukta letter Unicode excludes from composition, य़,
# is its letter and the nukta).
nfc = lambda c: unicodedata.normalize("NFC", c)
return [[[nfc(keys.get(k, ["", ""])[level]) for k in row] for row in ROWS] for level in (0, 1)]
def rust_str(s):
return '"' + "".join(c if c.isprintable() and c not in '"\\' else f"\\u{{{ord(c):04X}}}" for c in s) + '"'
def main():
symbols_dir, keysymdef = sys.argv[1], sys.argv[2]
names = keysyms(keysymdef)
out = [
"//! Generated by `tools/keyboard/build_layouts.py`; do not edit by hand.",
"//!",
"//! National keyboards that follow a published standard, both layers (as",
"//! typed, and with Shift), row by row: InScript (IS 13194:1991) for the",
"//! Indic scripts, Kedmanee (TIS 820-2538) for Thai, CRULP's phonetic layout",
"//! for Urdu. Read from xkeyboard-config (the X.Org keyboard database,",
"//! https://gitlab.freedesktop.org/xkeyboard-config/xkeyboard-config, MIT",
"//! licence). An empty string is a key with nothing in that layer.",
"",
"/// `(language, standard, [layer as typed, layer with Shift])`.",
"#[allow(clippy::type_complexity)]",
"pub(super) const STANDARD_LAYOUTS: &[(&str, &str, [&[&[&str]]; 2])] = &[",
]
for code, file, variant, standard in LAYOUTS:
ls = layers(symbols_dir, names, file, variant)
out.append(f" (\"{code}\", \"{standard}\", [")
for layer in ls:
out.append(" &[")
for row in layer:
out.append(" &[" + ", ".join(rust_str(c) for c in row) + "],")
out.append(" ],")
out.append(" ]),")
out.append("];")
OUT.write_text("\n".join(out) + "\n", encoding="utf-8")
print(f"{len(LAYOUTS)} layouts -> {OUT}")
if __name__ == "__main__":
main()