feat(quickshell): composable popouts — each widget owns its popout

The composition refactor left Popouts.qml a hardcoded switch, so a
user-composed widget could join the bar but couldn't open a popout in
the shared animated overlay — second-class, and a widget without a
popout is useless for most cases. quickshell's native PopupWindow would
be composable but drops the unfold/morph animation; keep the animation,
make the registry composable instead (caelestia's central Panels.qml
registry was considered and rejected — it fits a few author-owned edge
drawers, not many user-composable per-widget popouts).

PopoutState.open() now takes a Component instead of a name; each widget
carries its popout inline and hands it to the shared host, which renders
whatever is current. Popouts.qml is gone — no central registry, a user
widget brings its own popout and it renders in the same morphing frame.

Verified: qmllint clean, and an end-to-end config where a user widget
opens its own popout on startup instantiates with no errors or binding
loops. Visual/animation not machine-verifiable.
This commit is contained in:
2026-07-06 17:17:59 +02:00
parent 450580aec1
commit 89988c1405
16 changed files with 116 additions and 119 deletions
+2 -2
View File
@@ -69,9 +69,9 @@ 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
```
- **Not yet composable:** `Popouts.qml` is still a hardcoded `switch` registry — user-added *popouts* (as opposed to bar widgets) remain a follow-on.
- **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.
Rationale + spike results: `DECISIONS.md` (2026-07-05 pilot + user-extension, 2026-07-06 switch to composition).
Rationale + spike results: `DECISIONS.md` (2026-07-05 pilot + user-extension, 2026-07-06 composition + composable popouts).
## mpv + ModernZ OSC
+6
View File
@@ -194,3 +194,9 @@
- **Why**: The 2026-07-05 user-extension mechanism (JSON `moonarch.json` + `UserConfig` singleton + per-widget `enabled()` gates + `UserWidgets.qml` slot) is **not quickshell-conforming**. Verified against docs + source: quickshell has **no** native settings/toggle/override/merge/drop-in concept — a config is composable QML, and `$XDG_CONFIG_HOME` is searched **before** `$XDG_CONFIG_DIRS` (`src/launch/command.cpp`: `configBaseDirs()` prepends config-home, first `shell.qml` found wins). The toggle layer was a bespoke graft mimicking composition on a system-owned config, built without sign-off.
- **Tradeoffs**: Hiding a widget now means owning + editing your own `shell.qml` (more friction than a one-line JSON edit) — but conform, and reorder / add-own / replace-a-system-widget all fall out for free (the three deferred "spekulativ" items become moot). Composer widgets bind `popouts: bar.popouts` explicitly (slightly verbose). Once a user owns their `shell.qml`, structural default updates no longer reach them (only imported component updates). Adopted the official **named-config** distribution pattern (`/etc/xdg/quickshell/moonarch/`, `-c moonarch`) over the unnamed default — one extra migration (niri spawn, Alt+W, file move) for the documented-correct form. `Popouts.qml` stays a hardcoded switch; user-added *popouts* remain a follow-on.
- **How**: All `defaults/xdg/quickshell/*` moved into a `moonarch/` subfolder (generic PKGBUILD find-loop installs to `/etc/xdg/quickshell/moonarch/`, no PKGBUILD edit). `Bar.qml` → generic container with `leftContent`/`centerContent`/`rightContent` slots + `popouts` alias. `shell.qml` → the default composition filling those slots. Deleted `UserConfig.qml` + `moonarch.json` handling + `UserWidgets` slot; stripped `&& UserConfig.enabled(...)` from the 7 self-hiding widgets (self-hide logic kept). `qmldir` regenerated (no `UserConfig`). `config.kdl` spawn + Alt+W → `quickshell -c moonarch`. Verified: `qmllint` clean (only quickshell C++ type-resolution artifacts), throwaway `quickshell -p …/moonarch``Configuration Loaded`, no binding loop/error.
## 2026-07-06 Quickshell bar: composable popouts (widget owns its popout)
- **Who**: Dominik, ClaudeCode
- **Why**: The composition switch left `Popouts.qml` a hardcoded `switch` — a user-composed widget could join the bar but couldn't open a popout in the shared animated overlay, so it was second-class (a widget without a popout is useless for most cases). quickshell's native `PopupWindow` would be composable by construction but drops moonarch's unfold/morph animation (one shared overlay) — decided: **keep the animation**, make the registry composable instead. caelestia's central `Panels.qml` + `DrawerVisibilities` was considered and rejected: it fits a handful of author-owned edge drawers, not many user-composable per-widget popouts (it reintroduces central registration).
- **Tradeoffs**: `open()` takes a `Component` instead of a name — less conventional, but it removes the central registry entirely: a widget carries its own popout and renders it in the shared morphing frame. Churn across the 10 trigger widgets + `open()` signature; each widget file gains `pragma ComponentBehavior: Bound` so the inline popout `Component` resolves `root` when the shared host instantiates it.
- **How**: `PopoutState.currentName``currentComponent`; `open(component, rect, payload)`. `PopoutHost` renders `Loader { sourceComponent: popouts.currentComponent }`. `Popouts.qml` deleted, `qmldir` regenerated. Each of the 10 widgets declares an inline `Component { XxxPopout { popouts: root.popouts } }` (Tray also wires `menuHandle: popouts.payload`) and calls `open(thatComponent, …)`. Verified: `qmllint` clean, and an end-to-end throwaway config where a user widget opens its **own** popout on startup instantiates in the shared host with no errors/binding loops. Visual/animation unverified (needs a live eyeball).
@@ -1,5 +1,6 @@
// ABOUTME: Audio bar widget — volume icon + percent; scroll adjusts volume, click opens the audio popout.
// ABOUTME: Holds a persistent PwObjectTracker so the default sink/source stay bound for live volume state.
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell.Services.Pipewire
@@ -7,6 +8,14 @@ Item {
id: root
property var popouts: null
// This widget's popout — handed to the shared animated host on click.
Component {
id: popoutComponent
AudioPopout {
popouts: root.popouts
}
}
implicitWidth: row.width
implicitHeight: Theme.barHeight
@@ -61,7 +70,7 @@ Item {
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("audio", Qt.rect(pr.x, pr.y, 0, 0), null);
root.popouts.open(popoutComponent, Qt.rect(pr.x, pr.y, 0, 0), null);
}
onWheel: wheel => {
if (!root.sinkAudio)
@@ -1,11 +1,20 @@
// ABOUTME: Backlight bar widget — brightness icon + percent; scroll adjusts, click opens the slider popout.
// ABOUTME: Backed by the Backlight service; hidden on machines without a backlight device.
pragma ComponentBehavior: Bound
import QtQuick
Item {
id: root
property var popouts: null
// This widget's popout — handed to the shared animated host on click.
Component {
id: popoutComponent
BacklightPopout {
popouts: root.popouts
}
}
visible: Backlight.supported
implicitWidth: visible ? row.implicitWidth : 0
implicitHeight: Theme.barHeight
@@ -41,7 +50,7 @@ Item {
if (!root.popouts)
return;
const pr = root.mapToItem(null, root.width, root.height + 4);
root.popouts.open("backlight", Qt.rect(pr.x, pr.y, 0, 0), null);
root.popouts.open(popoutComponent, Qt.rect(pr.x, pr.y, 0, 0), null);
}
onWheel: wheel => {
const step = wheel.angleDelta.y > 0 ? 0.05 : -0.05;
+10 -1
View File
@@ -1,5 +1,6 @@
// ABOUTME: Laptop battery/charging bar widget backed by quickshell UPower.
// ABOUTME: Renders the capacity percentage plus a Nerd Font battery icon, colored by state.
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell.Services.UPower
@@ -7,6 +8,14 @@ Row {
id: root
property var popouts: null
// This widget's popout — handed to the shared animated host on click.
Component {
id: popoutComponent
BatteryPopout {
popouts: root.popouts
}
}
readonly property var dev: UPower.displayDevice
readonly property bool present: dev && dev.isLaptopBattery
// UPower reports the charge as a 0.0-1.0 fraction; scale to a 0-100 percentage.
@@ -50,7 +59,7 @@ Row {
if (!root.popouts)
return;
const pr = root.mapToItem(null, root.width, root.height + 4);
root.popouts.open("battery", Qt.rect(pr.x, pr.y, 0, 0), null);
root.popouts.open(popoutComponent, Qt.rect(pr.x, pr.y, 0, 0), null);
}
}
Tooltip {
@@ -1,5 +1,6 @@
// ABOUTME: Bluetooth bar widget — adapter/connection state icon; click opens the bluetooth popout.
// ABOUTME: Hidden entirely when the machine has no bluetooth adapter (e.g. desktops without BT).
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell.Bluetooth
@@ -7,6 +8,14 @@ Item {
id: root
property var popouts: null
// This widget's popout — handed to the shared animated host on click.
Component {
id: popoutComponent
BluetoothPopout {
popouts: root.popouts
}
}
readonly property var adapter: Bluetooth.defaultAdapter
readonly property bool on: root.adapter ? root.adapter.enabled : false
readonly property bool connected: Bluetooth.devices ? Bluetooth.devices.values.some(d => d && d.connected) : false
@@ -35,7 +44,7 @@ Item {
if (!root.popouts)
return;
const pr = root.mapToItem(null, root.width, root.height + 4);
root.popouts.open("bluetooth", Qt.rect(pr.x, pr.y, 0, 0), null);
root.popouts.open(popoutComponent, Qt.rect(pr.x, pr.y, 0, 0), null);
}
}
@@ -1,11 +1,20 @@
// ABOUTME: CPU governor bar widget — glyph reflects the active auto-cpufreq profile (CpuGovService.state).
// ABOUTME: Click opens the cpugov popout to switch profiles (performance / powersave / auto).
pragma ComponentBehavior: Bound
import QtQuick
Item {
id: root
property var popouts: null
// This widget's popout — handed to the shared animated host on click.
Component {
id: popoutComponent
CpuGovPopout {
popouts: root.popouts
}
}
implicitWidth: iconText.implicitWidth
implicitHeight: Theme.barHeight
@@ -49,7 +58,7 @@ Item {
if (!root.popouts)
return;
const pr = root.mapToItem(null, root.width, root.height + 4);
root.popouts.open("cpugov", Qt.rect(pr.x, pr.y, 0, 0), null);
root.popouts.open(popoutComponent, Qt.rect(pr.x, pr.y, 0, 0), null);
}
}
HoverHandler {
@@ -1,5 +1,6 @@
// ABOUTME: MPRIS media bar widget — music glyph + track title; click opens the media popout, scroll skips.
// ABOUTME: Picks the active player (playing, else first controllable) from the Mpris service; hidden if none.
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell.Services.Mpris
@@ -8,6 +9,14 @@ Item {
property var popouts: null
property int maxWidth: 220
// This widget's popout — handed to the shared animated host on click.
Component {
id: popoutComponent
MprisPopout {
popouts: root.popouts
}
}
readonly property var player: {
const ps = (Mpris.players ? Mpris.players.values : [])
// Skip the playerctld proxy — it mirrors another player, or lingers as a titleless ghost.
@@ -51,7 +60,7 @@ Item {
if (!root.popouts || !root.player)
return;
const pr = root.mapToItem(null, root.width, root.height + 4);
root.popouts.open("mpris", Qt.rect(pr.x, pr.y, 0, 0), root.player);
root.popouts.open(popoutComponent, Qt.rect(pr.x, pr.y, 0, 0), root.player);
}
onWheel: wheel => {
if (!root.player)
@@ -1,5 +1,6 @@
// ABOUTME: Network bar widget — wifi signal-strength icon; click opens the network popout.
// ABOUTME: Reads the connected wifi network's signal from the Networking service (wifi-focused).
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell.Networking
@@ -7,6 +8,14 @@ Item {
id: root
property var popouts: null
// This widget's popout — handed to the shared animated host on click.
Component {
id: popoutComponent
NetworkPopout {
popouts: root.popouts
}
}
implicitWidth: iconText.implicitWidth
implicitHeight: Theme.barHeight
@@ -48,7 +57,7 @@ Item {
if (!root.popouts)
return;
const pr = root.mapToItem(null, root.width, root.height + 4);
root.popouts.open("network", Qt.rect(pr.x, pr.y, 0, 0), null);
root.popouts.open(popoutComponent, Qt.rect(pr.x, pr.y, 0, 0), null);
}
}
@@ -1,11 +1,20 @@
// ABOUTME: Nightlight bar widget — click opens the popout (toggle + temperature), right-click quick-toggles.
// ABOUTME: Backed by the NightlightService singleton (quickshell-managed wlsunset, no systemd).
pragma ComponentBehavior: Bound
import QtQuick
Item {
id: root
property var popouts: null
// This widget's popout — handed to the shared animated host on click.
Component {
id: popoutComponent
NightlightPopout {
popouts: root.popouts
}
}
implicitWidth: iconText.implicitWidth
implicitHeight: Theme.barHeight
@@ -30,7 +39,7 @@ Item {
if (!root.popouts)
return;
const pr = root.mapToItem(null, root.width, root.height + 4);
root.popouts.open("nightlight", Qt.rect(pr.x, pr.y, 0, 0), null);
root.popouts.open(popoutComponent, Qt.rect(pr.x, pr.y, 0, 0), null);
}
}
HoverHandler {
@@ -1,11 +1,20 @@
// ABOUTME: Notifications bar widget — bell icon reflecting count/DND; click opens the center, right-click DND.
// ABOUTME: Backed by the NotifService singleton (count, dnd).
pragma ComponentBehavior: Bound
import QtQuick
Item {
id: root
property var popouts: null
// This widget's popout — handed to the shared animated host on click.
Component {
id: popoutComponent
NotifCenter {
popouts: root.popouts
}
}
implicitWidth: iconText.implicitWidth
implicitHeight: Theme.barHeight
@@ -34,7 +43,7 @@ Item {
if (!root.popouts)
return;
const pr = root.mapToItem(null, root.width, root.height + 4);
root.popouts.open("notifications", Qt.rect(pr.x, pr.y, 0, 0), null);
root.popouts.open(popoutComponent, Qt.rect(pr.x, pr.y, 0, 0), null);
}
}
@@ -179,10 +179,13 @@ PanelWindow {
}
}
Popouts {
// The active widget hands its own popout Component to PopoutState.open();
// we just render whatever is current. Content self-wires `popouts` (and payload)
// from its defining widget, so nothing needs injecting here.
Loader {
id: content
anchors.centerIn: parent
popouts: root.popouts
sourceComponent: root.popouts.currentComponent
}
}
}
@@ -5,15 +5,15 @@ import QtQuick
QtObject {
id: root
property string currentName: ""
property var currentComponent: null
property bool hasCurrent: false
property rect anchorRect: Qt.rect(0, 0, 0, 0)
property var payload: null
function open(name, rect, payload) {
function open(component, rect, payload) {
root.payload = payload ?? null;
root.anchorRect = rect;
root.currentName = name;
root.currentComponent = component;
root.hasCurrent = true;
}
@@ -1,101 +0,0 @@
// ABOUTME: Popout content registry — maps the active popout name to its content component.
// ABOUTME: Loaded inside PopoutHost; extend the mapping as more popouts are added.
pragma ComponentBehavior: Bound
import QtQuick
Loader {
id: root
property var popouts // PopoutState
// Kept active even while closed so the host knows the panel's size for the full slide-out
// animation; each popout gates its own side effects (scan/discovery) on being the current one.
active: true
sourceComponent: {
switch (root.popouts.currentName) {
case "tray":
return trayComp;
case "audio":
return audioComp;
case "network":
return networkComp;
case "bluetooth":
return bluetoothComp;
case "battery":
return batteryComp;
case "notifications":
return notifCenterComp;
case "cpugov":
return cpuGovComp;
case "backlight":
return backlightComp;
case "mpris":
return mprisComp;
case "nightlight":
return nightlightComp;
default:
return null;
}
}
Component {
id: trayComp
TrayMenu {
menuHandle: root.popouts.payload
popouts: root.popouts
}
}
Component {
id: audioComp
AudioPopout {
popouts: root.popouts
}
}
Component {
id: networkComp
NetworkPopout {
popouts: root.popouts
}
}
Component {
id: bluetoothComp
BluetoothPopout {
popouts: root.popouts
}
}
Component {
id: batteryComp
BatteryPopout {
popouts: root.popouts
}
}
Component {
id: notifCenterComp
NotifCenter {
popouts: root.popouts
}
}
Component {
id: cpuGovComp
CpuGovPopout {
popouts: root.popouts
}
}
Component {
id: backlightComp
BacklightPopout {
popouts: root.popouts
}
}
Component {
id: mprisComp
MprisPopout {
popouts: root.popouts
}
}
Component {
id: nightlightComp
NightlightPopout {
popouts: root.popouts
}
}
}
+10 -1
View File
@@ -9,6 +9,15 @@ Row {
property var popouts: null // PopoutState
spacing: 10
// The tray menu popout — payload carries the clicked icon's menu handle.
Component {
id: popoutComponent
TrayMenu {
popouts: root.popouts
menuHandle: root.popouts.payload
}
}
Repeater {
model: SystemTray.items
@@ -38,7 +47,7 @@ Row {
else if (mouse.button === Qt.RightButton && iconItem.modelData.hasMenu && root.popouts) {
// Icon bottom-right in screen coords (the bar window origin equals the screen origin).
const p = iconItem.mapToItem(null, iconItem.width, iconItem.height + 4);
root.popouts.open("tray", Qt.rect(p.x, p.y, 0, 0), iconItem.modelData.menu);
root.popouts.open(popoutComponent, Qt.rect(p.x, p.y, 0, 0), iconItem.modelData.menu);
}
}
}
-1
View File
@@ -26,7 +26,6 @@ NotifToastHost NotifToastHost.qml
NotifToast NotifToast.qml
PopoutHost PopoutHost.qml
PopoutPanel PopoutPanel.qml
Popouts Popouts.qml
PopoutState PopoutState.qml
PrivacyIndicator PrivacyIndicator.qml
singleton Backlight Backlight.qml