feat(quickshell): port remaining Waybar modules + native nightlight

Brings the bar pilot to Waybar parity (minus taskbar/power). New indicators
and popouts: nightlight, cpu governor, gamemode, updates, privacy, backlight,
and mpris media controls.

Nightlight is reimplemented natively — quickshell runs wlsunset as a child
Process (toggle = running, temperature via restart, state persisted to an XDG
state file), replacing the fragile systemd-service-as-toggle. Privacy detects
mic/screenshare via active PipeWire links (not idle stream presence) to avoid
false positives from the always-on rnnoise input. cpugov/updates reuse the
existing moonarch helper scripts for their privileged/cached logic.

Also: PopoutPanel.panelWidth is now per-popout overridable, VolumeSlider gained
a release signal (apply-on-release), and the right cluster is regrouped by
function (media, tray, alerts, connectivity, output, modes, battery).

Still a pilot: not deployed, packaged, or niri-spawned.
This commit is contained in:
2026-07-05 10:09:56 +02:00
parent a19ab8b5d8
commit d2295aaacf
18 changed files with 956 additions and 5 deletions
+6
View File
@@ -164,3 +164,9 @@
- **Why**: Explore consolidating the shell stack (bar, popouts, notifications) onto quickshell/QML — one toolkit and one Catppuccin theme instead of Waybar plus separate daemons. The bar is the first, highest-ROI, fully reversible step.
- **Tradeoffs**: Runs in parallel to the deployed Waybar during evaluation (two theming worlds temporarily). Kept OUT of the deploy path — not niri-spawned, not packaged in moonarch-git, not in the package lists — until daily-drive acceptance. The notification daemon cannot coexist with swaync (only one owner of `org.freedesktop.Notifications`); swaync remains the deployed daemon, quickshell's is opt-in for testing. Greeter (moongreet) deliberately stays Rust (login-critical).
- **How**: `defaults/xdg/quickshell/` — flat QML (quickshell auto-registers same-directory singletons). Catppuccin `Theme` singleton, Niri IPC service, shared popout framework (`PopoutHost`/`PopoutState`/`PopoutPanel`), bar widgets (clock, workspaces, window title, tray drill-down menu, network/bluetooth/audio/battery incl. conservation toggle, idle inhibitor), themed tooltips, and a `NotificationServer`-based daemon (toasts + center + DND + IPC). Runs manually via `quickshell -p`, hot-reloads from the repo.
## 2026-07-05 Quickshell bar: port remaining Waybar modules, native nightlight
- **Who**: Dominik, ClaudeCode
- **Why**: Bring the quickshell pilot to Waybar parity (minus `wlr/taskbar` + `custom/power`, dropped) after an audit + remediation pass.
- **Tradeoffs**: Reuse the existing moonarch helper scripts where they carry privileged/cached/stateful logic (cpugov → `pkexec auto-cpufreq`, updates → `moonarch-waybar-updates` + `moonarch-update`); native QML where quickshell owns the data (mpris, privacy, backlight). Nightlight moved OFF the systemd service to a quickshell-managed `wlsunset` **Process** (toggle = process running, temperature via restart, state persisted to an XDG state file) — cleaner and loop-proof; the systemd `wlsunset.service` is disabled for quickshell sessions (kept for Waybar). Privacy gated on **active** pipewire links (not mere stream presence) to avoid false "mic in use" from idle rnnoise / mic-split streams. Gamemode + updates poll on timers (no native D-Bus/API in quickshell). Still a pilot — not deployed/packaged/niri-spawned.
- **How**: New indicators + popouts under `defaults/xdg/quickshell/` (`Nightlight{Service,Indicator,Popout}`, `CpuGov{Indicator,Popout}`, `Gamemode`/`Updates`/`Privacy` indicators, `Backlight{,Indicator,Popout}`, `Mpris{Widget,Popout}`). `PopoutPanel.panelWidth` made per-popout overridable; `VolumeSlider` gained a `released` signal (apply-on-release for the nightlight temperature). Bar right cluster regrouped by function (media · tray · alerts · connectivity · output · modes · battery).
+56
View File
@@ -0,0 +1,56 @@
// ABOUTME: Backlight service — detects the sysfs device, reads brightness live, sets via brightnessctl.
// ABOUTME: `percent` is 0..1; writing goes through brightnessctl (unprivileged via logind).
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
Singleton {
id: root
property string device: ""
property int cur: 0
property int max: 1
readonly property bool supported: root.device.length > 0 && root.max > 1
readonly property real percent: root.max > 0 ? root.cur / root.max : 0
// Detect the first backlight device once.
Process {
running: true
command: ["sh", "-c", "ls -1 /sys/class/backlight 2>/dev/null | head -1"]
stdout: StdioCollector {
id: detOut
}
onExited: (code, status) => root.device = detOut.text.trim()
}
FileView {
id: curFile
path: root.device.length > 0 ? "/sys/class/backlight/" + root.device + "/brightness" : ""
blockLoading: true
watchChanges: true
onLoaded: root.cur = parseInt((text() || "").trim()) || root.cur
}
FileView {
id: maxFile
path: root.device.length > 0 ? "/sys/class/backlight/" + root.device + "/max_brightness" : ""
blockLoading: true
onLoaded: root.max = parseInt((text() || "").trim()) || root.max
}
Process {
id: setProc
onExited: (code, status) => curFile.reload()
}
function setPercent(p) {
if (!root.supported)
return;
const pct = Math.max(1, Math.min(100, Math.round(p * 100)));
setProc.command = ["brightnessctl", "-d", root.device, "set", pct + "%"];
setProc.running = true;
}
function adjust(deltaPct) {
root.setPercent(root.percent + deltaPct);
}
}
@@ -0,0 +1,59 @@
// 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.
import QtQuick
Item {
id: root
property var popouts: null
visible: Backlight.supported
implicitWidth: visible ? row.implicitWidth : 0
implicitHeight: Theme.barHeight
readonly property int pct: Math.round(Backlight.percent * 100)
// md-brightness-6 (low) / md-brightness-7 (high) — matches the waybar module.
readonly property string icon: root.pct >= 50 ? String.fromCodePoint(0xf00e0) : String.fromCodePoint(0xf00df)
Row {
id: row
anchors.verticalCenter: parent.verticalCenter
spacing: 4
Text {
anchors.verticalCenter: parent.verticalCenter
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize + 1
color: Theme.text
text: root.icon
}
Text {
anchors.verticalCenter: parent.verticalCenter
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
color: Theme.text
text: root.pct + "%"
}
}
MouseArea {
anchors.fill: parent
onClicked: {
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);
}
onWheel: wheel => {
const step = wheel.angleDelta.y > 0 ? 0.05 : -0.05;
Backlight.adjust(step);
}
}
HoverHandler {
id: hover
}
Tooltip {
anchorItem: root
shown: hover.hovered
text: qsTr("Brightness ") + root.pct + "%"
}
}
@@ -0,0 +1,29 @@
// ABOUTME: Backlight popout — a brightness slider backed by the Backlight service (brightnessctl).
// ABOUTME: Registered as the "backlight" popout.
import QtQuick
PopoutPanel {
id: root
property var popouts: null
title: qsTr("Brightness")
panelWidth: 220
Row {
width: root.contentWidth
spacing: 8
Text {
anchors.verticalCenter: parent.verticalCenter
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize + 1
color: Theme.text
text: String.fromCodePoint(0xf00e0) // md-brightness-7
}
VolumeSlider {
anchors.verticalCenter: parent.verticalCenter
width: root.contentWidth - 34 - 8
value: Backlight.percent
onMoved: value => Backlight.setPercent(value)
}
}
}
+43 -4
View File
@@ -60,34 +60,73 @@ PanelWindow {
Layout.fillWidth: true
}
// Right cluster.
Tray {
// Right cluster — grouped by function.
// Media (auto-hides when nothing is playing).
MprisWidget {
Layout.alignment: Qt.AlignVCenter
popouts: popouts
}
// System tray.
ModuleBox {
Layout.alignment: Qt.AlignVCenter
IdleInhibitorIndicator {}
Tray {
popouts: popouts
}
}
// Status indicators grouped in one module box.
// Attention / status — auto-hiding alerts + notifications.
ModuleBox {
Layout.alignment: Qt.AlignVCenter
Row {
spacing: 12
PrivacyIndicator {}
GamemodeIndicator {}
UpdatesIndicator {}
NotificationIndicator {
popouts: popouts
}
}
}
// Connectivity.
ModuleBox {
Layout.alignment: Qt.AlignVCenter
Row {
spacing: 12
NetworkIndicator {
popouts: popouts
}
BluetoothIndicator {
popouts: popouts
}
}
}
// Output levels — volume + brightness.
ModuleBox {
Layout.alignment: Qt.AlignVCenter
Row {
spacing: 12
AudioIndicator {
popouts: popouts
}
BacklightIndicator {
popouts: popouts
}
}
}
// Session toggles / modes.
ModuleBox {
Layout.alignment: Qt.AlignVCenter
Row {
spacing: 12
NightlightIndicator {
popouts: popouts
}
CpuGovIndicator {
popouts: popouts
}
IdleInhibitorIndicator {}
}
}
// Battery.
ModuleBox {
Layout.alignment: Qt.AlignVCenter
Battery {
@@ -0,0 +1,69 @@
// ABOUTME: CPU governor bar widget — glyph reflects the current scaling_governor.
// ABOUTME: Click opens the cpugov popout to switch via auto-cpufreq (pkexec).
import QtQuick
import Quickshell.Io
Item {
id: root
property var popouts: null
implicitWidth: iconText.implicitWidth
implicitHeight: Theme.barHeight
property string governor: ""
FileView {
id: govFile
path: "/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor"
blockLoading: true
watchChanges: true
onLoaded: root.governor = (text() || "").trim()
}
// auto-cpufreq can switch the governor at runtime; watchChanges is best-effort on sysfs, so poll too.
Timer {
interval: 60000
running: true
repeat: true
onTriggered: govFile.reload()
}
// Glyphs match moonarch-waybar-cpugov; unknown governors fall back to the name.
readonly property string icon: {
switch (root.governor) {
case "performance":
return String.fromCodePoint(0xf04c5); // speedometer
case "balanced":
return String.fromCodePoint(0xf0f85); // speedometer-medium
case "powersave":
return String.fromCodePoint(0xf032a); // leaf
default:
return "";
}
}
Text {
id: iconText
anchors.verticalCenter: parent.verticalCenter
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize + 1
color: Theme.text
text: root.icon.length > 0 ? root.icon : root.governor
}
MouseArea {
anchors.fill: parent
onClicked: {
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);
}
}
HoverHandler {
id: hover
}
Tooltip {
anchorItem: root
shown: hover.hovered
text: qsTr("CPU governor: ") + (root.governor || "—")
}
}
+56
View File
@@ -0,0 +1,56 @@
// ABOUTME: CPU governor popout — switch via `pkexec auto-cpufreq --force=...` (same path as the walker menu).
// ABOUTME: Performance / Powersave / Auto(reset). Registered as the "cpugov" popout.
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell.Io
PopoutPanel {
id: root
property var popouts: null
title: qsTr("CPU Governor")
panelWidth: 200
Process {
id: applyProc
}
function apply(mode) {
if (applyProc.running)
return;
applyProc.command = ["pkexec", "auto-cpufreq", "--force=" + mode];
applyProc.running = true;
if (root.popouts)
root.popouts.close();
}
Repeater {
model: [
{
"label": qsTr("Performance"),
"mode": "performance"
},
{
"label": qsTr("Powersave"),
"mode": "powersave"
},
{
"label": qsTr("Auto (reset)"),
"mode": "reset"
}
]
delegate: ListRow {
id: govRow
required property var modelData
width: root.contentWidth
onClicked: root.apply(govRow.modelData.mode)
Text {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
text: govRow.modelData.label
color: Theme.text
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
}
}
}
@@ -0,0 +1,68 @@
// ABOUTME: Gamemode bar indicator — shows a gamepad glyph + running-game count when GameMode is active.
// ABOUTME: Polls feralinteractive GameMode's ClientCount over the session bus; hidden when zero/absent.
import QtQuick
import Quickshell.Io
Item {
id: root
property int count: 0
readonly property bool activeGame: root.count > 0
visible: root.activeGame
implicitWidth: visible ? row.implicitWidth : 0
implicitHeight: Theme.barHeight
Process {
id: proc
command: ["busctl", "--user", "--", "get-property", "com.feralinteractive.GameMode", "/com/feralinteractive/GameMode", "com.feralinteractive.GameMode", "ClientCount"]
stdout: StdioCollector {
id: out
}
onExited: (code, status) => {
if (code !== 0) {
root.count = 0;
return;
}
const parts = out.text.trim().split(/\s+/);
root.count = parseInt(parts[parts.length - 1]) || 0;
}
}
Timer {
interval: 5000
running: true
repeat: true
triggeredOnStart: true
onTriggered: proc.running = true
}
Row {
id: row
anchors.verticalCenter: parent.verticalCenter
spacing: 3
Text {
anchors.verticalCenter: parent.verticalCenter
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize + 1
color: Theme.green
text: String.fromCodePoint(0xf0eb5) // md-gamepad-square
}
Text {
anchors.verticalCenter: parent.verticalCenter
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize - 2
color: Theme.text
text: root.count
}
}
HoverHandler {
id: hover
}
Tooltip {
anchorItem: root
shown: hover.hovered
text: root.count + " " + qsTr("game(s) running")
}
}
+122
View File
@@ -0,0 +1,122 @@
// ABOUTME: Media popout — cover art, title/artist, prev/play-pause/next, and a seek slider.
// ABOUTME: Controls the player passed as the popout payload (same one the widget shows).
import QtQuick
import Quickshell.Services.Mpris
PopoutPanel {
id: root
property var popouts: null
title: qsTr("Media")
panelWidth: 260
readonly property var player: root.popouts ? root.popouts.payload : null
readonly property bool playing: root.player ? root.player.isPlaying : false
// Cover art — fixed centered square (independent of panel width).
Item {
width: root.contentWidth
height: visible ? 110 : 0
visible: art.source.toString().length > 0
Image {
id: art
anchors.centerIn: parent
width: 110
height: 110
source: root.player && root.player.trackArtUrl ? root.player.trackArtUrl : ""
sourceSize.width: 110
sourceSize.height: 110
fillMode: Image.PreserveAspectCrop
}
}
Text {
width: root.contentWidth
text: root.player ? (root.player.trackTitle || qsTr("Unknown")) : qsTr("No player")
color: Theme.text
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.bold: true
elide: Text.ElideRight
}
Text {
width: root.contentWidth
visible: root.player && root.player.trackArtist.length > 0
text: root.player ? root.player.trackArtist : ""
color: Theme.subtext1
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize - 1
elide: Text.ElideRight
}
// Transport controls, centered.
Item {
width: root.contentWidth
implicitHeight: 28
visible: root.player !== null
Row {
anchors.centerIn: parent
spacing: 20
Text {
anchors.verticalCenter: parent.verticalCenter
visible: root.player && root.player.canGoPrevious
text: String.fromCodePoint(0xf04ae) // md-skip-previous
color: prevHover.hovered ? Theme.accent : Theme.text
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize + 6
HoverHandler {
id: prevHover
}
MouseArea {
anchors.fill: parent
onClicked: if (root.player)
root.player.previous()
}
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: root.playing ? String.fromCodePoint(0xf03e4) : String.fromCodePoint(0xf040a) // pause / play
color: playHover.hovered ? Theme.accent : Theme.text
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize + 10
HoverHandler {
id: playHover
}
MouseArea {
anchors.fill: parent
onClicked: if (root.player)
root.player.togglePlaying()
}
}
Text {
anchors.verticalCenter: parent.verticalCenter
visible: root.player && root.player.canGoNext
text: String.fromCodePoint(0xf04ad) // md-skip-next
color: nextHover.hovered ? Theme.accent : Theme.text
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize + 6
HoverHandler {
id: nextHover
}
MouseArea {
anchors.fill: parent
onClicked: if (root.player)
root.player.next()
}
}
}
}
// Seek slider — only when the player reports a length.
VolumeSlider {
width: root.contentWidth
visible: root.player && root.player.lengthSupported && root.player.length > 0
value: (root.player && root.player.length > 0) ? root.player.position / root.player.length : 0
onMoved: value => {
if (root.player && root.player.canSeek)
root.player.position = value * root.player.length;
}
}
}
+69
View File
@@ -0,0 +1,69 @@
// 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.
import QtQuick
import Quickshell.Services.Mpris
Item {
id: root
property var popouts: null
property int maxWidth: 220
readonly property var player: {
const ps = Mpris.players ? Mpris.players.values : [];
return ps.find(p => p && p.isPlaying) || ps.find(p => p && p.canControl) || (ps.length ? ps[0] : null);
}
readonly property bool playing: root.player ? root.player.isPlaying : false
visible: root.player !== null
implicitWidth: visible ? row.implicitWidth : 0
implicitHeight: Theme.barHeight
Row {
id: row
anchors.verticalCenter: parent.verticalCenter
spacing: 6
Text {
anchors.verticalCenter: parent.verticalCenter
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize + 1
color: root.playing ? Theme.accent : Theme.subtext0
text: String.fromCodePoint(0xf075a) // md-music
}
Text {
anchors.verticalCenter: parent.verticalCenter
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
color: Theme.text
elide: Text.ElideRight
width: Math.min(implicitWidth, root.maxWidth)
text: root.player ? (root.player.trackTitle || root.player.identity || "") : ""
}
}
MouseArea {
anchors.fill: parent
onClicked: {
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);
}
onWheel: wheel => {
if (!root.player)
return;
if (wheel.angleDelta.y > 0)
root.player.next();
else
root.player.previous();
}
}
HoverHandler {
id: hover
}
Tooltip {
anchorItem: root
shown: hover.hovered
text: root.player ? ((root.player.trackTitle || "") + (root.player.trackArtist ? " — " + root.player.trackArtist : "")) : ""
}
}
@@ -0,0 +1,44 @@
// 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).
import QtQuick
Item {
id: root
property var popouts: null
implicitWidth: iconText.implicitWidth
implicitHeight: Theme.barHeight
Text {
id: iconText
anchors.verticalCenter: parent.verticalCenter
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize + 1
color: NightlightService.enabled ? Theme.yellow : Theme.text
// md-lightbulb (on) / md-lightbulb-outline (off).
text: NightlightService.enabled ? String.fromCodePoint(0xf0335) : String.fromCodePoint(0xf0336)
}
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.LeftButton | Qt.RightButton
onClicked: mouse => {
if (mouse.button === Qt.RightButton) {
NightlightService.toggle();
return;
}
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);
}
}
HoverHandler {
id: hover
}
Tooltip {
anchorItem: root
shown: hover.hovered
text: NightlightService.enabled ? qsTr("Nightlight ") + NightlightService.temperature + "K" : qsTr("Nightlight off")
}
}
@@ -0,0 +1,56 @@
// ABOUTME: Nightlight popout — on/off toggle plus a colour-temperature slider (applied on release).
// ABOUTME: Backed by the NightlightService singleton. Registered as the "nightlight" popout.
import QtQuick
PopoutPanel {
id: root
property var popouts: null
title: qsTr("Nightlight")
panelWidth: 240
// Live value shown while dragging; committed to the service (wlsunset restart) on release.
property int pendingTemp: NightlightService.temperature
headerActions: Toggle {
checked: NightlightService.enabled
onToggled: NightlightService.toggle()
}
function toValue(t) {
return (t - NightlightService.minTemp) / (NightlightService.maxTemp - NightlightService.minTemp);
}
function toTemp(v) {
return Math.round(NightlightService.minTemp + v * (NightlightService.maxTemp - NightlightService.minTemp));
}
Item {
width: root.contentWidth
implicitHeight: 18
Text {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
text: qsTr("Temperature")
color: Theme.subtext0
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize - 1
}
Text {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: root.pendingTemp + "K"
color: Theme.text
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize - 1
}
}
VolumeSlider {
width: root.contentWidth
enabled: NightlightService.enabled
opacity: enabled ? 1 : 0.5
value: root.toValue(root.pendingTemp)
onMoved: v => root.pendingTemp = root.toTemp(v)
onReleased: v => NightlightService.temperature = root.toTemp(v)
}
}
@@ -0,0 +1,90 @@
// ABOUTME: Nightlight service — runs wlsunset directly as a child process; toggle = process running.
// ABOUTME: Constant colour temperature (fixed high 6500, low carries the value); state in an XDG file.
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
Singleton {
id: root
readonly property int minTemp: 2500
readonly property int maxTemp: 6000
property bool enabled: false
property int temperature: 5000
readonly property string statePath: (Quickshell.env("XDG_STATE_HOME") || (Quickshell.env("HOME") + "/.local/state")) + "/moonarch/nightlight"
// Manual sun window (sunset 00:01) → always "night" → always the low temp. wlsunset requires
// high > low, so the high (6500, daytime, never seen) is fixed and the low carries our value.
// No systemd, no restart loop — lifetime is `running`.
Process {
id: proc
command: ["wlsunset", "-T", "6500", "-t", String(root.temperature), "-S", "00:00", "-s", "00:01"]
}
Timer {
id: restartTimer
interval: 120
onTriggered: if (root.enabled)
proc.running = true
}
function reapply() {
if (!root.enabled) {
proc.running = false;
return;
}
if (proc.running) {
// Temperature changed while on: restart with the new command after it exits.
proc.running = false;
restartTimer.restart();
} else {
proc.running = true;
}
}
onEnabledChanged: {
root.reapply();
root.save();
}
onTemperatureChanged: {
if (root.enabled)
root.reapply();
root.save();
}
function toggle() {
root.enabled = !root.enabled;
}
// --- persistence (survives reboot; PersistentProperties would only survive a hot-reload) ---
FileView {
id: stateFile
path: root.statePath
blockLoading: true
printErrors: false
onLoaded: {
try {
const j = JSON.parse(stateFile.text());
if (typeof j.temp === "number")
root.temperature = Math.max(root.minTemp, Math.min(root.maxTemp, j.temp));
if (typeof j.enabled === "boolean")
root.enabled = j.enabled;
} catch (e) {}
}
// First run: no state file yet — create it with defaults so later reads are clean.
onLoadFailed: root.save()
}
Process {
id: saveProc
}
function save() {
const json = JSON.stringify({
"enabled": root.enabled,
"temp": root.temperature
});
saveProc.command = ["sh", "-c", "mkdir -p \"$(dirname '" + root.statePath + "')\" && printf '%s' '" + json + "' > '" + root.statePath + "'"];
saveProc.running = true;
}
}
+2 -1
View File
@@ -7,7 +7,8 @@ Column {
property string title: ""
// Optional control(s) shown right-aligned next to the title (e.g. an enable Toggle).
property alias headerActions: headerRight.data
readonly property int panelWidth: 320
// Overridable per popout — content-light popouts (e.g. cpugov) can set a narrower width.
property int panelWidth: 320
readonly property int contentWidth: panelWidth - leftPadding - rightPadding
width: panelWidth
+32
View File
@@ -24,6 +24,14 @@ Loader {
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;
}
@@ -66,4 +74,28 @@ Loader {
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
}
}
}
@@ -0,0 +1,67 @@
// ABOUTME: Privacy bar indicator — shows mic/screenshare glyphs while an app captures audio-in or video.
// ABOUTME: Heuristic over pipewire stream nodes (AudioInStream / Video); hidden when nothing is capturing.
import QtQuick
import Quickshell.Services.Pipewire
Item {
id: root
// A stream node exists whenever an app *opens* an input (e.g. the always-present rnnoise
// capture, or mic splits) — even while idle/suspended. So gate on an ACTIVE link (data
// actually flowing), checking either endpoint to stay direction-agnostic.
readonly property var groups: Pipewire.linkGroups ? Pipewire.linkGroups.values : []
function activeStream(flag) {
return root.groups.some(lg => {
if (!lg || lg.state !== PwLinkState.Active)
return false;
const t = lg.target;
const s = lg.source;
return (t && t.isStream && (t.type & flag)) || (s && s.isStream && (s.type & flag));
});
}
readonly property bool micActive: root.activeStream(PwNodeType.AudioInStream)
readonly property bool screenActive: root.activeStream(PwNodeType.Video)
visible: root.micActive || root.screenActive
implicitWidth: visible ? row.implicitWidth : 0
implicitHeight: Theme.barHeight
Row {
id: row
anchors.verticalCenter: parent.verticalCenter
spacing: 8
Text {
anchors.verticalCenter: parent.verticalCenter
visible: root.screenActive
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize + 1
color: Theme.red
text: String.fromCodePoint(0xf1483) // md-monitor-share (screencast)
}
Text {
anchors.verticalCenter: parent.verticalCenter
visible: root.micActive
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize + 1
color: Theme.red
text: String.fromCodePoint(0xf036c) // md-microphone
}
}
HoverHandler {
id: hover
}
Tooltip {
anchorItem: root
shown: hover.hovered
text: {
const parts = [];
if (root.screenActive)
parts.push(qsTr("Screen sharing"));
if (root.micActive)
parts.push(qsTr("Microphone in use"));
return parts.join(" · ");
}
}
}
@@ -0,0 +1,85 @@
// ABOUTME: Pending-updates bar indicator — count from moonarch-waybar-updates (self-caching, repo+AUR).
// ABOUTME: Hidden when zero; click opens moonarch-update in a foot terminal.
import QtQuick
import Quickshell.Io
Item {
id: root
property int count: 0
property string tip: ""
visible: root.count > 0
implicitWidth: visible ? row.implicitWidth : 0
implicitHeight: Theme.barHeight
// The script self-caches (1h / on pacman-db change), so a slow poll here is cheap.
Process {
id: proc
command: ["moonarch-waybar-updates"]
stdout: StdioCollector {
id: out
}
onExited: (code, status) => {
const t = out.text.trim();
if (!t) {
root.count = 0;
root.tip = "";
return;
}
try {
const j = JSON.parse(t);
root.count = parseInt(j.text) || 0;
root.tip = j.tooltip || "";
} catch (e) {
root.count = 0;
root.tip = "";
}
}
}
Timer {
interval: 300000
running: true
repeat: true
triggeredOnStart: true
onTriggered: proc.running = true
}
Process {
id: openProc
command: ["foot", "env", "MOONUP_WAIT=1", "moonarch-update"]
}
Row {
id: row
anchors.verticalCenter: parent.verticalCenter
spacing: 3
Text {
anchors.verticalCenter: parent.verticalCenter
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize + 1
color: Theme.text
text: String.fromCodePoint(0xf1377) // md-refresh-circle
}
Text {
anchors.verticalCenter: parent.verticalCenter
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize - 2
color: Theme.text
text: root.count
}
}
MouseArea {
anchors.fill: parent
onClicked: openProc.running = true
}
HoverHandler {
id: hover
}
Tooltip {
anchorItem: root
shown: hover.hovered
text: root.tip.length > 0 ? root.tip : root.count + " " + qsTr("updates")
}
}
+3
View File
@@ -6,6 +6,8 @@ Item {
id: root
property real value: 0
signal moved(real value)
// Emitted once when the drag/click ends — for consumers that apply on release (e.g. nightlight temp).
signal released(real value)
implicitWidth: 140
implicitHeight: 16
@@ -48,5 +50,6 @@ Item {
if (pressed)
apply(mouse.x);
}
onReleased: mouse => root.released(Math.max(0, Math.min(1, mouse.x / root.width)))
}
}