feat(quickshell): popouts close when the focus leaves them
Update PKGBUILD version / update-pkgver (push) Successful in 5s
Update PKGBUILD version / update-pkgver (push) Successful in 5s
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.
This commit is contained in:
@@ -94,6 +94,9 @@ quickshell is the **default bar and notification daemon**, spawned by niri as `q
|
||||
|
||||
- **Popouts by keybind:** registered popouts are addressable by name through the `PopoutRouter` singleton, which exposes an `IpcHandler { target: "popout" }` with `toggle`/`open`/`close`. A niri keybind fires `qs -c moonarch ipc call popout toggle <name>` (e.g. `Mod+A` → `audio`, in `defaults/xdg/niri/config.kdl`). The router routes to the focused monitor's `PopoutState` via `Niri.activeOutput` (workspace `is_focused` → `output`, matched against `screen.name`); an open popout grabs the keyboard (Escape closes). Names: `audio backlight network bluetooth nightlight cpugov battery notifications media launcher camera calendar clipboard power`. Tray is not keybind-addressable.
|
||||
|
||||
- **Close on focus loss:** an open popout folds up as soon as the focus leaves it. Three signals, all of them needed: (1) the host surface losing the keyboard — `PopoutHost` reads Qt's `Window.active`, since Quickshell's `PanelWindow` exposes no focus state of its own; this is the only signal that catches a focus grab by another *layer surface*. (2) niri focusing an actual window (`Niri.focusedWindowId >= 0`). The `>= 0` matters: niri sets window focus to **none** while the popout holds the keyboard (`Window focus changed: None` on the event stream), so reacting to every change would make each open close itself. (3) The focused workspace changing (`Niri.focusedWorkspaceId`) — workspace ids are unique across outputs, so this covers a monitor switch as well; a workspace switch does not necessarily take the keyboard off the popout, which is why (1) does not subsume it. `PopoutHost.sawFocus` gates the first `Window.active == false`, which arrives in the gap between mapping the surface and the compositor handing focus over.
|
||||
- **No exception for auth prompts.** A popout action that raises a pkexec or keyring prompt (camera, cpugov, batsaver, VPN) loses the focus to it and therefore closes the popout; the resulting state shows on the next open. That is a deliberate choice for one rule over a special case — the `OnDemand` keyboard focus in `PopoutHost` still exists so such a prompt can become typeable at all.
|
||||
|
||||
- **Launcher:** `Launcher.qml` is the first widget in the left cluster (an apps glyph) and owns `LauncherPopout.qml` — an application launcher over `DesktopEntries.applications` (search field + keyboard-navigable icon+name list; ↑/↓ select, Enter/click launch via `entry.execute()`, Esc close; app icons via `Quickshell.iconPath`). It registers under `launcher` (so it is IPC-addressable) and is bound to `Mod+Space` (`qs -c moonarch ipc call popout toggle launcher`, in `config.kdl`) in addition to icon-click. Scope is app-launch (drun) only; `walker` stays installed and keeps the other modes (run-command, calc, files), but has no keybind of its own left — what still calls it is dmenu duty for the Waybar-reserve scripts (`moonarch-sink-switcher`, `moonarch-cpugov`, `moonarch-vpn`), which is why the package cannot be dropped.
|
||||
|
||||
- **Clipboard history:** `ClipboardPopout.qml` (registered as `clipboard`, bound to `Super+C`) replaces walker's clipboard provider. Backend stays **cliphist** — the popout shells out to it: `list` on every open (cliphist has no change signal, so the open *is* the refresh point), `decode <id> | wl-copy` on Enter/click, and `delete` with the entry's raw list line on stdin for **Ctrl+D**. Every call carries `-db-path $XDG_RUNTIME_DIR/cliphist/db`, because the history lives in the session db that `cliphist-text.service` creates, not in cliphist's `~/.cache` default. All shell arguments are passed positionally (`sh -c '… "$1" "$2"' sh a b`), so no clipboard content ever becomes part of a script.
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# Decisions
|
||||
|
||||
## 2026-08-18 – Popouts close when the focus leaves them
|
||||
- **Who**: Dominik, ClaudeCode
|
||||
- **Why**: An open popout only closed on a backdrop click, Escape, a toggle, or another screen claiming the system-wide slot. Anything that moved the focus without a click — Alt+Tab, a focus keybind, a workspace switch, a newly mapped window — left the popout standing over the desktop.
|
||||
- **Tradeoffs**: (1) **Which signal** — niri's event stream has none for layer-surface focus (verified: opening a popout emits no event about the popout itself), so the compositor alone cannot answer the question. Qt's `Window.active` can, but Quickshell's `PanelWindow` does not export focus state, and `_backingWindow` is private. Measured with a throwaway `qs -p` config: an `OnDemand` overlay surface reports `active` false while mapping, true once niri hands it the keyboard, false the instant another window takes it. (2) **One signal or three** — `Window.active` alone misses a workspace switch that does not take the keyboard away, and `focusedWindowId` alone misses a focus grab by another layer surface. Both were kept, plus the workspace id, rather than picking a single "cleanest" one. (3) **The `>= 0` filter** — niri drops window focus to *none* while the popout holds the keyboard (`Window focus changed: None`), so a naive "focused window changed → close" closes every popout immediately after opening it. Found by capturing the raw event stream; the first capture run hid it because `grep` in a pipe is block-buffered and `timeout`'s SIGTERM discarded the buffer. (4) **Auth prompts** — a popout action that raises a pkexec/keyring prompt (camera, cpugov, batsaver, VPN) hands the focus to that prompt, so the popout closes and the new state is only visible on the next open. Suppressing the rule while such a helper runs was offered and rejected: Dominik chose one rule with no special case. The `OnDemand` keyboard focus stays as it is — it is what lets those prompts take focus at all (see the comment in `PopoutHost.qml`).
|
||||
- **How**: `PopoutHost.qml` — `panel.surfaceActive` on Qt's `Window.active`, closing when it goes false, with a `sawFocus` flag so the mapped-but-not-yet-focused moment does not count. `PopoutState.qml` — two derived properties over the `Niri` singleton: `focusedWindow` (closes only for an id `>= 0`) and `focusedWorkspace`. `Niri.qml` gained `focusedWorkspaceId` next to `activeOutput`, read from the `is_focused` workspace; ids are unique across outputs, so it covers a monitor switch too. Verified against an instrumented copy of the config run as a second `qs -p` instance: focusing a window closes it, a workspace switch closes it, an untouched popout stays open for the whole wait, and — with the two compositor triggers disabled in the copy — the `Window.active` path closes it on its own, so no branch is dead code.
|
||||
|
||||
## 2026-08-14 – Power menu becomes a Quickshell popout; moonset and moonlock retired
|
||||
- **Who**: Dominik, ClaudeCode
|
||||
- **Why**: With the greeter and the locker gone (entry below), `moonset` was the last self-maintained GTK4 program — a keybind-invoked overlay with five buttons, which the Quickshell config can do with the popout machinery it already has. It was also the only thing still calling `moonlock`, so replacing it is what finally retires that too.
|
||||
|
||||
@@ -24,6 +24,13 @@ Singleton {
|
||||
return w ? w.output : "";
|
||||
}
|
||||
|
||||
// Id of the focused workspace (-1 until known). Ids are unique across outputs, so a change here
|
||||
// covers both a workspace switch and a monitor switch — which is what popouts close on.
|
||||
readonly property int focusedWorkspaceId: {
|
||||
const w = root.workspaces.find(x => x && x.is_focused);
|
||||
return w ? w.id : -1;
|
||||
}
|
||||
|
||||
function recomputeActive() {
|
||||
root.activeWindow = (root.focusedWindowId >= 0 && root.windowsById[root.focusedWindowId])
|
||||
? root.windowsById[root.focusedWindowId]
|
||||
|
||||
@@ -5,6 +5,7 @@ pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Shapes
|
||||
import QtQuick.Window
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
|
||||
@@ -15,10 +16,13 @@ PanelWindow {
|
||||
readonly property bool wantOpen: root.popouts.hasCurrent
|
||||
// Keep the window rendered through the close (fold-up) animation, then hide it.
|
||||
property bool rendered: false
|
||||
// Set once the surface has actually held the keyboard since this open — see panel.surfaceActive.
|
||||
property bool sawFocus: false
|
||||
onWantOpenChanged: {
|
||||
if (root.wantOpen) {
|
||||
hideTimer.stop();
|
||||
root.rendered = true;
|
||||
root.sawFocus = false;
|
||||
// Grab keyboard focus so the keyboard works even when opened via keybind (no click to focus us).
|
||||
// Deferred: the surface must be visible before forceActiveFocus takes on this layer.
|
||||
// Focus the panel's first control (PopoutPanel.focusFirst); fall back to the shape (e.g. Tray).
|
||||
@@ -143,6 +147,20 @@ PanelWindow {
|
||||
focus: true
|
||||
Keys.onEscapePressed: root.popouts.close()
|
||||
|
||||
// Close as soon as the surface loses the keyboard — to another toplevel, an auth prompt
|
||||
// raised by a popout action, or another layer surface. PanelWindow exposes no focus state,
|
||||
// so this reads Qt's Window.active, which goes true when niri hands the on-demand surface
|
||||
// the keyboard and false the moment anything else takes it (measured). root.sawFocus gates
|
||||
// the initial false: the surface is mapped a moment before focus arrives, and reacting to
|
||||
// that would close every popout right after opening it.
|
||||
readonly property bool surfaceActive: Window.active
|
||||
onSurfaceActiveChanged: {
|
||||
if (panel.surfaceActive)
|
||||
root.sawFocus = true;
|
||||
else if (root.sawFocus && root.wantOpen)
|
||||
root.popouts.close();
|
||||
}
|
||||
|
||||
ShapePath {
|
||||
fillColor: Theme.base
|
||||
strokeWidth: 0
|
||||
|
||||
@@ -82,4 +82,21 @@ QtObject {
|
||||
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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user