From 5671324eb2b8f36cbafb6f2be104b418269b27ec Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Tue, 18 Aug 2026 01:51:46 -0400 Subject: [PATCH] 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 --- config/dot/hypr/monitors.lua | 28 +- .../quickshell/config/PreferenceSchema.qml | 13 + config/dot/quickshell/displays-harness.qml | 56 ++++ .../modules/settings/ChoiceGrid.qml | 118 +++++++++ .../modules/settings/DisplayModePicker.qml | 121 +++++++++ .../modules/settings/DisplaysPage.qml | 216 +++++++++++----- config/dot/quickshell/modules/settings/qmldir | 2 + config/dot/quickshell/services/Displays.qml | 239 ++++++++++++++++++ tests/quickshell/displays-contract.sh | 139 ++++++++++ 9 files changed, 870 insertions(+), 62 deletions(-) create mode 100644 config/dot/quickshell/displays-harness.qml create mode 100644 config/dot/quickshell/modules/settings/ChoiceGrid.qml create mode 100644 config/dot/quickshell/modules/settings/DisplayModePicker.qml create mode 100644 config/dot/quickshell/services/Displays.qml create mode 100755 tests/quickshell/displays-contract.sh diff --git a/config/dot/hypr/monitors.lua b/config/dot/hypr/monitors.lua index cf9b809..044faea 100644 --- a/config/dot/hypr/monitors.lua +++ b/config/dot/hypr/monitors.lua @@ -17,11 +17,35 @@ -- overrides.lua) and read the notes in that file first. -- ───────────────────────────────────────────────────────────────────────────── +local prefs = require("prefs") + +-- Per-output overrides written by Panama Settings, keyed by output name: +-- { ["DP-2"] = { mode = "3840x2160@60", scale = 2, transform = 0 } } +-- +-- Only mode, scale, and transform are read. Colour management and bit depth +-- stay here, because those are the settings with a documented reason attached +-- (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", {}) + +local function override(output, field, fallback) + local entry = displays[output] + if type(entry) ~= "table" then + return fallback + end + local value = entry[field] + if value == nil or type(value) ~= type(fallback) then + return fallback + end + return value +end + hl.monitor({ output = "DP-2", - mode = "4500x3000@60", + mode = override("DP-2", "mode", "4500x3000@60"), position = "0x0", - scale = 1.5, + scale = override("DP-2", "scale", 1.5), + transform = override("DP-2", "transform", 0), -- 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 diff --git a/config/dot/quickshell/config/PreferenceSchema.qml b/config/dot/quickshell/config/PreferenceSchema.qml index 97ec98b..fd9f057 100644 --- a/config/dot/quickshell/config/PreferenceSchema.qml +++ b/config/dot/quickshell/config/PreferenceSchema.qml @@ -508,6 +508,19 @@ Singleton { detail: "Shortcuts you have moved from their shipped chord" }, + // ── Display configuration ─────────────────────────────────────────── + // { "": { mode, scale, transform } }, applied by + // hypr/monitors.lua on top of the shipped values. Colour management and + // bit depth are deliberately not here: those carry a documented + // screencopy tradeoff that a settings page cannot explain at the moment + // you would be changing it. + { + key: "displays", type: "json", def: ({}), group: "display", + internal: true, + label: "Display configuration", + detail: "Resolution, scale, and rotation per connected display" + }, + // ── Internal ──────────────────────────────────────────────────────── { key: "lastPage", type: "string", def: "home", group: "internal", diff --git a/config/dot/quickshell/displays-harness.qml b/config/dot/quickshell/displays-harness.qml new file mode 100644 index 0000000..2f4c15a --- /dev/null +++ b/config/dot/quickshell/displays-harness.qml @@ -0,0 +1,56 @@ +import Quickshell +import Quickshell.Io +import QtQuick + +import qs.config +import qs.services + +ShellRoot { + IpcHandler { + target: "displays-test" + + function status(): string { + const monitor = Displays.monitors.length > 0 ? Displays.monitors[0] : null; + return JSON.stringify({ + count: Displays.monitors.length, + name: monitor ? monitor.name : "", + width: monitor ? monitor.width : 0, + height: monitor ? monitor.height : 0, + refresh: monitor ? Math.round(monitor.refreshRate) : 0, + scale: monitor ? monitor.scale : 0, + transform: monitor ? monitor.transform : -1, + modes: monitor ? monitor.modes.length : 0, + awaiting: Displays.awaitingConfirmation, + secondsLeft: Displays.secondsLeft, + lastError: Displays.lastError, + overridden: monitor ? Displays.isOverridden(monitor.name) : false + }); + } + + 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); + 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); + 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 === "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 revertChange(): void { Displays.revert(); } + function forget(): void { + const monitor = Displays.monitors[0]; + if (monitor) Displays.forget(monitor.name); + } + function refresh(): void { Displays.refresh(); } + } +} diff --git a/config/dot/quickshell/modules/settings/ChoiceGrid.qml b/config/dot/quickshell/modules/settings/ChoiceGrid.qml new file mode 100644 index 0000000..3490536 --- /dev/null +++ b/config/dot/quickshell/modules/settings/ChoiceGrid.qml @@ -0,0 +1,118 @@ +// A row of choices that wraps, for options that do not fit a segmented control. +// +// ChoiceRow puts two or three options on one line. Scales and rotations are +// more numerous and their labels are wider, so they wrap into a grid rather +// than shrinking to illegibility on a narrow, tiled window. +// +// Unlike ChoiceRow this is not schema-bound: it reports a value and lets the +// caller decide what to do with it, because a display change has to go through +// an apply-then-confirm cycle rather than straight into the store. + +import QtQuick +import qs.config + +Column { + id: root + + property string label: "" + property string detail: "" + property var options: [] + property var current: null + property bool enabled: true + property bool divider: true + + signal picked(var value) + + spacing: 9 + bottomPadding: 12 + + Column { + width: parent.width + spacing: 3 + + Text { + width: parent.width + text: root.label + color: Theme.fg + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + font.weight: Font.Medium + } + Text { + width: parent.width + visible: root.detail !== "" + text: root.detail + color: Theme.fgDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + wrapMode: Text.WordWrap + } + } + + Flow { + width: parent.width + spacing: 7 + + Repeater { + model: root.options + + Rectangle { + id: option + + required property var modelData + + readonly property bool selected: root.current === option.modelData.value + + implicitWidth: Math.max(78, caption.implicitWidth + 26) + implicitHeight: 32 + radius: 9 + opacity: root.enabled ? 1 : 0.45 + color: option.selected ? "transparent" : Theme.alpha(Theme.fg, hover.hovered && root.enabled ? 0.11 : 0.06) + border.width: option.selected ? 1 : 0 + border.color: Theme.alpha(Theme.accent, 0.5) + + // The prism marks the selection here as everywhere else. + Rectangle { + anchors.fill: parent + radius: parent.radius + visible: option.selected + border.width: 0 + gradient: Gradient { + orientation: Gradient.Horizontal + GradientStop { position: 0.0; color: Theme.alpha(Theme.accent, 0.28) } + GradientStop { position: 1.0; color: Theme.alpha(Theme.accentSecondary, 0.28) } + } + } + + Text { + id: caption + anchors.centerIn: parent + text: option.modelData.label + color: option.selected ? Theme.fg : Theme.fgDim + font.family: Theme.fontFamily + font.features: Theme.tabularFigures + font.pixelSize: Theme.fontSize + font.weight: option.selected ? Font.DemiBold : Font.Normal + } + + HoverHandler { + id: hover + enabled: root.enabled + cursorShape: Qt.PointingHandCursor + } + + TapHandler { + enabled: root.enabled && !option.selected + onTapped: root.picked(option.modelData.value) + } + } + } + } + + Rectangle { + width: parent.width + height: 1 + visible: root.divider + color: Theme.alpha(Theme.fg, 0.065) + } +} diff --git a/config/dot/quickshell/modules/settings/DisplayModePicker.qml b/config/dot/quickshell/modules/settings/DisplayModePicker.qml new file mode 100644 index 0000000..1498b10 --- /dev/null +++ b/config/dot/quickshell/modules/settings/DisplayModePicker.qml @@ -0,0 +1,121 @@ +// The resolution list for one display. +// +// Grouped by resolution with refresh rates beside it, rather than a flat list +// of "3840x2160@60.00Hz" strings: this panel reports 35 modes, many of which +// differ only in refresh-rate rounding, and a flat list of those is a wall of +// near-identical text rather than a choice. + +import QtQuick +import qs.config +import qs.services + +Column { + id: root + + property var monitor: null + property bool enabled: true + + spacing: 0 + + readonly property var grouped: { + if (!root.monitor) + return []; + const buckets = {}; + const order = []; + for (const mode of root.monitor.modes) { + const key = mode.label; + if (!buckets[key]) { + buckets[key] = { label: key, width: mode.width, height: mode.height, rates: [] }; + order.push(key); + } + buckets[key].rates.push(mode); + } + return order.map(key => buckets[key]); + } + + Repeater { + model: root.grouped + + SettingRow { + id: resolution + + required property var modelData + required property int index + + readonly property bool isCurrent: root.monitor + && root.monitor.width === resolution.modelData.width + && root.monitor.height === resolution.modelData.height + + label: resolution.modelData.label + detail: resolution.isCurrent ? "Current resolution" : "" + controlWidth: Math.max(120, resolution.modelData.rates.length * 84) + divider: resolution.index < root.grouped.length - 1 + + Row { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 6 + + Repeater { + model: resolution.modelData.rates + + Rectangle { + id: rate + + required property var modelData + + readonly property bool selected: root.monitor + && resolution.isCurrent + && Math.round(root.monitor.refreshRate) === rate.modelData.refresh + + implicitWidth: Math.max(74, rateCaption.implicitWidth + 22) + implicitHeight: 30 + radius: 9 + opacity: root.enabled ? 1 : 0.45 + color: rate.selected ? "transparent" : Theme.alpha(Theme.fg, rateHover.hovered && root.enabled ? 0.11 : 0.06) + border.width: rate.selected ? 1 : 0 + border.color: Theme.alpha(Theme.accent, 0.5) + + Rectangle { + anchors.fill: parent + radius: parent.radius + visible: rate.selected + border.width: 0 + gradient: Gradient { + orientation: Gradient.Horizontal + GradientStop { position: 0.0; color: Theme.alpha(Theme.accent, 0.28) } + GradientStop { position: 1.0; color: Theme.alpha(Theme.accentSecondary, 0.28) } + } + } + + Text { + id: rateCaption + anchors.centerIn: parent + text: rate.modelData.refreshLabel + color: rate.selected ? Theme.fg : Theme.fgDim + font.family: Theme.fontFamily + font.features: Theme.tabularFigures + font.pixelSize: Theme.fontSizeSmall + font.weight: rate.selected ? Font.DemiBold : Font.Normal + } + + HoverHandler { + id: rateHover + enabled: root.enabled + cursorShape: Qt.PointingHandCursor + } + + TapHandler { + enabled: root.enabled && !rate.selected + onTapped: Displays.apply( + root.monitor.name, + 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 07230c7..25b0b21 100644 --- a/config/dot/quickshell/modules/settings/DisplaysPage.qml +++ b/config/dot/quickshell/modules/settings/DisplaysPage.qml @@ -1,81 +1,177 @@ +// Displays. +// +// Resolution, refresh rate, scale, and rotation, plus the gaming display +// policy that was already here. +// +// Every geometry change goes through an apply-then-confirm countdown. This is +// the one page where a wrong value can leave the screen unreadable or blank, +// and no other control in the app can undo it once that happens. Confirming is +// what writes the choice to the settings store; letting the countdown run +// leaves nothing behind. + import QtQuick import qs.config import qs.services import qs.widgets SettingsPage { + id: root + title: "Displays" lede: SystemSettings.monitorDescription || "Reading the active display…" + readonly property var monitor: Displays.monitors.length > 0 ? Displays.monitors[0] : null + + // The confirmation sits above everything, because while it is counting down + // it is the only thing that matters on this page. + header: Component { + Rectangle { + visible: Displays.awaitingConfirmation + implicitHeight: visible ? confirmRow.implicitHeight + 28 : 0 + radius: Theme.cardRadius + color: Theme.mix(Theme.bgPanel, Theme.warn, 0.12) + border.width: 1 + border.color: Theme.alpha(Theme.warn, 0.4) + + Row { + id: confirmRow + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: 16 + spacing: 14 + + Column { + width: parent.width - keepButton.width - revertButton.width - 28 + anchors.verticalCenter: parent.verticalCenter + spacing: 3 + + Text { + width: parent.width + text: "Keep this display setting?" + color: Theme.fg + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + font.weight: Font.DemiBold + } + Text { + width: parent.width + text: "Reverting in " + Displays.secondsLeft + (Displays.secondsLeft === 1 ? " second" : " seconds") + + " if you do nothing. If you cannot read this, just wait." + color: Theme.fgDim + font.family: Theme.fontFamily + font.features: Theme.tabularFigures + font.pixelSize: Theme.fontSizeSmall + wrapMode: Text.WordWrap + } + } + + SettingsButton { + id: revertButton + anchors.verticalCenter: parent.verticalCenter + text: "Revert now" + onClicked: Displays.revert() + } + SettingsButton { + id: keepButton + anchors.verticalCenter: parent.verticalCenter + text: "Keep" + onClicked: Displays.confirm() + } + } + } + } + SettingsCard { - title: SystemSettings.monitorName || "Active display" - subtitle: `${SystemSettings.monitorWidth} × ${SystemSettings.monitorHeight} at ${SystemSettings.monitorRefreshRate.toFixed(0)} Hz · ${SystemSettings.monitorScale.toFixed(1)}× scale · ${SystemSettings.monitorFormat}` - TextRow { label: "Color mode"; detail: "Wide-gamut SDR desktop at 10-bit"; value: SystemSettings.colorPreset || "wide" } - 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"; divider: false } + title: root.monitor ? root.monitor.name : (SystemSettings.monitorName || "Active display") + subtitle: root.monitor + ? `${root.monitor.description} · ${root.monitor.width} × ${root.monitor.height} at ${Math.round(root.monitor.refreshRate)} Hz · ${root.monitor.scale.toFixed(2)}× scale` + : "Reading the active display…" + + 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" + } + 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" + divider: Displays.isOverridden(root.monitor ? root.monitor.name : "") + } + ActionRow { + visible: Displays.isOverridden(root.monitor ? root.monitor.name : "") + label: "Using a custom display setting" + detail: "Forget it to go back to the resolution and scale Panama ships" + action: "Forget" + divider: false + onTriggered: Displays.forget(root.monitor.name) + } + } + + SettingsCard { + visible: root.monitor !== null + title: "Resolution" + subtitle: "Applied straight away, then reverted automatically unless you confirm." + + DisplayModePicker { + width: parent.width + monitor: root.monitor + enabled: !Displays.awaitingConfirmation && !Displays.busy + } + } + + SettingsCard { + visible: root.monitor !== null + title: "Scale and rotation" + + ChoiceGrid { + 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) + "×" })) + current: root.monitor ? root.monitor.scale : 1 + enabled: !Displays.awaitingConfirmation && !Displays.busy + onPicked: value => root.applyWith({ scale: value }) + } + + ChoiceGrid { + width: parent.width + label: "Rotation" + options: Displays.transforms + current: root.monitor ? root.monitor.transform : 0 + enabled: !Displays.awaitingConfirmation && !Displays.busy + divider: false + onPicked: value => root.applyWith({ transform: value }) + } } SettingsCard { title: "Gaming display policy" - subtitle: "These values apply immediately and are restored when Panama starts." - SettingRow { - label: "Game-aware HDR" - detail: "Enter HDR only for fullscreen content that requests it" - controlWidth: 48 - SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: SystemSettings.autoHdr; onToggled: value => SystemSettings.setAutoHdr(value) } - } - SettingRow { - label: "Content-aware VRR" - detail: "Enable variable refresh only for game and video content" - controlWidth: 48 - SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: SystemSettings.vrrPolicy === 3; onToggled: value => SystemSettings.setVrrPolicy(value ? 3 : 0) } - } - SettingRow { - label: "Direct scanout for games" - detail: "Bypass compositing only for windows classified as games" - divider: false - controlWidth: 48 - SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: SystemSettings.directScanoutPolicy === 2; onToggled: value => SystemSettings.setDirectScanoutPolicy(value ? 2 : 0) } - } + subtitle: "Applied immediately and restored when Panama starts." + + ToggleRow { setting: "autoHdr" } + ChoiceRow { setting: "vrrPolicy" } + ChoiceRow { setting: "directScanoutPolicy"; divider: false } } SettingsCard { - title: "Night Light" - SettingRow { - label: "Warm display colors" - detail: NightLight.automatic ? "Following the evening schedule" : "Manual control" - controlWidth: 48 - SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: NightLight.active; onToggled: NightLight.toggle() } - } - SettingRow { - label: "Color temperature" - detail: `${NightLight.temperature} K` - divider: false - controlWidth: 230 - ValueSlider { - anchors.fill: parent - value: (6500 - NightLight.temperature) / 4000 - icon: "weather-clear-night-symbolic" - onMoved: value => NightLight.temperature = Math.round((6500 - value * 4000) / 50) * 50 - } - } + visible: Displays.lastError !== "" + title: "Display problem" + subtitle: Displays.lastError } - Rectangle { - width: parent.width - height: warningText.implicitHeight + 30 - radius: Theme.cardRadius - color: Theme.alpha(Theme.warn, 0.085) - border.width: 1 - border.color: Theme.alpha(Theme.warn, 0.22) - Text { - id: warningText - anchors.fill: parent - anchors.margins: 15 - text: "Full-time desktop HDR stays unavailable here because the current compositor path can break screenshots, OBS, Sunshine, and lock-screen capture. Game-aware HDR keeps the desktop dependable without giving up HDR games." - color: Theme.mix(Theme.fg, Theme.warn, 0.25) - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeSmall - wrapMode: Text.WordWrap - } + // Applies a change to one field, keeping the others at what is in effect. + function applyWith(change: var): void { + if (!root.monitor) + return; + const current = `${root.monitor.width}x${root.monitor.height}@${Math.round(root.monitor.refreshRate)}`; + Displays.apply( + root.monitor.name, + change.mode ?? current, + change.scale ?? root.monitor.scale, + change.transform ?? root.monitor.transform); } } diff --git a/config/dot/quickshell/modules/settings/qmldir b/config/dot/quickshell/modules/settings/qmldir index 1305096..a3c9c33 100644 --- a/config/dot/quickshell/modules/settings/qmldir +++ b/config/dot/quickshell/modules/settings/qmldir @@ -35,3 +35,5 @@ ApplicationsPage 1.0 ApplicationsPage.qml DockPinsEditor 1.0 DockPinsEditor.qml DockAppPicker 1.0 DockAppPicker.qml ShortcutCapture 1.0 ShortcutCapture.qml +ChoiceGrid 1.0 ChoiceGrid.qml +DisplayModePicker 1.0 DisplayModePicker.qml diff --git a/config/dot/quickshell/services/Displays.qml b/config/dot/quickshell/services/Displays.qml new file mode 100644 index 0000000..f8f0efc --- /dev/null +++ b/config/dot/quickshell/services/Displays.qml @@ -0,0 +1,239 @@ +pragma Singleton + +// Display configuration: resolution, refresh rate, scale, and rotation. +// +// This is the only page in Panama Settings where a wrong value can leave you +// unable to SEE the screen well enough to undo it. A mode the display cannot +// show, or a scale that makes everything unreadable, is not recoverable through +// the same UI that caused it. +// +// So a change is never applied irreversibly. It is applied, then reverted +// automatically after a countdown unless confirmed -- the same contract every +// desktop uses for this one setting, and for the same reason. Confirming is +// what writes it to the settings store; letting the countdown run leaves +// nothing behind. +// +// Applied with `hyprctl eval` and hl.monitor{}. As everywhere else in Panama, +// success means the value was read back from the compositor and matched, never +// that a command exited zero. + +import Quickshell +import Quickshell.Io +import QtQuick +import qs.config + +Singleton { + id: root + + // [{ name, description, width, height, refreshRate, scale, transform, + // modes: [{ label, mode, width, height, refresh }] }] + property var monitors: [] + property string lastError: "" + + // Set while a change is applied but not yet confirmed. + property string pendingOutput: "" + property var pendingPrevious: null + property int secondsLeft: 0 + + readonly property bool awaitingConfirmation: root.pendingOutput !== "" + readonly property bool busy: query.running || applyRun.running + + readonly property int confirmSeconds: 15 + + readonly property var transforms: [ + { value: 0, label: "Landscape" }, + { value: 1, label: "Portrait" }, + { value: 2, label: "Landscape (flipped)" }, + { value: 3, label: "Portrait (flipped)" } + ] + + // Scales that divide this desktop's common resolutions into whole pixels. + // Hyprland rejects a fractional scale that does not, and the message it + // gives is not something to put in front of a user. + readonly property var scales: [1.0, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0] + + Process { + id: query + command: ["hyprctl", "-j", "monitors"] + stdout: StdioCollector { + onStreamFinished: root.parse(this.text) + } + onExited: (exitCode, exitStatus) => { + if (exitCode !== 0) + root.lastError = "Could not read the connected displays."; + } + } + + Process { + id: applyRun + onExited: (exitCode, exitStatus) => root.refresh() + } + + Component.onCompleted: root.refresh() + + function refresh(): void { + if (!query.running) + query.running = true; + } + + 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.lastError = ""; + } catch (error) { + root.lastError = "The display list could not be read."; + } + } + + // "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. + function normaliseModes(raw: var): var { + const seen = {}; + const out = []; + for (const entry of raw) { + const match = String(entry).match(/^(\d+)x(\d+)@([\d.]+)Hz$/); + if (!match) + continue; + const width = Number(match[1]); + const height = Number(match[2]); + const refresh = Math.round(Number(match[3])); + const key = `${width}x${height}@${refresh}`; + if (seen[key]) + continue; + seen[key] = true; + out.push({ + label: `${width} × ${height}`, + refreshLabel: `${refresh} Hz`, + mode: key, + width: width, + height: height, + refresh: refresh + }); + } + return out.sort((a, b) => (b.width * b.height) - (a.width * a.height) || b.refresh - a.refresh); + } + + function monitorNamed(name: string): var { + return root.monitors.find(monitor => monitor.name === name) ?? null; + } + + // 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.awaitingConfirmation) { + root.lastError = "Finish the current display change first."; + return false; + } + const monitor = root.monitorNamed(output); + if (!monitor) { + root.lastError = "That display is not connected."; + return false; + } + if (!monitor.modes.some(candidate => candidate.mode === mode)) { + 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."; + return false; + } + if (!root.transforms.some(candidate => candidate.value === transform)) { + root.lastError = "That rotation is not one Panama offers."; + return false; + } + + root.pendingPrevious = { + output: output, + mode: `${monitor.width}x${monitor.height}@${Math.round(monitor.refreshRate)}`, + scale: monitor.scale, + transform: monitor.transform + }; + root.pendingOutput = output; + root.secondsLeft = root.confirmSeconds; + countdown.restart(); + + root.push(output, mode, scale, transform); + return true; + } + + function push(output: string, mode: string, scale: real, transform: int): void { + // Values are validated above and the output name comes from the + // compositor's own list, so nothing user-authored reaches the payload. + applyRun.exec(["hyprctl", "eval", + `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); + } + + root.pendingOutput = ""; + root.pendingPrevious = null; + root.secondsLeft = 0; + root.lastError = ""; + } + + function revert(): 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); + } + + // Clears any stored override for an output so it returns to the value + // shipped in hypr/monitors.lua on the next start. + function forget(output: string): void { + const stored = DesktopPreferences.get("displays"); + if (!stored || typeof stored !== "object" || stored[output] === undefined) + return; + const next = Object.assign({}, stored); + delete next[output]; + DesktopPreferences.set("displays", next); + } + + function isOverridden(output: string): bool { + const stored = DesktopPreferences.get("displays"); + return !!(stored && typeof stored === "object" && stored[output] !== undefined); + } + + Timer { + id: countdown + interval: 1000 + repeat: true + onTriggered: { + root.secondsLeft -= 1; + if (root.secondsLeft <= 0) + root.revert(); + } + } +} diff --git a/tests/quickshell/displays-contract.sh b/tests/quickshell/displays-contract.sh new file mode 100755 index 0000000..88a3d9a --- /dev/null +++ b/tests/quickshell/displays-contract.sh @@ -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'