feat(keyboard): per-device layout memory in-flake (no ~/.local/state)

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.<name> 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 <noreply@anthropic.com>
This commit is contained in:
Bernardo Magri
2026-06-23 09:18:33 +01:00
parent 685126ab47
commit aed41793f8
5 changed files with 83 additions and 28 deletions

View File

@@ -485,9 +485,10 @@ how to override it. Items marked ✓ are shipped.
`custom/nightlight` indicator shows the moon while running and hides otherwise. `custom/nightlight` indicator shows the moon while running and hides otherwise.
This is the first cut of a broader principle — **any user-settable config 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 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 outside the checkout** (Phase 1 night-light + Phase 2 the per-device
below, still in `~/.local/state`; then opt-in auto-commit of the flake on keyboard-layout memory below are done — both now in the git-tracked state
each mutation, owned files only). Remaining (optional): geo (lat/long) auto 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 sunset/sunrise (would mean wlsunset, which schedules by location). Pending an
on-machine check (first enable rebuilds + comes on; later toggles instant; off on-machine check (first enable rebuilds + comes on; later toggles instant; off
persists across reboot via ExecCondition; stopping hyprsunset restores gamma). 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 hasn't been chosen before, it pops a rofi layout picker, applies the
choice as a **per-device** `hyprctl keyword device[<name>]:kb_layout` (the choice as a **per-device** `hyprctl keyword device[<name>]:kb_layout` (the
runtime twin of the declarative `device{}` blocks — so it isolates that runtime twin of the declarative `device{}` blocks — so it isolates that
one keyboard and never touches the built-in board), and remembers it one keyboard and never touches the built-in board), and remembers it. The
per-device in `~/.local/state/nomarchy/keyboard-layouts` — re-applied boot-time set (incl. the built-in keyboard) is never prompted. The
silently on later reconnects. The boot-time set (incl. the built-in runtime-remember complement to the declarative `keyboard.devices`.
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 **Picker verified on hardware (2026-06-18)**; the first cut applied the
choice with `switchxkblayout` (a global layout-index flip) and merged the choice with `switchxkblayout` (a global layout-index flip) and merged the
candidate pool into `input.kb_layout`, so a selection leaked onto 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 + laptop keyboard and stuck after unplug — fixed by the per-device keyword +
keeping the pool out of the session layout. Remaining (bonus): offer to keeping the pool out of the session layout.
write a choice into `keyboard.devices` so it graduates to the reproducible -**In-flake state + graduation (2026-06-23):** the remembered picks moved
config; **re-verify the per-device isolation on hardware**. 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 - 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

@@ -8,6 +8,10 @@ let
c = t.colors; c = t.colors;
inherit (config.nomarchy.lib) rgb rgba; 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 # swayosd's `--input-volume mute-toggle` draws the OSD but never flips the
# source mute (verified on hardware: wpctl toggles the state, swayosd's # 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 # input-mute path is a no-op — unlike its working output-mute path). Do the
@@ -76,19 +80,37 @@ let
# `device[<name>]:kb_layout` keyword — the runtime twin of the declarative # `device[<name>]:kb_layout` keyword — the runtime twin of the declarative
# device blocks, so it isolates that one keyboard and never disturbs the # device blocks, so it isolates that one keyboard and never disturbs the
# built-in board (switchxkblayout flipped the *shared* layout, which leaked # built-in board (switchxkblayout flipped the *shared* layout, which leaked
# onto the laptop and stuck after unplug). NOTE: needs an on-hardware test # onto the laptop and stuck after unplug).
# (hotplug isn't verifiable in CI). #
# 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" '' keyboardWatch = pkgs.writeShellScriptBin "nomarchy-keyboard-watch" ''
set -u set -u
layouts="${lib.concatStringsSep " " pickerLayouts}" layouts="${lib.concatStringsSep " " pickerLayouts}"
declared="${lib.concatStringsSep " " (builtins.attrNames config.nomarchy.keyboard.devices)}" declared="${lib.concatStringsSep " " (builtins.attrNames config.nomarchy.keyboard.devices)}"
state="''${XDG_STATE_HOME:-$HOME/.local/state}/nomarchy/keyboard-layouts" sync=${sync}
mkdir -p "$(dirname "$state")"; [ -f "$state" ] || : > "$state"
apply() { hyprctl keyword "device[$1]:kb_layout" "$2" >/dev/null 2>&1; } apply() { hyprctl keyword "device[$1]:kb_layout" "$2" >/dev/null 2>&1; }
keyboards() { hyprctl devices -j | jq -r '.keyboards[].name'; } keyboards() { hyprctl devices -j | jq -r '.keyboards[].name'; }
is_declared() { case " $declared " in *" $1 "*) return 0 ;; *) return 1 ;; esac; } 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 # Startup: re-apply remembered layouts, never prompt so the built-in
# keyboard just stays on the session default. # keyboard just stays on the session default.
@@ -112,7 +134,7 @@ let
apply "$kb" "$s" apply "$kb" "$s"
else else
choice=$(printf '%s\n' $layouts | rofi -dmenu -p "Layout · $kb") 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 fi
done done
prev="$cur" 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.<name> 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 # nwg-displays: interactive monitor arranger (applies live via hyprctl and
# writes a config file). A helper to find values for nomarchy.monitors — # 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 # the declarative config stays the source of truth (we don't source its

