Files
Panama/config/dot/quickshell/services/Displays.qml
T

376 lines
13 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 var pendingRequested: null
property bool pendingVerified: false
property bool revertQueued: false
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
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) => {
if (!root.awaitingConfirmation)
return;
if (root.revertQueued) {
root.performRevert();
return;
}
if (exitCode !== 0) {
root.revertWithMessage("The display rejected that change and Panama restored the previous setting.");
return;
}
verifyTimer.attempts = 0;
verifyTimer.restart();
}
}
Process {
id: revertRun
onExited: (exitCode, exitStatus) => {
if (exitCode !== 0)
root.lastError = "The previous display setting could not be restored automatically.";
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 ?? [])
}));
if (root.awaitingConfirmation && root.pendingRequested
&& root.matchesRequest(root.monitorNamed(root.pendingOutput), root.pendingRequested)) {
root.pendingVerified = true;
verifyTimer.stop();
root.lastError = "";
} else if (!root.awaitingConfirmation && (
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 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;
}
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.6
&& Math.abs(monitor.scale - requested.scale) < 0.001
&& monitor.transform === requested.transform;
}
// 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.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.width}x${monitor.height}@${Math.round(monitor.refreshRate)}`,
scale: monitor.scale,
transform: monitor.transform
};
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;
if (message !== "")
root.lastError = message;
if (applyRun.running) {
root.revertQueued = true;
return;
}
root.performRevert();
}
function performRevert(): void {
const previous = root.pendingPrevious;
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
interval: 120
repeat: true
onTriggered: {
attempts++;
if (attempts > 25) {
root.revertWithMessage("The display did not apply that setting, so Panama restored the previous one.");
return;
}
root.refresh();
}
}
Timer {
id: countdown
interval: 1000
repeat: true
onTriggered: {
root.secondsLeft -= 1;
if (root.secondsLeft <= 0)
root.revert();
}
}
}