From efc53435a211a551ec1f581e97384e7193a52206 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Tue, 18 Aug 2026 02:40:29 -0400 Subject: [PATCH] Verify display restoration and expose outputs --- config/dot/quickshell/displays-harness.qml | 7 +- .../modules/settings/DisplaysPage.qml | 36 ++++++- config/dot/quickshell/services/Displays.qml | 98 ++++++++++++++----- tests/quickshell/displays-contract.sh | 13 ++- 4 files changed, 124 insertions(+), 30 deletions(-) diff --git a/config/dot/quickshell/displays-harness.qml b/config/dot/quickshell/displays-harness.qml index db4331f..8f524be 100644 --- a/config/dot/quickshell/displays-harness.qml +++ b/config/dot/quickshell/displays-harness.qml @@ -16,7 +16,8 @@ ShellRoot { name: monitor ? monitor.name : "", width: monitor ? monitor.width : 0, height: monitor ? monitor.height : 0, - refresh: monitor ? Math.round(monitor.refreshRate) : 0, + refresh: monitor ? monitor.refreshRate : 0, + mode: monitor ? monitor.mode : "", scale: monitor ? monitor.scale : 0, transform: monitor ? monitor.transform : -1, modes: monitor ? monitor.modes.length : 0, @@ -31,14 +32,14 @@ ShellRoot { function applyScale(scale: real): bool { const monitor = Displays.monitors[0]; if (!monitor) return false; - const mode = monitor.width + "x" + monitor.height + "@" + Math.round(monitor.refreshRate); + const mode = monitor.mode; return Displays.apply(monitor.name, mode, scale, monitor.transform); } function applyBad(kind: string): bool { const monitor = Displays.monitors[0]; if (!monitor) return false; - const mode = monitor.width + "x" + monitor.height + "@" + Math.round(monitor.refreshRate); + const mode = monitor.mode; 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") { diff --git a/config/dot/quickshell/modules/settings/DisplaysPage.qml b/config/dot/quickshell/modules/settings/DisplaysPage.qml index df5a16a..85b8ea7 100644 --- a/config/dot/quickshell/modules/settings/DisplaysPage.qml +++ b/config/dot/quickshell/modules/settings/DisplaysPage.qml @@ -20,11 +20,24 @@ SettingsPage { title: "Displays" lede: SystemSettings.monitorDescription || "Reading the active display…" - readonly property var monitor: Displays.monitors.length > 0 ? Displays.monitors[0] : null + property string selectedOutput: "" + readonly property var monitor: Displays.monitorNamed(root.selectedOutput) + ?? (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)}` + ? root.monitor.mode : "" + function syncSelectedOutput(): void { + if (!Displays.monitorNamed(root.selectedOutput)) + root.selectedOutput = Displays.monitors.length > 0 ? Displays.monitors[0].name : ""; + } + + Component.onCompleted: root.syncSelectedOutput() + Connections { + target: Displays + function onMonitorsChanged(): void { root.syncSelectedOutput(); } + } + // The confirmation sits above everything, because while it is counting down // it is the only thing that matters on this page. header: Component { @@ -86,6 +99,25 @@ SettingsPage { } } + SettingsCard { + visible: Displays.monitors.length > 1 + title: "Connected display" + subtitle: "Choose the output whose resolution, scale, and rotation you want to adjust." + + ChoiceGrid { + width: parent.width + label: "Display" + options: Displays.monitors.map(monitor => ({ + value: monitor.name, + label: monitor.description || monitor.name + })) + current: root.monitor ? root.monitor.name : "" + enabled: !Displays.awaitingConfirmation && !Displays.busy + divider: false + onPicked: value => root.selectedOutput = value + } + } + SettingsCard { title: root.monitor ? root.monitor.name : (SystemSettings.monitorName || "Active display") subtitle: root.monitor diff --git a/config/dot/quickshell/services/Displays.qml b/config/dot/quickshell/services/Displays.qml index 1430af4..bf83475 100644 --- a/config/dot/quickshell/services/Displays.qml +++ b/config/dot/quickshell/services/Displays.qml @@ -36,6 +36,8 @@ Singleton { property var pendingRequested: null property bool pendingVerified: false property bool revertQueued: false + property var revertExpected: null + property string revertReason: "" property int secondsLeft: 0 readonly property bool awaitingConfirmation: root.pendingOutput !== "" @@ -43,6 +45,7 @@ Singleton { && root.pendingVerified && !root.busy readonly property bool busy: query.running || applyRun.running || revertRun.running + || root.revertExpected !== null readonly property int confirmSeconds: 15 @@ -91,9 +94,10 @@ Singleton { Process { id: revertRun onExited: (exitCode, exitStatus) => { - if (exitCode !== 0) - root.lastError = "The previous display setting could not be restored automatically."; - root.refresh(); + // Exit status is advisory only. Hyprland's Lua bridge can report + // success without applying a value, so exact readback decides. + revertVerifyTimer.attempts = 0; + revertVerifyTimer.restart(); } } @@ -107,22 +111,42 @@ Singleton { function parse(text: string): void { try { const raw = JSON.parse(text); - root.monitors = raw.map(monitor => ({ - name: monitor.name ?? "", - description: monitor.description ?? monitor.model ?? "Display", - width: monitor.width ?? 0, - height: monitor.height ?? 0, - refreshRate: monitor.refreshRate ?? 0, - scale: monitor.scale ?? 1, - transform: monitor.transform ?? 0, - modes: root.normaliseModes(monitor.availableModes ?? []) - })); + root.monitors = raw.map(monitor => { + const modes = root.normaliseModes(monitor.availableModes ?? []); + const width = monitor.width ?? 0; + const height = monitor.height ?? 0; + const refreshRate = monitor.refreshRate ?? 0; + const current = modes + .filter(mode => mode.width === width && mode.height === height) + .sort((left, right) => + Math.abs(left.refresh - refreshRate) - Math.abs(right.refresh - refreshRate))[0]; + return { + name: monitor.name ?? "", + description: monitor.description ?? monitor.model ?? "Display", + width: width, + height: height, + refreshRate: refreshRate, + mode: current?.mode ?? `${width}x${height}@${refreshRate}`, + scale: monitor.scale ?? 1, + transform: monitor.transform ?? 0, + modes: modes + }; + }); if (root.awaitingConfirmation && root.pendingRequested && root.matchesRequest(root.monitorNamed(root.pendingOutput), root.pendingRequested)) { root.pendingVerified = true; verifyTimer.stop(); root.lastError = ""; - } else if (!root.awaitingConfirmation && ( + } else if (root.revertExpected + && root.matchesRequest(root.monitorNamed(root.revertExpected.output), root.revertExpected)) { + revertVerifyTimer.stop(); + root.revertExpected = null; + if (root.revertReason === "") + root.lastError = ""; + else + root.lastError = root.revertReason; + root.revertReason = ""; + } else if (!root.awaitingConfirmation && !root.revertExpected && ( root.lastError === "Could not read the connected displays." || root.lastError === "The display list could not be read.")) { root.lastError = ""; @@ -133,9 +157,9 @@ Singleton { } // "4500x3000@60.00Hz" -> a sortable record. The compositor reports the same - // resolution several times at refresh rates that differ only in rounding - // (60.00 and 59.94), which as a list of buttons is noise rather than - // choice, so equal rounded pairs collapse to one. + // resolution at distinct rates such as 60.00 and 59.94. Those identities + // remain separate because confirmation and recovery must read back the + // exact mode the user chose, even when their rounded labels look similar. function normaliseModes(raw: var): var { const seen = {}; const out = []; @@ -145,15 +169,19 @@ Singleton { continue; const width = Number(match[1]); const height = Number(match[2]); - const refresh = Math.round(Number(match[3])); - const key = `${width}x${height}@${refresh}`; + const refreshText = match[3]; + const refresh = Number(refreshText); + const roundedRefresh = Math.round(refresh); + const key = `${width}x${height}@${refreshText}`; if (seen[key]) continue; seen[key] = true; out.push({ label: `${width} × ${height}`, - refreshLabel: `${refresh} Hz`, - mode: key, + refreshLabel: Math.abs(refresh - roundedRefresh) < 0.005 + ? `${roundedRefresh} Hz` + : `${refresh.toFixed(2)} Hz`, + mode: `${width}x${height}@${refreshText}`, width: width, height: height, refresh: refresh @@ -207,7 +235,7 @@ Singleton { return !!parts && monitor.width === parts.width && monitor.height === parts.height - && Math.abs(monitor.refreshRate - parts.refresh) < 0.6 + && Math.abs(monitor.refreshRate - parts.refresh) < 0.01 && Math.abs(monitor.scale - requested.scale) < 0.001 && monitor.transform === requested.transform; } @@ -215,6 +243,10 @@ Singleton { // 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 { + if (root.busy) { + root.lastError = "Wait for the current display operation to finish."; + return false; + } if (root.awaitingConfirmation) { root.lastError = "Finish the current display change first."; return false; @@ -239,7 +271,7 @@ Singleton { root.pendingPrevious = { output: output, - mode: `${monitor.width}x${monitor.height}@${Math.round(monitor.refreshRate)}`, + mode: monitor.mode, scale: monitor.scale, transform: monitor.transform }; @@ -313,6 +345,7 @@ Singleton { countdown.stop(); verifyTimer.stop(); root.pendingVerified = false; + root.revertReason = message; if (message !== "") root.lastError = message; if (applyRun.running) { @@ -324,6 +357,7 @@ Singleton { function performRevert(): void { const previous = root.pendingPrevious; + root.revertExpected = previous; root.clearPending(); if (previous) { revertRun.exec(["hyprctl", "eval", @@ -362,6 +396,24 @@ Singleton { } } + Timer { + id: revertVerifyTimer + property int attempts: 0 + interval: 120 + repeat: true + onTriggered: { + attempts++; + if (attempts > 25) { + stop(); + root.revertExpected = null; + root.revertReason = ""; + root.lastError = "The previous display setting could not be verified. Open Displays and restore it manually."; + return; + } + root.refresh(); + } + } + Timer { id: countdown interval: 1000 diff --git a/tests/quickshell/displays-contract.sh b/tests/quickshell/displays-contract.sh index 57bc152..e897997 100755 --- a/tests/quickshell/displays-contract.sh +++ b/tests/quickshell/displays-contract.sh @@ -36,6 +36,7 @@ fail() { # Keep is unavailable until compositor readback exactly matches the request. for contract in \ 'property var pendingRequested:' \ + 'property var revertExpected:' \ 'readonly property bool canConfirm:' \ 'function matchesRequest(' \ 'function scalesForMode(' \ @@ -46,6 +47,14 @@ 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. @@ -125,7 +134,7 @@ display_is_restored() { --argjson scale "$original_scale" \ --argjson transform "$original_transform" \ '.width == $width and .height == $height - and ((.refreshRate - $refresh) | fabs) < 0.6 + and ((.refreshRate - $refresh) | fabs) < 0.01 and ((.scale - $scale) | fabs) < 0.001 and .transform == $transform' <<<"$current" >/dev/null } @@ -179,7 +188,7 @@ 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_mode="$(jq -r .mode <<<"$state")" original_width="$(jq -r .width <<<"$state")" original_height="$(jq -r .height <<<"$state")" original_refresh="$(jq -r .refresh <<<"$state")"