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// Logging via the `log` crate (only active with `logging` feature),
8// matching the conditional shim used in `keyboard.rs`.
9#[cfg(feature = "logging")]
10use log::debug;
11#[cfg(not(feature = "logging"))]
12macro_rules! debug {
13    ($($arg:tt)*) => {};
14}
15
16#[derive(Debug, Clone)]
17pub struct KeyboardLayout {
18    pub layout: String,
19    pub variant: String,
20}
21
22impl KeyboardLayout {
23    pub fn detect() -> Self {
24        // Order matters: compositor IPC and X11 reflect the *currently active* session
25        // layout (honoring runtime switches via xkb-switch and friends), so they win when
26        // reachable. localectl is the systemd source-of-truth fallback for cases where
27        // X env vars aren't visible (e.g. running as a systemd --user service, issue #39).
28        // Env vars come last as an explicit user override escape hatch.
29        if let Some(kl) = Self::from_hyprland() {
30            return kl;
31        }
32        if let Some(kl) = Self::from_sway() {
33            return kl;
34        }
35        if let Some(kl) = Self::from_x11() {
36            return kl;
37        }
38        if let Some(kl) = Self::from_localectl() {
39            return kl;
40        }
41        if let Some(kl) = Self::from_env() {
42            return kl;
43        }
44        Self {
45            layout: String::new(),
46            variant: String::new(),
47        }
48    }
49
50    fn from_hyprland() -> Option<Self> {
51        let output = Command::new("hyprctl")
52            .args(["devices", "-j"])
53            .output()
54            .ok()?;
55        if !output.status.success() {
56            return None;
57        }
58        let json: serde_json::Value = serde_json::from_slice(&output.stdout).ok()?;
59        let keyboards = json.get("keyboards")?.as_array()?;
60        let kb = keyboards
61            .iter()
62            .find(|k| {
63                let name = k.get("name").and_then(|n| n.as_str()).unwrap_or("");
64                let layout = k.get("layout").and_then(|l| l.as_str()).unwrap_or("");
65                !layout.is_empty() && (name.contains("translated") || name.contains("at-"))
66            })
67            .or_else(|| {
68                keyboards.iter().find(|k| {
69                    let layout = k.get("layout").and_then(|l| l.as_str()).unwrap_or("");
70                    !layout.is_empty()
71                })
72            })?;
73        let layout = kb.get("layout")?.as_str()?.to_string();
74        let variant = kb
75            .get("variant")
76            .and_then(|v| v.as_str())
77            .unwrap_or("")
78            .to_string();
79        Some(Self { layout, variant })
80    }
81
82    /// Query Sway for the active keyboard layout via `swaymsg -t get_inputs`.
83    ///
84    /// Sway's `xkb_layout_names` array exposes *display* names like
85    /// `"German"` or `"English (US)"`, not the XKB layout codes (`"de"`,
86    /// `"us"`) that `xkbcommon` understands. Feeding a display name into
87    /// `Keymap::new_from_names` causes xkbcommon to silently fall back to
88    /// the compile-time default layout — which in turn would
89    /// short-circuit the env-var / `/etc/default/keyboard` fallbacks that
90    /// can produce the *correct* code.
91    ///
92    /// We therefore confirm Sway is reachable (so the function is not
93    /// dead code) but always return `None`, letting the caller fall
94    /// through to the next fallback. If a real display-name → XKB-code
95    /// lookup table is added later, this is the place to wire it up.
96    fn from_sway() -> Option<Self> {
97        let output = Command::new("swaymsg")
98            .args(["-t", "get_inputs", "--raw"])
99            .output()
100            .ok()?;
101        if !output.status.success() {
102            return None;
103        }
104
105        let inputs: Vec<serde_json::Value> = serde_json::from_slice(&output.stdout).ok()?;
106
107        // Confirm there is at least one keyboard input with an active
108        // layout — guards against running this on an X11 session where
109        // swaymsg may exist but not return useful data.
110        let kb = inputs.iter().find(|i| {
111            i.get("type").and_then(|t| t.as_str()) == Some("keyboard")
112                && i.get("xkb_active_layout_name").is_some()
113        })?;
114
115        // Confirm the layout-name array is well-formed; we don't use
116        // the value because it's a display name, not an XKB code.
117        let layout_names = kb.get("xkb_layout_names")?.as_array()?;
118        let active_idx = kb
119            .get("xkb_active_layout_index")
120            .and_then(|i| i.as_u64())
121            .unwrap_or(0) as usize;
122        let _display_name = layout_names.get(active_idx)?.as_str()?;
123
124        // Intentionally fall through — see the doc comment above.
125        None
126    }
127
128    fn from_x11() -> Option<Self> {
129        if std::env::var_os("DISPLAY").is_none() || std::env::var_os("WAYLAND_DISPLAY").is_some() {
130            return None;
131        }
132
133        let output = match Command::new("setxkbmap").arg("-query").output() {
134            Ok(out) => out,
135            Err(_e) => {
136                debug!("setxkbmap probe failed to spawn: {_e}");
137                return None;
138            }
139        };
140        if !output.status.success() {
141            debug!(
142                "setxkbmap -query exited non-zero: status={:?}",
143                output.status.code()
144            );
145            return None;
146        }
147
148        let stdout = match String::from_utf8(output.stdout) {
149            Ok(s) => s,
150            Err(_e) => {
151                debug!("setxkbmap -query stdout was not valid utf-8: {_e}");
152                return None;
153            }
154        };
155        let parsed = parse_setxkbmap_query(&stdout);
156        if let Some(_kl) = parsed.as_ref() {
157            debug!(
158                "x11 layout detected via setxkbmap: layout={} variant={}",
159                _kl.layout, _kl.variant
160            );
161        } else {
162            debug!("setxkbmap -query returned no parseable layout");
163        }
164        parsed
165    }
166
167    /// Query `localectl status` for the system X11 layout/variant.
168    ///
169    /// `localectl` is a systemd binary that doesn't need `DISPLAY` or
170    /// `XAUTHORITY`. On a systemd host this is the source-of-truth for
171    /// the system keyboard layout when the X11 environment isn't
172    /// available to the daemon (e.g. running under `systemd --user`
173    /// with `DISPLAY` not imported into the user manager).
174    fn from_localectl() -> Option<Self> {
175        let output = match Command::new("localectl").arg("status").output() {
176            Ok(out) => out,
177            Err(_e) => {
178                debug!("localectl probe failed to spawn: {_e}");
179                return None;
180            }
181        };
182        if !output.status.success() {
183            debug!(
184                "localectl status exited non-zero: status={:?}",
185                output.status.code()
186            );
187            return None;
188        }
189
190        let stdout = match String::from_utf8(output.stdout) {
191            Ok(s) => s,
192            Err(_e) => {
193                debug!("localectl status stdout was not valid utf-8: {_e}");
194                return None;
195            }
196        };
197        let parsed = parse_localectl_status(&stdout);
198        if let Some(_kl) = parsed.as_ref() {
199            debug!(
200                "layout detected via localectl: layout={} variant={}",
201                _kl.layout, _kl.variant
202            );
203        } else {
204            debug!("localectl status returned no parseable X11 layout");
205        }
206        parsed
207    }
208
209    fn from_env() -> Option<Self> {
210        let layout = std::env::var("XKB_DEFAULT_LAYOUT").ok()?;
211        if layout.is_empty() {
212            return None;
213        }
214        let variant = std::env::var("XKB_DEFAULT_VARIANT").unwrap_or_default();
215        Some(Self { layout, variant })
216    }
217}
218
219fn parse_setxkbmap_query(output: &str) -> Option<KeyboardLayout> {
220    let mut layout = None;
221    let mut variant = None;
222
223    for line in output.lines() {
224        let Some((key, value)) = line.split_once(':') else {
225            continue;
226        };
227        match key.trim() {
228            "layout" => layout = Some(value.trim().to_string()),
229            "variant" => variant = Some(value.trim().to_string()),
230            _ => {}
231        }
232    }
233
234    let layout = layout.filter(|s| !s.is_empty())?;
235    Some(KeyboardLayout {
236        layout,
237        variant: variant.unwrap_or_default(),
238    })
239}
240
241/// Parse `localectl status` output for the X11 keyboard layout/variant.
242///
243/// `localectl` indents its key/value lines with a varying number of
244/// leading spaces (3 or 4 in practice). Trim each line before splitting
245/// on the first `:` to be robust against that. Only `X11 Layout` and
246/// `X11 Variant` are extracted — other keys (`System Locale`,
247/// `VC Keymap`, `X11 Model`, `X11 Options`) are intentionally ignored
248/// to keep this patch tightly scoped to the layout-detection bug.
249fn parse_localectl_status(output: &str) -> Option<KeyboardLayout> {
250    let mut layout = None;
251    let mut variant = None;
252
253    for line in output.lines() {
254        let trimmed = line.trim();
255        let Some((key, value)) = trimmed.split_once(':') else {
256            continue;
257        };
258        match key.trim() {
259            "X11 Layout" => layout = Some(value.trim().to_string()),
260            "X11 Variant" => variant = Some(value.trim().to_string()),
261            _ => {}
262        }
263    }
264
265    let layout = layout.filter(|s| !s.is_empty())?;
266    Some(KeyboardLayout {
267        layout,
268        variant: variant.unwrap_or_default(),
269    })
270}
271
272pub struct XkbKeymap {
273    map: HashMap<char, KeyMapping>,
274    level3_keycode: u16,
275}
276
277impl XkbKeymap {
278    pub fn from_layout(detected: &KeyboardLayout) -> anyhow::Result<Self> {
279        let context = xkbcommon::xkb::Context::new(xkbcommon::xkb::CONTEXT_NO_FLAGS);
280        let keymap = xkbcommon::xkb::Keymap::new_from_names(
281            &context,
282            "",
283            "",
284            &detected.layout,
285            &detected.variant,
286            None,
287            xkbcommon::xkb::KEYMAP_COMPILE_NO_FLAGS,
288        )
289        .ok_or_else(|| {
290            anyhow::anyhow!(
291                "failed to create XKB keymap for layout '{}' variant '{}'",
292                detected.layout,
293                detected.variant
294            )
295        })?;
296        // Fall back to KEY_RIGHTALT when no dedicated `<LVL3>` key exists
297        // — that's the common case on plain `us`, `de`, etc. where AltGr
298        // *is* RightAlt. Dedicated `<LVL3>` keycodes show up on layouts
299        // like `us:qwerty-fr`.
300        let level3_keycode =
301            find_level3_keycode(&keymap).unwrap_or_else(|| evdev::Key::KEY_RIGHTALT.code());
302        let map = build_reverse_map(&keymap);
303        Ok(Self {
304            map,
305            level3_keycode,
306        })
307    }
308
309    pub fn lookup(&self, ch: char) -> Option<&KeyMapping> {
310        self.map.get(&ch)
311    }
312    pub fn level3_keycode(&self) -> u16 {
313        self.level3_keycode
314    }
315    pub fn len(&self) -> usize {
316        self.map.len()
317    }
318    pub fn is_empty(&self) -> bool {
319        self.map.is_empty()
320    }
321}
322
323use xkbcommon::xkb::keysyms::{
324    KEY_ISO_Level3_Shift, KEY_dead_acute, KEY_dead_cedilla, KEY_dead_circumflex,
325    KEY_dead_diaeresis, KEY_dead_grave, KEY_dead_tilde,
326};
327
328const ACCENTED_VIA_DEAD_KEY: &[(char, char, u32)] = &[
329    ('ã', 'a', KEY_dead_tilde),
330    ('õ', 'o', KEY_dead_tilde),
331    ('ñ', 'n', KEY_dead_tilde),
332    ('Ã', 'A', KEY_dead_tilde),
333    ('Õ', 'O', KEY_dead_tilde),
334    ('Ñ', 'N', KEY_dead_tilde),
335    ('á', 'a', KEY_dead_acute),
336    ('é', 'e', KEY_dead_acute),
337    ('í', 'i', KEY_dead_acute),
338    ('ó', 'o', KEY_dead_acute),
339    ('ú', 'u', KEY_dead_acute),
340    ('ý', 'y', KEY_dead_acute),
341    ('Á', 'A', KEY_dead_acute),
342    ('É', 'E', KEY_dead_acute),
343    ('Í', 'I', KEY_dead_acute),
344    ('Ó', 'O', KEY_dead_acute),
345    ('Ú', 'U', KEY_dead_acute),
346    ('â', 'a', KEY_dead_circumflex),
347    ('ê', 'e', KEY_dead_circumflex),
348    ('î', 'i', KEY_dead_circumflex),
349    ('ô', 'o', KEY_dead_circumflex),
350    ('û', 'u', KEY_dead_circumflex),
351    ('Â', 'A', KEY_dead_circumflex),
352    ('Ê', 'E', KEY_dead_circumflex),
353    ('Ô', 'O', KEY_dead_circumflex),
354    ('à', 'a', KEY_dead_grave),
355    ('è', 'e', KEY_dead_grave),
356    ('ì', 'i', KEY_dead_grave),
357    ('ò', 'o', KEY_dead_grave),
358    ('ù', 'u', KEY_dead_grave),
359    ('À', 'A', KEY_dead_grave),
360    ('ä', 'a', KEY_dead_diaeresis),
361    ('ë', 'e', KEY_dead_diaeresis),
362    ('ï', 'i', KEY_dead_diaeresis),
363    ('ö', 'o', KEY_dead_diaeresis),
364    ('ü', 'u', KEY_dead_diaeresis),
365    ('ç', 'c', KEY_dead_cedilla),
366    ('Ç', 'C', KEY_dead_cedilla),
367];
368
369/// Scan the keymap for a key whose **base-level** (layout 0, level 0)
370/// keysym is `ISO_Level3_Shift`. Returns the evdev keycode of the first
371/// such key that is **not** `KEY_RIGHTALT` — that's the dedicated
372/// `<LVL3>` key on layouts like `us:qwerty-fr`.
373///
374/// Returns `None` when there is no dedicated `<LVL3>` key (the common
375/// case: `ISO_Level3_Shift` is bound to `KEY_RIGHTALT` itself, or not at
376/// all). Callers fall back to `KEY_RIGHTALT` in that case.
377///
378/// Restricted to layout 0 / level 0 because `ISO_Level3_Shift` is a
379/// modifier and lives at the base level — scanning every level would
380/// occasionally pick up keys that *produce* a Level3 modifier as a
381/// shifted symbol, which is not what we want.
382fn find_level3_keycode(keymap: &xkbcommon::xkb::Keymap) -> Option<u16> {
383    let right_alt = evdev::Key::KEY_RIGHTALT.code();
384
385    for raw_keycode in keymap.min_keycode().raw()..=keymap.max_keycode().raw() {
386        let keycode = xkbcommon::xkb::Keycode::new(raw_keycode);
387        let evdev_keycode: u16 = raw_keycode.saturating_sub(8).try_into().unwrap_or(u16::MAX);
388
389        // Restrict to layout 0 / level 0 — `ISO_Level3_Shift` is a
390        // modifier at the base level.
391        let syms = keymap.key_get_syms_by_level(keycode, 0, 0);
392        if !syms.iter().any(|sym| sym.raw() == KEY_ISO_Level3_Shift) {
393            continue;
394        }
395
396        if evdev_keycode != right_alt {
397            return Some(evdev_keycode);
398        }
399    }
400
401    None
402}
403
404#[allow(non_upper_case_globals)]
405pub(crate) fn build_reverse_map(keymap: &xkbcommon::xkb::Keymap) -> HashMap<char, KeyMapping> {
406    let mut map: HashMap<char, KeyMapping> = HashMap::new();
407    let mut dead_keys: HashMap<u32, KeyTap> = HashMap::new();
408    let min = keymap.min_keycode().raw();
409    let max = keymap.max_keycode().raw();
410    let num_layouts = keymap.num_layouts();
411
412    for raw_keycode in min..=max {
413        let keycode = xkbcommon::xkb::Keycode::new(raw_keycode);
414        for layout in 0..num_layouts {
415            let num_levels = keymap.num_levels_for_key(keycode, layout);
416            for level in 0..num_levels {
417                if level > 3 {
418                    continue;
419                }
420                let syms = keymap.key_get_syms_by_level(keycode, layout, level);
421                let evdev_keycode: u16 =
422                    raw_keycode.saturating_sub(8).try_into().unwrap_or(u16::MAX);
423                let shift = level == 1 || level == 3;
424                let altgr = level == 2 || level == 3;
425
426                for &sym in syms {
427                    let raw = sym.raw();
428                    if matches!(
429                        raw,
430                        KEY_dead_grave
431                            | KEY_dead_acute
432                            | KEY_dead_circumflex
433                            | KEY_dead_tilde
434                            | KEY_dead_diaeresis
435                            | KEY_dead_cedilla
436                    ) {
437                        dead_keys.entry(raw).or_insert(KeyTap {
438                            keycode: evdev_keycode,
439                            shift,
440                            altgr,
441                        });
442                        continue;
443                    }
444                    let unicode = xkbcommon::xkb::keysym_to_utf32(sym);
445                    if unicode == 0 {
446                        continue;
447                    }
448                    if let Some(ch) = char::from_u32(unicode) {
449                        map.entry(ch).or_insert(KeyMapping {
450                            main: KeyTap {
451                                keycode: evdev_keycode,
452                                shift,
453                                altgr,
454                            },
455                            follow: None,
456                        });
457                    }
458                }
459            }
460        }
461    }
462
463    // Pass 1: dead_X + Space for literal punctuation
464    for (ch, dead_sym) in [
465        ('\'', KEY_dead_acute),
466        ('"', KEY_dead_diaeresis),
467        ('~', KEY_dead_tilde),
468        ('`', KEY_dead_grave),
469        ('^', KEY_dead_circumflex),
470    ] {
471        if map.contains_key(&ch) {
472            continue;
473        }
474        if let Some(dk) = dead_keys.get(&dead_sym) {
475            map.insert(
476                ch,
477                KeyMapping {
478                    main: *dk,
479                    follow: Some(KeyTap {
480                        keycode: evdev::Key::KEY_SPACE.code(),
481                        shift: false,
482                        altgr: false,
483                    }),
484                },
485            );
486        }
487    }
488
489    // Pass 2: dead_X + base_letter for accented letters
490    for &(ch, base, dead_sym) in ACCENTED_VIA_DEAD_KEY {
491        if map.contains_key(&ch) {
492            continue;
493        }
494        let Some(dk) = dead_keys.get(&dead_sym) else {
495            continue;
496        };
497        let Some(base_map) = map.get(&base).copied() else {
498            continue;
499        };
500        if base_map.follow.is_some() {
501            continue;
502        }
503        map.insert(
504            ch,
505            KeyMapping {
506                main: *dk,
507                follow: Some(base_map.main),
508            },
509        );
510    }
511
512    map
513}
514
515#[cfg(test)]
516mod tests {
517    use super::*;
518
519    fn us_layout() -> KeyboardLayout {
520        KeyboardLayout {
521            layout: "us".to_string(),
522            variant: String::new(),
523        }
524    }
525
526    fn layout(name: &str, variant: &str) -> KeyboardLayout {
527        KeyboardLayout {
528            layout: name.to_string(),
529            variant: variant.to_string(),
530        }
531    }
532
533    fn assert_key(
534        km: &XkbKeymap,
535        ch: char,
536        expected_keycode: u16,
537        expected_shift: bool,
538        label: &str,
539    ) {
540        assert_key_full(km, ch, expected_keycode, expected_shift, false, label);
541    }
542
543    fn assert_key_full(
544        km: &XkbKeymap,
545        ch: char,
546        expected_keycode: u16,
547        expected_shift: bool,
548        expected_altgr: bool,
549        label: &str,
550    ) {
551        let mapping = km
552            .lookup(ch)
553            .unwrap_or_else(|| panic!("'{ch}' should be in {label} keymap"));
554        assert_eq!(
555            mapping.main.keycode, expected_keycode,
556            "'{ch}' should be at evdev {expected_keycode} on {label}, got {}",
557            mapping.main.keycode
558        );
559        assert_eq!(
560            mapping.main.shift, expected_shift,
561            "'{ch}' shift should be {expected_shift} on {label}"
562        );
563        assert_eq!(
564            mapping.main.altgr, expected_altgr,
565            "'{ch}' altgr should be {expected_altgr} on {label}"
566        );
567    }
568
569    #[test]
570    fn build_us_keymap() {
571        let km = XkbKeymap::from_layout(&us_layout());
572        if let Ok(km) = km {
573            assert!(!km.is_empty(), "keymap should not be empty");
574            assert!(km.lookup('a').is_some(), "'a' should be in the keymap");
575        }
576    }
577
578    #[test]
579    fn parse_x11_setxkbmap_query() {
580        let layout = parse_setxkbmap_query(
581            "rules:      evdev\nmodel:      pc104\nlayout:     us\nvariant:    qwerty-fr\n",
582        )
583        .unwrap();
584
585        assert_eq!(layout.layout, "us");
586        assert_eq!(layout.variant, "qwerty-fr");
587    }
588
589    #[test]
590    fn us_qwerty_fr_typeable_via_uinput() {
591        let km = XkbKeymap::from_layout(&layout("us", "qwerty-fr")).unwrap();
592
593        // The real contract: on us:qwerty-fr, AltGr lives behind a
594        // dedicated `<LVL3>` keycode that is *not* KEY_RIGHTALT. The
595        // exact evdev keycode (currently 84 / KEY_KP4) is an XKB-
596        // implementation detail and could shift between xkbcommon
597        // versions, so assert the property rather than the number.
598        assert_ne!(
599            km.level3_keycode(),
600            evdev::Key::KEY_RIGHTALT.code(),
601            "us:qwerty-fr must use the dedicated XKB <LVL3> keycode for \
602             AltGr, not KEY_RIGHTALT"
603        );
604        assert_key_full(&km, 'é', 17, false, true, "US qwerty-fr");
605
606        for ch in ['ç', 'à', 'é', 'è', 'ù'] {
607            assert!(
608                km.lookup(ch).is_some(),
609                "'{ch}' must be reachable via uinput on us:qwerty-fr"
610            );
611        }
612    }
613
614    #[test]
615    fn shift_mapping_for_uppercase() {
616        let km = XkbKeymap::from_layout(&us_layout());
617        if let Ok(km) = km {
618            if let Some(mapping) = km.lookup('A') {
619                assert!(
620                    mapping.main.shift,
621                    "uppercase 'A' should require shift on standard layouts"
622                );
623            }
624        }
625    }
626
627    // --- QWERTZ family (y/z swapped) ---
628
629    #[test]
630    fn german_layout() {
631        let km = XkbKeymap::from_layout(&layout("de", "")).unwrap();
632        assert_key(&km, 'z', 21, false, "German");
633        assert_key(&km, 'y', 44, false, "German");
634    }
635
636    #[test]
637    fn swiss_layout() {
638        let km = XkbKeymap::from_layout(&layout("ch", "")).unwrap();
639        assert_key(&km, 'z', 21, false, "Swiss");
640        assert_key(&km, 'y', 44, false, "Swiss");
641    }
642
643    #[test]
644    fn czech_layout() {
645        let km = XkbKeymap::from_layout(&layout("cz", "")).unwrap();
646        assert_key(&km, 'z', 21, false, "Czech");
647        assert_key(&km, 'y', 44, false, "Czech");
648        assert_key(&km, 'ů', 39, false, "Czech");
649    }
650
651    #[test]
652    fn slovak_layout() {
653        let km = XkbKeymap::from_layout(&layout("sk", "")).unwrap();
654        assert_key(&km, 'z', 21, false, "Slovak");
655        assert_key(&km, 'y', 44, false, "Slovak");
656        assert_key(&km, 'ô', 39, false, "Slovak");
657    }
658
659    #[test]
660    fn hungarian_layout() {
661        let km = XkbKeymap::from_layout(&layout("hu", "")).unwrap();
662        assert_key(&km, 'z', 21, false, "Hungarian");
663        assert_key(&km, 'y', 44, false, "Hungarian");
664        assert_key(&km, 'ö', 11, false, "Hungarian");
665        assert_key(&km, 'ü', 12, false, "Hungarian");
666    }
667
668    // --- AZERTY family (a/q and z/w swapped) ---
669
670    #[test]
671    fn french_layout() {
672        let km = XkbKeymap::from_layout(&layout("fr", "")).unwrap();
673        assert_key(&km, 'a', 16, false, "French");
674        assert_key(&km, 'q', 30, false, "French");
675        assert_key(&km, 'z', 17, false, "French");
676        assert_key(&km, 'w', 44, false, "French");
677    }
678
679    #[test]
680    fn belgian_layout() {
681        let km = XkbKeymap::from_layout(&layout("be", "")).unwrap();
682        assert_key(&km, 'a', 16, false, "Belgian");
683        assert_key(&km, 'q', 30, false, "Belgian");
684        assert_key(&km, 'z', 17, false, "Belgian");
685        assert_key(&km, 'w', 44, false, "Belgian");
686        assert_key(&km, 'm', 39, false, "Belgian");
687    }
688
689    // --- QWERTY-based with special characters ---
690
691    #[test]
692    fn spanish_layout() {
693        let km = XkbKeymap::from_layout(&layout("es", "")).unwrap();
694        assert_key(&km, 'ñ', 39, false, "Spanish");
695    }
696
697    #[test]
698    fn portuguese_layout() {
699        let km = XkbKeymap::from_layout(&layout("pt", "")).unwrap();
700        assert_key(&km, 'a', 30, false, "Portuguese");
701        assert_key(&km, 'z', 44, false, "Portuguese");
702        assert_key(&km, 'q', 16, false, "Portuguese");
703    }
704
705    #[test]
706    fn italian_layout() {
707        let km = XkbKeymap::from_layout(&layout("it", "")).unwrap();
708        assert_key(&km, 'a', 30, false, "Italian");
709        assert_key(&km, 'z', 44, false, "Italian");
710        assert_key(&km, 'q', 16, false, "Italian");
711        assert_key(&km, 'w', 17, false, "Italian");
712    }
713
714    #[test]
715    fn uk_layout() {
716        let km = XkbKeymap::from_layout(&layout("gb", "")).unwrap();
717        assert_key(&km, 'a', 30, false, "UK");
718        assert_key(&km, 'z', 44, false, "UK");
719        // UK has '#' without shift (evdev 43), unlike US where it's Shift+3.
720        assert_key(&km, '#', 43, false, "UK");
721        assert_key(&km, '£', 4, true, "UK");
722    }
723
724    // --- Nordic layouts ---
725
726    #[test]
727    fn swedish_layout() {
728        let km = XkbKeymap::from_layout(&layout("se", "")).unwrap();
729        assert_key(&km, 'ö', 39, false, "Swedish");
730        assert_key(&km, 'ä', 40, false, "Swedish");
731    }
732
733    #[test]
734    fn norwegian_layout() {
735        let km = XkbKeymap::from_layout(&layout("no", "")).unwrap();
736        assert_key(&km, 'ø', 39, false, "Norwegian");
737        assert_key(&km, 'æ', 40, false, "Norwegian");
738    }
739
740    #[test]
741    fn danish_layout() {
742        let km = XkbKeymap::from_layout(&layout("dk", "")).unwrap();
743        assert_key(&km, 'ø', 40, false, "Danish");
744        assert_key(&km, 'æ', 39, false, "Danish");
745    }
746
747    #[test]
748    fn finnish_layout() {
749        let km = XkbKeymap::from_layout(&layout("fi", "")).unwrap();
750        assert_key(&km, 'ö', 39, false, "Finnish");
751        assert_key(&km, 'ä', 40, false, "Finnish");
752    }
753
754    // --- Eastern European ---
755
756    #[test]
757    fn polish_layout() {
758        let km = XkbKeymap::from_layout(&layout("pl", "")).unwrap();
759        assert_key(&km, 'a', 30, false, "Polish");
760        assert_key(&km, 'z', 44, false, "Polish");
761        // Polish accented characters live at level 2 (AltGr) and now go
762        // through uinput directly, no clipboard fallback needed.
763        assert_key_full(&km, 'ą', 30, false, true, "Polish");
764        assert_key_full(&km, 'ę', 18, false, true, "Polish");
765    }
766
767    #[test]
768    fn us_intl_typeable_via_uinput() {
769        let km = XkbKeymap::from_layout(&KeyboardLayout {
770            layout: "us".to_string(),
771            variant: "intl".to_string(),
772        })
773        .unwrap();
774        // Every character that previously had to fall back to clipboard
775        // paste (and was therefore broken in terminals like Alacritty)
776        // must now have a direct uinput route — either as a level 2/3
777        // AltGr key, or via the dead-key + Space fallback table.
778        for ch in [
779            '\'', '"', '~', '`', '^', 'ç', 'á', 'é', 'í', 'ó', 'ú', 'ã', 'ñ',
780        ] {
781            assert!(
782                km.lookup(ch).is_some(),
783                "'{ch}' must be reachable via uinput on us:intl, got no mapping"
784            );
785        }
786    }
787
788    // --- Synthesis routing (locks in direct vs dead-key+follow paths) ---
789
790    /// Pass 1 (literal accent via `dead_X + Space`): for each of the
791    /// chars Pass 1 covers, on us:intl the char must be reachable, and
792    /// — if it ended up routed through synthesis rather than a direct
793    /// level mapping — the follow tap must be unmodified Space.
794    /// Whether any specific char is direct vs synthesized is layout-
795    /// dependent (us:intl puts some literal accents at level 2 directly),
796    /// but the invariant holds: synthesized routes always end with Space.
797    #[test]
798    fn us_intl_pass1_chars_synthesized_routes_end_with_space() {
799        let km = XkbKeymap::from_layout(&KeyboardLayout {
800            layout: "us".to_string(),
801            variant: "intl".to_string(),
802        })
803        .unwrap();
804        for ch in ['\'', '"', '~', '`', '^'] {
805            let mapping = km
806                .lookup(ch)
807                .unwrap_or_else(|| panic!("'{ch}' must be reachable on us:intl"));
808            if let Some(follow) = mapping.follow {
809                assert_eq!(
810                    follow.keycode,
811                    evdev::Key::KEY_SPACE.code(),
812                    "'{ch}' was synthesized; follow tap must be SPACE \
813                     (dead_X + space sequence)"
814                );
815                assert!(
816                    !follow.shift && !follow.altgr,
817                    "'{ch}' synthesis follow tap must be unmodified SPACE"
818                );
819            }
820        }
821    }
822
823    /// Pass 2 (accented letter via `dead_X + base_letter`): `ã` is not
824    /// reachable at any level on us:intl, so it must be synthesized as
825    /// `dead_tilde + a`. This is the deterministic test that proves
826    /// Pass 2 actually fires; the Polish/Spanish tests below cover the
827    /// no-overwrite invariant for layouts that do expose the char
828    /// directly.
829    #[test]
830    fn us_intl_tilde_letter_uses_dead_key_synthesis() {
831        let km = XkbKeymap::from_layout(&KeyboardLayout {
832            layout: "us".to_string(),
833            variant: "intl".to_string(),
834        })
835        .unwrap();
836        let a_main = km.lookup('a').expect("'a' must be in keymap").main;
837        let mapping = km.lookup('ã').expect("ã must be reachable on us:intl");
838        let follow = mapping.follow.expect(
839            "ã on us:intl must be synthesized via dead_tilde + a — \
840             the literal char is not at any level on this layout",
841        );
842        assert_eq!(
843            follow.keycode, a_main.keycode,
844            "ã follow tap must target the same evdev keycode as 'a' \
845             (dead_tilde + a sequence)"
846        );
847    }
848
849    /// Polish exposes `ą` at level 2 directly. The synthesis pass MUST
850    /// NOT overwrite that direct entry — `ą` must keep `follow=None`
851    /// and use AltGr, not synthesize via dead_ogonek + a.
852    #[test]
853    fn polish_accented_letter_is_direct_not_synthesized() {
854        let km = XkbKeymap::from_layout(&layout("pl", "")).unwrap();
855        let mapping = km.lookup('ą').expect("ą must be reachable on Polish");
856        assert!(
857            mapping.follow.is_none(),
858            "ą on Polish must be a direct AltGr tap, not synthesized — \
859             synthesis pass must not overwrite a direct mapping"
860        );
861        assert!(mapping.main.altgr, "ą on Polish must hold AltGr");
862    }
863
864    /// Spanish exposes `ñ` at level 0 directly (it's on the dedicated
865    /// `ñ` key). Even though `ñ` is in `ACCENTED_VIA_DEAD_KEY`, the
866    /// synthesis pass must skip it because the direct entry already
867    /// exists — locks in the cross-layout no-overwrite invariant.
868    #[test]
869    fn spanish_enye_is_direct_not_synthesized() {
870        let km = XkbKeymap::from_layout(&layout("es", "")).unwrap();
871        let mapping = km.lookup('ñ').expect("ñ must be reachable on Spanish");
872        assert!(
873            mapping.follow.is_none(),
874            "ñ on Spanish must be a direct level-0 tap, not synthesized — \
875             synthesis pass must not overwrite even when the char is in \
876             the synthesis table"
877        );
878        assert!(
879            !mapping.main.altgr && !mapping.main.shift,
880            "ñ on Spanish is on a dedicated key — no modifiers required"
881        );
882    }
883
884    // --- Alternative Latin layouts ---
885
886    #[test]
887    fn dvorak_layout() {
888        let km = XkbKeymap::from_layout(&layout("us", "dvorak")).unwrap();
889        assert_key(&km, 'o', 31, false, "Dvorak");
890        assert_key(&km, 'e', 32, false, "Dvorak");
891        assert_key(&km, 's', 39, false, "Dvorak");
892    }
893
894    #[test]
895    fn colemak_layout() {
896        let km = XkbKeymap::from_layout(&layout("us", "colemak")).unwrap();
897        assert_key(&km, 'f', 18, false, "Colemak");
898        assert_key(&km, 'n', 36, false, "Colemak");
899        assert_key(&km, 's', 32, false, "Colemak");
900    }
901
902    // --- Non-Latin layouts ---
903
904    #[test]
905    fn russian_layout() {
906        let km = XkbKeymap::from_layout(&layout("ru", "")).unwrap();
907        assert_key(&km, 'ф', 30, false, "Russian");
908        assert_key(&km, 'я', 44, false, "Russian");
909        assert_key(&km, 'й', 16, false, "Russian");
910        assert_key(&km, 'ц', 17, false, "Russian");
911    }
912
913    #[test]
914    fn ukrainian_layout() {
915        let km = XkbKeymap::from_layout(&layout("ua", "")).unwrap();
916        assert_key(&km, 'ф', 30, false, "Ukrainian");
917        assert_key(&km, 'я', 44, false, "Ukrainian");
918        assert_key(&km, 'й', 16, false, "Ukrainian");
919        assert_key(&km, 'і', 31, false, "Ukrainian");
920    }
921
922    #[test]
923    fn greek_layout() {
924        let km = XkbKeymap::from_layout(&layout("gr", "")).unwrap();
925        assert_key(&km, 'α', 30, false, "Greek");
926        assert_key(&km, 'ζ', 44, false, "Greek");
927        assert_key(&km, 'ω', 47, false, "Greek");
928    }
929
930    #[test]
931    fn japanese_layout() {
932        // Japanese (jp) is QWERTY-based for Latin characters.
933        let km = XkbKeymap::from_layout(&layout("jp", "")).unwrap();
934        assert_key(&km, 'a', 30, false, "Japanese");
935        assert_key(&km, 'z', 44, false, "Japanese");
936    }
937
938    // --- Sway detection regression ---
939
940    /// `KeyboardLayout::from_sway` deliberately returns `None` because
941    /// Sway's `xkb_layout_names` exposes display strings (e.g. `"German"`,
942    /// `"English (US)"`) rather than XKB layout codes (`"de"`, `"us"`).
943    /// Returning `Some(display_name)` would make the caller short-circuit
944    /// the env-var fallback and silently compile against the *default*
945    /// layout, which is the bug this regression test guards against.
946    #[test]
947    fn from_sway_always_returns_none() {
948        // `from_sway` shells out to `swaymsg`; on most CI machines that
949        // command doesn't exist, but if it does we must still see `None`
950        // (the function intentionally never returns `Some` regardless of
951        // what swaymsg replies with).
952        let result = KeyboardLayout::from_sway();
953        assert!(
954            result.is_none(),
955            "from_sway must return None (display names ≠ XKB codes); got {result:?}"
956        );
957    }
958
959    // --- localectl parser tests ---
960
961    #[test]
962    fn parse_localectl_us_no_variant() {
963        let output = "\
964System Locale: LANG=en_US.UTF-8
965    VC Keymap: us
966   X11 Layout: us
967    X11 Model: pc105+inet
968  X11 Options: terminate:ctrl_alt_bksp
969";
970        let kl = parse_localectl_status(output).expect("should parse US layout");
971        assert_eq!(kl.layout, "us");
972        assert_eq!(kl.variant, "");
973    }
974
975    #[test]
976    fn parse_localectl_fr_bepo_full() {
977        let output = "\
978System Locale: LANG=en_US.UTF-8
979    VC Keymap: fr-bepo
980   X11 Layout: fr
981    X11 Model: pc104
982  X11 Variant: bepo
983  X11 Options: caps:swapescape,compose:rwin
984";
985        let kl = parse_localectl_status(output).expect("should parse fr(bepo)");
986        assert_eq!(kl.layout, "fr");
987        assert_eq!(kl.variant, "bepo");
988    }
989
990    #[test]
991    fn parse_localectl_empty_layout_returns_none() {
992        let output = "\
993System Locale: LANG=en_US.UTF-8
994    VC Keymap: us
995   X11 Layout:
996    X11 Model: pc105
997";
998        assert!(parse_localectl_status(output).is_none());
999    }
1000
1001    #[test]
1002    fn parse_localectl_missing_x11_section_returns_none() {
1003        let output = "\
1004System Locale: LANG=en_US.UTF-8
1005    VC Keymap: us
1006";
1007        assert!(parse_localectl_status(output).is_none());
1008    }
1009
1010    #[test]
1011    fn parse_localectl_tolerates_whitespace_variants() {
1012        // Mix tabs, varying leading-space widths, and trailing whitespace.
1013        let output = "System Locale: LANG=en_US.UTF-8\n\
1014\t\tVC Keymap: fr-bepo\n\
1015 X11 Layout:   fr  \n\
1016      X11 Model: pc104\n\
1017\tX11 Variant:\tbepo\n";
1018        let kl = parse_localectl_status(output).expect("should tolerate whitespace mix");
1019        assert_eq!(kl.layout, "fr");
1020        assert_eq!(kl.variant, "bepo");
1021    }
1022
1023    #[test]
1024    fn parse_localectl_ignores_unknown_keys() {
1025        let output = "\
1026System Locale: LANG=en_US.UTF-8
1027    VC Keymap: us
1028          Foo: bar
1029   X11 Layout: us
1030    X11 Model: pc105
1031       Random: line with: multiple colons
1032  X11 Variant: dvorak
1033";
1034        let kl = parse_localectl_status(output).expect("should ignore unknown keys");
1035        assert_eq!(kl.layout, "us");
1036        assert_eq!(kl.variant, "dvorak");
1037    }
1038
1039    // --- localectl chain-gap regression ---
1040
1041    /// Issue #39: detection must have at least one source that works
1042    /// without any X session env vars on a systemd host. `from_localectl`
1043    /// fills that gap. If `localectl` is installed (the case on every
1044    /// systemd distro), it must return `Some(layout)` with a non-empty
1045    /// layout code — this is what prevents the US/QWERTY fallback that
1046    /// produced garbled output for fr(bepo) users running under
1047    /// `systemd --user`.
1048    #[test]
1049    fn from_localectl_returns_layout_when_available() {
1050        // Skip if the binary is unavailable (e.g. non-systemd CI image).
1051        if Command::new("localectl").arg("--version").output().is_err() {
1052            eprintln!("skipping: localectl not on PATH");
1053            return;
1054        }
1055        let result = KeyboardLayout::from_localectl();
1056        let kl = result.expect(
1057            "from_localectl must return Some(_) on a systemd host — \
1058             this is the detection-chain gap that allowed issue #39",
1059        );
1060        assert!(
1061            !kl.layout.is_empty(),
1062            "from_localectl returned Some with empty layout: {kl:?}"
1063        );
1064    }
1065}