feat(quickshell): add bar pilot (parallel to Waybar, not deployed)

Exploratory step toward consolidating the shell stack onto quickshell/QML
— one toolkit and one Catppuccin theme instead of Waybar plus separate
daemons. Runs manually via `quickshell -p`, alongside the still-deployed
Waybar; deliberately kept out of the deploy path (not niri-spawned, not
packaged, not in the package lists) until daily-drive acceptance.

Includes: Catppuccin Theme singleton, Niri IPC service, shared popout
framework, bar widgets (clock, workspaces, window title, tray drill-down,
network/bluetooth/audio/battery incl. conservation toggle, idle inhibitor),
themed tooltips, and a NotificationServer-based daemon (toasts + center +
DND + IPC). The notification daemon cannot coexist with swaync (single bus
owner), so swaync stays the deployed daemon; quickshell's is opt-in.

Rationale and tradeoffs recorded in DECISIONS.md.
This commit is contained in:
2026-07-04 23:32:55 +02:00
parent d101b23351
commit 33bb0a2576
35 changed files with 2659 additions and 0 deletions
+6
View File
@@ -158,3 +158,9 @@
- **Why**: gsettings had `Colloid-Dark-Catppuccin` while config files had `Colloid-Catppuccin` — inconsistent. Grey accent matches the icon theme (Colloid-Grey-Catppuccin-Dark). Explicit `-Dark` variant is more reliable than depending on `prefer-dark` color-scheme setting.
- **Tradeoffs**: Explicit dark locks out light mode toggle — acceptable since Moonarch is dark-only by design.
- **How**: Updated transform.sh, post-install.sh, gtk-3.0/settings.ini, and gsettings to `Colloid-Grey-Dark-Catppuccin`. GTK4 symlinks updated accordingly.
## 2026-07-04 Add quickshell bar pilot (parallel to Waybar, not yet deployed)
- **Who**: Dominik, ClaudeCode
- **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.
@@ -0,0 +1,86 @@
// ABOUTME: Audio bar widget — volume icon + percent; scroll adjusts volume, click opens the audio popout.
// ABOUTME: Holds a persistent PwObjectTracker so the default sink/source stay bound for live volume state.
import QtQuick
import Quickshell.Services.Pipewire
Item {
id: root
property var popouts: null
implicitWidth: row.width
implicitHeight: Theme.barHeight
readonly property var sink: Pipewire.defaultAudioSink
readonly property var sinkAudio: sink ? sink.audio : null
readonly property real vol: sinkAudio ? sinkAudio.volume : 0
readonly property bool muted: sinkAudio ? sinkAudio.muted : false
// Keep the default sink + source bound so their audio props (volume/mute) stay live.
PwObjectTracker {
objects: {
const l = [];
if (Pipewire.defaultAudioSink)
l.push(Pipewire.defaultAudioSink);
if (Pipewire.defaultAudioSource)
l.push(Pipewire.defaultAudioSource);
return l;
}
}
// Nerd Font (Material) volume ramp — codepoints direct to avoid glyph-encoding ambiguity in source.
readonly property string icon: root.muted || root.vol <= 0 ? String.fromCodePoint(0xf0581) // volume-off
: root.vol < 0.34 ? String.fromCodePoint(0xf057f) // volume-low
: root.vol < 0.67 ? String.fromCodePoint(0xf0580) // volume-medium
: String.fromCodePoint(0xf057e) // volume-high
Row {
id: row
anchors.verticalCenter: parent.verticalCenter
spacing: 4
Text {
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize + 1
color: root.muted ? Theme.subtext0 : Theme.text
text: root.icon
}
Text {
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
color: Theme.text
text: Math.round(root.vol * 100) + "%"
}
}
// One MouseArea for both click (open popout) and wheel (adjust volume) — WheelHandler never
// received axis events on this layer-shell surface; MouseArea.onWheel does.
MouseArea {
anchors.fill: parent
onClicked: {
if (!root.popouts)
return;
// Right edge in screen coords; the host anchors content to rect.x - width (right-aligned).
const pr = root.mapToItem(null, root.width, root.height + 4);
root.popouts.open("audio", Qt.rect(pr.x, pr.y, 0, 0), null);
}
onWheel: wheel => {
if (!root.sinkAudio)
return;
const step = wheel.angleDelta.y > 0 ? 0.05 : -0.05;
root.sinkAudio.volume = Math.max(0, Math.min(1, root.vol + step));
}
}
HoverHandler {
id: hover
}
Tooltip {
anchorItem: root
shown: hover.hovered
text: {
const name = root.sink ? (root.sink.description || root.sink.name || "") : "";
const vol = root.muted ? qsTr("muted") : Math.round(root.vol * 100) + "%";
return name ? name + " · " + vol : vol;
}
}
}
+148
View File
@@ -0,0 +1,148 @@
// ABOUTME: Audio popout — default sink & source volume/mute plus output/input device switchers.
// ABOUTME: Rendered in PopoutHost; list/hover pattern inspired by caelestia-dots/shell (own impl).
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell.Services.Pipewire
PopoutPanel {
id: root
property var popouts: null
title: qsTr("Audio")
readonly property var sink: Pipewire.defaultAudioSink
readonly property var source: Pipewire.defaultAudioSource
DeviceControl {
node: root.sink
activeIcon: String.fromCodePoint(0xf057e) // volume-high
mutedIcon: String.fromCodePoint(0xf0581) // volume-off
}
Switcher {
heading: qsTr("Output")
current: root.sink
nodes: Pipewire.nodes ? Pipewire.nodes.values.filter(n => n && n.isSink && !n.isStream) : []
onPick: n => Pipewire.preferredDefaultAudioSink = n
}
// Divider between the output and input sections.
Rectangle {
width: root.contentWidth
height: 1
color: Theme.surface1
}
DeviceControl {
node: root.source
activeIcon: String.fromCodePoint(0xf036c) // microphone
mutedIcon: String.fromCodePoint(0xf036d) // microphone-off
}
Switcher {
heading: qsTr("Input")
current: root.source
nodes: Pipewire.nodes ? Pipewire.nodes.values.filter(n => n && !n.isSink && !n.isStream && n.audio) : []
onPick: n => Pipewire.preferredDefaultAudioSource = n
}
// Mute toggle + volume slider + percent for one pipewire node.
component DeviceControl: Row {
id: dc
property var node: null
property string activeIcon: ""
property string mutedIcon: ""
readonly property var au: dc.node ? dc.node.audio : null
visible: dc.node !== null
width: root.contentWidth
spacing: 8
Text {
id: muteBtn
anchors.verticalCenter: parent.verticalCenter
width: 22
horizontalAlignment: Text.AlignHCenter
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize + 3
color: (dc.au && dc.au.muted) ? Theme.subtext0 : Theme.text
text: (dc.au && dc.au.muted) ? dc.mutedIcon : dc.activeIcon
MouseArea {
anchors.fill: parent
onClicked: {
if (dc.au)
dc.au.muted = !dc.au.muted;
}
}
}
VolumeSlider {
anchors.verticalCenter: parent.verticalCenter
width: dc.width - muteBtn.width - pctLabel.width - dc.spacing * 2
value: dc.au ? dc.au.volume : 0
onMoved: v => {
if (dc.au)
dc.au.volume = v;
}
}
Text {
id: pctLabel
anchors.verticalCenter: parent.verticalCenter
width: 34
horizontalAlignment: Text.AlignRight
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
color: Theme.subtext1
text: Math.round((dc.au ? dc.au.volume : 0) * 100) + "%"
}
}
// Device chooser list — clicking sets the preferred default sink/source.
component Switcher: Column {
id: sw
property string heading: ""
property var nodes: []
property var current: null
signal pick(var node)
width: root.contentWidth
spacing: 2
visible: sw.nodes.length > 1
Text {
text: sw.heading
color: Theme.subtext0
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize - 1
}
Repeater {
model: sw.nodes
delegate: ListRow {
id: rowItem
required property var modelData
width: sw.width
onClicked: {
sw.pick(rowItem.modelData);
if (root.popouts)
root.popouts.close();
}
Row {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
spacing: 6
Text {
width: 12
text: rowItem.modelData === sw.current ? String.fromCodePoint(0xf012c) : "" // md-check
color: Theme.accent
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
Text {
width: sw.width - 12 - 18
text: rowItem.modelData.description || rowItem.modelData.nickname || rowItem.modelData.name || ""
color: Theme.text
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
elide: Text.ElideRight
}
}
}
}
}
}
+98
View File
@@ -0,0 +1,98 @@
// ABOUTME: The moonarch quickshell top bar — one PanelWindow per screen.
// ABOUTME: Left: workspaces + focused-window title; right: tray, clock, battery.
import QtQuick
import QtQuick.Layouts
import Quickshell
import Quickshell.Wayland
PanelWindow {
id: bar
required property var modelData
screen: modelData
anchors {
top: true
left: true
right: true
}
implicitHeight: Theme.barHeight
exclusiveZone: Theme.barHeight
color: Theme.barBg
// Shared popout state + host for this screen (tray menu now, more popouts later).
PopoutState {
id: popouts
}
PopoutHost {
popouts: popouts
screen: bar.screen
}
// Holds a Wayland idle-inhibitor on this bar's surface while the shared toggle is on.
// One per screen, all bound to the same state — any single active inhibitor keeps the session awake.
IdleInhibitor {
window: bar
enabled: IdleInhibit.enabled
}
RowLayout {
anchors.fill: parent
anchors.leftMargin: Theme.spacing
anchors.rightMargin: Theme.spacing
spacing: Theme.spacing
// Left cluster.
ModuleBox {
Layout.alignment: Qt.AlignVCenter
Clock {}
}
Workspaces {
Layout.alignment: Qt.AlignVCenter
screenName: bar.screen ? bar.screen.name : ""
}
WindowTitle {
Layout.alignment: Qt.AlignVCenter
Layout.maximumWidth: 420
}
// Center spacer.
Item {
Layout.fillWidth: true
}
// Right cluster.
Tray {
Layout.alignment: Qt.AlignVCenter
popouts: popouts
}
ModuleBox {
Layout.alignment: Qt.AlignVCenter
IdleInhibitorIndicator {}
}
// Status indicators grouped in one module box.
ModuleBox {
Layout.alignment: Qt.AlignVCenter
Row {
spacing: 12
NotificationIndicator {
popouts: popouts
}
NetworkIndicator {
popouts: popouts
}
BluetoothIndicator {
popouts: popouts
}
AudioIndicator {
popouts: popouts
}
}
}
ModuleBox {
Layout.alignment: Qt.AlignVCenter
Battery {
popouts: popouts
}
}
}
}
+93
View File
@@ -0,0 +1,93 @@
// ABOUTME: Laptop battery/charging bar widget backed by quickshell UPower.
// ABOUTME: Renders the capacity percentage plus a Nerd Font battery icon, colored by state.
import QtQuick
import Quickshell.Services.UPower
Row {
id: root
property var popouts: null
readonly property var dev: UPower.displayDevice
readonly property bool present: dev && dev.isLaptopBattery
// UPower reports the charge as a 0.0-1.0 fraction; scale to a 0-100 percentage.
readonly property real pct: dev ? dev.percentage * 100 : 0
readonly property int devState: dev ? dev.state : UPowerDeviceState.Unknown
// On AC in any form (actively charging, held at a charge limit, or full) → show the bolt ramp.
readonly property bool plugged: devState === UPowerDeviceState.Charging || devState === UPowerDeviceState.PendingCharge || devState === UPowerDeviceState.FullyCharged
// 11-step icon ramps (empty -> full); codepoints direct to avoid glyph-encoding ambiguity.
readonly property var dischargeIcons: [0xf008e, 0xf007a, 0xf007b, 0xf007c, 0xf007d, 0xf007e, 0xf007f, 0xf0080, 0xf0081, 0xf0082, 0xf0079].map(c => String.fromCodePoint(c))
readonly property var chargeIcons: [0xf089f, 0xf089c, 0xf0086, 0xf0087, 0xf0088, 0xf089d, 0xf0089, 0xf089e, 0xf008a, 0xf008b, 0xf0085].map(c => String.fromCodePoint(c))
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
spacing: 4
Text {
anchors.verticalCenter: parent.verticalCenter
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
color: Theme.text
text: Math.round(root.pct) + "%"
}
Text {
anchors.verticalCenter: parent.verticalCenter
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
text: root.icon
// Green while actively charging or full; lavender while plugged but held (e.g. batsaver limit);
// otherwise level-based (red / yellow / normal) on battery.
color: root.devState === UPowerDeviceState.Charging || root.devState === UPowerDeviceState.FullyCharged ? Theme.stateCharging : root.devState === UPowerDeviceState.PendingCharge ? Theme.accent : root.pct <= 15 ? Theme.stateCritical : root.pct <= 30 ? Theme.stateWarning : Theme.text
}
HoverHandler {
id: hover
}
TapHandler {
onTapped: {
if (!root.popouts)
return;
const pr = root.mapToItem(null, root.width, root.height + 4);
root.popouts.open("battery", Qt.rect(pr.x, pr.y, 0, 0), null);
}
}
Tooltip {
anchorItem: root
shown: hover.hovered
text: {
const s = root.dev ? root.dev.state : UPowerDeviceState.Unknown;
let label = "";
switch (s) {
case UPowerDeviceState.Charging:
label = qsTr("charging");
break;
case UPowerDeviceState.Discharging:
label = qsTr("discharging");
break;
case UPowerDeviceState.FullyCharged:
label = qsTr("full");
break;
case UPowerDeviceState.PendingCharge:
label = qsTr("plugged in");
break;
case UPowerDeviceState.PendingDischarge:
label = qsTr("pending");
break;
case UPowerDeviceState.Empty:
label = qsTr("empty");
break;
}
// Time estimate only exists while actively charging/discharging with a known rate.
const secs = s === UPowerDeviceState.Charging ? (root.dev ? root.dev.timeToFull : 0) : s === UPowerDeviceState.Discharging ? (root.dev ? root.dev.timeToEmpty : 0) : 0;
let t = "";
if (secs > 0) {
const h = Math.floor(secs / 3600);
const m = Math.floor((secs % 3600) / 60);
t = " · " + (h > 0 ? h + "h " : "") + m + "m";
}
return Math.round(root.pct) + "%" + (label ? " · " + label : "") + t;
}
}
}
+183
View File
@@ -0,0 +1,183 @@
// ABOUTME: Battery popout — charge/health/time/power readout plus the conservation-mode toggle.
// ABOUTME: Readout from quickshell UPower; conservation via the privileged moonarch-batsaver-toggle helper.
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell.Io
import Quickshell.Services.UPower
PopoutPanel {
id: root
property var popouts: null
title: qsTr("Battery")
// --- battery readout (UPower) ---
readonly property var dev: UPower.displayDevice
readonly property real pct: dev ? dev.percentage * 100 : 0
readonly property int devState: dev ? dev.state : UPowerDeviceState.Unknown
readonly property string stateLabel: {
switch (root.devState) {
case UPowerDeviceState.Charging:
return qsTr("charging");
case UPowerDeviceState.Discharging:
return qsTr("discharging");
case UPowerDeviceState.FullyCharged:
return qsTr("full");
case UPowerDeviceState.PendingCharge:
return qsTr("plugged in");
case UPowerDeviceState.PendingDischarge:
return qsTr("pending");
case UPowerDeviceState.Empty:
return qsTr("empty");
default:
return "";
}
}
// Time estimate exists only while actively charging/discharging with a known rate.
readonly property int etaSecs: root.devState === UPowerDeviceState.Charging ? (root.dev ? root.dev.timeToFull : 0) : root.devState === UPowerDeviceState.Discharging ? (root.dev ? root.dev.timeToEmpty : 0) : 0
readonly property real rate: root.dev ? Math.abs(root.dev.changeRate) : 0
// healthPercentage may be reported as 0-1 or 0-100 depending on the backend; normalize.
readonly property real health: {
if (!root.dev || !root.dev.healthSupported)
return -1;
const h = root.dev.healthPercentage;
return h <= 1 ? h * 100 : h;
}
function fmtTime(secs) {
if (secs <= 0)
return "";
const h = Math.floor(secs / 3600);
const m = Math.floor((secs % 3600) / 60);
return (h > 0 ? h + "h " : "") + m + "m";
}
// --- conservation (batsaver) state, read from sysfs ---
property int limit: 100
property bool supported: false
readonly property bool conservation: root.supported && root.limit <= 80
// 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"
onShownChanged: if (root.shown)
limitFile.reload()
FileView {
id: limitFile
path: "/sys/class/power_supply/BAT0/charge_control_end_threshold"
blockLoading: true
onLoaded: {
const v = parseInt((text() || "").trim());
if (!isNaN(v)) {
root.limit = v;
root.supported = true;
}
}
onLoadFailed: root.supported = false
}
// moonarch-batsaver-toggle flips 80<->100 (pkexec + persistence itself); re-read when it exits.
Process {
id: toggleProc
command: ["/usr/bin/moonarch-batsaver-toggle"]
onExited: (code, status) => limitFile.reload()
}
// Headline: charge % + status.
Text {
width: root.contentWidth
text: Math.round(root.pct) + "%" + (root.stateLabel ? " · " + root.stateLabel : "")
color: Theme.text
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize + 3
font.bold: true
}
InfoRow {
visible: root.etaSecs > 0
label: root.devState === UPowerDeviceState.Charging ? qsTr("Time to full") : qsTr("Time to empty")
value: root.fmtTime(root.etaSecs)
}
InfoRow {
visible: root.rate > 0
label: qsTr("Power draw")
value: root.rate.toFixed(1) + " W"
}
InfoRow {
visible: root.health >= 0
label: qsTr("Health")
value: Math.round(root.health) + "%"
}
// Divider between the readout and the conservation control.
Rectangle {
width: root.contentWidth
height: 1
color: Theme.surface1
}
// Conservation — labeled row so the toggle can't be mistaken for a battery on/off switch.
Item {
width: root.contentWidth
implicitHeight: 20
visible: root.supported
Text {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
text: qsTr("Conservation")
color: Theme.text
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
Toggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: root.conservation
enabled: root.supported && !toggleProc.running
onToggled: toggleProc.running = true
}
}
Text {
width: root.contentWidth
visible: root.supported
text: root.conservation ? qsTr("Charging stops at %1%").arg(root.limit) : qsTr("Charging to 100%")
color: Theme.subtext0
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize - 1
wrapMode: Text.WordWrap
}
Text {
width: root.contentWidth
visible: !root.supported
text: qsTr("No charge-limit support")
color: Theme.subtext0
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize - 1
}
// Label (left) + value (right) row for the readout.
component InfoRow: Item {
id: infoRow
property string label: ""
property string value: ""
width: root.contentWidth
implicitHeight: 18
Text {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
text: infoRow.label
color: Theme.subtext0
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize - 1
}
Text {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: infoRow.value
color: Theme.text
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize - 1
}
}
}
@@ -0,0 +1,50 @@
// ABOUTME: Bluetooth bar widget — adapter/connection state icon; click opens the bluetooth popout.
// ABOUTME: Hidden entirely when the machine has no bluetooth adapter (e.g. desktops without BT).
import QtQuick
import Quickshell.Bluetooth
Item {
id: root
property var popouts: null
readonly property var adapter: Bluetooth.defaultAdapter
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
implicitWidth: visible ? iconText.implicitWidth : 0
implicitHeight: Theme.barHeight
// Nerd Font (Material) bluetooth icons — codepoints direct to avoid glyph-encoding ambiguity.
readonly property string icon: !root.on ? String.fromCodePoint(0xf00b2) // bluetooth-off
: root.connected ? String.fromCodePoint(0xf00b1) // bluetooth-connect
: String.fromCodePoint(0xf00af) // bluetooth
Text {
id: iconText
anchors.verticalCenter: parent.verticalCenter
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize + 1
color: root.on ? Theme.text : Theme.subtext0
text: root.icon
}
MouseArea {
anchors.fill: parent
onClicked: {
if (!root.popouts)
return;
const pr = root.mapToItem(null, root.width, root.height + 4);
root.popouts.open("bluetooth", Qt.rect(pr.x, pr.y, 0, 0), null);
}
}
HoverHandler {
id: hover
}
Tooltip {
anchorItem: root
shown: hover.hovered
text: !root.on ? qsTr("Bluetooth off") : root.connected ? qsTr("Bluetooth connected") : qsTr("Bluetooth on")
}
}
+150
View File
@@ -0,0 +1,150 @@
// ABOUTME: Bluetooth popout — adapter + scan toggles and a device list (connect/disconnect/pair/forget).
// ABOUTME: Rendered in PopoutHost; list/hover pattern like the other popouts (own impl).
import QtQuick
import Quickshell
import Quickshell.Bluetooth
PopoutPanel {
id: root
property var popouts: null
title: qsTr("Bluetooth")
headerActions: Toggle {
checked: root.adapter ? root.adapter.enabled : false
onToggled: if (root.adapter)
root.adapter.enabled = !root.adapter.enabled
}
readonly property var adapter: Bluetooth.defaultAdapter
readonly property var devs: {
if (!root.adapter || !root.adapter.devices)
return [];
return root.adapter.devices.values.slice().sort((a, b) => {
if (a.connected !== b.connected)
return a.connected ? -1 : 1;
if (a.paired !== b.paired)
return a.paired ? -1 : 1;
return (a.deviceName || a.name || "").localeCompare(b.deviceName || b.name || "");
});
}
// 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"
// 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)
root.adapter.discovering = root.shown;
}
onShownChanged: root.syncDiscovery()
Component.onCompleted: root.syncDiscovery()
Component.onDestruction: if (root.adapter && root.adapter.discovering)
root.adapter.discovering = false
Text {
visible: root.adapter === null
width: root.contentWidth
text: qsTr("No Bluetooth adapter")
color: Theme.subtext0
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
Column {
width: root.contentWidth
spacing: 2
visible: root.adapter !== null && root.adapter.enabled
Repeater {
model: root.devs
delegate: ListRow {
id: devRow
required property var modelData
width: parent.width
onClicked: {
const d = devRow.modelData;
if (d.state === BluetoothDeviceState.Connected)
d.disconnect();
else if (d.paired || d.bonded)
d.connect();
else
d.pair();
}
Image {
id: devIcon
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
width: 16
height: 16
sourceSize.width: 16
sourceSize.height: 16
fillMode: Image.PreserveAspectFit
visible: source.toString().length > 0
source: devRow.modelData.icon ? Quickshell.iconPath(devRow.modelData.icon, true) : ""
}
// Right cluster — a Row auto-collapses hidden children, so no per-item width juggling.
Row {
id: rightCluster
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
Text {
anchors.verticalCenter: parent.verticalCenter
visible: devRow.modelData.batteryAvailable
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize - 1
color: Theme.subtext1
text: {
const b = devRow.modelData.battery;
return Math.round(b <= 1 ? b * 100 : b) + "%";
}
}
Text {
id: statusMark
anchors.verticalCenter: parent.verticalCenter
visible: text.length > 0
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
color: (devRow.modelData.state === BluetoothDeviceState.Connected) ? Theme.accent : Theme.subtext0
text: {
if (devRow.modelData.pairing)
return qsTr("pairing…");
if (devRow.modelData.state === BluetoothDeviceState.Connecting)
return qsTr("connecting…");
if (devRow.modelData.state === BluetoothDeviceState.Connected)
return String.fromCodePoint(0xf012c); // md-check
return "";
}
}
// Forget (paired devices only) — own MouseArea grabs the click before the row.
Text {
anchors.verticalCenter: parent.verticalCenter
visible: devRow.modelData.paired
text: String.fromCodePoint(0xf0156) // md-close
color: Theme.subtext0
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
MouseArea {
anchors.fill: parent
onClicked: devRow.modelData.forget()
}
}
}
Text {
anchors.left: devIcon.visible ? devIcon.right : parent.left
anchors.leftMargin: devIcon.visible ? 6 : 0
anchors.right: rightCluster.left
anchors.rightMargin: 6
anchors.verticalCenter: parent.verticalCenter
text: devRow.modelData.deviceName || devRow.modelData.name || devRow.modelData.address
elide: Text.ElideRight
color: Theme.text
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
}
}
}
}
+34
View File
@@ -0,0 +1,34 @@
// ABOUTME: Clock/date bar widget backed by the quickshell SystemClock.
// ABOUTME: Click toggles between the short (no year) and long (with year) date.
import QtQuick
import Quickshell
Text {
id: root
property bool showYear: false
SystemClock {
id: clock
precision: SystemClock.Minutes
}
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.bold: true
color: Theme.text
text: Qt.formatDateTime(clock.date, root.showYear ? "dd.MM.yyyy HH:mm" : "dd.MM. HH:mm")
MouseArea {
anchors.fill: parent
onClicked: root.showYear = !root.showYear
}
HoverHandler {
id: hover
}
Tooltip {
anchorItem: root
shown: hover.hovered
text: Qt.formatDateTime(clock.date, "dddd, dd. MMMM yyyy")
}
}
+16
View File
@@ -0,0 +1,16 @@
// ABOUTME: Shared idle-inhibit state for the bar — one toggle mirrored across all screens.
// ABOUTME: Each Bar's Wayland IdleInhibitor binds its `enabled` here, so the whole session stays awake as one.
pragma Singleton
import QtQuick
import Quickshell
Singleton {
id: root
// When true, the session is prevented from idling (no idle lock/sleep).
property bool enabled: false
function toggle() {
root.enabled = !root.enabled;
}
}
@@ -0,0 +1,35 @@
// ABOUTME: Idle-inhibitor bar widget — click toggles whether the session may idle (idle lock/sleep).
// ABOUTME: Reflects the shared IdleInhibit state; the actual Wayland inhibitor lives on each Bar window.
import QtQuick
Item {
id: root
implicitWidth: iconText.implicitWidth
implicitHeight: Theme.barHeight
Text {
id: iconText
anchors.verticalCenter: parent.verticalCenter
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize + 1
color: IdleInhibit.enabled ? Theme.accent : Theme.text
// Nerd Font (Material) — codepoints direct to avoid glyph-encoding ambiguity in source.
text: IdleInhibit.enabled ? String.fromCodePoint(0xf0208) // eye — display stays awake
: String.fromCodePoint(0xf0209) // eye-off — idle sleep/lock allowed
}
HoverHandler {
id: hover
}
MouseArea {
anchors.fill: parent
onClicked: IdleInhibit.toggle()
}
Tooltip {
anchorItem: root
shown: hover.hovered
text: IdleInhibit.enabled ? qsTr("Staying awake — no idle lock") : qsTr("Idle lock active")
}
}
+29
View File
@@ -0,0 +1,29 @@
// ABOUTME: Hoverable, clickable list row for popout lists (audio devices, wifi networks, BT devices).
// ABOUTME: Shared hover background + click signal; row content goes in the default slot inside inner.
import QtQuick
Rectangle {
id: root
default property alias content: inner.data
property alias hovered: hover.hovered
signal clicked
implicitHeight: 32
radius: Theme.radius
color: hover.hovered ? Theme.surface0 : "transparent"
Item {
id: inner
anchors.fill: parent
anchors.leftMargin: 6
anchors.rightMargin: 6
HoverHandler {
id: hover
}
MouseArea {
anchors.fill: parent
onClicked: root.clicked()
}
}
}
+20
View File
@@ -0,0 +1,20 @@
// ABOUTME: Reusable translucent rounded container for a single bar module.
// ABOUTME: Applies the shared padding, radius and module background from Theme.
import QtQuick
Rectangle {
id: root
default property alias data: inner.data
implicitWidth: inner.width + Theme.modulePadH * 2
implicitHeight: Theme.barHeight - Theme.moduleMarginV * 2
color: Theme.moduleBg
radius: Theme.radius
Item {
id: inner
anchors.centerIn: parent
width: childrenRect.width
height: childrenRect.height
}
}
@@ -0,0 +1,63 @@
// ABOUTME: Network bar widget — wifi signal-strength icon; click opens the network popout.
// ABOUTME: Reads the connected wifi network's signal from the Networking service (wifi-focused).
import QtQuick
import Quickshell.Networking
Item {
id: root
property var popouts: null
implicitWidth: iconText.implicitWidth
implicitHeight: Theme.barHeight
readonly property var wifiDev: {
const ds = Networking.devices ? Networking.devices.values : [];
return ds.find(d => d && d.type === DeviceType.Wifi) || null;
}
readonly property var conn: {
if (!root.wifiDev || !root.wifiDev.networks)
return null;
return root.wifiDev.networks.values.find(n => n && n.connected) || null;
}
// Nerd Font (Material) wifi-strength ramp — codepoints direct to avoid glyph-encoding ambiguity.
readonly property string icon: {
if (!Networking.wifiEnabled)
return String.fromCodePoint(0xf05aa); // wifi-off
if (!root.conn)
return String.fromCodePoint(0xf092d); // wifi-strength-outline (no connection)
const s = root.conn.signalStrength;
return s >= 0.75 ? String.fromCodePoint(0xf0928) // strength-4
: s >= 0.5 ? String.fromCodePoint(0xf0925) // strength-3
: s >= 0.25 ? String.fromCodePoint(0xf0922) // strength-2
: String.fromCodePoint(0xf091f); // strength-1
}
Text {
id: iconText
anchors.verticalCenter: parent.verticalCenter
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize + 1
color: Networking.wifiEnabled ? Theme.text : Theme.subtext0
text: root.icon
}
MouseArea {
anchors.fill: parent
onClicked: {
if (!root.popouts)
return;
const pr = root.mapToItem(null, root.width, root.height + 4);
root.popouts.open("network", Qt.rect(pr.x, pr.y, 0, 0), null);
}
}
HoverHandler {
id: hover
}
Tooltip {
anchorItem: root
shown: hover.hovered
text: !Networking.wifiEnabled ? qsTr("Wi-Fi off") : !root.conn ? qsTr("Not connected") : root.conn.name + " · " + Math.round(root.conn.signalStrength * 100) + "%"
}
}
+121
View File
@@ -0,0 +1,121 @@
// ABOUTME: Network popout (minimal) — wifi enable toggle + list of networks to connect/disconnect.
// ABOUTME: No PSK entry for new secured networks (out of scope); list/hover pattern like the other popouts.
import QtQuick
import Quickshell.Networking
PopoutPanel {
id: root
property var popouts: null
title: qsTr("Network")
headerActions: Toggle {
checked: Networking.wifiEnabled
onToggled: Networking.wifiEnabled = !Networking.wifiEnabled
}
readonly property var wifiDev: {
const ds = Networking.devices ? Networking.devices.values : [];
return ds.find(d => d && d.type === DeviceType.Wifi) || null;
}
readonly property var nets: {
if (!root.wifiDev || !root.wifiDev.networks)
return [];
return root.wifiDev.networks.values.slice().sort((a, b) => {
if (a.connected !== b.connected)
return a.connected ? -1 : 1;
return b.signalStrength - a.signalStrength;
});
}
// 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"
onShownChanged: if (root.wifiDev)
root.wifiDev.scannerEnabled = root.shown
Component.onCompleted: if (root.wifiDev)
root.wifiDev.scannerEnabled = root.shown
Component.onDestruction: if (root.wifiDev)
root.wifiDev.scannerEnabled = false
Text {
visible: root.wifiDev === null
width: root.contentWidth
text: qsTr("No Wi-Fi device")
color: Theme.subtext0
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
Column {
width: root.contentWidth
spacing: 2
visible: root.wifiDev !== null && Networking.wifiEnabled
Repeater {
model: root.nets
delegate: ListRow {
id: netRow
required property var modelData
readonly property bool secured: netRow.modelData.security !== WifiSecurityType.Open
readonly property bool actionable: netRow.modelData.connected || netRow.modelData.known || !netRow.secured
width: parent.width
onClicked: {
const n = netRow.modelData;
if (n.connected)
n.disconnect();
else if (n.known || !netRow.secured)
n.connect();
// secured + unknown: no-op (no PSK entry in minimal scope)
}
Row {
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 6
Text {
id: sig
anchors.verticalCenter: parent.verticalCenter
width: 18
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
color: Theme.text
text: {
const s = netRow.modelData.signalStrength;
return s >= 0.75 ? String.fromCodePoint(0xf0928) : s >= 0.5 ? String.fromCodePoint(0xf0925) : s >= 0.25 ? String.fromCodePoint(0xf0922) : String.fromCodePoint(0xf091f);
}
}
Text {
anchors.verticalCenter: parent.verticalCenter
width: parent.width - sig.width - lockIcon.width - marker.width - parent.spacing * 3
text: netRow.modelData.name
elide: Text.ElideRight
color: netRow.actionable ? Theme.text : Theme.subtext0
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
Text {
id: lockIcon
anchors.verticalCenter: parent.verticalCenter
visible: netRow.secured
width: visible ? 14 : 0
text: String.fromCodePoint(0xf033e) // lock
color: Theme.subtext0
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize - 2
}
Text {
id: marker
anchors.verticalCenter: parent.verticalCenter
visible: netRow.modelData.connected
width: visible ? 14 : 0
text: String.fromCodePoint(0xf012c) // md-check
color: Theme.accent
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
}
}
}
}
}
+119
View File
@@ -0,0 +1,119 @@
// ABOUTME: Niri compositor IPC service — consumes the NIRI_SOCKET event stream.
// ABOUTME: Exposes reactive workspaces and the focused window for the bar widgets.
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
Singleton {
id: root
readonly property string socketPath: Quickshell.env("NIRI_SOCKET") || ""
// Reactive state consumed by widgets.
property var workspaces: []
property var windowsById: ({})
property int focusedWindowId: -1
property var activeWindow: null
function recomputeActive() {
root.activeWindow = (root.focusedWindowId >= 0 && root.windowsById[root.focusedWindowId])
? root.windowsById[root.focusedWindowId]
: null;
}
// Event stream: niri sends the full current state up-front, then incremental updates.
Socket {
id: eventSock
path: root.socketPath
connected: root.socketPath.length > 0
parser: SplitParser {
splitMarker: "\n"
onRead: line => root.handleEvent(line)
}
onConnectedChanged: {
if (connected)
write("\"EventStream\"\n");
}
}
function handleEvent(line) {
if (!line || line.length === 0)
return;
var ev;
try {
ev = JSON.parse(line);
} catch (e) {
return;
}
if (ev.WorkspacesChanged) {
root.workspaces = ev.WorkspacesChanged.workspaces;
} else if (ev.WorkspaceActivated) {
var a = ev.WorkspaceActivated;
var target = root.workspaces.find(w => w.id === a.id);
if (target) {
var out = target.output;
root.workspaces = root.workspaces.map(function (w) {
var n = Object.assign({}, w);
if (w.output === out)
n.is_active = (w.id === a.id);
if (a.focused)
n.is_focused = (w.id === a.id);
return n;
});
}
} else if (ev.WorkspaceUrgencyChanged) {
var u = ev.WorkspaceUrgencyChanged;
root.workspaces = root.workspaces.map(w => w.id === u.id ? Object.assign({}, w, {
is_urgent: u.urgent
}) : w);
} else if (ev.WorkspaceActiveWindowChanged) {
var c = ev.WorkspaceActiveWindowChanged;
root.workspaces = root.workspaces.map(w => w.id === c.workspace_id ? Object.assign({}, w, {
active_window_id: c.active_window_id
}) : w);
} else if (ev.WindowsChanged) {
var map = {};
for (var i = 0; i < ev.WindowsChanged.windows.length; i++) {
var win = ev.WindowsChanged.windows[i];
map[win.id] = win;
}
root.windowsById = map;
var focused = ev.WindowsChanged.windows.find(w => w.is_focused);
root.focusedWindowId = focused ? focused.id : -1;
root.recomputeActive();
} else if (ev.WindowOpenedOrChanged) {
var w2 = ev.WindowOpenedOrChanged.window;
var m2 = Object.assign({}, root.windowsById);
m2[w2.id] = w2;
root.windowsById = m2;
if (w2.is_focused)
root.focusedWindowId = w2.id;
root.recomputeActive();
} else if (ev.WindowClosed) {
var m3 = Object.assign({}, root.windowsById);
delete m3[ev.WindowClosed.id];
root.windowsById = m3;
if (root.focusedWindowId === ev.WindowClosed.id)
root.focusedWindowId = -1;
root.recomputeActive();
} else if (ev.WindowFocusChanged) {
var fid = ev.WindowFocusChanged.id;
root.focusedWindowId = (fid === null || fid === undefined) ? -1 : fid;
root.recomputeActive();
}
}
// Focus a workspace by its niri per-output index. Best-effort for the pilot.
function focusWorkspace(idx) {
focusProc.command = ["niri", "msg", "action", "focus-workspace", String(idx)];
focusProc.running = true;
}
Process {
id: focusProc
}
}
+168
View File
@@ -0,0 +1,168 @@
// ABOUTME: Notification center (history) — reuses the popout chrome; DND toggle + clear-all + a capped list.
// ABOUTME: Registered as the "notifications" popout; opening it silences any still-pending toasts.
pragma ComponentBehavior: Bound
import QtQuick
PopoutPanel {
id: root
property var popouts: null
title: qsTr("Notifications")
readonly property int shownMax: 8
headerActions: Row {
spacing: 10
Rectangle {
anchors.verticalCenter: parent.verticalCenter
visible: NotifService.count > 0
implicitWidth: 24
implicitHeight: 18
radius: Theme.radius
color: clearHover.containsMouse ? Theme.surface1 : Theme.surface0
Text {
anchors.centerIn: parent
text: String.fromCodePoint(0xf01b4) // md-delete (clear all)
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
color: clearHover.containsMouse ? Theme.red : Theme.subtext0
}
MouseArea {
id: clearHover
anchors.fill: parent
hoverEnabled: true
onClicked: NotifService.clearAll()
}
}
// On = notifications active; off = do not disturb (reads naturally next to "Clear").
Toggle {
anchors.verticalCenter: parent.verticalCenter
checked: !NotifService.dnd
onToggled: NotifService.toggleDnd()
}
}
// 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"
function silencePending() {
for (const i of NotifService.list)
i.popup = false;
}
onShownChanged: if (root.shown)
root.silencePending()
Component.onCompleted: root.silencePending()
Text {
width: root.contentWidth
visible: NotifService.count === 0
text: qsTr("No notifications")
color: Theme.subtext0
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
Repeater {
model: NotifService.list.slice(0, root.shownMax)
delegate: Rectangle {
id: row
required property var modelData // NotifItem
width: root.contentWidth
implicitHeight: rowCol.implicitHeight + 12
radius: Theme.radius
color: Theme.surface0
Column {
id: rowCol
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: 8
anchors.rightMargin: 8
spacing: 2
Text {
width: parent.width - 16
text: row.modelData.summary
color: Theme.text
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.bold: true
elide: Text.ElideRight
}
Text {
width: parent.width
visible: row.modelData.body.length > 0
text: row.modelData.body
color: Theme.subtext1
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize - 1
textFormat: Text.PlainText
wrapMode: Text.WordWrap
maximumLineCount: 4
elide: Text.ElideRight
}
Row {
spacing: 6
visible: row.modelData.actions.length > 0
topPadding: 2
Repeater {
model: row.modelData.actions
delegate: Rectangle {
id: actionBtn
required property var modelData // NotificationAction
radius: Theme.radius
color: actionHover.containsMouse ? Theme.surface2 : Theme.surface1
implicitWidth: actionText.implicitWidth + 14
implicitHeight: actionText.implicitHeight + 6
Text {
id: actionText
anchors.centerIn: parent
text: actionBtn.modelData.text
color: Theme.text
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize - 1
}
MouseArea {
id: actionHover
anchors.fill: parent
hoverEnabled: true
onClicked: actionBtn.modelData.invoke()
}
}
}
}
}
// Dismiss (removes from history).
Text {
anchors.top: parent.top
anchors.right: parent.right
anchors.topMargin: 4
anchors.rightMargin: 6
text: String.fromCodePoint(0xf0156) // md-close
color: dismissHover.containsMouse ? Theme.red : Theme.subtext0
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
MouseArea {
id: dismissHover
anchors.fill: parent
hoverEnabled: true
onClicked: row.modelData.close()
}
}
}
}
Text {
width: root.contentWidth
visible: NotifService.count > root.shownMax
text: "+" + (NotifService.count - root.shownMax) + " " + qsTr("more")
color: Theme.subtext0
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize - 1
}
}
+45
View File
@@ -0,0 +1,45 @@
// ABOUTME: Model wrapper for one server Notification — caches fields and owns the toast expiry timer.
// ABOUTME: `popup` is true while shown as a toast; close() dismisses on the bus (removal happens on onClosed).
import QtQuick
import Quickshell.Services.Notifications
QtObject {
id: root
required property Notification notification
readonly property string summary: notification ? notification.summary : ""
readonly property string body: notification ? notification.body : ""
readonly property string appIcon: notification ? notification.appIcon : ""
readonly property string image: notification ? notification.image : ""
readonly property int urgency: notification ? notification.urgency : NotificationUrgency.Normal
readonly property var actions: notification ? notification.actions : []
// Shown as a transient toast while true; set false to keep it only in history.
property bool popup: true
// Hovering a toast pauses its expiry.
property bool paused: false
// Set once the toast has slid in; survives delegate rebuilds so a toast re-instantiated
// because a sibling changed state does not replay the entrance animation.
property bool entered: false
// Auto-expire the toast (not the history entry). Critical urgency and an explicit
// expireTimeout of 0 ("never expire", per the freedesktop spec) are exempt.
property Timer timer: Timer {
running: root.popup && !root.paused && root.urgency !== NotificationUrgency.Critical && (!root.notification || root.notification.expireTimeout !== 0)
interval: root.notification && root.notification.expireTimeout > 0 ? root.notification.expireTimeout : 5000
onTriggered: root.popup = false
}
// dismiss() closes it on the bus; the server then emits `closed`, handled below → removal.
// dismiss() closes it on the bus; the server then emits `closed`, handled below → removal.
function close() {
root.notification.dismiss();
}
property Connections conn: Connections {
target: root.notification
function onClosed(reason) {
NotifService.remove(root);
}
}
}
+76
View File
@@ -0,0 +1,76 @@
// ABOUTME: Notification daemon singleton — hosts the NotificationServer and the owned notification list.
// ABOUTME: Wraps each incoming Notification in a NotifItem; exposes history (list) + live toasts (popups).
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Services.Notifications
import Quickshell.Io
Singleton {
id: root
// Newest-first. History resets on hot-reload (dev only; production quickshell does not reload).
property var list: []
readonly property var popups: root.list.filter(i => i.popup)
readonly property int count: root.list.length
property bool dnd: false
function shouldPopup() {
return !root.dnd;
}
function remove(item) {
root.list = root.list.filter(x => x !== item);
item.destroy();
}
function clearAll() {
// Snapshot — close() mutates the list.
const snapshot = root.list.slice();
for (const i of snapshot)
i.close();
}
function toggleDnd() {
root.dnd = !root.dnd;
}
NotificationServer {
id: server
keepOnReload: false
bodySupported: true
bodyMarkupSupported: true
bodyImagesSupported: true
imageSupported: true
actionsSupported: true
actionIconsSupported: true
onNotification: notif => {
notif.tracked = true;
const item = itemComp.createObject(root, {
notification: notif
});
item.popup = root.shouldPopup();
root.list = [item, ...root.list];
}
}
Component {
id: itemComp
NotifItem {}
}
// For future niri keybinds (see plan follow-up); harmless to expose now.
IpcHandler {
target: "notifs"
function toggleDnd(): void {
root.toggleDnd();
}
function clear(): void {
root.clearAll();
}
function isDnd(): bool {
return root.dnd;
}
}
}
+136
View File
@@ -0,0 +1,136 @@
// ABOUTME: Single transient toast delegate — icon/image, summary, body, urgency accent, action buttons.
// ABOUTME: Slides in from the right; hover pauses expiry; left-click dismisses (keeps history), middle closes.
import QtQuick
import Quickshell
import Quickshell.Services.Notifications
Rectangle {
id: root
required property var modelData // NotifItem
width: 340
implicitHeight: layout.implicitHeight + 20
radius: Theme.radius
color: Theme.mantle
border.width: 1
border.color: root.modelData.urgency === NotificationUrgency.Critical ? Theme.red : Theme.surface1
// Slide-in from the right edge — but only for a genuinely new toast. The Repeater rebuilds
// all delegates whenever any item's popup flag changes; `entered` (on the model, survives the
// rebuild) makes a re-instantiated toast render at rest instead of replaying the animation.
x: root.modelData.entered ? 0 : width + 20
Component.onCompleted: {
root.x = 0;
root.modelData.entered = true;
}
Behavior on x {
NumberAnimation {
duration: Theme.animDurationSlide
easing.type: Theme.animEasingSpatial
}
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.LeftButton | Qt.MiddleButton
onEntered: root.modelData.paused = true
onExited: root.modelData.paused = false
onClicked: mouse => {
if (mouse.button === Qt.MiddleButton)
root.modelData.close();
else
root.modelData.popup = false;
}
}
Row {
id: layout
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: 10
anchors.rightMargin: 10
spacing: 10
Item {
width: 32
height: 32
anchors.verticalCenter: parent.verticalCenter
Image {
anchors.fill: parent
visible: source.toString().length > 0
source: root.modelData.image.length > 0 ? Qt.resolvedUrl(root.modelData.image) : root.modelData.appIcon.length > 0 ? Quickshell.iconPath(root.modelData.appIcon, true) : ""
sourceSize.width: 32
sourceSize.height: 32
fillMode: Image.PreserveAspectFit
}
Text {
anchors.centerIn: parent
visible: root.modelData.image.length === 0 && root.modelData.appIcon.length === 0
font.family: Theme.fontFamily
font.pixelSize: 22
color: Theme.accent
text: String.fromCodePoint(0xf009a) // bell
}
}
Column {
width: parent.width - 32 - parent.spacing
spacing: 2
Text {
width: parent.width
text: root.modelData.summary
color: Theme.text
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.bold: true
elide: Text.ElideRight
}
Text {
width: parent.width
visible: root.modelData.body.length > 0
text: root.modelData.body
color: Theme.subtext1
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize - 1
textFormat: Text.PlainText
wrapMode: Text.WordWrap
maximumLineCount: 3
elide: Text.ElideRight
}
Row {
spacing: 6
visible: root.modelData.actions.length > 0
Repeater {
model: root.modelData.actions
delegate: Rectangle {
id: actionBtn
required property var modelData // NotificationAction
radius: Theme.radius
color: actionHover.containsMouse ? Theme.surface1 : Theme.surface0
implicitWidth: actionText.implicitWidth + 16
implicitHeight: actionText.implicitHeight + 8
Text {
id: actionText
anchors.centerIn: parent
text: actionBtn.modelData.text
color: Theme.text
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize - 1
}
MouseArea {
id: actionHover
anchors.fill: parent
hoverEnabled: true
onClicked: actionBtn.modelData.invoke()
}
}
}
}
}
}
}
@@ -0,0 +1,40 @@
// ABOUTME: Per-screen transient toast layer — a top-right layer-shell surface stacking live toasts.
// ABOUTME: Reserves no space; shows only while there are popups. One instance per screen (via Variants).
import QtQuick
import Quickshell
import Quickshell.Wayland
PanelWindow {
id: root
required property var modelData
screen: modelData
anchors {
top: true
right: true
}
implicitWidth: 360
implicitHeight: Math.max(1, col.implicitHeight + 16)
exclusiveZone: 0
exclusionMode: ExclusionMode.Ignore
color: "transparent"
visible: NotifService.popups.length > 0
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
WlrLayershell.namespace: "moonarch-notifications"
Column {
id: col
anchors.top: parent.top
anchors.right: parent.right
anchors.topMargin: 8
anchors.rightMargin: 8
spacing: 8
Repeater {
model: NotifService.popups
delegate: NotifToast {}
}
}
}
@@ -0,0 +1,49 @@
// ABOUTME: Notifications bar widget — bell icon reflecting count/DND; click opens the center, right-click DND.
// ABOUTME: Backed by the NotifService singleton (count, dnd).
import QtQuick
Item {
id: root
property var popouts: null
implicitWidth: iconText.implicitWidth
implicitHeight: Theme.barHeight
// Nerd Font (Material) — codepoints direct to avoid glyph-encoding ambiguity.
readonly property string icon: NotifService.dnd ? String.fromCodePoint(0xf009b) // bell-off
: NotifService.count > 0 ? String.fromCodePoint(0xf116b) // bell-badge
: String.fromCodePoint(0xf009c) // bell-outline
Text {
id: iconText
anchors.verticalCenter: parent.verticalCenter
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize + 1
color: NotifService.dnd ? Theme.subtext0 : NotifService.count > 0 ? Theme.accent : Theme.text
text: root.icon
}
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.LeftButton | Qt.RightButton
onClicked: mouse => {
if (mouse.button === Qt.RightButton) {
NotifService.toggleDnd();
return;
}
if (!root.popouts)
return;
const pr = root.mapToItem(null, root.width, root.height + 4);
root.popouts.open("notifications", Qt.rect(pr.x, pr.y, 0, 0), null);
}
}
HoverHandler {
id: hover
}
Tooltip {
anchorItem: root
shown: hover.hovered
text: NotifService.dnd ? qsTr("Do not disturb") : NotifService.count + " " + qsTr("notifications")
}
}
+189
View File
@@ -0,0 +1,189 @@
// ABOUTME: Backdrop window hosting the active bar popout, one per screen.
// ABOUTME: Panel unfolds from under the bar, morphs size between popouts, and melts into the bar via
// ABOUTME: concave top flares. Reveal/morph pattern inspired by caelestia-dots/shell (GPL-3); own impl.
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Shapes
import Quickshell
import Quickshell.Wayland
PanelWindow {
id: root
required property var popouts // PopoutState
readonly property bool wantOpen: root.popouts.hasCurrent
// Keep the window rendered through the close (fold-up) animation, then hide it.
property bool rendered: false
onWantOpenChanged: {
if (root.wantOpen) {
hideTimer.stop();
root.rendered = true;
} else {
hideTimer.restart();
}
}
Timer {
id: hideTimer
interval: Theme.animDurationSlide + 40
onTriggered: root.rendered = false
}
anchors {
top: true
bottom: true
left: true
right: true
}
exclusionMode: ExclusionMode.Normal
exclusiveZone: 0
color: "transparent"
visible: root.rendered
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand
WlrLayershell.namespace: "moonarch-popout"
// Backdrop — click outside the menu closes it. Our own MouseArea (not a compositor grab).
MouseArea {
anchors.fill: parent
enabled: root.wantOpen
onClicked: root.popouts.close()
}
// Clip frame parked just below the bar. offset goes 1 (closed) -> 0 (open): the clip height grows
// while the panel slides down into it, so the popout unfolds from the bar edge (no empty box).
// Sizes are derived straight from the content (NOT from the Shape's implicit size, which depends on
// its own width and would feed back into a runaway growth loop).
Item {
id: frame
readonly property int pad: 4
readonly property int flareR: 12 // top concave flare radius
readonly property int br: Theme.radius // bottom convex radius
readonly property real bodyW: content.implicitWidth + pad * 2
readonly property real bodyH: content.implicitHeight + pad * 2
property real offset: root.wantOpen ? 0 : 1
clip: true
// Body right edge aligns to the trigger icon; the panel is wider by the flare radius each side.
x: root.popouts.anchorRect.x - width + flareR
y: 0
implicitWidth: bodyW + flareR * 2
implicitHeight: bodyH * (1 - offset)
width: implicitWidth
height: implicitHeight
Behavior on offset {
NumberAnimation {
duration: Theme.animDurationSlide
easing.type: Theme.animEasingSpatial
}
}
// Morph size/position ONLY while fully open (switching popouts). During the fold animation
// offset drives the height, and width/x must snap — otherwise the first open animates x from
// the stale anchor and looks like it flies in from the corner.
Behavior on implicitWidth {
enabled: frame.offset < 0.01
NumberAnimation {
duration: Theme.animDurationSlide
easing.type: Theme.animEasingSpatial
}
}
Behavior on implicitHeight {
enabled: frame.offset < 0.01
NumberAnimation {
duration: Theme.animDurationSlide
easing.type: Theme.animEasingSpatial
}
}
Behavior on x {
enabled: frame.offset < 0.01
NumberAnimation {
duration: Theme.animDurationSlide
easing.type: Theme.animEasingSpatial
}
}
Shape {
id: panel
readonly property int cr: frame.flareR
readonly property int br: frame.br
readonly property real wt: width
readonly property real ht: height
width: frame.width
height: frame.bodyH
// Slide within the clip: fully tucked above (hidden) when closed, at rest when open.
anchors.top: parent.top
anchors.topMargin: -frame.bodyH * frame.offset
preferredRendererType: Shape.CurveRenderer
antialiasing: true
focus: true
Keys.onEscapePressed: root.popouts.close()
ShapePath {
fillColor: Theme.base
strokeWidth: 0
startX: 0
startY: 0
PathLine {
x: panel.wt
y: 0
}
// Right concave flare: bar edge -> body right side.
PathArc {
x: panel.wt - panel.cr
y: panel.cr
radiusX: panel.cr
radiusY: panel.cr
direction: PathArc.Counterclockwise
}
PathLine {
x: panel.wt - panel.cr
y: panel.ht - panel.br
}
// Bottom-right convex.
PathArc {
x: panel.wt - panel.cr - panel.br
y: panel.ht
radiusX: panel.br
radiusY: panel.br
direction: PathArc.Clockwise
}
PathLine {
x: panel.cr + panel.br
y: panel.ht
}
// Bottom-left convex.
PathArc {
x: panel.cr
y: panel.ht - panel.br
radiusX: panel.br
radiusY: panel.br
direction: PathArc.Clockwise
}
PathLine {
x: panel.cr
y: panel.cr
}
// Left concave flare: body left side -> bar edge.
PathArc {
x: 0
y: 0
radiusX: panel.cr
radiusY: panel.cr
direction: PathArc.Counterclockwise
}
}
Popouts {
id: content
anchors.centerIn: parent
popouts: root.popouts
}
}
}
}
+44
View File
@@ -0,0 +1,44 @@
// ABOUTME: Shared popout chrome — a fixed-width, padded, titled column wrapping popout content.
// ABOUTME: Header row shows the title with an optional right-aligned control (headerActions), content follows.
import QtQuick
Column {
id: root
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
readonly property int contentWidth: panelWidth - leftPadding - rightPadding
width: panelWidth
leftPadding: 12
rightPadding: 12
topPadding: 12
bottomPadding: 12
spacing: 8
Item {
id: header
width: root.contentWidth
visible: root.title.length > 0
height: visible ? 20 : 0
Text {
id: titleText
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
text: root.title
color: Theme.subtext0
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.bold: true
}
Item {
id: headerRight
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
width: childrenRect.width
height: childrenRect.height
}
}
}
+23
View File
@@ -0,0 +1,23 @@
// ABOUTME: Shared state for a bar's popout host — which popout is open, where, and its payload.
// ABOUTME: One instance per bar (per screen); bar widgets call open()/close().
import QtQuick
QtObject {
id: root
property string currentName: ""
property bool hasCurrent: false
property rect anchorRect: Qt.rect(0, 0, 0, 0)
property var payload: null
function open(name, rect, payload) {
root.payload = payload ?? null;
root.anchorRect = rect;
root.currentName = name;
root.hasCurrent = true;
}
function close() {
root.hasCurrent = false;
}
}
+69
View File
@@ -0,0 +1,69 @@
// ABOUTME: Popout content registry — maps the active popout name to its content component.
// ABOUTME: Loaded inside PopoutHost; extend the mapping as more popouts are added.
pragma ComponentBehavior: Bound
import QtQuick
Loader {
id: root
property var popouts // PopoutState
// Kept active even while closed so the host knows the panel's size for the full slide-out
// animation; each popout gates its own side effects (scan/discovery) on being the current one.
active: true
sourceComponent: {
switch (root.popouts.currentName) {
case "tray":
return trayComp;
case "audio":
return audioComp;
case "network":
return networkComp;
case "bluetooth":
return bluetoothComp;
case "battery":
return batteryComp;
case "notifications":
return notifCenterComp;
default:
return null;
}
}
Component {
id: trayComp
TrayMenu {
menuHandle: root.popouts.payload
popouts: root.popouts
}
}
Component {
id: audioComp
AudioPopout {
popouts: root.popouts
}
}
Component {
id: networkComp
NetworkPopout {
popouts: root.popouts
}
}
Component {
id: bluetoothComp
BluetoothPopout {
popouts: root.popouts
}
}
Component {
id: batteryComp
BatteryPopout {
popouts: root.popouts
}
}
Component {
id: notifCenterComp
NotifCenter {
popouts: root.popouts
}
}
}
+55
View File
@@ -0,0 +1,55 @@
// ABOUTME: Catppuccin Mocha design tokens for the moonarch quickshell bar.
// ABOUTME: Central singleton mirroring the palette and geometry used across the moonarch ecosystem.
pragma Singleton
import QtQuick
import Quickshell
Singleton {
id: root
// Catppuccin Mocha palette — values sourced from the existing foot/swaync/waybar configs.
readonly property color base: "#1e1e2e"
readonly property color mantle: "#181825"
readonly property color crust: "#11111b"
readonly property color surface0: "#313244"
readonly property color surface1: "#45475a"
readonly property color surface2: "#585b70"
readonly property color text: "#cdd6f4"
readonly property color subtext0: "#a6adc8"
readonly property color subtext1: "#bac2de"
readonly property color accent: "#b4befe" // Lavender — the moonarch ecosystem accent
readonly property color red: "#f38ba8"
readonly property color green: "#a6e3a1"
readonly property color yellow: "#f9e2af"
readonly property color peach: "#fab387"
readonly property color blue: "#89b4fa"
readonly property color mauve: "#cba6f7"
// State colors — kept 1:1 with the current waybar style.css.
readonly property color stateCritical: "#cc3436"
readonly property color stateCharging: "#2dcc36"
readonly property color stateWarning: "#e6e600"
// Typography — matches the waybar bar font.
readonly property string fontFamily: "UbuntuSans Nerd Font"
readonly property int fontSize: 13
// Geometry — waybar-derived.
readonly property int barHeight: 40
readonly property int radius: 4
readonly property int modulePadH: 10
readonly property int moduleMarginV: 8
readonly property int spacing: 5
// Motion — centralized animation tokens.
readonly property int animDuration: 160
readonly property int animDurationShort: 110
readonly property int animEasing: Easing.OutCubic
// Spatial motion — popout reveal/morph (size + position), a livelier expressive decel.
readonly property int animDurationSlide: 240
readonly property int animEasingSpatial: Easing.OutQuint
// Backgrounds.
readonly property color barBg: base // opaque — matches the popout bg so popouts read as a bar extension
readonly property color moduleBg: Qt.rgba(surface1.r, surface1.g, surface1.b, 0.35)
}
+34
View File
@@ -0,0 +1,34 @@
// ABOUTME: Small on/off pill toggle switch; caller binds `checked` and handles toggled().
// ABOUTME: Shared by the network (wifi) and bluetooth (adapter/scan) popouts.
import QtQuick
Rectangle {
id: root
property bool checked: false
signal toggled
implicitWidth: 36
implicitHeight: 18
radius: height / 2
color: checked ? Theme.accent : Theme.surface1
Rectangle {
width: parent.height - 4
height: width
radius: width / 2
anchors.verticalCenter: parent.verticalCenter
x: root.checked ? root.width - width - 2 : 2
color: Theme.base
Behavior on x {
NumberAnimation {
duration: Theme.animDurationShort
easing.type: Theme.animEasing
}
}
}
MouseArea {
anchors.fill: parent
onClicked: root.toggled()
}
}
+58
View File
@@ -0,0 +1,58 @@
// ABOUTME: Reusable hover tooltip for bar widgets — a themed popup anchored under the widget.
// ABOUTME: Root is a PopupWindow so it can live inside positioner widgets (Row) without being laid out.
import QtQuick
import Quickshell
PopupWindow {
id: root
property string text: ""
property Item anchorItem: null
// Caller binds this to a HoverHandler's `hovered`; the tooltip appears after a short delay.
property bool shown: false
visible: false
color: "transparent"
anchor.item: root.anchorItem
anchor.edges: Edges.Bottom
anchor.gravity: Edges.Bottom
anchor.margins.top: 6
implicitWidth: bg.implicitWidth
implicitHeight: bg.implicitHeight
onShownChanged: {
if (root.shown)
delay.restart();
else {
delay.stop();
root.visible = false;
}
}
Timer {
id: delay
interval: 450
onTriggered: root.visible = root.shown && root.text.length > 0
}
Rectangle {
id: bg
anchors.fill: parent
implicitWidth: label.implicitWidth + 16
implicitHeight: label.implicitHeight + 8
color: Theme.base
radius: Theme.radius
border.width: 1
border.color: Theme.surface1
Text {
id: label
anchors.centerIn: parent
text: root.text
color: Theme.text
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
}
}
+47
View File
@@ -0,0 +1,47 @@
// ABOUTME: System tray (StatusNotifierItem) bar widget backed by quickshell SystemTray.
// ABOUTME: Left-click activates, middle-click secondary action, right-click opens the themed popout menu.
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell.Services.SystemTray
Row {
id: root
property var popouts: null // PopoutState
spacing: 10
Repeater {
model: SystemTray.items
delegate: Item {
id: iconItem
required property var modelData
width: 15
height: 15
anchors.verticalCenter: parent ? parent.verticalCenter : undefined
Image {
anchors.fill: parent
source: iconItem.modelData.icon
sourceSize.width: 15
sourceSize.height: 15
fillMode: Image.PreserveAspectFit
}
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
onClicked: mouse => {
if (mouse.button === Qt.LeftButton)
iconItem.modelData.activate();
else if (mouse.button === Qt.MiddleButton)
iconItem.modelData.secondaryActivate();
else if (mouse.button === Qt.RightButton && iconItem.modelData.hasMenu && root.popouts) {
// Icon bottom-right in screen coords (the bar window origin equals the screen origin).
const p = iconItem.mapToItem(null, iconItem.width, iconItem.height + 4);
root.popouts.open("tray", Qt.rect(p.x, p.y, 0, 0), iconItem.modelData.menu);
}
}
}
}
}
}
+196
View File
@@ -0,0 +1,196 @@
// ABOUTME: Tray menu content (StatusNotifierItem) — StackView drill-down, rendered inside the popout host.
// ABOUTME: Submenu-drill pattern inspired by caelestia-dots/shell (GPL-3); own implementation.
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Controls
import Quickshell
StackView {
id: root
property var menuHandle: null
property var popouts: null
implicitWidth: currentItem ? currentItem.implicitWidth : 0
implicitHeight: currentItem ? currentItem.implicitHeight : 0
// The popout instance persists across reopens (Loader keyed on currentName), 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"
onShownChanged: if (root.shown)
root.pop(null, StackView.Immediate)
initialItem: SubMenu {
handle: root.menuHandle
}
// Instant StackView transitions — the SubMenu handles its own fade/scale.
pushEnter: Transition {}
pushExit: Transition {}
popEnter: Transition {}
popExit: Transition {}
Component {
id: subMenuComp
SubMenu {}
}
component SubMenu: Column {
id: sub
property var handle: null
property bool isSubMenu: false
readonly property int menuWidth: 240
padding: 4
spacing: 2
QsMenuOpener {
id: opener
menu: sub.handle
}
// Back row (submenus only).
Rectangle {
visible: sub.isSubMenu
width: sub.menuWidth
implicitHeight: 26
radius: Theme.radius
color: backHover.hovered ? Theme.surface0 : "transparent"
Row {
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
anchors.leftMargin: 8
spacing: 6
Text {
text: String.fromCodePoint(0xf0141) // md-chevron-left
color: Theme.accent
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
Text {
text: qsTr("Back")
color: Theme.subtext1
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
}
HoverHandler {
id: backHover
}
MouseArea {
anchors.fill: parent
onClicked: root.pop()
}
}
Repeater {
model: opener.children
delegate: Item {
id: row
required property QsMenuEntry modelData
// modelData goes null transiently while the menu tears down — guard every access.
readonly property bool isSep: row.modelData?.isSeparator ?? false
width: sub.menuWidth
implicitHeight: row.isSep ? 7 : 26
// Separator.
Rectangle {
visible: row.isSep
anchors {
left: parent.left
right: parent.right
verticalCenter: parent.verticalCenter
leftMargin: 6
rightMargin: 6
}
height: 1
color: Theme.surface1
opacity: 0.6
}
// Entry.
Rectangle {
visible: row.modelData !== null && !row.isSep
anchors.fill: parent
radius: Theme.radius
color: entryHover.hovered && (row.modelData?.enabled ?? false) ? Theme.surface0 : "transparent"
Text {
id: check
anchors.left: parent.left
anchors.leftMargin: 6
anchors.verticalCenter: parent.verticalCenter
width: 12
text: (row.modelData?.checkState ?? 0) === Qt.Checked ? String.fromCodePoint(0xf012c) : "" // md-check
color: Theme.accent
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
Image {
id: entryIcon
visible: (row.modelData?.icon ?? "").length > 0
anchors.left: check.right
anchors.leftMargin: visible ? 4 : 0
anchors.verticalCenter: parent.verticalCenter
width: visible ? 16 : 0
height: 16
sourceSize.width: 16
sourceSize.height: 16
source: row.modelData?.icon ?? ""
fillMode: Image.PreserveAspectFit
}
Text {
anchors.left: entryIcon.right
anchors.leftMargin: 6
anchors.right: chevron.left
anchors.rightMargin: 4
anchors.verticalCenter: parent.verticalCenter
text: row.modelData?.text ?? ""
color: (row.modelData?.enabled ?? false) ? Theme.text : Theme.subtext0
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
elide: Text.ElideRight
}
Text {
id: chevron
anchors.right: parent.right
anchors.rightMargin: 6
anchors.verticalCenter: parent.verticalCenter
visible: row.modelData?.hasChildren ?? false
text: String.fromCodePoint(0xf0142) // md-chevron-right
color: Theme.subtext0
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
HoverHandler {
id: entryHover
}
MouseArea {
anchors.fill: parent
enabled: (row.modelData?.enabled ?? false) && !row.isSep
onClicked: {
const entry = row.modelData;
if (!entry)
return;
if (entry.hasChildren)
root.push(subMenuComp, {
handle: entry,
isSubMenu: true
});
else {
entry.triggered();
if (root.popouts)
root.popouts.close();
}
}
}
}
}
}
}
}
+52
View File
@@ -0,0 +1,52 @@
// ABOUTME: Reusable horizontal 0-1 slider (track, accent fill, draggable handle).
// ABOUTME: Emits moved(value) on click/drag; used for the audio sink and source volume.
import QtQuick
Item {
id: root
property real value: 0
signal moved(real value)
implicitWidth: 140
implicitHeight: 16
readonly property real ratio: Math.max(0, Math.min(1, root.value))
Rectangle {
id: track
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
height: 5
radius: height / 2
color: Theme.surface1
}
Rectangle {
anchors.left: track.left
anchors.verticalCenter: parent.verticalCenter
height: track.height
radius: track.radius
width: handle.x + handle.width / 2
color: Theme.accent
}
Rectangle {
id: handle
width: 14
height: 14
radius: width / 2
anchors.verticalCenter: parent.verticalCenter
x: (root.width - width) * root.ratio
color: Theme.text
}
MouseArea {
anchors.fill: parent
function apply(mx) {
root.moved(Math.max(0, Math.min(1, mx / root.width)));
}
onPressed: mouse => apply(mouse.x)
onPositionChanged: mouse => {
if (pressed)
apply(mouse.x);
}
}
}
+70
View File
@@ -0,0 +1,70 @@
// ABOUTME: Focused-window title bar widget backed by the niri IPC service.
// ABOUTME: Shows the app icon (resolved from app_id) before the title, with waybar rewrite rules.
import QtQuick
import Quickshell
Row {
id: root
property int maxWidth: 420
spacing: 6
readonly property var win: Niri.activeWindow
readonly property string appId: win ? (win.app_id || "") : ""
readonly property string raw: win ? (win.title || "") : ""
function lookupEntry() {
if (!root.appId)
return null;
return DesktopEntries.byId(root.appId) || DesktopEntries.byId(root.appId.toLowerCase());
}
readonly property string iconName: {
var e = root.lookupEntry();
return e ? (e.icon || "") : "";
}
function rewrite(t) {
if (!t)
return "";
var m = t.match(/^(.*) - Mozilla Firefox$/);
if (m)
return "🌎 " + m[1]; // globe emoji
m = t.match(/^(.*) - zsh$/);
if (m)
return "> [" + m[1] + "]";
return t;
}
readonly property int iconSize: Theme.fontSize + 4
Image {
id: icon
anchors.verticalCenter: parent.verticalCenter
visible: root.iconName.length > 0 && root.raw.length > 0
source: root.iconName.length > 0 ? Quickshell.iconPath(root.iconName, true) : ""
width: visible ? root.iconSize : 0
height: root.iconSize
sourceSize.width: root.iconSize
sourceSize.height: root.iconSize
fillMode: Image.PreserveAspectFit
}
Text {
anchors.verticalCenter: parent.verticalCenter
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
color: Theme.subtext1
elide: Text.ElideRight
text: root.rewrite(root.raw)
width: Math.min(implicitWidth, root.maxWidth - (icon.visible ? icon.width + root.spacing : 0))
}
HoverHandler {
id: hover
}
Tooltip {
anchorItem: root
shown: hover.hovered
text: root.raw
}
}
+39
View File
@@ -0,0 +1,39 @@
// ABOUTME: Niri workspaces bar widget — one indicator per workspace on this screen.
// ABOUTME: Highlights the focused workspace, marks urgent ones, and focuses on click.
import QtQuick
Row {
id: root
property string screenName: ""
spacing: 2
readonly property var list: Niri.workspaces.filter(w => root.screenName === "" || w.output === root.screenName).slice().sort((a, b) => a.idx - b.idx)
Repeater {
model: root.list
delegate: Rectangle {
id: wsItem
required property var modelData
width: 22
height: 22
anchors.verticalCenter: parent ? parent.verticalCenter : undefined
radius: Theme.radius
color: modelData.is_focused ? Qt.rgba(Theme.accent.r, Theme.accent.g, Theme.accent.b, 0.18) : modelData.is_active ? Qt.rgba(Theme.text.r, Theme.text.g, Theme.text.b, 0.08) : "transparent"
Text {
anchors.centerIn: parent
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
// Active (focused) dot vs default dot — matches the waybar niri/workspaces icons.
text: wsItem.modelData.is_focused ? String.fromCodePoint(0xf10c) : String.fromCodePoint(0xf111)
color: wsItem.modelData.is_urgent ? Theme.stateCritical : wsItem.modelData.is_focused ? Theme.accent : Theme.subtext0
}
MouseArea {
anchors.fill: parent
onClicked: Niri.focusWorkspace(wsItem.modelData.idx)
}
}
}
}
+18
View File
@@ -0,0 +1,18 @@
// ABOUTME: Entry point for the moonarch quickshell bar pilot.
// ABOUTME: Instantiates one Bar per connected screen.
//@ pragma IconTheme Colloid-Grey-Catppuccin-Dark
import Quickshell
ShellRoot {
Variants {
model: Quickshell.screens
Bar {}
}
// Transient notification toasts — one top-right layer-shell surface per screen.
Variants {
model: Quickshell.screens
NotifToastHost {}
}
}