Files
Panama/tests/quickshell/displays-contract
T
Gabriel Brown 6016efa436 Meet main where it has moved since this branch was written
The rebase itself is the previous five commits replayed onto main; this is what
they needed once they landed there, kept separate so the replay stays readable.

The shell did not start. A Column in this branch's notification settings menu
assigned its own implicitWidth and implicitHeight, which a Qt 6 positioner
computes and does not let you set. That took out every contract that launches a
shell -- six of the seven failures were this one line, and none of them said so
until the error was read to the bottom. A Column already measures itself from its
children, so the bindings are simply gone.

Three assertions pinned an implementation main has since replaced, and each is
updated to pin the intent rather than the mechanism:

  - The display picker now reads primaryFirstMonitors, which is monitors sorted
    with the primary first. Still populated from what is connected, which is what
    the contract is for; the sorted list is the point, so the picker opens on the
    display somebody is most likely to mean. This branch made that change and
    broke its own contract without noticing.
  - The accent swatches come from the accentName schema rather than
    Object.keys(Theme.accents). Same swatches, same order, one source shared with
    every other enum row.
  - The OSD used to take no pointer input at all. It takes some now, because this
    branch's own design calls for a secondary click on a visible OSD to open its
    settings, and a Wayland input region cannot admit one button and refuse
    another. The rule that survives is that the region stays bounded to the OSD's
    own card: it floats over other windows for a couple of seconds, and a region
    bigger than the card would swallow clicks meant for something underneath.

Theme's accent table moved to ThemeProfiles, which is this branch's point -- a
curated accent and a custom profile become the same kind of record. main had
meanwhile given each accent a `gnome` member, the nearest name in GNOME's fixed
accent-color enum, which is what makes libadwaita applications recolor instead of
staying in GNOME blue. That member moved into the curated table rather than being
dropped, and adwaita-accent-contract now reads it where it lives.

Where main had simply moved further along the same path, main won: the focused
border driven by the chosen accent rather than a hardcoded pair, the gradient
built through the shared serializer rather than a hand-rolled string, the
multi-edge dock geometry. This branch's context menu, keyboard focus and
accessibility work sit on top of those rather than beside them.

Three new contracts arrived carrying .sh and lost it, along with the references
in this branch's own plan.

124 contracts pass.

Claude-Session: https://claude.ai/code/session_01NvgBuSWB5sE43yWmg21ozj
2026-08-20 22:23:27 -04:00

376 lines
16 KiB
Bash
Executable File

