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

1075 lines
48 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, rotation, position,
// color 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)" }
]
// Color 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.";
}
}
// "[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 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 color 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 color 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,
// color 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();
}
}
}