diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f6475b9b..e36f2aee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -95,3 +95,18 @@ jobs: run: ./scripts/check-skills.sh - name: scripts/check-irq-locks.sh (INV-3 / Rule 3.5.1) run: ./scripts/check-irq-locks.sh + + keyboard-layouts: + name: Keyboard layouts + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # xkbcompile is a host-side std tool; the layouts come from xkb-data. + - uses: dtolnay/rust-toolchain@stable + + - name: Install xkb-data + run: sudo apt-get update && sudo apt-get install -y xkb-data + + - name: scripts/check-keyboard-layouts.sh (#702) + run: ./scripts/check-keyboard-layouts.sh diff --git a/data/keyboard/KBDSP.klc b/data/keyboard/KBDSP.klc deleted file mode 100644 index 2547b86f..00000000 --- a/data/keyboard/KBDSP.klc +++ /dev/null @@ -1,219 +0,0 @@ -KBD KBDSP "Spanish" - -LOCALENAME "es-ES" - -LOCALEID "0000040a" - -VERSION 1.0 - -ATTRIBUTES -ALTGR - -SHIFTSTATE - -0 // normal -1 // shift -2 // ctrl -6 // altgr -3 // shift+ctrl - -LAYOUT - -//SC VK_ Cap 0 1 2 6 3 -//-- --- ---- ---- ---- ---- ---- ---- - -02 1 0 1 0021 -1 007c -03 2 0 2 0022 -1 0040 -04 3 0 3 00b7 -1 0023 -05 4 0 4 0024 -1 007e@ -06 5 0 5 0025 -1 20ac -07 6 0 6 0026 -1 00ac 001e -08 7 0 7 002f -09 8 0 8 0028 -0a 9 0 9 0029 -0b 0 0 0 003d -0c OEM_4 0 0027 003f -0d OEM_6 0 00a1 00bf -10 Q 1 q Q -11 W 1 w W -12 E 1 e E -1 20ac -13 R 1 r R -14 T 1 t T -15 Y 1 y Y -16 U 1 u U -17 I 1 i I -18 O 1 o O -19 P 1 p P -1a OEM_1 0 0060@ 005e@ 001b 005b -1b OEM_PLUS 0 002b 002a 001d 005d -1e A 1 a A -1f S 1 s S -20 D 1 d D -21 F 1 f F -22 G 1 g G -23 H 1 h H -24 J 1 j J -25 K 1 k K -26 L 1 l L -27 OEM_3 1 00f1 00d1 -28 OEM_7 0 00b4@ 00a8@ -1 007b -29 OEM_5 0 00ba 00aa -1 005c -2b OEM_2 1 00e7 00c7 001c 007d -2c Z 1 z Z -2d X 1 x X -2e C 1 c C -2f V 1 v V -30 B 1 b B -31 N 1 n N -32 M 1 m M -33 OEM_COMMA 0 002c 003b -34 OEM_PERIOD 0 002e 003a -35 OEM_MINUS 0 002d 005f -39 SPACE 0 0020 0020 0020 -53 DECIMAL 0 002e 002e -56 OEM_102 0 003c 003e 001c - -DEADKEY 0060 // grave - -0061 00e0 // a -> à -0065 00e8 // e -> è -0069 00ec // i -> ì -006f 00f2 // o -> ò -0075 00f9 // u -> ù -0041 00c0 // A -> À -0045 00c8 // E -> È -0049 00cc // I -> Ì -004f 00d2 // O -> Ò -0055 00d9 // U -> Ù -0020 0060 // space -> ` - -DEADKEY 005e // circumflex - -0061 00e2 // a -> â -0065 00ea // e -> ê -0069 00ee // i -> î -006f 00f4 // o -> ô -0075 00fb // u -> û -0041 00c2 // A -> Â -0045 00ca // E -> Ê -0049 00ce // I -> Î -004f 00d4 // O -> Ô -0055 00db // U -> Û -0020 005e // space -> ^ - -DEADKEY 00b4 // acute - -0061 00e1 // a -> á -0065 00e9 // e -> é -0069 00ed // i -> í -006f 00f3 // o -> ó -0075 00fa // u -> ú -0079 00fd // y -> ý -0041 00c1 // A -> Á -0045 00c9 // E -> É -0049 00cd // I -> Í -004f 00d3 // O -> Ó -0055 00da // U -> Ú -0059 00dd // Y -> Ý -0020 00b4 // space -> ´ - -DEADKEY 00a8 // diaeresis - -0061 00e4 // a -> ä -0065 00eb // e -> ë -0069 00ef // i -> ï -006f 00f6 // o -> ö -0075 00fc // u -> ü -0079 00ff // y -> ÿ -0041 00c4 // A -> Ä -0045 00cb // E -> Ë -0049 00cf // I -> Ï -004f 00d6 // O -> Ö -0055 00dc // U -> Ü -0020 00a8 // space -> ¨ - -DEADKEY 007e // tilde - -0061 00e3 // a -> ã -006e 00f1 // n -> ñ -006f 00f5 // o -> õ -0041 00c3 // A -> Ã -004e 00d1 // N -> Ñ -004f 00d5 // O -> Õ -0020 007e // space -> ~ - -KEYNAME - -01 ESC -0e RETROCESO -0f TABULACION -1c ENTRAR -1d CTRL -2a MAYUSCULAS -36 "MAYUSCULAS DERECHA" -37 "TECLA DE MULTIPLICACION" -38 ALT -39 "BARRA ESPACIADORA" -3a "BLOQ MAYUS" -3b F1 -3c F2 -3d F3 -3e F4 -3f F5 -40 F6 -41 F7 -42 F8 -43 F9 -44 F10 -45 PAUSA -46 "BLOQ DESPL" -47 "NUMERO 7" -48 "NUMERO 8" -49 "NUMERO 9" -4a "TECLA DE SUSTRACCION" -4b "NUMERO 4" -4c "NUMERO 5" -4d "NUMERO 6" -4e "TECLA DE ADICION" -4f "NUMERO 1" -50 "NUMERO 2" -51 "NUMERO 3" -52 "NUMERO 0" -53 "TECLA DECIMAL" -57 F11 -58 F12 - -KEYNAME_EXT - -1c INTRO -1d "CTRL DERECHA" -35 "TECLA DE DIVISION" -37 "IMP PANT" -38 "ALT DERECHA" -45 "BLOQ NUM" -46 INTERRUMPIR -47 INICIO -48 "FLECHA ARRIBA" -49 "RE PAG" -4b "FLECHA IZQUIERDA" -4d "FLECHA DERECHA" -4f FIN -50 "FLECHA ABAJO" -51 "AV PAG" -52 INSERT -53 SUPR -54 <00> -56 AYUDA -5b "WINDOWS IZQUIERDA" -5c "WINDOWS DERECHA" -5d APLICACION - -KEYNAME_DEAD - -00b4 AGUDO -0060 GRAVE -005e CIRCUNFLEJO -00a8 DIERERIS -007e TILDE - -ENDKBD \ No newline at end of file diff --git a/data/keyboard/KBDUS.klc b/data/keyboard/KBDUS.klc deleted file mode 100644 index c1e2ea20..00000000 --- a/data/keyboard/KBDUS.klc +++ /dev/null @@ -1,145 +0,0 @@ -LOCALEID "00000409" - -SHIFTSTATE - -0 // normal -1 // shift -2 // ctrl -3 // shift+ctrl - -LAYOUT - -//SC VK_ Cap 0 1 2 3 -//-- --- ---- ---- ---- ---- ---- - -02 1 0 1 0021 -03 2 0 2 0040 -1 0000 -04 3 0 3 0023 -05 4 0 4 0024 -06 5 0 5 0025 -07 6 0 6 005e -1 001e -08 7 0 7 0026 -09 8 0 8 002a -0a 9 0 9 0028 -0b 0 0 0 0029 -0c OEM_MINUS 0 002d 005f -1 001f -0d OEM_PLUS 0 003d 002b -10 Q 1 q Q -11 W 1 w W -12 E 1 e E -13 R 1 r R -14 T 1 t T -15 Y 1 y Y -16 U 1 u U -17 I 1 i I -18 O 1 o O -19 P 1 p P -1a OEM_4 0 005b 007b 001b -1b OEM_6 0 005d 007d 001d -1e A 1 a A -1f S 1 s S -20 D 1 d D -21 F 1 f F -22 G 1 g G -23 H 1 h H -24 J 1 j J -25 K 1 k K -26 L 1 l L -27 OEM_1 0 003b 003a -28 OEM_7 0 0027 0022 -29 OEM_3 0 0060 007e -2b OEM_5 0 005c 007c 001c -2c Z 1 z Z -2d X 1 x X -2e C 1 c C -2f V 1 v V -30 B 1 b B -31 N 1 n N -32 M 1 m M -33 OEM_COMMA 0 002c 003c -34 OEM_PERIOD 0 002e 003e -35 OEM_2 0 002f 003f -39 SPACE 0 0020 0020 0020 -53 DECIMAL 0 002e 002e -56 OEM_102 0 005c 007c 001c - -KEYNAME - -01 Esc -0e Backspace -0f Tab -1c Enter -1d Ctrl -2a Shift -36 "Right Shift" -37 "Num *" -38 Alt -39 Space -3a "Caps Lock" -3b F1 -3c F2 -3d F3 -3e F4 -3f F5 -40 F6 -41 F7 -42 F8 -43 F9 -44 F10 -45 Pause -46 "Scroll Lock" -47 "Num 7" -48 "Num 8" -49 "Num 9" -4a "Num -" -4b "Num 4" -4c "Num 5" -4d "Num 6" -4e "Num +" -4f "Num 1" -50 "Num 2" -51 "Num 3" -52 "Num 0" -53 "Num Del" -54 "Sys Req" -57 F11 -58 F12 -7c F13 -7d F14 -7e F15 -7f F16 -80 F17 -81 F18 -82 F19 -83 F20 -84 F21 -85 F22 -86 F23 -87 F24 - -KEYNAME_EXT - -1c "Num Enter" -1d "Right Ctrl" -35 "Num /" -37 "Prnt Scrn" -38 "Right Alt" -45 "Num Lock" -46 Break -47 Home -48 Up -49 "Page Up" -4b Left -4d Right -4f End -50 Down -51 "Page Down" -52 Insert -53 Delete -54 <00> -56 Help -5b "Left Windows" -5c "Right Windows" -5d Application - -ENDKBD \ No newline at end of file diff --git a/data/keyboard/US.kbd b/data/keyboard/US.kbd deleted file mode 100644 index c9cca891..00000000 Binary files a/data/keyboard/US.kbd and /dev/null differ diff --git a/data/keyboard/ca-ES.kbd b/data/keyboard/ca-ES.kbd new file mode 100644 index 00000000..bccf5fef Binary files /dev/null and b/data/keyboard/ca-ES.kbd differ diff --git a/data/keyboard/cs-CZ.kbd b/data/keyboard/cs-CZ.kbd new file mode 100644 index 00000000..3148a184 Binary files /dev/null and b/data/keyboard/cs-CZ.kbd differ diff --git a/data/keyboard/da-DK.kbd b/data/keyboard/da-DK.kbd new file mode 100644 index 00000000..072345f9 Binary files /dev/null and b/data/keyboard/da-DK.kbd differ diff --git a/data/keyboard/de-DE.kbd b/data/keyboard/de-DE.kbd new file mode 100644 index 00000000..b97f2e5d Binary files /dev/null and b/data/keyboard/de-DE.kbd differ diff --git a/data/keyboard/Spanish.kbd b/data/keyboard/en-US.kbd similarity index 76% rename from data/keyboard/Spanish.kbd rename to data/keyboard/en-US.kbd index 29adde99..28be6352 100644 Binary files a/data/keyboard/Spanish.kbd and b/data/keyboard/en-US.kbd differ diff --git a/data/keyboard/es-ES.kbd b/data/keyboard/es-ES.kbd new file mode 100644 index 00000000..0ee20332 Binary files /dev/null and b/data/keyboard/es-ES.kbd differ diff --git a/data/keyboard/fi-FI.kbd b/data/keyboard/fi-FI.kbd new file mode 100644 index 00000000..e3edbe05 Binary files /dev/null and b/data/keyboard/fi-FI.kbd differ diff --git a/data/keyboard/fr-FR.kbd b/data/keyboard/fr-FR.kbd new file mode 100644 index 00000000..fcc85582 Binary files /dev/null and b/data/keyboard/fr-FR.kbd differ diff --git a/data/keyboard/it-IT.kbd b/data/keyboard/it-IT.kbd new file mode 100644 index 00000000..0f8756d9 Binary files /dev/null and b/data/keyboard/it-IT.kbd differ diff --git a/data/keyboard/nb-NO.kbd b/data/keyboard/nb-NO.kbd new file mode 100644 index 00000000..d39907dd Binary files /dev/null and b/data/keyboard/nb-NO.kbd differ diff --git a/data/keyboard/nl-NL.kbd b/data/keyboard/nl-NL.kbd new file mode 100644 index 00000000..79b8f519 Binary files /dev/null and b/data/keyboard/nl-NL.kbd differ diff --git a/data/keyboard/pl-PL.kbd b/data/keyboard/pl-PL.kbd new file mode 100644 index 00000000..d88a5ebd Binary files /dev/null and b/data/keyboard/pl-PL.kbd differ diff --git a/data/keyboard/pt-BR.kbd b/data/keyboard/pt-BR.kbd new file mode 100644 index 00000000..2e2e0e95 Binary files /dev/null and b/data/keyboard/pt-BR.kbd differ diff --git a/data/keyboard/pt-PT.kbd b/data/keyboard/pt-PT.kbd new file mode 100644 index 00000000..4a72ee13 Binary files /dev/null and b/data/keyboard/pt-PT.kbd differ diff --git a/data/keyboard/sv-SE.kbd b/data/keyboard/sv-SE.kbd new file mode 100644 index 00000000..bf0ab108 Binary files /dev/null and b/data/keyboard/sv-SE.kbd differ diff --git a/docs/architecture/repository.md b/docs/architecture/repository.md index 68aea252..a5b67480 100644 --- a/docs/architecture/repository.md +++ b/docs/architecture/repository.md @@ -28,10 +28,11 @@ neodos/ ├── libnet/ # User-mode networking library ├── userbin/ # 52 user binaries (PID 1, shell, utilities) ├── drivers/ # 10 standalone NEM drivers -├── tools/ # 4 Rust host tools + 1 Python script +├── tools/ # 5 Rust host tools + 1 Python script │ ├── crashdump/ # Crash dump decoder │ ├── gen-hiv/ # Registry hive generator -│ ├── kbdcompile/ # Keyboard layout compiler +│ ├── kbdcompile/ # Keyboard layout compiler (.klc → .kbd) +│ ├── xkbcompile/ # Keyboard layout compiler (XKB → .kbd) │ └── nltc/ # NLT compiler ├── scripts/ # bash utilities (MCP server migrated → neodos-dev-server) ├── data/ # Locale TOML (141 files) + keyboard layouts @@ -109,7 +110,8 @@ kbdcompile (host) ───→ data/keyboard (layout sources) | `nxdump` | Host tool, reads ELF/NXE/NEM files. | | `nxeinfo` | Host tool, reads NXE headers. | | `nxpkg` | Host tool, creates/extracts NXP packages. | -| `kbdcompile` | Host tool, compiles keyboard layouts. | +| `kbdcompile` | Host tool, compiles keyboard layouts (`.klc` → `.kbd`). | +| `xkbcompile` | Host tool, converts XKB layouts → `.kbd`. | | `scripts/mcp_server` | Python, completely independent. | | `scripts/check_deps.py` | Python, standalone validation. | | `scripts/gen_*` | Python, host-side data generation. | @@ -142,7 +144,7 @@ kbdcompile (host) ───→ data/keyboard (layout sources) |-----------|-----------|-----------| | `neodev` | `NeoDev` | Fully standalone host tool. Standard Rust deps (clap, anyhow, serde). Changes at own pace. Can version independently. Build/test/image creation for any repo. | | `neodos-lsp` + `scripts/mcp_server` | `NeoDOS Dev Server` (merged) | **Migrated.** LSP + MCP merged into single Rust workspace at [neodos-dev-server](https://github.com/NeoDOS-Project/neodos-dev-server). Shared toolkit (neodos-toolkit) + LSP binary + MCP binary. | -| `nltc, kbdcompile` | `NeoToolchain` or separate | Both are host-side compilers for OS data formats. Can evolve independently of kernel version. | +| `nltc, kbdcompile, xkbcompile` | `NeoToolchain` or separate | Host-side compilers for OS data formats. Can evolve independently of kernel version. | | `nxdump, nxeinfo, nxpkg` | `NeoTools` | UX-focused analysis tools. Independent release cycle. Useful for OS analysis even without building kernel. | | `scripts/mcp_server` | `NeoMCP` | Python MCP server for AI-assisted development. Completely independent of kernel. | | `data/locale/*` | `NeoTranslations` | Translation data. Community contributions don't need kernel access. Can be translated entirely by non-developers. | diff --git a/docs/architecture/source-of-truth.md b/docs/architecture/source-of-truth.md index 0a48761c..e54ec32a 100644 --- a/docs/architecture/source-of-truth.md +++ b/docs/architecture/source-of-truth.md @@ -628,7 +628,7 @@ layout translation. NeoKBD handles scancode → Unicode conversion, dead key com modifier tracking, and hotkey dispatch (Ctrl+Alt+Del → poweroff, Alt+F1-F8 → VT switch). **Rule 11.6.3**: Layout switching is via `ob_set_info(KeyboardSetLayout=43)` on -`\Device\Keyboard` with a layout name string (e.g., "Spanish", "US"). The `neokey.nxe` +`\Device\Keyboard` with a layout name string (e.g., "es-ES", "en-US"). The `neokey.nxe` binary provides the user interface: `NEOKEY layout `, `NEOKEY layouts`, `NEOKEY show`. **Rule 11.6.4**: Keyboard configuration (active layout, repeat delay/rate, NumLock/CapsLock diff --git a/docs/design/neokbd-design.md b/docs/design/neokbd-design.md index 3b074efb..56e6e42b 100644 --- a/docs/design/neokbd-design.md +++ b/docs/design/neokbd-design.md @@ -902,14 +902,20 @@ object::namespace::ob_insert_object("\\Device\\Keyboard", kbd_id); ## Appendix A: Archivos .kbd soportados inicialmente -Los layouts se generan desde los mismos archivos `.klc` existentes, pero convertidos a formato `.kbd` con una herramienta (`tools/kbdcompile/`): +Los layouts se generan con `tools/xkbcompile` desde los XKB del sistema +(`/usr/share/X11/xkb/symbols/*`), sin ficheros `.klc` intermedios: -| Archivo | Layout | LangTag | Scancodes | Compose | -| --- | --- | --- | --- | --- | -| `US.kbd` | US | en-US | 256 | 0 | -| `Spanish.kbd` | Spanish | es-ES | 256 | 54 | +| Archivo | Layout XKB | Nombre | LangTag | Teclas | Compose | +| --- | --- | --- | --- | --- | --- | +| `en-US.kbd` | `us(basic)` | en-US | en-US | 47 | 0 | +| `es-ES.kbd` | `es(deadtilde)` | es-ES | es-ES | 46 | 634 | + +`scripts/gen-keyboard-layouts.sh` los regenera y +`scripts/check-keyboard-layouts.sh` verifica en CI que no estén vacíos ni +desincronizados. -La herramienta `kbdcompile` toma un `.klc` y produce un `.kbd` binario. Se invoca en el build de la imagen, no en runtime. +XKB cubre muchos idiomas con licencia abierta; sus límites (un solo grupo, +niveles 1-3, keysyms exóticos) están en `tools/xkbcompile/README.md`. --- diff --git a/neodos-kernel/src/input/kbd/layout.rs b/neodos-kernel/src/input/kbd/layout.rs index 6098d54f..ee40f141 100644 --- a/neodos-kernel/src/input/kbd/layout.rs +++ b/neodos-kernel/src/input/kbd/layout.rs @@ -73,17 +73,21 @@ impl KbdLayout { const KBD_MAGIC: [u8; 4] = [b'K', b'B', b'D', 0]; pub fn load_kbd(path: &str) -> Result { - let mut buf = [0u8; 8192]; - let size = crate::globals::with_vfs(|vfs| -> Result { + let data: Vec = crate::globals::with_vfs(|vfs| -> Result, ()> { let (drive_idx, node) = vfs.resolve_path(path).map_err(|_| ())?; if node.mode & crate::fs::vfs::MODE_FILE == 0 { return Err(()); } - let mut tmp = [0u8; 8192]; - let n = vfs.read(drive_idx, node.inode, 0, &mut tmp).map_err(|_| ())?; - buf[..n].copy_from_slice(&tmp[..n]); - Ok(n) + // Size the buffer from the inode: layout files grow with their compose + // table, so a fixed cap would silently truncate them. + let mut buf = Vec::new(); + buf.resize((node.size as usize).max(4096), 0u8); + let n = vfs.read(drive_idx, node.inode, 0, &mut buf).map_err(|_| ())?; + buf.truncate(n); + Ok(buf) }).map_err(|_| ())?; + let buf: &[u8] = &data; + let size = data.len(); if size < 16 { return Err(()); @@ -157,7 +161,7 @@ pub fn load_layouts() -> usize { let fallback = KbdLayout { name: { let mut n = [0u8; 32]; - n[..2].copy_from_slice(b"US"); + n[..5].copy_from_slice(b"en-US"); n }, lang_tag: { @@ -170,20 +174,17 @@ pub fn load_layouts() -> usize { }; layouts.push(fallback); - // Try loading from C:\System\Keyboard\*.kbd - let dir_path = "C:\\System\\Keyboard"; - let has_dir = crate::globals::with_vfs(|vfs| -> bool { - vfs.resolve_path(dir_path).is_ok() - }); - - if has_dir { - // Try Spanish.kbd - if let Ok(layout) = load_kbd("C:\\System\\Keyboard\\Spanish.kbd") { - // Replace the fallback US if it's Spanish (or keep both) - if layouts.iter().any(|l| l.name_str() == "Spanish") { - // already loaded - } else { - layouts.push(layout); + // Disk layouts (locale-tagged) override the built-in fallback when present. + // Generate/verify all locales with scripts/gen-keyboard-layouts.sh; only + // these are enabled because loading many layouts at boot stresses the page + // cache and triggers #709, so more are enabled once that is fixed. + const LAYOUT_TAGS: &[&str] = &["en-US", "es-ES"]; + for tag in LAYOUT_TAGS { + let path = alloc::format!("C:\\System\\Keyboard\\{tag}.kbd"); + if let Ok(layout) = load_kbd(&path) { + match layouts.iter().position(|l| l.name_str() == layout.name_str()) { + Some(i) => layouts[i] = layout, + None => layouts.push(layout), } } } @@ -243,19 +244,23 @@ pub fn is_dead_key(layout: &KbdLayout, scancode: u8, modifiers: u8) -> bool { flags == 1 } +/// Compose `base` with a pending `dead` key using `layout`'s compose table. +pub fn compose_in(layout: &KbdLayout, dead: u16, base: u16) -> u16 { + for entry in &layout.compose { + if entry.dead == dead && entry.base == base { + return entry.result; + } + } + // No compose match found; return the base character (or itself). + if dead == base { dead } else { b'?' as u16 } +} + pub fn compose(dead: u16, base: u16) -> u16 { - // Try to find a compose entry matching this dead+base combination let kbd = crate::kbd::KBD.lock(); - if let Some(layout) = kbd.active_layout() { - for entry in &layout.compose { - if entry.dead == dead && entry.base == base { - return entry.result; - } - } + match kbd.active_layout() { + Some(layout) => compose_in(layout, dead, base), + None => if dead == base { dead } else { b'?' as u16 }, } - // No compose match found; return '?' or the base character - if dead == base { dead } - else { b'?' as u16 } } fn builtin_us_layout() -> [KeyEntry; 256] { diff --git a/neodos-kernel/src/input/kbd/mod.rs b/neodos-kernel/src/input/kbd/mod.rs index a9b825c4..7cc769ad 100644 --- a/neodos-kernel/src/input/kbd/mod.rs +++ b/neodos-kernel/src/input/kbd/mod.rs @@ -67,7 +67,7 @@ pub struct KbdConfig { impl Default for KbdConfig { fn default() -> Self { KbdConfig { - layout_name: "US".to_string(), + layout_name: "en-US".to_string(), repeat_delay: 500, repeat_rate: 30, numlock_on_boot: true, @@ -118,10 +118,28 @@ impl NeoKbd { }).map(|i| i as u32) } + /// Resolve a configured layout name against the loaded layouts: by name, + /// then by language tag, then mapping pre-locale-tag names (`US`, + /// `Spanish`). Kept dynamic so it tracks whatever is actually loaded. + pub fn resolve_layout_index(&self, name: &str) -> Option { + if let Some(i) = self.find_layout_index(name) { + return Some(i); + } + if let Some(i) = self + .layouts + .iter() + .position(|l| l.lang_tag_str().eq_ignore_ascii_case(name)) + { + return Some(i as u32); + } + legacy_layout_alias(name).and_then(|alias| self.find_layout_index(alias)) + } + pub fn set_layout_by_name(&mut self, name: &str) -> Result<(), ()> { let idx = self.find_layout_index(name).ok_or(())?; self.state.active_layout_index = idx; - self.config.layout_name = name.to_string(); + // Canonicalise to the layout's own name (e.g. "es-ES", not "spanish"). + self.config.layout_name = self.layouts[idx as usize].name_str().to_string(); let _ = config::kbd_save_config(&self.config); let _ = crate::eventbus::EVENT_BUS.push_event( crate::eventbus::EVENT_KEYB_LAYOUT, @@ -318,6 +336,17 @@ lazy_static! { pub static ref KBD: Mutex = Mutex::new(NeoKbd::new()); } +/// Map a pre-locale-tag layout name to its current tag (legacy Registry values). +fn legacy_layout_alias(name: &str) -> Option<&'static str> { + if name.eq_ignore_ascii_case("us") { + Some("en-US") + } else if name.eq_ignore_ascii_case("spanish") || name.eq_ignore_ascii_case("sp") { + Some("es-ES") + } else { + None + } +} + pub fn kbd_init() { kinfo!(LogSubsys::Kbd, "Initializing Keyboard Manager (NeoKBD)..."); @@ -334,11 +363,21 @@ pub fn kbd_init() { let config = config::kbd_load_config(); let mut kbd = KBD.lock(); kbd.config = config; - if let Some(idx) = kbd.find_layout_index(&kbd.config.layout_name) { + + // Resolve the persisted layout against the layouts actually loaded, then + // persist the canonical name when it changed. This migrates legacy values + // (`US`/`Spanish`) to `en-US`/`es-ES` and normalises unknown values to the + // default, so the Registry converges instead of keeping a stale name. + if !kbd.layouts.is_empty() { + let requested = kbd.config.layout_name.clone(); + let idx = kbd.resolve_layout_index(&requested).unwrap_or(0); kbd.state.active_layout_index = idx; - } else if !kbd.layouts.is_empty() { - kbd.state.active_layout_index = 0; - kbd.config.layout_name = kbd.layouts[0].name_str().to_string(); + let canonical = kbd.layouts[idx as usize].name_str().to_string(); + let changed = canonical != requested; + kbd.config.layout_name = canonical; + if changed { + let _ = config::kbd_save_config(&kbd.config); + } } if kbd.config.numlock_on_boot { kbd.state.modifiers |= KBD_NUMLOCK; @@ -357,3 +396,121 @@ pub fn kbd_init() { kinfo!(LogSubsys::Kbd, "Ready (layout: {})", layout_name); } + +// ── Tests ────────────────────────────────────────────────────────── + +pub fn register_tests() { + use crate::{test_case, test_eq, test_true}; + + // Layout switching: lookup is case-insensitive, switching updates the + // active index and canonicalises the persisted config name. + test_case!("kbd_switch_layout_by_name", { + let mut kbd = NeoKbd::new(); + let mk = |name: &str| { + let mut lay = KbdLayout { + name: [0u8; 32], + lang_tag: [0u8; 16], + entries: [layout::KeyEntry::empty(); 256], + compose: Vec::new(), + }; + lay.name[..name.len()].copy_from_slice(name.as_bytes()); + lay + }; + kbd.layouts.push(mk("en-US")); + kbd.layouts.push(mk("es-ES")); + + test_eq!(kbd.find_layout_index("en-US"), Some(0)); + test_eq!(kbd.find_layout_index("EN-us"), Some(0)); + test_eq!(kbd.find_layout_index("es-ES"), Some(1)); + test_eq!(kbd.find_layout_index("de-DE"), None); + // Dynamic resolution: legacy aliases map onto the loaded layouts. + test_eq!(kbd.resolve_layout_index("Spanish"), Some(1)); + test_eq!(kbd.resolve_layout_index("SP"), Some(1)); + test_eq!(kbd.resolve_layout_index("US"), Some(0)); + test_eq!(kbd.resolve_layout_index("de-DE"), None); + test_eq!(kbd.active_layout().map(|l| l.name_str()), Some("en-US")); + + test_true!(kbd.set_layout_by_name("es-ES").is_ok()); + test_eq!(kbd.state.active_layout_index, 1); + test_eq!(kbd.active_layout().map(|l| l.name_str()), Some("es-ES")); + test_eq!(kbd.config.layout_name.as_str(), "es-ES"); + }); + + // Both locale-tagged layouts must be present (disk image provides them). + test_case!("kbd_locale_layouts_loaded", { + let kbd = KBD.lock(); + test_true!(kbd.find_layout_index("en-US").is_some()); + test_true!(kbd.find_layout_index("es-ES").is_some()); + }); + + // #394: typing with the Spanish layout produces Spanish characters. + test_case!("kbd_spanish_layout_types", { + let idx = KBD.lock().find_layout_index("es-ES"); + test_true!(idx.is_some()); + let es = KBD.lock().layouts[idx.unwrap_or(0) as usize].clone(); + + // ñ / Ñ / ç and the shifted ; on the Spanish table. + test_eq!(layout::lookup_codepoint(&es, 0x27, 0), Some('ñ' as u16)); + test_eq!(layout::lookup_codepoint(&es, 0x27, KBD_SHIFT), Some('Ñ' as u16)); + test_eq!(layout::lookup_codepoint(&es, 0x2b, 0), Some('ç' as u16)); + test_eq!(layout::lookup_codepoint(&es, 0x33, KBD_SHIFT), Some(';' as u16)); + + // Dead keys: ` (grave), ´ (acute) and ~ (AltGr+4, `es(deadtilde)`). + test_true!(layout::is_dead_key(&es, 0x1a, 0)); + test_true!(layout::is_dead_key(&es, 0x28, 0)); + test_true!(layout::is_dead_key(&es, 0x05, KBD_ALTGR)); + + // Composition: acute+a, tilde+n, diaeresis+u. + test_eq!(layout::compose_in(&es, '´' as u16, 'a' as u16), 'á' as u16); + test_eq!(layout::compose_in(&es, '~' as u16, 'n' as u16), 'ñ' as u16); + test_eq!(layout::compose_in(&es, '¨' as u16, 'u' as u16), 'ü' as u16); + }); + + // The English layout keeps its US punctuation. + test_case!("kbd_us_layout_types", { + let idx = KBD.lock().find_layout_index("en-US"); + test_true!(idx.is_some()); + let en = KBD.lock().layouts[idx.unwrap_or(0) as usize].clone(); + test_eq!(layout::lookup_codepoint(&en, 0x27, 0), Some(';' as u16)); + test_eq!(layout::lookup_codepoint(&en, 0x27, KBD_SHIFT), Some(':' as u16)); + }); + + // A missing / malformed `.kbd` is rejected rather than panicking. + test_case!("kbd_missing_layout_is_rejected", { + test_true!(layout::load_kbd("C:\\System\\Keyboard\\does-not-exist.kbd").is_err()); + }); + + // Repeat delay/rate bounds are enforced by the manager. + test_case!("kbd_repeat_bounds", { + let mut kbd = NeoKbd::new(); + test_true!(kbd.set_repeat_delay(99).is_err()); + test_true!(kbd.set_repeat_delay(100).is_ok()); + test_true!(kbd.set_repeat_delay(2000).is_ok()); + test_true!(kbd.set_repeat_delay(2001).is_err()); + test_true!(kbd.set_repeat_rate(1).is_err()); + test_true!(kbd.set_repeat_rate(2).is_ok()); + test_true!(kbd.set_repeat_rate(60).is_ok()); + test_true!(kbd.set_repeat_rate(61).is_err()); + test_eq!(kbd.config.repeat_delay, 2000); + test_eq!(kbd.config.repeat_rate, 60); + }); + + // LED state is masked to the three defined bits. + test_case!("kbd_leds_masked", { + let mut kbd = NeoKbd::new(); + kbd.set_leds(0xFF); + test_eq!(kbd.state.leds, 0x07); + kbd.set_leds(0x02); + test_eq!(kbd.state.leds, 0x02); + }); + + // Shift make/break updates the modifier state. + test_case!("kbd_shift_toggles_modifier", { + let mut kbd = NeoKbd::new(); + test_eq!(kbd.state.modifiers & KBD_SHIFT, 0); + kbd.process_scancode(0x2A, true); + test_true!(kbd.state.modifiers & KBD_SHIFT != 0); + kbd.process_scancode(0x2A, false); + test_eq!(kbd.state.modifiers & KBD_SHIFT, 0); + }); +} diff --git a/neodos-kernel/src/input/mod.rs b/neodos-kernel/src/input/mod.rs index 8c18fcce..e9e63e7e 100644 --- a/neodos-kernel/src/input/mod.rs +++ b/neodos-kernel/src/input/mod.rs @@ -15,6 +15,8 @@ pub fn register_tests() { // Raw keyboard event ring + VK mapping (#466). crate::input::kbd::ring::register_tests(); + // Layout switching + locale layouts (#394). + crate::input::kbd::register_tests(); test_case!("input_empty_pop", { let buf = InputBuffer::new(); diff --git a/scripts/check-keyboard-layouts.sh b/scripts/check-keyboard-layouts.sh new file mode 100755 index 00000000..79b0f981 --- /dev/null +++ b/scripts/check-keyboard-layouts.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# Verify data/keyboard/.kbd are non-empty and in sync with the XKB +# layouts (issue #702; conversion now via tools/xkbcompile). +# +# Fails if a .kbd is missing, maps no keys, or differs from a fresh compile. +# Run from anywhere; needs the XKB data (package `xkb-data`). +set -euo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$root" + +(cd tools/xkbcompile && cargo build --quiet) +bin=tools/xkbcompile/target/debug/xkbcompile + +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT + +layouts=( + "us:basic:en-US" + "es:deadtilde:es-ES" + "es:cat:ca-ES" + "fr:basic:fr-FR" + "de:basic:de-DE" + "it:basic:it-IT" + "pt:basic:pt-PT" + "br:abnt2:pt-BR" + "nl:basic:nl-NL" + "se:basic:sv-SE" + "dk:basic:da-DK" + "no:basic:nb-NO" + "fi:kotoistus:fi-FI" + "pl:basic:pl-PL" + "cz:basic:cs-CZ" +) + +fail=0 +for spec in "${layouts[@]}"; do + IFS=: read -r layout variant tag <<<"$spec" + kbd="data/keyboard/$tag.kbd" + + if [ ! -f "$kbd" ]; then + echo "check-keyboard-layouts: missing $kbd" >&2 + fail=1 + continue + fi + + if ! "$bin" "$layout" "$variant" --name "$tag" --lang "$tag" --out "$tmp/$tag.kbd" 2>"$tmp/$tag.err"; then + echo "check-keyboard-layouts: xkbcompile failed for $layout($variant)" >&2 + fail=1 + continue + fi + + if ! grep -qE '\([1-9][0-9]* keys' "$tmp/$tag.err"; then + echo "check-keyboard-layouts: $layout($variant) produced no keys" >&2 + fail=1 + fi + + if ! cmp -s "$tmp/$tag.kbd" "$kbd"; then + echo "check-keyboard-layouts: $kbd is stale (run scripts/gen-keyboard-layouts.sh)" >&2 + fail=1 + fi +done + +if [ "$fail" -ne 0 ]; then + echo "check-keyboard-layouts: FAILED" >&2 + exit 1 +fi +echo "check-keyboard-layouts: OK" diff --git a/scripts/gen-keyboard-layouts.sh b/scripts/gen-keyboard-layouts.sh new file mode 100755 index 00000000..55e86e46 --- /dev/null +++ b/scripts/gen-keyboard-layouts.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Regenerate data/keyboard/.kbd from the XKB layouts with xkbcompile. +# +# `layout:variant:tag` — the tag is the NeoDOS layout name/lang (locale tag). +set -euo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$root" + +(cd tools/xkbcompile && cargo build --quiet) +bin=tools/xkbcompile/target/debug/xkbcompile + +# Keep in sync with check-keyboard-layouts.sh. +layouts=( + "us:basic:en-US" + "es:deadtilde:es-ES" + "es:cat:ca-ES" + "fr:basic:fr-FR" + "de:basic:de-DE" + "it:basic:it-IT" + "pt:basic:pt-PT" + "br:abnt2:pt-BR" + "nl:basic:nl-NL" + "se:basic:sv-SE" + "dk:basic:da-DK" + "no:basic:nb-NO" + "fi:kotoistus:fi-FI" + "pl:basic:pl-PL" + "cz:basic:cs-CZ" +) + +for spec in "${layouts[@]}"; do + IFS=: read -r layout variant tag <<<"$spec" + "$bin" "$layout" "$variant" --name "$tag" --lang "$tag" --out "data/keyboard/$tag.kbd" +done + +echo "gen-keyboard-layouts: done" diff --git a/tools/kbdcompile/src/main.rs b/tools/kbdcompile/src/main.rs index cfa380b0..687d40ab 100644 --- a/tools/kbdcompile/src/main.rs +++ b/tools/kbdcompile/src/main.rs @@ -1,3 +1,19 @@ +//! kbdcompile — convert a Microsoft KLC layout into the kernel `.kbd` format. +//! +//! Usage: `kbdcompile [name] [langtag]` +//! +//! KLC sections handled: +//! - `SHIFTSTATE`: ordered list of shift states (e.g. `0 1 2 6 3`). +//! - `LAYOUT`: `SC VK Cap ...` rows. Values are either a +//! single literal character (`q`, `1`) or a 4-hex-digit Unicode codepoint +//! (`00f1`); `-1` means "no mapping"; a trailing `@` marks a dead key. +//! - `DEADKEY `: `base hex -> composed hex` rows. +//! - `COMPOSE`: `dead base result` hex rows. +//! +//! Shift-state columns map onto the kernel `KeyEntry` fields: +//! `0 -> normal`, `1 -> shift`, `2 -> ctrl`, `6 -> altgr`. Dead-key flags use +//! bit0 (normal), bit1 (shift) and bit2 (altgr), matching `layout::is_dead_key`. + use std::fs; const KBD_MAGIC: &[u8; 4] = b"KBD\0"; @@ -18,13 +34,40 @@ impl KeyEntry { } } -#[repr(C)] struct ComposeEntry { dead: u16, base: u16, result: u16, } +/// Parse a KLC layout cell. Returns `(codepoint, is_dead)` or `None` for `-1`. +fn parse_value(tok: &str) -> Option<(u16, bool)> { + let mut s = tok.trim(); + let mut dead = false; + if let Some(stripped) = s.strip_suffix('@') { + dead = true; + s = stripped; + } + if s.is_empty() || s == "-1" { + return None; + } + // Single literal character (`q`, `Q`, `1`, `%`). + if s.chars().count() == 1 { + let c = s.chars().next().unwrap(); + return Some((c as u16, dead)); + } + // 4-hex-digit Unicode codepoint. + if let Ok(v) = u16::from_str_radix(s, 16) { + return Some((v, dead)); + } + // Fallback: first ASCII character. + s.chars().next().filter(char::is_ascii).map(|c| (c as u16, dead)) +} + +fn hex_u16(tok: &str) -> Option { + u16::from_str_radix(tok.trim().trim_start_matches("0x"), 16).ok() +} + fn main() { let args: Vec = std::env::args().collect(); if args.len() < 3 { @@ -34,132 +77,126 @@ fn main() { let input_path = &args[1]; let output_path = &args[2]; - let name = if args.len() > 3 { &args[3] } else { "Unknown" }; - let lang_tag = if args.len() > 4 { &args[4] } else { "xx-XX" }; + let name = if args.len() > 3 { args[3].clone() } else { "Unknown".to_string() }; + let lang_tag = if args.len() > 4 { args[4].clone() } else { "xx-XX".to_string() }; let content = fs::read_to_string(input_path).unwrap_or_default(); + // Strip a UTF-8 BOM if present. + let content = content.strip_prefix('\u{feff}').unwrap_or(&content); - let mut entries: Vec = Vec::with_capacity(256); - for _ in 0..256 { - entries.push(KeyEntry::empty()); - } - let compose: Vec = Vec::new(); + let mut entries: Vec = (0..256).map(|_| KeyEntry::empty()).collect(); + let mut compose: Vec = Vec::new(); + + // Ordered shift states from the SHIFTSTATE block (0 1 2 6 3 ...). + let mut shift_states: Vec = Vec::new(); + let mut current_dead: Option = None; - // Parse KLC format — very simple parser - let mut in_layout = false; - let mut in_shift = false; - let mut in_altgr = false; - let mut shift_map: Vec<(u8, u16)> = Vec::new(); - let mut altgr_map: Vec<(u8, u16)> = Vec::new(); - let mut normal_map: Vec<(u8, u16)> = Vec::new(); + #[derive(PartialEq)] + enum Mode { Scan, ShiftState, Layout, DeadKey, Compose, Skip } + let mut mode = Mode::Scan; - for line in content.lines() { + for raw in content.lines() { + let line = raw.trim_end(); let trimmed = line.trim(); - if trimmed.starts_with("//") || trimmed.is_empty() { + + if trimmed.is_empty() { + // Blank lines are separators inside blocks (e.g. between a + // `DEADKEY` header and its rows); blocks end at the next header. + continue; + } + if trimmed.starts_with("//") { continue; } - match trimmed { - "LAYOUT" => { in_layout = true; continue; } - "SHIFTSTATE 0" => { in_shift = false; in_altgr = false; continue; } - "SHIFTSTATE 1" => { in_shift = true; in_altgr = false; continue; } - "SHIFTSTATE 2" => { in_shift = false; in_altgr = true; continue; } - "COMPOSE" => { - in_layout = false; - continue; - } - "ENDKBD" => break, - "KEYNAME" | "KEYNAME_EXT" | "DEADKEY" | "KEYNAME_DEAD" | "LIGATURE" | "DESCRIPTION" | "CODEPAGE" => { + // Section headers are the first whitespace-delimited token. + let first = trimmed.split_whitespace().next().unwrap_or(""); + match first { + "SHIFTSTATE" => { mode = Mode::ShiftState; continue; } + "LAYOUT" => { mode = Mode::Layout; continue; } + "DEADKEY" => { + let dead = trimmed.split_whitespace().nth(1).and_then(hex_u16); + current_dead = dead; + mode = if dead.is_some() { Mode::DeadKey } else { Mode::Skip }; continue; } - "ENDLAYOUT" => { in_layout = false; continue; } + "COMPOSE" => { mode = Mode::Compose; continue; } + "KEYNAME" | "KEYNAME_EXT" | "KEYNAME_DEAD" | "LIGATURE" | "DESCRIPTIONS" + | "ENDKBD" | "ENDLAYOUT" | "LOCALENAME" | "LOCALEID" | "VERSION" + | "ATTRIBUTES" | "ALTGR" => { mode = Mode::Skip; continue; } _ => {} } - if in_layout && trimmed.starts_with("SC") { - // Parse SCxxxx = hex_value - let parts: Vec<&str> = trimmed.split('=').collect(); - if parts.len() != 2 { continue; } - let sc_str = parts[0].trim().trim_start_matches("SC"); - let val_str = parts[1].trim(); - if let (Ok(sc), Ok(val)) = (u8::from_str_radix(sc_str, 16), u16::from_str_radix(val_str, 16)) { - normal_map.push((sc, val)); + match mode { + Mode::ShiftState => { + if let Ok(state) = first.parse::() { + shift_states.push(state); + } } - } - - if trimmed.starts_with("SC") && !in_layout && !in_shift && !in_altgr { - // Another format: SCxxx=value outside LAYOUT block - let parts: Vec<&str> = trimmed.split('=').collect(); - if parts.len() == 2 { - let sc_str = parts[0].trim().trim_start_matches("SC"); - let val_str = parts[1].trim(); - if let (Ok(sc), Ok(val)) = (u8::from_str_radix(sc_str, 16), u16::from_str_radix(val_str, 16)) { - normal_map.push((sc, val)); + Mode::Layout => { + let cols: Vec<&str> = trimmed.split_whitespace().collect(); + if cols.len() < 3 { continue; } + let sc = match u8::from_str_radix(cols[0], 16) { + Ok(v) => v, + Err(_) => continue, + }; + for (i, state) in shift_states.iter().enumerate() { + let idx = 3 + i; + if idx >= cols.len() { break; } + let (cp, dead) = match parse_value(cols[idx]) { + Some(v) => v, + None => continue, + }; + let e = &mut entries[sc as usize]; + match state { + 0 => { e.normal = cp; if dead { e.flags |= 0x01; } } + 1 => { e.shift = cp; if dead { e.flags |= 0x02; } } + 2 => { e.ctrl = cp; } + 6 => { e.altgr = cp; if dead { e.flags |= 0x04; } } + _ => {} // 3 (shift+ctrl): no kernel column + } } } - } - - if in_shift && trimmed.starts_with("SC") { - let parts: Vec<&str> = trimmed.split('=').collect(); - if parts.len() == 2 { - let sc_str = parts[0].trim().trim_start_matches("SC"); - let val_str = parts[1].trim(); - if let (Ok(sc), Ok(val)) = (u8::from_str_radix(sc_str, 16), u16::from_str_radix(val_str, 16)) { - shift_map.push((sc, val)); + Mode::DeadKey => { + if let Some(dead) = current_dead { + let cols: Vec<&str> = trimmed.split_whitespace().collect(); + if cols.len() >= 2 { + if let (Some(base), Some(result)) = (hex_u16(cols[0]), hex_u16(cols[1])) { + compose.push(ComposeEntry { dead, base, result }); + } + } } } - } - - if in_altgr && trimmed.starts_with("SC") { - let parts: Vec<&str> = trimmed.split('=').collect(); - if parts.len() == 2 { - let sc_str = parts[0].trim().trim_start_matches("SC"); - let val_str = parts[1].trim(); - if let (Ok(sc), Ok(val)) = (u8::from_str_radix(sc_str, 16), u16::from_str_radix(val_str, 16)) { - altgr_map.push((sc, val)); + Mode::Compose => { + let cols: Vec<&str> = trimmed.split_whitespace().collect(); + if cols.len() >= 3 { + if let (Some(dead), Some(base), Some(result)) = + (hex_u16(cols[0]), hex_u16(cols[1]), hex_u16(cols[2])) + { + compose.push(ComposeEntry { dead, base, result }); + } } } + Mode::Scan | Mode::Skip => {} } } - // Fill entries - for (sc, val) in &normal_map { - if (*sc as usize) < 256 { - entries[*sc as usize].normal = *val; - } - } - for (sc, val) in &shift_map { - if (*sc as usize) < 256 { - entries[*sc as usize].shift = *val; - } - } - for (sc, val) in &altgr_map { - if (*sc as usize) < 256 { - entries[*sc as usize].altgr = *val; - } - } + // Write output. + let mut out = Vec::with_capacity(60 + 256 * 16 + 4 + compose.len() * 6); + out.extend_from_slice(KBD_MAGIC); + out.extend_from_slice(&1u32.to_le_bytes()); - // Write output - let mut out = Vec::new(); - out.extend_from_slice(KBD_MAGIC); // Magic: 4 bytes - out.extend_from_slice(&1u32.to_le_bytes()); // Version: 4 bytes - - // Name: 32 bytes let mut name_bytes = [0u8; 32]; let nlen = name.len().min(31); name_bytes[..nlen].copy_from_slice(&name.as_bytes()[..nlen]); out.extend_from_slice(&name_bytes); - // Lang tag: 16 bytes let mut tag_bytes = [0u8; 16]; let tlen = lang_tag.len().min(15); tag_bytes[..tlen].copy_from_slice(&lang_tag.as_bytes()[..tlen]); out.extend_from_slice(&tag_bytes); - // Scancode count: 4 bytes out.extend_from_slice(&256u32.to_le_bytes()); - // Key entries: 256 * 16 = 4096 bytes for entry in &entries { out.extend_from_slice(&entry.normal.to_le_bytes()); out.extend_from_slice(&entry.shift.to_le_bytes()); @@ -169,10 +206,7 @@ fn main() { out.extend_from_slice(&entry._pad); } - // Compose count: 4 bytes out.extend_from_slice(&(compose.len() as u32).to_le_bytes()); - - // Compose entries: each 6 bytes for entry in &compose { out.extend_from_slice(&entry.dead.to_le_bytes()); out.extend_from_slice(&entry.base.to_le_bytes()); @@ -180,5 +214,9 @@ fn main() { } fs::write(output_path, &out).expect("Failed to write output file"); - eprintln!("Wrote {} bytes to {}", out.len(), output_path); + let mapped = entries.iter().filter(|e| e.normal != 0xFFFF).count(); + eprintln!( + "Wrote {} bytes to {} ({} scancodes, {} compose entries)", + out.len(), output_path, mapped, compose.len() + ); } diff --git a/tools/xkbcompile/Cargo.lock b/tools/xkbcompile/Cargo.lock new file mode 100644 index 00000000..65de7452 --- /dev/null +++ b/tools/xkbcompile/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "xkbcompile" +version = "0.1.0" diff --git a/tools/xkbcompile/Cargo.toml b/tools/xkbcompile/Cargo.toml new file mode 100644 index 00000000..fe78b64d --- /dev/null +++ b/tools/xkbcompile/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "xkbcompile" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "xkbcompile" +path = "src/main.rs" + +[dependencies] diff --git a/tools/xkbcompile/README.md b/tools/xkbcompile/README.md new file mode 100644 index 00000000..a4804c6e --- /dev/null +++ b/tools/xkbcompile/README.md @@ -0,0 +1,46 @@ +# xkbcompile + +Host tool: convert a **Linux XKB** layout (`/usr/share/X11/xkb/symbols/*`) into +the NeoDOS `.kbd` keyboard format. Complements `kbdcompile` (which reads the +Microsoft `.klc` format); XKB is offline and openly licensed, so it is the +preferred source for shipping many languages. + +```bash +cargo build +./target/debug/xkbcompile [variant] \ + [--name TAG] [--lang TAG] [--out FILE] \ + [--symbols-dir DIR] [--keycodes FILE] [--compose FILE] + +# Spanish (es) base variant -> data/keyboard/es-ES.kbd +./target/debug/xkbcompile es basic --name es-ES --lang es-ES \ + --out ../../data/keyboard/es-ES.kbd +``` + +Defaults: `--symbols-dir /usr/share/X11/xkb/symbols`, +`--keycodes /usr/share/X11/xkb/keycodes/evdev`, +`--compose /usr/share/X11/locale/en_US.UTF-8/Compose`, `--out +data/keyboard/.kbd`. + +## What it does + +- Resolves `include "file(section)"` chains (e.g. `es(basic)` → + `latin(type4)` → `latin(basic)`), later `key` definitions overriding earlier. +- Maps EVDEV keycodes to PC set-1 scancodes (`set1 = evdev - 8`). +- Maps levels 1-3 to the kernel's `normal`/`shift`/`altgr` columns. +- Translates keysyms: Latin-1 names, `U` Unicode keysyms and the common + punctuation/accent names. +- Marks `dead_*` keysyms (per level) and builds the compose table from the + X11 `Compose` file for the dead keys used. + +## Limits + +- Only the first keyboard group; levels 4+ (Shift+AltGr) are dropped (the + kernel format has no such column). +- Exotic keysyms (Greek, Cyrillic, ligatures, `Greek_*`, `XF86*`) are not + translated and leave the key unmapped. +- The `Ctrl` column is left unset (XKB does not define ctrl levels); the kernel + falls back to the normal/shift value. + +Adding a layout to the OS also needs wiring it into `neodev/src/image.rs` +(`kbd_layouts`) and the kernel load list (`load_layouts()`), until dynamic +`.kbd` discovery lands (#390). diff --git a/tools/xkbcompile/src/main.rs b/tools/xkbcompile/src/main.rs new file mode 100644 index 00000000..49a755f4 --- /dev/null +++ b/tools/xkbcompile/src/main.rs @@ -0,0 +1,747 @@ +//! xkbcompile — convert an XKB layout (`/usr/share/X11/xkb/symbols/*`) into +//! the NeoDOS `.kbd` format. +//! +//! Usage: +//! xkbcompile [variant] [--name TAG] [--lang TAG] [--out FILE] +//! [--symbols-dir DIR] [--keycodes FILE] [--compose FILE] +//! +//! Scope: single group, levels 1-3 (normal / shift / AltGr). It resolves +//! `include "file(section)"` chains, maps EVDEV keycodes to PC set-1 +//! scancodes, translates X11 keysyms (Latin-1 / `U` / the common names), +//! marks dead keys and, when a Compose file is given, builds the composition +//! table for the dead keysyms used. +//! +//! Known limits: only the first keyboard group; levels 4+ (Shift+AltGr) are +//! dropped; exotic keysyms (Greek, Cyrillic, ligatures) are not translated. + +use std::collections::{BTreeMap, HashSet}; +use std::fs; +use std::path::{Path, PathBuf}; + +const KBD_MAGIC: &[u8; 4] = b"KBD\0"; + +#[repr(C)] +#[derive(Clone, Copy)] +struct KeyEntry { + normal: u16, + shift: u16, + altgr: u16, + ctrl: u16, + flags: u8, + _pad: [u8; 7], +} + +impl KeyEntry { + fn empty() -> Self { + KeyEntry { normal: 0xFFFF, shift: 0xFFFF, altgr: 0xFFFF, ctrl: 0xFFFF, flags: 0, _pad: [0u8; 7] } + } +} + +struct Compose { + dead: u16, + base: u16, + result: u16, +} + +struct Args { + layout: String, + variant: Option, + name: String, + lang: String, + out: PathBuf, + symbols_dir: PathBuf, + keycodes: PathBuf, + compose: PathBuf, +} + +fn main() { + let args = match parse_args() { + Ok(a) => a, + Err(msg) => { + eprintln!("xkbcompile: {msg}"); + eprintln!( + "usage: xkbcompile [variant] [--name TAG] [--lang TAG] [--out FILE] \ + [--symbols-dir DIR] [--keycodes FILE] [--compose FILE]" + ); + std::process::exit(2); + } + }; + + let mut keymap: BTreeMap> = BTreeMap::new(); + let mut visited: HashSet<(String, String)> = HashSet::new(); + if let Err(e) = resolve(&args.symbols_dir, &args.layout, args.variant.as_deref(), &mut keymap, &mut visited) { + eprintln!("xkbcompile: {e}"); + std::process::exit(1); + } + + let keycodes = match parse_keycodes(&args.keycodes) { + Ok(m) => m, + Err(e) => { + eprintln!("xkbcompile: {}", e); + std::process::exit(1); + } + }; + + let mut entries = vec![KeyEntry::empty(); 256]; + let mut dead_labels: HashSet = HashSet::new(); + + for (kname, syms) in &keymap { + let ev = match keycodes.get(kname) { + Some(v) => *v, + None => continue, + }; + // PC set-1 scancode = EVDEV keycode - 8 for the alphanumeric block. + if ev < 9 { + continue; + } + let set1 = ev - 8; + if set1 == 0 || set1 > 0x58 { + continue; + } + let mut e = KeyEntry::empty(); + for (level, sym) in syms.iter().enumerate() { + if sym.is_empty() || sym == "NoSymbol" || sym == "VoidSymbol" { + continue; + } + let (cp, dead) = match symbol_to_char(sym) { + Some(v) => v, + None => continue, + }; + if dead { + dead_labels.insert(cp); + } + match level { + 0 => { e.normal = cp; if dead { e.flags |= 0x01; } } + 1 => { e.shift = cp; if dead { e.flags |= 0x02; } } + 2 => { e.altgr = cp; if dead { e.flags |= 0x04; } } + _ => {} // level 4+ (Shift+AltGr): no kernel column + } + } + entries[set1 as usize] = e; + } + + let compose = if args.compose.exists() { + parse_compose(&args.compose, &dead_labels) + } else { + Vec::new() + }; + + let bytes = write_kbd(&args.name, &args.lang, &entries, &compose); + if let Err(e) = fs::write(&args.out, &bytes) { + eprintln!("xkbcompile: cannot write {}: {e}", args.out.display()); + std::process::exit(1); + } + let mapped = entries.iter().filter(|e| e.normal != 0xFFFF).count(); + eprintln!( + "xkbcompile: wrote {} ({} keys, {} compose) [{}]", + args.out.display(), mapped, compose.len(), args.name + ); +} + +fn parse_args() -> Result { + let mut pos: Vec = Vec::new(); + let mut name = None; + let mut lang = None; + let mut out = None; + let mut symbols_dir = PathBuf::from("/usr/share/X11/xkb/symbols"); + let mut keycodes = PathBuf::from("/usr/share/X11/xkb/keycodes/evdev"); + let mut compose = PathBuf::from("/usr/share/X11/locale/en_US.UTF-8/Compose"); + + let mut it = std::env::args().skip(1); + while let Some(a) = it.next() { + match a.as_str() { + "--name" => name = it.next(), + "--lang" => lang = it.next(), + "--out" => out = it.next().map(PathBuf::from), + "--symbols-dir" => symbols_dir = it.next().map(PathBuf::from).ok_or("--symbols-dir needs a value")?, + "--keycodes" => keycodes = it.next().map(PathBuf::from).ok_or("--keycodes needs a value")?, + "--compose" => compose = it.next().map(PathBuf::from).ok_or("--compose needs a value")?, + "-h" | "--help" => return Err("help".into()), + other if other.starts_with("--") => return Err(format!("unknown option {other}")), + other => pos.push(other.to_string()), + } + } + + if pos.is_empty() { + return Err("missing ".into()); + } + let layout = pos[0].clone(); + let variant = pos.get(1).cloned(); + let tag = variant.as_ref().map(|v| format!("{layout}-{v}")).unwrap_or_else(|| layout.clone()); + let name = name.unwrap_or_else(|| tag.clone()); + let lang = lang.unwrap_or(tag); + let out = out.unwrap_or_else(|| PathBuf::from(format!("data/keyboard/{name}.kbd"))); + + Ok(Args { layout, variant, name, lang, out, symbols_dir, keycodes, compose }) +} + +// ── XKB symbols parsing ───────────────────────────────────────────── + +/// Resolve a `xkb_symbols` section, expanding `include` chains in order so +/// that later `key` definitions override earlier (included) ones. +fn resolve( + dir: &Path, + file: &str, + section: Option<&str>, + keymap: &mut BTreeMap>, + visited: &mut HashSet<(String, String)>, +) -> Result<(), String> { + let key = (file.to_string(), section.unwrap_or_default().to_string()); + if !visited.insert(key) { + return Ok(()); // cycle guard + } + + let path = dir.join(file); + let text = fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?; + let body = find_section(&text, section).ok_or_else(|| { + format!("{}: section '{}' not found", path.display(), section.unwrap_or("")) + })?; + let body = strip_comments(&body); + + for stmt in split_statements(&body) { + let s = stmt.trim(); + if s.is_empty() { + continue; + } + if let Some(rest) = s.strip_prefix("include") { + if let Some(spec) = quoted(rest) { + let (f, sec) = parse_include(&spec); + resolve(dir, &f, sec.as_deref(), keymap, visited)?; + } + } else if s.starts_with("key") { + if let Some((kname, syms)) = parse_key(s) { + keymap.insert(kname, syms); + } + } + } + Ok(()) +} + +/// Return the body of `xkb_symbols "name" { ... }`. With `None`, the default +/// (or first) section is used. +fn find_section(text: &str, name: Option<&str>) -> Option { + let mut idx = 0usize; + let mut first: Option = None; + let mut default: Option = None; + + while let Some(rel) = text[idx..].find("xkb_symbols") { + let start = idx + rel; + let after = &text[start + "xkb_symbols".len()..]; + let sname = quoted(after).unwrap_or_default(); + // `default` attribute lives just before `xkb_symbols`. + let prefix_start = text[..start].rfind('}').map(|p| p + 1).unwrap_or(0); + let is_default = text[prefix_start..start].contains("default"); + + let brace = match after.find('{') { + Some(b) => b, + None => break, + }; + let body_start = start + "xkb_symbols".len() + brace + 1; + let body = match balanced_braces(&text[body_start..]) { + Some(b) => b, + None => break, + }; + + if let Some(n) = name { + if sname == n { + return Some(body); + } + } else { + if first.is_none() { + first = Some(body.clone()); + } + if is_default && default.is_none() { + default = Some(body.clone()); + } + } + idx = body_start + body.len() + 1; + } + + if name.is_none() { + default.or(first) + } else { + None + } +} + +/// Drop `//` line comments (XKB names never contain `//`). +fn strip_comments(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + for line in text.lines() { + let cut = line.find("//").unwrap_or(line.len()); + out.push_str(&line[..cut]); + out.push('\n'); + } + out +} + +/// Split a section body into `;`-terminated statements (respecting quotes and +/// brackets/braces). +fn split_statements(body: &str) -> Vec { + let mut out = Vec::new(); + let mut cur = String::new(); + let mut in_quote = false; + let mut depth = 0i32; + for c in body.chars() { + match c { + '"' => { in_quote = !in_quote; cur.push(c); } + '[' | '{' => { depth += 1; cur.push(c); } + ']' | '}' => { depth -= 1; cur.push(c); } + ';' if !in_quote && depth <= 0 => { out.push(std::mem::take(&mut cur)); } + _ => cur.push(c), + } + } + if !cur.trim().is_empty() { + out.push(cur); + } + out +} + +fn parse_key(stmt: &str) -> Option<(String, Vec)> { + let lt = stmt.find('<')?; + let gt = stmt[lt..].find('>')? + lt; + let name = stmt[lt + 1..gt].trim().to_string(); + + // Find the keysym list: the first `[` group not preceded by `type`/`symbols`. + let bytes = stmt.as_bytes(); + let mut i = 0; + while let Some(rel) = stmt[i..].find('[') { + let p = i + rel; + // preceding word + let mut w = p; + while w > 0 && (bytes[w - 1].is_ascii_alphanumeric() || bytes[w - 1] == b'_') { + w -= 1; + } + let word = &stmt[w..p]; + if word == "type" || word == "symbols" { + i = p + 1; + continue; + } + let close = stmt[p..].find(']')? + p; + let inner = &stmt[p + 1..close]; + let syms: Vec = inner + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + return Some((name, syms)); + } + Some((name, Vec::new())) +} + +/// Parse the first quoted string in `s`. +fn quoted(s: &str) -> Option { + let start = s.find('"')? + 1; + let end = s[start..].find('"')? + start; + Some(s[start..end].to_string()) +} + +/// Parse `file` or `file(section)` into its parts. +fn parse_include(spec: &str) -> (String, Option) { + match spec.find('(') { + Some(open) => { + let file = spec[..open].trim().to_string(); + let section = spec[open + 1..].trim_end_matches(')').trim().to_string(); + (file, Some(section)) + } + None => (spec.trim().to_string(), None), + } +} + +/// Body of a `{ ... }` block (the input must start right after the `{`). +fn balanced_braces(s: &str) -> Option { + let mut depth = 1i32; + for (i, c) in s.char_indices() { + match c { + '{' => depth += 1, + '}' => { + depth -= 1; + if depth == 0 { + return Some(s[..i].to_string()); + } + } + _ => {} + } + } + None +} + +// ── keycodes ──────────────────────────────────────────────────────── + +fn parse_keycodes(path: &Path) -> Result, String> { + let text = fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?; + let mut map = BTreeMap::new(); + for line in text.lines() { + let line = line.trim(); + if line.starts_with("//") || line.starts_with("alias") { + continue; + } + // = 24; + if let Some(lt) = line.find('<') { + if let Some(gt) = line[lt..].find('>') { + let name = line[lt + 1..lt + gt].trim().to_string(); + if let Some(eq) = line.find('=') { + let num: String = line[eq + 1..] + .trim_start() + .chars() + .take_while(|c| c.is_ascii_digit()) + .collect(); + if let Ok(v) = num.parse::() { + map.insert(name, v); + } + } + } + } + } + Ok(map) +} + +// ── keysyms ───────────────────────────────────────────────────────── + +/// Map an XKB keysym name to `(codepoint, is_dead)`. +fn symbol_to_char(name: &str) -> Option<(u16, bool)> { + let name = name.trim(); + if name.is_empty() || name == "NoSymbol" || name == "VoidSymbol" { + return None; + } + if let Some(rest) = name.strip_prefix("dead_") { + return dead_label(rest).map(|cp| (cp, true)); + } + // Unicode keysym `U` (4-6 hex digits). + if let Some(hex) = name.strip_prefix('U') { + if hex.len() >= 4 && hex.len() <= 6 && hex.chars().all(|c| c.is_ascii_hexdigit()) { + if let Ok(v) = u32::from_str_radix(hex, 16) { + if let Ok(cp) = u16::try_from(v) { + return Some((cp, false)); + } + } + } + } + if name.chars().count() == 1 { + let c = name.chars().next().unwrap(); + if (c as u32) < 0x80 { + return Some((c as u16, false)); + } + } + named_keysym(name).map(|cp| (cp, false)) +} + +/// Spacing codepoint used as the compose key for each dead keysym (matches the +/// convention of the `.klc`-derived layouts). +fn dead_label(rest: &str) -> Option { + Some(match rest { + "grave" => 0x0060, + "acute" => 0x00B4, + "circumflex" => 0x005E, + "tilde" => 0x007E, + "diaeresis" => 0x00A8, + "macron" => 0x00AF, + "breve" => 0x02D8, + "abovedot" => 0x02D9, + "abovering" => 0x02DA, + "doubleacute" => 0x02DD, + "caron" => 0x02C7, + "cedilla" => 0x00B8, + "ogonek" => 0x02DB, + "belowdot" => 0x0323, + "hook" => 0x0309, + "horn" => 0x031B, + "stroke" => 0x0338, + "belowcomma" => 0x0326, + "iota" => 0x0345, + "voiced_sound" => 0x3099, + "semivoiced_sound" => 0x309A, + _ => return None, + }) +} + +fn named_keysym(name: &str) -> Option { + Some(match name { + "space" => 0x20, + "exclam" => 0x21, + "quotedbl" => 0x22, + "numbersign" => 0x23, + "dollar" => 0x24, + "percent" => 0x25, + "ampersand" => 0x26, + "apostrophe" | "quoteright" => 0x27, + "parenleft" => 0x28, + "parenright" => 0x29, + "asterisk" => 0x2A, + "plus" => 0x2B, + "comma" => 0x2C, + "minus" => 0x2D, + "period" => 0x2E, + "slash" => 0x2F, + "0" => 0x30, + "1" => 0x31, + "2" => 0x32, + "3" => 0x33, + "4" => 0x34, + "5" => 0x35, + "6" => 0x36, + "7" => 0x37, + "8" => 0x38, + "9" => 0x39, + "colon" => 0x3A, + "semicolon" => 0x3B, + "less" => 0x3C, + "equal" => 0x3D, + "greater" => 0x3E, + "question" => 0x3F, + "at" => 0x40, + "bracketleft" => 0x5B, + "backslash" => 0x5C, + "bracketright" => 0x5D, + "asciicircum" => 0x5E, + "underscore" => 0x5F, + "grave" | "quoteleft" => 0x60, + "braceleft" => 0x7B, + "bar" => 0x7C, + "braceright" => 0x7D, + "asciitilde" => 0x7E, + // Latin-1 supplement + "nobreakspace" => 0xA0, + "exclamdown" => 0xA1, + "cent" => 0xA2, + "sterling" => 0xA3, + "currency" => 0xA4, + "yen" => 0xA5, + "brokenbar" => 0xA6, + "section" => 0xA7, + "diaeresis" => 0xA8, + "copyright" => 0xA9, + "ordfeminine" => 0xAA, + "guillemotleft" => 0xAB, + "notsign" => 0xAC, + "hyphen" => 0xAD, + "registered" => 0xAE, + "macron" => 0xAF, + "degree" => 0xB0, + "plusminus" => 0xB1, + "twosuperior" => 0xB2, + "threesuperior" => 0xB3, + "acute" => 0xB4, + "mu" => 0xB5, + "paragraph" => 0xB6, + "periodcentered" => 0xB7, + "cedilla" => 0xB8, + "onesuperior" => 0xB9, + "masculine" => 0xBA, + "guillemotright" => 0xBB, + "onequarter" => 0xBC, + "onehalf" => 0xBD, + "threequarters" => 0xBE, + "questiondown" => 0xBF, + "Agrave" => 0xC0, + "Aacute" => 0xC1, + "Acircumflex" => 0xC2, + "Atilde" => 0xC3, + "Adiaeresis" => 0xC4, + "Aring" => 0xC5, + "AE" => 0xC6, + "Ccedilla" => 0xC7, + "Egrave" => 0xC8, + "Eacute" => 0xC9, + "Ecircumflex" => 0xCA, + "Ediaeresis" => 0xCB, + "Igrave" => 0xCC, + "Iacute" => 0xCD, + "Icircumflex" => 0xCE, + "Idiaeresis" => 0xCF, + "ETH" => 0xD0, + "Ntilde" => 0xD1, + "Ograve" => 0xD2, + "Oacute" => 0xD3, + "Ocircumflex" => 0xD4, + "Otilde" => 0xD5, + "Odiaeresis" => 0xD6, + "multiply" => 0xD7, + "Oslash" => 0xD8, + "Ugrave" => 0xD9, + "Uacute" => 0xDA, + "Ucircumflex" => 0xDB, + "Udiaeresis" => 0xDC, + "Yacute" => 0xDD, + "THORN" => 0xDE, + "ssharp" => 0xDF, + "agrave" => 0xE0, + "aacute" => 0xE1, + "acircumflex" => 0xE2, + "atilde" => 0xE3, + "adiaeresis" => 0xE4, + "aring" => 0xE5, + "ae" => 0xE6, + "ccedilla" => 0xE7, + "egrave" => 0xE8, + "eacute" => 0xE9, + "ecircumflex" => 0xEA, + "ediaeresis" => 0xEB, + "igrave" => 0xEC, + "iacute" => 0xED, + "icircumflex" => 0xEE, + "idiaeresis" => 0xEF, + "eth" => 0xF0, + "ntilde" => 0xF1, + "ograve" => 0xF2, + "oacute" => 0xF3, + "ocircumflex" => 0xF4, + "otilde" => 0xF5, + "odiaeresis" => 0xF6, + "division" => 0xF7, + "oslash" => 0xF8, + "ugrave" => 0xF9, + "uacute" => 0xFA, + "ucircumflex" => 0xFB, + "udiaeresis" => 0xFC, + "yacute" => 0xFD, + "thorn" => 0xFE, + "ydiaeresis" => 0xFF, + // Common extras + "EuroSign" => 0x20AC, + "oe" => 0x0153, + "OE" => 0x0152, + "leftsinglequotemark" => 0x2018, + "rightsinglequotemark" => 0x2019, + "leftdoublequotemark" => 0x201C, + "rightdoublequotemark" => 0x201D, + "endash" => 0x2013, + "emdash" => 0x2014, + "ellipsis" => 0x2026, + "trademark" => 0x2122, + "bullet" => 0x2022, + // Latin Extended-A (Polish, Czech, ...) + "aogonek" => 0x0105, + "Aogonek" => 0x0104, + "cacute" => 0x0107, + "Cacute" => 0x0106, + "eogonek" => 0x0119, + "Eogonek" => 0x0118, + "lstroke" => 0x0142, + "Lstroke" => 0x0141, + "nacute" => 0x0144, + "Nacute" => 0x0143, + "sacute" => 0x015B, + "Sacute" => 0x015A, + "zacute" => 0x017A, + "Zacute" => 0x0179, + "zdot" | "zabovedot" => 0x017C, + "Zdot" | "Zabovedot" => 0x017B, + "ccaron" => 0x010D, + "Ccaron" => 0x010C, + "dcaron" => 0x010F, + "Dcaron" => 0x010E, + "ecaron" => 0x011B, + "Ecaron" => 0x011A, + "ncaron" => 0x0148, + "Ncaron" => 0x0147, + "rcaron" => 0x0159, + "Rcaron" => 0x0158, + "scaron" => 0x0161, + "Scaron" => 0x0160, + "tcaron" => 0x0165, + "Tcaron" => 0x0164, + "uring" => 0x016F, + "Uring" => 0x016E, + "zcaron" => 0x017E, + "Zcaron" => 0x017D, + "abreve" => 0x0103, + "Abreve" => 0x0102, + "dstroke" => 0x0111, + "Dstroke" => 0x0110, + "scedilla" => 0x015F, + "Scedilla" => 0x015E, + "gbreve" => 0x011F, + "Gbreve" => 0x011E, + "idotless" => 0x0131, + _ => return None, + }) +} + +// ── Compose ───────────────────────────────────────────────────────── + +fn parse_compose(path: &Path, dead_labels: &HashSet) -> Vec { + let text = match fs::read_to_string(path) { + Ok(t) => t, + Err(_) => return Vec::new(), + }; + let mut out = Vec::new(); + for line in text.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') || line.starts_with("include") { + continue; + } + let colon = match line.find(':') { + Some(c) => c, + None => continue, + }; + let (keys_part, rest) = line.split_at(colon); + let keys: Vec<&str> = keys_part + .split('<') + .skip(1) + .filter_map(|s| s.split('>').next()) + .collect(); + if keys.len() != 2 { + continue; // only two-key sequences + } + let (d_cp, d_dead) = match symbol_to_char(keys[0]) { + Some(v) => v, + None => continue, + }; + if !d_dead || !dead_labels.contains(&d_cp) { + continue; + } + let base = match symbol_to_char(keys[1]) { + Some((cp, false)) => cp, + _ => continue, + }; + let result = match compose_result(rest) { + Some(cp) => cp, + None => continue, + }; + out.push(Compose { dead: d_cp, base, result }); + } + out +} + +/// First Unicode codepoint of the `"..."` string after the `:`. +fn compose_result(rest: &str) -> Option { + let q = rest.find('"')? + 1; + let end = rest[q..].find('"')? + q; + rest[q..end].chars().next().map(|c| c as u16) +} + +// ── .kbd writer ───────────────────────────────────────────────────── + +fn write_kbd(name: &str, lang: &str, entries: &[KeyEntry], compose: &[Compose]) -> Vec { + let mut out = Vec::with_capacity(60 + 256 * 16 + 4 + compose.len() * 6); + out.extend_from_slice(KBD_MAGIC); + out.extend_from_slice(&1u32.to_le_bytes()); + + let mut name_bytes = [0u8; 32]; + let nlen = name.len().min(31); + name_bytes[..nlen].copy_from_slice(&name.as_bytes()[..nlen]); + out.extend_from_slice(&name_bytes); + + let mut tag_bytes = [0u8; 16]; + let tlen = lang.len().min(15); + tag_bytes[..tlen].copy_from_slice(&lang.as_bytes()[..tlen]); + out.extend_from_slice(&tag_bytes); + + out.extend_from_slice(&256u32.to_le_bytes()); + for e in entries { + out.extend_from_slice(&e.normal.to_le_bytes()); + out.extend_from_slice(&e.shift.to_le_bytes()); + out.extend_from_slice(&e.altgr.to_le_bytes()); + out.extend_from_slice(&e.ctrl.to_le_bytes()); + out.push(e.flags); + out.extend_from_slice(&e._pad); + } + out.extend_from_slice(&(compose.len() as u32).to_le_bytes()); + for c in compose { + out.extend_from_slice(&c.dead.to_le_bytes()); + out.extend_from_slice(&c.base.to_le_bytes()); + out.extend_from_slice(&c.result.to_le_bytes()); + } + out +}