From 7ebfab4bd45c6d5a94b0261707aef1c22b449688 Mon Sep 17 00:00:00 2001 From: Bernardo Magri Date: Mon, 13 Jul 2026 11:53:01 +0100 Subject: [PATCH] fix(docking): make clamshell hotplug safe and discoverable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real dock QA exposed pointer-bound menus, an absent default rescue watcher, the unsafe zero-output panel-off path, missing keyboard/audio hotplug coverage, and unpainted new outputs. Route Rofi to the focused output; replace panel disable with safe workspace migration; always run and reconnect the display watcher and repaint hotplugged outputs; expose a default per-device keyboard picker; handle audio card availability transitions. Verified: V1 — nix flake check --no-build; checks.docking-ux real Home Manager artifact build; option-docs/template-sot; Nix parse, py_compile, and git diff --check. V2 unavailable: no readable KVM. V3 pending in agent/HARDWARE-QUEUE.md. --- README.md | 10 +- agent/HARDWARE-QUEUE.md | 69 ++----- agent/JOURNAL.md | 19 +- agent/MEMORY.md | 4 + docs/ROADMAP.md | 35 ++-- flake.nix | 44 +++++ modules/home/dock-audio.nix | 49 +++-- modules/home/hyprland.nix | 184 ++++++++++++------ modules/home/keybinds.nix | 1 + modules/home/options.nix | 14 +- modules/home/rofi.nix | 73 +++++-- modules/home/theme.nix | 5 +- .../nomarchy-theme-sync.py | 2 +- templates/downstream/home.nix | 6 +- tools/monitor-fallback.nix | 13 +- 15 files changed, 357 insertions(+), 171 deletions(-) diff --git a/README.md b/README.md index 5ee176b..72dba9a 100644 --- a/README.md +++ b/README.md @@ -237,7 +237,7 @@ two tables below are split along exactly that line. | `nomarchy.keyboard.layout` | `"us"` | XKB layout for the Hyprland session (installer writes the matching `services.xserver.xkb` in system.nix; the console + LUKS prompt follow via the distro default) | | `nomarchy.keyboard.variant` | `""` | XKB variant for the session | | `nomarchy.keyboard.devices` | `{}` | Per-device layout overrides (Hyprland `device` blocks keyed by `hyprctl devices` name) — e.g. an external keyboard with its own layout/variant | -| `nomarchy.keyboard.layouts` | `[]` | Extra candidate layouts; when set, a watcher prompts (rofi) for a layout on a newly-connected keyboard and remembers it per-device | +| `nomarchy.keyboard.layouts` | `[]` | Optional layouts to put first in the new-keyboard picker; the watcher runs by default, offers every installed XKB layout, and remembers the choice per-device. Pick/change one anytime at System › Keyboard | | `nomarchy.nightlight.enable` | `false` | Scheduled blue-light filter (hyprsunset) — warm at night (`.temperature`, default 4000K) between `.sunset`/`.sunrise`, no shift by day; set BOTH `.latitude`/`.longitude` for **geo mode** (wlsunset computes sunrise/sunset from your location; the fixed times are ignored); off by default — enable it from the menu (System › Night light; the first enable rebuilds), then toggle on/off instantly from the menu or the Waybar moon indicator (writes `settings.nightlight.on` in your flake, no rebuild) and it survives reboot, so it stays reproducible | | `nomarchy.updates.enable` | `false` | Passive update awareness: a background check (`.interval`, default daily) that flags when flake inputs (nixpkgs, the Nomarchy input, …) are behind upstream — and, with Flatpak on, when apps have updates (`.flatpak`) — via a Waybar indicator + notification. Never changes anything; click the indicator to run the upgrade flow | | `nomarchy.hyprland.enable` | `true` | Nomarchy's Hyprland config | @@ -245,7 +245,7 @@ two tables below are split along exactly that line. | `nomarchy.rofi.enable` | `true` | Themed rofi launcher + `nomarchy-menu` dispatcher | | `nomarchy.swaync.enable` | `true` | swaync notifications, themed | | `nomarchy.batteryNotify.enable` | `true` | Low-battery toasts at the bar's thresholds — 25% low, 10% critical (stays up until dismissed); silent no-op on machines without a battery | -| `nomarchy.dockAudio.enable` | `true` | Default audio output follows dock/monitor sinks (HDMI/DP/USB) on hotplug, with a toast; EasyEffects follows along; unplug falls back to built-in | +| `nomarchy.dockAudio.enable` | `true` | Default audio output follows dock/monitor sinks (HDMI/DP/USB) on new-sink or card/port-availability hotplug events, with a toast; EasyEffects follows along; unplug falls back to built-in. Manual route: System › Audio | | `nomarchy.firstBootWelcome.enable` | `true` | One dismissible “you're set” toast on the first session (SUPER+M / SUPER+T / SUPER+? + network pointer); marker is `settings.firstBootShown` in the flake checkout | | `nomarchy.idle.enable` | `true` | hyprlock + hypridle (idle lock 5 min, display off 10, suspend 15 min — battery-only) | | `nomarchy.yazi.enable` | `true` | yazi TUI file manager, themed + curated plugins | @@ -370,9 +370,9 @@ generations` is also your theme history. Waybar even restyles in place: it re-reads `style.css` when the symlink flips. The **wallpaper** is the one runtime piece (awww — nixpkgs' swww — is -imperative; nothing in Nix consumes the path): applied at session start and -after every switch via a tiny activation hook, cycled instantly with -`bg next`. +imperative; nothing in Nix consumes the path): applied at session start, +after every switch via a tiny activation hook, and again when a monitor is +hotplugged; cycled instantly with `bg next`. ### Config the menu writes (not just themes) diff --git a/agent/HARDWARE-QUEUE.md b/agent/HARDWARE-QUEUE.md index ffd59bf..1d9f00e 100644 --- a/agent/HARDWARE-QUEUE.md +++ b/agent/HARDWARE-QUEUE.md @@ -11,54 +11,27 @@ the **T14s** (webcam case). ## Any machine (dev box is fine) -- [ ] **#86 clamshell lid + external display** — V2 asserts - `HandleLidSwitchDocked=ignore` in logind.conf. On a laptop with a - real external monitor: open a session, plug external, close lid — - machine must **stay awake** (no suspend); external panel remains - usable (or wakes with keyboard if DPMS). Unplug external, close lid - — should suspend as usual. Optional: with a docked display-profile - (`eDP-1` disable), confirm clamshell layout still looks right after - lid close. Pass = no surprise suspend when docked. -- [ ] **#87 round 2: dock audio auto-follow (watcher)** — round 1 result - (Newton, 2026-07-12): audio stayed on speakers when docking; the - running generation had no `90-nomarchy-dock-audio` rules yet, and - live testing showed the priority rules alone can never win anyway — - WirePlumber's stored default (`default-nodes` state, written by any - past explicit pick) outranks `priority.session`, and this machine - (like most) has one. Fix shipped: the `nomarchy-dock-audio` user - service `pactl set-default-sink`s dock-class sinks on hotplug; - EasyEffects follows the default (verified live: EE's output links - moved to a fake dock-named null sink mid-playback). Retest after - pull+rebuild+home on ≥ this rev: play audio, plug the dock → - toast "Audio → " and sound moves with EE effects - still active; unplug → sound returns to speakers; while docked, - picking speakers in the Audio menu sticks until the next replug. - If it does NOT switch, capture for diagnosis: `pactl subscribe` - output during the plug, `pactl --format=json list sinks` while - docked (sink names may not match the dock regexes, or the sink - node may pre-exist and only flip route availability — the watcher - keys on new-sink events). -- [ ] **Docked display/audio menu rows** (2026-07-12) — with a real - external monitor: Display menu shows "Laptop screen off · - everything → " (only with >1 active output), "Screen on · - " after a disable, "Move workspace · → next monitor", "Swap - workspaces" (exactly 2 outputs). Laptop-off must move its - workspaces to the external; Screen on restores it (preferred/auto — - declared scale/pos return at the next relogin/switch). Audio menu - shows "Send output → " when one exists and isn't - default; picking it moves output + toasts. Pass = rows appear/act - as described, and none of them show on a single-display session. - Also — **run this one with nothing important open**: with the - laptop screen off via the menu, physically unplug the dock. The - panel should re-enable within ~2s (`rescue_blackout` in the - display watcher). Known risk from the VM harness - (tools/monitor-fallback.nix): on Hyprland 0.55.4 the zero-output - state itself can crash the compositor faster than any rescue - (softGL VM: 4/5 runs; control with the panel active survives - always) — a crash on real DRM ends the session to the greeter on - the re-lit panel. If that happens on hardware, the "Laptop screen - off" row needs a rework (mirror instead of disable, or drop until - a Hyprland bump) — file it as a NOW bug with the coredump. +- [ ] **Docking correction round 3 (2026-07-13 hardware-QA follow-up)** — + prior result: lid-close stayed awake, but SUPER+M rendered on the + hidden laptop workspace; the keyboard never prompted; audio stayed + on speakers; the old laptop-off action lost some applications and + abrupt undock left eDP black. After updating to the commit carrying + this entry and rebuilding Home Manager, start with the laptop open + and play audio. Close the lid and plug the monitor/keyboard: SUPER+M + must render on the external focused workspace; the new keyboard must + ask for a searchable XKB layout (or set/change it at System › + Keyboard); audio must toast and move to the monitor. System › Audio › + Output device remains the manual fallback; the current wallpaper must + cover the external output/workspaces within ~2s. Open Display and choose + **Dock mode**: all eDP workspaces/windows move intact to the external + and focus follows; no terminal/application exits. Yank the cable: the + internal panel is already the logical fallback, lights immediately, + and the moved workspaces/windows survive. Replug then manually choose + speakers: volume/mute changes must not force the monitor again; only a + fresh replug should. If audio still misses, capture `pactl subscribe` + during plug plus `pactl --format=json list sinks` while docked. If the + menu is still misplaced, capture `hyprctl monitors -j` immediately + before SUPER+M (the `.focused` field is the discriminator). - [ ] **Parallel fingerprint-or-password on the real reader** (AMD dev box, 2026-07-12) — with a finger enrolled and `fingerprint.pam = true` (parallel is the default): `sudo -k true` diff --git a/agent/JOURNAL.md b/agent/JOURNAL.md index f1d1531..d225b18 100644 --- a/agent/JOURNAL.md +++ b/agent/JOURNAL.md @@ -19,6 +19,24 @@ Template: --- +## 2026-07-13 — Docking hardware-QA corrections (interactive feedback) +- **Task:** Bernardo's real clamshell QA: menu on hidden eDP, no keyboard + layout route, audio stayed on speakers, laptop-off lost apps, abrupt + undock never restored eDP. +- **Did:** Rofi → focused output; replaced eDP disable with safe Dock mode + (move workspaces/focus, keep fallback); display watcher now unconditional, + reconnecting and race-tolerant. Keyboard watcher runs by default with all + XKB layouts + System › Keyboard. Audio also reacts to card/port changes; + monitor-added re-applies the current awww wallpaper to the new output. + Corrected the fallback harness assumption; added `checks.docking-ux` and + synced README/template/ROADMAP/hardware queue. +- **Verified:** V1 — `nix flake check --no-build`; `checks.docking-ux` + built the real HM generation and passed syntax/invariant/layout-catalog + assertions; option docs 90/90, template SoT 4/4; Nix parse + Python + compile + diff check. V2 unavailable (no readable KVM). +- **Pending:** hardware retest in HARDWARE-QUEUE (docking correction round 3). +- **Next suggestion:** hardware retest this exact dock sequence. + ## 2026-07-12 — #89 slice 3: Documented Signal tray icon limitation (no commit) - **Task:** BACKLOG #89 slice 3 — Signal's tray icon is full-colour blue and doesn't match the monochrome bar. - **Did:** Researched Signal's Linux tray icon behavior. Found it is a hardcoded, dynamically generated pixmap (`Signal_status_icon_1`) driven by its Electron wrapper with no built-in toggle for a monochrome icon. Added an entry to `docs/ROADMAP.md`'s known issues noting that certain Electron apps cannot be natively themed. Closed out #89 from the backlog without introducing brittle `app.asar` repacking hacks. @@ -608,4 +626,3 @@ Template: - **Pending:** slices 2 (systemd sunrise/sunset timers + login apply, V2) and 3 (Look & Feel menu) remain under #79. - **Next suggestion:** #79 slice 2 — the timer/hook (V2 runNixOSTest). - diff --git a/agent/MEMORY.md b/agent/MEMORY.md index 876debd..be4b5f4 100644 --- a/agent/MEMORY.md +++ b/agent/MEMORY.md @@ -162,6 +162,10 @@ iteration would otherwise rediscover. - Hyprland 0.55 renames `stayfocused` → `stay_focused` (and similar underscore effects); `stayfocused 1` is `invalid field type` at parse (§ polkit workspace rules, 2026-07-10 hardware). +- Never gate a safety listener behind the optional feature it also serves: + the display menu existed with no profiles while its blackout rescue did + not. Rofi defaults to mouse-pointer output (`monitor=-5`), which is stale + in clamshell mode; use focused output (`-1`) for keyboard-launched UI. - Hyprland 0.53 rewrote window rules: `windowrulev2` is a hard error and the old rule-first `float, class:^…$` no longer parses — both surface a red config-error banner on the default desktop. Hyprlang legacy form is diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index e6fc440..68c46e7 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -416,14 +416,9 @@ Design/decision records and a running log of shipped work (items marked `useDefaultOutputDevice` defaults true and verified live — with a stream playing, EE's output links moved to a fake dock-named null sink and back on unload. Unplug = WirePlumber priority fallback to built-in. - Docked menu rows shipped alongside: Display gains "Laptop screen off · - everything → " (live-only `keyword monitor ,disable` — a - persisted disable could black-screen an undocked boot), "Screen on · - ", "Move workspace · → next monitor" - (`movecurrentworkspacetomonitor +1`), "Swap workspaces" - (`swapactiveworkspaces`, exactly-2 gate); Audio gains a self-gated - "Send output → " quick row (the watcher's manual override). - Real-dock V3 for both in HARDWARE-QUEUE. + Docked menu rows shipped alongside (the original panel-disable action is + superseded by the hardware-QA correction below); Audio has a self-gated + "Send output → " quick row plus the full output picker. ✓ **Undock blackout rescue (2026-07-12):** Bernardo asked whether the panel comes back if you undock while "Laptop screen off" — VM answer (tools/monitor-fallback.nix, full softGL desktop): **no**, Hyprland @@ -439,10 +434,28 @@ Design/decision records and a running log of shipped work (items marked survives 5/5, so it's the state, not headless removal; monitor- disconnect crashes are a known upstream class). Posture: rescue ships as defense-in-depth (recovers every surviving case; worst case on real - DRM is session-to-greeter on the re-lit panel, not a black brick); - V3 decides if the menu row needs a mirror-based rework. Also fixed in - the rescue: `grep -c .` exits 1 at count 0 — an `||`-guard on that + DRM is session-to-greeter on the re-lit panel, not a black brick). + Also fixed in the rescue: `grep -c .` exits 1 at count 0 — an `||`-guard on that pipeline silently disabled the rescue exactly when it mattered. + ✓ **Docking hardware-QA correction (2026-07-13):** real clamshell QA + confirmed every warned residual: Rofi's default `monitor=-5` followed + the mouse pointer onto the hidden laptop workspace; the profile watcher + (and blackout rescue) was not launched with default + `displayProfiles = {}`; disabling eDP then unplugging failed to restore + it, with application loss consistent with the known zero-output crash; + audio did not arrive as a new sink; and the keyboard picker was disabled + by an empty optional-candidate list. Correction: Rofi targets the focused + output (`monitor=-1`); interactive **Dock mode** explicitly moves eDP + workspaces/focus to the external but keeps eDP logically active as the + unplug fallback; the reconnecting display watcher runs unconditionally + and watches through the racy removal snapshot; keyboard hotplug runs by + default, searches the pinned XKB catalog without D-Bus, persists + per-device, and has a manual System › Keyboard path; dock audio handles new sinks and + card/profile/port-availability changes while ignoring volume/default + changes so manual picks stick; and monitor-added re-applies the current + awww wallpaper after the output settles (new outputs do not inherit the + existing image). `checks.docking-ux` guards the generated default-session + artifacts; the corrected real sequence remains V3. ✓ **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 6c5b988..3cd5b89 100644 --- a/flake.nix +++ b/flake.nix @@ -779,6 +779,50 @@ in assert ok; pkgs.runCommand "nomarchy-display-profiles" { } "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 + # zero-output-prone "Laptop screen off" menu action again. + docking-ux = + let + hm = self.nixosConfigurations.nomarchy-live.config + .home-manager.users.${username}; + gen = hm.home.activationPackage; + hyprconf = hm.xdg.configFile."hypr/hyprland.conf".source; + audio = toString hm.systemd.user.services.nomarchy-dock-audio.Service.ExecStart; + in + assert hm.programs.rofi.extraConfig.monitor == -1; + pkgs.runCommand "nomarchy-docking-ux" + { nativeBuildInputs = [ pkgs.bash pkgs.findutils pkgs.gnugrep ]; } + '' + set -euo pipefail + menu=$(find ${gen}/home-path/bin -name nomarchy-menu | head -1) + keyboard=$(find ${gen}/home-path/bin -name nomarchy-keyboard-layout | head -1) + watcher=$(find ${gen}/home-path/bin -name nomarchy-display-profile-watch | head -1) + test -n "$menu" -a -n "$keyboard" -a -n "$watcher" + bash -n "$menu" + bash -n "$keyboard" + bash -n "$watcher" + bash -n ${audio} + "$keyboard" layouts > layouts.txt + test "$(wc -l < layouts.txt)" -gt 50 + grep -qx us layouts.txt + grep -qx gb layouts.txt + grep -qx pt layouts.txt + grep -q 'nomarchy-keyboard-watch' ${hyprconf} + grep -q 'nomarchy-menu keyboard' ${hyprconf} + grep -q 'nomarchy-display-profile-watch' ${hyprconf} + grep -q -- '--quiet wallpaper' "$watcher" + grep -q 'Dock mode' "$menu" + grep -q 'moveworkspacetomonitor' "$menu" + grep -q 'nomarchy-keyboard-layout' "$menu" + ! grep -q 'Laptop screen off' "$menu" + grep -q "'change':card" ${audio} + grep -q 'availability' ${audio} + echo "docking-ux: focused menu + safe display + keyboard/audio watchers present" + touch $out + ''; + # Night-light geo mode: with BOTH lat/long set the backing unit # flips hyprsunset → wlsunset (location-computed sunrise/sunset) # and the live-state ExecCondition gate survives the swap (the diff --git a/modules/home/dock-audio.nix b/modules/home/dock-audio.nix index 5479f7c..fde4c26 100644 --- a/modules/home/dock-audio.nix +++ b/modules/home/dock-audio.nix @@ -46,28 +46,47 @@ let "Output → ''${2:-$1}" 2>/dev/null || true } + # Return dock-class sinks whose current port is usable. PipeWire often + # creates the HDMI/DP node before the cable is plugged and later flips its + # card/port availability, so presence alone is not a hotplug signal. + dock_sinks() { + $PACTL --format=json list sinks \ + | $JQ -r --arg re '^(${dockSinkRe})$' ' + .[] + | select(.name | test($re)) + | select( + ((.ports // []) | length) == 0 + or any(.ports[]?; (.availability // "unknown") != "not available") + ) + | "\(.name)\t\(.description)"' + } + + adopt_first_dock() { + dock_sinks | while IFS="$TAB" read -r name desc; do + [ -n "$name" ] || continue + switch_to "$name" "$desc" + break + done + } + # Startup sweep (login/relogin while already docked): adopt a dock # sink only when the current default is NOT one — a service restart # must not override a manual in-dock pick. if ! is_dock "$($PACTL get-default-sink)"; then - $PACTL --format=json list sinks \ - | $JQ -r '.[] | "\(.name)\t\(.description)"' \ - | while IFS="$TAB" read -r name desc; do - if is_dock "$name"; then switch_to "$name" "$desc"; break; fi - done + adopt_first_dock fi - # Event loop: `pactl subscribe` prints "Event 'new' on sink #NN". + # Event loop: handle both a genuinely new sink and the common case where + # an existing HDMI/DP card changes profile/port availability. Deliberately + # ignore ordinary sink "change" events (volume/mute) and server-default + # changes, so a manual speaker pick while docked remains sticky. LC_ALL=C $PACTL subscribe | while read -r _ ev _ kind id; do - [ "$ev" = "'new'" ] && [ "$kind" = "sink" ] || continue - id=''${id#\#} - case "$id" in *[!0-9]*|"") continue ;; esac - sleep 0.5 # let the node's description/routes settle - line=$($PACTL --format=json list sinks | $JQ -r --argjson i "$id" \ - '.[] | select(.index == $i) | "\(.name)\t\(.description)"') - [ -n "$line" ] || continue - name=''${line%%"$TAB"*}; desc=''${line#*"$TAB"} - if is_dock "$name"; then switch_to "$name" "$desc"; fi + case "$ev:$kind" in + "'new':sink"|"'new':card"|"'change':card") ;; + *) continue ;; + esac + sleep 0.75 # let PipeWire finish the profile/route transition + adopt_first_dock done ''; in diff --git a/modules/home/hyprland.nix b/modules/home/hyprland.nix index fa0d15c..7ec45b9 100644 --- a/modules/home/hyprland.nix +++ b/modules/home/hyprland.nix @@ -215,13 +215,21 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList case "$v" in true|True) return 0 ;; *) return 1 ;; esac } outputs() { hyprctl monitors all -j 2>/dev/null | jq -r '.[].name' | sort; } + paint_outputs() { + # awww does not inherit the current image when Hyprland adds an output. + # Let the new output settle, then re-apply the current wallpaper to all + # outputs. Backgrounded so profile matching never waits on animation. + sleep 0.5 + ${sync} --quiet wallpaper >/dev/null 2>&1 || true + } # Blackout rescue — the session must never end up with ZERO active # outputs. Hyprland does NOT re-enable a soft-disabled monitor when # the last active one disconnects (VM-verified, tools/ - # monitor-fallback.nix): menu "Laptop screen off" + sudden undock - # left a dead session. On every monitorremoved, if nothing is - # active, re-enable whatever is disabled (preferred/auto — the + # monitor-fallback.nix): the former panel-off menu action (and still-valid + # named profiles that disable eDP) + sudden undock left a dead session. + # On every monitorremoved, if nothing is active, re-enable whatever is + # disabled (preferred/auto — the # declared rule returns at the next reload/relogin). Independent of # the profile auto-switch and its settings gate: this is a safety # invariant, not a layout preference. @@ -237,17 +245,23 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList # ~0.5s of the last output going while it persisted, so shrinking # the window is the priority; the keyword is idempotent and the # retries cover a first attempt racing the removal teardown. - tries=0 - while [ "$(active_count)" = 0 ] && [ "$tries" -lt 4 ]; do - hyprctl monitors all -j 2>/dev/null | jq -r '.[] | select(.disabled) | .name' \ - | while read -r m; do - [ -n "$m" ] || continue - hyprctl keyword monitor "$m,preferred,auto,auto" >/dev/null 2>&1 - done + tries=0 rescued= + # monitorremoved can arrive just before the departing output disappears + # from `hyprctl monitors`; keep a short bounded watch instead of deciding + # from that racy first snapshot. The first check is still immediate. + while [ "$tries" -lt 8 ]; do + if [ "$(active_count)" = 0 ]; then + rescued=1 + hyprctl monitors all -j 2>/dev/null | jq -r '.[] | select(.disabled) | .name' \ + | while read -r m; do + [ -n "$m" ] || continue + hyprctl keyword monitor "$m,preferred,auto,auto" >/dev/null 2>&1 + done + fi tries=$((tries+1)) - sleep 1 + sleep 0.25 done - if [ "$tries" -gt 0 ] && [ "$(active_count)" != 0 ]; then + if [ -n "$rescued" ] && [ "$(active_count)" != 0 ]; then notify-send "Display" "Screen re-enabled — the active output disappeared." 2>/dev/null || true fi } @@ -257,6 +271,7 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList [ "$cur" = "$1" ] && return [ -n "$cur" ] || return auto_on || return + command -v nomarchy-display-profile >/dev/null 2>&1 || return target=$(nomarchy-display-profile match $cur) || target="base" @@ -271,20 +286,27 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList } prev=$(outputs) - - # Listen to Hyprland's IPC socket for instant hotplug reaction - ${pkgs.socat}/bin/socat -U - UNIX-CONNECT:$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.socket2.sock | while read -r line; do - case "$line" in - monitorremoved*) - rescue_blackout - res=$(check_monitors "$prev") - [ -n "$res" ] && prev="$res" - ;; - monitoradded*) - res=$(check_monitors "$prev") - [ -n "$res" ] && prev="$res" - ;; - esac + + # Listen to Hyprland's IPC socket for instant hotplug reaction. Reconnect + # after a compositor reload/socket hiccup instead of silently losing the + # safety listener for the rest of the session. + socket="$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.socket2.sock" + while :; do + ${pkgs.socat}/bin/socat -U - UNIX-CONNECT:"$socket" | while read -r line; do + case "$line" in + monitorremoved*) + rescue_blackout + res=$(check_monitors "$prev") + [ -n "$res" ] && prev="$res" + ;; + monitoradded*) + paint_outputs & + res=$(check_monitors "$prev") + [ -n "$res" ] && prev="$res" + ;; + esac + done + sleep 1 done ''; @@ -299,7 +321,67 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList (lib.splitString "," config.nomarchy.keyboard.layout) ++ config.nomarchy.keyboard.layouts ); - kbAutoSwitch = config.nomarchy.keyboard.layouts != [ ]; + + # Per-device keyboard helper shared by the hotplug watcher and the manual + # System › Keyboard menu. The configured candidates are listed first, then + # every XKB layout known to the system, so the feature works on a default + # install instead of silently disappearing until home.nix is edited. + keyboardTool = pkgs.writeShellScriptBin "nomarchy-keyboard-layout" '' + set -u + sync=${sync} + + layouts() { + printf '%s\n' ${lib.concatMapStringsSep " " lib.escapeShellArg pickerLayouts} + ${pkgs.gawk}/bin/awk ' + /^! layout/ { in_layouts=1; next } + /^!/ && in_layouts { exit } + in_layouts && NF { print $1 } + ' ${pkgs.xkeyboard_config}/share/X11/xkb/rules/base.lst + } + saved_map() { "$sync" get settings.keyboard.devices 2>/dev/null || echo '{}'; } + saved_for() { saved_map | jq -r --arg k "$1" '.[$k] // empty'; } + apply_runtime() { hyprctl keyword "device[$1]:kb_layout" "$2" >/dev/null 2>&1; } + + case "''${1:-}" in + layouts) + layouts | ${pkgs.gawk}/bin/awk 'NF && !seen[$0]++' ;; + devices) + hyprctl devices -j 2>/dev/null \ + | jq -r '.keyboards[] | "\(.name)\t\(.active_keymap // \"unknown\")"' ;; + saved) + [ -n "''${2:-}" ] || exit 64 + saved_for "$2" ;; + restore) + [ -n "''${2:-}" ] || exit 64 + layout=$(saved_for "$2") + [ -n "$layout" ] && apply_runtime "$2" "$layout" ;; + apply) + [ -n "''${2:-}" ] && [ -n "''${3:-}" ] || exit 64 + layouts | ${pkgs.gnugrep}/bin/grep -Fxq "$3" \ + || { notify-send "Keyboard" "Unknown XKB layout: $3"; exit 64; } + if apply_runtime "$2" "$3"; then + map=$(saved_map | jq -c --arg k "$2" --arg v "$3" '. + {($k): $v}') || exit 1 + if "$sync" --quiet set settings.keyboard.devices "$map" --no-switch; then + notify-send "Keyboard" "$2 → $3" + else + notify-send "Keyboard" "$2 → $3 for this session, but the choice could not be saved." + exit 1 + fi + else + notify-send "Keyboard" "Could not apply $3 to $2." + exit 1 + fi ;; + forget) + [ -n "''${2:-}" ] || exit 64 + map=$(saved_map | jq -c --arg k "$2" 'del(.[$k])') || exit 1 + "$sync" --quiet set settings.keyboard.devices "$map" --no-switch || exit 1 + apply_runtime "$2" ${lib.escapeShellArg config.nomarchy.keyboard.layout} || true + notify-send "Keyboard" "$2 now follows the session layout." ;; + *) + echo "usage: nomarchy-keyboard-layout [layouts|devices|saved |restore |apply |forget ]" >&2 + exit 64 ;; + esac + ''; # On a keyboard that connects after login (not declared in # keyboard.devices, not already remembered), ask for a layout and persist it @@ -320,34 +402,18 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList # 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)}" - sync=${sync} - - apply() { hyprctl keyword "device[$1]:kb_layout" "$2" >/dev/null 2>&1; } + tool=${keyboardTool}/bin/nomarchy-keyboard-layout keyboards() { hyprctl devices -j | jq -r '.keyboards[].name'; } is_declared() { case " $declared " in *" $1 "*) return 0 ;; *) return 1 ;; esac; } - # 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. prev=" " for kb in $(keyboards); do prev="$prev$kb " is_declared "$kb" && continue - s=$(saved_for "$kb"); [ -n "$s" ] && apply "$kb" "$s" + "$tool" restore "$kb" || true done # Watch for keyboards that connect later. @@ -358,12 +424,13 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList cur="$cur$kb " case "$prev" in *" $kb "*) continue ;; esac is_declared "$kb" && continue - s=$(saved_for "$kb") + s=$("$tool" saved "$kb") if [ -n "$s" ]; then - apply "$kb" "$s" + "$tool" restore "$kb" || true else - choice=$(printf '%s\n' $layouts | rofi -dmenu -p "Layout · $kb") - [ -n "$choice" ] && { remember "$kb" "$choice"; apply "$kb" "$choice"; } + choice=$("$tool" layouts \ + | rofi -monitor -1 -dmenu -i -p "Keyboard layout · $kb (e.g. us, gb, pt, br)") + [ -n "$choice" ] && "$tool" apply "$kb" "$choice" fi done prev="$cur" @@ -420,8 +487,10 @@ in # the clean pkill a theme switch now does — respawns the bar instead # of orphaning the session bar-less. ] ++ lib.optional config.nomarchy.waybar.enable "nomarchy-waybar" - ++ lib.optional kbAutoSwitch "${keyboardWatch}/bin/nomarchy-keyboard-watch" - ++ lib.optional (profiles != { }) "${displayProfileWatch}/bin/nomarchy-display-profile-watch"; + ++ [ + "${keyboardWatch}/bin/nomarchy-keyboard-watch" + "${displayProfileWatch}/bin/nomarchy-display-profile-watch" + ]; # ── Theme-driven look ────────────────────────────────────────── # These flow from theme-state.json and stay at NORMAL priority: @@ -636,11 +705,10 @@ in home.packages = lib.optionals (config.nomarchy.hyprland.enable && config.nomarchy.displays.enable) [ pkgs.nwg-displays ] - # The new-keyboard watcher on PATH (when enabled) so it's discoverable and - # runnable by name for debugging — Hyprland still exec-once's it by store path. - ++ lib.optional (config.nomarchy.hyprland.enable && kbAutoSwitch) keyboardWatch - # The display-profile switcher (the menu's Profiles row gates on it) - # + its hotplug watcher, on PATH for debugging like the keyboard one. - ++ lib.optionals (config.nomarchy.hyprland.enable && profiles != { }) - [ displayProfileTool displayProfileWatch ]; + # Runtime hardware watchers/helpers stay on PATH for manual recovery and + # debugging. The display-profile switcher itself remains gated so the + # menu does not advertise an empty Profiles submenu. + ++ lib.optionals config.nomarchy.hyprland.enable + ([ keyboardTool keyboardWatch displayProfileWatch ] + ++ lib.optional (profiles != { }) displayProfileTool); } diff --git a/modules/home/keybinds.nix b/modules/home/keybinds.nix index ef1fadf..da26fe5 100644 --- a/modules/home/keybinds.nix +++ b/modules/home/keybinds.nix @@ -49,6 +49,7 @@ { mods = "$mod CTRL"; key = "E"; action = "exec, nomarchy-menu emoji"; desc = "Emoji picker"; } { mods = "$mod CTRL"; key = "N"; action = "exec, nomarchy-menu network"; desc = "Network (networkmanager_dmenu)"; } { mods = "$mod CTRL"; key = "B"; action = "exec, nomarchy-menu bluetooth"; desc = "Bluetooth"; } + { mods = "$mod CTRL"; key = "K"; action = "exec, nomarchy-menu keyboard"; desc = "Keyboard layout"; } { mods = "$mod CTRL"; key = "S"; action = "exec, nomarchy-menu capture"; desc = "Screenshot / capture"; } { mods = "$mod CTRL"; key = "P"; action = "exec, nomarchy-menu colorpicker"; desc = "Color picker (→ clipboard)"; } { mods = "$mod CTRL"; key = "A"; action = "exec, nomarchy-menu ask"; desc = "Ask Claude"; } diff --git a/modules/home/options.nix b/modules/home/options.nix index 9452fac..30c2c2d 100644 --- a/modules/home/options.nix +++ b/modules/home/options.nix @@ -128,13 +128,13 @@ in default = [ ]; example = [ "de" "fr" ]; description = '' - Extra candidate layouts offered by the interactive new-keyboard - picker. When non-empty, a small watcher runs in the session: when a - keyboard connects that isn't covered by nomarchy.keyboard.devices and - 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 in the git-tracked in-flake state + Extra candidate layouts shown first by the interactive new-keyboard + picker (all installed XKB layouts remain searchable). A small watcher + always runs in the Hyprland session: when a keyboard connects that + isn't covered by nomarchy.keyboard.devices and hasn't been chosen + before, it pops a rofi picker, applies the choice to that keyboard only + (a per-device kb_layout, so the built-in board is left 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 diff --git a/modules/home/rofi.nix b/modules/home/rofi.nix index 898b161..71b4af3 100644 --- a/modules/home/rofi.nix +++ b/modules/home/rofi.nix @@ -236,6 +236,7 @@ let # one arrow everywhere. Always matched EXACTLY ("↩ Back") so it can't # collide with clipboard or filename content. BACK="↩ Back" + TAB=$(printf '\t') back() { printf '%s\n' "$BACK"; } # dmenu mode is case-sensitive by default (dmenu compatibility), even @@ -515,7 +516,12 @@ ${themeRows} sinks=$(${pkgs.pulseaudio}/bin/pactl --format=json list sinks 2>/dev/null || true) cursink=$(${pkgs.pulseaudio}/bin/pactl get-default-sink 2>/dev/null || true) dockname=$(printf '%s' "$sinks" | jq -r --arg cur "$cursink" --arg re '${dockSinkRe}' \ - '[.[] | select((.name | test("^(" + $re + ")$")) and .name != $cur)][0].name // empty') + '[.[] + | select((.name | test("^(" + $re + ")$")) and .name != $cur) + | select( + ((.ports // []) | length) == 0 + or any(.ports[]?; (.availability // "unknown") != "not available") + )][0].name // empty') dockdesc= [ -n "$dockname" ] && dockdesc=$(printf '%s' "$sinks" \ | jq -r --arg n "$dockname" '[.[] | select(.name == $n)][0].description // empty') @@ -534,6 +540,33 @@ ${themeRows} *Input*) exec rofi-pulse-select source ;; esac ;; + keyboard) + command -v nomarchy-keyboard-layout >/dev/null 2>&1 \ + || { notify-send "Keyboard" "Per-device layout helper is unavailable."; exit 0; } + devices=$(nomarchy-keyboard-layout devices) + [ -n "$devices" ] \ + || { notify-send "Keyboard" "No keyboards reported by Hyprland."; exit 0; } + picked=$( { + printf '%s\n' "$devices" | while IFS="$TAB" read -r dev active; do + printf '%s · %s\n' "$dev" "$active" + done + back + } | rofi_menu -p "Keyboard") || exit 0 + [ "$picked" = "$BACK" ] && exec "$0" system + device=''${picked%% ·*} + saved=$(nomarchy-keyboard-layout saved "$device") + layout=$( { + [ -n "$saved" ] && printf 'Use session default · forget %s\n' "$saved" + nomarchy-keyboard-layout layouts + back + } | rofi_menu -p "Layout · $device (now: ''${saved:-session default})") || exit 0 + case "$layout" in + "$BACK") exec "$0" keyboard ;; + "Use session default"*) exec nomarchy-keyboard-layout forget "$device" ;; + "") exit 0 ;; + *) exec nomarchy-keyboard-layout apply "$device" "$layout" ;; + esac ;; + display) # Per-output resolution picker. Applies the chosen mode INSTANTLY via # `hyprctl keyword monitor` — keeping the output's current position and @@ -566,11 +599,12 @@ ${themeRows} if [ -n "$profs" ] || [ "$nActive" -gt 1 ] || [ -n "$offmon" ]; then name=$( { [ -n "$profs" ] && printf 'Profiles ›\n' - # Docked rows — self-gated: never offer to disable the only - # active display; the re-enable row appears whenever an - # output is soft-off (so an undocked laptop can recover too). + # Dock mode deliberately keeps the internal panel logically + # active as the unplug fallback. Soft-disabling it can leave + # Hyprland with zero outputs and kill the whole session before a + # user-space rescue runs. Move its workspaces and focus instead. [ "$nActive" -gt 1 ] && [ -n "$internal" ] && [ -n "$external" ] \ - && printf 'Laptop screen off · everything → %s\n' "$external" + && printf 'Dock mode · everything → %s (safe unplug)\n' "$external" [ -n "$offmon" ] && printf 'Screen on · %s\n' "$offmon" [ "$nActive" -gt 1 ] && printf 'Move workspace · → next monitor\n' [ "$nActive" -eq 2 ] && printf 'Swap workspaces · %s ⇄ %s\n' \ @@ -582,16 +616,16 @@ ${themeRows} [ "$name" = "$BACK" ] && exec "$0" system [ "$name" = "Profiles ›" ] && exec "$0" display-profile case "$name" in - "Laptop screen off"*) - # Live-only on purpose: Hyprland moves the panel's - # workspaces to the remaining output on disable; persisting - # a disable could black-screen an undocked boot, so the - # declared rule returns at the next relogin/switch. - if hyprctl keyword monitor "$internal,disable" >/dev/null 2>&1; then - notify-send "Display" "Laptop screen off — workspaces moved to $external (this session; turn it back on from this menu)." - else - notify-send "Display" "Could not disable $internal." - fi + "Dock mode"*) + moved=0 + while read -r ws; do + [ -n "$ws" ] || continue + hyprctl dispatch moveworkspacetomonitor "$ws" "$external" >/dev/null 2>&1 \ + && moved=$((moved+1)) + done < <(hyprctl workspaces -j | jq -r --arg m "$internal" \ + '.[] | select(.monitor == $m and .id > 0) | .id') + hyprctl dispatch focusmonitor "$external" >/dev/null 2>&1 + notify-send "Display" "Dock mode — $moved workspace(s) moved to $external; laptop panel kept as the unplug fallback." exit 0 ;; "Screen on"*) # preferred/auto/auto is good enough live; the declared @@ -1106,6 +1140,8 @@ ${themeRows} [ -e "''${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/pulse/native" ] \ && row "Audio" audio-volume-high row "Display" video-display + command -v nomarchy-keyboard-layout >/dev/null 2>&1 \ + && row "Keyboard${menuHint "keyboard"}" preferences-desktop-keyboard command -v system-config-printer >/dev/null 2>&1 && row "Printers" printer row "Do Not Disturb${menuHint "dnd"}" notification-disabled if [ "$(nomarchy-theme-sync get settings.autoTimezone 2>/dev/null)" = true ] @@ -1145,6 +1181,7 @@ ${themeRows} *Bluetooth*) exec "$0" bluetooth ;; *Audio*) exec "$0" audio ;; *Display*) exec "$0" display ;; + *Keyboard*) exec "$0" keyboard ;; *Printers*) exec "$0" printers ;; *"Do Not Disturb"*) exec "$0" dnd ;; *"Auto timezone"*) exec "$0" autotimezone ;; @@ -1262,7 +1299,7 @@ ${themeRows} esac ;; *) - echo "usage: nomarchy-menu [lookfeel|tools|system|power|power-profile|powermgmt|batterylimit|theme|clipboard|calc|files|emoji|web|network|bluetooth|audio|display|display-profile|printers|capture|colorpicker|keybinds|ask|dnd|nightlight|autotimezone|autocommit|vpn|snapshot|doctor|firmware|fingerprint|controlcenter|rollback|whatchanged]" >&2 + echo "usage: nomarchy-menu [lookfeel|tools|system|power|power-profile|powermgmt|batterylimit|theme|clipboard|calc|files|emoji|web|network|bluetooth|audio|display|display-profile|keyboard|printers|capture|colorpicker|keybinds|ask|dnd|nightlight|autotimezone|autocommit|vpn|snapshot|doctor|firmware|fingerprint|controlcenter|rollback|whatchanged]" >&2 exit 64 ;; esac ''; @@ -1477,6 +1514,10 @@ in extraConfig = { modi = "drun,run"; + # Rofi defaults to the output containing the mouse pointer (-5). + # After docking with the lid shut that pointer can remain on the hidden + # laptop workspace while keyboard focus is on the external display. + monitor = -1; # Hyprland's currently focused output show-icons = true; # app icons via the theme's icon set icon-theme = t.iconTheme; # resolved in theme.nix (Papirus-*/per-theme) drun-display-format = "{name}"; diff --git a/modules/home/theme.nix b/modules/home/theme.nix index e163e17..34be971 100644 --- a/modules/home/theme.nix +++ b/modules/home/theme.nix @@ -11,8 +11,9 @@ # history is generation history. # # The one runtime exception is the wallpaper (swww is imperative by -# nature): applied at session start, after every switch (hook below), -# and cycled instantly with `nomarchy-theme-sync bg next`. +# nature): applied at session start, after every switch (hook below), and +# after output hotplug (hyprland.nix); cycled instantly with +# `nomarchy-theme-sync bg next`. { config, lib, pkgs, ... }: let diff --git a/pkgs/nomarchy-theme-sync/nomarchy-theme-sync.py b/pkgs/nomarchy-theme-sync/nomarchy-theme-sync.py index d700095..affae8e 100644 --- a/pkgs/nomarchy-theme-sync/nomarchy-theme-sync.py +++ b/pkgs/nomarchy-theme-sync/nomarchy-theme-sync.py @@ -598,7 +598,7 @@ def cmd_get(args) -> None: def cmd_wallpaper(_args) -> None: - """Apply the current wallpaper (session start, post-switch hook).""" + """Apply the current wallpaper (session start, switch, output hotplug).""" apply_wallpaper(load_state(), wait=True) diff --git a/templates/downstream/home.nix b/templates/downstream/home.nix index dee766e..3951655 100644 --- a/templates/downstream/home.nix +++ b/templates/downstream/home.nix @@ -73,9 +73,9 @@ # "keychron-keychron-k2" = { layout = "de"; }; # }; # - # 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) + # nomarchy.keyboard.layouts = [ "de" "fr" ]; # optional: put these first in the + # # new-keyboard picker (all installed XKB + # # layouts are searchable without this) # ── Application suite ─────────────────────────────────────────────── # A starter complete-workstation set, installed for your user — yours to diff --git a/tools/monitor-fallback.nix b/tools/monitor-fallback.nix index d799a8b..5f2a1bb 100644 --- a/tools/monitor-fallback.nix +++ b/tools/monitor-fallback.nix @@ -1,8 +1,10 @@ # Undock blackout-rescue harness — boots the full desktop headlessly # (softGL Hyprland, same recipe as theme-shot.nix) and replays the -# dangerous sequence: add a headless "external", soft-disable the -# internal panel (the Display menu's "Laptop screen off" command), then -# remove the external. Hyprland does NOT re-enable a disabled monitor on +# dangerous profile edge: add a headless "external", soft-disable the +# internal panel (a named profile can still do this), then remove the +# external. The interactive Display menu no longer disables the panel: its +# safe Dock mode moves workspaces/focus but keeps eDP as the unplug fallback. +# Hyprland does NOT re-enable a profile-disabled monitor on # its own (verified 2026-07-12 — the session ends with zero active # outputs), so the display-profile watcher's rescue_blackout # (modules/home/hyprland.nix) must bring the panel back; this asserts it @@ -63,6 +65,9 @@ pkgs.testers.runNixOSTest { machine.wait_for_unit("multi-user.target") machine.wait_for_file("/run/user/1000/hypr", 180) machine.sleep(20) # softGL first paint settle + machine.wait_until_succeeds( + "pgrep -af nomarchy-display-profile-watch >/dev/null", timeout=30 + ) hy = ("su - nomarchy -c 'XDG_RUNTIME_DIR=/run/user/1000 " "HYPRLAND_INSTANCE_SIGNATURE=$(ls /run/user/1000/hypr | head -1) hyprctl ") @@ -85,7 +90,7 @@ pkgs.testers.runNixOSTest { ext = [n for n in names if n != internal][0] print(f"external (headless): {ext}") - # Menu action: "Laptop screen off" — the exact command the row runs. + # Named display-profile edge: eDP is disabled before an abrupt undock. machine.succeed(hy + f"keyword monitor {internal},disable'") for _ in range(20): if [x["name"] for x in mons()] == [ext]: break