Author SHA1 Message Date
Gabriel Brown c2cd79b547 Serialize display recovery checks 2026-08-18 02:55:43 -04:00
Gabriel Brown 0a18290137 Close display verification races 2026-08-18 02:55:43 -04:00
Gabriel Brown efc53435a2 Verify display restoration and expose outputs 2026-08-18 02:55:43 -04:00
Gabriel Brown 67674d297e Polish display confirmation state 2026-08-18 02:55:43 -04:00
Gabriel Brown 2d4b126f14 Harden display apply and recovery 2026-08-18 02:55:43 -04:00
Gabriel Brown 5671324eb2 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
2026-08-18 02:55:43 -04:00
10 changed files with 1411 additions and 71 deletions
+97 -2
View File
@@ -17,11 +17,88 @@
-- 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", {})
if type(displays) ~= "table" then
displays = {}
end
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 nil
end
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 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 = "4500x3000@60",
mode = dp2 and dp2.mode or shipped_mode,
position = "0x0",
scale = 1.5,
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
@@ -32,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 = "",
@@ -508,6 +508,19 @@ Singleton {
detail: "Shortcuts you have moved from their shipped chord"
},
// ── Display configuration ───────────────────────────────────────────
// { "<output>": { 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",
@@ -0,0 +1,75 @@
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 ? monitor.refreshRate : 0,
mode: monitor ? monitor.mode : "",
scale: monitor ? monitor.scale : 0,
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
});
}
function applyScale(scale: real): bool {
const monitor = Displays.monitors[0];
if (!monitor) return false;
const mode = monitor.mode;
return Displays.apply(monitor.name, mode, scale, monitor.transform);
}
function refreshIdentityFixture(): string {
const modes = Displays.normaliseModes([
"[email protected]",
"[email protected]"
]);
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;
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") {
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(): bool { return 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(); }
}
}
@@ -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)
}
}
@@ -0,0 +1,120 @@
// The resolution list for one display.
//
// Grouped by resolution with refresh rates beside it, rather than a flat list
// of "[email protected]" 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: resolution.isCurrent
&& Displays.modeIsCurrent(root.monitor, rate.modelData)
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,
Displays.nearestCleanScale(rate.modelData.mode, root.monitor.scale),
root.monitor.transform)
}
}
}
}
}
}
}
@@ -1,81 +1,219 @@
// 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…"
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 }
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.mode
: ""
function syncSelectedOutput(): void {
if (!Displays.monitorNamed(root.selectedOutput))
root.selectedOutput = Displays.monitors.length > 0 ? Displays.monitors[0].name : "";
}
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) }
}
}
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
}
}
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 {
Rectangle {
width: parent.width
height: warningText.implicitHeight + 30
visible: Displays.awaitingConfirmation
implicitHeight: visible ? confirmRow.implicitHeight + 28 : 0
radius: Theme.cardRadius
color: Theme.alpha(Theme.warn, 0.085)
color: Theme.mix(Theme.bgPanel, Theme.warn, 0.12)
border.width: 1
border.color: Theme.alpha(Theme.warn, 0.22)
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 {
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)
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"
enabled: Displays.canConfirm
onClicked: Displays.confirm()
}
}
}
}
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
? `${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: root.monitor
? `${root.monitor.colorPreset || "standard"} · ${root.monitor.currentFormat || "detecting format"}`
: "Detecting"
}
TextRow {
label: "Variable refresh"
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 {
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.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 })
}
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: "Applied immediately and restored when Panama starts."
ToggleRow { setting: "autoHdr" }
ChoiceRow { setting: "vrrPolicy" }
ChoiceRow { setting: "directScanoutPolicy"; divider: false }
}
SettingsCard {
visible: Displays.lastError !== ""
title: "Display problem"
subtitle: Displays.lastError
}
// Applies a change to one field, keeping the others at what is in effect.
function applyWith(change: var): void {
if (!root.monitor)
return;
const mode = change.mode ?? root.currentMode;
const requestedScale = change.scale ?? root.monitor.scale;
Displays.apply(
root.monitor.name,
mode,
Displays.isScaleClean(mode, requestedScale)
? requestedScale
: Displays.nearestCleanScale(mode, requestedScale),
change.transform ?? root.monitor.transform);
}
}
@@ -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.implicitHeight > 0 ? pinnedHeader.bottom : parent.top
anchors.bottom: parent.bottom
anchors.topMargin: pinnedHeader.implicitHeight > 0 ? 16 : 0
clip: true
contentWidth: width
contentHeight: layout.implicitHeight + 64
contentHeight: layout.implicitHeight + (pinnedHeader.implicitHeight > 0 ? 34 : 64)
boundsBehavior: Flickable.StopAtBounds
Column {
@@ -40,15 +60,9 @@ Item {
width: parent.width - 68
x: 34
y: 30
y: pinnedHeader.implicitHeight > 0 ? 0 : 30
spacing: 16
Loader {
width: parent.width
active: root.header !== null
sourceComponent: root.header
}
Text {
width: parent.width
visible: root.title !== ""
@@ -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
+465
View File
@@ -0,0 +1,465 @@
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 var pendingRequested: null
property bool pendingVerified: false
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 !== ""
readonly property bool canConfirm: root.awaitingConfirmation
&& root.pendingVerified
&& !root.busy
readonly property bool busy: query.running || applyRun.running || revertRun.running
|| root.revertExpected !== null
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
property int generation: 0
command: ["hyprctl", "-j", "monitors"]
stdout: StdioCollector {
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();
}
}
Process {
id: applyRun
onExited: (exitCode, exitStatus) => {
if (!root.awaitingConfirmation)
return;
if (root.revertQueued) {
if (!query.running)
root.performRevert();
return;
}
if (exitCode !== 0) {
root.revertWithMessage("The display rejected that change and Panama restored the previous setting.");
return;
}
verifyTimer.attempts = 0;
verifyTimer.ticks = 0;
verifyTimer.restart();
}
}
Process {
id: revertRun
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.ticks = 0;
revertVerifyTimer.restart();
}
}
Component.onCompleted: root.refresh()
function refresh(): bool {
if (!query.running) {
query.generation = root.operationGeneration;
query.running = true;
return true;
}
return false;
}
function parse(text: string, generation: int): void {
try {
const raw = JSON.parse(text);
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,
currentFormat: monitor.currentFormat ?? "",
colorPreset: monitor.colorManagementPreset ?? "",
vrr: monitor.vrr === true,
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.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 = "";
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 = "";
}
} catch (error) {
root.lastError = "The display list could not be read.";
}
}
// "[email protected]" -> a sortable record. The compositor reports the same
// 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 = [];
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 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: Math.abs(refresh - roundedRefresh) < 0.005
? `${roundedRefresh} Hz`
: `${refresh.toFixed(2)} Hz`,
mode: `${width}x${height}@${refreshText}`,
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;
}
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.01
&& Math.abs(monitor.scale - requested.scale) < 0.001
&& 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 {
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;
}
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.isScaleClean(mode, scale)) {
root.lastError = "That scale does not divide this resolution cleanly.";
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.mode,
scale: monitor.scale,
transform: monitor.transform
};
root.operationGeneration++;
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);
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(): 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;
}
function revert(): void {
root.revertWithMessage("");
}
function revertWithMessage(message: string): void {
if (!root.awaitingConfirmation)
return;
countdown.stop();
verifyTimer.stop();
root.pendingVerified = false;
root.revertReason = message;
if (message !== "")
root.lastError = message;
if (applyRun.running || query.running) {
root.revertQueued = true;
return;
}
root.performRevert();
}
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",
`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
// 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: verifyTimer
property int attempts: 0
property int ticks: 0
interval: 120
repeat: true
onTriggered: {
ticks++;
if (ticks > 50) {
root.revertWithMessage("The display did not apply that setting, so Panama restored the previous one.");
return;
}
if (root.refresh())
attempts++;
}
}
Timer {
id: revertVerifyTimer
property int attempts: 0
property int ticks: 0
interval: 120
repeat: true
onTriggered: {
ticks++;
if (ticks > 50) {
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.";
return;
}
if (root.refresh())
attempts++;
}
}
Timer {
id: countdown
interval: 1000
repeat: true
onTriggered: {
root.secondsLeft -= 1;
if (root.secondsLeft <= 0)
root.revert();
}
}
}
+300
View File
@@ -0,0 +1,300 @@
#!/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"
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:' \
'property var revertExpected:' \
'property bool revertVerificationActive:' \
'property int revertGeneration:' \
'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'
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.
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=""
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.01
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"
}
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=""
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 }')"
refresh_fixture="$(run ipc call displays-test refreshIdentityFixture)"
jq -e '
.count == 2
and .modes == ["[email protected]", "[email protected]"]
and .selected == ["[email protected]"]
' <<<"$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
done
state="$(status)"
monitor_name="$(jq -r .name <<<"$state")"
[[ -n "$monitor_name" ]] || fail "no display was detected: $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")"
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
dirtyScale
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 immediate Revert may race both the apply process and its first readback.
# It must queue until both are clear, then verify the original generation.
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 'the immediate-revert fixture could not apply'
run ipc call displays-test revertChange >/dev/null
immediate_reverted=false
for _ in $(seq 1 60); do
if display_is_restored && [[ "$(status | jq -r .awaiting)" == "false" ]]; then
immediate_reverted=true
break
fi
sleep 0.2
done
[[ "$immediate_reverted" == true ]] \
|| fail 'an immediate Revert raced the apply/readback and did not restore the display'
# ── An unconfirmed change reverts on its own and stores nothing ──────────────
[[ "$(run ipc call displays-test applyScale "$target_scale")" == "true" ]] \
|| fail 'a valid scale change was refused'
applied=false
for _ in $(seq 1 30); do
[[ "$(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 [[ "$(monitor_state | jq -r '.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
[[ "$(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'
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'
restore_display || fail 'the final cleanup could not restore and verify the original display'
original_mode=""
stop_harness
trap - EXIT
printf 'displays contract: PASS\n'