#!/usr/bin/env bash # The Dock on an edge other than the bottom, and on chosen screens. # # The rules: # # 1. A dock spans the edge it lives on, which means anchoring BOTH ends of # that edge. Anchoring one leaves the surface free to collapse to its # implicit size on that axis -- the first side dock written here came out # one pixel tall, looked like nothing had rendered, and passed every static # check. So the geometry is measured, not read. # 2. Bottom is unchanged. Somebody who never touches the setting must get the # dock they already had, byte for byte in behaviour. # 3. Only one axis gets an implicit size. Setting both fights the anchors. # 4. An empty screen list means every screen. A list of names goes stale the # moment a display is unplugged, so "all" must not be spelled as one. # 5. Reordering pins never loses or duplicates an entry. # 6. The drag grip sets preventStealing. Without it the settings page's own # Flickable claims the vertical gesture and the row never moves -- which is # the exact objection this feature was refused over for a long time. # 7. The point that reveals the dock is still inside the input region once the # dock has revealed. The region follows the body, and a bottom dock is # centred while its reveal strip spans the whole edge -- so a region that # forgets the body's own offset lands somewhere the pointer is not, hover # drops on the frame the dock arrives, and it hides under a still cursor. # 8. The dock's own drag-to-reorder commits once, on release, and measures a # slot from a real icon rather than assuming one -- a DockItem is taller # than it is wide, so a constant is wrong on one of the two orientations. # # The geometry checks launch isolated shells against a temporary config. set -uo pipefail repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" shell_dir="$repo_dir/config/dot/quickshell" dock="$shell_dir/modules/dock/Dock.qml" body="$shell_dir/modules/dock/DockBody.qml" strip="$shell_dir/modules/settings/DockPinsStrip.qml" fail() { printf 'dock position contract: %s\n' "$1" >&2 exit 1 } for path in "$dock" "$body" "$strip"; do [[ -r "$path" ]] || fail "missing $path" done # Keep this contract honest: it must never add a probe to the linked source # tree, and every exit path must leave that tree byte-for-byte as it began. source_probe="$shell_dir/dock-position-probe.qml" source_probe_existed=0 source_probe_checksum="" if [[ -e "$source_probe" ]]; then source_probe_existed=1 source_probe_checksum="$(sha256sum -- "$source_probe")" fi source_tree_checksum() { find "$shell_dir" -type f -print0 \ | LC_ALL=C sort -z \ | xargs -0 sha256sum -- \ | sha256sum } source_tree_initial_checksum="$(source_tree_checksum)" assert_source_tree_unchanged() { [[ "$(source_tree_checksum)" == "$source_tree_initial_checksum" ]] \ || { printf 'dock position contract: the Quickshell source tree changed\n' >&2; return 1; } if (( source_probe_existed )); then [[ -e "$source_probe" && "$(sha256sum -- "$source_probe")" == "$source_probe_checksum" ]] \ || { printf 'dock position contract: the source probe changed\n' >&2; return 1; } else [[ ! -e "$source_probe" ]] \ || { printf 'dock position contract: the contract created a source probe\n' >&2; return 1; } fi } fixture_pid="" stop_fixture() { [[ -n "$fixture_pid" ]] || return 0 if kill -0 "$fixture_pid" 2>/dev/null; then kill -TERM "$fixture_pid" 2>/dev/null || true wait "$fixture_pid" 2>/dev/null || true fi fixture_pid="" } cleanup() { local status=$? local fixture_cleanup='pass' local source_probe_assertions='pass' trap - EXIT HUP INT TERM stop_fixture if ! rm -rf -- "$work"; then printf 'dock position contract: could not remove temporary fixture\n' >&2 fixture_cleanup='fail' status=1 fi if ! assert_source_tree_unchanged; then source_probe_assertions='fail' status=1 fi if [[ -n "${DOCK_POSITION_CONTRACT_CLEANUP_PROOF:-}" ]]; then printf 'fixture-cleanup=%s source-and-probe-assertions=%s\n' \ "$fixture_cleanup" "$source_probe_assertions" \ >"$DOCK_POSITION_CONTRACT_CLEANUP_PROOF" || status=1 fi exit "$status" } if ! work="$(mktemp -d)"; then fail 'could not create a temporary fixture' fi [[ -d "$work" ]] || fail 'could not create a temporary fixture' trap cleanup EXIT HUP INT TERM fixture_shell="$work/quickshell" # These checks are deliberately against this harness. A live settings read or # source-tree probe means the contract itself is unsafe before it maps a panel. probe_name='dock-position-probe.qml' source_write_marker="$(printf 'cat >\"$shell_dir/%s\"' "$probe_name")" home_dollar="$(printf '\044')" home_tilde="$(printf '\176')" home_marker="${home_dollar}HOME" settings_store_spellings=( "${home_dollar}HOME/.config/panama/settings.json" "${home_dollar}{HOME}/.config/panama/settings.json" "${home_tilde}/.config/panama/settings.json" "${home_dollar}{XDG_CONFIG_HOME:-${home_dollar}HOME/.config}/panama/settings.json" ) assert_no_real_settings_reference() { local marker for marker in "${settings_store_spellings[@]}"; do grep -Fq "$marker" "$1" && return 1 done return 0 } run_settings_path_probes() { local spelling local number=0 for spelling in "${settings_store_spellings[@]}"; do local fixture="$work/settings-path-$number" printf 'settings=%s\n' "$spelling" >"$fixture" assert_no_real_settings_reference "$fixture" \ && fail "the real-settings guard accepted $spelling" ((number += 1)) done local safe_fixture="$work/settings-path-safe" printf 'XDG_CONFIG_HOME=%s/config\n' "$work" >"$safe_fixture" assert_no_real_settings_reference "$safe_fixture" \ || fail 'the real-settings guard rejected an isolated configuration' } run_pre_copy_cleanup_probe() { [[ "${DOCK_POSITION_CONTRACT_SKIP_PRECOPY_PROBE:-}" == 1 ]] && return local proof="$work/pre-copy-cleanup-proof" if DOCK_POSITION_CONTRACT_INJECT_PRECOPY_FAILURE=1 \ DOCK_POSITION_CONTRACT_SKIP_PRECOPY_PROBE=1 \ DOCK_POSITION_CONTRACT_CLEANUP_PROOF="$proof" \ "$0" >"$work/pre-copy-output" 2>&1; then fail 'the injected pre-copy failure did not fail' fi [[ -r "$proof" ]] \ || fail 'the injected pre-copy failure did not leave cleanup proof' grep -Fxq 'fixture-cleanup=pass source-and-probe-assertions=pass' "$proof" \ || fail 'the injected pre-copy failure skipped cleanup or source/probe assertions' } unsafe_harness=() grep -Fq "$source_write_marker" "$0" && unsafe_harness+=('writes its probe in the Quickshell source tree') assert_no_real_settings_reference "$0" || unsafe_harness+=('reads the user configuration directory') (( ${#unsafe_harness[@]} == 0 )) \ || fail "unsafe harness: ${unsafe_harness[*]}" run_settings_path_probes [[ "${DOCK_POSITION_CONTRACT_INJECT_PRECOPY_FAILURE:-}" != 1 ]] \ || fail 'injected pre-copy failure' run_pre_copy_cleanup_probe cp -a "$shell_dir/." "$fixture_shell/" \ || fail 'could not copy the Quickshell fixture' # ── 3, 4, 6. What can be read ─────────────────────────────────────────────── grep -q 'implicitHeight: root.vertical ? 0 :' "$dock" \ || fail 'the dock sets an implicit height on both orientations, which fights the anchors' grep -q 'implicitWidth: root.vertical ? ' "$dock" \ || fail 'the dock has no implicit width for a side position' grep -q 'wanted.length === 0' "$dock" \ || fail 'an empty screen list is not treated as every screen' grep -q 'preventStealing: true' "$strip" \ || fail 'the drag grip does not set preventStealing, so the page will scroll instead of reordering' # A drag is not reachable from the keyboard, so the strip owes the keyboard its # own path. The old editor spelled it as ↑/↓ buttons; the strip spells it as # arrow keys on a focused icon. Either way it has to exist. grep -q 'Keys.onLeftPressed' "$strip" && grep -q 'Keys.onRightPressed' "$strip" \ || fail 'the pinned strip offers no keyboard-reachable reorder' grep -q 'activeFocusOnTab: true' "$strip" \ || fail 'a pinned icon cannot be reached by Tab, so the keyboard reorder is unreachable' # ── 8. The dock's own drag ───────────────────────────────────────────────── # Dragging an icon along the dock moves the same pin the strip does, so it has # the same two ways to go wrong. # One write per gesture. Committing per slot crossed rewrites settings.json a # dozen times for one drag, and every rewrite re-evaluates the model underneath # the gesture. [[ "$(grep -c 'DesktopPreferences.set("dockPinned"' "$body")" -eq 1 ]] \ || fail 'the dock commits its reorder more than once per gesture, or not through DesktopPreferences' # A DockItem is taller than it is wide -- the running dots sit under the icon -- # so a slot down a side dock is further than a slot across a bottom one. Reading # the pitch from the item that started the drag is what keeps both honest; a # constant here was wrong on one of the two orientations. grep -q 'signal dragStarted(real pitch)' "$shell_dir/modules/dock/DockItem.qml" \ || fail 'the dock drag assumes a slot size instead of measuring the item, so a side dock steps wrong' # ── 5. Reordering keeps every entry, exactly once ─────────────────────────── python3 - <<'PY' || fail 'reordering loses or duplicates a pinned application' def drag_to(working, dragging, target): if dragging < 0 or target == dragging: return working, dragging if target < 0 or target >= len(working): return working, dragging nxt = working[:] nxt.insert(target, nxt.pop(dragging)) return nxt, target base = ["a", "b", "c", "d", "e"] for start, targets in [(0, [1, 2, 3, 4]), (4, [3, 2, 1, 0]), (2, [3]), (2, [1]), (0, [3, 3, 3]), (1, [-1]), (3, [99])]: working, dragging = base[:], start for target in targets: working, dragging = drag_to(working, dragging, target) if sorted(working) != sorted(base) or len(working) != len(base): raise SystemExit(f'starting at {start} through {targets} produced {working}') PY # ── 1, 2, 7. Geometry, measured ──────────────────────────────────────────────── probe="$fixture_shell/$probe_name" cat >"$probe" <<'QML' import Quickshell import QtQuick import qs.modules.dock ShellRoot { Dock { id: probe } // The input region is measured in both states, because the bug it exists // for lives in the transition between them: a region that stops covering // the pointer the moment the dock arrives takes the hover away with it. function report(state) { const m = probe.mask.item; console.warn("MASKGEOM " + probe.position + " " + state + " surfaceW=" + Math.round(probe.width) + " surfaceH=" + Math.round(probe.height) + " x=" + Math.round(m.x) + " y=" + Math.round(m.y) + " w=" + Math.round(m.width) + " h=" + Math.round(m.height)); } Timer { interval: 1200; running: true onTriggered: { console.warn("DOCKGEOM " + probe.position + " vertical=" + probe.vertical + " w=" + Math.round(probe.width) + " h=" + Math.round(probe.height)); probe.revealed = false; hidden.start(); } } // Long enough for the slide to finish; the region follows the body, so // measuring mid-animation measures nothing in particular. Timer { id: hidden interval: 400 onTriggered: { report("hidden"); probe.revealed = true; shown.start(); } } Timer { id: shown interval: 400 onTriggered: { report("revealed"); Qt.quit(); } } } QML measure() { local position="$1" python3 - "$work/config/panama/settings.json" "$position" <<'PY' import json, pathlib, sys out = pathlib.Path(sys.argv[1]) data = { "schemaVersion": 1, "dockAutohide": True, "dockPosition": sys.argv[2], "dockScreens": [], "dockIconSize": 48, "dockRevealDelayMs": 0, "dockHideDelayMs": 250, "dockPinned": [], } out.parent.mkdir(parents=True, exist_ok=True) out.write_text(json.dumps(data) + "\n") PY local output="$work/$position.output" ( cd "$fixture_shell" \ && XDG_CONFIG_HOME="$work/config" XDG_STATE_HOME="$work/state" \ exec timeout --foreground --kill-after=5s 40s qs -p "./$probe_name" ) >"$output" 2>&1 & fixture_pid=$! if ! wait "$fixture_pid"; then fixture_pid="" cat "$output" >&2 fail "the $position fixture did not exit cleanly" fi fixture_pid="" geometry="$(grep -oE '(DOCKGEOM|MASKGEOM) .*' "$output")" \ || fail "the $position fixture produced no geometry" } geometry="" measure bottom bottom="$geometry" [[ -n "$bottom" ]] || fail 'the bottom dock produced no geometry at all' measure left left="$geometry" [[ -n "$left" ]] || fail 'the left dock produced no geometry at all' measure right right="$geometry" [[ -n "$right" ]] || fail 'the right dock produced no geometry at all' # The span checks read the window; the region checks read every line. first_dock() { grep -o 'DOCKGEOM .*' <<<"$1" | head -1; } python3 - "$(first_dock "$bottom")" "$(first_dock "$left")" <<'PY' || fail 'a dock does not span the edge it lives on' import re, sys def read(line): m = re.search(r'DOCKGEOM (\w+) vertical=(\w+) w=(\d+) h=(\d+)', line) if not m: raise SystemExit(f'unreadable probe output: {line!r}') return m.group(1), m.group(2) == 'true', int(m.group(3)), int(m.group(4)) _, bottom_vertical, bottom_w, bottom_h = read(sys.argv[1]) _, left_vertical, left_w, left_h = read(sys.argv[2]) if bottom_vertical: raise SystemExit('the bottom dock reports itself as vertical') if not left_vertical: raise SystemExit('the left dock does not report itself as vertical') # The bug this exists for: a side dock anchored at one end only collapses to a # sliver on the axis it should span. if left_h <= bottom_h: raise SystemExit(f'the left dock is {left_h}px tall and does not span the screen') if bottom_w <= left_w: raise SystemExit(f'the bottom dock is {bottom_w}px wide and does not span the screen') if left_w >= bottom_w or bottom_h >= left_h: raise SystemExit('the two orientations are not thin on opposite axes') PY # ── 7. The pointer that reveals the dock is still inside the region ───────── python3 - "$bottom" "$left" "$right" <<'REGION' || fail 'revealing the dock moves the input region off the pointer that revealed it' import re, sys FIELDS = re.compile( r'MASKGEOM (\w+) (\w+) surfaceW=(\d+) surfaceH=(\d+) ' r'x=(-?\d+) y=(-?\d+) w=(-?\d+) h=(-?\d+)') def regions(blob): found = {} for line in blob.splitlines(): m = FIELDS.search(line) if m: found[m.group(2)] = (m.group(1),) + tuple(int(g) for g in m.groups()[2:]) return found # Where a hand actually goes to summon the dock: the middle of the edge it # lives on, a pixel in from that edge. def aim(position, surface_w, surface_h): if position == "left": return 1, surface_h / 2 if position == "right": return surface_w - 1, surface_h / 2 return surface_w / 2, surface_h - 1 for blob in sys.argv[1:]: found = regions(blob) for state in ("hidden", "revealed"): if state not in found: raise SystemExit(f'the probe reported no {state} input region') position, surface_w, surface_h, x, y, w, h = found[state] px, py = aim(position, surface_w, surface_h) if not (x <= px <= x + w and y <= py <= y + h): raise SystemExit( f'the {position} dock {state} takes input over x {x}..{x + w}, ' f'y {y}..{y + h}, which does not contain the pointer at ' f'({px:.0f}, {py:.0f})') REGION printf 'dock position contract: ok\n'