fix(display): #142 — dock mode is an intent, and survives a rebuild
All checks were successful
Check / eval (push) Successful in 3m48s

Every rebuild while docked re-lit the panel and threw a workspace onto
it. Not forgetfulness: a rebuild PERFORMS an undock. Dock mode lived only
in the compositor (`keyword monitor <internal>, disable`, nothing written
down), the config's only monitor line is the catch-all
`monitor=,preferred,auto,1`, and rewriting the config makes Hyprland
reload it — re-enabling every output. Our own undock path already uses
`hyprctl reload` as exactly that hammer (hardware-proven 2026-07-14).

The watcher could not rescue it: reconcile diffs `monitors all`, which
lists disabled outputs, so the connected set is identical across the flip
— structurally blind, the blind spot of rounds 4-5's "the output set is
the truth".

So the intent is recorded where every other setting lives —
settings.display.dockMode, in-flake, menu-writable — written at the one
choke point both menu and watcher pass through (transition: dock → true,
undock → false, `enable` of an internal → false, because "Screen on" IS
leaving dock mode and must not be undone a second later). The watcher
re-asserts it as a tick invariant rather than an event reaction, which is
what makes the reload case work; auto_dock was already idempotent, so it
costs one `hyprctl monitors` per tick.

The trap avoided: baking the disable into the config would survive a
reload and hand a user with no external a zero-output session at login —
the #100/#127 brick. The config cannot know what is plugged in, so it
keeps defaulting to "everything on" and the re-assert is gated on an
external being present.

Verified: 9/9 gates unit-tested by driving the REAL generated function
against stubs (incl. no-external → never re-dock, profile active → yield,
already-docked → no churn), plus the state write/read round-trip on a
throwaway checkout so the live flake was untouched. V3 queued — needs a
relogin, the watcher is exec-once.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-16 11:51:52 +01:00
parent 02ec0f10cd
commit 30e632ff65
4 changed files with 111 additions and 47 deletions

View File

@@ -190,6 +190,23 @@ let
done
}
# Dock mode is an *intent*, and until #142 it lived only in the compositor:
# `keyword monitor <internal>, disable` and nothing written down. Any config
# reload re-applies the catch-all `monitor=,preferred,auto,1` and re-lights
# the panel which is exactly why `undock` below uses `hyprctl reload` as
# its hammer so every `nomarchy-home` silently performed an undock and the
# user re-picked Dock mode by hand.
#
# So record the intent where every other setting lives (the in-flake state,
# menu-writable), and let the watcher re-assert it. `--no-switch`: the
# watcher reads this at run time; a rebuild here would be both pointless and
# recursive. Failure to write is never fatal a session that cannot reach
# the state file must still dock.
set_dock_intent() { # $1 = true|false
${sync} --quiet set settings.display.dockMode "$1" --no-switch \
>/dev/null 2>&1 || log "dock-intent=$1 result=state-write-failed"
}
case "''${1:-}" in
dock)
internal="''${2:-}"; external="''${3:-}"
@@ -204,6 +221,7 @@ let
if hyprctl --batch \
"keyword monitor $rule; $moves dispatch focusmonitor $external; keyword monitor $internal, disable" \
>/dev/null 2>&1; then
set_dock_intent true
log "transition=dock internal=$internal external=$external result=ok"
else
log "transition=dock internal=$internal external=$external result=failed"
@@ -212,6 +230,10 @@ let
undock)
internal="''${2:-}"
valid_output "$internal" || { echo "invalid monitor name" >&2; exit 64; }
# Clear the intent *first*: this path runs when the external is already
# gone, and if anything below fails the last thing the session needs is
# a watcher that keeps trying to dock to a monitor that left.
set_dock_intent false
# Safety-critical ordering: never remove/move away from an external
# before the internal panel is active and can receive workspaces.
#
@@ -262,6 +284,14 @@ let
enable)
internal="''${2:-}"
valid_output "$internal" || exit 64
# Menu Display "Screen on". Turning the laptop panel back on *is*
# leaving dock mode, and saying so is what stops the watcher from
# re-docking the panel out from under the user a second later (#142).
# Only the internal clears the intent: lighting some third monitor is
# not a statement about the lid.
case "$internal" in
eDP*|LVDS*|DSI*) set_dock_intent false ;;
esac
# Reachable only from the menu, i.e. with a screen already on, so the
# keyword flushes and no reload escalation is needed but still make
# `monitors` the proof, or the menu's "back on" notification cheers
@@ -558,6 +588,37 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList
fi
}
# Dock mode has to be re-asserted, not just entered (#142). `reconcile`
# below diffs the *connected* set (`monitors all`, which lists disabled
# outputs too), so it is blind by construction to a panel being switched
# back **on**: the connected set does not change, and every rebuild does
# exactly that Hyprland reloads the rewritten config, the catch-all
# `monitor=,preferred,auto,1` re-lights the internal, and a workspace lands
# on it. So the invariant is checked on every tick instead of on events:
# if the user's recorded intent is dock and the panel is up while an
# external is present, put it back.
#
# `auto_dock` is already idempotent it returns early unless the internal
# is enabled so this costs one `hyprctl monitors` per tick and does
# nothing in the overwhelmingly common case. Gated on the same display
# profile check as the event path: a profile is a deliberate, more specific
# layout and outranks the dock heuristic.
enforce_dock_intent() {
[ "$(${sync} get settings.display.dockMode 2>/dev/null)" = true ] || return
auto_on || return
[ "$(nomarchy-display-profile active 2>/dev/null || echo none)" = "none" ] || return
internal=$(internal_output)
[ -n "$internal" ] || return
# Only with an external actually present: intent must never be able to
# black out a laptop that has nowhere else to draw (#127's brick).
[ -n "$(external_outputs)" ] || return
hyprctl monitors -j 2>/dev/null \
| jq -e --arg m "$internal" 'any(.[]; .name == $m)' >/dev/null || return
log "dock-intent=true panel=$internal state=re-enabled action=re-dock"
acquire_inhibitor
auto_dock
}
# The set of connected outputs not the IPC event stream is the source
# of truth. Hyprland's socket can miss or drop events (see the loop
# below), and a lost monitoradded used to mean no docking profile, no
@@ -656,6 +717,10 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList
release_inhibitor
awaiting_lid_open=
fi
# Last, because it re-derives `internal` and the invariants above must
# see reconcile's own value: a queued undock outranks a stale dock
# intent, and this returns early anyway while no external is present.
enforce_dock_intent
}
# Login/relogin while already docked still needs protection. Do not