View File

@@ -107,9 +107,12 @@ in
hasn't been chosen before, it pops a rofi picker (these layouts plus hasn't been chosen before, it pops a rofi picker (these layouts plus
the primary nomarchy.keyboard.layout), applies the choice to that the primary nomarchy.keyboard.layout), applies the choice to that
keyboard only (a per-device kb_layout, so the built-in board is left keyboard only (a per-device kb_layout, so the built-in board is left
alone), and remembers it per-device (~/.local/state) re-applying alone), and remembers it in the git-tracked in-flake state
automatically on later reconnects. The runtime-remember complement to (settings.keyboard.devices, not ~/.local/state) re-applied
the declarative keyboard.devices; a stateful runtime piece by design. 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 nomarchy.keyboard.layout for that keyboard only e.g. an external
keyboard that's physically a different layout than the laptop's keyboard that's physically a different layout than the laptop's
built-in one. Hyprland applies it automatically whenever that 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.
''; '';
}; };

View File

@@ -55,11 +55,18 @@ let
# switch always replaces it (deep_merge would otherwise leave it stuck). # switch always replaces it (deep_merge would otherwise leave it stuck).
border = { active = "accent"; inactive = "overlay"; }; border = { active = "accent"; inactive = "overlay"; };
# Non-appearance feature settings the menu writes into this same in-flake # Non-appearance feature settings the menu/watchers write into this same
# state. nomarchy.nightlight: `installed` (sticky — gates the unit, so the # in-flake state. nomarchy.nightlight: `installed` (sticky — gates the unit,
# first enable rebuilds) and `on` (instant runtime on/off). Defaulted so a # so the first enable rebuilds) and `on` (instant runtime on/off).
# sparse/older state file still evaluates; nomarchy.settings exposes them. # settings.keyboard.devices: per-device layouts the new-keyboard watcher
settings.nightlight = { installed = false; on = true; }; # 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; parsed = lib.recursiveUpdate defaults themeState;

View File

@@ -38,8 +38,9 @@
# "keychron-keychron-k2" = { layout = "de"; }; # "keychron-keychron-k2" = { layout = "de"; };
# }; # };
# #
# nomarchy.keyboard.layouts = [ "de" "fr" ]; # rofi-prompt for a layout when a # nomarchy.keyboard.layouts = [ "de" "fr" ]; # rofi-prompt for a layout when a new
# # new keyboard connects + remember it # # keyboard connects + remember it in the
# # flake state (graduates to .devices above)
# ── Application suite ─────────────────────────────────────────────── # ── Application suite ───────────────────────────────────────────────
# A starter complete-workstation set, installed for your user — yours to # A starter complete-workstation set, installed for your user — yours to