feat(waybar): show the layout indicator only when the layout is ambiguous (#109)
All checks were successful
Check / eval (push) Successful in 3m27s
All checks were successful
Check / eval (push) Successful in 3m27s
The indicator answers "which layout am I typing in?", so it is worth bar space exactly when that question has more than one answer. It was gated on a per-device override merely *existing*, which is the trap this box walked into: the junk rows a keyboard's extra HID collections leave behind are all remembered at the session layout, so they added no second answer and still raised an indicator that could only ever show one value. Gate on layouts rather than devices, and read the runtime-remembered settings.keyboard.devices alongside the declared option — the new-keyboard watcher writes those without a rebuild and they only graduate into keyboard.devices later, so a bar reading the option alone was wrong for exactly as long as the memory was fresh, which is when it matters most. keyboard.layouts is now excluded on purpose: hyprland.nix keeps it as the pool the picker offers first and deliberately never merges it into kb_layout, so listing candidates cannot make the current layout ambiguous. A device actually using one of them still does. All four whole-swaps list the module unconditionally, so a single-layout setup saw on a swapped bar what the generated bar hides — the parity rule was already broken. Filter a swap through the same answer instead of asking four hand-written files to re-derive it. Logic is pure in modules/home/waybar-language.nix, matching monitor-rules / dock-audio-rules; checks.waybar-language unit-tests both halves against boreal's real swap, including a fixture-drift guard so the parity assertion cannot pass vacuously. Verified V2: new check passes, docking-ux and theme-wholeswap still pass, `nix flake check` is clean, and the live config's generated bar now resolves modules-right without hyprland/language on its single-layout default. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
claim that float/move bindings are absent. Pass = presentation is scannable and
|
||||||
an automated comparison detects omissions or stale displayed bindings.
|
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
|
### 110. `[big]` Retire Control Center safely
|
||||||
|
|
||||||
Split this into two phases before implementation: first build a migration/drop
|
Split this into two phases before implementation: first build a migration/drop
|
||||||
|
|||||||
@@ -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
|
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
|
timing to V3. V3 is queued as round 4; no speculative audio power policy was
|
||||||
added.
|
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
|
✓ **Parallel fingerprint-or-password (2026-07-12, Bernardo promoted
|
||||||
from PROPOSED):** `nomarchy.hardware.fingerprint.parallel` (default
|
from PROPOSED):** `nomarchy.hardware.fingerprint.parallel` (default
|
||||||
**true** — the better UX is what opting into fingerprint PAM gets you;
|
**true** — the better UX is what opting into fingerprint PAM gets you;
|
||||||
|
|||||||
53
flake.nix
53
flake.nix
@@ -876,6 +876,59 @@
|
|||||||
in
|
in
|
||||||
assert ok; pkgs.runCommand "nomarchy-display-profiles" { } "touch $out";
|
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
|
# Docking artifact guard: default/no-profile Home Manager output
|
||||||
# must still launch both hotplug watchers, put Rofi on the focused
|
# must still launch both hotplug watchers, put Rofi on the focused
|
||||||
# monitor, expose the manual keyboard picker, and never ship the
|
# monitor, expose the manual keyboard picker, and never ship the
|
||||||
|
|||||||
51
modules/home/waybar-language.nix
Normal file
51
modules/home/waybar-language.nix
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -12,12 +12,16 @@
|
|||||||
let
|
let
|
||||||
t = config.nomarchy.theme;
|
t = config.nomarchy.theme;
|
||||||
|
|
||||||
# Show the active-keyboard-layout indicator only when more than one layout
|
# Whether the keyboard-layout indicator is worth bar space, and the filter
|
||||||
# is in play — multiple session layouts (comma-separated) or per-device
|
# that holds a whole-swap to the same answer. Contract + rationale (and
|
||||||
# overrides (nomarchy.keyboard.devices) — so single-layout bars stay clean.
|
# checks.waybar-language) live in ./waybar-language.nix.
|
||||||
showLanguage = lib.hasInfix "," config.nomarchy.keyboard.layout
|
langLib = import ./waybar-language.nix { inherit lib; };
|
||||||
|| config.nomarchy.keyboard.layouts != [ ]
|
showLanguage = langLib.show {
|
||||||
|| config.nomarchy.keyboard.devices != { };
|
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-
|
# Power-profile indicator (power-profiles-daemon). Both scripts self-
|
||||||
# gate: they exit silently unless this is a laptop (a battery is
|
# gate: they exit silently unless this is a laptop (a battery is
|
||||||
@@ -534,7 +538,7 @@ in
|
|||||||
# docs/OVERRIDES.md.
|
# docs/OVERRIDES.md.
|
||||||
settings.mainBar = lib.mkDefault (
|
settings.mainBar = lib.mkDefault (
|
||||||
if hasConfigOverride
|
if hasConfigOverride
|
||||||
then builtins.fromJSON (builtins.readFile configOverride)
|
then gateLanguage (builtins.fromJSON (builtins.readFile configOverride))
|
||||||
else generatedSettings
|
else generatedSettings
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user