From 2d4b126f14f662267e9919cb66ff3066be8e2bdc Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Tue, 18 Aug 2026 02:31:12 -0400 Subject: [PATCH] Harden display apply and recovery --- config/dot/hypr/monitors.lua | 89 ++++++++- config/dot/quickshell/displays-harness.qml | 7 +- .../modules/settings/DisplayModePicker.qml | 2 +- .../modules/settings/DisplaysPage.qml | 16 +- .../modules/settings/SettingsPage.qml | 32 ++- config/dot/quickshell/services/Displays.qml | 188 +++++++++++++++--- tests/quickshell/displays-contract.sh | 152 ++++++++++++-- 7 files changed, 422 insertions(+), 64 deletions(-) diff --git a/config/dot/hypr/monitors.lua b/config/dot/hypr/monitors.lua index 044faea..9fe3fe6 100644 --- a/config/dot/hypr/monitors.lua +++ b/config/dot/hypr/monitors.lua @@ -27,25 +27,78 @@ local prefs = require("prefs") -- (see the header) rather than preferences, and a settings page has no way to -- explain the screencopy tradeoff at the moment you would be changing it. local displays = prefs.get("displays", {}) +if type(displays) ~= "table" then + displays = {} +end -local function override(output, field, fallback) +local function mode_dimensions(mode) + if type(mode) ~= "string" then + return nil, nil + end + local width, height, refresh = mode:match("^(%d+)x(%d+)@(%d+%.%d+)$") + if width == nil then + width, height, refresh = mode:match("^(%d+)x(%d+)@(%d+)$") + end + width, height, refresh = tonumber(width), tonumber(height), tonumber(refresh) + if width == nil or height == nil or refresh == nil + or width <= 0 or height <= 0 or refresh <= 0 then + return nil, nil + end + return width, height +end + +local function valid_mode(mode) + local width = mode_dimensions(mode) + return width ~= nil +end + +local function valid_scale(mode, scale) + local width, height = mode_dimensions(mode) + if width == nil or type(scale) ~= "number" or scale ~= scale + or scale <= 0 or scale > 4 then + return false + end + local logical_width = width / scale + local logical_height = height / scale + return math.abs(logical_width - math.floor(logical_width + 0.5)) < 0.0001 + and math.abs(logical_height - math.floor(logical_height + 0.5)) < 0.0001 +end + +local function valid_transform(transform) + return type(transform) == "number" + and transform == math.floor(transform) + and transform >= 0 + and transform <= 3 +end + +local function display_entry(output) + if type(output) ~= "string" or output == "" + or output:match("^[%w_.-]+$") == nil then + return nil + end local entry = displays[output] if type(entry) ~= "table" then - return fallback + return nil end - local value = entry[field] - if value == nil or type(value) ~= type(fallback) then - return fallback + if not valid_mode(entry.mode) + or not valid_scale(entry.mode, entry.scale) + or not valid_transform(entry.transform) then + return nil end - return value + return entry end +local shipped_mode = "4500x3000@60" +local shipped_scale = 1.5 +local shipped_transform = 0 +local dp2 = display_entry("DP-2") + hl.monitor({ output = "DP-2", - mode = override("DP-2", "mode", "4500x3000@60"), + mode = dp2 and dp2.mode or shipped_mode, position = "0x0", - scale = override("DP-2", "scale", 1.5), - transform = override("DP-2", "transform", 0), + scale = dp2 and dp2.scale or shipped_scale, + transform = dp2 and dp2.transform or shipped_transform, -- 10-bit output. 4500x3000@60 at 10bpc is ~24 Gbps, right at the edge of -- DP 1.4 HBR3, so this relies on DSC. If the display fails to light up or @@ -56,6 +109,24 @@ hl.monitor({ cm = "auto", }) +-- Other connected outputs use the same validated per-output store. They keep +-- automatic placement and the compositor's normal colour policy; DP-2 alone +-- carries the panel-specific 10-bit policy documented above. +for output, _ in pairs(displays) do + if output ~= "DP-2" then + local entry = display_entry(output) + if entry ~= nil then + hl.monitor({ + output = output, + mode = entry.mode, + position = "auto", + scale = entry.scale, + transform = entry.transform, + }) + end + end +end + -- Any monitor not named above: sane defaults rather than nothing. hl.monitor({ output = "", diff --git a/config/dot/quickshell/displays-harness.qml b/config/dot/quickshell/displays-harness.qml index 2f4c15a..db4331f 100644 --- a/config/dot/quickshell/displays-harness.qml +++ b/config/dot/quickshell/displays-harness.qml @@ -21,6 +21,7 @@ ShellRoot { transform: monitor ? monitor.transform : -1, modes: monitor ? monitor.modes.length : 0, awaiting: Displays.awaitingConfirmation, + canConfirm: Displays.canConfirm, secondsLeft: Displays.secondsLeft, lastError: Displays.lastError, overridden: monitor ? Displays.isOverridden(monitor.name) : false @@ -40,12 +41,16 @@ ShellRoot { const mode = monitor.width + "x" + monitor.height + "@" + Math.round(monitor.refreshRate); if (kind === "mode") return Displays.apply(monitor.name, "9999x9999@240", monitor.scale, monitor.transform); if (kind === "scale") return Displays.apply(monitor.name, mode, 1.37, monitor.transform); + if (kind === "dirtyScale") { + const dirty = Displays.scales.find(scale => !Displays.isScaleClean(mode, scale)); + return dirty === undefined ? false : Displays.apply(monitor.name, mode, dirty, monitor.transform); + } if (kind === "transform") return Displays.apply(monitor.name, mode, monitor.scale, 9); if (kind === "output") return Displays.apply("NOPE-1", mode, monitor.scale, monitor.transform); return false; } - function confirmChange(): void { Displays.confirm(); } + function confirmChange(): bool { return Displays.confirm(); } function revertChange(): void { Displays.revert(); } function forget(): void { const monitor = Displays.monitors[0]; diff --git a/config/dot/quickshell/modules/settings/DisplayModePicker.qml b/config/dot/quickshell/modules/settings/DisplayModePicker.qml index 1498b10..ce4f5ed 100644 --- a/config/dot/quickshell/modules/settings/DisplayModePicker.qml +++ b/config/dot/quickshell/modules/settings/DisplayModePicker.qml @@ -110,7 +110,7 @@ Column { onTapped: Displays.apply( root.monitor.name, rate.modelData.mode, - root.monitor.scale, + Displays.nearestCleanScale(rate.modelData.mode, root.monitor.scale), root.monitor.transform) } } diff --git a/config/dot/quickshell/modules/settings/DisplaysPage.qml b/config/dot/quickshell/modules/settings/DisplaysPage.qml index 25b0b21..df5a16a 100644 --- a/config/dot/quickshell/modules/settings/DisplaysPage.qml +++ b/config/dot/quickshell/modules/settings/DisplaysPage.qml @@ -21,6 +21,9 @@ SettingsPage { lede: SystemSettings.monitorDescription || "Reading the active display…" readonly property var monitor: Displays.monitors.length > 0 ? Displays.monitors[0] : null + readonly property string currentMode: root.monitor + ? `${root.monitor.width}x${root.monitor.height}@${Math.round(root.monitor.refreshRate)}` + : "" // The confirmation sits above everything, because while it is counting down // it is the only thing that matters on this page. @@ -76,6 +79,7 @@ SettingsPage { id: keepButton anchors.verticalCenter: parent.verticalCenter text: "Keep" + enabled: Displays.canConfirm onClicked: Displays.confirm() } } @@ -131,7 +135,8 @@ SettingsPage { width: parent.width label: "Scale" detail: "Fractional scales that do not divide the resolution into whole pixels are rejected by the compositor, so only clean ones are offered." - options: Displays.scales.map(scale => ({ value: scale, label: scale.toFixed(2) + "×" })) + options: Displays.scalesForMode(root.currentMode) + .map(scale => ({ value: scale, label: scale.toFixed(2) + "×" })) current: root.monitor ? root.monitor.scale : 1 enabled: !Displays.awaitingConfirmation && !Displays.busy onPicked: value => root.applyWith({ scale: value }) @@ -167,11 +172,14 @@ SettingsPage { function applyWith(change: var): void { if (!root.monitor) return; - const current = `${root.monitor.width}x${root.monitor.height}@${Math.round(root.monitor.refreshRate)}`; + const mode = change.mode ?? root.currentMode; + const requestedScale = change.scale ?? root.monitor.scale; Displays.apply( root.monitor.name, - change.mode ?? current, - change.scale ?? root.monitor.scale, + mode, + Displays.isScaleClean(mode, requestedScale) + ? requestedScale + : Displays.nearestCleanScale(mode, requestedScale), change.transform ?? root.monitor.transform); } } diff --git a/config/dot/quickshell/modules/settings/SettingsPage.qml b/config/dot/quickshell/modules/settings/SettingsPage.qml index 1336928..5ba1a69 100644 --- a/config/dot/quickshell/modules/settings/SettingsPage.qml +++ b/config/dot/quickshell/modules/settings/SettingsPage.qml @@ -28,11 +28,31 @@ Item { // scrolls -- the Appearance page pins its live preview here. property Component header: null + Loader { + id: pinnedHeader + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.leftMargin: 34 + anchors.rightMargin: 34 + anchors.topMargin: 30 + active: root.header !== null + sourceComponent: root.header + z: 1 + } + Flickable { - anchors.fill: parent + id: pageScroll + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: pinnedHeader.active ? pinnedHeader.bottom : parent.top + anchors.bottom: parent.bottom + anchors.topMargin: pinnedHeader.active ? 16 : 0 clip: true contentWidth: width - contentHeight: layout.implicitHeight + 64 + contentHeight: layout.implicitHeight + (pinnedHeader.active ? 34 : 64) boundsBehavior: Flickable.StopAtBounds Column { @@ -40,15 +60,9 @@ Item { width: parent.width - 68 x: 34 - y: 30 + y: pinnedHeader.active ? 0 : 30 spacing: 16 - Loader { - width: parent.width - active: root.header !== null - sourceComponent: root.header - } - Text { width: parent.width visible: root.title !== "" diff --git a/config/dot/quickshell/services/Displays.qml b/config/dot/quickshell/services/Displays.qml index f8f0efc..217f655 100644 --- a/config/dot/quickshell/services/Displays.qml +++ b/config/dot/quickshell/services/Displays.qml @@ -33,10 +33,16 @@ Singleton { // Set while a change is applied but not yet confirmed. property string pendingOutput: "" property var pendingPrevious: null + property var pendingRequested: null + property bool pendingVerified: false + property bool revertQueued: false property int secondsLeft: 0 readonly property bool awaitingConfirmation: root.pendingOutput !== "" - readonly property bool busy: query.running || applyRun.running + readonly property bool canConfirm: root.awaitingConfirmation + && root.pendingVerified + && !root.busy + readonly property bool busy: query.running || applyRun.running || revertRun.running readonly property int confirmSeconds: 15 @@ -66,7 +72,29 @@ Singleton { Process { id: applyRun - onExited: (exitCode, exitStatus) => root.refresh() + onExited: (exitCode, exitStatus) => { + if (!root.awaitingConfirmation) + return; + if (root.revertQueued) { + root.performRevert(); + return; + } + if (exitCode !== 0) { + root.revertWithMessage("The display rejected that change and Panama restored the previous setting."); + return; + } + verifyTimer.attempts = 0; + verifyTimer.restart(); + } + } + + Process { + id: revertRun + onExited: (exitCode, exitStatus) => { + if (exitCode !== 0) + root.lastError = "The previous display setting could not be restored automatically."; + root.refresh(); + } } Component.onCompleted: root.refresh() @@ -89,7 +117,14 @@ Singleton { transform: monitor.transform ?? 0, modes: root.normaliseModes(monitor.availableModes ?? []) })); - root.lastError = ""; + if (root.awaitingConfirmation && root.pendingRequested + && root.matchesRequest(root.monitorNamed(root.pendingOutput), root.pendingRequested)) { + root.pendingVerified = true; + verifyTimer.stop(); + root.lastError = ""; + } else if (!root.awaitingConfirmation) { + root.lastError = ""; + } } catch (error) { root.lastError = "The display list could not be read."; } @@ -129,6 +164,52 @@ Singleton { return root.monitors.find(monitor => monitor.name === name) ?? null; } + function modeParts(mode: string): var { + const match = String(mode).match(/^(\d+)x(\d+)@(\d+(?:\.\d+)?)$/); + if (!match) + return null; + return { + width: Number(match[1]), + height: Number(match[2]), + refresh: Number(match[3]) + }; + } + + function isScaleClean(mode: string, scale: real): bool { + const parts = root.modeParts(mode); + if (!parts || root.scales.indexOf(scale) < 0 || !Number.isFinite(scale) || scale <= 0) + return false; + const logicalWidth = parts.width / scale; + const logicalHeight = parts.height / scale; + return Math.abs(logicalWidth - Math.round(logicalWidth)) < 0.0001 + && Math.abs(logicalHeight - Math.round(logicalHeight)) < 0.0001; + } + + function scalesForMode(mode: string): var { + return root.scales.filter(scale => root.isScaleClean(mode, scale)); + } + + function nearestCleanScale(mode: string, preferred: real): real { + const choices = root.scalesForMode(mode); + if (choices.length === 0) + return 1.0; + return choices.reduce((best, candidate) => + Math.abs(candidate - preferred) < Math.abs(best - preferred) ? candidate : best, + choices[0]); + } + + function matchesRequest(monitor: var, requested: var): bool { + if (!monitor || !requested || monitor.name !== requested.output) + return false; + const parts = root.modeParts(requested.mode); + return !!parts + && monitor.width === parts.width + && monitor.height === parts.height + && Math.abs(monitor.refreshRate - parts.refresh) < 0.6 + && Math.abs(monitor.scale - requested.scale) < 0.001 + && monitor.transform === requested.transform; + } + // Applies immediately and starts the countdown. Nothing is stored yet: the // settings file is only written by confirm(). function apply(output: string, mode: string, scale: real, transform: int): bool { @@ -145,8 +226,8 @@ Singleton { root.lastError = "That display does not offer that mode."; return false; } - if (root.scales.indexOf(scale) < 0) { - root.lastError = "That scale is not one Panama offers."; + if (!root.isScaleClean(mode, scale)) { + root.lastError = "That scale does not divide this resolution cleanly."; return false; } if (!root.transforms.some(candidate => candidate.value === transform)) { @@ -160,8 +241,17 @@ Singleton { scale: monitor.scale, transform: monitor.transform }; + root.pendingRequested = { + output: output, + mode: mode, + scale: scale, + transform: transform + }; root.pendingOutput = output; + root.pendingVerified = false; + root.revertQueued = false; root.secondsLeft = root.confirmSeconds; + root.lastError = ""; countdown.restart(); root.push(output, mode, scale, transform); @@ -175,39 +265,68 @@ Singleton { `hl.monitor({ output = "${output}", mode = "${mode}", scale = ${scale}, transform = ${transform} })`]); } - function confirm(): void { - if (!root.awaitingConfirmation) - return; - const monitor = root.monitorNamed(root.pendingOutput); - countdown.stop(); - - if (monitor) { - const stored = DesktopPreferences.get("displays"); - const next = Object.assign({}, (stored && typeof stored === "object") ? stored : {}); - next[root.pendingOutput] = { - mode: `${monitor.width}x${monitor.height}@${Math.round(monitor.refreshRate)}`, - scale: monitor.scale, - transform: monitor.transform - }; - DesktopPreferences.set("displays", next); + function confirm(): bool { + if (!root.canConfirm || !root.matchesRequest( + root.monitorNamed(root.pendingOutput), root.pendingRequested)) { + if (root.awaitingConfirmation) + root.lastError = "Wait for the display to finish applying before keeping it."; + return false; } + const stored = DesktopPreferences.get("displays"); + const next = Object.assign({}, (stored && typeof stored === "object") ? stored : {}); + next[root.pendingOutput] = { + mode: root.pendingRequested.mode, + scale: root.pendingRequested.scale, + transform: root.pendingRequested.transform + }; + if (!DesktopPreferences.set("displays", next)) { + root.lastError = "That display setting could not be saved. Revert it and try again."; + return false; + } + + root.clearPending(); + root.lastError = ""; + return true; + } + + function clearPending(): void { + countdown.stop(); + verifyTimer.stop(); root.pendingOutput = ""; root.pendingPrevious = null; + root.pendingRequested = null; + root.pendingVerified = false; + root.revertQueued = false; root.secondsLeft = 0; - root.lastError = ""; } function revert(): void { + root.revertWithMessage(""); + } + + function revertWithMessage(message: string): void { if (!root.awaitingConfirmation) return; - const previous = root.pendingPrevious; countdown.stop(); - root.pendingOutput = ""; - root.pendingPrevious = null; - root.secondsLeft = 0; - if (previous) - root.push(previous.output, previous.mode, previous.scale, previous.transform); + verifyTimer.stop(); + root.pendingVerified = false; + if (message !== "") + root.lastError = message; + if (applyRun.running) { + root.revertQueued = true; + return; + } + root.performRevert(); + } + + function performRevert(): void { + const previous = root.pendingPrevious; + root.clearPending(); + if (previous) { + revertRun.exec(["hyprctl", "eval", + `hl.monitor({ output = "${previous.output}", mode = "${previous.mode}", scale = ${previous.scale}, transform = ${previous.transform} })`]); + } } // Clears any stored override for an output so it returns to the value @@ -226,6 +345,21 @@ Singleton { return !!(stored && typeof stored === "object" && stored[output] !== undefined); } + Timer { + id: verifyTimer + property int attempts: 0 + interval: 120 + repeat: true + onTriggered: { + attempts++; + if (attempts > 25) { + root.revertWithMessage("The display did not apply that setting, so Panama restored the previous one."); + return; + } + root.refresh(); + } + } + Timer { id: countdown interval: 1000 diff --git a/tests/quickshell/displays-contract.sh b/tests/quickshell/displays-contract.sh index 88a3d9a..57bc152 100755 --- a/tests/quickshell/displays-contract.sh +++ b/tests/quickshell/displays-contract.sh @@ -23,33 +23,144 @@ 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)" +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:' \ + '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' + +# 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="" -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 +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.6 + 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" } -trap restore EXIT + +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="" @@ -69,6 +180,9 @@ 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_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")" @@ -85,6 +199,7 @@ mode scale transform output +dirtyScale KINDS # The display must not have moved for any of those. @@ -98,16 +213,17 @@ target_scale=$(awk -v s="$original_scale" 'BEGIN { print (s == 1.25) ? 1.5 : 1.2 applied=false for _ in $(seq 1 30); do - [[ "$(hyprctl -j monitors | jq -r '.[0].scale')" == "$target_scale" ]] && { applied=true; break; } + [[ "$(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 [[ "$(hyprctl -j monitors | jq -r '.[0].scale')" == "$original_scale" ]]; then + if [[ "$(monitor_state | jq -r '.scale')" == "$original_scale" ]]; then reverted=true break fi @@ -119,8 +235,16 @@ done # ── 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 +[[ "$(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' @@ -134,6 +258,8 @@ 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 -restore printf 'displays contract: PASS\n'