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:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) + "%"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user