diff --git a/agent/BACKLOG.md b/agent/BACKLOG.md index 63be285..df0375a 100644 --- a/agent/BACKLOG.md +++ b/agent/BACKLOG.md @@ -149,13 +149,6 @@ Hyprland binding appears from the canonical source. Do not repeat the obsolete claim that float/move bindings are absent. Pass = presentation is scannable and an automated comparison detects omissions or stale displayed bindings. -### 109. Conditional keyboard Waybar indicator parity - -Hide the keyboard indicator for an unambiguous one-layout setup; show it when -multiple layouts, a per-device override, or a remembered keyboard makes it -useful. Pass = hotplug/remembered-device transitions update correctly and every -whole-bar swap matches the generated default bar. - ### 110. `[big]` Retire Control Center safely Split this into two phases before implementation: first build a migration/drop diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 0170cad..eaf1346 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -500,6 +500,52 @@ Design/decision records and a running log of shipped work (items marked the internal-first recovery before deletion and leaves real cable-removal timing to V3. V3 is queued as round 4; no speculative audio power policy was added. + ✓ **Docking rounds 4–5 — the hotplug event is not the truth (2026-07-14):** + the dev-box dock pass failed on wallpaper, dock mode, docked audio and a + four-times keyboard prompt. Three causes, and a process lesson. `socat -T 1` + closed Hyprland's IPC socket after a second of *inactivity* — a dock is + normally plugged into an idle desktop, so `monitoradded` was routinely lost + in the reconnect gap, and wallpaper, profile and audio all hung off that one + event. The connection is now long-lived with the tick coming from a 1s + `read` timeout, and the *output set* is the source of truth: `reconcile` + diffs it on ticks and events alike, so an event only makes the reaction + instant. A newly-present external is as unambiguous a plug as the event, so + it may still override a manual sink choice. Automatic dock mode never + existed — the watcher auto-*undocked* but on plug only matched display + profiles, so with none saved it did nothing while `Dock mode` stayed + menu-only; the pair is now symmetric, with a matched profile winning as the + more specific layout. Hyprland calls every key-capable evdev node a + "keyboard" (lid/power buttons, a monitor's control channel, each extra HID + collection), so udev's `ID_INPUT_KEYBOARD` is now the filter and the prompt + is once per physical keyboard, applied to every node it brings. Audio to the + BenQ was unreachable *by any means* because WirePlumber had the GPU's DP + card pinned to `pro-audio`, whose raw `pro-output-N` sinks have no ports, + no routing and no volume — and card profiles persist, so it survived + forever; a plug that finds nothing routable now repairs cards carrying an + *available* HDMI/DP port (the monitor asking for audio over the cable), + which keeps the internal analog card and considered pro-audio setups + untouched. On UCM cards headphones are a sink rather than a route, and a + stored default outranks every priority rule, so the jack is now followed + explicitly; unplug needs no undo since the pin goes stale by itself. The + process lesson: both watchers are `exec-once`, so `nomarchy-home` leaves the + *old* processes running and two of the four reports were never testing the + fix — **relogin before testing a watcher**. V3 is queued as round 6. + ✓ **Conditional layout indicator + swap parity (#109, 2026-07-14):** the + indicator answers "which layout am I typing in?", so it earns bar space + exactly when that has more than one answer — not when a device merely + exists. Counting devices was the trap: the junk rows a keyboard's extra HID + collections leave behind are all remembered at the session layout and added + no second answer. The gate reads the runtime-remembered + `settings.keyboard.devices` alongside the declared option, since the watcher + writes them without a rebuild and they only graduate later — reading the + option alone was wrong for exactly as long as the memory was fresh. + `keyboard.layouts` is excluded on purpose: hyprland.nix keeps it as the + picker's candidate pool and never merges it into `kb_layout`, so it cannot + make the current layout ambiguous by itself. All four whole-swaps list the + module unconditionally and so used to show what the generated bar hid; they + are filtered through the same answer rather than re-deriving it by hand + (the parity rule). Logic is pure in `modules/home/waybar-language.nix`; + `checks.waybar-language` unit-tests both halves against boreal's real swap. ✓ **Parallel fingerprint-or-password (2026-07-12, Bernardo promoted from PROPOSED):** `nomarchy.hardware.fingerprint.parallel` (default **true** — the better UX is what opting into fingerprint PAM gets you; diff --git a/flake.nix b/flake.nix index 9c252d0..b065b42 100644 --- a/flake.nix +++ b/flake.nix @@ -876,6 +876,59 @@ in assert ok; pkgs.runCommand "nomarchy-display-profiles" { } "touch $out"; + # The layout indicator's gate, and the parity of a whole-swap bar + # with the generated one. Both halves are pure — the interesting + # inputs (a fresh runtime memory, junk device rows) are awkward to + # stage in a VM and trivial to state here. + waybar-language = + let + inherit (nixpkgs) lib; + langLib = import ./modules/home/waybar-language.nix { inherit lib; }; + expect = cond: msg: lib.assertMsg cond "waybar-language: ${msg}"; + swap = builtins.fromJSON (builtins.readFile ./themes/boreal/waybar.jsonc); + gatedOff = langLib.gate false swap; + inSwap = bar: lib.any + (n: builtins.isList bar.${n} && lib.elem "hyprland/language" bar.${n}) + (lib.filter (lib.hasPrefix "modules-") (lib.attrNames bar)); + ok = + expect (! langLib.show { layout = "us"; }) + "a plain single-layout session must not show the indicator" + && expect (langLib.show { layout = "us,pt"; }) + "two session layouts must show the indicator" + && expect (langLib.show { + layout = "us"; + devices.dell-kb.layout = "pt"; + }) + "a declared per-device override must show the indicator" + && expect (langLib.show { + layout = "us"; + remembered."by-tech-gaming-keyboard" = "pt"; + }) + "a freshly remembered device must show the indicator before it graduates" + && expect (! langLib.show { + layout = "us"; + remembered = { + "generic-benq-gw2790qt" = "us"; + "by-tech-gaming-keyboard-consumer-control" = "us"; + }; + }) + "devices remembered at the session layout add no second answer" + && expect (! langLib.show { layout = "us"; devices.dell-kb.layout = "us"; }) + "a declared override at the session layout adds no second answer" + # The pool is picker candidates only (hyprland.nix never + # merges it into kb_layout), so it cannot raise the indicator + # by itself — a device actually using one of them does. + && expect (! langLib.show { layout = "us"; }) + "the candidate pool alone must not show the indicator" + && expect (inSwap swap) + "fixture drift: boreal's whole-swap no longer lists the language module" + && expect (! inSwap gatedOff) + "a whole-swap must hide the module wherever the generated bar does" + && expect (langLib.gate true swap == swap) + "gating on must leave a whole-swap untouched"; + in + assert ok; pkgs.runCommand "nomarchy-waybar-language" { } "touch $out"; + # Docking artifact guard: default/no-profile Home Manager output # must still launch both hotplug watchers, put Rofi on the focused # monitor, expose the manual keyboard picker, and never ship the diff --git a/modules/home/waybar-language.nix b/modules/home/waybar-language.nix new file mode 100644 index 0000000..8dfcb84 --- /dev/null +++ b/modules/home/waybar-language.nix @@ -0,0 +1,51 @@ +# When the keyboard-layout indicator earns a place in the bar, and how a +# whole-swap bar is held to the same answer (BACKLOG #109). +# +# Pure so checks.waybar-language can unit-test the contract without building +# a bar. Consumed by modules/home/waybar.nix. +{ lib }: + +rec { + # The session's layouts are the comma-separated kb_layout. + # nomarchy.keyboard.layouts is deliberately NOT among them — hyprland.nix + # keeps it as the pool the new-keyboard picker offers first and never + # merges it into kb_layout — so listing candidates cannot by itself make + # the current layout ambiguous. + sessionLayouts = layout: lib.filter (l: l != "") (lib.splitString "," layout); + + # The indicator answers "which layout am I typing in?", so it is worth bar + # space exactly when that has more than one answer. + # + # devices = declared nomarchy.keyboard.devices (submodules with .layout) + # remembered = settings.keyboard.devices from the in-flake state: what the + # new-keyboard watcher writes the moment a layout is chosen. + # These only graduate into `devices` at the next rebuild, so a + # bar reading `devices` alone stays wrong for exactly as long + # as the memory is fresh — which is when it matters most. + # + # Counting devices rather than layouts is the trap: remembering "us" for a + # device on a "us" session adds no second answer, and the rows a keyboard's + # extra HID collections leave behind are all of that kind. + show = { layout, devices ? { }, remembered ? { } }: + let + session = sessionLayouts layout; + perDevice = lib.mapAttrsToList (_: d: d.layout) devices + ++ lib.attrValues remembered; + in + lib.length session > 1 || lib.any (l: ! lib.elem l session) perDevice; + + # A whole-swap authors its bar in full and every one of them lists the + # language module unconditionally, so without this a single-layout setup + # showed on a swapped bar what the generated bar hides. Filter the swap + # through the one answer rather than asking four hand-written files to + # re-derive it (CONVENTIONS: the parity rule). + gate = showLanguage: bar: + if showLanguage then bar + else + lib.mapAttrs + (n: v: + if lib.hasPrefix "modules-" n && builtins.isList v + then lib.filter (m: m != "hyprland/language") v + else v) + bar; +} diff --git a/modules/home/waybar.nix b/modules/home/waybar.nix index 1e4afca..cdc03e2 100644 --- a/modules/home/waybar.nix +++ b/modules/home/waybar.nix @@ -12,12 +12,16 @@ let t = config.nomarchy.theme; - # Show the active-keyboard-layout indicator only when more than one layout - # 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 != { }; + # Whether the keyboard-layout indicator is worth bar space, and the filter + # that holds a whole-swap to the same answer. Contract + rationale (and + # checks.waybar-language) live in ./waybar-language.nix. + langLib = import ./waybar-language.nix { inherit lib; }; + showLanguage = langLib.show { + layout = config.nomarchy.keyboard.layout; + devices = config.nomarchy.keyboard.devices; + remembered = config.nomarchy.settings.keyboard.devices or { }; + }; + gateLanguage = langLib.gate showLanguage; # Power-profile indicator (power-profiles-daemon). Both scripts self- # gate: they exit silently unless this is a laptop (a battery is @@ -534,7 +538,7 @@ in # docs/OVERRIDES.md. settings.mainBar = lib.mkDefault ( if hasConfigOverride - then builtins.fromJSON (builtins.readFile configOverride) + then gateLanguage (builtins.fromJSON (builtins.readFile configOverride)) else generatedSettings );