Files
moonarch/defaults/xdg/quickshell/moonarch/PopoutState.qml
T
nevaforget cf1db166e9 feat(quickshell): popouts close when the focus leaves them
An open popout only closed on a backdrop click, Escape, a toggle, or
another screen claiming the slot. Focus moving away without a click —
Alt+Tab, a focus keybind, a workspace switch, a newly mapped window —
left it standing over the desktop.

Three signals, all needed:

- the host surface losing the keyboard, read from Qt's Window.active
  (PanelWindow exports no focus state); the only one that catches a
  focus grab by another layer surface
- niri focusing an actual window, filtered on id >= 0: niri drops window
  focus to none while the popout holds the keyboard, so reacting to every
  change would make each open close itself
- the focused workspace changing, via the new Niri.focusedWorkspaceId;
  workspace ids are unique across outputs, so a monitor switch counts too

No exception for pkexec/keyring prompts raised by popout actions: they
take the focus, so the popout closes and the new state shows on the next
open. Rationale and measurements in DECISIONS.md.
2026-08-18 15:30:06 +02:00

103 lines
4.8 KiB
QML

// ABOUTME: Shared state for a bar's popout host — which popout is open, where, and its payload.
// ABOUTME: One instance per bar (per screen); bar widgets call open()/close(). Only one may be open
// ABOUTME: system-wide — cross-screen exclusivity is coordinated through the PopoutBus singleton.
import QtQuick
QtObject {
id: root
// Connector name of this bar's screen — set by Bar.qml so the router can route keybinds here.
property string screenName: ""
property var currentComponent: null
property bool hasCurrent: false
property rect anchorRect: Qt.rect(0, 0, 0, 0)
property var payload: null
// Keybind-addressable popouts on this screen: name -> { component, anchorItem, payloadFn }.
// Widgets register themselves; the router opens them by name on the active monitor.
property var registry: ({})
// Name of the currently open registered popout ("" if none, or opened directly via open()).
property string currentName: ""
function open(component, rect, payload) {
root.payload = payload ?? null;
root.anchorRect = rect;
root.currentComponent = component;
root.hasCurrent = true;
// open() is the low-level entry (also used by Tray, which is not keybind-addressable);
// clear currentName so a stale name can't fool toggleByName. openByName sets it afterwards.
root.currentName = "";
// Claim the system-wide slot; any popout open on another screen folds up.
PopoutBus.owner = root;
}
function close() {
// Deliberately leaves currentComponent set: PopoutHost's Loader keeps that
// instance alive and sized through the fold-up animation. Nulling it here would
// unload the content instantly and collapse the slide-out to zero size.
root.hasCurrent = false;
root.currentName = "";
if (PopoutBus.owner === root)
PopoutBus.owner = null;
}
// Widgets call this once (Component.onCompleted) to become keybind-addressable.
// payloadFn is evaluated at open time (e.g. () => root.player for live media state); may be null.
function register(name, component, anchorItem, payloadFn) {
root.registry[name] = {
component: component,
anchorItem: anchorItem,
payloadFn: payloadFn || null
};
}
// Open a registered popout by name, anchoring to its widget's current on-screen position.
// Centralizes the rect math that every widget's click handler used to duplicate.
function openByName(name) {
const e = root.registry[name];
if (!e || !e.anchorItem)
return;
const it = e.anchorItem;
// Anchor rect = the widget's on-screen box (top-left below it + its width). The host picks the
// alignment side from this, so a left-cluster widget's popout grows rightward and stays on-screen.
const tl = it.mapToItem(null, 0, it.height + 4);
root.open(e.component, Qt.rect(tl.x, tl.y, it.width, 0), e.payloadFn ? e.payloadFn() : null);
root.currentName = name;
}
function toggleByName(name) {
if (root.hasCurrent && root.currentName === name)
root.close();
else
root.openByName(name);
}
// Self-register with the router so keybinds routed to this screen find us.
Component.onCompleted: if (root.screenName)
PopoutRouter.states[root.screenName] = root
Component.onDestruction: if (PopoutRouter.states[root.screenName] === root)
delete PopoutRouter.states[root.screenName]
// Fold up when another screen's popout claims the system-wide slot.
readonly property bool ownsSlot: PopoutBus.owner === root
onOwnsSlotChanged: if (!root.ownsSlot && root.hasCurrent)
root.close()
// --- Close when the focus moves away -------------------------------------
// Compositor-side half of the rule; the surface-side half (the popout window losing the keyboard)
// lives in PopoutHost, the only place Qt exposes it. Both are wanted: a workspace switch does not
// necessarily take the keyboard off the popout, and a focus grab by another layer surface produces
// no niri window event.
// niri drops window focus to *none* while the popout holds the keyboard (verified on the event
// stream: opening a popout emits `Window focus changed: None`), so only a switch to an actual
// window counts — reacting to every change would make each open close itself.
readonly property int focusedWindow: Niri.focusedWindowId
onFocusedWindowChanged: if (root.hasCurrent && root.focusedWindow >= 0)
root.close()
// Workspace switch, and with it a monitor switch (workspace ids are unique across outputs).
readonly property int focusedWorkspace: Niri.focusedWorkspaceId
onFocusedWorkspaceChanged: if (root.hasCurrent)
root.close()
}