#!/usr/bin/env bash
# Display configuration.
#
# This is the only setting in Panama that can leave the user unable to SEE the
# screen well enough to undo it: a mode the panel cannot show, or a scale that
# makes everything unreadable, is not recoverable through the UI that caused it.
#
# So the property under test is not "can it change the resolution" but "does an
# unconfirmed change always come back". A regression here is not a broken
# feature, it is a user staring at a blank monitor.
#
# * an unconfirmed change reverts on its own, and stores nothing
# * a confirmed change is what writes to the settings store
# * a mode, scale, rotation, or output the compositor did not offer is refused
# before anything is applied
#
# The compositor is the live one -- there is no way to test this otherwise --
# but preferences are isolated, and every path restores the display it started
# from.
set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
harness="$repo_dir/config/dot/quickshell/displays-harness.qml"
service="$repo_dir/config/dot/quickshell/services/Displays.qml"
page="$repo_dir/config/dot/quickshell/modules/settings/DisplaysPage.qml"
settings_page="$repo_dir/config/dot/quickshell/modules/settings/SettingsPage.qml"
monitors_lua="$repo_dir/config/dot/hypr/monitors.lua"
fail() {
printf 'displays contract: %s\n' "$1" >&2
exit 1
}
# Keep is unavailable until compositor readback exactly matches the request.
for contract in \
'property var pendingRequestedLayout:' \
'property var revertExpectedLayout:' \
'property bool revertVerificationActive:' \
'property int revertGeneration:' \
'readonly property bool canConfirm:' \
'function matchesLayout(' \
'function scalesForMode(' \
'function isScaleClean(' \
'x: Number.isInteger(monitor.x)' \
'y: Number.isInteger(monitor.y)' \
'primary: monitor.name === primaryName'; do
rg -Fq "$contract" "$service" || fail "display service contract is missing: $contract"
done
rg -Fq 'enabled: Displays.canConfirm' "$page" \
|| fail 'Keep is enabled before the display change is verified'
rg -Fq 'options: Displays.scalesForMode(' "$page" \
|| fail 'scale choices are not filtered for the active resolution'
rg -Fq 'property string selectedOutput:' "$page" \
|| fail 'connected outputs cannot be selected'
# primaryFirstMonitors is monitors sorted with the primary first, so the
# selector is still populated from what is connected -- which is what this
# protects. Naming the sorted list rather than the raw one is the point: the
# picker should open on the display somebody is most likely to mean.
rg -Fq 'options: Displays.primaryFirstMonitors.map(' "$page" \
|| fail 'the output selector is not populated from connected displays'
rg -Fq 'id: revertVerifyTimer' "$service" \
|| fail 'automatic restoration has no bounded readback verification'
rg -Fq 'if (root.busy)' "$service" \
|| fail 'the display service accepts a new apply while another operation is busy'
# Stored JSON is untyped at field level, so the Lua startup consumer is the
# final validation boundary and must support every named output it accepts.
for contract in 'valid_mode' 'valid_scale' 'valid_transform' 'valid_position' 'valid_primary' 'pairs(displays)'; do
rg -Fq "$contract" "$monitors_lua" || fail "monitor startup validation is missing: $contract"
done
# SettingsPage headers are genuinely pinned outside its scrolling surface.
python3 - "$settings_page" <<'PY' || fail 'SettingsPage header is not pinned outside the Flickable'
import sys
text = open(sys.argv[1], encoding="utf-8").read()
loader = text.find("id: pinnedHeader")
flickable = text.find("id: pageScroll")
if loader < 0 or flickable < 0 or loader > flickable:
raise SystemExit(1)
PY
MONITORS_LUA="$monitors_lua" lua - <<'LUA' || fail 'monitor startup accepted invalid persisted geometry or ignored a named output'
package.preload["prefs"] = function()
return {
get = function()
return {
["DP-2"] = {
mode = "4500x3000@60", scale = 1.5, transform = 0,
x = 0, y = 0, primary = true,
},
["HDMI-A-1"] = {
mode = "2560x1440@60", scale = 1, transform = 1,
x = 3000, y = 0, primary = false,
},
["LEGACY-1"] = { mode = "1920x1080@60", scale = 1.5, transform = 0 },
["PARTIAL-1"] = {
mode = "1920x1080@60", scale = 1, transform = 0,
x = 4440,
},
["BAD-PRIMARY"] = {
mode = "1920x1080@60", scale = 1, transform = 0,
x = 4440, y = 0, primary = "yes",
},
["BAD OUTPUT"] = {
mode = "1920x1080@60", scale = 1, transform = 0,
x = 4440, y = 0, primary = false,
},
}
end,
}
end
local calls = {}
hl = { monitor = function(value) table.insert(calls, value) end }
assert(loadfile(os.getenv("MONITORS_LUA")))()
local by_output = {}
for _, value in ipairs(calls) do by_output[value.output] = value end
assert(by_output["DP-2"].mode == "4500x3000@60")
assert(by_output["DP-2"].scale == 1.5)
assert(by_output["DP-2"].transform == 0)
assert(by_output["DP-2"].position == "0x0")
assert(by_output["DP-2"].bitdepth == 10)
assert(by_output["DP-2"].cm == "auto")
assert(by_output["HDMI-A-1"].mode == "2560x1440@60")
assert(by_output["HDMI-A-1"].scale == 1)
assert(by_output["HDMI-A-1"].transform == 1)
assert(by_output["HDMI-A-1"].position == "3000x0")
assert(by_output["LEGACY-1"].position == "auto")
assert(by_output["PARTIAL-1"] == nil)
assert(by_output["BAD-PRIMARY"] == nil)
assert(by_output["BAD OUTPUT"] == nil)
assert(by_output[""] ~= nil)
LUA
rg -Fq 'Resolution, scale, rotation, position, and primary display' \
"$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml" \
|| fail 'the display preference does not document complete layout persistence'
if [[ "${PANAMA_DISPLAYS_STATIC_ONLY:-0}" == "1" ]]; then
printf 'displays contract: PASS (static)\n'
exit 0
fi
config_home="$(mktemp -d /tmp/panama-displays-config.XXXXXX)"
run() { XDG_CONFIG_HOME="$config_home" qs -p "$harness" "$@"; }
status() { run ipc call displays-test status; }
# The shipped geometry, read from the Hyprland config rather than from the
# running compositor.
#
# Everything below captures "original" from what it observes at start, which is
# correct only if the display is already in a good state. A previous run that
# failed mid-revert leaves the display changed, and the next run then captures
# THAT as the original and faithfully restores the desktop to a broken value.
# One flake becomes permanent. So refuse to run from a state that does not match
# what the config says, rather than laundering it.
shipped_scale="$(sed -n 's/^local shipped_scale *= *\([0-9.]*\).*/\1/p' \
"$repo_dir/config/dot/hypr/monitors.lua" | head -1)"
[[ -n "$shipped_scale" ]] || fail 'could not read the shipped scale from monitors.lua -- the guard below depends on it, and skipping it silently is how a dirty baseline gets laundered'
if [[ -n "$shipped_scale" ]]; then
live_scale="$(hyprctl -j monitors | jq -r '.[0].scale')"
if ! awk -v a="$live_scale" -v b="$shipped_scale" 'BEGIN { exit !(a == b) }'; then
fail "the display is at scale $live_scale but the config ships $shipped_scale -- refusing to capture a dirty state as the baseline. Restore it first: hyprctl eval 'hl.monitor({ output = \"DP-2\", mode = \"4500x3000@60\", scale = $shipped_scale, transform = 0 })'"
fi
fi
original_mode=""
original_scale=""
original_transform=""
original_width=""
original_height=""
original_refresh=""
original_x=""
original_y=""
monitor_name=""
monitor_state() {
hyprctl -j monitors | jq -c --arg output "$monitor_name" '.[] | select(.name == $output)'
}
display_is_restored() {
local current
current="$(monitor_state)"
[[ -n "$current" ]] || return 1
jq -e \
--argjson width "$original_width" \
--argjson height "$original_height" \
--argjson refresh "$original_refresh" \
--argjson scale "$original_scale" \
--argjson transform "$original_transform" \
--argjson x "$original_x" \
--argjson y "$original_y" \
'.width == $width and .height == $height
and ((.refreshRate - $refresh) | fabs) < 0.01
and ((.scale - $scale) | fabs) < 0.001
and .transform == $transform
and .x == $x and .y == $y' <<<"$current" >/dev/null
}
restore_display() {
[[ -n "$original_mode" ]] || return 0
hyprctl eval "hl.monitor({ output = \"$monitor_name\", mode = \"$original_mode\", position = \"${original_x}x${original_y}\", scale = $original_scale, transform = $original_transform })" >/dev/null \
|| return 1
for _ in $(seq 1 50); do
display_is_restored && return 0
sleep 0.2
done
return 1
}
stop_harness() {
# Kill by PID, never `pkill -f displays-harness`: that pattern also matches
# any shell whose command line contains this script's text, which includes
# the invoking shell itself.
[[ -n "${harness_pid:-}" ]] && kill "$harness_pid" >/dev/null 2>&1 || true
rm -rf "$config_home"
}
cleanup() {
local status=$?
trap - EXIT
if ! restore_display; then
printf 'displays contract: FAILED to restore %s to %s at %sx%s scale %s transform %s\n' \
"$monitor_name" "$original_mode" "$original_x" "$original_y" \
"$original_scale" "$original_transform" >&2
status=1
fi
stop_harness
exit "$status"
}
trap cleanup EXIT
XDG_CONFIG_HOME="$config_home" qs -p "$harness" --daemonize >/dev/null
harness_pid=""
for _ in $(seq 1 40); do
run ipc show 2>/dev/null | rg -q '^target displays-test$' && break
sleep 0.1
done
run ipc show 2>/dev/null | rg -q '^target displays-test$' || fail 'test IPC target did not start'
harness_pid="$(run list | awk '/Process ID:/ { print $3; exit }')"
refresh_fixture="$(run ipc call displays-test refreshIdentityFixture)"
jq -e '
.count == 2
and .modes == ["[email protected]", "[email protected]"]
and .selected == ["[email protected]"]
' <<<"$refresh_fixture" >/dev/null \
|| fail "59.94 Hz and 60.00 Hz lost their distinct selection identity: $refresh_fixture"
position_fixture="$(run ipc call displays-test positionFixture)"
jq -e '. == [
{"name":"DP-2","x":140,"y":80,"primary":true},
{"name":"HDMI-A-1","x":3140,"y":80,"primary":false}
]' <<<"$position_fixture" >/dev/null \
|| fail "monitor positions or primary selection were parsed incorrectly: $position_fixture"
for _ in $(seq 1 50); do
[[ "$(status | jq -r .count)" != "0" ]] && break
sleep 0.1
done
state="$(status)"
monitor_name="$(jq -r .name <<<"$state")"
[[ -n "$monitor_name" ]] || fail "no display was detected: $state"
original_mode="$(jq -r .mode <<<"$state")"
original_width="$(jq -r .width <<<"$state")"
original_height="$(jq -r .height <<<"$state")"
original_refresh="$(jq -r .refresh <<<"$state")"
original_scale="$(jq -r .scale <<<"$state")"
original_transform="$(jq -r .transform <<<"$state")"
original_x="$(jq -r .x <<<"$state")"
original_y="$(jq -r .y <<<"$state")"
[[ "$(jq -r .modes <<<"$state")" -gt 0 ]] || fail 'the display reported no usable modes'
# ── Anything the compositor did not offer is refused before applying ─────────
while IFS= read -r kind; do
[[ "$(run ipc call displays-test applyBad "$kind")" == "false" ]] \
|| fail "an invalid $kind was accepted"
[[ "$(status | jq -r .awaiting)" == "false" ]] \
|| fail "an invalid $kind left a change pending"
done <<'KINDS'
mode
scale
transform
output
dirtyScale
KINDS
# The display must not have moved for any of those.
now="$(status)"
[[ "$(jq -r .scale <<<"$now")" == "$original_scale" ]] || fail 'a refused change still altered the scale'
# An immediate Revert may race both the apply process and its first readback.
# It must queue until both are clear, then verify the original generation.
target_scale=$(awk -v s="$original_scale" 'BEGIN { print (s == 1.25) ? 1.5 : 1.25 }')
[[ "$(run ipc call displays-test applyScale "$target_scale")" == "true" ]] \
|| fail 'the immediate-revert fixture could not apply'
run ipc call displays-test revertChange >/dev/null
immediate_reverted=false
for _ in $(seq 1 60); do
if display_is_restored && [[ "$(status | jq -r .awaiting)" == "false" ]]; then
immediate_reverted=true
break
fi
sleep 0.2
done
[[ "$immediate_reverted" == true ]] \
|| fail 'an immediate Revert raced the apply/readback and did not restore the display'
# ── An unconfirmed change reverts on its own and stores nothing ──────────────
[[ "$(run ipc call displays-test applyScale "$target_scale")" == "true" ]] \
|| fail 'a valid scale change was refused'
applied=false
for _ in $(seq 1 30); do
[[ "$(monitor_state | jq -r '.scale')" == "$target_scale" ]] && { applied=true; break; }
sleep 0.2
done
[[ "$applied" == true ]] || fail 'the scale change never reached the compositor'
[[ "$(status | jq -r .awaiting)" == "true" ]] || fail 'an applied change is not awaiting confirmation'
[[ "$(status | jq -r .canConfirm)" == "true" ]] || fail 'an applied change was never verified by compositor readback'
# Wait out the countdown. This is the whole point of the contract.
reverted=false
for _ in $(seq 1 120); do
if [[ "$(monitor_state | jq -r '.scale')" == "$original_scale" ]]; then
reverted=true
break
fi
sleep 0.5
done
[[ "$reverted" == true ]] || fail 'an unconfirmed change did NOT revert -- this would strand a user on an unreadable display'
[[ "$(status | jq -r .awaiting)" == "false" ]] || fail 'the pending state survived the revert'
[[ "$(status | jq -r .overridden)" == "false" ]] || fail 'an unconfirmed change was written to the settings store'
# ── A confirmed change is what writes ────────────────────────────────────────
[[ "$(run ipc call displays-test applyScale "$target_scale")" == "true" ]] \
|| fail 'the confirmed-change fixture could not apply'
# Hyprland can apply and read back a change faster than two IPC round trips, so
# this live test cannot reliably observe the pre-readback state. The
# fake-compositor contract deterministically holds that boundary open and
# proves Keep refuses it; this path proves a real readback eventually enables
# and persists Keep on the physical display.
verified=false
for _ in $(seq 1 30); do
[[ "$(status | jq -r .canConfirm)" == "true" ]] && { verified=true; break; }
sleep 0.2
done
[[ "$verified" == true ]] || fail 'the confirmed change never became safe to keep'
[[ "$(run ipc call displays-test confirmChange)" == "true" ]] \
|| fail 'Keep refused a verified display change'
sleep 0.6
[[ "$(status | jq -r .awaiting)" == "false" ]] || fail 'confirming did not clear the pending state'
[[ "$(status | jq -r .overridden)" == "true" ]] || fail 'confirming did not store the change'
store="$config_home/panama/settings.json"
jq -e --arg m "$monitor_name" '.displays[$m].scale != null' "$store" >/dev/null \
|| fail 'the confirmed change is not in the settings store'
# ── Forgetting clears it ─────────────────────────────────────────────────────
run ipc call displays-test forget >/dev/null
sleep 0.6
[[ "$(status | jq -r .overridden)" == "false" ]] || fail 'forget did not clear the stored display setting'
restore_display || fail 'the final cleanup could not restore and verify the original display'
original_mode=""
stop_harness
trap - EXIT
printf 'displays contract: PASS\n'