From aed41793f83a094b8d3ae8edbe0f5f7eccb7bd85 Mon Sep 17 00:00:00 2001 From: Bernardo Magri Date: Tue, 23 Jun 2026 09:18:33 +0100 Subject: [PATCH] feat(keyboard): per-device layout memory in-flake (no ~/.local/state) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of the in-flake-state philosophy, mirroring night-light: the nomarchy-keyboard-watch daemon now remembers per-device layouts in the git-tracked state file (settings.keyboard.devices) instead of ~/.local/state/nomarchy/keyboard-layouts. - Reads the LIVE working-tree map (theme-sync get → jq) so a pick this session is honoured at once; writes instantly with --no-switch (no rebuild). Writes the whole map back at the dot-free parent path (read-modify-write) so a device name with a dot can't corrupt the tool's dotted set-path (Hyprland only sanitises spaces→-). - Graduation: nomarchy.keyboard.devices now mkDefault-merges entries from settings.keyboard.devices, so each remembered device becomes a generated Hyprland device{} block on the next rebuild (reproducible, hotplug-applied). A hand-written keyboard.devices. still wins; once baked, the watcher's is_declared check makes it step back. - theme.nix seeds settings.keyboard.devices = {} (sparse state still evaluates); options/template descriptions updated; ROADMAP marked done. Eval-verified (graduation + mkDefault precedence) and the set/get writer round-trips. Still needs the on-hardware hotplug re-verify (the picker path isn't testable in CI). Co-Authored-By: Claude Opus 4.8 --- docs/ROADMAP.md | 32 ++++++++++++++++--------- modules/home/hyprland.nix | 44 ++++++++++++++++++++++++++++++----- modules/home/options.nix | 13 +++++++---- modules/home/theme.nix | 17 ++++++++++---- templates/downstream/home.nix | 5 ++-- 5 files changed, 83 insertions(+), 28 deletions(-) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 4bd1192..204158a 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -485,9 +485,10 @@ how to override it. Items marked ✓ are shipped. `custom/nightlight` indicator shows the moon while running and hides otherwise. This is the first cut of a broader principle — **any user-settable config gets a menu writer that lands it in the downstream flake; no state lives - outside the checkout** (next target: the per-device keyboard-layout memory - below, still in `~/.local/state`; then opt-in auto-commit of the flake on - each mutation, owned files only). Remaining (optional): geo (lat/long) auto + outside the checkout** (Phase 1 night-light + Phase 2 the per-device + keyboard-layout memory below are done — both now in the git-tracked state + file; next target: opt-in auto-commit of the flake on each mutation, owned + files only). Remaining (optional): geo (lat/long) auto sunset/sunrise (would mean wlsunset, which schedules by location). Pending an on-machine check (first enable rebuilds + comes on; later toggles instant; off persists across reboot via ExecCondition; stopping hyprsunset restores gamma). @@ -508,18 +509,27 @@ how to override it. Items marked ✓ are shipped. hasn't been chosen before, it pops a rofi layout picker, applies the 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. + one keyboard and never touches the built-in board), and remembers it. The + boot-time set (incl. the built-in keyboard) is never prompted. The + runtime-remember complement to the declarative `keyboard.devices`. **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**. + keeping the pool out of the session layout. + - ✓ **In-flake state + graduation (2026-06-23):** the remembered picks moved + out of `~/.local/state/nomarchy/keyboard-layouts` into the git-tracked + state file (`settings.keyboard.devices`, a device-name → layout map) — + Phase 2 of the in-flake-state principle, the same path night-light took. + The watcher reads the **live** working tree (a pick is honoured at once) + and writes instantly with `--no-switch` (no rebuild), merging the whole map + back at the dot-free parent path so a device name containing a dot can't + corrupt the dotted set-path. Each remembered device **graduates** into + `nomarchy.keyboard.devices` on the next rebuild (a generated Hyprland + `device{}` block, `mkDefault` so a hand-written entry still wins), after + which the watcher sees it as declared and steps back. Eval-verified + (graduation + precedence) and the writer round-trips; **still needs the + on-hardware hotplug re-verify** (the picker path isn't testable in CI). - 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 c908194..97196ad 100644 --- a/modules/home/hyprland.nix +++ b/modules/home/hyprland.nix @@ -8,6 +8,10 @@ let c = t.colors; inherit (config.nomarchy.lib) rgb rgba; + # nomarchy-theme-sync — the in-flake state writer the keyboard watcher uses + # to remember per-device layouts (same path night-light's toggle takes). + sync = lib.getExe config.nomarchy.package; + # swayosd's `--input-volume mute-toggle` draws the OSD but never flips the # source mute (verified on hardware: wpctl toggles the state, swayosd's # input-mute path is a no-op — unlike its working output-mute path). Do the @@ -76,19 +80,37 @@ let # `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). + # onto the laptop and stuck after unplug). + # + # The remembered choices live in the git-tracked in-flake state + # (settings.keyboard.devices), NOT ~/.local/state — read live from the + # working tree (so a pick this session is honoured at once) and written + # instantly with `--no-switch` (no rebuild). A later rebuild graduates them + # into nomarchy.keyboard.devices (generated device{} blocks), after which the + # watcher treats them as declared and steps back. NOTE: needs an on-hardware + # test (hotplug isn't verifiable in CI). keyboardWatch = pkgs.writeShellScriptBin "nomarchy-keyboard-watch" '' set -u 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" + sync=${sync} 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"; } + + # The remembered map (device-name -> layout) from the LIVE in-flake state; + # an absent key (sparse state) reads as an empty object. + saved_map() { "$sync" get settings.keyboard.devices 2>/dev/null || echo '{}'; } + saved_for() { saved_map | jq -r --arg k "$1" '.[$k] // empty'; } + + # Persist a pick INSTANTLY: merge it into the map and write the whole object + # back at the dot-free parent path, so a device name containing a dot can't + # corrupt the dotted set-path. --no-switch = write only, no rebuild. + remember() { + map=$(saved_map | jq -c --arg k "$1" --arg v "$2" '. + {($k): $v}') || return + "$sync" --quiet set settings.keyboard.devices "$map" --no-switch + } # Startup: re-apply remembered layouts, never prompt — so the built-in # keyboard just stays on the session default. @@ -112,7 +134,7 @@ let 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"; } + [ -n "$choice" ] && { remember "$kb" "$choice"; apply "$kb" "$choice"; } fi done prev="$cur" @@ -280,6 +302,16 @@ in }; }; + # Runtime-remembered per-device layouts (settings.keyboard.devices, written + # by the watcher into the in-flake state) graduate into the declarative + # keyboard.devices: each becomes a generated Hyprland device{} block — + # reproducible and applied natively on hotplug — after which the watcher sees + # it as declared and steps back. mkDefault so a hand-written + # keyboard.devices. in home.nix still wins for the same keyboard. + nomarchy.keyboard.devices = lib.mapAttrs + (_name: layout: { layout = lib.mkDefault layout; }) + config.nomarchy.settings.keyboard.devices; + # nwg-displays: interactive monitor arranger (applies live via hyprctl and # 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 diff --git a/modules/home/options.nix b/modules/home/options.nix index 29fb275..08f8775 100644 --- a/modules/home/options.nix +++ b/modules/home/options.nix @@ -107,9 +107,12 @@ in hasn't been chosen before, it pops a rofi picker (these layouts plus 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. + alone), and remembers it in the git-tracked in-flake state + (settings.keyboard.devices, not ~/.local/state) — re-applied + automatically on later reconnects and across reboots. Each remembered + choice graduates into nomarchy.keyboard.devices on the next rebuild + (a generated device block). The runtime-remember complement to the + declarative keyboard.devices. ''; }; @@ -136,7 +139,9 @@ in nomarchy.keyboard.layout for that keyboard only — e.g. an external keyboard that's physically a different layout than the laptop's built-in one. Hyprland applies it automatically whenever that - keyboard is connected. + keyboard is connected. The interactive watcher + (nomarchy.keyboard.layouts) also writes its remembered picks here on + the next rebuild, so a runtime choice graduates into reproducible config. ''; }; diff --git a/modules/home/theme.nix b/modules/home/theme.nix index f234ddc..07c06e3 100644 --- a/modules/home/theme.nix +++ b/modules/home/theme.nix @@ -55,11 +55,18 @@ let # switch always replaces it (deep_merge would otherwise leave it stuck). border = { active = "accent"; inactive = "overlay"; }; - # Non-appearance feature settings the menu writes into this same in-flake - # state. nomarchy.nightlight: `installed` (sticky — gates the unit, so the - # first enable rebuilds) and `on` (instant runtime on/off). Defaulted so a - # sparse/older state file still evaluates; nomarchy.settings exposes them. - settings.nightlight = { installed = false; on = true; }; + # Non-appearance feature settings the menu/watchers write into this same + # in-flake state. nomarchy.nightlight: `installed` (sticky — gates the unit, + # so the first enable rebuilds) and `on` (instant runtime on/off). + # settings.keyboard.devices: per-device layouts the new-keyboard watcher + # remembers (device-name -> XKB layout), git-tracked instead of + # ~/.local/state; they graduate into nomarchy.keyboard.devices on the next + # rebuild. Defaulted so a sparse/older state file still evaluates; + # nomarchy.settings exposes them. + settings = { + nightlight = { installed = false; on = true; }; + keyboard.devices = { }; + }; }; parsed = lib.recursiveUpdate defaults themeState; diff --git a/templates/downstream/home.nix b/templates/downstream/home.nix index 6b6a792..e8fc03c 100644 --- a/templates/downstream/home.nix +++ b/templates/downstream/home.nix @@ -38,8 +38,9 @@ # "keychron-keychron-k2" = { layout = "de"; }; # }; # - # nomarchy.keyboard.layouts = [ "de" "fr" ]; # rofi-prompt for a layout when a - # # new keyboard connects + remember it + # nomarchy.keyboard.layouts = [ "de" "fr" ]; # rofi-prompt for a layout when a new + # # keyboard connects + remember it in the + # # flake state (graduates to .devices above) # ── Application suite ─────────────────────────────────────────────── # A starter complete-workstation set, installed for your user — yours to