Files
Panama/config/dot/quickshell/services/Displays.qml
T
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

240 lines
8.5 KiB
QML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.";
}
}
// "[email protected]" -> 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();
}
}
}