Harden display apply and recovery

This commit is contained in:
Gabriel Brown
2026-08-18 02:55:43 -04:00
parent 5671324eb2
commit 2d4b126f14
7 changed files with 422 additions and 64 deletions
+6 -1
View File
@@ -21,6 +21,7 @@ ShellRoot {
transform: monitor ? monitor.transform : -1,
modes: monitor ? monitor.modes.length : 0,
awaiting: Displays.awaitingConfirmation,
canConfirm: Displays.canConfirm,
secondsLeft: Displays.secondsLeft,
lastError: Displays.lastError,
overridden: monitor ? Displays.isOverridden(monitor.name) : false
@@ -40,12 +41,16 @@ ShellRoot {
const mode = monitor.width + "x" + monitor.height + "@" + Math.round(monitor.refreshRate);
if (kind === "mode") return Displays.apply(monitor.name, "9999x9999@240", monitor.scale, monitor.transform);
if (kind === "scale") return Displays.apply(monitor.name, mode, 1.37, monitor.transform);
if (kind === "dirtyScale") {
const dirty = Displays.scales.find(scale => !Displays.isScaleClean(mode, scale));
return dirty === undefined ? false : Displays.apply(monitor.name, mode, dirty, monitor.transform);
}
if (kind === "transform") return Displays.apply(monitor.name, mode, monitor.scale, 9);
if (kind === "output") return Displays.apply("NOPE-1", mode, monitor.scale, monitor.transform);
return false;
}
function confirmChange(): void { Displays.confirm(); }
function confirmChange(): bool { return Displays.confirm(); }
function revertChange(): void { Displays.revert(); }
function forget(): void {
const monitor = Displays.monitors[0];
@@ -110,7 +110,7 @@ Column {
onTapped: Displays.apply(
root.monitor.name,
rate.modelData.mode,
root.monitor.scale,
Displays.nearestCleanScale(rate.modelData.mode, root.monitor.scale),
root.monitor.transform)
}
}
@@ -21,6 +21,9 @@ SettingsPage {
lede: SystemSettings.monitorDescription || "Reading the active display…"
readonly property var monitor: Displays.monitors.length > 0 ? Displays.monitors[0] : null
readonly property string currentMode: root.monitor
? `${root.monitor.width}x${root.monitor.height}@${Math.round(root.monitor.refreshRate)}`
: ""
// The confirmation sits above everything, because while it is counting down
// it is the only thing that matters on this page.
@@ -76,6 +79,7 @@ SettingsPage {
id: keepButton
anchors.verticalCenter: parent.verticalCenter
text: "Keep"
enabled: Displays.canConfirm
onClicked: Displays.confirm()
}
}
@@ -131,7 +135,8 @@ SettingsPage {
width: parent.width
label: "Scale"
detail: "Fractional scales that do not divide the resolution into whole pixels are rejected by the compositor, so only clean ones are offered."
options: Displays.scales.map(scale => ({ value: scale, label: scale.toFixed(2) + "×" }))
options: Displays.scalesForMode(root.currentMode)
.map(scale => ({ value: scale, label: scale.toFixed(2) + "×" }))
current: root.monitor ? root.monitor.scale : 1
enabled: !Displays.awaitingConfirmation && !Displays.busy
onPicked: value => root.applyWith({ scale: value })
@@ -167,11 +172,14 @@ SettingsPage {
function applyWith(change: var): void {
if (!root.monitor)
return;
const current = `${root.monitor.width}x${root.monitor.height}@${Math.round(root.monitor.refreshRate)}`;
const mode = change.mode ?? root.currentMode;
const requestedScale = change.scale ?? root.monitor.scale;
Displays.apply(
root.monitor.name,
change.mode ?? current,
change.scale ?? root.monitor.scale,
mode,
Displays.isScaleClean(mode, requestedScale)
? requestedScale
: Displays.nearestCleanScale(mode, requestedScale),
change.transform ?? root.monitor.transform);
}
}
@@ -28,11 +28,31 @@ Item {
// scrolls -- the Appearance page pins its live preview here.
property Component header: null
Loader {
id: pinnedHeader
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.leftMargin: 34
anchors.rightMargin: 34
anchors.topMargin: 30
active: root.header !== null
sourceComponent: root.header
z: 1
}
Flickable {
anchors.fill: parent
id: pageScroll
anchors.left: parent.left
anchors.right: parent.right
anchors.top: pinnedHeader.active ? pinnedHeader.bottom : parent.top
anchors.bottom: parent.bottom
anchors.topMargin: pinnedHeader.active ? 16 : 0
clip: true
contentWidth: width
contentHeight: layout.implicitHeight + 64
contentHeight: layout.implicitHeight + (pinnedHeader.active ? 34 : 64)
boundsBehavior: Flickable.StopAtBounds
Column {
@@ -40,15 +60,9 @@ Item {
width: parent.width - 68
x: 34
y: 30
y: pinnedHeader.active ? 0 : 30
spacing: 16
Loader {
width: parent.width
active: root.header !== null
sourceComponent: root.header
}
Text {
width: parent.width
visible: root.title !== ""
+161 -27
View File
@@ -33,10 +33,16 @@ Singleton {
// Set while a change is applied but not yet confirmed.
property string pendingOutput: ""
property var pendingPrevious: null
property var pendingRequested: null
property bool pendingVerified: false
property bool revertQueued: false
property int secondsLeft: 0
readonly property bool awaitingConfirmation: root.pendingOutput !== ""
readonly property bool busy: query.running || applyRun.running
readonly property bool canConfirm: root.awaitingConfirmation
&& root.pendingVerified
&& !root.busy
readonly property bool busy: query.running || applyRun.running || revertRun.running
readonly property int confirmSeconds: 15
@@ -66,7 +72,29 @@ Singleton {
Process {
id: applyRun
onExited: (exitCode, exitStatus) => root.refresh()
onExited: (exitCode, exitStatus) => {
if (!root.awaitingConfirmation)
return;
if (root.revertQueued) {
root.performRevert();
return;
}
if (exitCode !== 0) {
root.revertWithMessage("The display rejected that change and Panama restored the previous setting.");
return;
}
verifyTimer.attempts = 0;
verifyTimer.restart();
}
}
Process {
id: revertRun
onExited: (exitCode, exitStatus) => {
if (exitCode !== 0)
root.lastError = "The previous display setting could not be restored automatically.";
root.refresh();
}
}
Component.onCompleted: root.refresh()
@@ -89,7 +117,14 @@ Singleton {
transform: monitor.transform ?? 0,
modes: root.normaliseModes(monitor.availableModes ?? [])
}));
root.lastError = "";
if (root.awaitingConfirmation && root.pendingRequested
&& root.matchesRequest(root.monitorNamed(root.pendingOutput), root.pendingRequested)) {
root.pendingVerified = true;
verifyTimer.stop();
root.lastError = "";
} else if (!root.awaitingConfirmation) {
root.lastError = "";
}
} catch (error) {
root.lastError = "The display list could not be read.";
}
@@ -129,6 +164,52 @@ Singleton {
return root.monitors.find(monitor => monitor.name === name) ?? null;
}
function modeParts(mode: string): var {
const match = String(mode).match(/^(\d+)x(\d+)@(\d+(?:\.\d+)?)$/);
if (!match)
return null;
return {
width: Number(match[1]),
height: Number(match[2]),
refresh: Number(match[3])
};
}
function isScaleClean(mode: string, scale: real): bool {
const parts = root.modeParts(mode);
if (!parts || root.scales.indexOf(scale) < 0 || !Number.isFinite(scale) || scale <= 0)
return false;
const logicalWidth = parts.width / scale;
const logicalHeight = parts.height / scale;
return Math.abs(logicalWidth - Math.round(logicalWidth)) < 0.0001
&& Math.abs(logicalHeight - Math.round(logicalHeight)) < 0.0001;
}
function scalesForMode(mode: string): var {
return root.scales.filter(scale => root.isScaleClean(mode, scale));
}
function nearestCleanScale(mode: string, preferred: real): real {
const choices = root.scalesForMode(mode);
if (choices.length === 0)
return 1.0;
return choices.reduce((best, candidate) =>
Math.abs(candidate - preferred) < Math.abs(best - preferred) ? candidate : best,
choices[0]);
}
function matchesRequest(monitor: var, requested: var): bool {
if (!monitor || !requested || monitor.name !== requested.output)
return false;
const parts = root.modeParts(requested.mode);
return !!parts
&& monitor.width === parts.width
&& monitor.height === parts.height
&& Math.abs(monitor.refreshRate - parts.refresh) < 0.6
&& Math.abs(monitor.scale - requested.scale) < 0.001
&& monitor.transform === requested.transform;
}
// Applies immediately and starts the countdown. Nothing is stored yet: the
// settings file is only written by confirm().
function apply(output: string, mode: string, scale: real, transform: int): bool {
@@ -145,8 +226,8 @@ Singleton {
root.lastError = "That display does not offer that mode.";
return false;
}
if (root.scales.indexOf(scale) < 0) {
root.lastError = "That scale is not one Panama offers.";
if (!root.isScaleClean(mode, scale)) {
root.lastError = "That scale does not divide this resolution cleanly.";
return false;
}
if (!root.transforms.some(candidate => candidate.value === transform)) {
@@ -160,8 +241,17 @@ Singleton {
scale: monitor.scale,
transform: monitor.transform
};
root.pendingRequested = {
output: output,
mode: mode,
scale: scale,
transform: transform
};
root.pendingOutput = output;
root.pendingVerified = false;
root.revertQueued = false;
root.secondsLeft = root.confirmSeconds;
root.lastError = "";
countdown.restart();
root.push(output, mode, scale, transform);
@@ -175,39 +265,68 @@ Singleton {
`hl.monitor({ output = "${output}", mode = "${mode}", scale = ${scale}, transform = ${transform} })`]);
}
function confirm(): void {
if (!root.awaitingConfirmation)
return;
const monitor = root.monitorNamed(root.pendingOutput);
countdown.stop();
if (monitor) {
const stored = DesktopPreferences.get("displays");
const next = Object.assign({}, (stored && typeof stored === "object") ? stored : {});
next[root.pendingOutput] = {
mode: `${monitor.width}x${monitor.height}@${Math.round(monitor.refreshRate)}`,
scale: monitor.scale,
transform: monitor.transform
};
DesktopPreferences.set("displays", next);
function confirm(): bool {
if (!root.canConfirm || !root.matchesRequest(
root.monitorNamed(root.pendingOutput), root.pendingRequested)) {
if (root.awaitingConfirmation)
root.lastError = "Wait for the display to finish applying before keeping it.";
return false;
}
const stored = DesktopPreferences.get("displays");
const next = Object.assign({}, (stored && typeof stored === "object") ? stored : {});
next[root.pendingOutput] = {
mode: root.pendingRequested.mode,
scale: root.pendingRequested.scale,
transform: root.pendingRequested.transform
};
if (!DesktopPreferences.set("displays", next)) {
root.lastError = "That display setting could not be saved. Revert it and try again.";
return false;
}
root.clearPending();
root.lastError = "";
return true;
}
function clearPending(): void {
countdown.stop();
verifyTimer.stop();
root.pendingOutput = "";
root.pendingPrevious = null;
root.pendingRequested = null;
root.pendingVerified = false;
root.revertQueued = false;
root.secondsLeft = 0;
root.lastError = "";
}
function revert(): void {
root.revertWithMessage("");
}
function revertWithMessage(message: string): void {
if (!root.awaitingConfirmation)
return;
const previous = root.pendingPrevious;
countdown.stop();
root.pendingOutput = "";
root.pendingPrevious = null;
root.secondsLeft = 0;
if (previous)
root.push(previous.output, previous.mode, previous.scale, previous.transform);
verifyTimer.stop();
root.pendingVerified = false;
if (message !== "")
root.lastError = message;
if (applyRun.running) {
root.revertQueued = true;
return;
}
root.performRevert();
}
function performRevert(): void {
const previous = root.pendingPrevious;
root.clearPending();
if (previous) {
revertRun.exec(["hyprctl", "eval",
`hl.monitor({ output = "${previous.output}", mode = "${previous.mode}", scale = ${previous.scale}, transform = ${previous.transform} })`]);
}
}
// Clears any stored override for an output so it returns to the value
@@ -226,6 +345,21 @@ Singleton {
return !!(stored && typeof stored === "object" && stored[output] !== undefined);
}
Timer {
id: verifyTimer
property int attempts: 0
interval: 120
repeat: true
onTriggered: {
attempts++;
if (attempts > 25) {
root.revertWithMessage("The display did not apply that setting, so Panama restored the previous one.");
return;
}
root.refresh();
}
}
Timer {
id: countdown
interval: 1000