feat(keyboard): interactive new-keyboard layout prompt + persist

The runtime-remember complement to nomarchy.keyboard.devices. Set
nomarchy.keyboard.layouts (candidate layouts) and a nomarchy-keyboard-watch
daemon runs (exec-once): it polls hyprctl devices, and when a keyboard
connects after login that isn't declared and hasn't been chosen before, it
pops a rofi layout picker, applies the choice via hyprctl switchxkblayout (an
index into the candidate set carried by input.kb_layout), and remembers it
per-device in ~/.local/state -- re-applied silently on later reconnects. The
boot-time set (incl. the built-in keyboard) is never prompted.

Pure bash + jq/hyprctl/rofi (all in the session PATH); state lives outside
the flake (a stateful runtime piece by design). Eval + bash-syntax verified;
the hotplug behaviour needs an on-hardware test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Bernardo Magri
2026-06-15 17:43:59 +01:00
parent 69f959365e
commit 71f37bfc30
5 changed files with 92 additions and 13 deletions

View File

@@ -189,6 +189,7 @@ examples: **[docs/OVERRIDES.md](docs/OVERRIDES.md)**.
| `nomarchy.keyboard.layout` | `"us"` | XKB layout for the Hyprland session (installer writes the matching `services.xserver.xkb` in system.nix; the console + LUKS prompt follow via the distro default) | | `nomarchy.keyboard.layout` | `"us"` | XKB layout for the Hyprland session (installer writes the matching `services.xserver.xkb` in system.nix; the console + LUKS prompt follow via the distro default) |
| `nomarchy.keyboard.variant` | `""` | XKB variant for the session | | `nomarchy.keyboard.variant` | `""` | XKB variant for the session |
| `nomarchy.keyboard.devices` | `{}` | Per-device layout overrides (Hyprland `device` blocks keyed by `hyprctl devices` name) — e.g. an external keyboard with its own layout/variant | | `nomarchy.keyboard.devices` | `{}` | Per-device layout overrides (Hyprland `device` blocks keyed by `hyprctl devices` name) — e.g. an external keyboard with its own layout/variant |
| `nomarchy.keyboard.layouts` | `[]` | Extra candidate layouts; when set, a watcher prompts (rofi) for a layout on a newly-connected keyboard and remembers it per-device |
| `nomarchy.hyprland.enable` | `true` | Nomarchy's Hyprland config | | `nomarchy.hyprland.enable` | `true` | Nomarchy's Hyprland config |
| `nomarchy.waybar.enable` | `true` | Nomarchy's Waybar | | `nomarchy.waybar.enable` | `true` | Nomarchy's Waybar |
| `nomarchy.rofi.enable` | `true` | Themed rofi launcher + `nomarchy-menu` dispatcher | | `nomarchy.rofi.enable` | `true` | Themed rofi launcher + `nomarchy-menu` dispatcher |

View File

@@ -293,17 +293,20 @@ how to override it. Items marked ✓ are shipped.
declarative way). A Waybar `hyprland/language` indicator shows the active declarative way). A Waybar `hyprland/language` indicator shows the active
layout, gated on more than one layout being in play (comma in the session layout, gated on more than one layout being in play (comma in the session
layout, or any per-device override) so single-layout bars stay clean. layout, or any per-device override) so single-layout bars stay clean.
- **Interactive new-keyboard prompt (runtime + persist):** a small daemon - **Interactive new-keyboard prompt (runtime + persist):** set
listening to Hyprland's socket for keyboard hotplug — when an *unknown* `nomarchy.keyboard.layouts` (candidate layouts) and a `nomarchy-keyboard-
keyboard connects, pop a rofi layout picker, apply the choice watch` daemon runs (exec-once): it polls `hyprctl devices`, and when a
(`hyprctl switchxkblayout` among a configured candidate set, or reload the keyboard connects *after* login that isn't in `keyboard.devices` and
device config), and persist it per-device to a state file so it re-applies hasn't been chosen before, it pops a rofi layout picker, applies the
automatically on every later reconnect. The runtime-remember complement to choice with `hyprctl switchxkblayout` (an index into the candidate set,
the declarative `nomarchy.keyboard.devices` above (set it once, on the fly, which `input.kb_layout` carries), and remembers it per-device in
no config editing). Nice bridge: offer to also write the choice into `~/.local/state/nomarchy/keyboard-layouts` — re-applied silently on later
`nomarchy.keyboard.devices` so it graduates into the reproducible config. reconnects. The boot-time set (incl. the built-in keyboard) is never
Note: a stateful runtime piece (like the wallpaper) — it sits outside the prompted. The runtime-remember complement to the declarative
pure-declarative model by design. `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.
- Remaining: a multi-layout cycle bind (`hyprctl switchxkblayout` / xkb - Remaining: a multi-layout cycle bind (`hyprctl switchxkblayout` / xkb
`grp:` options) for switching layouts on one keyboard; add the `grp:` options) for switching layouts on one keyboard; add the
`hyprland/language` module to the summer whole-swap themes for parity `hyprland/language` module to the summer whole-swap themes for parity

