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:
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user