Files
Panama/tests/quickshell/dock-position-contract
T
Gabriel Brown f8f5b25510 Make Shell a category, the bar legible, and the dock a real dock
Desktop & Dock becomes Shell — Bar, Dock, Control Center, Tiling,
Workspaces — the home for everything Quickshell draws. The settings-
management cluster moves to System as Sync & Backup, Appearance's
Shell tab dissolves, and 24-hour time finally lives on Date & Time,
which always owned it.

The bar gets what it never had: a way to survive the wallpaper. A
second neutral text family (follow theme, or forced light or dark),
a one-layer shadow under every glyph, and a gradient scrim for
wallpapers nothing else survives — all off by default, pixel-identical
until asked. Widgets earn toggles (weather, media, clipboard, calendar
countdown), the vitals cluster stops leaving a dead pill behind, and
Control Center's sections learn to step aside.

The dock graduates from MVP: a context menu with window rows, pin,
unpin, quit and new-window; scroll an icon to cycle its windows; drag
to reorder on the dock itself; hover previews with one-shot captures;
and "Add App to Dock" in the launcher. Three real bugs died en route —
menus that slid away with the autohide, a readonly-property crash on
every menu open, and a drag that drifted half a slot per icon on side
docks. The pinned-apps editor in Settings becomes a drag strip.

166 contracts; the full suite is green except two live display and
switcher tests that cannot run behind a locked session — re-verified
on unlock.

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
2026-08-24 04:28:20 -04:00

262 lines
11 KiB
Bash
Executable File

#!/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. The
# real settings are read to build them and never written.
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
work="$(mktemp -d)"
trap 'rm -rf "$work" "$shell_dir/dock-position-probe.qml"' EXIT
# ── 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 ────────────────────────────────────────────────
cat >"$shell_dir/dock-position-probe.qml" <<'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
settings_source="${XDG_CONFIG_HOME:-$HOME/.config}/panama/settings.json"
[[ -r "$settings_source" ]] || fail 'no settings to build a probe configuration from'
measure() {
local position="$1"
python3 - "$settings_source" "$work/panama/settings.json" "$position" <<'PY'
import json, pathlib, sys
data = json.loads(pathlib.Path(sys.argv[1]).read_text())
data["dockPosition"] = sys.argv[3]
out = pathlib.Path(sys.argv[2])
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(data))
PY
( cd "$shell_dir" && XDG_CONFIG_HOME="$work" timeout 40 qs -p ./dock-position-probe.qml 2>&1 ) \
| grep -oE '(DOCKGEOM|MASKGEOM) .*'
}
bottom="$(measure bottom)"
[[ -n "$bottom" ]] || fail 'the bottom dock produced no geometry at all'
left="$(measure left)"
[[ -n "$left" ]] || fail 'the left dock produced no geometry at all'
right="$(measure right)"
[[ -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'