feat(quickshell): user-extendable bar via config toggles + widget slot

quickshell has no native config merge, so the system bar could only be
adopted wholesale (fork it, lose system updates). This adds an in-repo
extension path: users toggle widgets and add their own without forking.

- UserConfig singleton reads ~/.config/quickshell/moonarch.json
  ({"widgets":{"<key>":false}}); absent/broken = all on, live-reloaded
- qmldir (generated) exposes singletons + components so user QML can
  import "file:///etc/xdg/quickshell" and reuse Theme/ModuleBox/…
- Bar gates its 16 widgets on UserConfig.enabled(key) and loads an
  optional UserWidgets.qml into a right-cluster slot
- verified against quickshell 0.3.0: toggles hide widgets, user widget
  themes correctly, empty/broken config keeps everything on, no binding loops

Pilot only — qmldir must ship when the pilot is packaged. See DECISIONS.md.
This commit is contained in:
2026-07-05 20:00:58 +02:00
parent 9eeeb9c2ee
commit d2bd8ec470
12 changed files with 168 additions and 9 deletions
+30
View File
@@ -41,6 +41,36 @@ Waybar is started via `moonarch-waybar` (not directly). The wrapper merges an op
- Requires `jq` (declared as a dependency in the PKGBUILD)
- The system config must be valid JSON (no JSONC)
## Quickshell Bar: User Extension (Pilot)
The quickshell bar (`defaults/xdg/quickshell/`, deployed to `/etc/xdg/quickshell/`) is system-provided but user-extensible without forking the whole config. quickshell has no native merge — the mechanism is built in-repo:
- **Toggle widgets** — `~/.config/quickshell/moonarch.json`:
```json
{ "widgets": { "battery": false, "cpugov": false } }
```
Absent or broken file = every widget on. A widget shows unless its key is explicitly `false`. Read live (no restart) by the `UserConfig` singleton (`UserConfig.qml`, reusing the `NightlightService` FileView+JSON pattern). Keys: `clock`, `workspaces`, `windowTitle`, `mpris`, `tray`, `privacy`, `updates`, `notifications`, `network`, `bluetooth`, `audio`, `backlight`, `nightlight`, `cpugov`, `idleInhibitor`, `battery`.
- **Add own widgets** — `~/.config/quickshell/UserWidgets.qml` (loaded into a slot at the start of the right cluster when present):
```qml
import QtQuick
import "file:///etc/xdg/quickshell" // exposes Theme + ModuleBox/Tooltip/… via the qmldir
Row {
spacing: 12
ModuleBox {
Text { text: "hi"; color: Theme.accent; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize }
}
}
```
**Do not** anchor a direct child of `ModuleBox` with `anchors.centerIn: parent` — `ModuleBox` sizes to `childrenRect`, so a centered child creates a binding loop. Give the child its own size (as above). User widgets can't open bar popouts yet (the `Popouts.qml` registry is hardcoded).
- **Why the user path works**: a `~/.config/quickshell/` dir without a `shell.qml` does not displace the system config (verified). Cross-dir access to our singletons/components needs the `qmldir` in the system dir — it is **generated** from the `.qml` files; regenerate it when adding components:
```sh
cd defaults/xdg/quickshell && 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
```
Rationale + spike results: `DECISIONS.md` (2026-07-05).
## mpv + ModernZ OSC
The video player is `mpv` with [ModernZ](https://github.com/Samillion/ModernZ) as the OSC, thumbnails via thumbfast:
+6
View File
@@ -176,3 +176,9 @@
- **Why**: The gamemode indicator polled feralinteractive GameMode's `ClientCount` via a `busctl` fork every 5s, `running` regardless of visibility — i.e. 24/7 on every machine, including while gaming, for a value that is 0 almost all the time. On a gaming machine the poll lands exactly where a fork must not: mid-game (risk of a periodic frame hitch).
- **Tradeoffs**: The overhead is objectively small (a busctl fork every 5s ≈ a fraction of one core), so "performance hog" overstates it — but it is pure waste. Considered rebuilding it event-driven (one persistent `busctl --user monitor` on GameMode's `GameRegistered`/`GameUnregistered` signals + a single initial read → zero poll), which is the clean fix, but the effort isn't worth it for an indicator that only shows while a fullscreen game is running (i.e. when the bar usually isn't even visible). No native D-Bus in quickshell rules out a cheap signal subscription. Consistent with `wlr/taskbar` + `custom/power` already dropped from the pilot.
- **How**: Removed `GamemodeIndicator.qml` and its reference in `Bar.qml`. GameMode status remains available on the CLI (`gamemoded -s`).
## 2026-07-05 Quickshell bar: user extension mechanism (toggle + add widgets)
- **Who**: Dominik, ClaudeCode
- **Why**: quickshell has no native config merge/drop-in/plugin system — it picks a single config root by XDG precedence. We want a system-provided bar (`/etc/xdg/quickshell/`) that a user can *configure* (toggle widgets) and *extend* (own widgets) without forking the whole config and losing system updates for their part.
- **Tradeoffs**: Two behaviours were spiked against quickshell 0.3.0 first, not guessed: (1) a `~/.config/quickshell/` dir *without* a `shell.qml` does **not** displace the system config — so the user's files may live at the natural path. (2) User QML in a foreign dir cannot see our singletons implicitly, but a `qmldir` in the system dir + `import "file:///etc/xdg/quickshell"` exposes singletons **and** components natively (verified: `Theme.accent` resolves; property-injection via `Loader.setSource` is the fallback). Scope kept to **toggle + add** — no per-widget override (expensive: per-slot loader + the hardcoded `Popouts.qml` registry) and no reordering/user-popouts (follow-up). Toggles use JSON (Waybar-familiar, reuses the `NightlightService` FileView+JSON pattern); own widgets stay QML (unavoidable). A user widget rooted at/containing `ModuleBox` must not anchor its direct child with `centerIn: parent` — that hits a pre-existing `childrenRect` binding loop in `ModuleBox` (documented, not fixed here).
- **How**: New `UserConfig.qml` singleton reads `~/.config/quickshell/moonarch.json` (`{"widgets":{"<key>":false}}`, absent/broken = all on) and probes for `UserWidgets.qml`. New `qmldir` (generated from the `.qml` files) declares all singletons + components for cross-dir import. `Bar.qml` gates each of the 16 widgets on `UserConfig.enabled("<key>")` (self-hiding widgets combine it into their internal `visible`; group `ModuleBox`es hide when all their keys are off) and adds a `Loader` slot (right cluster) bound to `UserWidgets.qml` when present. Still a pilot — the `qmldir` must be shipped when the pilot is later packaged.
@@ -6,7 +6,7 @@ Item {
id: root
property var popouts: null
visible: Backlight.supported
visible: Backlight.supported && UserConfig.enabled("backlight")
implicitWidth: visible ? row.implicitWidth : 0
implicitHeight: Theme.barHeight
+25 -2
View File
@@ -1,5 +1,5 @@
// ABOUTME: The moonarch quickshell top bar — one PanelWindow per screen.
// ABOUTME: Left: workspaces + focused-window title; right: tray, clock, battery.
// ABOUTME: Left: workspaces + focused-window title; right: tray, clock, battery. Widgets toggle via UserConfig.
import QtQuick
import QtQuick.Layouts
import Quickshell
@@ -44,10 +44,12 @@ PanelWindow {
// Left cluster.
ModuleBox {
Layout.alignment: Qt.AlignVCenter
visible: UserConfig.enabled("clock")
Clock {}
}
Workspaces {
Layout.alignment: Qt.AlignVCenter
visible: UserConfig.enabled("workspaces")
screenName: bar.screen ? bar.screen.name : ""
}
WindowTitle {
@@ -60,6 +62,13 @@ PanelWindow {
Layout.fillWidth: true
}
// User extension slot — loads ~/.config/quickshell/UserWidgets.qml if present (see CLAUDE.md).
Loader {
Layout.alignment: Qt.AlignVCenter
active: UserConfig.hasUserWidgets
source: active ? UserConfig.userWidgetsUrl : ""
}
// Right cluster — grouped by function.
// Media (auto-hides when nothing is playing).
MprisWidget {
@@ -69,6 +78,7 @@ PanelWindow {
// System tray.
ModuleBox {
Layout.alignment: Qt.AlignVCenter
visible: UserConfig.enabled("tray")
Tray {
popouts: popouts
}
@@ -76,11 +86,13 @@ PanelWindow {
// Attention / status — auto-hiding alerts + notifications.
ModuleBox {
Layout.alignment: Qt.AlignVCenter
visible: UserConfig.enabled("privacy") || UserConfig.enabled("updates") || UserConfig.enabled("notifications")
Row {
spacing: 12
PrivacyIndicator {}
UpdatesIndicator {}
NotificationIndicator {
visible: UserConfig.enabled("notifications")
popouts: popouts
}
}
@@ -88,9 +100,11 @@ PanelWindow {
// Connectivity.
ModuleBox {
Layout.alignment: Qt.AlignVCenter
visible: UserConfig.enabled("network") || UserConfig.enabled("bluetooth")
Row {
spacing: 12
NetworkIndicator {
visible: UserConfig.enabled("network")
popouts: popouts
}
BluetoothIndicator {
@@ -101,9 +115,11 @@ PanelWindow {
// Output levels — volume + brightness.
ModuleBox {
Layout.alignment: Qt.AlignVCenter
visible: UserConfig.enabled("audio") || UserConfig.enabled("backlight")
Row {
spacing: 12
AudioIndicator {
visible: UserConfig.enabled("audio")
popouts: popouts
}
BacklightIndicator {
@@ -114,21 +130,28 @@ PanelWindow {
// Session toggles / modes.
ModuleBox {
Layout.alignment: Qt.AlignVCenter
visible: UserConfig.enabled("nightlight") || UserConfig.enabled("cpugov") || UserConfig.enabled("idleInhibitor")
Row {
spacing: 12
NightlightIndicator {
visible: UserConfig.enabled("nightlight")
popouts: popouts
}
CpuGovIndicator {
visible: UserConfig.enabled("cpugov")
popouts: popouts
}
IdleInhibitorIndicator {}
IdleInhibitorIndicator {
visible: UserConfig.enabled("idleInhibitor")
}
}
}
// Battery.
ModuleBox {
Layout.alignment: Qt.AlignVCenter
visible: batteryItem.visible
Battery {
id: batteryItem
popouts: popouts
}
}
+1 -1
View File
@@ -21,7 +21,7 @@ Row {
readonly property int level: Math.min(10, Math.max(0, Math.round(pct / 100 * 10)))
readonly property string icon: (plugged ? chargeIcons : dischargeIcons)[level]
visible: present
visible: present && UserConfig.enabled("battery")
spacing: 4
Text {
@@ -11,7 +11,7 @@ Item {
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
visible: root.adapter !== null
visible: root.adapter !== null && UserConfig.enabled("bluetooth")
implicitWidth: visible ? iconText.implicitWidth : 0
implicitHeight: Theme.barHeight
+1 -1
View File
@@ -18,7 +18,7 @@ Item {
}
readonly property bool playing: root.player ? root.player.isPlaying : false
visible: root.player !== null
visible: root.player !== null && UserConfig.enabled("mpris")
implicitWidth: visible ? row.implicitWidth : 0
implicitHeight: Theme.barHeight
+1 -1
View File
@@ -22,7 +22,7 @@ Item {
readonly property bool micActive: root.activeStream(PwNodeType.AudioInStream)
readonly property bool screenActive: root.activeStream(PwNodeType.Video)
visible: root.micActive || root.screenActive
visible: (root.micActive || root.screenActive) && UserConfig.enabled("privacy")
implicitWidth: visible ? row.implicitWidth : 0
implicitHeight: Theme.barHeight
+1 -1
View File
@@ -9,7 +9,7 @@ Item {
property int count: 0
property string tip: ""
visible: root.count > 0
visible: root.count > 0 && UserConfig.enabled("updates")
implicitWidth: visible ? row.implicitWidth : 0
implicitHeight: Theme.barHeight
+53
View File
@@ -0,0 +1,53 @@
// ABOUTME: User config service — reads ~/.config/quickshell/moonarch.json for per-widget toggles
// ABOUTME: and detects an optional UserWidgets.qml extension. Absent/broken = defaults (all on).
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
Singleton {
id: root
readonly property string configDir: (Quickshell.env("XDG_CONFIG_HOME") || (Quickshell.env("HOME") + "/.config")) + "/quickshell"
readonly property string configPath: root.configDir + "/moonarch.json"
readonly property string userWidgetsUrl: "file://" + root.configDir + "/UserWidgets.qml"
// Parsed { "<widgetKey>": bool } map. Empty object = no overrides = everything on.
property var widgets: ({})
// Whether ~/.config/quickshell/UserWidgets.qml exists (drives the extension slot).
property bool hasUserWidgets: false
// A widget is shown unless the user explicitly set its key to false.
function enabled(key) {
return root.widgets[key] !== false;
}
FileView {
id: cfgFile
path: root.configPath
blockLoading: true
printErrors: false
watchChanges: true
onLoaded: {
try {
const j = JSON.parse(cfgFile.text());
root.widgets = (j && typeof j.widgets === "object" && j.widgets !== null) ? j.widgets : ({});
} catch (e) {
root.widgets = ({});
}
}
// No config yet, or unreadable → no overrides.
onLoadFailed: root.widgets = ({})
}
// Existence probe for the extension file — Loader.source is bound to userWidgetsUrl only when present.
FileView {
id: userWidgetsProbe
path: root.configDir + "/UserWidgets.qml"
blockLoading: true
printErrors: false
watchChanges: true
onLoaded: root.hasUserWidgets = true
onLoadFailed: root.hasUserWidgets = false
}
}
+1 -1
View File
@@ -40,7 +40,7 @@ Row {
Image {
id: icon
anchors.verticalCenter: parent.verticalCenter
visible: root.iconName.length > 0 && root.raw.length > 0
visible: root.iconName.length > 0 && root.raw.length > 0 && UserConfig.enabled("windowTitle")
source: root.iconName.length > 0 ? Quickshell.iconPath(root.iconName, true) : ""
width: visible ? root.iconSize : 0
height: root.iconSize
+47
View File
@@ -0,0 +1,47 @@
AudioIndicator AudioIndicator.qml
AudioPopout AudioPopout.qml
BacklightIndicator BacklightIndicator.qml
BacklightPopout BacklightPopout.qml
Bar Bar.qml
Battery Battery.qml
BatteryPopout BatteryPopout.qml
BluetoothIndicator BluetoothIndicator.qml
BluetoothPopout BluetoothPopout.qml
Clock Clock.qml
CpuGovIndicator CpuGovIndicator.qml
CpuGovPopout CpuGovPopout.qml
IdleInhibitorIndicator IdleInhibitorIndicator.qml
ListRow ListRow.qml
ModuleBox ModuleBox.qml
MprisPopout MprisPopout.qml
MprisWidget MprisWidget.qml
NetworkIndicator NetworkIndicator.qml
NetworkPopout NetworkPopout.qml
NightlightIndicator NightlightIndicator.qml
NightlightPopout NightlightPopout.qml
NotifCenter NotifCenter.qml
NotificationIndicator NotificationIndicator.qml
NotifItem NotifItem.qml
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
singleton CpuGovService CpuGovService.qml
singleton IdleInhibit IdleInhibit.qml
singleton NightlightService NightlightService.qml
singleton Niri Niri.qml
singleton NotifService NotifService.qml
singleton Theme Theme.qml
singleton UserConfig UserConfig.qml
Toggle Toggle.qml
Tooltip Tooltip.qml
TrayMenu TrayMenu.qml
Tray Tray.qml
UpdatesIndicator UpdatesIndicator.qml
VolumeSlider VolumeSlider.qml
WindowTitle WindowTitle.qml
Workspaces Workspaces.qml