Harden display apply and recovery
This commit is contained in:
@@ -27,25 +27,78 @@ local prefs = require("prefs")
|
||||
-- (see the header) rather than preferences, and a settings page has no way to
|
||||
-- explain the screencopy tradeoff at the moment you would be changing it.
|
||||
local displays = prefs.get("displays", {})
|
||||
if type(displays) ~= "table" then
|
||||
displays = {}
|
||||
end
|
||||
|
||||
local function override(output, field, fallback)
|
||||
local function mode_dimensions(mode)
|
||||
if type(mode) ~= "string" then
|
||||
return nil, nil
|
||||
end
|
||||
local width, height, refresh = mode:match("^(%d+)x(%d+)@(%d+%.%d+)$")
|
||||
if width == nil then
|
||||
width, height, refresh = mode:match("^(%d+)x(%d+)@(%d+)$")
|
||||
end
|
||||
width, height, refresh = tonumber(width), tonumber(height), tonumber(refresh)
|
||||
if width == nil or height == nil or refresh == nil
|
||||
or width <= 0 or height <= 0 or refresh <= 0 then
|
||||
return nil, nil
|
||||
end
|
||||
return width, height
|
||||
end
|
||||
|
||||
local function valid_mode(mode)
|
||||
local width = mode_dimensions(mode)
|
||||
return width ~= nil
|
||||
end
|
||||
|
||||
local function valid_scale(mode, scale)
|
||||
local width, height = mode_dimensions(mode)
|
||||
if width == nil or type(scale) ~= "number" or scale ~= scale
|
||||
or scale <= 0 or scale > 4 then
|
||||
return false
|
||||
end
|
||||
local logical_width = width / scale
|
||||
local logical_height = height / scale
|
||||
return math.abs(logical_width - math.floor(logical_width + 0.5)) < 0.0001
|
||||
and math.abs(logical_height - math.floor(logical_height + 0.5)) < 0.0001
|
||||
end
|
||||
|
||||
local function valid_transform(transform)
|
||||
return type(transform) == "number"
|
||||
and transform == math.floor(transform)
|
||||
and transform >= 0
|
||||
and transform <= 3
|
||||
end
|
||||
|
||||
local function display_entry(output)
|
||||
if type(output) ~= "string" or output == ""
|
||||
or output:match("^[%w_.-]+$") == nil then
|
||||
return nil
|
||||
end
|
||||
local entry = displays[output]
|
||||
if type(entry) ~= "table" then
|
||||
return fallback
|
||||
return nil
|
||||
end
|
||||
local value = entry[field]
|
||||
if value == nil or type(value) ~= type(fallback) then
|
||||
return fallback
|
||||
if not valid_mode(entry.mode)
|
||||
or not valid_scale(entry.mode, entry.scale)
|
||||
or not valid_transform(entry.transform) then
|
||||
return nil
|
||||
end
|
||||
return value
|
||||
return entry
|
||||
end
|
||||
|
||||
local shipped_mode = "4500x3000@60"
|
||||
local shipped_scale = 1.5
|
||||
local shipped_transform = 0
|
||||
local dp2 = display_entry("DP-2")
|
||||
|
||||
hl.monitor({
|
||||
output = "DP-2",
|
||||
mode = override("DP-2", "mode", "4500x3000@60"),
|
||||
mode = dp2 and dp2.mode or shipped_mode,
|
||||
position = "0x0",
|
||||
scale = override("DP-2", "scale", 1.5),
|
||||
transform = override("DP-2", "transform", 0),
|
||||
scale = dp2 and dp2.scale or shipped_scale,
|
||||
transform = dp2 and dp2.transform or shipped_transform,
|
||||
|
||||
-- 10-bit output. 4500x3000@60 at 10bpc is ~24 Gbps, right at the edge of
|
||||
-- DP 1.4 HBR3, so this relies on DSC. If the display fails to light up or
|
||||
@@ -56,6 +109,24 @@ hl.monitor({
|
||||
cm = "auto",
|
||||
})
|
||||
|
||||
-- Other connected outputs use the same validated per-output store. They keep
|
||||
-- automatic placement and the compositor's normal colour policy; DP-2 alone
|
||||
-- carries the panel-specific 10-bit policy documented above.
|
||||
for output, _ in pairs(displays) do
|
||||
if output ~= "DP-2" then
|
||||
local entry = display_entry(output)
|
||||
if entry ~= nil then
|
||||
hl.monitor({
|
||||
output = output,
|
||||
mode = entry.mode,
|
||||
position = "auto",
|
||||
scale = entry.scale,
|
||||
transform = entry.transform,
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Any monitor not named above: sane defaults rather than nothing.
|
||||
hl.monitor({
|
||||
output = "",
|
||||
|
||||
@@ -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 !== ""
|
||||
|
||||
@@ -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 ?? [])
|
||||
}));
|
||||
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();
|
||||
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;
|
||||
}
|
||||
|
||||
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
|
||||
mode: root.pendingRequested.mode,
|
||||
scale: root.pendingRequested.scale,
|
||||
transform: root.pendingRequested.transform
|
||||
};
|
||||
DesktopPreferences.set("displays", next);
|
||||
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
|
||||
|
||||
@@ -23,33 +23,144 @@ set -euo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
harness="$repo_dir/config/dot/quickshell/displays-harness.qml"
|
||||
config_home="$(mktemp -d /tmp/panama-displays-config.XXXXXX)"
|
||||
service="$repo_dir/config/dot/quickshell/services/Displays.qml"
|
||||
page="$repo_dir/config/dot/quickshell/modules/settings/DisplaysPage.qml"
|
||||
settings_page="$repo_dir/config/dot/quickshell/modules/settings/SettingsPage.qml"
|
||||
monitors_lua="$repo_dir/config/dot/hypr/monitors.lua"
|
||||
|
||||
fail() {
|
||||
printf 'displays contract: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Keep is unavailable until compositor readback exactly matches the request.
|
||||
for contract in \
|
||||
'property var pendingRequested:' \
|
||||
'readonly property bool canConfirm:' \
|
||||
'function matchesRequest(' \
|
||||
'function scalesForMode(' \
|
||||
'function isScaleClean('; do
|
||||
rg -Fq "$contract" "$service" || fail "display service contract is missing: $contract"
|
||||
done
|
||||
rg -Fq 'enabled: Displays.canConfirm' "$page" \
|
||||
|| fail 'Keep is enabled before the display change is verified'
|
||||
rg -Fq 'options: Displays.scalesForMode(' "$page" \
|
||||
|| fail 'scale choices are not filtered for the active resolution'
|
||||
|
||||
# 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.
|
||||
for contract in 'valid_mode' 'valid_scale' 'valid_transform' 'pairs(displays)'; do
|
||||
rg -Fq "$contract" "$monitors_lua" || fail "monitor startup validation is missing: $contract"
|
||||
done
|
||||
|
||||
# SettingsPage headers are genuinely pinned outside its scrolling surface.
|
||||
python3 - "$settings_page" <<'PY' || fail 'SettingsPage header is not pinned outside the Flickable'
|
||||
import sys
|
||||
text = open(sys.argv[1], encoding="utf-8").read()
|
||||
loader = text.find("id: pinnedHeader")
|
||||
flickable = text.find("id: pageScroll")
|
||||
if loader < 0 or flickable < 0 or loader > flickable:
|
||||
raise SystemExit(1)
|
||||
PY
|
||||
|
||||
MONITORS_LUA="$monitors_lua" lua - <<'LUA' || fail 'monitor startup accepted invalid persisted geometry or ignored a named output'
|
||||
package.preload["prefs"] = function()
|
||||
return {
|
||||
get = function()
|
||||
return {
|
||||
["DP-2"] = { mode = "not-a-mode", scale = -1, transform = 99 },
|
||||
["HDMI-A-1"] = { mode = "1920x1080@60", scale = 1.5, transform = 1 },
|
||||
["BAD OUTPUT"] = { mode = "1920x1080@60", scale = 1, transform = 0 },
|
||||
}
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
local calls = {}
|
||||
hl = { monitor = function(value) table.insert(calls, value) end }
|
||||
assert(loadfile(os.getenv("MONITORS_LUA")))()
|
||||
|
||||
local by_output = {}
|
||||
for _, value in ipairs(calls) do by_output[value.output] = value end
|
||||
assert(by_output["DP-2"].mode == "4500x3000@60")
|
||||
assert(by_output["DP-2"].scale == 1.5)
|
||||
assert(by_output["DP-2"].transform == 0)
|
||||
assert(by_output["HDMI-A-1"].mode == "1920x1080@60")
|
||||
assert(by_output["HDMI-A-1"].scale == 1.5)
|
||||
assert(by_output["HDMI-A-1"].transform == 1)
|
||||
assert(by_output["BAD OUTPUT"] == nil)
|
||||
assert(by_output[""] ~= nil)
|
||||
LUA
|
||||
|
||||
if [[ "${PANAMA_DISPLAYS_STATIC_ONLY:-0}" == "1" ]]; then
|
||||
printf 'displays contract: PASS (static)\n'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
config_home="$(mktemp -d /tmp/panama-displays-config.XXXXXX)"
|
||||
|
||||
run() { XDG_CONFIG_HOME="$config_home" qs -p "$harness" "$@"; }
|
||||
status() { run ipc call displays-test status; }
|
||||
|
||||
original_mode=""
|
||||
original_scale=""
|
||||
original_transform=""
|
||||
original_width=""
|
||||
original_height=""
|
||||
original_refresh=""
|
||||
monitor_name=""
|
||||
|
||||
restore() {
|
||||
# Through hyprctl rather than the harness: if the harness apply path is what
|
||||
# is broken, the daily-driver display must still come back.
|
||||
if [[ -n "$original_mode" ]]; then
|
||||
hyprctl eval "hl.monitor({ output = \"$monitor_name\", mode = \"$original_mode\", scale = $original_scale, transform = $original_transform })" >/dev/null 2>&1 || true
|
||||
fi
|
||||
monitor_state() {
|
||||
hyprctl -j monitors | jq -c --arg output "$monitor_name" '.[] | select(.name == $output)'
|
||||
}
|
||||
|
||||
display_is_restored() {
|
||||
local current
|
||||
current="$(monitor_state)"
|
||||
[[ -n "$current" ]] || return 1
|
||||
jq -e \
|
||||
--argjson width "$original_width" \
|
||||
--argjson height "$original_height" \
|
||||
--argjson refresh "$original_refresh" \
|
||||
--argjson scale "$original_scale" \
|
||||
--argjson transform "$original_transform" \
|
||||
'.width == $width and .height == $height
|
||||
and ((.refreshRate - $refresh) | fabs) < 0.6
|
||||
and ((.scale - $scale) | fabs) < 0.001
|
||||
and .transform == $transform' <<<"$current" >/dev/null
|
||||
}
|
||||
|
||||
restore_display() {
|
||||
[[ -n "$original_mode" ]] || return 0
|
||||
hyprctl eval "hl.monitor({ output = \"$monitor_name\", mode = \"$original_mode\", scale = $original_scale, transform = $original_transform })" >/dev/null \
|
||||
|| return 1
|
||||
for _ in $(seq 1 50); do
|
||||
display_is_restored && return 0
|
||||
sleep 0.2
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
stop_harness() {
|
||||
# Kill by PID, never `pkill -f displays-harness`: that pattern also matches
|
||||
# any shell whose command line contains this script's text, which includes
|
||||
# the invoking shell itself.
|
||||
[[ -n "${harness_pid:-}" ]] && kill "$harness_pid" >/dev/null 2>&1 || true
|
||||
rm -rf "$config_home"
|
||||
}
|
||||
trap restore EXIT
|
||||
|
||||
cleanup() {
|
||||
local status=$?
|
||||
trap - EXIT
|
||||
if ! restore_display; then
|
||||
printf 'displays contract: FAILED to restore %s to %s scale %s transform %s\n' \
|
||||
"$monitor_name" "$original_mode" "$original_scale" "$original_transform" >&2
|
||||
status=1
|
||||
fi
|
||||
stop_harness
|
||||
exit "$status"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
XDG_CONFIG_HOME="$config_home" qs -p "$harness" --daemonize >/dev/null
|
||||
harness_pid=""
|
||||
@@ -69,6 +180,9 @@ state="$(status)"
|
||||
monitor_name="$(jq -r .name <<<"$state")"
|
||||
[[ -n "$monitor_name" ]] || fail "no display was detected: $state"
|
||||
original_mode="$(jq -r '"\(.width)x\(.height)@\(.refresh)"' <<<"$state")"
|
||||
original_width="$(jq -r .width <<<"$state")"
|
||||
original_height="$(jq -r .height <<<"$state")"
|
||||
original_refresh="$(jq -r .refresh <<<"$state")"
|
||||
original_scale="$(jq -r .scale <<<"$state")"
|
||||
original_transform="$(jq -r .transform <<<"$state")"
|
||||
|
||||
@@ -85,6 +199,7 @@ mode
|
||||
scale
|
||||
transform
|
||||
output
|
||||
dirtyScale
|
||||
KINDS
|
||||
|
||||
# The display must not have moved for any of those.
|
||||
@@ -98,16 +213,17 @@ target_scale=$(awk -v s="$original_scale" 'BEGIN { print (s == 1.25) ? 1.5 : 1.2
|
||||
|
||||
applied=false
|
||||
for _ in $(seq 1 30); do
|
||||
[[ "$(hyprctl -j monitors | jq -r '.[0].scale')" == "$target_scale" ]] && { applied=true; break; }
|
||||
[[ "$(monitor_state | jq -r '.scale')" == "$target_scale" ]] && { applied=true; break; }
|
||||
sleep 0.2
|
||||
done
|
||||
[[ "$applied" == true ]] || fail 'the scale change never reached the compositor'
|
||||
[[ "$(status | jq -r .awaiting)" == "true" ]] || fail 'an applied change is not awaiting confirmation'
|
||||
[[ "$(status | jq -r .canConfirm)" == "true" ]] || fail 'an applied change was never verified by compositor readback'
|
||||
|
||||
# Wait out the countdown. This is the whole point of the contract.
|
||||
reverted=false
|
||||
for _ in $(seq 1 120); do
|
||||
if [[ "$(hyprctl -j monitors | jq -r '.[0].scale')" == "$original_scale" ]]; then
|
||||
if [[ "$(monitor_state | jq -r '.scale')" == "$original_scale" ]]; then
|
||||
reverted=true
|
||||
break
|
||||
fi
|
||||
@@ -119,8 +235,16 @@ done
|
||||
|
||||
# ── A confirmed change is what writes ────────────────────────────────────────
|
||||
run ipc call displays-test applyScale "$target_scale" >/dev/null
|
||||
sleep 1
|
||||
run ipc call displays-test confirmChange >/dev/null
|
||||
[[ "$(run ipc call displays-test confirmChange)" == "false" ]] \
|
||||
|| fail 'Keep accepted a display change before compositor readback'
|
||||
verified=false
|
||||
for _ in $(seq 1 30); do
|
||||
[[ "$(status | jq -r .canConfirm)" == "true" ]] && { verified=true; break; }
|
||||
sleep 0.2
|
||||
done
|
||||
[[ "$verified" == true ]] || fail 'the confirmed change never became safe to keep'
|
||||
[[ "$(run ipc call displays-test confirmChange)" == "true" ]] \
|
||||
|| fail 'Keep refused a verified display change'
|
||||
sleep 0.6
|
||||
[[ "$(status | jq -r .awaiting)" == "false" ]] || fail 'confirming did not clear the pending state'
|
||||
[[ "$(status | jq -r .overridden)" == "true" ]] || fail 'confirming did not store the change'
|
||||
@@ -134,6 +258,8 @@ run ipc call displays-test forget >/dev/null
|
||||
sleep 0.6
|
||||
[[ "$(status | jq -r .overridden)" == "false" ]] || fail 'forget did not clear the stored display setting'
|
||||
|
||||
restore_display || fail 'the final cleanup could not restore and verify the original display'
|
||||
original_mode=""
|
||||
stop_harness
|
||||
trap - EXIT
|
||||
restore
|
||||
printf 'displays contract: PASS\n'
|
||||
|
||||
Reference in New Issue
Block a user