414 lines
16 KiB
Bash
Executable File
414 lines
16 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.
|
|
|
|
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'
|