Files
Gabriel Brown 317b7a0962 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.
2026-08-21 23:06:50 -04:00

157 lines
5.1 KiB
QML

pragma Singleton
// Curated shell feedback. This is deliberately not a notification store: one
// event is visible, at most four wait behind it, and expired entries vanish.
import Quickshell
import Quickshell.Hyprland
import QtQuick
Singleton {
id: root
readonly property int ambientPriority: 10
readonly property int importantPriority: 50
readonly property int criticalPriority: 90
property var activeEvent: null
property var pendingEvents: []
property int sequence: 0
readonly property bool active: root.activeEvent !== null
readonly property int queueLength: root.pendingEvents.length
signal eventPublished(var event)
signal eventDismissed(string key)
Timer {
id: expiryTimer
repeat: false
onTriggered: root.dismiss()
}
function publish(candidate: var): bool {
const event = root.normalize(candidate);
if (!event)
return false;
// DND is allowed to quiet device ambience, but it must never hide a
// privacy transition or the result of something the user initiated.
if (Notifs.doNotDisturb && event.priority < root.importantPriority)
return false;
if (root.activeEvent && root.activeEvent.key === event.key) {
root.activeEvent = event;
root.armExpiry();
root.eventPublished(event);
return true;
}
const withoutEquivalent = root.pendingEvents.filter(item => item.key !== event.key);
if (!root.activeEvent || event.priority > root.activeEvent.priority) {
if (root.activeEvent)
withoutEquivalent.push(root.activeEvent);
root.pendingEvents = root.trimQueue(withoutEquivalent);
root.activeEvent = event;
root.armExpiry();
} else {
withoutEquivalent.push(event);
root.pendingEvents = root.trimQueue(withoutEquivalent);
}
root.eventPublished(event);
return true;
}
function dismiss(): void {
if (!root.activeEvent)
return;
const dismissedKey = root.activeEvent.key;
expiryTimer.stop();
root.activeEvent = null;
root.eventDismissed(dismissedKey);
root.advance();
}
function invoke(): void {
if (!root.activeEvent)
return;
const action = root.activeEvent.actionId;
const data = root.activeEvent.actionData;
root.dismiss();
if (action === "focus-workspace") {
FocusSession.activateWorkspace();
} else if (action === "open-workspace") {
const workspaceId = Number(data);
if (Number.isInteger(workspaceId) && workspaceId > 0)
Hyprland.dispatch(`hl.dsp.focus({ workspace = ${workspaceId} })`);
} else if (action === "open-activity") {
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);
}
}
function reset(): void {
expiryTimer.stop();
root.activeEvent = null;
root.pendingEvents = [];
}
function normalize(candidate: var): var {
if (!candidate || !candidate.key || !candidate.title)
return null;
root.sequence++;
const priority = Number(candidate.priority ?? root.ambientPriority);
const duration = Number(candidate.durationMs ?? 3200);
return {
key: String(candidate.key),
icon: String(candidate.icon ?? "dialog-information-symbolic"),
glyph: String(candidate.glyph ?? ""),
title: String(candidate.title),
detail: String(candidate.detail ?? ""),
tone: String(candidate.tone ?? "accent"),
priority: Number.isFinite(priority) ? priority : root.ambientPriority,
durationMs: Math.max(1200, Number.isFinite(duration) ? duration : 3200),
actionId: String(candidate.actionId ?? ""),
actionData: String(candidate.actionData ?? ""),
monitorName: String(candidate.monitorName ?? ""),
sequence: root.sequence
};
}
function trimQueue(events: var): var {
const sorted = events.slice().sort((a, b) => {
if (a.priority !== b.priority)
return b.priority - a.priority;
return a.sequence - b.sequence;
});
return sorted.slice(0, 4);
}
function advance(): void {
if (root.pendingEvents.length === 0)
return;
const next = root.pendingEvents[0];
root.pendingEvents = root.pendingEvents.slice(1);
root.activeEvent = next;
root.armExpiry();
}
function armExpiry(): void {
expiryTimer.stop();
if (!root.activeEvent)
return;
expiryTimer.interval = root.activeEvent.durationMs;
expiryTimer.start();
}
}