fix(docking): make clamshell hotplug safe and discoverable
All checks were successful
Check / eval (push) Successful in 3m32s

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.
This commit is contained in:
2026-07-13 11:53:01 +01:00
parent 16846786e2
commit 7ebfab4bd4
15 changed files with 357 additions and 171 deletions

View File

@@ -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.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.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.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.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.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 | | `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.rofi.enable` | `true` | Themed rofi launcher + `nomarchy-menu` dispatcher |
| `nomarchy.swaync.enable` | `true` | swaync notifications, themed | | `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.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.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.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 | | `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. re-reads `style.css` when the symlink flips.
The **wallpaper** is the one runtime piece (awww — nixpkgs' swww — is The **wallpaper** is the one runtime piece (awww — nixpkgs' swww — is
imperative; nothing in Nix consumes the path): applied at session start and imperative; nothing in Nix consumes the path): applied at session start,
after every switch via a tiny activation hook, cycled instantly with after every switch via a tiny activation hook, and again when a monitor is
`bg next`. hotplugged; cycled instantly with `bg next`.
### Config the menu writes (not just themes) ### Config the menu writes (not just themes)

View File

@@ -11,54 +11,27 @@ the **T14s** (webcam case).
## Any machine (dev box is fine) ## Any machine (dev box is fine)
- [ ] **#86 clamshell lid + external display** — V2 asserts - [ ] **Docking correction round 3 (2026-07-13 hardware-QA follow-up)**
`HandleLidSwitchDocked=ignore` in logind.conf. On a laptop with a prior result: lid-close stayed awake, but SUPER+M rendered on the
real external monitor: open a session, plug external, close lid — hidden laptop workspace; the keyboard never prompted; audio stayed
machine must **stay awake** (no suspend); external panel remains on speakers; the old laptop-off action lost some applications and
usable (or wakes with keyboard if DPMS). Unplug external, close lid abrupt undock left eDP black. After updating to the commit carrying
— should suspend as usual. Optional: with a docked display-profile this entry and rebuilding Home Manager, start with the laptop open
(`eDP-1` disable), confirm clamshell layout still looks right after and play audio. Close the lid and plug the monitor/keyboard: SUPER+M
lid close. Pass = no surprise suspend when docked. must render on the external focused workspace; the new keyboard must
- [ ] **#87 round 2: dock audio auto-follow (watcher)** — round 1 result ask for a searchable XKB layout (or set/change it at System
(Newton, 2026-07-12): audio stayed on speakers when docking; the Keyboard); audio must toast and move to the monitor. System Audio
running generation had no `90-nomarchy-dock-audio` rules yet, and Output device remains the manual fallback; the current wallpaper must
live testing showed the priority rules alone can never win anyway — cover the external output/workspaces within ~2s. Open Display and choose
WirePlumber's stored default (`default-nodes` state, written by any **Dock mode**: all eDP workspaces/windows move intact to the external
past explicit pick) outranks `priority.session`, and this machine and focus follows; no terminal/application exits. Yank the cable: the
(like most) has one. Fix shipped: the `nomarchy-dock-audio` user internal panel is already the logical fallback, lights immediately,
service `pactl set-default-sink`s dock-class sinks on hotplug; and the moved workspaces/windows survive. Replug then manually choose
EasyEffects follows the default (verified live: EE's output links speakers: volume/mute changes must not force the monitor again; only a
moved to a fake dock-named null sink mid-playback). Retest after fresh replug should. If audio still misses, capture `pactl subscribe`
pull+rebuild+home on ≥ this rev: play audio, plug the dock → during plug plus `pactl --format=json list sinks` while docked. If the
toast "Audio → <monitor/dock>" and sound moves with EE effects menu is still misplaced, capture `hyprctl monitors -j` immediately
still active; unplug → sound returns to speakers; while docked, before SUPER+M (the `.focused` field is the discriminator).
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 → <ext>" (only with >1 active output), "Screen on ·
<name>" 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 → <dock sink>" 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.
- [ ] **Parallel fingerprint-or-password on the real reader** (AMD dev - [ ] **Parallel fingerprint-or-password on the real reader** (AMD dev
box, 2026-07-12) — with a finger enrolled and box, 2026-07-12) — with a finger enrolled and
`fingerprint.pam = true` (parallel is the default): `sudo -k true` `fingerprint.pam = true` (parallel is the default): `sudo -k true`

