285 lines
11 KiB
Bash
Executable File
285 lines
11 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 pendingRequested:' \
|
|
'property var revertExpected:' \
|
|
'property bool revertVerificationActive:' \
|
|
'property int revertGeneration:' \
|
|
'readonly property bool canConfirm:' \
|
|
'function matchesRequest(' \
|
|
'function scalesForMode(' \
|
|
'function isScaleClean('; 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'
|
|
rg -Fq 'options: Displays.monitors.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' '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 = "not-a-mode", scale = -1, transform = 99 },
|
|
["HDMI-A-1"] = { mode = "1920x1080@60", scale = 1.5, transform = 1 },
|
|
["BAD OUTPUT"] = { mode = "1920x1080@60", scale = 1, transform = 0 },
|
|
}
|
|
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["HDMI-A-1"].mode == "1920x1080@60")
|
|
assert(by_output["HDMI-A-1"].scale == 1.5)
|
|
assert(by_output["HDMI-A-1"].transform == 1)
|
|
assert(by_output["BAD OUTPUT"] == nil)
|
|
assert(by_output[""] ~= nil)
|
|
LUA
|
|
|
|
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; }
|
|
|
|
original_mode=""
|
|
original_scale=""
|
|
original_transform=""
|
|
original_width=""
|
|
original_height=""
|
|
original_refresh=""
|
|
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" \
|
|
'.width == $width and .height == $height
|
|
and ((.refreshRate - $refresh) | fabs) < 0.01
|
|
and ((.scale - $scale) | fabs) < 0.001
|
|
and .transform == $transform' <<<"$current" >/dev/null
|
|
}
|
|
|
|
restore_display() {
|
|
[[ -n "$original_mode" ]] || return 0
|
|
hyprctl eval "hl.monitor({ output = \"$monitor_name\", mode = \"$original_mode\", 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 scale %s transform %s\n' \
|
|
"$monitor_name" "$original_mode" "$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"
|
|
|
|
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")"
|
|
|
|
[[ "$(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 unconfirmed change reverts on its own and stores nothing ──────────────
|
|
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 '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" >/dev/null
|
|
[[ "$(run ipc call displays-test confirmChange)" == "false" ]] \
|
|
|| fail 'Keep accepted a display change before compositor readback'
|
|
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'
|