fix(display): #142 — dock mode is an intent, and survives a rebuild
All checks were successful
Check / eval (push) Successful in 3m48s
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user