Apply monitor layouts transactionally
This commit is contained in:
@@ -21,6 +21,7 @@ import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import "DisplayLayout.js" as DisplayLayout
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
@@ -31,12 +32,11 @@ Singleton {
|
||||
property string lastError: ""
|
||||
|
||||
// Set while a change is applied but not yet confirmed.
|
||||
property string pendingOutput: ""
|
||||
property var pendingPrevious: null
|
||||
property var pendingRequested: null
|
||||
property var pendingPreviousLayout: null
|
||||
property var pendingRequestedLayout: null
|
||||
property bool pendingVerified: false
|
||||
property bool revertQueued: false
|
||||
property var revertExpected: null
|
||||
property var revertExpectedLayout: null
|
||||
property string revertReason: ""
|
||||
property bool revertVerificationActive: false
|
||||
property int operationGeneration: 0
|
||||
@@ -44,12 +44,12 @@ Singleton {
|
||||
property bool externalChangeBlocked: false
|
||||
property int secondsLeft: 0
|
||||
|
||||
readonly property bool awaitingConfirmation: root.pendingOutput !== ""
|
||||
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.revertExpected !== null
|
||||
|| root.revertExpectedLayout !== null
|
||||
|
||||
readonly property int confirmSeconds: 15
|
||||
|
||||
@@ -163,25 +163,26 @@ Singleton {
|
||||
modes: modes
|
||||
};
|
||||
});
|
||||
if (root.awaitingConfirmation && root.pendingRequested
|
||||
&& root.matchesRequest(root.monitorNamed(root.pendingOutput), root.pendingRequested)) {
|
||||
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.revertExpected
|
||||
&& root.matchesRequest(root.monitorNamed(root.revertExpected.output), root.revertExpected)) {
|
||||
&& root.revertExpectedLayout
|
||||
&& root.matchesLayout(root.monitors, root.revertExpectedLayout)) {
|
||||
revertVerifyTimer.stop();
|
||||
root.revertVerificationActive = false;
|
||||
root.revertGeneration = -1;
|
||||
root.revertExpected = null;
|
||||
root.revertExpectedLayout = null;
|
||||
if (root.revertReason === "")
|
||||
root.lastError = "";
|
||||
else
|
||||
root.lastError = root.revertReason;
|
||||
root.revertReason = "";
|
||||
} else if (!root.awaitingConfirmation && !root.revertExpected && (
|
||||
} 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 = "";
|
||||
@@ -274,16 +275,41 @@ Singleton {
|
||||
choices[0]);
|
||||
}
|
||||
|
||||
function matchesRequest(monitor: var, requested: var): bool {
|
||||
if (!monitor || !requested || monitor.name !== requested.output)
|
||||
function currentLayout(): var {
|
||||
return root.monitors.map(monitor => ({
|
||||
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
|
||||
}));
|
||||
}
|
||||
|
||||
function matchesLayout(monitors: var, layout: var): bool {
|
||||
if (!Array.isArray(monitors) || !Array.isArray(layout)
|
||||
|| monitors.length !== layout.length)
|
||||
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.01
|
||||
&& Math.abs(monitor.scale - requested.scale) < 0.001
|
||||
&& monitor.transform === requested.transform;
|
||||
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
|
||||
|| monitor.x !== requested.x || monitor.y !== requested.y)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function modeIsCurrent(monitor: var, candidate: var): bool {
|
||||
@@ -293,9 +319,47 @@ Singleton {
|
||||
&& Math.abs(monitor.refreshRate - candidate.refresh) < 0.01;
|
||||
}
|
||||
|
||||
// Applies immediately and starts the countdown. Nothing is stored yet: the
|
||||
// settings file is only written by confirm().
|
||||
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);
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
// One-field controls remain callers of the complete-layout transaction.
|
||||
// Their edit is cloned into the current layout so every output's position
|
||||
// participates in apply, verification, and rollback.
|
||||
function apply(output: string, mode: string, scale: real, transform: int): bool {
|
||||
const layout = root.currentLayout();
|
||||
const record = layout.find(candidate => candidate.name === output);
|
||||
const parts = root.modeParts(mode);
|
||||
if (!record || !parts) {
|
||||
root.lastError = record ? "That display does not offer that mode." : "That display is not connected.";
|
||||
return false;
|
||||
}
|
||||
record.mode = mode;
|
||||
record.width = parts.width;
|
||||
record.height = parts.height;
|
||||
record.refreshRate = parts.refresh;
|
||||
record.scale = scale;
|
||||
record.transform = transform;
|
||||
return root.applyLayout(layout);
|
||||
}
|
||||
|
||||
// Applies immediately and starts the countdown. Nothing is stored yet: the
|
||||
// complete connected layout is only written by confirm().
|
||||
function applyLayout(layout: var): bool {
|
||||
if (root.externalChangeBlocked) {
|
||||
root.lastError = "Wait for Settings to finish restoring before changing a display.";
|
||||
return false;
|
||||
@@ -308,58 +372,46 @@ Singleton {
|
||||
root.lastError = "Finish the current display change first.";
|
||||
return false;
|
||||
}
|
||||
const monitor = root.monitorNamed(output);
|
||||
if (!monitor) {
|
||||
root.lastError = "That display is not connected.";
|
||||
return false;
|
||||
}
|
||||
if (!monitor.modes.some(candidate => candidate.mode === mode)) {
|
||||
root.lastError = "That display does not offer that mode.";
|
||||
return false;
|
||||
}
|
||||
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)) {
|
||||
root.lastError = "That rotation is not one Panama offers.";
|
||||
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.pendingPrevious = {
|
||||
output: output,
|
||||
mode: monitor.mode,
|
||||
scale: monitor.scale,
|
||||
transform: monitor.transform
|
||||
};
|
||||
root.pendingPreviousLayout = root.currentLayout();
|
||||
root.operationGeneration++;
|
||||
root.pendingRequested = {
|
||||
output: output,
|
||||
mode: mode,
|
||||
scale: scale,
|
||||
transform: transform
|
||||
};
|
||||
root.pendingOutput = output;
|
||||
root.pendingRequestedLayout = normalized;
|
||||
root.pendingVerified = false;
|
||||
root.revertQueued = false;
|
||||
root.secondsLeft = root.confirmSeconds;
|
||||
root.lastError = "";
|
||||
countdown.restart();
|
||||
|
||||
root.push(output, mode, scale, transform);
|
||||
root.pushLayout(normalized, applyRun);
|
||||
return true;
|
||||
}
|
||||
|
||||
function push(output: string, mode: string, scale: real, transform: int): void {
|
||||
// Values are validated above and the output name comes from the
|
||||
// compositor's own list, so nothing user-authored reaches the payload.
|
||||
applyRun.exec(["hyprctl", "eval",
|
||||
`hl.monitor({ output = "${output}", mode = "${mode}", scale = ${scale}, transform = ${transform} })`]);
|
||||
function makePrimary(output: string): bool {
|
||||
const layout = root.currentLayout();
|
||||
if (!layout.some(record => record.name === output)) {
|
||||
root.lastError = "That display is not connected.";
|
||||
return false;
|
||||
}
|
||||
for (const record of layout)
|
||||
record.primary = record.name === output;
|
||||
return root.applyLayout(DisplayLayout.normalize(layout));
|
||||
}
|
||||
|
||||
function pushLayout(layout: var, runner: var): void {
|
||||
const payload = layout.map(record =>
|
||||
`hl.monitor({ output = "${record.name}", mode = "${record.mode}", position = "${record.x}x${record.y}", scale = ${record.scale}, transform = ${record.transform} })`
|
||||
).join("; ");
|
||||
runner.exec(["hyprctl", "eval", payload]);
|
||||
}
|
||||
|
||||
function confirm(): bool {
|
||||
if (!root.canConfirm || !root.matchesRequest(
|
||||
root.monitorNamed(root.pendingOutput), root.pendingRequested)) {
|
||||
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;
|
||||
@@ -367,11 +419,16 @@ Singleton {
|
||||
|
||||
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
|
||||
};
|
||||
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
|
||||
};
|
||||
}
|
||||
if (!DesktopPreferences.set("displays", next)) {
|
||||
root.lastError = "That display setting could not be saved. Revert it and try again.";
|
||||
return false;
|
||||
@@ -385,9 +442,8 @@ Singleton {
|
||||
function clearPending(): void {
|
||||
countdown.stop();
|
||||
verifyTimer.stop();
|
||||
root.pendingOutput = "";
|
||||
root.pendingPrevious = null;
|
||||
root.pendingRequested = null;
|
||||
root.pendingPreviousLayout = null;
|
||||
root.pendingRequestedLayout = null;
|
||||
root.pendingVerified = false;
|
||||
root.revertQueued = false;
|
||||
root.secondsLeft = 0;
|
||||
@@ -414,16 +470,25 @@ Singleton {
|
||||
}
|
||||
|
||||
function performRevert(): void {
|
||||
const previous = root.pendingPrevious;
|
||||
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));
|
||||
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.revertExpected = previous;
|
||||
root.revertExpectedLayout = previous.length > 0 ? previous : null;
|
||||
root.revertVerificationActive = false;
|
||||
root.clearPending();
|
||||
if (previous) {
|
||||
revertRun.exec(["hyprctl", "eval",
|
||||
`hl.monitor({ output = "${previous.output}", mode = "${previous.mode}", scale = ${previous.scale}, transform = ${previous.transform} })`]);
|
||||
}
|
||||
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
|
||||
@@ -442,6 +507,19 @@ Singleton {
|
||||
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: verifyTimer
|
||||
property int attempts: 0
|
||||
@@ -451,7 +529,7 @@ Singleton {
|
||||
onTriggered: {
|
||||
ticks++;
|
||||
if (ticks > 50) {
|
||||
root.revertWithMessage("The display did not apply that setting, so Panama restored the previous one.");
|
||||
root.verificationTimedOut();
|
||||
return;
|
||||
}
|
||||
if (root.refresh())
|
||||
@@ -468,12 +546,7 @@ Singleton {
|
||||
onTriggered: {
|
||||
ticks++;
|
||||
if (ticks > 50) {
|
||||
stop();
|
||||
root.revertVerificationActive = false;
|
||||
root.revertGeneration = -1;
|
||||
root.revertExpected = null;
|
||||
root.revertReason = "";
|
||||
root.lastError = "The previous display setting could not be verified. Open Displays and restore it manually.";
|
||||
root.revertVerificationTimedOut();
|
||||
return;
|
||||
}
|
||||
if (root.refresh())
|
||||
|
||||
Reference in New Issue
Block a user