Verify display restoration and expose outputs

This commit is contained in:
Gabriel Brown
2026-08-18 02:55:43 -04:00
parent 67674d297e
commit efc53435a2
4 changed files with 124 additions and 30 deletions
+4 -3
View File
@@ -16,7 +16,8 @@ ShellRoot {
name: monitor ? monitor.name : "", name: monitor ? monitor.name : "",
width: monitor ? monitor.width : 0, width: monitor ? monitor.width : 0,
height: monitor ? monitor.height : 0, height: monitor ? monitor.height : 0,
refresh: monitor ? Math.round(monitor.refreshRate) : 0, refresh: monitor ? monitor.refreshRate : 0,
mode: monitor ? monitor.mode : "",
scale: monitor ? monitor.scale : 0, scale: monitor ? monitor.scale : 0,
transform: monitor ? monitor.transform : -1, transform: monitor ? monitor.transform : -1,
modes: monitor ? monitor.modes.length : 0, modes: monitor ? monitor.modes.length : 0,
@@ -31,14 +32,14 @@ ShellRoot {
function applyScale(scale: real): bool { function applyScale(scale: real): bool {
const monitor = Displays.monitors[0]; const monitor = Displays.monitors[0];
if (!monitor) return false; if (!monitor) return false;
const mode = monitor.width + "x" + monitor.height + "@" + Math.round(monitor.refreshRate); const mode = monitor.mode;
return Displays.apply(monitor.name, mode, scale, monitor.transform); return Displays.apply(monitor.name, mode, scale, monitor.transform);
} }
function applyBad(kind: string): bool { function applyBad(kind: string): bool {
const monitor = Displays.monitors[0]; const monitor = Displays.monitors[0];
if (!monitor) return false; if (!monitor) return false;
const mode = monitor.width + "x" + monitor.height + "@" + Math.round(monitor.refreshRate); const mode = monitor.mode;
if (kind === "mode") return Displays.apply(monitor.name, "9999x9999@240", monitor.scale, monitor.transform); 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 === "scale") return Displays.apply(monitor.name, mode, 1.37, monitor.transform);
if (kind === "dirtyScale") { if (kind === "dirtyScale") {
@@ -20,11 +20,24 @@ SettingsPage {
title: "Displays" title: "Displays"
lede: SystemSettings.monitorDescription || "Reading the active display…" lede: SystemSettings.monitorDescription || "Reading the active display…"
readonly property var monitor: Displays.monitors.length > 0 ? Displays.monitors[0] : null property string selectedOutput: ""
readonly property var monitor: Displays.monitorNamed(root.selectedOutput)
?? (Displays.monitors.length > 0 ? Displays.monitors[0] : null)
readonly property string currentMode: root.monitor readonly property string currentMode: root.monitor
? `${root.monitor.width}x${root.monitor.height}@${Math.round(root.monitor.refreshRate)}` ? root.monitor.mode
: "" : ""
function syncSelectedOutput(): void {
if (!Displays.monitorNamed(root.selectedOutput))
root.selectedOutput = Displays.monitors.length > 0 ? Displays.monitors[0].name : "";
}
Component.onCompleted: root.syncSelectedOutput()
Connections {
target: Displays
function onMonitorsChanged(): void { root.syncSelectedOutput(); }
}
// The confirmation sits above everything, because while it is counting down // The confirmation sits above everything, because while it is counting down
// it is the only thing that matters on this page. // it is the only thing that matters on this page.
header: Component { header: Component {
@@ -86,6 +99,25 @@ SettingsPage {
} }
} }
SettingsCard {
visible: Displays.monitors.length > 1
title: "Connected display"
subtitle: "Choose the output whose resolution, scale, and rotation you want to adjust."
ChoiceGrid {
width: parent.width
label: "Display"
options: Displays.monitors.map(monitor => ({
value: monitor.name,
label: monitor.description || monitor.name
}))
current: root.monitor ? root.monitor.name : ""
enabled: !Displays.awaitingConfirmation && !Displays.busy
divider: false
onPicked: value => root.selectedOutput = value
}
}
SettingsCard { SettingsCard {
title: root.monitor ? root.monitor.name : (SystemSettings.monitorName || "Active display") title: root.monitor ? root.monitor.name : (SystemSettings.monitorName || "Active display")
subtitle: root.monitor subtitle: root.monitor
+75 -23
View File
@@ -36,6 +36,8 @@ Singleton {
property var pendingRequested: null property var pendingRequested: null
property bool pendingVerified: false property bool pendingVerified: false
property bool revertQueued: false property bool revertQueued: false
property var revertExpected: null
property string revertReason: ""
property int secondsLeft: 0 property int secondsLeft: 0
readonly property bool awaitingConfirmation: root.pendingOutput !== "" readonly property bool awaitingConfirmation: root.pendingOutput !== ""
@@ -43,6 +45,7 @@ Singleton {
&& root.pendingVerified && root.pendingVerified
&& !root.busy && !root.busy
readonly property bool busy: query.running || applyRun.running || revertRun.running readonly property bool busy: query.running || applyRun.running || revertRun.running
|| root.revertExpected !== null
readonly property int confirmSeconds: 15 readonly property int confirmSeconds: 15
@@ -91,9 +94,10 @@ Singleton {
Process { Process {
id: revertRun id: revertRun
onExited: (exitCode, exitStatus) => { onExited: (exitCode, exitStatus) => {
if (exitCode !== 0) // Exit status is advisory only. Hyprland's Lua bridge can report
root.lastError = "The previous display setting could not be restored automatically."; // success without applying a value, so exact readback decides.
root.refresh(); revertVerifyTimer.attempts = 0;
revertVerifyTimer.restart();
} }
} }
@@ -107,22 +111,42 @@ Singleton {
function parse(text: string): void { function parse(text: string): void {
try { try {
const raw = JSON.parse(text); const raw = JSON.parse(text);
root.monitors = raw.map(monitor => ({ root.monitors = raw.map(monitor => {
name: monitor.name ?? "", const modes = root.normaliseModes(monitor.availableModes ?? []);
description: monitor.description ?? monitor.model ?? "Display", const width = monitor.width ?? 0;
width: monitor.width ?? 0, const height = monitor.height ?? 0;
height: monitor.height ?? 0, const refreshRate = monitor.refreshRate ?? 0;
refreshRate: monitor.refreshRate ?? 0, const current = modes
scale: monitor.scale ?? 1, .filter(mode => mode.width === width && mode.height === height)
transform: monitor.transform ?? 0, .sort((left, right) =>
modes: root.normaliseModes(monitor.availableModes ?? []) 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,
modes: modes
};
});
if (root.awaitingConfirmation && root.pendingRequested if (root.awaitingConfirmation && root.pendingRequested
&& root.matchesRequest(root.monitorNamed(root.pendingOutput), root.pendingRequested)) { && root.matchesRequest(root.monitorNamed(root.pendingOutput), root.pendingRequested)) {
root.pendingVerified = true; root.pendingVerified = true;
verifyTimer.stop(); verifyTimer.stop();
root.lastError = ""; root.lastError = "";
} else if (!root.awaitingConfirmation && ( } else if (root.revertExpected
&& root.matchesRequest(root.monitorNamed(root.revertExpected.output), root.revertExpected)) {
revertVerifyTimer.stop();
root.revertExpected = null;
if (root.revertReason === "")
root.lastError = "";
else
root.lastError = root.revertReason;
root.revertReason = "";
} else if (!root.awaitingConfirmation && !root.revertExpected && (
root.lastError === "Could not read the connected displays." root.lastError === "Could not read the connected displays."
|| root.lastError === "The display list could not be read.")) { || root.lastError === "The display list could not be read.")) {
root.lastError = ""; root.lastError = "";
@@ -133,9 +157,9 @@ Singleton {
} }
// "[email protected]" -> a sortable record. The compositor reports the same // "[email protected]" -> a sortable record. The compositor reports the same
// resolution several times at refresh rates that differ only in rounding // resolution at distinct rates such as 60.00 and 59.94. Those identities
// (60.00 and 59.94), which as a list of buttons is noise rather than // remain separate because confirmation and recovery must read back the
// choice, so equal rounded pairs collapse to one. // exact mode the user chose, even when their rounded labels look similar.
function normaliseModes(raw: var): var { function normaliseModes(raw: var): var {
const seen = {}; const seen = {};
const out = []; const out = [];
@@ -145,15 +169,19 @@ Singleton {
continue; continue;
const width = Number(match[1]); const width = Number(match[1]);
const height = Number(match[2]); const height = Number(match[2]);
const refresh = Math.round(Number(match[3])); const refreshText = match[3];
const key = `${width}x${height}@${refresh}`; const refresh = Number(refreshText);
const roundedRefresh = Math.round(refresh);
const key = `${width}x${height}@${refreshText}`;
if (seen[key]) if (seen[key])
continue; continue;
seen[key] = true; seen[key] = true;
out.push({ out.push({
label: `${width} × ${height}`, label: `${width} × ${height}`,
refreshLabel: `${refresh} Hz`, refreshLabel: Math.abs(refresh - roundedRefresh) < 0.005
mode: key, ? `${roundedRefresh} Hz`
: `${refresh.toFixed(2)} Hz`,
mode: `${width}x${height}@${refreshText}`,
width: width, width: width,
height: height, height: height,
refresh: refresh refresh: refresh
@@ -207,7 +235,7 @@ Singleton {
return !!parts return !!parts
&& monitor.width === parts.width && monitor.width === parts.width
&& monitor.height === parts.height && monitor.height === parts.height
&& Math.abs(monitor.refreshRate - parts.refresh) < 0.6 && Math.abs(monitor.refreshRate - parts.refresh) < 0.01
&& Math.abs(monitor.scale - requested.scale) < 0.001 && Math.abs(monitor.scale - requested.scale) < 0.001
&& monitor.transform === requested.transform; && monitor.transform === requested.transform;
} }
@@ -215,6 +243,10 @@ Singleton {
// Applies immediately and starts the countdown. Nothing is stored yet: the // Applies immediately and starts the countdown. Nothing is stored yet: the
// settings file is only written by confirm(). // settings file is only written by confirm().
function apply(output: string, mode: string, scale: real, transform: int): bool { function apply(output: string, mode: string, scale: real, transform: int): bool {
if (root.busy) {
root.lastError = "Wait for the current display operation to finish.";
return false;
}
if (root.awaitingConfirmation) { if (root.awaitingConfirmation) {
root.lastError = "Finish the current display change first."; root.lastError = "Finish the current display change first.";
return false; return false;
@@ -239,7 +271,7 @@ Singleton {
root.pendingPrevious = { root.pendingPrevious = {
output: output, output: output,
mode: `${monitor.width}x${monitor.height}@${Math.round(monitor.refreshRate)}`, mode: monitor.mode,
scale: monitor.scale, scale: monitor.scale,
transform: monitor.transform transform: monitor.transform
}; };
@@ -313,6 +345,7 @@ Singleton {
countdown.stop(); countdown.stop();
verifyTimer.stop(); verifyTimer.stop();
root.pendingVerified = false; root.pendingVerified = false;
root.revertReason = message;
if (message !== "") if (message !== "")
root.lastError = message; root.lastError = message;
if (applyRun.running) { if (applyRun.running) {
@@ -324,6 +357,7 @@ Singleton {
function performRevert(): void { function performRevert(): void {
const previous = root.pendingPrevious; const previous = root.pendingPrevious;
root.revertExpected = previous;
root.clearPending(); root.clearPending();
if (previous) { if (previous) {
revertRun.exec(["hyprctl", "eval", revertRun.exec(["hyprctl", "eval",
@@ -362,6 +396,24 @@ Singleton {
} }
} }
Timer {
id: revertVerifyTimer
property int attempts: 0
interval: 120
repeat: true
onTriggered: {
attempts++;
if (attempts > 25) {
stop();
root.revertExpected = null;
root.revertReason = "";
root.lastError = "The previous display setting could not be verified. Open Displays and restore it manually.";
return;
}
root.refresh();
}
}
Timer { Timer {
id: countdown id: countdown
interval: 1000 interval: 1000
+11 -2
View File
@@ -36,6 +36,7 @@ fail() {
# Keep is unavailable until compositor readback exactly matches the request. # Keep is unavailable until compositor readback exactly matches the request.
for contract in \ for contract in \
'property var pendingRequested:' \ 'property var pendingRequested:' \
'property var revertExpected:' \
'readonly property bool canConfirm:' \ 'readonly property bool canConfirm:' \
'function matchesRequest(' \ 'function matchesRequest(' \
'function scalesForMode(' \ 'function scalesForMode(' \
@@ -46,6 +47,14 @@ rg -Fq 'enabled: Displays.canConfirm' "$page" \
|| fail 'Keep is enabled before the display change is verified' || fail 'Keep is enabled before the display change is verified'
rg -Fq 'options: Displays.scalesForMode(' "$page" \ rg -Fq 'options: Displays.scalesForMode(' "$page" \
|| fail 'scale choices are not filtered for the active resolution' || fail 'scale choices are not filtered for the active resolution'
rg -Fq 'property string selectedOutput:' "$page" \
|| fail 'connected outputs cannot be selected'
rg -Fq 'options: Displays.monitors.map(' "$page" \
|| fail 'the output selector is not populated from connected displays'
rg -Fq 'id: revertVerifyTimer' "$service" \
|| fail 'automatic restoration has no bounded readback verification'
rg -Fq 'if (root.busy)' "$service" \
|| fail 'the display service accepts a new apply while another operation is busy'
# Stored JSON is untyped at field level, so the Lua startup consumer is the # Stored JSON is untyped at field level, so the Lua startup consumer is the
# final validation boundary and must support every named output it accepts. # final validation boundary and must support every named output it accepts.
@@ -125,7 +134,7 @@ display_is_restored() {
--argjson scale "$original_scale" \ --argjson scale "$original_scale" \
--argjson transform "$original_transform" \ --argjson transform "$original_transform" \
'.width == $width and .height == $height '.width == $width and .height == $height
and ((.refreshRate - $refresh) | fabs) < 0.6 and ((.refreshRate - $refresh) | fabs) < 0.01
and ((.scale - $scale) | fabs) < 0.001 and ((.scale - $scale) | fabs) < 0.001
and .transform == $transform' <<<"$current" >/dev/null and .transform == $transform' <<<"$current" >/dev/null
} }
@@ -179,7 +188,7 @@ done
state="$(status)" state="$(status)"
monitor_name="$(jq -r .name <<<"$state")" monitor_name="$(jq -r .name <<<"$state")"
[[ -n "$monitor_name" ]] || fail "no display was detected: $state" [[ -n "$monitor_name" ]] || fail "no display was detected: $state"
original_mode="$(jq -r '"\(.width)x\(.height)@\(.refresh)"' <<<"$state")" original_mode="$(jq -r .mode <<<"$state")"
original_width="$(jq -r .width <<<"$state")" original_width="$(jq -r .width <<<"$state")"
original_height="$(jq -r .height <<<"$state")" original_height="$(jq -r .height <<<"$state")"
original_refresh="$(jq -r .refresh <<<"$state")" original_refresh="$(jq -r .refresh <<<"$state")"