View File

@@ -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) ## 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. - **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. - **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) - **Pending:** slices 2 (systemd sunrise/sunset timers + login apply, V2)
and 3 (Look & Feel menu) remain under #79. and 3 (Look & Feel menu) remain under #79.
- **Next suggestion:** #79 slice 2 — the timer/hook (V2 runNixOSTest). - **Next suggestion:** #79 slice 2 — the timer/hook (V2 runNixOSTest).

View File

@@ -162,6 +162,10 @@ iteration would otherwise rediscover.
- Hyprland 0.55 renames `stayfocused``stay_focused` (and similar - Hyprland 0.55 renames `stayfocused``stay_focused` (and similar
underscore effects); `stayfocused 1` is `invalid field type` at parse underscore effects); `stayfocused 1` is `invalid field type` at parse
(§ polkit workspace rules, 2026-07-10 hardware). (§ 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 - Hyprland 0.53 rewrote window rules: `windowrulev2` is a hard error and
the old rule-first `float, class:^…$` no longer parses — both surface a the old rule-first `float, class:^…$` no longer parses — both surface a
red config-error banner on the default desktop. Hyprlang legacy form is red config-error banner on the default desktop. Hyprlang legacy form is

View File

@@ -416,14 +416,9 @@ Design/decision records and a running log of shipped work (items marked
`useDefaultOutputDevice` defaults true and verified live — with a `useDefaultOutputDevice` defaults true and verified live — with a
stream playing, EE's output links moved to a fake dock-named null sink 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. and back on unload. Unplug = WirePlumber priority fallback to built-in.
Docked menu rows shipped alongside: Display gains "Laptop screen off · Docked menu rows shipped alongside (the original panel-disable action is
everything → <ext>" (live-only `keyword monitor <eDP>,disable` — a superseded by the hardware-QA correction below); Audio has a self-gated
persisted disable could black-screen an undocked boot), "Screen on · "Send output → <dock sink>" quick row plus the full output picker.
<name>", "Move workspace · → next monitor"
(`movecurrentworkspacetomonitor +1`), "Swap workspaces"
(`swapactiveworkspaces`, exactly-2 gate); Audio gains a self-gated
"Send output → <dock sink>" quick row (the watcher's manual override).
Real-dock V3 for both in HARDWARE-QUEUE.
✓ **Undock blackout rescue (2026-07-12):** Bernardo asked whether the ✓ **Undock blackout rescue (2026-07-12):** Bernardo asked whether the
panel comes back if you undock while "Laptop screen off" — VM answer panel comes back if you undock while "Laptop screen off" — VM answer
(tools/monitor-fallback.nix, full softGL desktop): **no**, Hyprland (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- survives 5/5, so it's the state, not headless removal; monitor-
disconnect crashes are a known upstream class). Posture: rescue ships disconnect crashes are a known upstream class). Posture: rescue ships
as defense-in-depth (recovers every surviving case; worst case on real 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); 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 Also fixed in the rescue: `grep -c .` exits 1 at count 0 — an `||`-guard on that
the rescue: `grep -c .` exits 1 at count 0 — an `||`-guard on that
pipeline silently disabled the rescue exactly when it mattered. 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 ✓ **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;

View File

@@ -779,6 +779,50 @@
in in
assert ok; pkgs.runCommand "nomarchy-display-profiles" { } "touch $out"; 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 # Night-light geo mode: with BOTH lat/long set the backing unit
# flips hyprsunset → wlsunset (location-computed sunrise/sunset) # flips hyprsunset → wlsunset (location-computed sunrise/sunset)
# and the live-state ExecCondition gate survives the swap (the # and the live-state ExecCondition gate survives the swap (the

View File

@@ -46,28 +46,47 @@ let
"Output ''${2:-$1}" 2>/dev/null || true "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 # Startup sweep (login/relogin while already docked): adopt a dock
# sink only when the current default is NOT one a service restart # sink only when the current default is NOT one a service restart
# must not override a manual in-dock pick. # must not override a manual in-dock pick.
if ! is_dock "$($PACTL get-default-sink)"; then if ! is_dock "$($PACTL get-default-sink)"; then
$PACTL --format=json list sinks \ adopt_first_dock
| $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
fi 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 LC_ALL=C $PACTL subscribe | while read -r _ ev _ kind id; do
[ "$ev" = "'new'" ] && [ "$kind" = "sink" ] || continue case "$ev:$kind" in
id=''${id#\#} "'new':sink"|"'new':card"|"'change':card") ;;
case "$id" in *[!0-9]*|"") continue ;; esac *) continue ;;
sleep 0.5 # let the node's description/routes settle esac
line=$($PACTL --format=json list sinks | $JQ -r --argjson i "$id" \ sleep 0.75 # let PipeWire finish the profile/route transition
'.[] | select(.index == $i) | "\(.name)\t\(.description)"') adopt_first_dock
[ -n "$line" ] || continue
name=''${line%%"$TAB"*}; desc=''${line#*"$TAB"}
if is_dock "$name"; then switch_to "$name" "$desc"; fi
done done
''; '';
in in

View File

@@ -215,13 +215,21 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList
case "$v" in true|True) return 0 ;; *) return 1 ;; esac case "$v" in true|True) return 0 ;; *) return 1 ;; esac
} }
outputs() { hyprctl monitors all -j 2>/dev/null | jq -r '.[].name' | sort; } 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 # Blackout rescue the session must never end up with ZERO active
# outputs. Hyprland does NOT re-enable a soft-disabled monitor when # outputs. Hyprland does NOT re-enable a soft-disabled monitor when
# the last active one disconnects (VM-verified, tools/ # the last active one disconnects (VM-verified, tools/
# monitor-fallback.nix): menu "Laptop screen off" + sudden undock # monitor-fallback.nix): the former panel-off menu action (and still-valid
# left a dead session. On every monitorremoved, if nothing is # named profiles that disable eDP) + sudden undock left a dead session.
# active, re-enable whatever is disabled (preferred/auto the # 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 # declared rule returns at the next reload/relogin). Independent of
# the profile auto-switch and its settings gate: this is a safety # the profile auto-switch and its settings gate: this is a safety
# invariant, not a layout preference. # 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 # ~0.5s of the last output going while it persisted, so shrinking
# the window is the priority; the keyword is idempotent and the # the window is the priority; the keyword is idempotent and the
# retries cover a first attempt racing the removal teardown. # retries cover a first attempt racing the removal teardown.
tries=0 tries=0 rescued=
while [ "$(active_count)" = 0 ] && [ "$tries" -lt 4 ]; do # monitorremoved can arrive just before the departing output disappears
hyprctl monitors all -j 2>/dev/null | jq -r '.[] | select(.disabled) | .name' \ # from `hyprctl monitors`; keep a short bounded watch instead of deciding
| while read -r m; do # from that racy first snapshot. The first check is still immediate.
[ -n "$m" ] || continue while [ "$tries" -lt 8 ]; do
hyprctl keyword monitor "$m,preferred,auto,auto" >/dev/null 2>&1 if [ "$(active_count)" = 0 ]; then
done 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)) tries=$((tries+1))
sleep 1 sleep 0.25
done 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 notify-send "Display" "Screen re-enabled the active output disappeared." 2>/dev/null || true
fi fi
} }
@@ -257,6 +271,7 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList
[ "$cur" = "$1" ] && return [ "$cur" = "$1" ] && return
[ -n "$cur" ] || return [ -n "$cur" ] || return
auto_on || return auto_on || return
command -v nomarchy-display-profile >/dev/null 2>&1 || return
target=$(nomarchy-display-profile match $cur) || target="base" target=$(nomarchy-display-profile match $cur) || target="base"
@@ -271,20 +286,27 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList
} }
prev=$(outputs) prev=$(outputs)
# Listen to Hyprland's IPC socket for instant hotplug reaction # Listen to Hyprland's IPC socket for instant hotplug reaction. Reconnect
${pkgs.socat}/bin/socat -U - UNIX-CONNECT:$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.socket2.sock | while read -r line; do # after a compositor reload/socket hiccup instead of silently losing the
case "$line" in # safety listener for the rest of the session.
monitorremoved*) socket="$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.socket2.sock"
rescue_blackout while :; do
res=$(check_monitors "$prev") ${pkgs.socat}/bin/socat -U - UNIX-CONNECT:"$socket" | while read -r line; do
[ -n "$res" ] && prev="$res" case "$line" in
;; monitorremoved*)
monitoradded*) rescue_blackout
res=$(check_monitors "$prev") res=$(check_monitors "$prev")
[ -n "$res" ] && prev="$res" [ -n "$res" ] && prev="$res"
;; ;;
esac monitoradded*)
paint_outputs &
res=$(check_monitors "$prev")
[ -n "$res" ] && prev="$res"
;;
esac
done
sleep 1
done done
''; '';
@@ -299,7 +321,67 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList
(lib.splitString "," config.nomarchy.keyboard.layout) (lib.splitString "," config.nomarchy.keyboard.layout)
++ config.nomarchy.keyboard.layouts ++ 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 <device>|restore <device>|apply <device> <layout>|forget <device>]" >&2
exit 64 ;;
esac
'';
# On a keyboard that connects after login (not declared in # On a keyboard that connects after login (not declared in
# keyboard.devices, not already remembered), ask for a layout and persist it # 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). # 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}"
declared="${lib.concatStringsSep " " (builtins.attrNames config.nomarchy.keyboard.devices)}" declared="${lib.concatStringsSep " " (builtins.attrNames config.nomarchy.keyboard.devices)}"
sync=${sync} tool=${keyboardTool}/bin/nomarchy-keyboard-layout
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; }
# 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.
prev=" " prev=" "
for kb in $(keyboards); do for kb in $(keyboards); do
prev="$prev$kb " prev="$prev$kb "
is_declared "$kb" && continue is_declared "$kb" && continue
s=$(saved_for "$kb"); [ -n "$s" ] && apply "$kb" "$s" "$tool" restore "$kb" || true
done done
# Watch for keyboards that connect later. # Watch for keyboards that connect later.
@@ -358,12 +424,13 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList
cur="$cur$kb " cur="$cur$kb "
case "$prev" in *" $kb "*) continue ;; esac case "$prev" in *" $kb "*) continue ;; esac
is_declared "$kb" && continue is_declared "$kb" && continue
s=$(saved_for "$kb") s=$("$tool" saved "$kb")
if [ -n "$s" ]; then if [ -n "$s" ]; then
apply "$kb" "$s" "$tool" restore "$kb" || true
else else
choice=$(printf '%s\n' $layouts | rofi -dmenu -p "Layout · $kb") choice=$("$tool" layouts \
[ -n "$choice" ] && { remember "$kb" "$choice"; apply "$kb" "$choice"; } | rofi -monitor -1 -dmenu -i -p "Keyboard layout · $kb (e.g. us, gb, pt, br)")
[ -n "$choice" ] && "$tool" apply "$kb" "$choice"
fi fi
done done
prev="$cur" prev="$cur"
@@ -420,8 +487,10 @@ in
# the clean pkill a theme switch now does — respawns the bar instead # the clean pkill a theme switch now does — respawns the bar instead
# of orphaning the session bar-less. # of orphaning the session bar-less.
] ++ lib.optional config.nomarchy.waybar.enable "nomarchy-waybar" ] ++ 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 ────────────────────────────────────────── # ── Theme-driven look ──────────────────────────────────────────
# These flow from theme-state.json and stay at NORMAL priority: # These flow from theme-state.json and stay at NORMAL priority:
@@ -636,11 +705,10 @@ in
home.packages = home.packages =
lib.optionals (config.nomarchy.hyprland.enable && config.nomarchy.displays.enable) lib.optionals (config.nomarchy.hyprland.enable && config.nomarchy.displays.enable)
[ pkgs.nwg-displays ] [ pkgs.nwg-displays ]
# The new-keyboard watcher on PATH (when enabled) so it's discoverable and # Runtime hardware watchers/helpers stay on PATH for manual recovery and
# runnable by name for debugging — Hyprland still exec-once's it by store path. # debugging. The display-profile switcher itself remains gated so the
++ lib.optional (config.nomarchy.hyprland.enable && kbAutoSwitch) keyboardWatch # menu does not advertise an empty Profiles submenu.
# The display-profile switcher (the menu's Profiles row gates on it) ++ lib.optionals config.nomarchy.hyprland.enable
# + its hotplug watcher, on PATH for debugging like the keyboard one. ([ keyboardTool keyboardWatch displayProfileWatch ]
++ lib.optionals (config.nomarchy.hyprland.enable && profiles != { }) ++ lib.optional (profiles != { }) displayProfileTool);
[ displayProfileTool displayProfileWatch ];
} }

