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
This commit is contained in:
@@ -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.";
|
||||
}
|
||||
}
|
||||
|
||||
// "[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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user