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

@@ -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}";