Give a reconnected display the arrangement it had
hypr/monitors.lua applies the stored per-output entries when the compositor reads its config, and never again. A monitor plugged in an hour later got the compositor's automatic placement instead of the position, scale and rotation this machine was told to use, and the only way back was to open Settings and apply it again. Docking should not cost you your desk. Deliberately not a confirmed transaction. applyLayout arms a fifteen second countdown because it is about to show you something you might not be able to undo; this restores a layout you already confirmed, on hardware you already had, and a countdown would be asking you to re-approve your own decision every time you sat down. It refuses rather than guesses when the stored mode is one the connected panel does not offer -- DP-1 on one dock is not DP-1 on another -- and when the surviving layout would name no primary. Both land on the compositor's automatic placement plus a toast that opens the Displays page, which is recoverable; silence would not be. That toast needed a new open-settings verb in StatusEvents, whose page name goes through ShellState's existing allow-list. The decision is split from the action as plannedRestore so it can be tested without driving a real compositor, and the harness sets topology and stored arrangement in one call because a real query landing between two would replace the fixture. Both fixtures travel base64: qs ipc call splits a JSON array of several objects into one argument per object, so a two-monitor fixture was arriving as an extra argument.
This commit is contained in:
@@ -79,6 +79,32 @@ ShellRoot {
|
||||
Displays.parse(text, generation);
|
||||
}
|
||||
|
||||
// The restore-on-reconnect decision, without applying anything. The
|
||||
// caller injects a topology with injectReadback first, sets the stored
|
||||
// arrangement here, and reads back what Panama would do about it.
|
||||
// Both arguments are base64. `qs ipc call` splits an argument that
|
||||
// looks like a JSON array of several objects into one argument per
|
||||
// object, so a two-monitor fixture arrives as two arguments and the
|
||||
// call is rejected for arity. Encoding sidesteps the parsing entirely.
|
||||
//
|
||||
// Topology and stored arrangement are set in one call on purpose: a
|
||||
// real compositor query landing between two calls would replace the
|
||||
// injected topology, and the answer would be about this machine's
|
||||
// actual monitor instead of the fixture.
|
||||
function restorePlan(readbackB64: string, storedB64: string): string {
|
||||
Displays.parse(Qt.atob(readbackB64), 0);
|
||||
DesktopPreferences.set("displays", JSON.parse(Qt.atob(storedB64)));
|
||||
const plan = Displays.plannedRestore();
|
||||
return JSON.stringify({
|
||||
action: plan.action,
|
||||
layout: (plan.layout ?? []).map(record => ({
|
||||
name: record.name, mode: record.mode, scale: record.scale,
|
||||
transform: record.transform, x: record.x, y: record.y,
|
||||
primary: record.primary
|
||||
}))
|
||||
});
|
||||
}
|
||||
|
||||
function expireApplyVerification(): void {
|
||||
Displays.verificationTimedOut();
|
||||
}
|
||||
|
||||
@@ -136,8 +136,131 @@ Singleton {
|
||||
// rollback then filters out any output that has disappeared.
|
||||
function reconcileTopology(): void {
|
||||
root.refresh();
|
||||
if (root.awaitingConfirmation)
|
||||
if (root.awaitingConfirmation) {
|
||||
root.revertWithMessage("A display was connected or disconnected, so Panama restored the previous setting.");
|
||||
return;
|
||||
}
|
||||
// Nothing in flight, so a display genuinely arrived or left. Give it
|
||||
// back the arrangement it was last confirmed with -- see restoreStored.
|
||||
restoreDebounce.restart();
|
||||
}
|
||||
|
||||
// Docking and undocking should not cost you your arrangement.
|
||||
//
|
||||
// hypr/monitors.lua applies the stored per-output entries, but only when
|
||||
// the compositor reads its config. A monitor plugged in an hour later gets
|
||||
// the compositor's automatic placement instead of the position, scale and
|
||||
// rotation this machine was told to use, and until now the only way to get
|
||||
// them back was to open Settings and apply them again.
|
||||
//
|
||||
// This is deliberately NOT a confirmed transaction. applyLayout arms a
|
||||
// fifteen-second countdown because it is about to show you something you
|
||||
// might not be able to undo; this is restoring a layout you already
|
||||
// confirmed, on hardware you already had, so a countdown would be asking
|
||||
// you to re-approve your own decision every time you sat down at a desk.
|
||||
//
|
||||
// It refuses rather than guesses in two cases, because a wrong answer here
|
||||
// is a screen you cannot see to fix:
|
||||
//
|
||||
// * A stored entry whose mode the connected panel does not offer. This
|
||||
// is the same monitor name on different hardware, which happens with
|
||||
// DP-1 on one dock and DP-1 on another.
|
||||
// * A stored arrangement that leaves any output with no on-screen
|
||||
// position at all.
|
||||
//
|
||||
// In both cases the compositor's automatic placement stands and a toast
|
||||
// says so, which is recoverable. Silence would not be.
|
||||
// The decision, with no side effects, so it can be tested without driving
|
||||
// a real compositor. Returns one of:
|
||||
// { action: "none" } nothing stored, or already correct
|
||||
// { action: "apply", layout } restore this
|
||||
// { action: "refuse" } stored arrangement does not fit
|
||||
function plannedRestore(): var {
|
||||
if (root.busy || root.awaitingConfirmation || root.monitors.length === 0)
|
||||
return { action: "none" };
|
||||
|
||||
const stored = DesktopPreferences.get("displays");
|
||||
const persisted = stored && typeof stored === "object" ? stored : {};
|
||||
|
||||
// Start from what is on screen and overlay each stored entry, so an
|
||||
// output with nothing saved keeps the compositor's own placement.
|
||||
let changed = false;
|
||||
const layout = root.currentLayout();
|
||||
for (const record of layout) {
|
||||
const entry = persisted[record.name];
|
||||
if (!root.isPersistedLayoutEntry(entry))
|
||||
continue;
|
||||
const parts = root.modeParts(entry.mode);
|
||||
if (!parts)
|
||||
continue;
|
||||
if (entry.mode !== record.mode
|
||||
|| Math.abs(entry.scale - record.scale) >= 0.001
|
||||
|| entry.transform !== record.transform
|
||||
|| (entry.x !== undefined && entry.x !== record.x)
|
||||
|| (entry.y !== undefined && entry.y !== record.y))
|
||||
changed = true;
|
||||
record.mode = entry.mode;
|
||||
record.width = parts.width;
|
||||
record.height = parts.height;
|
||||
record.refreshRate = parts.refresh;
|
||||
record.scale = entry.scale;
|
||||
record.transform = entry.transform;
|
||||
if (entry.x !== undefined) record.x = entry.x;
|
||||
if (entry.y !== undefined) record.y = entry.y;
|
||||
record.primary = entry.primary === true;
|
||||
}
|
||||
|
||||
if (!changed)
|
||||
return { action: "none" };
|
||||
|
||||
// Exactly one primary, on a display that is actually here. Undocking
|
||||
// takes the primary away, and a layout with none is one
|
||||
// DisplayLayout.validate refuses outright.
|
||||
if (layout.filter(record => record.primary).length !== 1) {
|
||||
layout.forEach(record => record.primary = false);
|
||||
layout[0].primary = true;
|
||||
}
|
||||
|
||||
// The same validator every user-initiated change goes through: it
|
||||
// checks the mode is one this panel offers, the scale is whole-pixel,
|
||||
// and the names match what is connected. A stored entry for hardware
|
||||
// that is no longer on this connector fails here, which is the point.
|
||||
const normalized = DisplayLayout.normalize(layout);
|
||||
if (!root.validRequestedLayout(normalized))
|
||||
return { action: "refuse" };
|
||||
|
||||
return { action: "apply", layout: normalized };
|
||||
}
|
||||
|
||||
function restoreStored(): void {
|
||||
const plan = root.plannedRestore();
|
||||
if (plan.action === "none")
|
||||
return;
|
||||
|
||||
if (plan.action === "refuse") {
|
||||
StatusEvents.publish({
|
||||
key: "display-restore",
|
||||
icon: "video-display-symbolic",
|
||||
title: "Kept the automatic display arrangement",
|
||||
detail: "The saved arrangement does not fit the displays connected now",
|
||||
tone: "warn",
|
||||
priority: StatusEvents.importantPriority,
|
||||
actionId: "open-settings",
|
||||
actionData: "displays"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
root.pushLayout(plan.layout, applyRun);
|
||||
}
|
||||
|
||||
// Displays announce themselves one at a time: plugging in a dock produces
|
||||
// several signature changes in quick succession, and applying a layout to
|
||||
// each intermediate topology would fight the compositor as it settles.
|
||||
Timer {
|
||||
id: restoreDebounce
|
||||
interval: 1200
|
||||
onTriggered: root.restoreStored()
|
||||
}
|
||||
|
||||
function refresh(): bool {
|
||||
|
||||
@@ -91,6 +91,11 @@ Singleton {
|
||||
ShellState.open("activity");
|
||||
} else if (action === "open-path" && data) {
|
||||
Quickshell.execDetached(["xdg-open", data]);
|
||||
} else if (action === "open-settings" && data) {
|
||||
// The page name is checked by ShellState's own allow-list, so an
|
||||
// event naming a page that does not exist lands on Home rather
|
||||
// than nowhere.
|
||||
ShellState.openSettings(data);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user