Skip to main content

xkb_type/
keymap.rs

1//! XKB reverse lookup table: char → (Keycode, Modifiers).
2
3use crate::{KeyMapping, KeyTap};
4use std::collections::HashMap;
5use std::process::Command;
6
7#[derive(Debug, Clone)]
8pub struct KeyboardLayout {
9    pub layout: String,
10    pub variant: String,
11}
12
13impl KeyboardLayout {
14    pub fn detect() -> Self {
15        if let Some(kl) = Self::from_hyprland() {
16            return kl;
17        }
18        if let Some(kl) = Self::from_sway() {
19            return kl;
20        }
21        if let Some(kl) = Self::from_env() {
22            return kl;
23        }
24        Self {
25            layout: String::new(),
26            variant: String::new(),
27        }
28    }
29
30    fn from_hyprland() -> Option<Self> {
31        let output = Command::new("hyprctl")
32            .args(["devices", "-j"])
33            .output()
34            .ok()?;
35        if !output.status.success() {
36            return None;
37        }
38        let json: serde_json::Value = serde_json::from_slice(&output.stdout).ok()?;
39        let keyboards = json.get("keyboards")?.as_array()?;
40        let kb = keyboards
41            .iter()
42            .find(|k| {
43                let name = k.get("name").and_then(|n| n.as_str()).unwrap_or("");
44                let layout = k.get("layout").and_then(|l| l.as_str()).unwrap_or("");
45                !layout.is_empty() && (name.contains("translated") || name.contains("at-"))
46            })
47            .or_else(|| {
48                keyboards.iter().find(|k| {
49                    let layout = k.get("layout").and_then(|l| l.as_str()).unwrap_or("");
50                    !layout.is_empty()
51                })
52            })?;
53        let layout = kb.get("layout")?.as_str()?.to_string();
54        let variant = kb
55            .get("variant")
56            .and_then(|v| v.as_str())
57            .unwrap_or("")
58            .to_string();
59        Some(Self { layout, variant })
60    }
61
62    /// Query Sway for the active keyboard layout via `swaymsg -t get_inputs`.
63    ///
64    /// Sway's `xkb_layout_names` array exposes *display* names like
65    /// `"German"` or `"English (US)"`, not the XKB layout codes (`"de"`,
66    /// `"us"`) that `xkbcommon` understands. Feeding a display name into
67    /// `Keymap::new_from_names` causes xkbcommon to silently fall back to
68    /// the compile-time default layout — which in turn would
69    /// short-circuit the env-var / `/etc/default/keyboard` fallbacks that
70    /// can produce the *correct* code.
71    ///
72    /// We therefore confirm Sway is reachable (so the function is not
73    /// dead code) but always return `None`, letting the caller fall
74    /// through to `from_env`. If a real display-name → XKB-code lookup
75    /// table is added later, this is the place to wire it up.
76    fn from_sway() -> Option<Self> {
77        let output = Command::new("swaymsg")
78            .args(["-t", "get_inputs", "--raw"])
79            .output()
80            .ok()?;
81        if !output.status.success() {
82            return None;
83        }
84
85        let inputs: Vec<serde_json::Value> = serde_json::from_slice(&output.stdout).ok()?;
86
87        // Confirm there is at least one keyboard input with an active
88        // layout — guards against running this on an X11 session where
89        // swaymsg may exist but not return useful data.
90        let kb = inputs.iter().find(|i| {
91            i.get("type").and_then(|t| t.as_str()) == Some("keyboard")
92                && i.get("xkb_active_layout_name").is_some()
93        })?;
94
95        // Confirm the layout-name array is well-formed; we don't use
96        // the value because it's a display name, not an XKB code.
97        let layout_names = kb.get("xkb_layout_names")?.as_array()?;
98        let active_idx = kb
99            .get("xkb_active_layout_index")
100            .and_then(|i| i.as_u64())
101            .unwrap_or(0) as usize;
102        let _display_name = layout_names.get(active_idx)?.as_str()?;
103
104        // Intentionally fall through — see the doc comment above.
105        None
106    }
107
108    fn from_env() -> Option<Self> {
109        let layout = std::env::var("XKB_DEFAULT_LAYOUT").ok()?;
110        if layout.is_empty() {
111            return None;
112        }
113        let variant = std::env::var("XKB_DEFAULT_VARIANT").unwrap_or_default();
114        Some(Self { layout, variant })
115    }
116}
117
118pub struct XkbKeymap {
119    map: HashMap<char, KeyMapping>,
120}
121
122impl XkbKeymap {
123    pub fn from_layout(detected: &KeyboardLayout) -> anyhow::Result<Self> {
124        let context = xkbcommon::xkb::Context::new(xkbcommon::xkb::CONTEXT_NO_FLAGS);
125        let keymap = xkbcommon::xkb::Keymap::new_from_names(
126            &context,
127            "",
128            "",
129            &detected.layout,
130            &detected.variant,
131            None,
132            xkbcommon::xkb::KEYMAP_COMPILE_NO_FLAGS,
133        )
134        .ok_or_else(|| {
135            anyhow::anyhow!(
136                "failed to create XKB keymap for layout '{}' variant '{}'",
137                detected.layout,
138                detected.variant
139            )
140        })?;
141        let map = build_reverse_map(&keymap);
142        Ok(Self { map })
143    }
144
145    pub fn lookup(&self, ch: char) -> Option<&KeyMapping> {
146        self.map.get(&ch)
147    }
148    pub fn len(&self) -> usize {
149        self.map.len()
150    }
151    pub fn is_empty(&self) -> bool {
152        self.map.is_empty()
153    }
154}
155
156use xkbcommon::xkb::keysyms::{
157    KEY_dead_acute, KEY_dead_cedilla, KEY_dead_circumflex, KEY_dead_diaeresis, KEY_dead_grave,
158    KEY_dead_tilde,
159};
160
161const ACCENTED_VIA_DEAD_KEY: &[(char, char, u32)] = &[
162    ('ã', 'a', KEY_dead_tilde),
163    ('õ', 'o', KEY_dead_tilde),
164    ('ñ', 'n', KEY_dead_tilde),
165    ('Ã', 'A', KEY_dead_tilde),
166    ('Õ', 'O', KEY_dead_tilde),
167    ('Ñ', 'N', KEY_dead_tilde),
168    ('á', 'a', KEY_dead_acute),
169    ('é', 'e', KEY_dead_acute),
170    ('í', 'i', KEY_dead_acute),
171    ('ó', 'o', KEY_dead_acute),
172    ('ú', 'u', KEY_dead_acute),
173    ('ý', 'y', KEY_dead_acute),
174    ('Á', 'A', KEY_dead_acute),
175    ('É', 'E', KEY_dead_acute),
176    ('Í', 'I', KEY_dead_acute),
177    ('Ó', 'O', KEY_dead_acute),
178    ('Ú', 'U', KEY_dead_acute),
179    ('â', 'a', KEY_dead_circumflex),
180    ('ê', 'e', KEY_dead_circumflex),
181    ('î', 'i', KEY_dead_circumflex),
182    ('ô', 'o', KEY_dead_circumflex),
183    ('û', 'u', KEY_dead_circumflex),
184    ('Â', 'A', KEY_dead_circumflex),
185    ('Ê', 'E', KEY_dead_circumflex),
186    ('Ô', 'O', KEY_dead_circumflex),
187    ('à', 'a', KEY_dead_grave),
188    ('è', 'e', KEY_dead_grave),
189    ('ì', 'i', KEY_dead_grave),
190    ('ò', 'o', KEY_dead_grave),
191    ('ù', 'u', KEY_dead_grave),
192    ('À', 'A', KEY_dead_grave),
193    ('ä', 'a', KEY_dead_diaeresis),
194    ('ë', 'e', KEY_dead_diaeresis),
195    ('ï', 'i', KEY_dead_diaeresis),
196    ('ö', 'o', KEY_dead_diaeresis),
197    ('ü', 'u', KEY_dead_diaeresis),
198    ('ç', 'c', KEY_dead_cedilla),
199    ('Ç', 'C', KEY_dead_cedilla),
200];
201
202#[allow(non_upper_case_globals)]
203pub(crate) fn build_reverse_map(keymap: &xkbcommon::xkb::Keymap) -> HashMap<char, KeyMapping> {
204    let mut map: HashMap<char, KeyMapping> = HashMap::new();
205    let mut dead_keys: HashMap<u32, KeyTap> = HashMap::new();
206    let min = keymap.min_keycode().raw();
207    let max = keymap.max_keycode().raw();
208    let num_layouts = keymap.num_layouts();
209
210    for raw_keycode in min..=max {
211        let keycode = xkbcommon::xkb::Keycode::new(raw_keycode);
212        for layout in 0..num_layouts {
213            let num_levels = keymap.num_levels_for_key(keycode, layout);
214            for level in 0..num_levels {
215                if level > 3 {
216                    continue;
217                }
218                let syms = keymap.key_get_syms_by_level(keycode, layout, level);
219                let evdev_keycode: u16 =
220                    raw_keycode.saturating_sub(8).try_into().unwrap_or(u16::MAX);
221                let shift = level == 1 || level == 3;
222                let altgr = level == 2 || level == 3;
223
224                for &sym in syms {
225                    let raw = sym.raw();
226                    if matches!(
227                        raw,
228                        KEY_dead_grave
229                            | KEY_dead_acute
230                            | KEY_dead_circumflex
231                            | KEY_dead_tilde
232                            | KEY_dead_diaeresis
233                            | KEY_dead_cedilla
234                    ) {
235                        dead_keys.entry(raw).or_insert(KeyTap {
236                            keycode: evdev_keycode,
237                            shift,
238                            altgr,
239                        });
240                        continue;
241                    }
242                    let unicode = xkbcommon::xkb::keysym_to_utf32(sym);
243                    if unicode == 0 {
244                        continue;
245                    }
246                    if let Some(ch) = char::from_u32(unicode) {
247                        map.entry(ch).or_insert(KeyMapping {
248                            main: KeyTap {
249                                keycode: evdev_keycode,
250                                shift,
251                                altgr,
252                            },
253                            follow: None,
254                        });
255                    }
256                }
257            }
258        }
259    }
260
261    // Pass 1: dead_X + Space for literal punctuation
262    for (ch, dead_sym) in [
263        ('\'', KEY_dead_acute),
264        ('"', KEY_dead_diaeresis),
265        ('~', KEY_dead_tilde),
266        ('`', KEY_dead_grave),
267        ('^', KEY_dead_circumflex),
268    ] {
269        if map.contains_key(&ch) {
270            continue;
271        }
272        if let Some(dk) = dead_keys.get(&dead_sym) {
273            map.insert(
274                ch,
275                KeyMapping {
276                    main: *dk,
277                    follow: Some(KeyTap {
278                        keycode: evdev::Key::KEY_SPACE.code(),
279                        shift: false,
280                        altgr: false,
281                    }),
282                },
283            );
284        }
285    }
286
287    // Pass 2: dead_X + base_letter for accented letters
288    for &(ch, base, dead_sym) in ACCENTED_VIA_DEAD_KEY {
289        if map.contains_key(&ch) {
290            continue;
291        }
292        let Some(dk) = dead_keys.get(&dead_sym) else {
293            continue;
294        };
295        let Some(base_map) = map.get(&base).copied() else {
296            continue;
297        };
298        if base_map.follow.is_some() {
299            continue;
300        }
301        map.insert(
302            ch,
303            KeyMapping {
304                main: *dk,
305                follow: Some(base_map.main),
306            },
307        );
308    }
309
310    map
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316
317    fn us_layout() -> KeyboardLayout {
318        KeyboardLayout {
319            layout: "us".to_string(),
320            variant: String::new(),
321        }
322    }
323
324    fn layout(name: &str, variant: &str) -> KeyboardLayout {
325        KeyboardLayout {
326            layout: name.to_string(),
327            variant: variant.to_string(),
328        }
329    }
330
331    fn assert_key(
332        km: &XkbKeymap,
333        ch: char,
334        expected_keycode: u16,
335        expected_shift: bool,
336        label: &str,
337    ) {
338        assert_key_full(km, ch, expected_keycode, expected_shift, false, label);
339    }
340
341    fn assert_key_full(
342        km: &XkbKeymap,
343        ch: char,
344        expected_keycode: u16,
345        expected_shift: bool,
346        expected_altgr: bool,
347        label: &str,
348    ) {
349        let mapping = km
350            .lookup(ch)
351            .unwrap_or_else(|| panic!("'{ch}' should be in {label} keymap"));
352        assert_eq!(
353            mapping.main.keycode, expected_keycode,
354            "'{ch}' should be at evdev {expected_keycode} on {label}, got {}",
355            mapping.main.keycode
356        );
357        assert_eq!(
358            mapping.main.shift, expected_shift,
359            "'{ch}' shift should be {expected_shift} on {label}"
360        );
361        assert_eq!(
362            mapping.main.altgr, expected_altgr,
363            "'{ch}' altgr should be {expected_altgr} on {label}"
364        );
365    }
366
367    #[test]
368    fn build_us_keymap() {
369        let km = XkbKeymap::from_layout(&us_layout());
370        if let Ok(km) = km {
371            assert!(!km.is_empty(), "keymap should not be empty");
372            assert!(km.lookup('a').is_some(), "'a' should be in the keymap");
373        }
374    }
375
376    #[test]
377    fn shift_mapping_for_uppercase() {
378        let km = XkbKeymap::from_layout(&us_layout());
379        if let Ok(km) = km {
380            if let Some(mapping) = km.lookup('A') {
381                assert!(
382                    mapping.main.shift,
383                    "uppercase 'A' should require shift on standard layouts"
384                );
385            }
386        }
387    }
388
389    // --- QWERTZ family (y/z swapped) ---
390
391    #[test]
392    fn german_layout() {
393        let km = XkbKeymap::from_layout(&layout("de", "")).unwrap();
394        assert_key(&km, 'z', 21, false, "German");
395        assert_key(&km, 'y', 44, false, "German");
396    }
397
398    #[test]
399    fn swiss_layout() {
400        let km = XkbKeymap::from_layout(&layout("ch", "")).unwrap();
401        assert_key(&km, 'z', 21, false, "Swiss");
402        assert_key(&km, 'y', 44, false, "Swiss");
403    }
404
405    #[test]
406    fn czech_layout() {
407        let km = XkbKeymap::from_layout(&layout("cz", "")).unwrap();
408        assert_key(&km, 'z', 21, false, "Czech");
409        assert_key(&km, 'y', 44, false, "Czech");
410        assert_key(&km, 'ů', 39, false, "Czech");
411    }
412
413    #[test]
414    fn slovak_layout() {
415        let km = XkbKeymap::from_layout(&layout("sk", "")).unwrap();
416        assert_key(&km, 'z', 21, false, "Slovak");
417        assert_key(&km, 'y', 44, false, "Slovak");
418        assert_key(&km, 'ô', 39, false, "Slovak");
419    }
420
421    #[test]
422    fn hungarian_layout() {
423        let km = XkbKeymap::from_layout(&layout("hu", "")).unwrap();
424        assert_key(&km, 'z', 21, false, "Hungarian");
425        assert_key(&km, 'y', 44, false, "Hungarian");
426        assert_key(&km, 'ö', 11, false, "Hungarian");
427        assert_key(&km, 'ü', 12, false, "Hungarian");
428    }
429
430    // --- AZERTY family (a/q and z/w swapped) ---
431
432    #[test]
433    fn french_layout() {
434        let km = XkbKeymap::from_layout(&layout("fr", "")).unwrap();
435        assert_key(&km, 'a', 16, false, "French");
436        assert_key(&km, 'q', 30, false, "French");
437        assert_key(&km, 'z', 17, false, "French");
438        assert_key(&km, 'w', 44, false, "French");
439    }
440
441    #[test]
442    fn belgian_layout() {
443        let km = XkbKeymap::from_layout(&layout("be", "")).unwrap();
444        assert_key(&km, 'a', 16, false, "Belgian");
445        assert_key(&km, 'q', 30, false, "Belgian");
446        assert_key(&km, 'z', 17, false, "Belgian");
447        assert_key(&km, 'w', 44, false, "Belgian");
448        assert_key(&km, 'm', 39, false, "Belgian");
449    }
450
451    // --- QWERTY-based with special characters ---
452
453    #[test]
454    fn spanish_layout() {
455        let km = XkbKeymap::from_layout(&layout("es", "")).unwrap();
456        assert_key(&km, 'ñ', 39, false, "Spanish");
457    }
458
459    #[test]
460    fn portuguese_layout() {
461        let km = XkbKeymap::from_layout(&layout("pt", "")).unwrap();
462        assert_key(&km, 'a', 30, false, "Portuguese");
463        assert_key(&km, 'z', 44, false, "Portuguese");
464        assert_key(&km, 'q', 16, false, "Portuguese");
465    }
466
467    #[test]
468    fn italian_layout() {
469        let km = XkbKeymap::from_layout(&layout("it", "")).unwrap();
470        assert_key(&km, 'a', 30, false, "Italian");
471        assert_key(&km, 'z', 44, false, "Italian");
472        assert_key(&km, 'q', 16, false, "Italian");
473        assert_key(&km, 'w', 17, false, "Italian");
474    }
475
476    #[test]
477    fn uk_layout() {
478        let km = XkbKeymap::from_layout(&layout("gb", "")).unwrap();
479        assert_key(&km, 'a', 30, false, "UK");
480        assert_key(&km, 'z', 44, false, "UK");
481        // UK has '#' without shift (evdev 43), unlike US where it's Shift+3.
482        assert_key(&km, '#', 43, false, "UK");
483        assert_key(&km, '£', 4, true, "UK");
484    }
485
486    // --- Nordic layouts ---
487
488    #[test]
489    fn swedish_layout() {
490        let km = XkbKeymap::from_layout(&layout("se", "")).unwrap();
491        assert_key(&km, 'ö', 39, false, "Swedish");
492        assert_key(&km, 'ä', 40, false, "Swedish");
493    }
494
495    #[test]
496    fn norwegian_layout() {
497        let km = XkbKeymap::from_layout(&layout("no", "")).unwrap();
498        assert_key(&km, 'ø', 39, false, "Norwegian");
499        assert_key(&km, 'æ', 40, false, "Norwegian");
500    }
501
502    #[test]
503    fn danish_layout() {
504        let km = XkbKeymap::from_layout(&layout("dk", "")).unwrap();
505        assert_key(&km, 'ø', 40, false, "Danish");
506        assert_key(&km, 'æ', 39, false, "Danish");
507    }
508
509    #[test]
510    fn finnish_layout() {
511        let km = XkbKeymap::from_layout(&layout("fi", "")).unwrap();
512        assert_key(&km, 'ö', 39, false, "Finnish");
513        assert_key(&km, 'ä', 40, false, "Finnish");
514    }
515
516    // --- Eastern European ---
517
518    #[test]
519    fn polish_layout() {
520        let km = XkbKeymap::from_layout(&layout("pl", "")).unwrap();
521        assert_key(&km, 'a', 30, false, "Polish");
522        assert_key(&km, 'z', 44, false, "Polish");
523        // Polish accented characters live at level 2 (AltGr) and now go
524        // through uinput directly, no clipboard fallback needed.
525        assert_key_full(&km, 'ą', 30, false, true, "Polish");
526        assert_key_full(&km, 'ę', 18, false, true, "Polish");
527    }
528
529    #[test]
530    fn us_intl_typeable_via_uinput() {
531        let km = XkbKeymap::from_layout(&KeyboardLayout {
532            layout: "us".to_string(),
533            variant: "intl".to_string(),
534        })
535        .unwrap();
536        // Every character that previously had to fall back to clipboard
537        // paste (and was therefore broken in terminals like Alacritty)
538        // must now have a direct uinput route — either as a level 2/3
539        // AltGr key, or via the dead-key + Space fallback table.
540        for ch in [
541            '\'', '"', '~', '`', '^', 'ç', 'á', 'é', 'í', 'ó', 'ú', 'ã', 'ñ',
542        ] {
543            assert!(
544                km.lookup(ch).is_some(),
545                "'{ch}' must be reachable via uinput on us:intl, got no mapping"
546            );
547        }
548    }
549
550    // --- Synthesis routing (locks in direct vs dead-key+follow paths) ---
551
552    /// Pass 1 (literal accent via `dead_X + Space`): for each of the
553    /// chars Pass 1 covers, on us:intl the char must be reachable, and
554    /// — if it ended up routed through synthesis rather than a direct
555    /// level mapping — the follow tap must be unmodified Space.
556    /// Whether any specific char is direct vs synthesized is layout-
557    /// dependent (us:intl puts some literal accents at level 2 directly),
558    /// but the invariant holds: synthesized routes always end with Space.
559    #[test]
560    fn us_intl_pass1_chars_synthesized_routes_end_with_space() {
561        let km = XkbKeymap::from_layout(&KeyboardLayout {
562            layout: "us".to_string(),
563            variant: "intl".to_string(),
564        })
565        .unwrap();
566        for ch in ['\'', '"', '~', '`', '^'] {
567            let mapping = km
568                .lookup(ch)
569                .unwrap_or_else(|| panic!("'{ch}' must be reachable on us:intl"));
570            if let Some(follow) = mapping.follow {
571                assert_eq!(
572                    follow.keycode,
573                    evdev::Key::KEY_SPACE.code(),
574                    "'{ch}' was synthesized; follow tap must be SPACE \
575                     (dead_X + space sequence)"
576                );
577                assert!(
578                    !follow.shift && !follow.altgr,
579                    "'{ch}' synthesis follow tap must be unmodified SPACE"
580                );
581            }
582        }
583    }
584
585    /// Pass 2 (accented letter via `dead_X + base_letter`): `ã` is not
586    /// reachable at any level on us:intl, so it must be synthesized as
587    /// `dead_tilde + a`. This is the deterministic test that proves
588    /// Pass 2 actually fires; the Polish/Spanish tests below cover the
589    /// no-overwrite invariant for layouts that do expose the char
590    /// directly.
591    #[test]
592    fn us_intl_tilde_letter_uses_dead_key_synthesis() {
593        let km = XkbKeymap::from_layout(&KeyboardLayout {
594            layout: "us".to_string(),
595            variant: "intl".to_string(),
596        })
597        .unwrap();
598        let a_main = km.lookup('a').expect("'a' must be in keymap").main;
599        let mapping = km.lookup('ã').expect("ã must be reachable on us:intl");
600        let follow = mapping.follow.expect(
601            "ã on us:intl must be synthesized via dead_tilde + a — \
602             the literal char is not at any level on this layout",
603        );
604        assert_eq!(
605            follow.keycode, a_main.keycode,
606            "ã follow tap must target the same evdev keycode as 'a' \
607             (dead_tilde + a sequence)"
608        );
609    }
610
611    /// Polish exposes `ą` at level 2 directly. The synthesis pass MUST
612    /// NOT overwrite that direct entry — `ą` must keep `follow=None`
613    /// and use AltGr, not synthesize via dead_ogonek + a.
614    #[test]
615    fn polish_accented_letter_is_direct_not_synthesized() {
616        let km = XkbKeymap::from_layout(&layout("pl", "")).unwrap();
617        let mapping = km.lookup('ą').expect("ą must be reachable on Polish");
618        assert!(
619            mapping.follow.is_none(),
620            "ą on Polish must be a direct AltGr tap, not synthesized — \
621             synthesis pass must not overwrite a direct mapping"
622        );
623        assert!(mapping.main.altgr, "ą on Polish must hold AltGr");
624    }
625
626    /// Spanish exposes `ñ` at level 0 directly (it's on the dedicated
627    /// `ñ` key). Even though `ñ` is in `ACCENTED_VIA_DEAD_KEY`, the
628    /// synthesis pass must skip it because the direct entry already
629    /// exists — locks in the cross-layout no-overwrite invariant.
630    #[test]
631    fn spanish_enye_is_direct_not_synthesized() {
632        let km = XkbKeymap::from_layout(&layout("es", "")).unwrap();
633        let mapping = km.lookup('ñ').expect("ñ must be reachable on Spanish");
634        assert!(
635            mapping.follow.is_none(),
636            "ñ on Spanish must be a direct level-0 tap, not synthesized — \
637             synthesis pass must not overwrite even when the char is in \
638             the synthesis table"
639        );
640        assert!(
641            !mapping.main.altgr && !mapping.main.shift,
642            "ñ on Spanish is on a dedicated key — no modifiers required"
643        );
644    }
645
646    // --- Alternative Latin layouts ---
647
648    #[test]
649    fn dvorak_layout() {
650        let km = XkbKeymap::from_layout(&layout("us", "dvorak")).unwrap();
651        assert_key(&km, 'o', 31, false, "Dvorak");
652        assert_key(&km, 'e', 32, false, "Dvorak");
653        assert_key(&km, 's', 39, false, "Dvorak");
654    }
655
656    #[test]
657    fn colemak_layout() {
658        let km = XkbKeymap::from_layout(&layout("us", "colemak")).unwrap();
659        assert_key(&km, 'f', 18, false, "Colemak");
660        assert_key(&km, 'n', 36, false, "Colemak");
661        assert_key(&km, 's', 32, false, "Colemak");
662    }
663
664    // --- Non-Latin layouts ---
665
666    #[test]
667    fn russian_layout() {
668        let km = XkbKeymap::from_layout(&layout("ru", "")).unwrap();
669        assert_key(&km, 'ф', 30, false, "Russian");
670        assert_key(&km, 'я', 44, false, "Russian");
671        assert_key(&km, 'й', 16, false, "Russian");
672        assert_key(&km, 'ц', 17, false, "Russian");
673    }
674
675    #[test]
676    fn ukrainian_layout() {
677        let km = XkbKeymap::from_layout(&layout("ua", "")).unwrap();
678        assert_key(&km, 'ф', 30, false, "Ukrainian");
679        assert_key(&km, 'я', 44, false, "Ukrainian");
680        assert_key(&km, 'й', 16, false, "Ukrainian");
681        assert_key(&km, 'і', 31, false, "Ukrainian");
682    }
683
684    #[test]
685    fn greek_layout() {
686        let km = XkbKeymap::from_layout(&layout("gr", "")).unwrap();
687        assert_key(&km, 'α', 30, false, "Greek");
688        assert_key(&km, 'ζ', 44, false, "Greek");
689        assert_key(&km, 'ω', 47, false, "Greek");
690    }
691
692    #[test]
693    fn japanese_layout() {
694        // Japanese (jp) is QWERTY-based for Latin characters.
695        let km = XkbKeymap::from_layout(&layout("jp", "")).unwrap();
696        assert_key(&km, 'a', 30, false, "Japanese");
697        assert_key(&km, 'z', 44, false, "Japanese");
698    }
699
700    // --- Sway detection regression ---
701
702    /// `KeyboardLayout::from_sway` deliberately returns `None` because
703    /// Sway's `xkb_layout_names` exposes display strings (e.g. `"German"`,
704    /// `"English (US)"`) rather than XKB layout codes (`"de"`, `"us"`).
705    /// Returning `Some(display_name)` would make the caller short-circuit
706    /// the env-var fallback and silently compile against the *default*
707    /// layout, which is the bug this regression test guards against.
708    #[test]
709    fn from_sway_always_returns_none() {
710        // `from_sway` shells out to `swaymsg`; on most CI machines that
711        // command doesn't exist, but if it does we must still see `None`
712        // (the function intentionally never returns `Some` regardless of
713        // what swaymsg replies with).
714        let result = KeyboardLayout::from_sway();
715        assert!(
716            result.is_none(),
717            "from_sway must return None (display names ≠ XKB codes); got {result:?}"
718        );
719    }
720}