feat(quickshell): operate popouts fully by keyboard
Update PKGBUILD version / update-pkgver (push) Successful in 6s
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -30,6 +30,7 @@ PanelWindow {
|
||||
|
||||
PopoutState {
|
||||
id: popoutState
|
||||
screenName: bar.screen ? bar.screen.name : ""
|
||||
}
|
||||
PopoutHost {
|
||||
popouts: popoutState
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user