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.
103 lines
4.8 KiB
QML
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()
|
|
}
|