View File

@@ -49,6 +49,7 @@
{ mods = "$mod CTRL"; key = "E"; action = "exec, nomarchy-menu emoji"; desc = "Emoji picker"; } { 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 = "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 = "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 = "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 = "P"; action = "exec, nomarchy-menu colorpicker"; desc = "Color picker ( clipboard)"; }
{ mods = "$mod CTRL"; key = "A"; action = "exec, nomarchy-menu ask"; desc = "Ask Claude"; } { mods = "$mod CTRL"; key = "A"; action = "exec, nomarchy-menu ask"; desc = "Ask Claude"; }

View File

@@ -128,13 +128,13 @@ in
default = [ ]; default = [ ];
example = [ "de" "fr" ]; example = [ "de" "fr" ];
description = '' description = ''
Extra candidate layouts offered by the interactive new-keyboard Extra candidate layouts shown first by the interactive new-keyboard
picker. When non-empty, a small watcher runs in the session: when a picker (all installed XKB layouts remain searchable). A small watcher
keyboard connects that isn't covered by nomarchy.keyboard.devices and always runs in the Hyprland session: when a keyboard connects that
hasn't been chosen before, it pops a rofi picker (these layouts plus isn't covered by nomarchy.keyboard.devices and hasn't been chosen
the primary nomarchy.keyboard.layout), applies the choice to that before, it pops a rofi picker, applies the choice to that keyboard only
keyboard only (a per-device kb_layout, so the built-in board is left (a per-device kb_layout, so the built-in board is left alone), and
alone), and remembers it in the git-tracked in-flake state remembers it in the git-tracked in-flake state
(settings.keyboard.devices, not ~/.local/state) re-applied (settings.keyboard.devices, not ~/.local/state) re-applied
automatically on later reconnects and across reboots. Each remembered automatically on later reconnects and across reboots. Each remembered
choice graduates into nomarchy.keyboard.devices on the next rebuild choice graduates into nomarchy.keyboard.devices on the next rebuild