View File

@@ -54,6 +54,64 @@ let
++ lib.optionals (m.vrr != null) [ "vrr" (toString m.vrr) ] ++ lib.optionals (m.vrr != null) [ "vrr" (toString m.vrr) ]
++ lib.optional (m.extra != "") m.extra ++ 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 (
(lib.splitString "," config.nomarchy.keyboard.layout)
++ config.nomarchy.keyboard.layouts
);
kbAutoSwitch = config.nomarchy.keyboard.layouts != [ ];
# On a keyboard that connects after login (not declared in
# 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).
keyboardWatch = pkgs.writeShellScriptBin "nomarchy-keyboard-watch" ''
set -u
layouts="${lib.concatStringsSep " " kbLayouts}"
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; }
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"; }
# Startup: re-apply remembered layouts, never prompt so the built-in
# keyboard just stays on the session default.
prev=" "
for kb in $(keyboards); do
prev="$prev$kb "
is_declared "$kb" && continue
s=$(saved_for "$kb"); [ -n "$s" ] && apply "$kb" "$s"
done
# Watch for keyboards that connect later.
while :; do
sleep 3
cur=" "
for kb in $(keyboards); do
cur="$cur$kb "
case "$prev" in *" $kb "*) continue ;; esac
is_declared "$kb" && continue
s=$(saved_for "$kb")
if [ -n "$s" ]; then
apply "$kb" "$s"
else
choice=$(printf '%s\n' $layouts | rofi -dmenu -p "Layout · $kb")
[ -n "$choice" ] && { printf '%s=%s\n' "$kb" "$choice" >> "$state"; apply "$kb" "$choice"; }
fi
done
prev="$cur"
done
'';
in in
{ {
wayland.windowManager.hyprland = lib.mkIf config.nomarchy.hyprland.enable { wayland.windowManager.hyprland = lib.mkIf config.nomarchy.hyprland.enable {
@@ -88,7 +146,7 @@ in
# Paint the wallpaper as soon as the session is up (waits for # Paint the wallpaper as soon as the session is up (waits for
# the daemon internally). # the daemon internally).
"nomarchy-theme-sync wallpaper" "nomarchy-theme-sync wallpaper"
]; ] ++ lib.optional kbAutoSwitch "${keyboardWatch}/bin/nomarchy-keyboard-watch";
# ── Theme-driven look ────────────────────────────────────────── # ── Theme-driven look ──────────────────────────────────────────
# These flow from theme-state.json and stay at NORMAL priority: # These flow from theme-state.json and stay at NORMAL priority:
@@ -144,7 +202,7 @@ in
input = { input = {
# kb_layout/variant come from nomarchy.keyboard.* — set that # kb_layout/variant come from nomarchy.keyboard.* — set that
# option (the installer writes it; it also drives tty/LUKS). # option (the installer writes it; it also drives tty/LUKS).
kb_layout = config.nomarchy.keyboard.layout; kb_layout = lib.concatStringsSep "," kbLayouts;
kb_variant = config.nomarchy.keyboard.variant; kb_variant = config.nomarchy.keyboard.variant;
follow_mouse = lib.mkDefault 1; follow_mouse = lib.mkDefault 1;
touchpad.natural_scroll = lib.mkDefault true; touchpad.natural_scroll = lib.mkDefault true;

View File

@@ -96,6 +96,22 @@ in
description = "XKB variant for the Hyprland session."; description = "XKB variant for the Hyprland session.";
}; };
keyboard.layouts = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
example = [ "de" "fr" ];
description = ''
Extra candidate layouts offered by the interactive new-keyboard
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.
'';
};
keyboard.devices = lib.mkOption { keyboard.devices = lib.mkOption {
type = lib.types.attrsOf (lib.types.submodule { type = lib.types.attrsOf (lib.types.submodule {
options = { options = {

View File

@@ -16,6 +16,7 @@ let
# is in play — multiple session layouts (comma-separated) or per-device # is in play — multiple session layouts (comma-separated) or per-device
# overrides (nomarchy.keyboard.devices) — so single-layout bars stay clean. # overrides (nomarchy.keyboard.devices) — so single-layout bars stay clean.
showLanguage = lib.hasInfix "," config.nomarchy.keyboard.layout showLanguage = lib.hasInfix "," config.nomarchy.keyboard.layout
|| config.nomarchy.keyboard.layouts != [ ]
|| config.nomarchy.keyboard.devices != { }; || config.nomarchy.keyboard.devices != { };
# Power-profile indicator (power-profiles-daemon). Both scripts self- # Power-profile indicator (power-profiles-daemon). Both scripts self-