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    /// What each tap in `map` produces on a keyboard with this layout.
276    #[cfg(feature = "xtest")]
277    group1: Group1Keysyms,
278}
279
280impl XkbKeymap {
281    pub fn from_layout(detected: &KeyboardLayout) -> anyhow::Result<Self> {
282        let context = xkbcommon::xkb::Context::new(xkbcommon::xkb::CONTEXT_NO_FLAGS);
283        let keymap = xkbcommon::xkb::Keymap::new_from_names(
284            &context,
285            "",
286            "",
287            &detected.layout,
288            &detected.variant,
289            None,
290            xkbcommon::xkb::KEYMAP_COMPILE_NO_FLAGS,
291        )
292        .ok_or_else(|| {
293            anyhow::anyhow!(
294                "failed to create XKB keymap for layout '{}' variant '{}'",
295                detected.layout,
296                detected.variant
297            )
298        })?;
299        // Fall back to KEY_RIGHTALT when no dedicated `<LVL3>` key exists
300        // — that's the common case on plain `us`, `de`, etc. where AltGr
301        // *is* RightAlt. Dedicated `<LVL3>` keycodes show up on layouts
302        // like `us:qwerty-fr`.
303        let level3_keycode =
304            find_level3_keycode(&keymap).unwrap_or_else(|| evdev::Key::KEY_RIGHTALT.code());
305        let map = build_reverse_map(&keymap);
306        Ok(Self {
307            map,
308            level3_keycode,
309            #[cfg(feature = "xtest")]
310            group1: Group1Keysyms::from_keymap(&keymap),
311        })
312    }
313
314    pub fn lookup(&self, ch: char) -> Option<&KeyMapping> {
315        self.map.get(&ch)
316    }
317    /// Every character the keymap can type, with how to type it.
318    #[cfg(feature = "xtest")]
319    pub(crate) fn iter(&self) -> impl Iterator<Item = (char, &KeyMapping)> {
320        self.map.iter().map(|(ch, m)| (*ch, m))
321    }
322    /// The group 1 keysyms of this layout's keys.
323    ///
324    /// A character [`build_reverse_map`] found in a later group (`ф` on
325    /// `us,ru`) is typed with a tap that means another keysym in group 1
326    /// (`a`): that keysym is what typing it gives while group 1 is active.
327    #[cfg(feature = "xtest")]
328    pub(crate) fn group1(&self) -> &Group1Keysyms {
329        &self.group1
330    }
331    /// Keep only the characters whose mapping `keep` accepts; the rest are
332    /// typed like characters the layout lacks (pasted).
333    #[cfg(feature = "xtest")]
334    pub(crate) fn retain(&mut self, mut keep: impl FnMut(&KeyMapping) -> bool) {
335        self.map.retain(|_, m| keep(m));
336    }
337    pub fn level3_keycode(&self) -> u16 {
338        self.level3_keycode
339    }
340    pub fn len(&self) -> usize {
341        self.map.len()
342    }
343    pub fn is_empty(&self) -> bool {
344        self.map.is_empty()
345    }
346}
347
348use xkbcommon::xkb::keysyms::{
349    KEY_ISO_Level3_Shift, KEY_dead_acute, KEY_dead_cedilla, KEY_dead_circumflex,
350    KEY_dead_diaeresis, KEY_dead_grave, KEY_dead_tilde,
351};
352
353const ACCENTED_VIA_DEAD_KEY: &[(char, char, u32)] = &[
354    ('ã', 'a', KEY_dead_tilde),
355    ('õ', 'o', KEY_dead_tilde),
356    ('ñ', 'n', KEY_dead_tilde),
357    ('Ã', 'A', KEY_dead_tilde),
358    ('Õ', 'O', KEY_dead_tilde),
359    ('Ñ', 'N', KEY_dead_tilde),
360    ('á', 'a', KEY_dead_acute),
361    ('é', 'e', KEY_dead_acute),
362    ('í', 'i', KEY_dead_acute),
363    ('ó', 'o', KEY_dead_acute),
364    ('ú', 'u', KEY_dead_acute),
365    ('ý', 'y', KEY_dead_acute),
366    ('Á', 'A', KEY_dead_acute),
367    ('É', 'E', KEY_dead_acute),
368    ('Í', 'I', KEY_dead_acute),
369    ('Ó', 'O', KEY_dead_acute),
370    ('Ú', 'U', KEY_dead_acute),
371    ('â', 'a', KEY_dead_circumflex),
372    ('ê', 'e', KEY_dead_circumflex),
373    ('î', 'i', KEY_dead_circumflex),
374    ('ô', 'o', KEY_dead_circumflex),
375    ('û', 'u', KEY_dead_circumflex),
376    ('Â', 'A', KEY_dead_circumflex),
377    ('Ê', 'E', KEY_dead_circumflex),
378    ('Ô', 'O', KEY_dead_circumflex),
379    ('à', 'a', KEY_dead_grave),
380    ('è', 'e', KEY_dead_grave),
381    ('ì', 'i', KEY_dead_grave),
382    ('ò', 'o', KEY_dead_grave),
383    ('ù', 'u', KEY_dead_grave),
384    ('À', 'A', KEY_dead_grave),
385    ('ä', 'a', KEY_dead_diaeresis),
386    ('ë', 'e', KEY_dead_diaeresis),
387    ('ï', 'i', KEY_dead_diaeresis),
388    ('ö', 'o', KEY_dead_diaeresis),
389    ('ü', 'u', KEY_dead_diaeresis),
390    ('ç', 'c', KEY_dead_cedilla),
391    ('Ç', 'C', KEY_dead_cedilla),
392];
393
394/// Scan the keymap for a key whose **base-level** (layout 0, level 0)
395/// keysym is `ISO_Level3_Shift`. Returns the evdev keycode of the first
396/// such key that is **not** `KEY_RIGHTALT` — that's the dedicated
397/// `<LVL3>` key on layouts like `us:qwerty-fr`.
398///
399/// Returns `None` when there is no dedicated `<LVL3>` key (the common
400/// case: `ISO_Level3_Shift` is bound to `KEY_RIGHTALT` itself, or not at
401/// all). Callers fall back to `KEY_RIGHTALT` in that case.
402///
403/// Restricted to layout 0 / level 0 because `ISO_Level3_Shift` is a
404/// modifier and lives at the base level — scanning every level would
405/// occasionally pick up keys that *produce* a Level3 modifier as a
406/// shifted symbol, which is not what we want.
407fn find_level3_keycode(keymap: &xkbcommon::xkb::Keymap) -> Option<u16> {
408    let right_alt = evdev::Key::KEY_RIGHTALT.code();
409
410    for raw_keycode in keymap.min_keycode().raw()..=keymap.max_keycode().raw() {
411        let keycode = xkbcommon::xkb::Keycode::new(raw_keycode);
412        let evdev_keycode: u16 = raw_keycode.saturating_sub(8).try_into().unwrap_or(u16::MAX);
413
414        // Restrict to layout 0 / level 0 — `ISO_Level3_Shift` is a
415        // modifier at the base level.
416        let syms = keymap.key_get_syms_by_level(keycode, 0, 0);
417        if !syms.iter().any(|sym| sym.raw() == KEY_ISO_Level3_Shift) {
418            continue;
419        }
420
421        if evdev_keycode != right_alt {
422            return Some(evdev_keycode);
423        }
424    }
425
426    None
427}
428
429/// The group 1 keysym at each of the four levels [`build_reverse_map`]
430/// uses (Shift, AltGr), of each key by evdev keycode: what a [`KeyTap`]
431/// produces while group 1 is active. A level a key lacks is `NoSymbol` (0).
432#[cfg(feature = "xtest")]
433pub(crate) struct Group1Keysyms(pub(crate) HashMap<u16, [u32; 4]>);
434
435#[cfg(feature = "xtest")]
436impl Group1Keysyms {
437    /// Read the first keysym of each level from a compiled keymap.
438    pub(crate) fn from_keymap(keymap: &xkbcommon::xkb::Keymap) -> Self {
439        let levels = (keymap.min_keycode().raw()..=keymap.max_keycode().raw())
440            .filter_map(|raw_keycode| {
441                let keycode = xkbcommon::xkb::Keycode::new(raw_keycode);
442                let evdev_keycode = u16::try_from(raw_keycode.checked_sub(8)?).ok()?;
443                let levels = [0, 1, 2, 3].map(|level| {
444                    keymap
445                        .key_get_syms_by_level(keycode, 0, level)
446                        .first()
447                        .map_or(0, |sym| sym.raw())
448                });
449                Some((evdev_keycode, levels))
450            })
451            .collect();
452        Self(levels)
453    }
454
455    /// The keysym `tap` produces, `NoSymbol` (0) if none.
456    pub(crate) fn keysym(&self, tap: &KeyTap) -> u32 {
457        // Level 1 (Shift), 2 (AltGr) and 3 (both), as in `build_reverse_map`.
458        let level = usize::from(tap.shift) | usize::from(tap.altgr) << 1;
459        self.0.get(&tap.keycode).map_or(0, |levels| levels[level])
460    }
461}
462
463#[allow(non_upper_case_globals)]
464pub(crate) fn build_reverse_map(keymap: &xkbcommon::xkb::Keymap) -> HashMap<char, KeyMapping> {
465    let mut map: HashMap<char, KeyMapping> = HashMap::new();
466    let mut dead_keys: HashMap<u32, KeyTap> = HashMap::new();
467    let min = keymap.min_keycode().raw();
468    let max = keymap.max_keycode().raw();
469    let num_layouts = keymap.num_layouts();
470
471    for raw_keycode in min..=max {
472        let keycode = xkbcommon::xkb::Keycode::new(raw_keycode);
473        for layout in 0..num_layouts {
474            let num_levels = keymap.num_levels_for_key(keycode, layout);
475            for level in 0..num_levels {
476                if level > 3 {
477                    continue;
478                }
479                let syms = keymap.key_get_syms_by_level(keycode, layout, level);
480                let evdev_keycode: u16 =
481                    raw_keycode.saturating_sub(8).try_into().unwrap_or(u16::MAX);
482                let shift = level == 1 || level == 3;
483                let altgr = level == 2 || level == 3;
484
485                for &sym in syms {
486                    let raw = sym.raw();
487                    if matches!(
488                        raw,
489                        KEY_dead_grave
490                            | KEY_dead_acute
491                            | KEY_dead_circumflex
492                            | KEY_dead_tilde
493                            | KEY_dead_diaeresis
494                            | KEY_dead_cedilla
495                    ) {
496                        dead_keys.entry(raw).or_insert(KeyTap {
497                            keycode: evdev_keycode,
498                            shift,
499                            altgr,
500                        });
501                        continue;
502                    }
503                    let unicode = xkbcommon::xkb::keysym_to_utf32(sym);
504                    if unicode == 0 {
505                        continue;
506                    }
507                    if let Some(ch) = char::from_u32(unicode) {
508                        map.entry(ch).or_insert(KeyMapping {
509                            main: KeyTap {
510                                keycode: evdev_keycode,
511                                shift,
512                                altgr,
513                            },
514                            follow: None,
515                        });
516                    }
517                }
518            }
519        }
520    }
521
522    // Pass 1: dead_X + Space for literal punctuation
523    for (ch, dead_sym) in [
524        ('\'', KEY_dead_acute),
525        ('"', KEY_dead_diaeresis),
526        ('~', KEY_dead_tilde),
527        ('`', KEY_dead_grave),
528        ('^', KEY_dead_circumflex),
529    ] {
530        if map.contains_key(&ch) {
531            continue;
532        }
533        if let Some(dk) = dead_keys.get(&dead_sym) {
534            map.insert(
535                ch,
536                KeyMapping {
537                    main: *dk,
538                    follow: Some(KeyTap {
539                        keycode: evdev::Key::KEY_SPACE.code(),
540                        shift: false,
541                        altgr: false,
542                    }),
543                },
544            );
545        }
546    }
547
548    // Pass 2: dead_X + base_letter for accented letters
549    for &(ch, base, dead_sym) in ACCENTED_VIA_DEAD_KEY {
550        if map.contains_key(&ch) {
551            continue;
552        }
553        let Some(dk) = dead_keys.get(&dead_sym) else {
554            continue;
555        };
556        let Some(base_map) = map.get(&base).copied() else {
557            continue;
558        };
559        if base_map.follow.is_some() {
560            continue;
561        }
562        map.insert(
563            ch,
564            KeyMapping {
565                main: *dk,
566                follow: Some(base_map.main),
567            },
568        );
569    }
570
571    map
572}
573
574#[cfg(test)]
575mod tests {
576    use super::*;
577
578    fn us_layout() -> KeyboardLayout {
579        KeyboardLayout {
580            layout: "us".to_string(),
581            variant: String::new(),
582        }
583    }
584
585    fn layout(name: &str, variant: &str) -> KeyboardLayout {
586        KeyboardLayout {
587            layout: name.to_string(),
588            variant: variant.to_string(),
589        }
590    }
591
592    fn assert_key(
593        km: &XkbKeymap,
594        ch: char,
595        expected_keycode: u16,
596        expected_shift: bool,
597        label: &str,
598    ) {
599        assert_key_full(km, ch, expected_keycode, expected_shift, false, label);
600    }
601
602    fn assert_key_full(
603        km: &XkbKeymap,
604        ch: char,
605        expected_keycode: u16,
606        expected_shift: bool,
607        expected_altgr: bool,
608        label: &str,
609    ) {
610        let mapping = km
611            .lookup(ch)
612            .unwrap_or_else(|| panic!("'{ch}' should be in {label} keymap"));
613        assert_eq!(
614            mapping.main.keycode, expected_keycode,
615            "'{ch}' should be at evdev {expected_keycode} on {label}, got {}",
616            mapping.main.keycode
617        );
618        assert_eq!(
619            mapping.main.shift, expected_shift,
620            "'{ch}' shift should be {expected_shift} on {label}"
621        );
622        assert_eq!(
623            mapping.main.altgr, expected_altgr,
624            "'{ch}' altgr should be {expected_altgr} on {label}"
625        );
626    }
627
628    #[test]
629    fn build_us_keymap() {
630        let km = XkbKeymap::from_layout(&us_layout());
631        if let Ok(km) = km {
632            assert!(!km.is_empty(), "keymap should not be empty");
633            assert!(km.lookup('a').is_some(), "'a' should be in the keymap");
634        }
635    }
636
637    #[test]
638    fn parse_x11_setxkbmap_query() {
639        let layout = parse_setxkbmap_query(
640            "rules:      evdev\nmodel:      pc104\nlayout:     us\nvariant:    qwerty-fr\n",
641        )
642        .unwrap();
643
644        assert_eq!(layout.layout, "us");
645        assert_eq!(layout.variant, "qwerty-fr");
646    }
647
648    #[test]
649    fn us_qwerty_fr_typeable_via_uinput() {
650        let km = XkbKeymap::from_layout(&layout("us", "qwerty-fr")).unwrap();
651
652        // The real contract: on us:qwerty-fr, AltGr lives behind a
653        // dedicated `<LVL3>` keycode that is *not* KEY_RIGHTALT. The
654        // exact evdev keycode (currently 84 / KEY_KP4) is an XKB-
655        // implementation detail and could shift between xkbcommon
656        // versions, so assert the property rather than the number.
657        assert_ne!(
658            km.level3_keycode(),
659            evdev::Key::KEY_RIGHTALT.code(),
660            "us:qwerty-fr must use the dedicated XKB <LVL3> keycode for \
661             AltGr, not KEY_RIGHTALT"
662        );
663        assert_key_full(&km, 'é', 17, false, true, "US qwerty-fr");
664
665        for ch in ['ç', 'à', 'é', 'è', 'ù'] {
666            assert!(
667                km.lookup(ch).is_some(),
668                "'{ch}' must be reachable via uinput on us:qwerty-fr"
669            );
670        }
671    }
672
673    #[test]
674    fn shift_mapping_for_uppercase() {
675        let km = XkbKeymap::from_layout(&us_layout());
676        if let Ok(km) = km {
677            if let Some(mapping) = km.lookup('A') {
678                assert!(
679                    mapping.main.shift,
680                    "uppercase 'A' should require shift on standard layouts"
681                );
682            }
683        }
684    }
685
686    // --- QWERTZ family (y/z swapped) ---
687
688    #[test]
689    fn german_layout() {
690        let km = XkbKeymap::from_layout(&layout("de", "")).unwrap();
691        assert_key(&km, 'z', 21, false, "German");
692        assert_key(&km, 'y', 44, false, "German");
693    }
694
695    #[test]
696    fn swiss_layout() {
697        let km = XkbKeymap::from_layout(&layout("ch", "")).unwrap();
698        assert_key(&km, 'z', 21, false, "Swiss");
699        assert_key(&km, 'y', 44, false, "Swiss");
700    }
701
702    #[test]
703    fn czech_layout() {
704        let km = XkbKeymap::from_layout(&layout("cz", "")).unwrap();
705        assert_key(&km, 'z', 21, false, "Czech");
706        assert_key(&km, 'y', 44, false, "Czech");
707        assert_key(&km, 'ů', 39, false, "Czech");
708    }
709
710    #[test]
711    fn slovak_layout() {
712        let km = XkbKeymap::from_layout(&layout("sk", "")).unwrap();
713        assert_key(&km, 'z', 21, false, "Slovak");
714        assert_key(&km, 'y', 44, false, "Slovak");
715        assert_key(&km, 'ô', 39, false, "Slovak");
716    }
717
718    #[test]
719    fn hungarian_layout() {
720        let km = XkbKeymap::from_layout(&layout("hu", "")).unwrap();
721        assert_key(&km, 'z', 21, false, "Hungarian");
722        assert_key(&km, 'y', 44, false, "Hungarian");
723        assert_key(&km, 'ö', 11, false, "Hungarian");
724        assert_key(&km, 'ü', 12, false, "Hungarian");
725    }
726
727    // --- AZERTY family (a/q and z/w swapped) ---
728
729    #[test]
730    fn french_layout() {
731        let km = XkbKeymap::from_layout(&layout("fr", "")).unwrap();
732        assert_key(&km, 'a', 16, false, "French");
733        assert_key(&km, 'q', 30, false, "French");
734        assert_key(&km, 'z', 17, false, "French");
735        assert_key(&km, 'w', 44, false, "French");
736    }
737
738    #[test]
739    fn belgian_layout() {
740        let km = XkbKeymap::from_layout(&layout("be", "")).unwrap();
741        assert_key(&km, 'a', 16, false, "Belgian");
742        assert_key(&km, 'q', 30, false, "Belgian");
743        assert_key(&km, 'z', 17, false, "Belgian");
744        assert_key(&km, 'w', 44, false, "Belgian");
745        assert_key(&km, 'm', 39, false, "Belgian");
746    }
747
748    // --- QWERTY-based with special characters ---
749
750    #[test]
751    fn spanish_layout() {
752        let km = XkbKeymap::from_layout(&layout("es", "")).unwrap();
753        assert_key(&km, 'ñ', 39, false, "Spanish");
754    }
755
756    #[test]
757    fn portuguese_layout() {
758        let km = XkbKeymap::from_layout(&layout("pt", "")).unwrap();
759        assert_key(&km, 'a', 30, false, "Portuguese");
760        assert_key(&km, 'z', 44, false, "Portuguese");
761        assert_key(&km, 'q', 16, false, "Portuguese");
762    }
763
764    #[test]
765    fn italian_layout() {
766        let km = XkbKeymap::from_layout(&layout("it", "")).unwrap();
767        assert_key(&km, 'a', 30, false, "Italian");
768        assert_key(&km, 'z', 44, false, "Italian");
769        assert_key(&km, 'q', 16, false, "Italian");
770        assert_key(&km, 'w', 17, false, "Italian");
771    }
772
773    #[test]
774    fn uk_layout() {
775        let km = XkbKeymap::from_layout(&layout("gb", "")).unwrap();
776        assert_key(&km, 'a', 30, false, "UK");
777        assert_key(&km, 'z', 44, false, "UK");
778        // UK has '#' without shift (evdev 43), unlike US where it's Shift+3.
779        assert_key(&km, '#', 43, false, "UK");
780        assert_key(&km, '£', 4, true, "UK");
781    }
782
783    // --- Nordic layouts ---
784
785    #[test]
786    fn swedish_layout() {
787        let km = XkbKeymap::from_layout(&layout("se", "")).unwrap();
788        assert_key(&km, 'ö', 39, false, "Swedish");
789        assert_key(&km, 'ä', 40, false, "Swedish");
790    }
791
792    #[test]
793    fn norwegian_layout() {
794        let km = XkbKeymap::from_layout(&layout("no", "")).unwrap();
795        assert_key(&km, 'ø', 39, false, "Norwegian");
796        assert_key(&km, 'æ', 40, false, "Norwegian");
797    }
798
799    #[test]
800    fn danish_layout() {
801        let km = XkbKeymap::from_layout(&layout("dk", "")).unwrap();
802        assert_key(&km, 'ø', 40, false, "Danish");
803        assert_key(&km, 'æ', 39, false, "Danish");
804    }
805
806    #[test]
807    fn finnish_layout() {
808        let km = XkbKeymap::from_layout(&layout("fi", "")).unwrap();
809        assert_key(&km, 'ö', 39, false, "Finnish");
810        assert_key(&km, 'ä', 40, false, "Finnish");
811    }
812
813    // --- Eastern European ---
814
815    #[test]
816    fn polish_layout() {
817        let km = XkbKeymap::from_layout(&layout("pl", "")).unwrap();
818        assert_key(&km, 'a', 30, false, "Polish");
819        assert_key(&km, 'z', 44, false, "Polish");
820        // Polish accented characters live at level 2 (AltGr) and now go
821        // through uinput directly, no clipboard fallback needed.
822        assert_key_full(&km, 'ą', 30, false, true, "Polish");
823        assert_key_full(&km, 'ę', 18, false, true, "Polish");
824    }
825
826    #[test]
827    fn us_intl_typeable_via_uinput() {
828        let km = XkbKeymap::from_layout(&KeyboardLayout {
829            layout: "us".to_string(),
830            variant: "intl".to_string(),
831        })
832        .unwrap();
833        // Every character that previously had to fall back to clipboard
834        // paste (and was therefore broken in terminals like Alacritty)
835        // must now have a direct uinput route — either as a level 2/3
836        // AltGr key, or via the dead-key + Space fallback table.
837        for ch in [
838            '\'', '"', '~', '`', '^', 'ç', 'á', 'é', 'í', 'ó', 'ú', 'ã', 'ñ',
839        ] {
840            assert!(
841                km.lookup(ch).is_some(),
842                "'{ch}' must be reachable via uinput on us:intl, got no mapping"
843            );
844        }
845    }
846
847    // --- Synthesis routing (locks in direct vs dead-key+follow paths) ---
848
849    /// Pass 1 (literal accent via `dead_X + Space`): for each of the
850    /// chars Pass 1 covers, on us:intl the char must be reachable, and
851    /// — if it ended up routed through synthesis rather than a direct
852    /// level mapping — the follow tap must be unmodified Space.
853    /// Whether any specific char is direct vs synthesized is layout-
854    /// dependent (us:intl puts some literal accents at level 2 directly),
855    /// but the invariant holds: synthesized routes always end with Space.
856    #[test]
857    fn us_intl_pass1_chars_synthesized_routes_end_with_space() {
858        let km = XkbKeymap::from_layout(&KeyboardLayout {
859            layout: "us".to_string(),
860            variant: "intl".to_string(),
861        })
862        .unwrap();
863        for ch in ['\'', '"', '~', '`', '^'] {
864            let mapping = km
865                .lookup(ch)
866                .unwrap_or_else(|| panic!("'{ch}' must be reachable on us:intl"));
867            if let Some(follow) = mapping.follow {
868                assert_eq!(
869                    follow.keycode,
870                    evdev::Key::KEY_SPACE.code(),
871                    "'{ch}' was synthesized; follow tap must be SPACE \
872                     (dead_X + space sequence)"
873                );
874                assert!(
875                    !follow.shift && !follow.altgr,
876                    "'{ch}' synthesis follow tap must be unmodified SPACE"
877                );
878            }
879        }
880    }
881
882    /// Pass 2 (accented letter via `dead_X + base_letter`): `ã` is not
883    /// reachable at any level on us:intl, so it must be synthesized as
884    /// `dead_tilde + a`. This is the deterministic test that proves
885    /// Pass 2 actually fires; the Polish/Spanish tests below cover the
886    /// no-overwrite invariant for layouts that do expose the char
887    /// directly.
888    #[test]
889    fn us_intl_tilde_letter_uses_dead_key_synthesis() {
890        let km = XkbKeymap::from_layout(&KeyboardLayout {
891            layout: "us".to_string(),
892            variant: "intl".to_string(),
893        })
894        .unwrap();
895        let a_main = km.lookup('a').expect("'a' must be in keymap").main;
896        let mapping = km.lookup('ã').expect("ã must be reachable on us:intl");
897        let follow = mapping.follow.expect(
898            "ã on us:intl must be synthesized via dead_tilde + a — \
899             the literal char is not at any level on this layout",
900        );
901        assert_eq!(
902            follow.keycode, a_main.keycode,
903            "ã follow tap must target the same evdev keycode as 'a' \
904             (dead_tilde + a sequence)"
905        );
906    }
907
908    /// Polish exposes `ą` at level 2 directly. The synthesis pass MUST
909    /// NOT overwrite that direct entry — `ą` must keep `follow=None`
910    /// and use AltGr, not synthesize via dead_ogonek + a.
911    #[test]
912    fn polish_accented_letter_is_direct_not_synthesized() {
913        let km = XkbKeymap::from_layout(&layout("pl", "")).unwrap();
914        let mapping = km.lookup('ą').expect("ą must be reachable on Polish");
915        assert!(
916            mapping.follow.is_none(),
917            "ą on Polish must be a direct AltGr tap, not synthesized — \
918             synthesis pass must not overwrite a direct mapping"
919        );
920        assert!(mapping.main.altgr, "ą on Polish must hold AltGr");
921    }
922
923    /// Spanish exposes `ñ` at level 0 directly (it's on the dedicated
924    /// `ñ` key). Even though `ñ` is in `ACCENTED_VIA_DEAD_KEY`, the
925    /// synthesis pass must skip it because the direct entry already
926    /// exists — locks in the cross-layout no-overwrite invariant.
927    #[test]
928    fn spanish_enye_is_direct_not_synthesized() {
929        let km = XkbKeymap::from_layout(&layout("es", "")).unwrap();
930        let mapping = km.lookup('ñ').expect("ñ must be reachable on Spanish");
931        assert!(
932            mapping.follow.is_none(),
933            "ñ on Spanish must be a direct level-0 tap, not synthesized — \
934             synthesis pass must not overwrite even when the char is in \
935             the synthesis table"
936        );
937        assert!(
938            !mapping.main.altgr && !mapping.main.shift,
939            "ñ on Spanish is on a dedicated key — no modifiers required"
940        );
941    }
942
943    // --- Alternative Latin layouts ---
944
945    #[test]
946    fn dvorak_layout() {
947        let km = XkbKeymap::from_layout(&layout("us", "dvorak")).unwrap();
948        assert_key(&km, 'o', 31, false, "Dvorak");
949        assert_key(&km, 'e', 32, false, "Dvorak");
950        assert_key(&km, 's', 39, false, "Dvorak");
951    }
952
953    #[test]
954    fn colemak_layout() {
955        let km = XkbKeymap::from_layout(&layout("us", "colemak")).unwrap();
956        assert_key(&km, 'f', 18, false, "Colemak");
957        assert_key(&km, 'n', 36, false, "Colemak");
958        assert_key(&km, 's', 32, false, "Colemak");
959    }
960
961    // --- Non-Latin layouts ---
962
963    #[test]
964    fn russian_layout() {
965        let km = XkbKeymap::from_layout(&layout("ru", "")).unwrap();
966        assert_key(&km, 'ф', 30, false, "Russian");
967        assert_key(&km, 'я', 44, false, "Russian");
968        assert_key(&km, 'й', 16, false, "Russian");
969        assert_key(&km, 'ц', 17, false, "Russian");
970    }
971
972    #[test]
973    fn ukrainian_layout() {
974        let km = XkbKeymap::from_layout(&layout("ua", "")).unwrap();
975        assert_key(&km, 'ф', 30, false, "Ukrainian");
976        assert_key(&km, 'я', 44, false, "Ukrainian");
977        assert_key(&km, 'й', 16, false, "Ukrainian");
978        assert_key(&km, 'і', 31, false, "Ukrainian");
979    }
980
981    #[test]
982    fn greek_layout() {
983        let km = XkbKeymap::from_layout(&layout("gr", "")).unwrap();
984        assert_key(&km, 'α', 30, false, "Greek");
985        assert_key(&km, 'ζ', 44, false, "Greek");
986        assert_key(&km, 'ω', 47, false, "Greek");
987    }
988
989    #[test]
990    fn japanese_layout() {
991        // Japanese (jp) is QWERTY-based for Latin characters.
992        let km = XkbKeymap::from_layout(&layout("jp", "")).unwrap();
993        assert_key(&km, 'a', 30, false, "Japanese");
994        assert_key(&km, 'z', 44, false, "Japanese");
995    }
996
997    // --- Sway detection regression ---
998
999    /// `KeyboardLayout::from_sway` deliberately returns `None` because
1000    /// Sway's `xkb_layout_names` exposes display strings (e.g. `"German"`,
1001    /// `"English (US)"`) rather than XKB layout codes (`"de"`, `"us"`).
1002    /// Returning `Some(display_name)` would make the caller short-circuit
1003    /// the env-var fallback and silently compile against the *default*
1004    /// layout, which is the bug this regression test guards against.
1005    #[test]
1006    fn from_sway_always_returns_none() {
1007        // `from_sway` shells out to `swaymsg`; on most CI machines that
1008        // command doesn't exist, but if it does we must still see `None`
1009        // (the function intentionally never returns `Some` regardless of
1010        // what swaymsg replies with).
1011        let result = KeyboardLayout::from_sway();
1012        assert!(
1013            result.is_none(),
1014            "from_sway must return None (display names ≠ XKB codes); got {result:?}"
1015        );
1016    }
1017
1018    // --- localectl parser tests ---
1019
1020    #[test]
1021    fn parse_localectl_us_no_variant() {
1022        let output = "\
1023System Locale: LANG=en_US.UTF-8
1024    VC Keymap: us
1025   X11 Layout: us
1026    X11 Model: pc105+inet
1027  X11 Options: terminate:ctrl_alt_bksp
1028";
1029        let kl = parse_localectl_status(output).expect("should parse US layout");
1030        assert_eq!(kl.layout, "us");
1031        assert_eq!(kl.variant, "");
1032    }
1033
1034    #[test]
1035    fn parse_localectl_fr_bepo_full() {
1036        let output = "\
1037System Locale: LANG=en_US.UTF-8
1038    VC Keymap: fr-bepo
1039   X11 Layout: fr
1040    X11 Model: pc104
1041  X11 Variant: bepo
1042  X11 Options: caps:swapescape,compose:rwin
1043";
1044        let kl = parse_localectl_status(output).expect("should parse fr(bepo)");
1045        assert_eq!(kl.layout, "fr");
1046        assert_eq!(kl.variant, "bepo");
1047    }
1048
1049    #[test]
1050    fn parse_localectl_empty_layout_returns_none() {
1051        let output = "\
1052System Locale: LANG=en_US.UTF-8
1053    VC Keymap: us
1054   X11 Layout:
1055    X11 Model: pc105
1056";
1057        assert!(parse_localectl_status(output).is_none());
1058    }
1059
1060    #[test]
1061    fn parse_localectl_missing_x11_section_returns_none() {
1062        let output = "\
1063System Locale: LANG=en_US.UTF-8
1064    VC Keymap: us
1065";
1066        assert!(parse_localectl_status(output).is_none());
1067    }
1068
1069    #[test]
1070    fn parse_localectl_tolerates_whitespace_variants() {
1071        // Mix tabs, varying leading-space widths, and trailing whitespace.
1072        let output = "System Locale: LANG=en_US.UTF-8\n\
1073\t\tVC Keymap: fr-bepo\n\
1074 X11 Layout:   fr  \n\
1075      X11 Model: pc104\n\
1076\tX11 Variant:\tbepo\n";
1077        let kl = parse_localectl_status(output).expect("should tolerate whitespace mix");
1078        assert_eq!(kl.layout, "fr");
1079        assert_eq!(kl.variant, "bepo");
1080    }
1081
1082    #[test]
1083    fn parse_localectl_ignores_unknown_keys() {
1084        let output = "\
1085System Locale: LANG=en_US.UTF-8
1086    VC Keymap: us
1087          Foo: bar
1088   X11 Layout: us
1089    X11 Model: pc105
1090       Random: line with: multiple colons
1091  X11 Variant: dvorak
1092";
1093        let kl = parse_localectl_status(output).expect("should ignore unknown keys");
1094        assert_eq!(kl.layout, "us");
1095        assert_eq!(kl.variant, "dvorak");
1096    }
1097
1098    // --- localectl chain-gap regression ---
1099
1100    /// Issue #39: detection must have at least one source that works
1101    /// without any X session env vars on a systemd host. `from_localectl`
1102    /// fills that gap. If `localectl` is installed (the case on every
1103    /// systemd distro), it must return `Some(layout)` with a non-empty
1104    /// layout code — this is what prevents the US/QWERTY fallback that
1105    /// produced garbled output for fr(bepo) users running under
1106    /// `systemd --user`.
1107    #[test]
1108    fn from_localectl_returns_layout_when_available() {
1109        // Skip if the binary is unavailable (e.g. non-systemd CI image).
1110        if Command::new("localectl").arg("--version").output().is_err() {
1111            eprintln!("skipping: localectl not on PATH");
1112            return;
1113        }
1114        let result = KeyboardLayout::from_localectl();
1115        let kl = result.expect(
1116            "from_localectl must return Some(_) on a systemd host — \
1117             this is the detection-chain gap that allowed issue #39",
1118        );
1119        assert!(
1120            !kl.layout.is_empty(),
1121            "from_localectl returned Some with empty layout: {kl:?}"
1122        );
1123    }
1124}