View File

@@ -236,6 +236,7 @@ let
# one arrow everywhere. Always matched EXACTLY (" Back") so it can't # one arrow everywhere. Always matched EXACTLY (" Back") so it can't
# collide with clipboard or filename content. # collide with clipboard or filename content.
BACK=" Back" BACK=" Back"
TAB=$(printf '\t')
back() { printf '%s\n' "$BACK"; } back() { printf '%s\n' "$BACK"; }
# dmenu mode is case-sensitive by default (dmenu compatibility), even # 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) 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) cursink=$(${pkgs.pulseaudio}/bin/pactl get-default-sink 2>/dev/null || true)
dockname=$(printf '%s' "$sinks" | jq -r --arg cur "$cursink" --arg re '${dockSinkRe}' \ 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= dockdesc=
[ -n "$dockname" ] && dockdesc=$(printf '%s' "$sinks" \ [ -n "$dockname" ] && dockdesc=$(printf '%s' "$sinks" \
| jq -r --arg n "$dockname" '[.[] | select(.name == $n)][0].description // empty') | jq -r --arg n "$dockname" '[.[] | select(.name == $n)][0].description // empty')
@@ -534,6 +540,33 @@ ${themeRows}
*Input*) exec rofi-pulse-select source ;; *Input*) exec rofi-pulse-select source ;;
esac ;; 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) display)
# Per-output resolution picker. Applies the chosen mode INSTANTLY via # Per-output resolution picker. Applies the chosen mode INSTANTLY via
# `hyprctl keyword monitor` keeping the output's current position and # `hyprctl keyword monitor` keeping the output's current position and
@@ -566,11 +599,12 @@ ${themeRows}
if [ -n "$profs" ] || [ "$nActive" -gt 1 ] || [ -n "$offmon" ]; then if [ -n "$profs" ] || [ "$nActive" -gt 1 ] || [ -n "$offmon" ]; then
name=$( { name=$( {
[ -n "$profs" ] && printf 'Profiles \n' [ -n "$profs" ] && printf 'Profiles \n'
# Docked rows self-gated: never offer to disable the only # Dock mode deliberately keeps the internal panel logically
# active display; the re-enable row appears whenever an # active as the unplug fallback. Soft-disabling it can leave
# output is soft-off (so an undocked laptop can recover too). # 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" ] \ [ "$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" [ -n "$offmon" ] && printf 'Screen on · %s\n' "$offmon"
[ "$nActive" -gt 1 ] && printf 'Move workspace · next monitor\n' [ "$nActive" -gt 1 ] && printf 'Move workspace · next monitor\n'
[ "$nActive" -eq 2 ] && printf 'Swap workspaces · %s %s\n' \ [ "$nActive" -eq 2 ] && printf 'Swap workspaces · %s %s\n' \
@@ -582,16 +616,16 @@ ${themeRows}
[ "$name" = "$BACK" ] && exec "$0" system [ "$name" = "$BACK" ] && exec "$0" system
[ "$name" = "Profiles " ] && exec "$0" display-profile [ "$name" = "Profiles " ] && exec "$0" display-profile
case "$name" in case "$name" in
"Laptop screen off"*) "Dock mode"*)
# Live-only on purpose: Hyprland moves the panel's moved=0
# workspaces to the remaining output on disable; persisting while read -r ws; do
# a disable could black-screen an undocked boot, so the [ -n "$ws" ] || continue
# declared rule returns at the next relogin/switch. hyprctl dispatch moveworkspacetomonitor "$ws" "$external" >/dev/null 2>&1 \
if hyprctl keyword monitor "$internal,disable" >/dev/null 2>&1; then && moved=$((moved+1))
notify-send "Display" "Laptop screen off workspaces moved to $external (this session; turn it back on from this menu)." done < <(hyprctl workspaces -j | jq -r --arg m "$internal" \
else '.[] | select(.monitor == $m and .id > 0) | .id')
notify-send "Display" "Could not disable $internal." hyprctl dispatch focusmonitor "$external" >/dev/null 2>&1
fi notify-send "Display" "Dock mode $moved workspace(s) moved to $external; laptop panel kept as the unplug fallback."
exit 0 ;; exit 0 ;;
"Screen on"*) "Screen on"*)
# preferred/auto/auto is good enough live; the declared # preferred/auto/auto is good enough live; the declared
@@ -1106,6 +1140,8 @@ ${themeRows}
[ -e "''${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/pulse/native" ] \ [ -e "''${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/pulse/native" ] \
&& row "Audio" audio-volume-high && row "Audio" audio-volume-high
row "Display" video-display 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 command -v system-config-printer >/dev/null 2>&1 && row "Printers" printer
row "Do Not Disturb${menuHint "dnd"}" notification-disabled row "Do Not Disturb${menuHint "dnd"}" notification-disabled
if [ "$(nomarchy-theme-sync get settings.autoTimezone 2>/dev/null)" = true ] if [ "$(nomarchy-theme-sync get settings.autoTimezone 2>/dev/null)" = true ]
@@ -1145,6 +1181,7 @@ ${themeRows}
*Bluetooth*) exec "$0" bluetooth ;; *Bluetooth*) exec "$0" bluetooth ;;
*Audio*) exec "$0" audio ;; *Audio*) exec "$0" audio ;;
*Display*) exec "$0" display ;; *Display*) exec "$0" display ;;
*Keyboard*) exec "$0" keyboard ;;
*Printers*) exec "$0" printers ;; *Printers*) exec "$0" printers ;;
*"Do Not Disturb"*) exec "$0" dnd ;; *"Do Not Disturb"*) exec "$0" dnd ;;
*"Auto timezone"*) exec "$0" autotimezone ;; *"Auto timezone"*) exec "$0" autotimezone ;;
@@ -1262,7 +1299,7 @@ ${themeRows}
esac ;; 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 ;; exit 64 ;;
esac esac
''; '';
@@ -1477,6 +1514,10 @@ in
extraConfig = { extraConfig = {
modi = "drun,run"; 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 show-icons = true; # app icons via the theme's icon set
icon-theme = t.iconTheme; # resolved in theme.nix (Papirus-*/per-theme) icon-theme = t.iconTheme; # resolved in theme.nix (Papirus-*/per-theme)
drun-display-format = "{name}"; drun-display-format = "{name}";

View File

@@ -11,8 +11,9 @@
# history is generation history. # history is generation history.
# #
# The one runtime exception is the wallpaper (swww is imperative by # The one runtime exception is the wallpaper (swww is imperative by
# nature): applied at session start, after every switch (hook below), # nature): applied at session start, after every switch (hook below), and
# and cycled instantly with `nomarchy-theme-sync bg next`. # after output hotplug (hyprland.nix); cycled instantly with
# `nomarchy-theme-sync bg next`.
{ config, lib, pkgs, ... }: { config, lib, pkgs, ... }:
let let

View File

@@ -598,7 +598,7 @@ def cmd_get(args) -> None:
def cmd_wallpaper(_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) apply_wallpaper(load_state(), wait=True)

View File

@@ -73,9 +73,9 @@
# "keychron-keychron-k2" = { layout = "de"; }; # "keychron-keychron-k2" = { layout = "de"; };
# }; # };
# #
# nomarchy.keyboard.layouts = [ "de" "fr" ]; # rofi-prompt for a layout when a new # nomarchy.keyboard.layouts = [ "de" "fr" ]; # optional: put these first in the
# # keyboard connects + remember it in the # # new-keyboard picker (all installed XKB
# # flake state (graduates to .devices above) # # layouts are searchable without this)
# ── 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

View File

@@ -1,8 +1,10 @@
# Undock blackout-rescue harness — boots the full desktop headlessly # Undock blackout-rescue harness — boots the full desktop headlessly
# (softGL Hyprland, same recipe as theme-shot.nix) and replays the # (softGL Hyprland, same recipe as theme-shot.nix) and replays the
# dangerous sequence: add a headless "external", soft-disable the # dangerous profile edge: add a headless "external", soft-disable the
# internal panel (the Display menu's "Laptop screen off" command), then # internal panel (a named profile can still do this), then remove the
# remove the external. Hyprland does NOT re-enable a disabled monitor on # 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 # its own (verified 2026-07-12 — the session ends with zero active
# outputs), so the display-profile watcher's rescue_blackout # outputs), so the display-profile watcher's rescue_blackout
# (modules/home/hyprland.nix) must bring the panel back; this asserts it # (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_unit("multi-user.target")
machine.wait_for_file("/run/user/1000/hypr", 180) machine.wait_for_file("/run/user/1000/hypr", 180)
machine.sleep(20) # softGL first paint settle 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 " hy = ("su - nomarchy -c 'XDG_RUNTIME_DIR=/run/user/1000 "
"HYPRLAND_INSTANCE_SIGNATURE=$(ls /run/user/1000/hypr | head -1) hyprctl ") "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] ext = [n for n in names if n != internal][0]
print(f"external (headless): {ext}") 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'") machine.succeed(hy + f"keyword monitor {internal},disable'")
for _ in range(20): for _ in range(20):
if [x["name"] for x in mons()] == [ext]: break if [x["name"] for x in mons()] == [ext]: break