diff --git a/config/dot/quickshell/displays-harness.qml b/config/dot/quickshell/displays-harness.qml index 8f524be..df12479 100644 --- a/config/dot/quickshell/displays-harness.qml +++ b/config/dot/quickshell/displays-harness.qml @@ -36,6 +36,19 @@ ShellRoot { return Displays.apply(monitor.name, mode, scale, monitor.transform); } + function refreshIdentityFixture(): string { + const modes = Displays.normaliseModes([ + "1920x1080@59.94Hz", + "1920x1080@60.00Hz" + ]); + const monitor = { width: 1920, height: 1080, refreshRate: 59.94 }; + return JSON.stringify({ + count: modes.length, + modes: modes.map(mode => mode.mode), + selected: modes.filter(mode => Displays.modeIsCurrent(monitor, mode)).map(mode => mode.mode) + }); + } + function applyBad(kind: string): bool { const monitor = Displays.monitors[0]; if (!monitor) return false; diff --git a/config/dot/quickshell/modules/settings/DisplayModePicker.qml b/config/dot/quickshell/modules/settings/DisplayModePicker.qml index ce4f5ed..6bef329 100644 --- a/config/dot/quickshell/modules/settings/DisplayModePicker.qml +++ b/config/dot/quickshell/modules/settings/DisplayModePicker.qml @@ -64,9 +64,8 @@ Column { required property var modelData - readonly property bool selected: root.monitor - && resolution.isCurrent - && Math.round(root.monitor.refreshRate) === rate.modelData.refresh + readonly property bool selected: resolution.isCurrent + && Displays.modeIsCurrent(root.monitor, rate.modelData) implicitWidth: Math.max(74, rateCaption.implicitWidth + 22) implicitHeight: 30 diff --git a/config/dot/quickshell/modules/settings/DisplaysPage.qml b/config/dot/quickshell/modules/settings/DisplaysPage.qml index 85b8ea7..a47a660 100644 --- a/config/dot/quickshell/modules/settings/DisplaysPage.qml +++ b/config/dot/quickshell/modules/settings/DisplaysPage.qml @@ -127,14 +127,16 @@ SettingsPage { TextRow { label: "Color mode" detail: "Wide-gamut SDR at 10-bit. Full-time HDR is left to the Hyprland config: it currently breaks screenshots, OBS, and the lock screen's blurred background." - value: SystemSettings.colorPreset || "wide" + value: root.monitor + ? `${root.monitor.colorPreset || "standard"} ยท ${root.monitor.currentFormat || "detecting format"}` + : "Detecting" } TextRow { label: "Variable refresh" - detail: SystemSettings.monitorVrrActive - ? "Active for current fullscreen content" - : "Ready when game or video content requests it" - value: SystemSettings.monitorVrrActive ? "Active" : "Standby" + detail: root.monitor && root.monitor.vrr + ? "Active on this output for current fullscreen content" + : "This output is ready when game or video content requests it" + value: root.monitor && root.monitor.vrr ? "Active" : "Standby" divider: Displays.isOverridden(root.monitor ? root.monitor.name : "") } ActionRow { diff --git a/config/dot/quickshell/services/Displays.qml b/config/dot/quickshell/services/Displays.qml index bf83475..20fd834 100644 --- a/config/dot/quickshell/services/Displays.qml +++ b/config/dot/quickshell/services/Displays.qml @@ -38,6 +38,9 @@ Singleton { property bool revertQueued: false property var revertExpected: null property string revertReason: "" + property bool revertVerificationActive: false + property int operationGeneration: 0 + property int revertGeneration: -1 property int secondsLeft: 0 readonly property bool awaitingConfirmation: root.pendingOutput !== "" @@ -63,13 +66,16 @@ Singleton { Process { id: query + property int generation: 0 command: ["hyprctl", "-j", "monitors"] stdout: StdioCollector { - onStreamFinished: root.parse(this.text) + onStreamFinished: root.parse(this.text, query.generation) } onExited: (exitCode, exitStatus) => { if (exitCode !== 0) root.lastError = "Could not read the connected displays."; + if (root.revertQueued && !applyRun.running && root.awaitingConfirmation) + root.performRevert(); } } @@ -96,6 +102,7 @@ Singleton { onExited: (exitCode, exitStatus) => { // Exit status is advisory only. Hyprland's Lua bridge can report // success without applying a value, so exact readback decides. + root.revertVerificationActive = true; revertVerifyTimer.attempts = 0; revertVerifyTimer.restart(); } @@ -104,11 +111,13 @@ Singleton { Component.onCompleted: root.refresh() function refresh(): void { - if (!query.running) + if (!query.running) { + query.generation = root.operationGeneration; query.running = true; + } } - function parse(text: string): void { + function parse(text: string, generation: int): void { try { const raw = JSON.parse(text); root.monitors = raw.map(monitor => { @@ -129,6 +138,9 @@ Singleton { mode: current?.mode ?? `${width}x${height}@${refreshRate}`, scale: monitor.scale ?? 1, transform: monitor.transform ?? 0, + currentFormat: monitor.currentFormat ?? "", + colorPreset: monitor.colorManagementPreset ?? "", + vrr: monitor.vrr === true, modes: modes }; }); @@ -137,9 +149,13 @@ Singleton { root.pendingVerified = true; verifyTimer.stop(); root.lastError = ""; - } else if (root.revertExpected + } else if (root.revertVerificationActive + && generation === root.revertGeneration + && root.revertExpected && root.matchesRequest(root.monitorNamed(root.revertExpected.output), root.revertExpected)) { revertVerifyTimer.stop(); + root.revertVerificationActive = false; + root.revertGeneration = -1; root.revertExpected = null; if (root.revertReason === "") root.lastError = ""; @@ -240,6 +256,13 @@ Singleton { && monitor.transform === requested.transform; } + function modeIsCurrent(monitor: var, candidate: var): bool { + return !!monitor && !!candidate + && monitor.width === candidate.width + && monitor.height === candidate.height + && Math.abs(monitor.refreshRate - candidate.refresh) < 0.01; + } + // 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 { @@ -275,6 +298,7 @@ Singleton { scale: monitor.scale, transform: monitor.transform }; + root.operationGeneration++; root.pendingRequested = { output: output, mode: mode, @@ -348,7 +372,7 @@ Singleton { root.revertReason = message; if (message !== "") root.lastError = message; - if (applyRun.running) { + if (applyRun.running || query.running) { root.revertQueued = true; return; } @@ -357,7 +381,10 @@ Singleton { function performRevert(): void { const previous = root.pendingPrevious; + root.operationGeneration++; + root.revertGeneration = root.operationGeneration; root.revertExpected = previous; + root.revertVerificationActive = false; root.clearPending(); if (previous) { revertRun.exec(["hyprctl", "eval", @@ -405,6 +432,8 @@ Singleton { attempts++; if (attempts > 25) { stop(); + root.revertVerificationActive = false; + root.revertGeneration = -1; root.revertExpected = null; root.revertReason = ""; root.lastError = "The previous display setting could not be verified. Open Displays and restore it manually."; diff --git a/tests/quickshell/displays-contract.sh b/tests/quickshell/displays-contract.sh index e897997..ec0acd0 100755 --- a/tests/quickshell/displays-contract.sh +++ b/tests/quickshell/displays-contract.sh @@ -37,6 +37,8 @@ fail() { for contract in \ 'property var pendingRequested:' \ 'property var revertExpected:' \ + 'property bool revertVerificationActive:' \ + 'property int revertGeneration:' \ 'readonly property bool canConfirm:' \ 'function matchesRequest(' \ 'function scalesForMode(' \ @@ -180,6 +182,14 @@ 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 == ["1920x1080@60.00", "1920x1080@59.94"] + and .selected == ["1920x1080@59.94"] +' <<<"$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