Files
Panama/config/dot/quickshell/services/Displays.qml
T
Gabriel Brown 8cf03d4529 Remove dead shell components and fix stale docs
NotificationCenter.qml, CalendarPopup.qml, and RecordingIndicator.qml
were never instantiated anywhere -- shell.qml builds NotificationList,
DateMenu, and CaptureOverlay in their place. Verified with a repo-wide
grep before deleting; updated the two stale comments in Notifs.qml
that still pointed at NotificationCenter.

DESKTOP-PARITY.md still described Wi-Fi QR sharing as deliberately
omitted, though it was since built. The System Health colour-profile
handoff pointed at GNOME's colour panel as if it worked, but the
daemon that actually loads an ICC profile doesn't run in this session,
so it silently does nothing -- reworded the card to say so. Displays
carried two verify-timer attempt counters that were incremented but
never read anywhere.

Claude-Session: https://claude.ai/code/session_01E6TJUAh41HaP25MVHWkhRZ
2026-08-18 21:23:32 -04:00

582 lines
22 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
import "DisplayLayout.js" as DisplayLayout
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 var pendingPreviousLayout: null
property var pendingRequestedLayout: null
property bool pendingVerified: false
property bool revertQueued: false
property var revertExpectedLayout: null
property string revertReason: ""
property bool revertVerificationActive: false
property int operationGeneration: 0
property int revertGeneration: -1
property bool externalChangeBlocked: false
property int secondsLeft: 0
property bool identifying: false
readonly property bool awaitingConfirmation: root.pendingRequestedLayout !== null
readonly property bool canConfirm: root.awaitingConfirmation
&& root.pendingVerified
&& !root.busy
readonly property bool busy: query.running || applyRun.running || revertRun.running
|| root.revertExpectedLayout !== 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.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.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 identify(): void {
root.identifying = true;
identifyTimer.restart();
}
function parse(text: string, generation: int): void {
try {
const raw = JSON.parse(text);
const stored = DesktopPreferences.get("displays");
const persisted = stored && typeof stored === "object" ? stored : {};
const persistedPrimaries = raw.filter(monitor => {
const entry = persisted[monitor.name ?? ""];
return root.isPersistedLayoutEntry(entry) && entry.primary === true;
});
const origin = raw.find(monitor => monitor.x === 0 && monitor.y === 0);
const primaryName = persistedPrimaries.length === 1
? persistedPrimaries[0].name
: (origin?.name ?? raw[0]?.name ?? "");
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,
x: Number.isInteger(monitor.x) ? monitor.x : 0,
y: Number.isInteger(monitor.y) ? monitor.y : 0,
primary: monitor.name === primaryName,
currentFormat: monitor.currentFormat ?? "",
colorPreset: monitor.colorManagementPreset ?? "",
vrr: monitor.vrr === true,
modes: modes
};
});
if (root.awaitingConfirmation && root.pendingRequestedLayout
&& generation === root.operationGeneration
&& root.matchesLayout(root.monitors, root.pendingRequestedLayout)) {
root.pendingVerified = true;
verifyTimer.stop();
root.lastError = "";
} else if (root.revertVerificationActive
&& generation === root.revertGeneration
&& root.revertExpectedLayout
&& root.matchesLayout(root.monitors, root.revertExpectedLayout)) {
revertVerifyTimer.stop();
root.revertVerificationActive = false;
root.revertGeneration = -1;
root.revertExpectedLayout = null;
if (root.revertReason === "")
root.lastError = "";
else
root.lastError = root.revertReason;
root.revertReason = "";
} else if (!root.awaitingConfirmation && !root.revertExpectedLayout && (
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 isPersistedLayoutEntry(entry: var): bool {
return !!entry && typeof entry === "object"
&& root.modeParts(entry.mode) !== null
&& Number.isFinite(entry.scale) && entry.scale > 0
&& Number.isInteger(entry.transform)
&& entry.transform >= 0 && entry.transform <= 3
&& Number.isInteger(entry.x) && entry.x >= -100000 && entry.x <= 100000
&& Number.isInteger(entry.y) && entry.y >= -100000 && entry.y <= 100000
&& typeof entry.primary === "boolean";
}
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 currentLayout(): var {
return root.monitors.map(monitor => ({
name: monitor.name,
width: monitor.width,
height: monitor.height,
refreshRate: monitor.refreshRate,
mode: monitor.mode,
scale: monitor.scale,
transform: monitor.transform,
x: monitor.x,
y: monitor.y,
primary: monitor.primary === true
}));
}
function matchesLayout(monitors: var, layout: var): bool {
if (!Array.isArray(monitors) || !Array.isArray(layout)
|| monitors.length !== layout.length)
return false;
const expected = Array.from(layout).sort((a, b) => a.name.localeCompare(b.name));
const actual = Array.from(monitors).sort((a, b) => a.name.localeCompare(b.name));
for (let index = 0; index < expected.length; index++) {
const requested = expected[index];
const monitor = actual[index];
const parts = root.modeParts(requested.mode);
if (!parts || monitor.name !== requested.name
|| 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
|| monitor.x !== requested.x || monitor.y !== requested.y)
return false;
}
return true;
}
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;
}
function validRequestedLayout(layout: var): bool {
if (!DisplayLayout.validate(layout) || layout.length !== root.monitors.length)
return false;
const currentNames = root.monitors.map(monitor => monitor.name).sort();
const requestedNames = layout.map(record => record.name).sort();
if (JSON.stringify(currentNames) !== JSON.stringify(requestedNames))
return false;
return layout.every(record => {
const monitor = root.monitorNamed(record.name);
const parts = root.modeParts(record.mode);
return !!monitor && !!parts
&& record.width === parts.width && record.height === parts.height
&& monitor.modes.some(candidate => candidate.mode === record.mode)
&& root.isScaleClean(record.mode, record.scale)
&& root.transforms.some(candidate => candidate.value === record.transform);
});
}
// One-field controls remain callers of the complete-layout transaction.
// Their edit is cloned into the current layout so every output's position
// participates in apply, verification, and rollback.
function apply(output: string, mode: string, scale: real, transform: int): bool {
const layout = root.currentLayout();
const record = layout.find(candidate => candidate.name === output);
const parts = root.modeParts(mode);
if (!record || !parts) {
root.lastError = record ? "That display does not offer that mode." : "That display is not connected.";
return false;
}
record.mode = mode;
record.width = parts.width;
record.height = parts.height;
record.refreshRate = parts.refresh;
record.scale = scale;
record.transform = transform;
return root.applyLayout(layout);
}
// Applies immediately and starts the countdown. Nothing is stored yet: the
// complete connected layout is only written by confirm().
function applyLayout(layout: var, protectedOperation: bool): bool {
if (root.externalChangeBlocked && protectedOperation !== true) {
root.lastError = "Wait for Settings to finish restoring before changing a display.";
return false;
}
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 normalized = DisplayLayout.normalize(layout);
if (!root.validRequestedLayout(normalized)) {
root.lastError = "That complete display layout is not valid for the connected displays.";
return false;
}
root.pendingPreviousLayout = root.currentLayout();
root.operationGeneration++;
root.pendingRequestedLayout = normalized;
root.pendingVerified = false;
root.revertQueued = false;
root.secondsLeft = root.confirmSeconds;
root.lastError = "";
countdown.restart();
root.pushLayout(normalized, applyRun);
return true;
}
// Settings restore holds the external-change lock while it proves a
// snapshot. This narrow entry point authorizes that one transaction while
// keeping every user-facing control blocked until restore settles.
function applyProtectedLayout(layout: var): bool {
return root.applyLayout(layout, true);
}
function makePrimary(output: string): bool {
const layout = root.currentLayout();
if (!layout.some(record => record.name === output)) {
root.lastError = "That display is not connected.";
return false;
}
for (const record of layout)
record.primary = record.name === output;
return root.applyLayout(DisplayLayout.normalize(layout));
}
function pushLayout(layout: var, runner: var): void {
const payload = layout.map(record =>
`hl.monitor({ output = "${record.name}", mode = "${record.mode}", position = "${record.x}x${record.y}", scale = ${record.scale}, transform = ${record.transform} })`
).join("; ");
runner.exec(["hyprctl", "eval", payload]);
}
function confirm(): bool {
if (!root.canConfirm
|| !root.matchesLayout(root.monitors, root.pendingRequestedLayout)) {
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 : {});
for (const record of root.pendingRequestedLayout) {
next[record.name] = {
mode: record.mode,
scale: record.scale,
transform: record.transform,
x: record.x,
y: record.y,
primary: record.primary
};
}
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.pendingPreviousLayout = null;
root.pendingRequestedLayout = 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 connected = {};
for (const monitor of root.monitors)
connected[monitor.name] = true;
const previous = (root.pendingPreviousLayout || [])
.filter(record => connected[record.name])
.map(record => Object.assign({}, record));
if (previous.length > 0 && !previous.some(record => record.primary)) {
const origin = previous.find(record => record.x === 0 && record.y === 0);
(origin || previous[0]).primary = true;
}
root.operationGeneration++;
root.revertGeneration = root.operationGeneration;
root.revertExpectedLayout = previous.length > 0 ? previous : null;
root.revertVerificationActive = false;
root.clearPending();
if (previous.length > 0)
root.pushLayout(previous, revertRun);
else
root.lastError = root.revertReason;
}
// 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);
}
function verificationTimedOut(): void {
root.revertWithMessage("The display did not apply that setting, so Panama restored the previous one.");
}
function revertVerificationTimedOut(): void {
revertVerifyTimer.stop();
root.revertVerificationActive = false;
root.revertGeneration = -1;
root.revertExpectedLayout = null;
root.revertReason = "";
root.lastError = "The previous display setting could not be verified. Open Displays and restore it manually.";
}
Timer {
id: identifyTimer
interval: 3000
repeat: false
onTriggered: root.identifying = false
}
Timer {
id: verifyTimer
property int ticks: 0
interval: 120
repeat: true
onTriggered: {
ticks++;
if (ticks > 50) {
root.verificationTimedOut();
return;
}
root.refresh();
}
}
Timer {
id: revertVerifyTimer
property int ticks: 0
interval: 120
repeat: true
onTriggered: {
ticks++;
if (ticks > 50) {
root.revertVerificationTimedOut();
return;
}
root.refresh();
}
}
Timer {
id: countdown
interval: 1000
repeat: true
onTriggered: {
root.secondsLeft -= 1;
if (root.secondsLeft <= 0)
root.revert();
}
}
}