pragma Singleton // Display configuration: resolution, refresh rate, scale, rotation, position, // colour management, variable refresh rate, and mirroring. // // 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, // x, y, primary, currentFormat, colorPreset, bitdepth, sdrBrightness, // sdrSaturation, mirrorOf, vrr, // modes: [{ label, mode, width, height, refresh }] }] // // bitdepth, sdrBrightness and sdrSaturation are 0 when the compositor does // not report them, which is not the same as a value: 0 is outside every // valid range, so verification skips a field it cannot read rather than // treating "unknown" as a mismatch. property var monitors: [] // Quickshell.screens is the topology authority. The override is only the // isolated harness model; normal sessions always observe Quickshell. property var screenOverride: null property string lastError: "" readonly property var screenModel: Array.isArray(root.screenOverride) ? root.screenOverride : Quickshell.screens readonly property string screenSignature: root.screenModel .map(screen => typeof screen === "string" ? screen : screen.name) .filter(name => !!name) .sort() .join("|") readonly property var primaryFirstMonitors: root.monitors.slice().sort((left, right) => { if (left.primary !== right.primary) return left.primary ? -1 : 1; return left.name.localeCompare(right.name); }) // 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)" } ] // Colour management presets Hyprland accepts as `cm`. "auto" is a policy, // not a state: the compositor resolves it to a concrete preset and reports // that one back, so it is never verified against readback. readonly property var colorProfiles: [ { value: "auto", label: "Automatic" }, { value: "srgb", label: "sRGB" }, { value: "wide", label: "Wide gamut" }, { value: "hdr", label: "HDR" } ] // Per-display override of the global VRR policy. -1 means the display has // no opinion and follows misc.vrr, which is expressed by leaving `vrr` out // of the monitor rule entirely. 3 (fullscreen games only) is deliberately // absent: it belongs to the global policy, not to one display. readonly property var vrrModes: [ { value: -1, label: "Follow gaming policy" }, { value: 0, label: "Off" }, { value: 1, label: "Always on" }, { value: 2, label: "Fullscreen only" } ] readonly property var bitdepths: [8, 10] readonly property real sdrBrightnessMin: 0.8 readonly property real sdrBrightnessMax: 2.0 readonly property real sdrSaturationMin: 0.8 readonly property real sdrSaturationMax: 1.2 // 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. // // performRevert already armed the timer; restarting it here gives // the readback its full window from the moment the command actually // returned, rather than from the moment it was issued. if (!root.revertVerificationActive) return; revertVerifyTimer.ticks = 0; revertVerifyTimer.restart(); } } Component.onCompleted: root.refresh() onScreenSignatureChanged: root.reconcileTopology() // A hotplug can invalidate the unconfirmed layout while the confirmation // is visible. Read the current compositor layout first; the normal queued // rollback then filters out any output that has disappeared. function reconcileTopology(): void { root.refresh(); if (root.awaitingConfirmation) { root.revertWithMessage("A display was connected or disconnected, so Panama restored the previous setting."); return; } // Nothing in flight, so a display genuinely arrived or left. Give it // back the arrangement it was last confirmed with -- see restoreStored. root.restoreDeferrals = 0; restoreDebounce.restart(); } // How many times the pending restore has been put off because something was // in flight. Reset by every fresh hotplug and by every decision actually // acted on; see restoreStored. property int restoreDeferrals: 0 readonly property int restoreDeferralLimit: 20 // Docking and undocking should not cost you your arrangement. // // hypr/monitors.lua applies the stored per-output entries, but only when // the compositor reads its config. A monitor plugged in an hour later gets // the compositor's automatic placement instead of the position, scale and // rotation this machine was told to use, and until now the only way to get // them back was to open Settings and apply them again. // // This is deliberately NOT a confirmed transaction. applyLayout arms a // fifteen-second countdown because it is about to show you something you // might not be able to undo; this is restoring a layout you already // confirmed, on hardware you already had, so a countdown would be asking // you to re-approve your own decision every time you sat down at a desk. // // It refuses rather than guesses in two cases, because a wrong answer here // is a screen you cannot see to fix: // // * A stored entry whose mode the connected panel does not offer. This // is the same monitor name on different hardware, which happens with // DP-1 on one dock and DP-1 on another. // * A stored arrangement that leaves any output with no on-screen // position at all. // // In both cases the compositor's automatic placement stands and a toast // says so, which is recoverable. Silence would not be. // The decision, with no side effects, so it can be tested without driving // a real compositor. Returns one of: // { action: "none", reason: "settled" } nothing stored, or already correct // { action: "none", reason: "unavailable" } cannot decide yet, ask again // { action: "apply", layout } restore this // { action: "refuse" } stored arrangement does not fit // // The two "none" answers are not the same answer. "Nothing to do" is final; // "not now" is a question that has to be asked again, and a dock changes the // topology and the readback at once, so "not now" is the common case exactly // when the restore matters most. function plannedRestore(): var { if (root.busy || root.awaitingConfirmation || root.monitors.length === 0) return { action: "none", reason: "unavailable" }; const stored = DesktopPreferences.get("displays"); const persisted = stored && typeof stored === "object" ? stored : {}; // Start from what is on screen and overlay each stored entry, so an // output with nothing saved keeps the compositor's own placement. let changed = false; const layout = root.currentLayout(); for (const record of layout) { const entry = persisted[record.name]; if (!root.isPersistedLayoutEntry(entry)) continue; const parts = root.modeParts(entry.mode); if (!parts) continue; if (entry.mode !== record.mode || Math.abs(entry.scale - record.scale) >= 0.001 || entry.transform !== record.transform || (entry.x !== undefined && entry.x !== record.x) || (entry.y !== undefined && entry.y !== record.y) || root.storedFieldsDiffer(entry, record)) changed = true; record.mode = entry.mode; record.width = parts.width; record.height = parts.height; record.refreshRate = parts.refresh; record.scale = entry.scale; record.transform = entry.transform; if (entry.x !== undefined) record.x = entry.x; if (entry.y !== undefined) record.y = entry.y; record.primary = entry.primary === true; root.overlayStoredFields(record, entry); } if (!changed) return { action: "none", reason: "settled" }; // Exactly one primary, on a display that is actually here. Undocking // takes the primary away, and a layout with none is one // DisplayLayout.validate refuses outright. if (layout.filter(record => record.primary).length !== 1) { layout.forEach(record => record.primary = false); layout[0].primary = true; } // The same validator every user-initiated change goes through: it // checks the mode is one this panel offers, the scale is whole-pixel, // and the names match what is connected. A stored entry for hardware // that is no longer on this connector fails here, which is the point. const normalized = DisplayLayout.normalize(layout); if (!root.validRequestedLayout(normalized)) return { action: "refuse" }; return { action: "apply", layout: normalized }; } function restoreStored(): void { const plan = root.plannedRestore(); if (plan.action === "none") { // Deferred, not dropped: a query or an apply in flight is a "ask me // again", and dropping it meant the arrangement stayed lost until // somebody opened Settings and applied it by hand. Bounded so a // stuck operation cannot leave this rescheduling itself all session // -- twenty tries outlasts a full confirmation countdown. if (plan.reason === "unavailable" && root.restoreDeferrals < root.restoreDeferralLimit) { root.restoreDeferrals += 1; restoreDebounce.restart(); } return; } root.restoreDeferrals = 0; if (plan.action === "refuse") { StatusEvents.publish({ key: "display-restore", icon: "video-display-symbolic", title: "Kept the automatic display arrangement", detail: "The saved arrangement does not fit the displays connected now", tone: "warn", priority: StatusEvents.importantPriority, actionId: "open-settings", actionData: "displays" }); return; } root.pushLayout(plan.layout, applyRun); } // Displays announce themselves one at a time: plugging in a dock produces // several signature changes in quick succession, and applying a layout to // each intermediate topology would fight the compositor as it settles. Timer { id: restoreDebounce interval: 1200 onTriggered: root.restoreStored() } 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.normalizeModes(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 ?? "", bitdepth: root.formatBitdepth(monitor.currentFormat ?? ""), sdrBrightness: Number.isFinite(monitor.sdrBrightness) ? monitor.sdrBrightness : 0, sdrSaturation: Number.isFinite(monitor.sdrSaturation) ? monitor.sdrSaturation : 0, // Hyprland reports "none" for an output that is not mirroring. mirrorOf: (monitor.mirrorOf ?? "none") === "none" ? "" : String(monitor.mirrorOf), 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."; } } // "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 normalizeModes(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; } // The framebuffer format is the only honest report of the bit depth in // effect: asking for 10-bit and getting it are different things, and a // panel that cannot carry the link rate quietly stays at 8. // // Matched on the channel widths rather than on the two names Panama happens // to have seen. The channel order is the compositor's business -- XRGB, // XBGR and ARGB all carry ten bits per channel in 2101010 and eight in 8888 // -- and pinning the whole string reported an output as "unknown" for the // one part of it that says nothing about depth. Anything else is still read // as unknown, which is never treated as a mismatch. function formatBitdepth(format: string): int { const text = String(format); if (/2101010$/.test(text)) return 10; if (/8888$/.test(text)) return 8; return 0; } function persistedDisplays(): var { const stored = DesktopPreferences.get("displays"); return stored && typeof stored === "object" ? stored : {}; } // The stored entry for an output, or null when nothing valid is stored. function savedEntry(output: string): var { const entry = root.persistedDisplays()[output]; return root.isPersistedLayoutEntry(entry) ? entry : null; } // Whether a stored entry asks for something the live record does not // already have. A field the entry does not carry is not a difference: it // predates that field, and the compositor's current value stands. function storedFieldsDiffer(entry: var, record: var): bool { return (entry.vrrMode !== undefined && entry.vrrMode !== record.vrrMode) || (entry.colorProfile !== undefined && entry.colorProfile !== record.colorProfile) || (entry.bitdepth !== undefined && entry.bitdepth !== record.bitdepth) || (entry.mirrorOf !== undefined && entry.mirrorOf !== record.mirrorOf) || (entry.sdrBrightness !== undefined && Math.abs(entry.sdrBrightness - record.sdrBrightness) >= 0.001) || (entry.sdrSaturation !== undefined && Math.abs(entry.sdrSaturation - record.sdrSaturation) >= 0.001); } function overlayStoredFields(record: var, entry: var): void { for (const field of ["vrrMode", "colorProfile", "bitdepth", "sdrBrightness", "sdrSaturation", "mirrorOf"]) { if (entry[field] !== undefined) record[field] = entry[field]; } } // Everything past `primary` is optional so that arrangements stored before // the colour and mirror fields existed still load. Present but invalid is // not optional: a half-written record is one Panama refuses rather than // guesses at, exactly as it treats a half-written position. 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" && (entry.vrrMode === undefined || DisplayLayout.validVrrMode(entry.vrrMode)) && (entry.colorProfile === undefined || DisplayLayout.validColorProfile(entry.colorProfile)) && (entry.bitdepth === undefined || DisplayLayout.validBitdepth(entry.bitdepth)) && (entry.sdrBrightness === undefined || DisplayLayout.validSdrBrightness(entry.sdrBrightness)) && (entry.sdrSaturation === undefined || DisplayLayout.validSdrSaturation(entry.sdrSaturation)) && (entry.mirrorOf === undefined || (typeof entry.mirrorOf === "string" && (entry.mirrorOf === "" || /^[A-Za-z0-9_.-]+$/.test(entry.mirrorOf)))); } 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]); } // The complete state of every connected display, read from the compositor. // // Every field is read live, because a Hyprland monitor rule replaces the // previous rule for that output wholesale: a change to one field that did // not carry the others would drop them back to compositor defaults. That is // the clobber this reads against. // // vrrMode is the exception. The compositor's `vrr` readback is whether // variable refresh is active right now, not which policy was configured, so // it comes from the stored record and defaults to -1 (follow the global // policy). Reverting to a record with -1 is still correct: the rule pushed // for it carries no `vrr` key, and the display falls back to misc.vrr. function currentLayout(): var { return root.monitors.map(monitor => { const saved = root.savedEntry(monitor.name); const live = DisplayLayout.validColorProfile(monitor.colorPreset) ? monitor.colorPreset : "auto"; const record = { 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, vrrMode: saved && DisplayLayout.validVrrMode(saved.vrrMode) ? saved.vrrMode : -1, // A display set to "auto" reads back as the preset auto chose. // Keeping the stored policy stops one apply from pinning the // display to whatever automatic happened to pick today. colorProfile: saved && saved.colorProfile === "auto" ? "auto" : live, sdrBrightness: DisplayLayout.validSdrBrightness(monitor.sdrBrightness) ? monitor.sdrBrightness : 1.0, sdrSaturation: DisplayLayout.validSdrSaturation(monitor.sdrSaturation) ? monitor.sdrSaturation : 1.0, mirrorOf: typeof monitor.mirrorOf === "string" ? monitor.mirrorOf : "" }; // The bit depth is the one field with no honest default. A // framebuffer format Panama cannot read means the depth is unknown, // and filling in 8 turns that ignorance into a request: monitorRule // would write `bitdepth = 8` at a panel that may well be running at // 10, and confirm() would store the guess as though it were read. // Left off instead, which every consumer already understands as "no // opinion" -- the rule omits the key, the compositor keeps what it // has, and isPersistedLayoutEntry accepts an entry without one. if (monitor.bitdepth !== 0) record.bitdepth = monitor.bitdepth; else if (saved && DisplayLayout.validBitdepth(saved.bitdepth)) record.bitdepth = saved.bitdepth; return record; }); } 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) return false; const mirrorOf = typeof requested.mirrorOf === "string" ? requested.mirrorOf : ""; if (root.readbackMirror(monitor) !== mirrorOf) return false; // A mirror is placed by the compositor on top of what it copies, so // its coordinates are not ours to assert. Every other display still // has to land exactly where it was asked to. if (mirrorOf === "" && (monitor.x !== requested.x || monitor.y !== requested.y)) return false; if (!root.matchesColor(monitor, requested)) return false; } return true; } // Readback accessors. They take either a parsed record from root.monitors // or a raw `hyprctl -j monitors` object, because verification is also // exercised against captured compositor output, and a comparison that only // understood one of the two shapes would pass for the wrong reason. function readbackMirror(monitor: var): string { const value = typeof monitor.mirrorOf === "string" ? monitor.mirrorOf : ""; return value === "none" ? "" : value; } function readbackPreset(monitor: var): string { if (typeof monitor.colorPreset === "string" && monitor.colorPreset !== "") return monitor.colorPreset; return typeof monitor.colorManagementPreset === "string" ? monitor.colorManagementPreset : ""; } function readbackBitdepth(monitor: var): int { if (Number.isInteger(monitor.bitdepth)) return monitor.bitdepth; return root.formatBitdepth(String(monitor.currentFormat ?? "")); } // The colour half of the readback comparison. Each field is asserted only // where the compositor reports something to compare against; vrrMode is // absent by design, since `vrr` reads back live state rather than policy. function matchesColor(monitor: var, requested: var): bool { // "auto" is resolved by the compositor into srgb or wide before it is // reported, so there is no value it could equal. It is applied, and the // preset it resolved to is what the page shows. const preset = root.readbackPreset(monitor); if (requested.colorProfile !== undefined && requested.colorProfile !== "auto" && preset !== "" && preset !== requested.colorProfile) return false; const bitdepth = root.readbackBitdepth(monitor); if (requested.bitdepth !== undefined && bitdepth !== 0 && bitdepth !== requested.bitdepth) return false; if (requested.sdrBrightness !== undefined && DisplayLayout.validSdrBrightness(monitor.sdrBrightness) && Math.abs(monitor.sdrBrightness - requested.sdrBrightness) >= 0.01) return false; if (requested.sdrSaturation !== undefined && DisplayLayout.validSdrSaturation(monitor.sdrSaturation) && Math.abs(monitor.sdrSaturation - requested.sdrSaturation) >= 0.01) 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); const mirrorOf = typeof record.mirrorOf === "string" ? record.mirrorOf : ""; 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) // DisplayLayout.validate already refuses chains and a mirroring // primary; this is the connected-hardware half of the same rule. && (mirrorOf === "" || !!root.monitorNamed(mirrorOf)); }); } // One-field controls remain callers of the complete-layout transaction. // Their edit is cloned into the current layout so every output's position, // colour and mirror state participates in apply, verification, and // rollback. `partial` carries only the fields being changed; anything it // leaves out keeps the value currentLayout just read from the compositor. function applyRecord(output: string, partial: var): bool { const layout = root.currentLayout(); const record = layout.find(candidate => candidate.name === output); if (!record) { root.lastError = "That display is not connected."; return false; } const changes = (partial && typeof partial === "object") ? partial : {}; if (changes.mode !== undefined) { const parts = root.modeParts(changes.mode); if (!parts) { root.lastError = "That display does not offer that mode."; return false; } record.mode = changes.mode; record.width = parts.width; record.height = parts.height; record.refreshRate = parts.refresh; } for (const field of ["scale", "transform", "vrrMode", "colorProfile", "bitdepth", "sdrBrightness", "sdrSaturation", "mirrorOf"]) { if (changes[field] !== undefined) record[field] = changes[field]; } // The arrangement is anchored on the primary display, and a mirror has // no position of its own to anchor to. Said plainly here rather than // left to the layout validator's one generic message. if (record.primary === true && typeof record.mirrorOf === "string" && record.mirrorOf !== "") { root.lastError = "The primary display cannot mirror another display. Make a different display primary first."; return false; } return root.applyLayout(layout); } // The original four-field entry point, kept so existing callers and the // harness keep working. function apply(output: string, mode: string, scale: real, transform: int): bool { return root.applyRecord(output, { mode: mode, scale: scale, transform: transform }); } // 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(); const target = layout.find(record => record.name === output); if (!target) { root.lastError = "That display is not connected."; return false; } // Refused rather than silently un-mirrored: the arrangement is anchored // on the primary, and a mirror has no position of its own. Turning the // mirror off is a change to what is on screen, and the user makes it. if (typeof target.mirrorOf === "string" && target.mirrorOf !== "") { root.lastError = "A mirrored display cannot be the primary. Set it back to an extended display first."; return false; } for (const record of layout) record.primary = record.name === output; return root.applyLayout(DisplayLayout.normalize(layout)); } // A monitor rule replaces the previous rule for that output entirely, so // every field the record knows is emitted every time. Omission is not // "leave it alone", it is "go back to the compositor's default", which is // exactly what the omission rules below rely on: // // * vrr is left out for vrrMode -1, which returns the display to the // global misc.vrr policy. Emitting the key at all IS the override. // * mirror is left out unless the record names a target. // * sdrbrightness / sdrsaturation are left out at their neutral 1.0. // Naming the neutral value pins the display to it, which is not what // "no opinion" means. // * position is "auto" for a mirror, whose place is the compositor's to // choose and ours to read back, never to ask for. function monitorRule(record: var): string { const mirrorOf = typeof record.mirrorOf === "string" ? record.mirrorOf : ""; const fields = [ `output = "${record.name}"`, `mode = "${record.mode}"`, mirrorOf === "" ? `position = "${record.x}x${record.y}"` : `position = "auto"`, `scale = ${record.scale}`, `transform = ${record.transform}` ]; if (mirrorOf !== "") fields.push(`mirror = "${mirrorOf}"`); if (DisplayLayout.validVrrMode(record.vrrMode) && record.vrrMode >= 0) fields.push(`vrr = ${record.vrrMode}`); if (DisplayLayout.validBitdepth(record.bitdepth)) fields.push(`bitdepth = ${record.bitdepth}`); if (DisplayLayout.validColorProfile(record.colorProfile)) fields.push(`cm = "${record.colorProfile}"`); if (DisplayLayout.validSdrBrightness(record.sdrBrightness) && Math.abs(record.sdrBrightness - 1.0) >= 0.001) fields.push(`sdrbrightness = ${record.sdrBrightness}`); if (DisplayLayout.validSdrSaturation(record.sdrSaturation) && Math.abs(record.sdrSaturation - 1.0) >= 0.001) fields.push(`sdrsaturation = ${record.sdrSaturation}`); return `hl.monitor({ ${fields.join(", ")} })`; } function pushLayout(layout: var, runner: var): void { const payload = layout.map(record => root.monitorRule(record)).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) { const entry = { mode: record.mode, scale: record.scale, transform: record.transform, x: record.x, y: record.y, primary: record.primary, vrrMode: record.vrrMode, colorProfile: record.colorProfile, sdrBrightness: record.sdrBrightness, sdrSaturation: record.sdrSaturation, mirrorOf: record.mirrorOf }; // Absent rather than guessed, as currentLayout leaves it. A stored // 8 that nobody read is one the next start would push at the display // as a request. if (DisplayLayout.validBitdepth(record.bitdepth)) entry.bitdepth = record.bitdepth; next[record.name] = entry; } 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)); // A mirror whose target was unplugged has nothing left to copy, and a // rule pointing at a missing output is one the compositor ignores // silently. Give it back its own picture instead. for (const record of previous) { if (typeof record.mirrorOf === "string" && record.mirrorOf !== "" && !connected[record.mirrorOf]) record.mirrorOf = ""; } 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) { // Armed here, before the push, and not only from revertRun.onExited. // A hyprctl that fails to start never emits `exited`, and the // verification timer is the only thing that ever clears // revertExpectedLayout -- which `busy` counts. So the one failure // that most needs a way out used to latch the whole Displays page // busy for the rest of the session, with no error to say why. root.revertVerificationActive = true; revertVerifyTimer.ticks = 0; revertVerifyTimer.restart(); 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(); } } }