diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 02a69d2..a0a07c7 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -372,15 +372,20 @@ how to override it. Items marked ✓ are shipped. watch` daemon runs (exec-once): it polls `hyprctl devices`, and when a keyboard connects *after* login that isn't in `keyboard.devices` and hasn't been chosen before, it pops a rofi layout picker, applies the - choice with `hyprctl switchxkblayout` (an index into the candidate set, - which `input.kb_layout` carries), and remembers it per-device in - `~/.local/state/nomarchy/keyboard-layouts` — re-applied silently on later - reconnects. The boot-time set (incl. the built-in keyboard) is never - prompted. The runtime-remember complement to the declarative - `keyboard.devices`; a stateful runtime piece by design. **Pending an - on-hardware test** (hotplug isn't verifiable in CI). Remaining (bonus): - offer to write a choice into `keyboard.devices` so it graduates to the - reproducible config. + choice as a **per-device** `hyprctl keyword device[]:kb_layout` (the + runtime twin of the declarative `device{}` blocks — so it isolates that + one keyboard and never touches the built-in board), and remembers it + per-device in `~/.local/state/nomarchy/keyboard-layouts` — re-applied + silently on later reconnects. The boot-time set (incl. the built-in + keyboard) is never prompted. The runtime-remember complement to the + declarative `keyboard.devices`; a stateful runtime piece by design. + **Picker verified on hardware (2026-06-18)**; the first cut applied the + choice with `switchxkblayout` (a global layout-index flip) and merged the + candidate pool into `input.kb_layout`, so a selection leaked onto the + laptop keyboard and stuck after unplug — fixed by the per-device keyword + + keeping the pool out of the session layout. Remaining (bonus): offer to + write a choice into `keyboard.devices` so it graduates to the reproducible + config; **re-verify the per-device isolation on hardware**. - Remaining: a multi-layout cycle bind (`hyprctl switchxkblayout` / xkb `grp:` options) for switching layouts on one keyboard; add the `hyprland/language` module to the summer whole-swap themes for parity diff --git a/modules/home/hyprland.nix b/modules/home/hyprland.nix index 7cba27f..75993fa 100644 --- a/modules/home/hyprland.nix +++ b/modules/home/hyprland.nix @@ -55,10 +55,14 @@ let ++ lib.optional (m.extra != "") m.extra ); - # Keyboard layout candidates: the primary nomarchy.keyboard.layout (split on - # commas) plus any nomarchy.keyboard.layouts. Drives input.kb_layout so all - # of them are switchable, and feeds the watcher's picker. - kbLayouts = lib.unique ( + # Candidate layouts offered by the new-keyboard picker: the session + # layout(s) (nomarchy.keyboard.layout, comma-split for a multi-layout + # session) plus the extra nomarchy.keyboard.layouts pool. The pool is + # deliberately NOT merged into input.kb_layout — those candidates are for + # *external* keyboards and get applied per-device by the watcher's apply(). + # Loading them onto the session keyboard is what let a global switch flip + # the built-in board to the wrong layout. + pickerLayouts = lib.unique ( (lib.splitString "," config.nomarchy.keyboard.layout) ++ config.nomarchy.keyboard.layouts ); @@ -68,18 +72,20 @@ let # keyboard.devices, not already remembered), ask for a layout and persist it # per-device, re-applying silently on later reconnects. Stateful runtime # piece — the complement to the declarative keyboard.devices. Reliable - # primitives only: poll hyprctl devices, apply via switchxkblayout (an index - # into kbLayouts). NOTE: needs an on-hardware test (hotplug isn't verifiable - # in CI). + # primitives only: poll hyprctl devices, apply with a per-device + # `device[]:kb_layout` keyword — the runtime twin of the declarative + # device blocks, so it isolates that one keyboard and never disturbs the + # built-in board (switchxkblayout flipped the *shared* layout, which leaked + # onto the laptop and stuck after unplug). NOTE: needs an on-hardware test + # (hotplug isn't verifiable in CI). keyboardWatch = pkgs.writeShellScriptBin "nomarchy-keyboard-watch" '' set -u - layouts="${lib.concatStringsSep " " kbLayouts}" + layouts="${lib.concatStringsSep " " pickerLayouts}" declared="${lib.concatStringsSep " " (builtins.attrNames config.nomarchy.keyboard.devices)}" state="''${XDG_STATE_HOME:-$HOME/.local/state}/nomarchy/keyboard-layouts" mkdir -p "$(dirname "$state")"; [ -f "$state" ] || : > "$state" - layout_index() { i=0; for l in $layouts; do [ "$l" = "$1" ] && { printf %s "$i"; return; }; i=$((i + 1)); done; printf %s -1; } - apply() { idx=$(layout_index "$2"); [ "$idx" -ge 0 ] && hyprctl switchxkblayout "$1" "$idx" >/dev/null 2>&1; } + apply() { hyprctl keyword "device[$1]:kb_layout" "$2" >/dev/null 2>&1; } keyboards() { hyprctl devices -j | jq -r '.keyboards[].name'; } is_declared() { case " $declared " in *" $1 "*) return 0 ;; *) return 1 ;; esac; } saved_for() { while IFS='=' read -r k v; do [ "$k" = "$1" ] && { printf '%s' "$v"; return; }; done < "$state"; } @@ -201,8 +207,10 @@ in input = { # kb_layout/variant come from nomarchy.keyboard.* — set that - # option (the installer writes it; it also drives tty/LUKS). - kb_layout = lib.concatStringsSep "," kbLayouts; + # option (the installer writes it; it also drives tty/LUKS). Only the + # session layout(s) land here; the keyboard.layouts pool is applied + # per-device to external boards, never onto the session keyboard. + kb_layout = config.nomarchy.keyboard.layout; kb_variant = config.nomarchy.keyboard.variant; follow_mouse = lib.mkDefault 1; touchpad.natural_scroll = lib.mkDefault true; @@ -270,7 +278,10 @@ in # writes a config file). A helper to find values for nomarchy.monitors — # the declarative config stays the source of truth (we don't source its # output). Gated on its toggle; pointless without the Hyprland session. - home.packages = lib.optionals - (config.nomarchy.hyprland.enable && config.nomarchy.displays.enable) - [ pkgs.nwg-displays ]; + home.packages = + lib.optionals (config.nomarchy.hyprland.enable && config.nomarchy.displays.enable) + [ pkgs.nwg-displays ] + # The new-keyboard watcher on PATH (when enabled) so it's discoverable and + # runnable by name for debugging — Hyprland still exec-once's it by store path. + ++ lib.optional (config.nomarchy.hyprland.enable && kbAutoSwitch) keyboardWatch; } diff --git a/modules/home/options.nix b/modules/home/options.nix index c61050e..807ca0c 100644 --- a/modules/home/options.nix +++ b/modules/home/options.nix @@ -105,10 +105,11 @@ in picker. When non-empty, a small watcher runs in the session: when a keyboard connects that isn't covered by nomarchy.keyboard.devices and hasn't been chosen before, it pops a rofi picker (these layouts plus - the primary nomarchy.keyboard.layout), applies the choice, and - remembers it per-device (~/.local/state) — re-applying automatically - on later reconnects. The runtime-remember complement to the - declarative keyboard.devices; a stateful runtime piece by design. + the primary nomarchy.keyboard.layout), applies the choice to that + keyboard only (a per-device kb_layout, so the built-in board is left + alone), and remembers it per-device (~/.local/state) — re-applying + automatically on later reconnects. The runtime-remember complement to + the declarative keyboard.devices; a stateful runtime piece by design. ''; };