Make displays configurable, with a revert countdown
Resolution, refresh rate, scale, and rotation, applied through
hl.monitor{} and stored per output.
This is the only setting in Panama where a wrong value 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 a change is never applied
irreversibly. It is applied, then reverted automatically after fifteen
seconds unless confirmed, and confirming is what writes it to the
settings store -- letting the countdown run leaves nothing behind.
The contract tests that property specifically: it applies a scale, waits
out the countdown, and asserts the display came back and that nothing
was stored. A regression there is not a broken feature, it is a user
staring at a blank monitor.
Modes are grouped by resolution with refresh rates beside them. The
panel reports 35, many differing only in refresh-rate rounding -- 60.00
and 59.94 -- which as a flat list of buttons is noise rather than
choice; equal rounded pairs collapse, leaving 21.
Only mode, scale, and transform are configurable. Colour management and
bit depth stay in monitors.lua because they carry a documented screencopy
tradeoff that a settings page cannot explain at the moment you would be
changing it.
Also replaces the display policy rows with the schema-bound ones, so the
page no longer restates labels that PreferenceSchema already holds.
Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
This commit is contained in:
Executable
+139
@@ -0,0 +1,139 @@
|
||||
#!/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"
|
||||
config_home="$(mktemp -d /tmp/panama-displays-config.XXXXXX)"
|
||||
|
||||
fail() {
|
||||
printf 'displays contract: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
run() { XDG_CONFIG_HOME="$config_home" qs -p "$harness" "$@"; }
|
||||
status() { run ipc call displays-test status; }
|
||||
|
||||
original_mode=""
|
||||
original_scale=""
|
||||
original_transform=""
|
||||
|
||||
restore() {
|
||||
# Through hyprctl rather than the harness: if the harness apply path is what
|
||||
# is broken, the daily-driver display must still come back.
|
||||
if [[ -n "$original_mode" ]]; then
|
||||
hyprctl eval "hl.monitor({ output = \"$monitor_name\", mode = \"$original_mode\", scale = $original_scale, transform = $original_transform })" >/dev/null 2>&1 || true
|
||||
fi
|
||||
# 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"
|
||||
}
|
||||
trap restore 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 }')"
|
||||
|
||||
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 '"\(.width)x\(.height)@\(.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
|
||||
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
|
||||
[[ "$(hyprctl -j monitors | jq -r '.[0].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'
|
||||
|
||||
# Wait out the countdown. This is the whole point of the contract.
|
||||
reverted=false
|
||||
for _ in $(seq 1 120); do
|
||||
if [[ "$(hyprctl -j monitors | jq -r '.[0].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
|
||||
sleep 1
|
||||
run ipc call displays-test confirmChange >/dev/null
|
||||
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'
|
||||
|
||||
trap - EXIT
|
||||
restore
|
||||
printf 'displays contract: PASS\n'
|
||||
Reference in New Issue
Block a user