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."; } } // "4500x3000@60.00Hz" -> 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(); } } }