fix(quickshell): tie up loose ends from the popout composition refactor

The composition + composable-popouts refactor (450580a, 89988c1) left
three rough edges:

- 6 popouts still read the removed PopoutState.currentName in their
  `shown` gate (undefined === "x" → permanently false), so wifi scan,
  bluetooth discovery, threshold reread, governor refresh, toast-silence
  and tray drill-down reset never fired again. Only the current component
  is ever instantiated, so `hasCurrent` alone is the correct gate.
- ModuleBox stopped self-hiding when empty: the Battery box on a desktop
  (no battery) and an empty tray rendered a bare rounded pill. Gate the
  shared container on `inner.implicitWidth > 0` so the fix covers both.
- CLAUDE.md customization example missed `import Quickshell` (unloadable
  as written) and called a Row's rejected centerIn anchor "harmless"
  (Row warns at runtime and ignores it).
This commit is contained in:
2026-07-06 23:34:35 +02:00
parent 89988c1405
commit a7bf8fe37b
8 changed files with 17 additions and 11 deletions
+2 -1
View File
@@ -53,6 +53,7 @@ quickshell is the **default bar and notification daemon**, spawned by niri as `q
```qml
import QtQuick
import QtQuick.Layouts
import Quickshell // ShellRoot, Variants, Quickshell.screens
import "file:///etc/xdg/quickshell/moonarch" // Theme + Bar/ModuleBox/… via the qmldir
ShellRoot {
Variants { model: Quickshell.screens
@@ -62,7 +63,7 @@ quickshell is the **default bar and notification daemon**, spawned by niri as `q
}
}
```
`ModuleBox` wraps its content in a `Row`, so a direct child using `anchors.centerIn: parent` is harmless — `Row` manages horizontal position and ignores the anchor (no binding loop). Vertical centering via `anchors.verticalCenter` still works.
`ModuleBox` wraps its content in a `Row` (and self-hides when its content collapses to zero width). Do **not** anchor a direct child with `anchors.centerIn: parent` — a `Row` rejects horizontal child anchors with a runtime warning (`QML Row: Cannot specify … centerIn anchors for items inside Row`) and ignores them. There is no binding loop (the `Row` sizes from children's implicit sizes, not `childrenRect`), but the centering silently does nothing. Center vertically with `anchors.verticalCenter: parent.verticalCenter` instead.
- **qmldir** exposes the singletons/components for the cross-dir `import`. It is **generated** from the `.qml` files; regenerate when adding components:
```sh
@@ -58,7 +58,7 @@ PopoutPanel {
// The popout instance persists across reopens; re-read the threshold on each open so an
// external change (terminal, or another screen's popout) is reflected.
readonly property bool shown: root.popouts && root.popouts.hasCurrent && root.popouts.currentName === "battery"
readonly property bool shown: root.popouts && root.popouts.hasCurrent
onShownChanged: if (root.shown)
limitFile.reload()
@@ -29,7 +29,7 @@ PopoutPanel {
// Content stays loaded even when closed (for the slide animation), so gate discovery on this
// popout actually being the shown one rather than on component lifecycle.
readonly property bool shown: root.popouts && root.popouts.hasCurrent && root.popouts.currentName === "bluetooth"
readonly property bool shown: root.popouts && root.popouts.hasCurrent
// Only write when it actually differs — BlueZ warns "already in progress" on a redundant start.
function syncDiscovery() {
if (root.adapter && root.adapter.discovering !== root.shown)
@@ -10,7 +10,7 @@ PopoutPanel {
panelWidth: 200
// Reflect an out-of-band switch (walker menu) whenever the popout becomes the shown one.
readonly property bool shown: root.popouts && root.popouts.hasCurrent && root.popouts.currentName === "cpugov"
readonly property bool shown: root.popouts && root.popouts.hasCurrent
onShownChanged: if (root.shown)
CpuGovService.refresh()
@@ -6,14 +6,19 @@ Rectangle {
id: root
default property alias data: inner.data
// Self-hide when empty: a module whose content collapses to zero width
// (e.g. Battery on a desktop, tray with no SNI items) must not leave a bare pill.
visible: inner.implicitWidth > 0
implicitWidth: inner.implicitWidth + Theme.modulePadH * 2
implicitHeight: Theme.barHeight - Theme.moduleMarginV * 2
color: Theme.moduleBg
radius: Theme.radius
// A Row derives its implicit size from the children's implicit sizes
// instead of childrenRect, and rejects horizontal child anchors — so a
// child using anchors.centerIn can no longer feed back into the size.
// A Row derives its implicit size from the children's implicit sizes instead
// of childrenRect, so a child can no longer feed back into the size. Note a Row
// rejects a child's horizontal anchors (left/right/horizontalCenter/fill/centerIn)
// with a runtime warning and ignores them — center a child with
// anchors.verticalCenter, not anchors.centerIn.
Row {
id: inner
anchors.centerIn: parent
@@ -28,7 +28,7 @@ PopoutPanel {
// Content stays loaded even when closed (for the slide animation), so gate scanning on this
// popout actually being the shown one rather than on component lifecycle.
readonly property bool shown: root.popouts && root.popouts.hasCurrent && root.popouts.currentName === "network"
readonly property bool shown: root.popouts && root.popouts.hasCurrent
onShownChanged: if (root.wifiDev)
root.wifiDev.scannerEnabled = root.shown
Component.onCompleted: if (root.wifiDev)
@@ -45,7 +45,7 @@ PopoutPanel {
// Opening the center silences pending toasts (they stay in history). Gated on `shown`
// — the popout instance persists across reopens, so Component.onCompleted alone fires only once.
readonly property bool shown: root.popouts && root.popouts.hasCurrent && root.popouts.currentName === "notifications"
readonly property bool shown: root.popouts && root.popouts.hasCurrent
function silencePending() {
for (const i of NotifService.list)
i.popup = false;
@@ -14,9 +14,9 @@ StackView {
implicitWidth: currentItem ? currentItem.implicitWidth : 0
implicitHeight: currentItem ? currentItem.implicitHeight : 0
// The popout instance persists across reopens (Loader keyed on currentName), so reset the
// The popout instance persists across reopens (Loader keyed on currentComponent), so reset the
// drill-down to the top level whenever the tray popout becomes shown again.
readonly property bool shown: root.popouts && root.popouts.hasCurrent && root.popouts.currentName === "tray"
readonly property bool shown: root.popouts && root.popouts.hasCurrent
onShownChanged: if (root.shown)
root.pop(null, StackView.Immediate)