1012 lines
44 KiB
QML
1012 lines
44 KiB
QML
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.
|
||
root.revertVerificationActive = true;
|
||
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.
|
||
restoreDebounce.restart();
|
||
}
|
||
|
||
// 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" } nothing stored, or already correct
|
||
// { action: "apply", layout } restore this
|
||
// { action: "refuse" } stored arrangement does not fit
|
||
function plannedRestore(): var {
|
||
if (root.busy || root.awaitingConfirmation || root.monitors.length === 0)
|
||
return { action: "none" };
|
||
|
||
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" };
|
||
|
||
// 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")
|
||
return;
|
||
|
||
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. Formats outside
|
||
// this map are read as "unknown", never as a mismatch.
|
||
function formatBitdepth(format: string): int {
|
||
if (format === "XRGB8888")
|
||
return 8;
|
||
if (format === "XRGB2101010")
|
||
return 10;
|
||
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";
|
||
return {
|
||
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,
|
||
bitdepth: monitor.bitdepth !== 0
|
||
? monitor.bitdepth
|
||
: (saved && DisplayLayout.validBitdepth(saved.bitdepth) ? saved.bitdepth : 8),
|
||
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 : ""
|
||
};
|
||
});
|
||
}
|
||
|
||
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) {
|
||
next[record.name] = {
|
||
mode: record.mode,
|
||
scale: record.scale,
|
||
transform: record.transform,
|
||
x: record.x,
|
||
y: record.y,
|
||
primary: record.primary,
|
||
vrrMode: record.vrrMode,
|
||
colorProfile: record.colorProfile,
|
||
bitdepth: record.bitdepth,
|
||
sdrBrightness: record.sdrBrightness,
|
||
sdrSaturation: record.sdrSaturation,
|
||
mirrorOf: record.mirrorOf
|
||
};
|
||
}
|
||
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)
|
||
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();
|
||
}
|
||
}
|
||
}
|