feat(quickshell): operate popouts fully by keyboard
Update PKGBUILD version / update-pkgver (push) Successful in 6s

Popouts can now be opened by niri keybind and driven entirely without a
mouse — the whole point of the keybinds.

- keybind: PopoutRouter singleton + IpcHandler (target "popout") route
  toggle/open/close to the focused monitor's PopoutState via
  Niri.activeOutput; each widget registers its popout by name and its
  click handler toggles by name. Mod+A now opens the audio popout
  (replacing walker's audio menu).
- navigation: shared controls (Toggle/ListRow/VolumeSlider) and the new
  IconButton are focusable (activeFocusOnTab, focus ring, Space/Enter,
  slider Left/Right); PopoutPanel focuses the first control on open and
  maps Up/Down onto Qt's Tab chain; an open popout grabs the keyboard.
- ad-hoc glyph buttons (audio mute, media transport, notif dismiss, BT
  forget) folded into IconButton; destructive actions turn red.

Tray stays mouse-only (per-icon menu, not keybind-addressable).
This commit is contained in:
2026-07-08 09:46:01 +02:00
parent 8680a6e5dd
commit a229ee9c6a
27 changed files with 333 additions and 95 deletions
+6 -2
View File
@@ -70,9 +70,13 @@ quickshell is the **default bar and notification daemon**, spawned by niri as `q
cd defaults/xdg/quickshell/moonarch && for f in *.qml; do [ "$f" = shell.qml ] && continue; n=${f%.qml}; grep -q 'pragma Singleton' "$f" && echo "singleton $n $f" || echo "$n $f"; done | sort > qmldir
```
- **Own popouts:** a composed widget carries its own popout as a `Component` and hands it to the shared animated host via `popouts.open(component, rect, payload)` — it renders in the same morphing frame as the system popouts. Pattern (see any indicator, e.g. `AudioIndicator.qml`): an inline `Component { AudioPopout { popouts: root.popouts } }` plus `root.popouts.open(that, rect, payload)`. The widget file needs `pragma ComponentBehavior: Bound` so the popout resolves `root` when the shared host instantiates it. There is no central popout registry.
- **Own popouts:** a composed widget carries its own popout as a `Component` and renders it in the shared morphing host. Pattern (see any indicator, e.g. `AudioIndicator.qml`): an inline `Component { AudioPopout { popouts: root.popouts } }`; the widget declares a `popoutName` and registers on completion (`popouts.register(popoutName, popoutComponent, root, payloadFn)`), then its click handler calls `popouts.toggleByName(popoutName)`. The widget file needs `pragma ComponentBehavior: Bound` so the popout resolves `root` when the shared host instantiates it. The low-level `popouts.open(component, rect, payload)` still exists for non-registered popouts (Tray).
Rationale + spike results: `DECISIONS.md` (2026-07-05 pilot + user-extension, 2026-07-06 composition + composable popouts).
- **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`. Tray is not keybind-addressable.
- **Keyboard navigation:** an open popout grabs the keyboard, so it is fully operable without the mouse. Controls (`Toggle`, `ListRow`, `VolumeSlider`, `IconButton`) set `activeFocusOnTab` and show a focus ring; `PopoutPanel` focuses the first control on open and maps ↑/↓ onto Qt's native Tab chain (`nextItemInFocusChain` + an `activeFocusOnTab` filter). Keys: Tab/↑/↓ move focus, ←/→ nudge a slider ±5%, Space/Enter activate, Esc closes. Icon actions use the shared `IconButton` (destructive ones set `activeColor: Theme.red`); Rectangle-shaped buttons (clear-all, notification actions) are made focusable inline.
Rationale + spike results: `DECISIONS.md` (2026-07-05 pilot + user-extension, 2026-07-06 composition + composable popouts, 2026-07-08 keybind registry + IPC router, keyboard-navigable controls).
## mpv + ModernZ OSC
+12
View File
@@ -1,5 +1,17 @@
# Decisions
## 2026-07-08 Quickshell popouts: keyboard-navigable controls
- **Who**: Dominik, ClaudeCode
- **Why**: Popouts open by keybind (Mod+A) and grab the keyboard, but nothing inside was operable without a mouse — the shared controls (`Toggle`/`ListRow`/`VolumeSlider`) and ad-hoc glyph buttons were pure `MouseArea`, took no focus, reacted to no key. Opening by key is pointless if you then need the mouse. Goal: full mouse-free operation.
- **Tradeoffs**: Two focus mechanisms weighed. (A) A central focus controller in `PopoutPanel` tracking a list of focusable children and moving an index — full control, but must collect dynamic children (Repeater delegates) and reimplements what Qt already provides. (B) Qt-native Tab chain: each control sets `activeFocusOnTab`, Qt links them in tree order (handles the deep nesting in `DeviceControl`/`Switcher`/`Repeater` for free), Tab/Shift+Tab work out of the box. Picked B; `PopoutPanel` only adds initial focus on open and maps ↑/↓ onto the same chain via `nextItemInFocusChain` + an `activeFocusOnTab` filter. Keyboard grab is "whenever open" (decided with the keybind feature), so mouse- and keybind-opened popouts behave identically. Ad-hoc glyph buttons (mute, media transport, dismiss, forget) folded into a shared `IconButton` rather than wiring focus/keys per site; Rectangle-shaped buttons (clear-all, notification actions) made focusable inline — too few and too varied for a shared type.
- **How**: `Toggle`/`ListRow`/`VolumeSlider` gain `activeFocusOnTab`, a focus ring, and `Keys` handlers (Space/Enter activate; slider Left/Right nudge ±5%, emitting both `moved` and `released`). New `IconButton.qml` — focusable glyph with `baseColor`/`activeColor`, Space/Enter → `activated()`. `PopoutPanel` gains `focusFirst`/`moveFocus`/`nextFocusable` + Up/Down keys + initial focus on completion. `PopoutHost` redirects the initial focus from the panel shape to the first control (`content.item.focusFirst`). Popouts: `AudioPopout` mute, `MprisPopout` prev/play/next, `NotifCenter` dismiss and `BluetoothPopout` forget → `IconButton` (destructive ones `activeColor: Theme.red`); `NotifCenter` clear-all + notification action pills made focusable inline. Verified via a repo test instance (`quickshell -p .` + `qs ipc call popout toggle`): all popouts load/open clean; interactive Tab/arrow/Space/Esc confirmed by Dom.
## 2026-07-08 Quickshell popouts: keybind-addressable via registry + IPC router
- **Who**: Dominik, ClaudeCode
- **Why**: Bar popouts (audio, network, battery, …) could only be opened by clicking their bar widget. Goal: open/toggle them via niri keybind too. The pilot deliberately shipped *without* a central registry — each widget held its own popout `Component` and called `popouts.open(component, rect, payload)` directly (composable design, see 2026-07-06). A keybind has no widget to click, so it needs a way to address a popout by name, place it on the active monitor, and give it keyboard focus.
- **Tradeoffs**: (A) Leave registry-free, hardcode per-popout IPC handlers — repetitive, each popout its own plumbing, no shared anchor/rect logic. (B) Add a per-screen registry + one router — small architectural addition against the original registry-free stance, but folds the duplicated `mapToItem`+`Qt.rect`+`open()` block (repeated verbatim in 9 widgets) into one `openByName()`, so it removes duplication rather than adding it. Picked B. Keyboard-grab scope: chose "grab whenever open" (Exclusive keyboard focus while any popout is open, mouse or keybind) over "only on keybind" — consistent behavior, Escape always closes; cost is that typing goes nowhere while a popout is open, accepted. Screen routing via niri's focused output (workspace `is_focused``output`), window-independent so it resolves on an empty workspace; matches Quickshell `screen.name` (same convention `Workspaces.qml` already uses). Tray excluded — it is per-icon with a menu payload, not a sensible keybind target.
- **How**: New `PopoutRouter.qml` singleton (mirrors `NotifService`'s singleton-hosts-IpcHandler pattern) exposes `IpcHandler { target: "popout" }` with `toggle`/`open`/`close`; resolves the active monitor's `PopoutState` via `Niri.activeOutput` (new readonly property), falls back to any registered state. `PopoutState.qml` gains `screenName`, a `registry` (name → {component, anchorItem, payloadFn}), `currentName`, and `register`/`openByName`/`toggleByName`; self-registers with the router on completion. `Bar.qml` passes `screenName`. `PopoutHost.qml` keyboard focus is now `wantOpen ? Exclusive : None` + deferred `forceActiveFocus()` so Escape works on keybind-open. The 9 keybind-addressable widgets (`AudioIndicator`, `BacklightIndicator`, `NetworkIndicator`, `BluetoothIndicator`, `NightlightIndicator`, `CpuGovIndicator`, `Battery`, `NotificationIndicator`, `MprisWidget`) register on completion and their click handlers call `toggleByName(popoutName)`; `MprisWidget` passes `() => root.player` as payloadFn (live player at open time). `qmldir` regenerated for the new singleton. Niri keybinds call `qs -c moonarch ipc call popout toggle <name>`.
## 2026-06-08 Fontconfig generic-family defaults via owned conf.d (65)
- **Who**: Dominik, ClaudeCode
- **Why**: Waybar rendered in Hack instead of Ubuntu after the Nerd Fonts "Ubuntu"→"UbuntuSans" rename (Canonical rebrand). Waybar's style.css referenced the now-dead family "Ubuntu Nerd Font"; fontconfig token-matched it to "Hack Nerd Font". An unowned hand-written `/etc/fonts/local.conf` additionally pinned sans-serif/monospace to Hack — a stale relic.
+1 -1
View File
@@ -144,7 +144,7 @@ binds {
Mod+Space hotkey-overlay-title="Run an Application: walker" { spawn "walker"; }
Mod+Escape hotkey-overlay-title="Session Menu: moonset" { spawn "moonset"; }
Mod+A { spawn "walker" "-s" "audio"; }
Mod+A hotkey-overlay-title="Audio: quickshell popout" { spawn "qs" "-c" "moonarch" "ipc" "call" "popout" "toggle" "audio"; }
XF86AudioRaiseVolume allow-when-locked=true { spawn-sh "wpctl set-volume @DEFAULT_AUDIO_SINK@ 0.1+"; }
XF86AudioLowerVolume allow-when-locked=true { spawn-sh "wpctl set-volume @DEFAULT_AUDIO_SINK@ 0.1-"; }
@@ -7,6 +7,11 @@ import Quickshell.Services.Pipewire
Item {
id: root
property var popouts: null
property string popoutName: "audio"
// Register with the shared popout state so this popout is addressable by keybind (niri IPC).
Component.onCompleted: if (root.popouts)
root.popouts.register(popoutName, popoutComponent, root, null)
// This widget's popout — handed to the shared animated host on click.
Component {
@@ -69,8 +74,7 @@ Item {
if (!root.popouts)
return;
// Right edge in screen coords; the host anchors content to rect.x - width (right-aligned).
const pr = root.mapToItem(null, root.width, root.height + 4);
root.popouts.open(popoutComponent, Qt.rect(pr.x, pr.y, 0, 0), null);
root.popouts.toggleByName(popoutName);
}
onWheel: wheel => {
if (!root.sinkAudio)
@@ -55,21 +55,16 @@ PopoutPanel {
width: root.contentWidth
spacing: 8
Text {
IconButton {
id: muteBtn
anchors.verticalCenter: parent.verticalCenter
width: 22
horizontalAlignment: Text.AlignHCenter
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize + 3
color: (dc.au && dc.au.muted) ? Theme.subtext0 : Theme.text
pixelSize: Theme.fontSize + 3
baseColor: (dc.au && dc.au.muted) ? Theme.subtext0 : Theme.text
text: (dc.au && dc.au.muted) ? dc.mutedIcon : dc.activeIcon
MouseArea {
anchors.fill: parent
onClicked: {
if (dc.au)
dc.au.muted = !dc.au.muted;
}
onActivated: {
if (dc.au)
dc.au.muted = !dc.au.muted;
}
}
VolumeSlider {
@@ -6,6 +6,11 @@ import QtQuick
Item {
id: root
property var popouts: null
property string popoutName: "backlight"
// Register with the shared popout state so this popout is addressable by keybind (niri IPC).
Component.onCompleted: if (root.popouts)
root.popouts.register(popoutName, popoutComponent, root, null)
// This widget's popout — handed to the shared animated host on click.
Component {
@@ -49,8 +54,7 @@ Item {
onClicked: {
if (!root.popouts)
return;
const pr = root.mapToItem(null, root.width, root.height + 4);
root.popouts.open(popoutComponent, Qt.rect(pr.x, pr.y, 0, 0), null);
root.popouts.toggleByName(popoutName);
}
onWheel: wheel => {
const step = wheel.angleDelta.y > 0 ? 0.05 : -0.05;
+1
View File
@@ -30,6 +30,7 @@ PanelWindow {
PopoutState {
id: popoutState
screenName: bar.screen ? bar.screen.name : ""
}
PopoutHost {
popouts: popoutState
+6 -2
View File
@@ -7,6 +7,11 @@ import Quickshell.Services.UPower
Row {
id: root
property var popouts: null
property string popoutName: "battery"
// Register with the shared popout state so this popout is addressable by keybind (niri IPC).
Component.onCompleted: if (root.popouts)
root.popouts.register(popoutName, popoutComponent, root, null)
// This widget's popout — handed to the shared animated host on click.
Component {
@@ -58,8 +63,7 @@ Row {
onTapped: {
if (!root.popouts)
return;
const pr = root.mapToItem(null, root.width, root.height + 4);
root.popouts.open(popoutComponent, Qt.rect(pr.x, pr.y, 0, 0), null);
root.popouts.toggleByName(popoutName);
}
}
Tooltip {
@@ -7,6 +7,11 @@ import Quickshell.Bluetooth
Item {
id: root
property var popouts: null
property string popoutName: "bluetooth"
// Register with the shared popout state so this popout is addressable by keybind (niri IPC).
Component.onCompleted: if (root.popouts)
root.popouts.register(popoutName, popoutComponent, root, null)
// This widget's popout — handed to the shared animated host on click.
Component {
@@ -43,8 +48,7 @@ Item {
onClicked: {
if (!root.popouts)
return;
const pr = root.mapToItem(null, root.width, root.height + 4);
root.popouts.open(popoutComponent, Qt.rect(pr.x, pr.y, 0, 0), null);
root.popouts.toggleByName(popoutName);
}
}
@@ -127,18 +127,15 @@ PopoutPanel {
return "";
}
}
// Forget (paired devices only) — own MouseArea grabs the click before the row.
Text {
// Forget (paired devices only) — its own focus stop / click target, separate from the row.
IconButton {
anchors.verticalCenter: parent.verticalCenter
visible: devRow.modelData.paired
text: String.fromCodePoint(0xf0156) // md-close
color: Theme.subtext0
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
MouseArea {
anchors.fill: parent
onClicked: devRow.modelData.forget()
}
pixelSize: Theme.fontSize
baseColor: Theme.subtext0
activeColor: Theme.red
onActivated: devRow.modelData.forget()
}
}
Text {
@@ -6,6 +6,11 @@ import QtQuick
Item {
id: root
property var popouts: null
property string popoutName: "cpugov"
// Register with the shared popout state so this popout is addressable by keybind (niri IPC).
Component.onCompleted: if (root.popouts)
root.popouts.register(popoutName, popoutComponent, root, null)
// This widget's popout — handed to the shared animated host on click.
Component {
@@ -57,8 +62,7 @@ Item {
onClicked: {
if (!root.popouts)
return;
const pr = root.mapToItem(null, root.width, root.height + 4);
root.popouts.open(popoutComponent, Qt.rect(pr.x, pr.y, 0, 0), null);
root.popouts.toggleByName(popoutName);
}
}
HoverHandler {
@@ -0,0 +1,30 @@
// ABOUTME: Focusable icon button — a Nerd Font glyph reacting to hover, keyboard focus, and Space/Enter.
// ABOUTME: Shared by popouts for glyph actions (mute, media transport) so they are keyboard-navigable.
import QtQuick
Text {
id: root
property int pixelSize: Theme.fontSize + 3
// Colour when neither hovered nor focused; consumers override to reflect state (e.g. muted → subtext0).
property color baseColor: Theme.text
// Colour when hovered or focused; consumers override for destructive actions (e.g. Theme.red).
property color activeColor: Theme.accent
signal activated
activeFocusOnTab: true
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
font.family: Theme.fontFamily
font.pixelSize: root.pixelSize
color: (hover.hovered || root.activeFocus) ? root.activeColor : root.baseColor
HoverHandler {
id: hover
}
MouseArea {
anchors.fill: parent
onClicked: root.activated()
}
Keys.onSpacePressed: root.activated()
Keys.onReturnPressed: root.activated()
}
+8 -1
View File
@@ -10,7 +10,14 @@ Rectangle {
implicitHeight: 32
radius: Theme.radius
color: hover.hovered ? Theme.surface0 : "transparent"
// Keyboard-navigable: highlight on hover OR focus, ring when focused, Space/Enter activates.
color: (hover.hovered || root.activeFocus) ? Theme.surface0 : "transparent"
activeFocusOnTab: true
border.width: root.activeFocus ? 1 : 0
border.color: Theme.accent
Keys.onSpacePressed: root.clicked()
Keys.onReturnPressed: root.clicked()
Item {
id: inner
@@ -100,52 +100,28 @@ PopoutPanel {
anchors.centerIn: parent
spacing: 20
Text {
IconButton {
anchors.verticalCenter: parent.verticalCenter
visible: root.player && root.player.canGoPrevious
text: String.fromCodePoint(0xf04ae) // md-skip-previous
color: prevHover.hovered ? Theme.accent : Theme.text
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize + 6
HoverHandler {
id: prevHover
}
MouseArea {
anchors.fill: parent
onClicked: if (root.player)
root.player.previous()
}
pixelSize: Theme.fontSize + 6
onActivated: if (root.player)
root.player.previous()
}
Text {
IconButton {
anchors.verticalCenter: parent.verticalCenter
text: root.playing ? String.fromCodePoint(0xf03e4) : String.fromCodePoint(0xf040a) // pause / play
color: playHover.hovered ? Theme.accent : Theme.text
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize + 10
HoverHandler {
id: playHover
}
MouseArea {
anchors.fill: parent
onClicked: if (root.player)
root.player.togglePlaying()
}
pixelSize: Theme.fontSize + 10
onActivated: if (root.player)
root.player.togglePlaying()
}
Text {
IconButton {
anchors.verticalCenter: parent.verticalCenter
visible: root.player && root.player.canGoNext
text: String.fromCodePoint(0xf04ad) // md-skip-next
color: nextHover.hovered ? Theme.accent : Theme.text
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize + 6
HoverHandler {
id: nextHover
}
MouseArea {
anchors.fill: parent
onClicked: if (root.player)
root.player.next()
}
pixelSize: Theme.fontSize + 6
onActivated: if (root.player)
root.player.next()
}
}
}
@@ -8,6 +8,12 @@ Item {
id: root
property var popouts: null
property int maxWidth: 220
property string popoutName: "media"
// Register with the shared popout state so this popout is addressable by keybind (niri IPC).
// payloadFn is evaluated at open time so the popout always gets the current active player.
Component.onCompleted: if (root.popouts)
root.popouts.register(popoutName, popoutComponent, root, () => root.player)
// This widget's popout — handed to the shared animated host on click.
Component {
@@ -59,8 +65,7 @@ Item {
onClicked: {
if (!root.popouts || !root.player)
return;
const pr = root.mapToItem(null, root.width, root.height + 4);
root.popouts.open(popoutComponent, Qt.rect(pr.x, pr.y, 0, 0), root.player);
root.popouts.toggleByName(popoutName);
}
onWheel: wheel => {
if (!root.player)
@@ -7,6 +7,11 @@ import Quickshell.Networking
Item {
id: root
property var popouts: null
property string popoutName: "network"
// Register with the shared popout state so this popout is addressable by keybind (niri IPC).
Component.onCompleted: if (root.popouts)
root.popouts.register(popoutName, popoutComponent, root, null)
// This widget's popout — handed to the shared animated host on click.
Component {
@@ -56,8 +61,7 @@ Item {
onClicked: {
if (!root.popouts)
return;
const pr = root.mapToItem(null, root.width, root.height + 4);
root.popouts.open(popoutComponent, Qt.rect(pr.x, pr.y, 0, 0), null);
root.popouts.toggleByName(popoutName);
}
}
@@ -6,6 +6,11 @@ import QtQuick
Item {
id: root
property var popouts: null
property string popoutName: "nightlight"
// Register with the shared popout state so this popout is addressable by keybind (niri IPC).
Component.onCompleted: if (root.popouts)
root.popouts.register(popoutName, popoutComponent, root, null)
// This widget's popout — handed to the shared animated host on click.
Component {
@@ -38,8 +43,7 @@ Item {
}
if (!root.popouts)
return;
const pr = root.mapToItem(null, root.width, root.height + 4);
root.popouts.open(popoutComponent, Qt.rect(pr.x, pr.y, 0, 0), null);
root.popouts.toggleByName(popoutName);
}
}
HoverHandler {
@@ -16,6 +16,14 @@ Singleton {
property int focusedWindowId: -1
property var activeWindow: null
// Connector name of the monitor holding the focused workspace ("" until known). Window-independent:
// an empty focused workspace still carries is_focused, so this resolves even with nothing on screen.
// Used to route keybind-triggered popouts to the active monitor (matches Quickshell screen.name).
readonly property string activeOutput: {
const w = root.workspaces.find(x => x && x.is_focused);
return w ? w.output : "";
}
function recomputeActive() {
root.activeWindow = (root.focusedWindowId >= 0 && root.windowsById[root.focusedWindowId])
? root.windowsById[root.focusedWindowId]
@@ -14,19 +14,26 @@ PopoutPanel {
spacing: 10
Rectangle {
id: clearBtn
anchors.verticalCenter: parent.verticalCenter
visible: NotifService.count > 0
implicitWidth: 24
implicitHeight: 18
radius: Theme.radius
color: clearHover.containsMouse ? Theme.surface1 : Theme.surface0
color: (clearHover.containsMouse || clearBtn.activeFocus) ? Theme.surface1 : Theme.surface0
activeFocusOnTab: true
border.width: clearBtn.activeFocus ? 1 : 0
border.color: Theme.accent
Keys.onSpacePressed: NotifService.clearAll()
Keys.onReturnPressed: NotifService.clearAll()
Text {
anchors.centerIn: parent
text: String.fromCodePoint(0xf01b4) // md-delete (clear all)
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
color: clearHover.containsMouse ? Theme.red : Theme.subtext0
color: (clearHover.containsMouse || clearBtn.activeFocus) ? Theme.red : Theme.subtext0
}
MouseArea {
id: clearHover
@@ -114,9 +121,15 @@ PopoutPanel {
id: actionBtn
required property var modelData // NotificationAction
radius: Theme.radius
color: actionHover.containsMouse ? Theme.surface2 : Theme.surface1
color: (actionHover.containsMouse || actionBtn.activeFocus) ? Theme.surface2 : Theme.surface1
implicitWidth: actionText.implicitWidth + 14
implicitHeight: actionText.implicitHeight + 6
activeFocusOnTab: true
border.width: actionBtn.activeFocus ? 1 : 0
border.color: Theme.accent
Keys.onSpacePressed: actionBtn.modelData.invoke()
Keys.onReturnPressed: actionBtn.modelData.invoke()
Text {
id: actionText
@@ -138,21 +151,16 @@ PopoutPanel {
}
// Dismiss (removes from history).
Text {
IconButton {
anchors.top: parent.top
anchors.right: parent.right
anchors.topMargin: 4
anchors.rightMargin: 6
text: String.fromCodePoint(0xf0156) // md-close
color: dismissHover.containsMouse ? Theme.red : Theme.subtext0
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
MouseArea {
id: dismissHover
anchors.fill: parent
hoverEnabled: true
onClicked: row.modelData.close()
}
pixelSize: Theme.fontSize
baseColor: Theme.subtext0
activeColor: Theme.red
onActivated: row.modelData.close()
}
}
}
@@ -6,6 +6,11 @@ import QtQuick
Item {
id: root
property var popouts: null
property string popoutName: "notifications"
// Register with the shared popout state so this popout is addressable by keybind (niri IPC).
Component.onCompleted: if (root.popouts)
root.popouts.register(popoutName, popoutComponent, root, null)
// This widget's popout — handed to the shared animated host on click.
Component {
@@ -42,8 +47,7 @@ Item {
}
if (!root.popouts)
return;
const pr = root.mapToItem(null, root.width, root.height + 4);
root.popouts.open(popoutComponent, Qt.rect(pr.x, pr.y, 0, 0), null);
root.popouts.toggleByName(popoutName);
}
}
@@ -19,6 +19,15 @@ PanelWindow {
if (root.wantOpen) {
hideTimer.stop();
root.rendered = true;
// 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).
Qt.callLater(() => {
if (content.item && content.item.focusFirst)
content.item.focusFirst();
else
panel.forceActiveFocus();
});
} else {
hideTimer.restart();
}
@@ -41,7 +50,9 @@ PanelWindow {
visible: root.rendered
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand
// Exclusive while open so a keybind-opened popout gets the keyboard (Escape, navigation) with no
// click; None while closed to release it. Compositor keybinds still fire (niri sees keys first).
WlrLayershell.keyboardFocus: root.wantOpen ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None
WlrLayershell.namespace: "moonarch-popout"
// Backdrop — click outside the menu closes it. Our own MouseArea (not a compositor grab).
@@ -1,6 +1,7 @@
// ABOUTME: Shared popout chrome — a fixed-width, padded, titled column wrapping popout content.
// ABOUTME: Header row shows the title with an optional right-aligned control (headerActions), content follows.
import QtQuick
import QtQuick.Window
Column {
id: root
@@ -11,6 +12,36 @@ Column {
property int panelWidth: 320
readonly property int contentWidth: panelWidth - leftPadding - rightPadding
// --- Keyboard navigation -------------------------------------------------
// Controls set activeFocusOnTab; Qt handles Tab/Shift+Tab across them in tree order.
// We focus the first control on open and map Up/Down onto the same chain.
// Walk the focus chain from `from` (or from root if null) to the next activeFocusOnTab item.
function nextFocusable(from, forward) {
let it = (from || root).nextItemInFocusChain(forward);
const start = it;
let guard = 0;
while (it && !it.activeFocusOnTab) {
it = it.nextItemInFocusChain(forward);
if (it === start || ++guard > 300)
return null;
}
return it;
}
function focusFirst() {
const it = root.nextFocusable(null, true);
if (it)
it.forceActiveFocus(Qt.TabFocusReason);
}
function moveFocus(forward) {
const it = root.nextFocusable(Window.activeFocusItem, forward);
if (it)
it.forceActiveFocus(forward ? Qt.TabFocusReason : Qt.BacktabFocusReason);
}
Keys.onUpPressed: root.moveFocus(false)
Keys.onDownPressed: root.moveFocus(true)
// Morph case: window already visible when a new panel loads. Initial-open is (re)driven by PopoutHost.
Component.onCompleted: Qt.callLater(root.focusFirst)
width: panelWidth
leftPadding: 12
rightPadding: 12
@@ -0,0 +1,44 @@
// ABOUTME: System-wide popout router + IPC endpoint — resolves a popout name on the active monitor's PopoutState.
// ABOUTME: Each PopoutState self-registers under its screen name; niri's focused output picks the target.
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
Singleton {
id: root
// screenName -> PopoutState. Each PopoutState registers itself on completion (and clears on destruction).
property var states: ({})
// The PopoutState on the focused monitor; falls back to any registered state (single-monitor,
// or before niri has reported a focused output).
function activeState() {
const out = Niri.activeOutput;
if (out && root.states[out])
return root.states[out];
const keys = Object.keys(root.states);
return keys.length ? root.states[keys[0]] : null;
}
// IPC surface for niri keybinds: `qs -c moonarch ipc call popout toggle <name>`.
IpcHandler {
target: "popout"
function toggle(name: string): void {
const s = root.activeState();
if (s)
s.toggleByName(name);
}
function open(name: string): void {
const s = root.activeState();
if (s)
s.openByName(name);
}
// Close whichever popout currently holds the system-wide slot, on any monitor.
function close(): void {
if (PopoutBus.owner)
PopoutBus.owner.close();
}
}
}
@@ -6,16 +6,27 @@ 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;
}
@@ -25,10 +36,46 @@ QtObject {
// 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;
const pr = it.mapToItem(null, it.width, it.height + 4);
root.open(e.component, Qt.rect(pr.x, pr.y, 0, 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)
@@ -12,6 +12,14 @@ Rectangle {
radius: height / 2
color: checked ? Theme.accent : Theme.surface1
// Keyboard-navigable: Tab-focusable, ring when focused, Space/Enter flips it.
activeFocusOnTab: true
border.width: root.activeFocus ? 2 : 0
border.color: Theme.text
Keys.onSpacePressed: root.toggled()
Keys.onReturnPressed: root.toggled()
Rectangle {
width: parent.height - 4
height: width
@@ -14,6 +14,17 @@ Item {
readonly property real ratio: Math.max(0, Math.min(1, root.value))
// Keyboard-navigable: Tab-focusable, Left/Right nudge the value by 5%.
// Emit both moved (live-apply consumers) and released (apply-on-release consumers, e.g. nightlight).
activeFocusOnTab: true
function step(d) {
const v = Math.max(0, Math.min(1, root.value + d));
root.moved(v);
root.released(v);
}
Keys.onLeftPressed: root.step(-0.05)
Keys.onRightPressed: root.step(0.05)
Rectangle {
id: track
anchors.left: parent.left
@@ -33,12 +44,21 @@ Item {
}
Rectangle {
id: handle
width: 14
height: 14
// Grow + fill accent (with a contrasting ring) when focused so keyboard focus is unmistakable.
width: root.activeFocus ? 20 : 14
height: width
radius: width / 2
anchors.verticalCenter: parent.verticalCenter
x: (root.width - width) * root.ratio
color: Theme.text
color: root.activeFocus ? Theme.accent : Theme.text
border.width: root.activeFocus ? 3 : 0
border.color: Theme.text
Behavior on width {
NumberAnimation {
duration: Theme.animDurationShort
easing.type: Theme.animEasing
}
}
}
MouseArea {
anchors.fill: parent
+2
View File
@@ -10,6 +10,7 @@ BluetoothPopout BluetoothPopout.qml
Clock Clock.qml
CpuGovIndicator CpuGovIndicator.qml
CpuGovPopout CpuGovPopout.qml
IconButton IconButton.qml
IdleInhibitorIndicator IdleInhibitorIndicator.qml
ListRow ListRow.qml
ModuleBox ModuleBox.qml
@@ -35,6 +36,7 @@ singleton NightlightService NightlightService.qml
singleton Niri Niri.qml
singleton NotifService NotifService.qml
singleton PopoutBus PopoutBus.qml
singleton PopoutRouter PopoutRouter.qml
singleton Theme Theme.qml
Toggle Toggle.qml
Tooltip Tooltip.qml