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.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.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.waybar.enable` | `true` | Nomarchy's Waybar |
| `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
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.
- **Interactive new-keyboard prompt (runtime + persist):** a small daemon
listening to Hyprland's socket for keyboard hotplug — when an *unknown*
keyboard connects, pop a rofi layout picker, apply the choice
(`hyprctl switchxkblayout` among a configured candidate set, or reload the
device config), and persist it per-device to a state file so it re-applies
automatically on every later reconnect. The runtime-remember complement to
the declarative `nomarchy.keyboard.devices` above (set it once, on the fly,
no config editing). Nice bridge: offer to also write the choice into
`nomarchy.keyboard.devices` so it graduates into the reproducible config.
Note: a stateful runtime piece (like the wallpaper) — it sits outside the
pure-declarative model by design.
- **Interactive new-keyboard prompt (runtime + persist):** 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 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.
- 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

View File

@@ -54,6 +54,64 @@ let
++ lib.optionals (m.vrr != null) [ "vrr" (toString m.vrr) ]
++ 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
{
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
# the daemon internally).
"nomarchy-theme-sync wallpaper"
];
] ++ lib.optional kbAutoSwitch "${keyboardWatch}/bin/nomarchy-keyboard-watch";
# ── Theme-driven look ──────────────────────────────────────────
# These flow from theme-state.json and stay at NORMAL priority:
@@ -144,7 +202,7 @@ in
input = {
# kb_layout/variant come from nomarchy.keyboard.* — set that
# 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;
follow_mouse = lib.mkDefault 1;
touchpad.natural_scroll = lib.mkDefault true;

View File

@@ -96,6 +96,22 @@ in
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 {
type = lib.types.attrsOf (lib.types.submodule {
options = {

View File

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