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