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

@@ -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[<name>]: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.<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
# 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