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