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
|
-- (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.
|
-- explain the screencopy tradeoff at the moment you would be changing it.
|
||||||
local displays = prefs.get("displays", {})
|
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]
|
local entry = displays[output]
|
||||||
if type(entry) ~= "table" then
|
if type(entry) ~= "table" then
|
||||||
return fallback
|
return nil
|
||||||
end
|
end
|
||||||
local value = entry[field]
|
if not valid_mode(entry.mode)
|
||||||
if value == nil or type(value) ~= type(fallback) then
|
or not valid_scale(entry.mode, entry.scale)
|
||||||
return fallback
|
or not valid_transform(entry.transform) then
|
||||||
|
return nil
|
||||||
end
|
end
|
||||||
return value
|
return entry
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local shipped_mode = "4500x3000@60"
|
||||||
|
local shipped_scale = 1.5
|
||||||
|
local shipped_transform = 0
|
||||||
|
local dp2 = display_entry("DP-2")
|
||||||
|
|
||||||
hl.monitor({
|
hl.monitor({
|
||||||
output = "DP-2",
|
output = "DP-2",
|
||||||
mode = override("DP-2", "mode", "4500x3000@60"),
|
mode = dp2 and dp2.mode or shipped_mode,
|
||||||
position = "0x0",
|
position = "0x0",
|
||||||
scale = override("DP-2", "scale", 1.5),
|
scale = dp2 and dp2.scale or shipped_scale,
|
||||||
transform = override("DP-2", "transform", 0),
|
transform = dp2 and dp2.transform or shipped_transform,
|
||||||
|
|
||||||
-- 10-bit output. 4500x3000@60 at 10bpc is ~24 Gbps, right at the edge of
|
-- 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
|
-- 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",
|
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.
|
-- Any monitor not named above: sane defaults rather than nothing.
|
||||||
hl.monitor({
|
hl.monitor({
|
||||||
output = "",
|
output = "",
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ ShellRoot {
|
|||||||
transform: monitor ? monitor.transform : -1,
|
transform: monitor ? monitor.transform : -1,
|
||||||
modes: monitor ? monitor.modes.length : 0,
|
modes: monitor ? monitor.modes.length : 0,
|
||||||
awaiting: Displays.awaitingConfirmation,
|
awaiting: Displays.awaitingConfirmation,
|
||||||
|
canConfirm: Displays.canConfirm,
|
||||||
secondsLeft: Displays.secondsLeft,
|
secondsLeft: Displays.secondsLeft,
|
||||||
lastError: Displays.lastError,
|
lastError: Displays.lastError,
|
||||||
overridden: monitor ? Displays.isOverridden(monitor.name) : false
|
overridden: monitor ? Displays.isOverridden(monitor.name) : false
|
||||||
@@ -40,12 +41,16 @@ ShellRoot {
|
|||||||
const mode = monitor.width + "x" + monitor.height + "@" + Math.round(monitor.refreshRate);
|
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 === "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") {
|
||||||
|
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 === "transform") return Displays.apply(monitor.name, mode, monitor.scale, 9);
|
||||||
if (kind === "output") return Displays.apply("NOPE-1", mode, monitor.scale, monitor.transform);
|
if (kind === "output") return Displays.apply("NOPE-1", mode, monitor.scale, monitor.transform);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function confirmChange(): void { Displays.confirm(); }
|
function confirmChange(): bool { return Displays.confirm(); }
|
||||||
function revertChange(): void { Displays.revert(); }
|
function revertChange(): void { Displays.revert(); }
|
||||||
function forget(): void {
|
function forget(): void {
|
||||||
const monitor = Displays.monitors[0];
|
const monitor = Displays.monitors[0];
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ Column {
|
|||||||
onTapped: Displays.apply(
|
onTapped: Displays.apply(
|
||||||
root.monitor.name,
|
root.monitor.name,
|
||||||
rate.modelData.mode,
|
rate.modelData.mode,
|
||||||
root.monitor.scale,
|
Displays.nearestCleanScale(rate.modelData.mode, root.monitor.scale),
|
||||||
root.monitor.transform)
|
root.monitor.transform)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ SettingsPage {
|
|||||||
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
|
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
|
// 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.
|
||||||
@@ -76,6 +79,7 @@ SettingsPage {
|
|||||||
id: keepButton
|
id: keepButton
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
text: "Keep"
|
text: "Keep"
|
||||||
|
enabled: Displays.canConfirm
|
||||||
onClicked: Displays.confirm()
|
onClicked: Displays.confirm()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -131,7 +135,8 @@ SettingsPage {
|
|||||||
width: parent.width
|
width: parent.width
|
||||||
label: "Scale"
|
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."
|
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
|
current: root.monitor ? root.monitor.scale : 1
|
||||||
enabled: !Displays.awaitingConfirmation && !Displays.busy
|
enabled: !Displays.awaitingConfirmation && !Displays.busy
|
||||||
onPicked: value => root.applyWith({ scale: value })
|
onPicked: value => root.applyWith({ scale: value })
|
||||||
@@ -167,11 +172,14 @@ SettingsPage {
|
|||||||
function applyWith(change: var): void {
|
function applyWith(change: var): void {
|
||||||
if (!root.monitor)
|
if (!root.monitor)
|
||||||
return;
|
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(
|
Displays.apply(
|
||||||
root.monitor.name,
|
root.monitor.name,
|
||||||
change.mode ?? current,
|
mode,
|
||||||
change.scale ?? root.monitor.scale,
|
Displays.isScaleClean(mode, requestedScale)
|
||||||
|
? requestedScale
|
||||||
|
: Displays.nearestCleanScale(mode, requestedScale),
|
||||||
change.transform ?? root.monitor.transform);
|
change.transform ?? root.monitor.transform);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,11 +28,31 @@ Item {
|
|||||||
// scrolls -- the Appearance page pins its live preview here.
|
// scrolls -- the Appearance page pins its live preview here.
|
||||||
property Component header: null
|
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 {
|
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
|
clip: true
|
||||||
contentWidth: width
|
contentWidth: width
|
||||||
contentHeight: layout.implicitHeight + 64
|
contentHeight: layout.implicitHeight + (pinnedHeader.active ? 34 : 64)
|
||||||
boundsBehavior: Flickable.StopAtBounds
|
boundsBehavior: Flickable.StopAtBounds
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
@@ -40,15 +60,9 @@ Item {
|
|||||||
|
|
||||||
width: parent.width - 68
|
width: parent.width - 68
|
||||||
x: 34
|
x: 34
|
||||||
y: 30
|
y: pinnedHeader.active ? 0 : 30
|
||||||
spacing: 16
|
spacing: 16
|
||||||
|
|
||||||
Loader {
|
|
||||||
width: parent.width
|
|
||||||
active: root.header !== null
|
|
||||||
sourceComponent: root.header
|
|
||||||
}
|
|
||||||
|
|
||||||
Text {
|
Text {
|
||||||
width: parent.width
|
width: parent.width
|
||||||
visible: root.title !== ""
|
visible: root.title !== ""
|
||||||
|
|||||||
@@ -33,10 +33,16 @@ Singleton {
|
|||||||
// Set while a change is applied but not yet confirmed.
|
// Set while a change is applied but not yet confirmed.
|
||||||
property string pendingOutput: ""
|
property string pendingOutput: ""
|
||||||
property var pendingPrevious: null
|
property var pendingPrevious: null
|
||||||
|
property var pendingRequested: null
|
||||||
|
property bool pendingVerified: false
|
||||||
|
property bool revertQueued: false
|
||||||
property int secondsLeft: 0
|
property int secondsLeft: 0
|
||||||
|
|
||||||
readonly property bool awaitingConfirmation: root.pendingOutput !== ""
|
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
|
readonly property int confirmSeconds: 15
|
||||||
|
|
||||||
@@ -66,7 +72,29 @@ Singleton {
|
|||||||
|
|
||||||
Process {
|
Process {
|
||||||
id: applyRun
|
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()
|
Component.onCompleted: root.refresh()
|
||||||
@@ -89,7 +117,14 @@ Singleton {
|
|||||||
transform: monitor.transform ?? 0,
|
transform: monitor.transform ?? 0,
|
||||||
modes: root.normaliseModes(monitor.availableModes ?? [])
|
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) {
|
} catch (error) {
|
||||||
root.lastError = "The display list could not be read.";
|
root.lastError = "The display list could not be read.";
|
||||||
}
|
}
|
||||||
@@ -129,6 +164,52 @@ Singleton {
|
|||||||
return root.monitors.find(monitor => monitor.name === name) ?? null;
|
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
|
// 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 {
|
||||||
@@ -145,8 +226,8 @@ Singleton {
|
|||||||
root.lastError = "That display does not offer that mode.";
|
root.lastError = "That display does not offer that mode.";
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (root.scales.indexOf(scale) < 0) {
|
if (!root.isScaleClean(mode, scale)) {
|
||||||
root.lastError = "That scale is not one Panama offers.";
|
root.lastError = "That scale does not divide this resolution cleanly.";
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!root.transforms.some(candidate => candidate.value === transform)) {
|
if (!root.transforms.some(candidate => candidate.value === transform)) {
|
||||||
@@ -160,8 +241,17 @@ Singleton {
|
|||||||
scale: monitor.scale,
|
scale: monitor.scale,
|
||||||
transform: monitor.transform
|
transform: monitor.transform
|
||||||
};
|
};
|
||||||
|
root.pendingRequested = {
|
||||||
|
output: output,
|
||||||
|
mode: mode,
|
||||||
|
scale: scale,
|
||||||
|
transform: transform
|
||||||
|
};
|
||||||
root.pendingOutput = output;
|
root.pendingOutput = output;
|
||||||
|
root.pendingVerified = false;
|
||||||
|
root.revertQueued = false;
|
||||||
root.secondsLeft = root.confirmSeconds;
|
root.secondsLeft = root.confirmSeconds;
|
||||||
|
root.lastError = "";
|
||||||
countdown.restart();
|
countdown.restart();
|
||||||
|
|
||||||
root.push(output, mode, scale, transform);
|
root.push(output, mode, scale, transform);
|
||||||
@@ -175,39 +265,68 @@ Singleton {
|
|||||||
`hl.monitor({ output = "${output}", mode = "${mode}", scale = ${scale}, transform = ${transform} })`]);
|
`hl.monitor({ output = "${output}", mode = "${mode}", scale = ${scale}, transform = ${transform} })`]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function confirm(): void {
|
function confirm(): bool {
|
||||||
if (!root.awaitingConfirmation)
|
if (!root.canConfirm || !root.matchesRequest(
|
||||||
return;
|
root.monitorNamed(root.pendingOutput), root.pendingRequested)) {
|
||||||
const monitor = root.monitorNamed(root.pendingOutput);
|
if (root.awaitingConfirmation)
|
||||||
countdown.stop();
|
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
|
|
||||||
};
|
|
||||||
DesktopPreferences.set("displays", next);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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.pendingOutput = "";
|
||||||
root.pendingPrevious = null;
|
root.pendingPrevious = null;
|
||||||
|
root.pendingRequested = null;
|
||||||
|
root.pendingVerified = false;
|
||||||
|
root.revertQueued = false;
|
||||||
root.secondsLeft = 0;
|
root.secondsLeft = 0;
|
||||||
root.lastError = "";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function revert(): void {
|
function revert(): void {
|
||||||
|
root.revertWithMessage("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function revertWithMessage(message: string): void {
|
||||||
if (!root.awaitingConfirmation)
|
if (!root.awaitingConfirmation)
|
||||||
return;
|
return;
|
||||||
const previous = root.pendingPrevious;
|
|
||||||
countdown.stop();
|
countdown.stop();
|
||||||
root.pendingOutput = "";
|
verifyTimer.stop();
|
||||||
root.pendingPrevious = null;
|
root.pendingVerified = false;
|
||||||
root.secondsLeft = 0;
|
if (message !== "")
|
||||||
if (previous)
|
root.lastError = message;
|
||||||
root.push(previous.output, previous.mode, previous.scale, previous.transform);
|
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
|
// 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);
|
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 {
|
Timer {
|
||||||
id: countdown
|
id: countdown
|
||||||
interval: 1000
|
interval: 1000
|
||||||
|
|||||||
@@ -23,33 +23,144 @@ set -euo pipefail
|
|||||||
|
|
||||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
harness="$repo_dir/config/dot/quickshell/displays-harness.qml"
|
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() {
|
fail() {
|
||||||
printf 'displays contract: %s\n' "$1" >&2
|
printf 'displays contract: %s\n' "$1" >&2
|
||||||
exit 1
|
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" "$@"; }
|
run() { XDG_CONFIG_HOME="$config_home" qs -p "$harness" "$@"; }
|
||||||
status() { run ipc call displays-test status; }
|
status() { run ipc call displays-test status; }
|
||||||
|
|
||||||
original_mode=""
|
original_mode=""
|
||||||
original_scale=""
|
original_scale=""
|
||||||
original_transform=""
|
original_transform=""
|
||||||
|
original_width=""
|
||||||
|
original_height=""
|
||||||
|
original_refresh=""
|
||||||
|
monitor_name=""
|
||||||
|
|
||||||
restore() {
|
monitor_state() {
|
||||||
# Through hyprctl rather than the harness: if the harness apply path is what
|
hyprctl -j monitors | jq -c --arg output "$monitor_name" '.[] | select(.name == $output)'
|
||||||
# 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
|
display_is_restored() {
|
||||||
fi
|
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
|
# Kill by PID, never `pkill -f displays-harness`: that pattern also matches
|
||||||
# any shell whose command line contains this script's text, which includes
|
# any shell whose command line contains this script's text, which includes
|
||||||
# the invoking shell itself.
|
# the invoking shell itself.
|
||||||
[[ -n "${harness_pid:-}" ]] && kill "$harness_pid" >/dev/null 2>&1 || true
|
[[ -n "${harness_pid:-}" ]] && kill "$harness_pid" >/dev/null 2>&1 || true
|
||||||
rm -rf "$config_home"
|
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
|
XDG_CONFIG_HOME="$config_home" qs -p "$harness" --daemonize >/dev/null
|
||||||
harness_pid=""
|
harness_pid=""
|
||||||
@@ -69,6 +180,9 @@ 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 '"\(.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_scale="$(jq -r .scale <<<"$state")"
|
||||||
original_transform="$(jq -r .transform <<<"$state")"
|
original_transform="$(jq -r .transform <<<"$state")"
|
||||||
|
|
||||||
@@ -85,6 +199,7 @@ mode
|
|||||||
scale
|
scale
|
||||||
transform
|
transform
|
||||||
output
|
output
|
||||||
|
dirtyScale
|
||||||
KINDS
|
KINDS
|
||||||
|
|
||||||
# The display must not have moved for any of those.
|
# 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
|
applied=false
|
||||||
for _ in $(seq 1 30); do
|
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
|
sleep 0.2
|
||||||
done
|
done
|
||||||
[[ "$applied" == true ]] || fail 'the scale change never reached the compositor'
|
[[ "$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 .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.
|
# Wait out the countdown. This is the whole point of the contract.
|
||||||
reverted=false
|
reverted=false
|
||||||
for _ in $(seq 1 120); do
|
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
|
reverted=true
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
@@ -119,8 +235,16 @@ done
|
|||||||
|
|
||||||
# ── A confirmed change is what writes ────────────────────────────────────────
|
# ── A confirmed change is what writes ────────────────────────────────────────
|
||||||
run ipc call displays-test applyScale "$target_scale" >/dev/null
|
run ipc call displays-test applyScale "$target_scale" >/dev/null
|
||||||
sleep 1
|
[[ "$(run ipc call displays-test confirmChange)" == "false" ]] \
|
||||||
run ipc call displays-test confirmChange >/dev/null
|
|| 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
|
sleep 0.6
|
||||||
[[ "$(status | jq -r .awaiting)" == "false" ]] || fail 'confirming did not clear the pending state'
|
[[ "$(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'
|
[[ "$(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
|
sleep 0.6
|
||||||
[[ "$(status | jq -r .overridden)" == "false" ]] || fail 'forget did not clear the stored display setting'
|
[[ "$(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
|
trap - EXIT
|
||||||
restore
|
|
||||||
printf 'displays contract: PASS\n'
|
printf 'displays contract: PASS\n'
|
||||||
|
|||||||
Reference in New Issue
Block a user