From c4b4a3bd26f25e59726cd44c9f0dacafb8329598 Mon Sep 17 00:00:00 2001 From: thomas Date: Mon, 24 Aug 2026 10:03:59 +0100 Subject: [PATCH 01/44] added quickshell config --- .../.config/quickshell/config/Appearance.qml | 25 ++++ dotfiles/.config/quickshell/config/Colors.qml | 59 +++++++++ dotfiles/.config/quickshell/config/qmldir | 3 + .../quickshell/modules/bar/ActiveWindow.qml | 13 ++ .../.config/quickshell/modules/bar/Bar.qml | 55 +++++++++ .../modules/bar/BluetoothIndicator.qml | 38 ++++++ .../.config/quickshell/modules/bar/Clock.qml | 18 +++ .../modules/bar/NetworkIndicator.qml | 35 ++++++ .../quickshell/modules/bar/PowerButton.qml | 114 ++++++++++++++++++ .../.config/quickshell/modules/bar/Tray.qml | 32 +++++ .../.config/quickshell/modules/bar/Volume.qml | 43 +++++++ .../quickshell/modules/bar/Workspaces.qml | 44 +++++++ .../quickshell/modules/osd/VolumeOsd.qml | 78 ++++++++++++ dotfiles/.config/quickshell/shell.qml | 16 +++ 14 files changed, 573 insertions(+) create mode 100644 dotfiles/.config/quickshell/config/Appearance.qml create mode 100644 dotfiles/.config/quickshell/config/Colors.qml create mode 100644 dotfiles/.config/quickshell/config/qmldir create mode 100644 dotfiles/.config/quickshell/modules/bar/ActiveWindow.qml create mode 100644 dotfiles/.config/quickshell/modules/bar/Bar.qml create mode 100644 dotfiles/.config/quickshell/modules/bar/BluetoothIndicator.qml create mode 100644 dotfiles/.config/quickshell/modules/bar/Clock.qml create mode 100644 dotfiles/.config/quickshell/modules/bar/NetworkIndicator.qml create mode 100644 dotfiles/.config/quickshell/modules/bar/PowerButton.qml create mode 100644 dotfiles/.config/quickshell/modules/bar/Tray.qml create mode 100644 dotfiles/.config/quickshell/modules/bar/Volume.qml create mode 100644 dotfiles/.config/quickshell/modules/bar/Workspaces.qml create mode 100644 dotfiles/.config/quickshell/modules/osd/VolumeOsd.qml create mode 100644 dotfiles/.config/quickshell/shell.qml diff --git a/dotfiles/.config/quickshell/config/Appearance.qml b/dotfiles/.config/quickshell/config/Appearance.qml new file mode 100644 index 0000000..e598eda --- /dev/null +++ b/dotfiles/.config/quickshell/config/Appearance.qml @@ -0,0 +1,25 @@ +pragma Singleton +import QtQuick + +QtObject { + readonly property string fontFamily: "Noto Sans" + readonly property string iconFontFamily: "Material Symbols Outlined" + // Material Symbols is a variable font. Left unset, glyphs fall back to the + // font's default optical size (24px), which is tuned for larger rendering + // and makes some icons (e.g. "lock") look heavier/bigger than others when + // drawn small — pin "opsz" near the actual render size to keep icons + // visually consistent. See Appearance.iconSize for the matching pixelSize. + readonly property var iconFontAxes: ({ "FILL": 0, "wght": 400, "GRAD": 0, "opsz": 20 }) + readonly property int iconSize: 16 + + readonly property int fontSize: 13 + readonly property int fontSizeSmall: 11 + + readonly property int radius: 14 + readonly property int radiusSmall: 8 + + readonly property int barHeight: 34 + readonly property int spacing: 10 + + readonly property int animationDuration: 150 +} diff --git a/dotfiles/.config/quickshell/config/Colors.qml b/dotfiles/.config/quickshell/config/Colors.qml new file mode 100644 index 0000000..311efbf --- /dev/null +++ b/dotfiles/.config/quickshell/config/Colors.qml @@ -0,0 +1,59 @@ +pragma Singleton +import QtQuick +import Quickshell +import Quickshell.Io + +// Wallpaper-driven palette, sourced from pywal's ~/.cache/wal/colors.json. +// Run `wal -i /path/to/wallpaper.png` to (re)generate it — this file watches +// the cache and updates live, no shell restart needed. Until that's been run +// once, everything falls back to a hand-picked dark palette below. +QtObject { + id: root + + readonly property bool active: _parsed !== null + property var _parsed: null + + // --- Fallback palette (used until pywal has run) --- + readonly property color fallbackBg: "#1a1b26" + readonly property color fallbackFg: "#c0caf5" + readonly property var fallbackPalette: [ + "#15161e", "#f7768e", "#9ece6a", "#e0af68", + "#7aa2f7", "#bb9af7", "#7dcfff", "#a9b1d6", + "#414868", "#f7768e", "#9ece6a", "#e0af68", + "#7aa2f7", "#bb9af7", "#7dcfff", "#c0caf5" + ] + + function paletteColor(i) { + if (_parsed && _parsed.colors) + return _parsed.colors["color" + i] || fallbackPalette[i] + return fallbackPalette[i] + } + + readonly property color background: _parsed && _parsed.special ? _parsed.special.background : fallbackBg + readonly property color foreground: _parsed && _parsed.special ? _parsed.special.foreground : fallbackFg + readonly property color primary: paletteColor(4) + // Named primaryText (not "onPrimary") because QML treats any "onXxx" + // identifier as a signal handler slot, not a regular property. + readonly property color primaryText: background + readonly property color urgent: paletteColor(1) + readonly property color muted: paletteColor(8) + readonly property color surface: Qt.lighter(background, 1.35) + + // QtObject has no default property, so the FileView is attached via a + // property assignment rather than as a plain child element. + readonly property FileView _walFile: FileView { + path: Quickshell.env("HOME") + "/.cache/wal/colors.json" + watchChanges: true + onLoaded: { + try { + root._parsed = JSON.parse(text()) + } catch (e) { + console.warn("Colors: failed to parse pywal cache:", e) + root._parsed = null + } + } + onLoadFailed: (error) => { + root._parsed = null + } + } +} diff --git a/dotfiles/.config/quickshell/config/qmldir b/dotfiles/.config/quickshell/config/qmldir new file mode 100644 index 0000000..26da31e --- /dev/null +++ b/dotfiles/.config/quickshell/config/qmldir @@ -0,0 +1,3 @@ +module config +singleton Colors 1.0 Colors.qml +singleton Appearance 1.0 Appearance.qml diff --git a/dotfiles/.config/quickshell/modules/bar/ActiveWindow.qml b/dotfiles/.config/quickshell/modules/bar/ActiveWindow.qml new file mode 100644 index 0000000..beb5d74 --- /dev/null +++ b/dotfiles/.config/quickshell/modules/bar/ActiveWindow.qml @@ -0,0 +1,13 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell.Hyprland +import "../../config" + +Text { + Layout.maximumWidth: 320 + elide: Text.ElideRight + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.muted + text: Hyprland.activeToplevel ? Hyprland.activeToplevel.title : "" +} diff --git a/dotfiles/.config/quickshell/modules/bar/Bar.qml b/dotfiles/.config/quickshell/modules/bar/Bar.qml new file mode 100644 index 0000000..ecc0363 --- /dev/null +++ b/dotfiles/.config/quickshell/modules/bar/Bar.qml @@ -0,0 +1,55 @@ +import Quickshell +import Quickshell.Wayland +import QtQuick +import QtQuick.Layouts +import "../../config" + +PanelWindow { + id: bar + + anchors { + top: true + left: true + right: true + } + implicitHeight: Appearance.barHeight + exclusiveZone: implicitHeight + color: "transparent" + + WlrLayershell.layer: WlrLayer.Top + WlrLayershell.namespace: "quickshell:bar" + + Rectangle { + anchors.fill: parent + color: Colors.background + } + + RowLayout { + anchors { + left: parent.left + verticalCenter: parent.verticalCenter + leftMargin: Appearance.spacing + } + spacing: Appearance.spacing + Workspaces { screen: bar.screen } + ActiveWindow {} + } + + Clock { + anchors.centerIn: parent + } + + RowLayout { + anchors { + right: parent.right + verticalCenter: parent.verticalCenter + rightMargin: Appearance.spacing + } + spacing: Appearance.spacing + Tray {} + NetworkIndicator {} + BluetoothIndicator {} + Volume {} + PowerButton {} + } +} diff --git a/dotfiles/.config/quickshell/modules/bar/BluetoothIndicator.qml b/dotfiles/.config/quickshell/modules/bar/BluetoothIndicator.qml new file mode 100644 index 0000000..8087ee1 --- /dev/null +++ b/dotfiles/.config/quickshell/modules/bar/BluetoothIndicator.qml @@ -0,0 +1,38 @@ +import QtQuick +import Quickshell.Bluetooth +import "../../config" + +Item { + id: root + implicitWidth: label.implicitWidth + 4 + implicitHeight: Appearance.barHeight + + readonly property var adapter: Bluetooth.defaultAdapter + readonly property bool anyConnected: { + if (!adapter) return false + const devices = adapter.devices.values + for (let i = 0; i < devices.length; i++) { + if (devices[i].connected) return true + } + return false + } + + Text { + id: label + anchors.centerIn: parent + font.family: Appearance.iconFontFamily + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 16 + color: (root.adapter && root.adapter.enabled) ? Colors.foreground : Colors.muted + text: { + if (!root.adapter || !root.adapter.enabled) return "bluetooth_disabled" + return root.anyConnected ? "bluetooth_connected" : "bluetooth" + } + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: if (root.adapter) root.adapter.enabled = !root.adapter.enabled + } +} diff --git a/dotfiles/.config/quickshell/modules/bar/Clock.qml b/dotfiles/.config/quickshell/modules/bar/Clock.qml new file mode 100644 index 0000000..66db3c9 --- /dev/null +++ b/dotfiles/.config/quickshell/modules/bar/Clock.qml @@ -0,0 +1,18 @@ +import QtQuick +import "../../config" + +Text { + id: root + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSize + font.bold: true + color: Colors.foreground + text: Qt.formatDateTime(new Date(), "ddd d MMM hh:mm") + + Timer { + interval: 1000 + running: true + repeat: true + onTriggered: root.text = Qt.formatDateTime(new Date(), "ddd d MMM hh:mm") + } +} diff --git a/dotfiles/.config/quickshell/modules/bar/NetworkIndicator.qml b/dotfiles/.config/quickshell/modules/bar/NetworkIndicator.qml new file mode 100644 index 0000000..e15be89 --- /dev/null +++ b/dotfiles/.config/quickshell/modules/bar/NetworkIndicator.qml @@ -0,0 +1,35 @@ +import QtQuick +import Quickshell.Networking +import "../../config" + +Item { + id: root + implicitWidth: label.implicitWidth + 4 + implicitHeight: Appearance.barHeight + + readonly property var connectedDevice: { + const devices = Networking.devices.values + for (let i = 0; i < devices.length; i++) { + if (devices[i].connected) + return devices[i] + } + return null + } + + Text { + id: label + anchors.centerIn: parent + font.family: Appearance.iconFontFamily + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 16 + color: root.connectedDevice ? Colors.foreground : Colors.muted + text: { + if (!root.connectedDevice) + return "wifi_off" + return root.connectedDevice.type === DeviceType.Wired ? "settings_ethernet" : "wifi" + } + } + + // TODO(follow-up): wire this up to a network panel (SSID list, connect/forget) + // once the control center lands. +} diff --git a/dotfiles/.config/quickshell/modules/bar/PowerButton.qml b/dotfiles/.config/quickshell/modules/bar/PowerButton.qml new file mode 100644 index 0000000..5af66b5 --- /dev/null +++ b/dotfiles/.config/quickshell/modules/bar/PowerButton.qml @@ -0,0 +1,114 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import "../../config" + +Item { + id: root + implicitWidth: label.implicitWidth + 4 + implicitHeight: Appearance.barHeight + + Text { + id: label + anchors.centerIn: parent + font.family: Appearance.iconFontFamily + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 16 + color: Colors.foreground + text: "power_settings_new" + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: menu.visible = !menu.visible + } + + Process { id: proc } + + PopupWindow { + id: menu + visible: false + color: "transparent" + implicitWidth: 170 + implicitHeight: list.model.length * 32 + (list.model.length - 1) * 2 + 16 + + anchor { + item: root + edges: Edges.Bottom | Edges.Right + gravity: Edges.Bottom | Edges.Left + margins.top: 4 + } + + Rectangle { + anchors.fill: parent + radius: Appearance.radius + color: Colors.surface + border.color: Colors.muted + border.width: 1 + + Column { + anchors.fill: parent + anchors.margins: 8 + spacing: 2 + + Repeater { + id: list + model: [ + // "iconSize" is optional — omit it to use the 15px default below. + // Material Symbols glyphs aren't all the same visual weight at a + // given size (a padlock silhouette reads bulkier than a thin arrow), + // so nudge individual icons here to balance them by eye. + { label: "Lock", icon: "lock", iconSize: 13, cmd: ["hyprlock"] }, + { label: "Log out", icon: "logout", cmd: ["hyprctl", "dispatch", "exit"] }, + { label: "Reboot", icon: "restart_alt", cmd: ["systemctl", "reboot"] }, + { label: "Shut down", icon: "power_settings_new", cmd: ["systemctl", "poweroff"] } + ] + + delegate: Rectangle { + id: entry + required property var modelData + width: parent ? parent.width : 0 + height: 32 + radius: Appearance.radiusSmall + color: entryMouse.containsMouse ? Colors.primary : "transparent" + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 8 + spacing: 8 + Text { + Layout.preferredWidth: 20 + horizontalAlignment: Text.AlignHCenter + text: entry.modelData.icon + font.family: Appearance.iconFontFamily + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: entry.modelData.iconSize || 15 + color: entryMouse.containsMouse ? Colors.primaryText : Colors.foreground + } + Text { + text: entry.modelData.label + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSize + color: entryMouse.containsMouse ? Colors.primaryText : Colors.foreground + } + } + + MouseArea { + id: entryMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + proc.command = entry.modelData.cmd + proc.running = true + menu.visible = false + } + } + } + } + } + } + } +} diff --git a/dotfiles/.config/quickshell/modules/bar/Tray.qml b/dotfiles/.config/quickshell/modules/bar/Tray.qml new file mode 100644 index 0000000..84c0597 --- /dev/null +++ b/dotfiles/.config/quickshell/modules/bar/Tray.qml @@ -0,0 +1,32 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell.Services.SystemTray +import Quickshell.Widgets +import "../../config" + +RowLayout { + spacing: Appearance.spacing * 0.6 + + Repeater { + model: SystemTray.items + + delegate: IconImage { + id: trayIcon + required property var modelData + implicitWidth: 16 + implicitHeight: 16 + source: modelData.icon + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.LeftButton | Qt.RightButton + onClicked: (mouse) => { + if (mouse.button === Qt.LeftButton) + trayIcon.modelData.activate() + else + trayIcon.modelData.secondaryActivate() + } + } + } + } +} diff --git a/dotfiles/.config/quickshell/modules/bar/Volume.qml b/dotfiles/.config/quickshell/modules/bar/Volume.qml new file mode 100644 index 0000000..9c3f94b --- /dev/null +++ b/dotfiles/.config/quickshell/modules/bar/Volume.qml @@ -0,0 +1,43 @@ +import QtQuick +import Quickshell.Services.Pipewire +import "../../config" + +Item { + id: root + implicitWidth: label.implicitWidth + 4 + implicitHeight: Appearance.barHeight + + readonly property var sink: Pipewire.defaultAudioSink + readonly property real vol: (sink && sink.audio) ? sink.audio.volume : 0 + readonly property bool muted: (sink && sink.audio) ? sink.audio.muted : false + + PwObjectTracker { objects: root.sink ? [root.sink] : [] } + + Text { + id: label + anchors.centerIn: parent + font.family: Appearance.iconFontFamily + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 16 + color: Colors.foreground + text: { + if (root.muted || root.vol <= 0) return "volume_off" + if (root.vol < 0.5) return "volume_down" + return "volume_up" + } + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + acceptedButtons: Qt.LeftButton + onClicked: if (root.sink && root.sink.audio) root.sink.audio.muted = !root.sink.audio.muted + + onWheel: (wheel) => { + if (!root.sink || !root.sink.audio) return + const step = 0.05 + const delta = wheel.angleDelta.y > 0 ? step : -step + root.sink.audio.volume = Math.max(0, Math.min(1, root.sink.audio.volume + delta)) + } + } +} diff --git a/dotfiles/.config/quickshell/modules/bar/Workspaces.qml b/dotfiles/.config/quickshell/modules/bar/Workspaces.qml new file mode 100644 index 0000000..bf655b4 --- /dev/null +++ b/dotfiles/.config/quickshell/modules/bar/Workspaces.qml @@ -0,0 +1,44 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell.Hyprland +import "../../config" + +RowLayout { + id: root + property var screen + + spacing: 4 + + Repeater { + model: Hyprland.workspaces + + delegate: Rectangle { + id: pill + required property var modelData + visible: modelData.monitor && root.screen && modelData.monitor.name === root.screen.name + + implicitWidth: 26 + implicitHeight: 22 + radius: Appearance.radiusSmall + color: modelData.urgent ? Colors.urgent + : modelData.active ? Colors.primary + : "transparent" + + Behavior on color { ColorAnimation { duration: Appearance.animationDuration } } + + Text { + anchors.centerIn: parent + text: modelData.name + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: modelData.active ? Colors.primaryText : Colors.foreground + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: Hyprland.dispatch("workspace " + modelData.name) + } + } + } +} diff --git a/dotfiles/.config/quickshell/modules/osd/VolumeOsd.qml b/dotfiles/.config/quickshell/modules/osd/VolumeOsd.qml new file mode 100644 index 0000000..b4381ee --- /dev/null +++ b/dotfiles/.config/quickshell/modules/osd/VolumeOsd.qml @@ -0,0 +1,78 @@ +import Quickshell +import Quickshell.Wayland +import Quickshell.Services.Pipewire +import QtQuick +import QtQuick.Layouts +import "../../config" + +PanelWindow { + id: osd + visible: false + color: "transparent" + exclusiveZone: 0 + + anchors { bottom: true } + margins.bottom: 70 + + implicitWidth: 240 + implicitHeight: 54 + + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.namespace: "quickshell:osd" + + readonly property var sink: Pipewire.defaultAudioSink + PwObjectTracker { objects: osd.sink ? [osd.sink] : [] } + + Timer { + id: hideTimer + interval: 1400 + onTriggered: osd.visible = false + } + + Connections { + target: osd.sink ? osd.sink.audio : null + function onVolumeChanged() { osd.visible = true; hideTimer.restart() } + function onMutedChanged() { osd.visible = true; hideTimer.restart() } + } + + Rectangle { + anchors.fill: parent + radius: Appearance.radius + color: Colors.surface + border.color: Colors.muted + border.width: 1 + + RowLayout { + anchors.fill: parent + anchors.margins: 14 + spacing: 12 + + Text { + font.family: Appearance.iconFontFamily + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 18 + color: Colors.foreground + text: { + const audio = osd.sink ? osd.sink.audio : null + if (!audio || audio.muted || audio.volume <= 0) return "volume_off" + return audio.volume < 0.5 ? "volume_down" : "volume_up" + } + } + + Rectangle { + Layout.fillWidth: true + height: 6 + radius: 3 + color: Colors.muted + + Rectangle { + height: parent.height + radius: 3 + color: Colors.primary + width: parent.width * (osd.sink && osd.sink.audio ? osd.sink.audio.volume : 0) + Behavior on width { NumberAnimation { duration: Appearance.animationDuration } } + } + } + } + } +} diff --git a/dotfiles/.config/quickshell/shell.qml b/dotfiles/.config/quickshell/shell.qml new file mode 100644 index 0000000..ab67430 --- /dev/null +++ b/dotfiles/.config/quickshell/shell.qml @@ -0,0 +1,16 @@ +import Quickshell +import "./modules/bar" +import "./modules/osd" + +ShellRoot { + Variants { + model: Quickshell.screens + + Bar { + required property var modelData + screen: modelData + } + } + + VolumeOsd {} +} -- 2.49.1 From 6c415d6fbb3bc4a7c55edf1cb4194833f10d4cd4 Mon Sep 17 00:00:00 2001 From: thomas Date: Mon, 24 Aug 2026 15:06:58 +0100 Subject: [PATCH 02/44] added starting funtionality --- .../.config/quickshell/config/Appearance.qml | 2 +- dotfiles/.config/quickshell/config/Colors.qml | 5 +- .../.config/quickshell/modules/bar/Bar.qml | 6 +- .../quickshell/modules/bar/PowerButton.qml | 177 +++++--- .../.config/quickshell/modules/bar/Volume.qml | 427 +++++++++++++++++- 5 files changed, 544 insertions(+), 73 deletions(-) diff --git a/dotfiles/.config/quickshell/config/Appearance.qml b/dotfiles/.config/quickshell/config/Appearance.qml index e598eda..a83c8a0 100644 --- a/dotfiles/.config/quickshell/config/Appearance.qml +++ b/dotfiles/.config/quickshell/config/Appearance.qml @@ -2,7 +2,7 @@ pragma Singleton import QtQuick QtObject { - readonly property string fontFamily: "Noto Sans" + readonly property string fontFamily: "CaskaydiaCove Nerd Font Mono" readonly property string iconFontFamily: "Material Symbols Outlined" // Material Symbols is a variable font. Left unset, glyphs fall back to the // font's default optical size (24px), which is tuned for larger rendering diff --git a/dotfiles/.config/quickshell/config/Colors.qml b/dotfiles/.config/quickshell/config/Colors.qml index 311efbf..62a35b1 100644 --- a/dotfiles/.config/quickshell/config/Colors.qml +++ b/dotfiles/.config/quickshell/config/Colors.qml @@ -36,7 +36,10 @@ QtObject { // identifier as a signal handler slot, not a regular property. readonly property color primaryText: background readonly property color urgent: paletteColor(1) - readonly property color muted: paletteColor(8) + // paletteColor(8) alone renders quite dark/low-contrast for text (it's + // meant as a muted *background* tone in most wal palettes) — lighten it + // so muted labels stay legible against the bar's dark background. + readonly property color muted: Qt.lighter(paletteColor(8), 2.2) readonly property color surface: Qt.lighter(background, 1.35) // QtObject has no default property, so the FileView is attached via a diff --git a/dotfiles/.config/quickshell/modules/bar/Bar.qml b/dotfiles/.config/quickshell/modules/bar/Bar.qml index ecc0363..0aa7c2b 100644 --- a/dotfiles/.config/quickshell/modules/bar/Bar.qml +++ b/dotfiles/.config/quickshell/modules/bar/Bar.qml @@ -47,9 +47,9 @@ PanelWindow { } spacing: Appearance.spacing Tray {} - NetworkIndicator {} + NetworkIndicator { barWindow: bar } BluetoothIndicator {} - Volume {} - PowerButton {} + Volume { barWindow: bar } + PowerButton { barWindow: bar } } } diff --git a/dotfiles/.config/quickshell/modules/bar/PowerButton.qml b/dotfiles/.config/quickshell/modules/bar/PowerButton.qml index 5af66b5..82b8d5b 100644 --- a/dotfiles/.config/quickshell/modules/bar/PowerButton.qml +++ b/dotfiles/.config/quickshell/modules/bar/PowerButton.qml @@ -2,6 +2,7 @@ import QtQuick import QtQuick.Layouts import Quickshell import Quickshell.Io +import Quickshell.Hyprland import "../../config" Item { @@ -9,6 +10,12 @@ Item { implicitWidth: label.implicitWidth + 4 implicitHeight: Appearance.barHeight + // The bar's own PanelWindow — passed in from Bar.qml so this popup can + // anchor to the bar itself rather than to this icon, keeping it a fixed + // distance from the bar and the screen edge regardless of where this + // icon sits in the row. + required property var barWindow + Text { id: label anchors.centerIn: parent @@ -22,7 +29,10 @@ Item { MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor - onClicked: menu.visible = !menu.visible + onClicked: { + menu.visible = !menu.visible + grab.active = menu.visible + } } Process { id: proc } @@ -31,79 +41,134 @@ Item { id: menu visible: false color: "transparent" - implicitWidth: 170 - implicitHeight: list.model.length * 32 + (list.model.length - 1) * 2 + 16 + implicitWidth: 210 + // Same padding as Volume.qml's panel. + readonly property int contentPadding: 16 + readonly property int labelOffset: -135 + implicitHeight: mainColumn.implicitHeight + contentPadding * 2 anchor { - item: root + window: root.barWindow + // Unlike anchor.item, anchor.window doesn't default rect to the + // window's own bounds — set it explicitly so edges/gravity below + // resolve against the full bar (and thus the true screen edges) + // instead of a near-zero rect at the window's origin. + rect.x: 0 + rect.y: 0 + rect.width: root.barWindow.width + rect.height: root.barWindow.height edges: Edges.Bottom | Edges.Right gravity: Edges.Bottom | Edges.Left - margins.top: 4 + margins.bottom: -4 + margins.right: 0 + } + + HyprlandFocusGrab { + id: grab + windows: [menu] + onCleared: menu.visible = false } Rectangle { anchors.fill: parent - radius: Appearance.radius + radius: 0 color: Colors.surface border.color: Colors.muted - border.width: 1 + border.width: 3 Column { + id: mainColumn anchors.fill: parent - anchors.margins: 8 - spacing: 2 + anchors.margins: menu.contentPadding + spacing: 10 - Repeater { - id: list - model: [ - // "iconSize" is optional — omit it to use the 15px default below. - // Material Symbols glyphs aren't all the same visual weight at a - // given size (a padlock silhouette reads bulkier than a thin arrow), - // so nudge individual icons here to balance them by eye. - { label: "Lock", icon: "lock", iconSize: 13, cmd: ["hyprlock"] }, - { label: "Log out", icon: "logout", cmd: ["hyprctl", "dispatch", "exit"] }, - { label: "Reboot", icon: "restart_alt", cmd: ["systemctl", "reboot"] }, - { label: "Shut down", icon: "power_settings_new", cmd: ["systemctl", "poweroff"] } - ] + // Header — same title layout as Volume.qml's panel: the bar + // icon large, plus a bold title. + RowLayout { + id: header + width: parent.width + spacing: 8 - delegate: Rectangle { - id: entry - required property var modelData - width: parent ? parent.width : 0 - height: 32 - radius: Appearance.radiusSmall - color: entryMouse.containsMouse ? Colors.primary : "transparent" + Text { + text: label.text + font.family: Appearance.iconFontFamily + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 30 + color: Colors.foreground + } - RowLayout { - anchors.fill: parent - anchors.leftMargin: 8 - spacing: 8 - Text { - Layout.preferredWidth: 20 - horizontalAlignment: Text.AlignHCenter - text: entry.modelData.icon - font.family: Appearance.iconFontFamily - font.variableAxes: Appearance.iconFontAxes - font.pixelSize: entry.modelData.iconSize || 15 - color: entryMouse.containsMouse ? Colors.primaryText : Colors.foreground + Text { + Layout.fillWidth: true + text: "Power" + font.family: Appearance.fontFamily + font.pixelSize: 18 + font.bold: true + color: Colors.foreground + } + } + + Column { + width: parent.width + spacing: 2 + + Repeater { + id: list + model: [ + { label: "Lock", icon: "lock", cmd: ["hyprlock"] }, + { label: "Suspend", icon: "bedtime", cmd: ["systemctl", "suspend"] }, + { label: "Hibernate", icon: "ac_unit", cmd: ["systemctl", "hibernate"] }, + { label: "Log out", icon: "logout", cmd: ["hyprctl", "dispatch", "exit"] }, + { label: "Reboot", icon: "restart_alt", cmd: ["systemctl", "reboot"] }, + { label: "Shut down", icon: "power_settings_new", cmd: ["systemctl", "poweroff"] } + ] + + delegate: Rectangle { + id: entry + required property var modelData + width: parent ? parent.width : 0 + height: 40 + radius: Appearance.radiusSmall + color: (!entry.modelData.disabled && entryMouse.containsMouse) ? Colors.primary : "transparent" + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 8 + spacing: 8 + Text { + Layout.preferredWidth: 25 + Layout.minimumWidth: 25 + Layout.maximumWidth: 25 + horizontalAlignment: Text.AlignLeft + text: entry.modelData.icon + font.family: Appearance.iconFontFamily + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: entry.modelData.iconSize || 15 + opacity: entry.modelData.disabled ? 0.4 : 1 + color: entryMouse.containsMouse ? Colors.primaryText : Colors.foreground + } + Text { + Layout.leftMargin: menu.labelOffset + (entry.modelData.textOffset || 0) + text: entry.modelData.label + horizontalAlignment: Text.AlignLeft + font.family: Appearance.fontFamily + font.pixelSize: 16 + opacity: entry.modelData.disabled ? 0.4 : 1 + color: entryMouse.containsMouse ? Colors.primaryText : Colors.foreground + } } - Text { - text: entry.modelData.label - font.family: Appearance.fontFamily - font.pixelSize: Appearance.fontSize - color: entryMouse.containsMouse ? Colors.primaryText : Colors.foreground - } - } - MouseArea { - id: entryMouse - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: { - proc.command = entry.modelData.cmd - proc.running = true - menu.visible = false + MouseArea { + id: entryMouse + anchors.fill: parent + enabled: !entry.modelData.disabled + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + proc.command = entry.modelData.cmd + proc.running = true + menu.visible = false + grab.active = false + } } } } diff --git a/dotfiles/.config/quickshell/modules/bar/Volume.qml b/dotfiles/.config/quickshell/modules/bar/Volume.qml index 9c3f94b..8c893fa 100644 --- a/dotfiles/.config/quickshell/modules/bar/Volume.qml +++ b/dotfiles/.config/quickshell/modules/bar/Volume.qml @@ -1,4 +1,7 @@ import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Hyprland import Quickshell.Services.Pipewire import "../../config" @@ -7,11 +10,87 @@ Item { implicitWidth: label.implicitWidth + 4 implicitHeight: Appearance.barHeight - readonly property var sink: Pipewire.defaultAudioSink - readonly property real vol: (sink && sink.audio) ? sink.audio.volume : 0 - readonly property bool muted: (sink && sink.audio) ? sink.audio.muted : false + // The bar's own PanelWindow — passed in from Bar.qml so this popup can + // anchor to the bar itself rather than to this icon, keeping it a fixed + // distance from the bar and the screen edge regardless of where this + // icon sits in the row. + required property var barWindow - PwObjectTracker { objects: root.sink ? [root.sink] : [] } + readonly property var sink: Pipewire.defaultAudioSink + readonly property var source: Pipewire.defaultAudioSource + readonly property var nodes: Pipewire.nodes ? Pipewire.nodes.values : [] + + readonly property real outputVolume: (root.sink && root.sink.audio) ? root.sink.audio.volume : 0 + readonly property bool outputMuted: (root.sink && root.sink.audio) ? root.sink.audio.muted : false + readonly property real inputVolume: (root.source && root.source.audio) ? root.source.audio.volume : 0 + readonly property bool inputMuted: (root.source && root.source.audio) ? root.source.audio.muted : false + + readonly property var candidateSinks: root.nodes.filter(function(n) { return n && n.isSink && !n.isStream }) + readonly property var candidateSources: root.nodes.filter(function(n) { return n && !n.isSink && !n.isStream && n.audio }) + + PwObjectTracker { objects: root.candidateSinks } + PwObjectTracker { objects: root.candidateSources } + + // Live mic level for the input gauge below — separate from inputVolume + // (the configured gain), this tracks the actual incoming signal. + // Only runs while the panel is open, to skip metering when unused. + PwNodePeakMonitor { + id: inputPeak + node: root.source + enabled: panel.visible + } + + // --- Device label/icon heuristics, ported from omarchy's audio panel + // Model.js (github.com/basecamp/omarchy shell/plugins/panels/audio/Model.js), + // adapted to this project's Material Symbols icon set instead of Nerd Font. + function friendlyDeviceLabel(text) { + var out = String(text || "").trim() + out = out.replace(/^sof-soundwire\s+/i, "") + out = out.replace(/^built-?in audio\s+/i, "") + out = out.replace(/\s+Output$/i, "") + out = out.replace(/\s+Input$/i, "") + out = out.replace(/Microphones/gi, "Microphone") + return out + } + + function deviceLabel(node) { + if (!node) return "Unknown" + var nick = friendlyDeviceLabel(node.nickname || "") + if (nick) return nick + return friendlyDeviceLabel(node.description || node.name || "Unknown") + } + + function deviceBlob(node) { + if (!node) return "" + var p = node.properties || {} + return [node.name, node.description, node.nickname, + p["device.icon-name"] || "", p["device.product.name"] || ""].join(" ").toLowerCase() + } + + function isHeadphoneNode(node) { + var blob = deviceBlob(node) + return blob.indexOf("headphone") !== -1 || blob.indexOf("headset") !== -1 + || blob.indexOf("earbud") !== -1 || blob.indexOf("earphone") !== -1 + || blob.indexOf("airpod") !== -1 + } + + function sinkIcon(node) { + if (!node) return "speaker" + if (isHeadphoneNode(node)) return "headphones" + var blob = deviceBlob(node) + if (blob.indexOf("bluetooth") !== -1) return "bluetooth_audio" + if (blob.indexOf("hdmi") !== -1 || blob.indexOf("display") !== -1) return "tv" + return "speaker" + } + + function sourceIcon(node) { + if (!node) return "mic" + var blob = deviceBlob(node) + if (blob.indexOf("headset") !== -1) return "headphones" + if (blob.indexOf("bluetooth") !== -1) return "bluetooth_audio" + if (blob.indexOf("webcam") !== -1 || blob.indexOf("camera") !== -1) return "videocam" + return "mic" + } Text { id: label @@ -20,19 +99,17 @@ Item { font.variableAxes: Appearance.iconFontAxes font.pixelSize: 16 color: Colors.foreground - text: { - if (root.muted || root.vol <= 0) return "volume_off" - if (root.vol < 0.5) return "volume_down" - return "volume_up" - } + text: (root.outputMuted || root.outputVolume <= 0) ? "volume_off" + : (root.outputVolume < 0.5 ? "volume_down" : "volume_up") } MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor - acceptedButtons: Qt.LeftButton - onClicked: if (root.sink && root.sink.audio) root.sink.audio.muted = !root.sink.audio.muted - + onClicked: { + panel.visible = !panel.visible + grab.active = panel.visible + } onWheel: (wheel) => { if (!root.sink || !root.sink.audio) return const step = 0.05 @@ -40,4 +117,330 @@ Item { root.sink.audio.volume = Math.max(0, Math.min(1, root.sink.audio.volume + delta)) } } + + // --- Reusable pieces ----------------------------------------------- + + component VolumeSlider: Item { + id: sliderRoot + property real value: 0 + property bool enabled: true + signal moved(real newValue) + implicitHeight: 16 + + Rectangle { + id: track + anchors.verticalCenter: parent.verticalCenter + width: parent.width + height: 4 + radius: 0 + color: Colors.muted + opacity: 0.35 + } + Rectangle { + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + width: track.width * Math.max(0, Math.min(1, sliderRoot.value)) + height: 4 + radius: 0 + color: Colors.primary + opacity: sliderRoot.enabled ? 1 : 0.4 + } + Rectangle { + width: 12 + height: 12 + radius: 6 + color: Colors.primary + opacity: sliderRoot.enabled ? 1 : 0.4 + anchors.verticalCenter: parent.verticalCenter + x: Math.max(0, Math.min(track.width - width, track.width * sliderRoot.value - width / 2)) + } + MouseArea { + anchors.fill: parent + enabled: sliderRoot.enabled + cursorShape: Qt.PointingHandCursor + function updateFromX(mx) { + sliderRoot.moved(Math.max(0, Math.min(1, mx / track.width))) + } + onPressed: (mouse) => updateFromX(mouse.x) + onPositionChanged: (mouse) => { if (pressed) updateFromX(mouse.x) } + } + } + + // Read-only live level gauge — same track styling as VolumeSlider but no + // handle/interaction, filled from an actual signal level instead of a + // configured value. + component LevelMeter: Item { + id: meterRoot + property real level: 0 + implicitHeight: 4 + + Rectangle { + anchors.verticalCenter: parent.verticalCenter + width: parent.width + height: 4 + radius: 0 + color: Colors.muted + opacity: 0.25 + } + Rectangle { + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + height: 4 + radius: 0 + color: Colors.paletteColor(2) + width: parent.width * Math.max(0, Math.min(1, meterRoot.level)) + Behavior on width { NumberAnimation { duration: 80 } } + } + } + + component ToggleSwitch: Item { + id: toggleRoot + property bool checked: false + signal toggled() + implicitWidth: 34 + implicitHeight: 18 + + Rectangle { + anchors.fill: parent + radius: 0 + color: toggleRoot.checked ? Colors.primary : Colors.muted + opacity: toggleRoot.checked ? 1 : 0.35 + } + Rectangle { + width: parent.height - 4 + height: parent.height - 4 + radius: 0 + color: Colors.background + anchors.verticalCenter: parent.verticalCenter + x: toggleRoot.checked ? parent.width - width - 2 : 2 + Behavior on x { NumberAnimation { duration: Appearance.animationDuration } } + } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: toggleRoot.toggled() + } + } + + component DeviceRow: Rectangle { + id: deviceRow + required property var node + required property string icon + required property bool active + signal activate() + width: parent ? parent.width : 0 + height: 30 + radius: 0 + color: rowMouse.containsMouse ? Colors.primary + : (deviceRow.active ? Qt.rgba(Colors.primary.r, Colors.primary.g, Colors.primary.b, 0.18) : "transparent") + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 8 + anchors.rightMargin: 8 + spacing: 8 + Text { + Layout.preferredWidth: 18 + Layout.minimumWidth: 18 + Layout.maximumWidth: 18 + horizontalAlignment: Text.AlignHCenter + text: deviceRow.icon + font.family: Appearance.iconFontFamily + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 14 + color: rowMouse.containsMouse ? Colors.primaryText : Colors.foreground + } + Text { + Layout.fillWidth: true + text: root.deviceLabel(deviceRow.node) + elide: Text.ElideRight + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSize + font.bold: deviceRow.active + color: rowMouse.containsMouse ? Colors.primaryText : Colors.foreground + } + } + MouseArea { + id: rowMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: deviceRow.activate() + } + } + + // --- Panel ----------------------------------------------------------- + + PopupWindow { + id: panel + visible: false + color: "transparent" + // Padding between the panel's border and its content. implicitWidth + // is contentWidth + padding on both sides, so the content area + // itself stays a fixed size regardless of how much padding surrounds it. + readonly property int contentWidth: 244 + readonly property int contentPadding: 16 + implicitWidth: contentWidth + contentPadding * 2 + implicitHeight: mainColumn.implicitHeight + contentPadding * 2 + + anchor { + window: root.barWindow + // Unlike anchor.item, anchor.window doesn't default rect to the + // window's own bounds — set it explicitly so edges/gravity below + // resolve against the full bar (and thus the true screen edges) + // instead of a near-zero rect at the window's origin. + rect.x: 0 + rect.y: 0 + rect.width: root.barWindow.width + rect.height: root.barWindow.height + edges: Edges.Bottom | Edges.Right + gravity: Edges.Bottom | Edges.Left + margins.bottom: -4 + margins.right: 0 + } + + HyprlandFocusGrab { + id: grab + windows: [panel] + onCleared: panel.visible = false + } + + Rectangle { + anchors.fill: parent + radius: 0 + color: Colors.surface + border.color: Colors.muted + border.width: 3 + + Column { + id: mainColumn + anchors.fill: parent + anchors.margins: panel.contentPadding + spacing: 10 + + // Header + RowLayout { + width: parent.width + spacing: 8 + + Text { + text: label.text + font.family: Appearance.iconFontFamily + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 40 + color: Colors.foreground + } + + Text { + Layout.fillWidth: true + text: "Audio" + font.family: Appearance.fontFamily + font.pixelSize: 18 + font.bold: true + color: Colors.foreground + } + + ToggleSwitch { + checked: !root.outputMuted + onToggled: if (root.sink && root.sink.audio) root.sink.audio.muted = !root.sink.audio.muted + } + } + + // Output + Column { + width: parent.width + spacing: 6 + + RowLayout { + width: parent.width + Text { + Layout.fillWidth: true + text: "OUTPUT" + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.muted + } + Text { + text: Math.round(root.outputVolume * 100) + "%" + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.muted + } + } + + VolumeSlider { + width: parent.width + value: root.outputVolume + enabled: !!root.sink + onMoved: (v) => { if (root.sink && root.sink.audio) root.sink.audio.volume = v } + } + + Column { + width: parent.width + spacing: 2 + Repeater { + model: root.candidateSinks + DeviceRow { + required property var modelData + node: modelData + icon: root.sinkIcon(modelData) + active: !!(root.sink && modelData && root.sink.id === modelData.id) + onActivate: Pipewire.preferredDefaultAudioSink = modelData + } + } + } + } + + // Input + Column { + width: parent.width + spacing: 6 + + RowLayout { + width: parent.width + Text { + Layout.fillWidth: true + text: "INPUT" + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.muted + } + Text { + text: Math.round(root.inputVolume * 100) + "%" + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.muted + } + } + + VolumeSlider { + width: parent.width + value: root.inputVolume + enabled: !!root.source + onMoved: (v) => { if (root.source && root.source.audio) root.source.audio.volume = v } + } + + LevelMeter { + width: parent.width + level: inputPeak.peak + } + + Column { + width: parent.width + spacing: 2 + Repeater { + model: root.candidateSources + DeviceRow { + required property var modelData + node: modelData + icon: root.sourceIcon(modelData) + active: !!(root.source && modelData && root.source.id === modelData.id) + onActivate: Pipewire.preferredDefaultAudioSource = modelData + } + } + } + } + } + } + } } -- 2.49.1 From 3d0a18866f60efdfcee6cc49a3db9f88deac189c Mon Sep 17 00:00:00 2001 From: thomas Date: Mon, 24 Aug 2026 15:07:18 +0100 Subject: [PATCH 03/44] implimented network widget --- .../modules/bar/NetworkIndicator.qml | 1094 ++++++++++++++++- 1 file changed, 1092 insertions(+), 2 deletions(-) diff --git a/dotfiles/.config/quickshell/modules/bar/NetworkIndicator.qml b/dotfiles/.config/quickshell/modules/bar/NetworkIndicator.qml index e15be89..260b4a5 100644 --- a/dotfiles/.config/quickshell/modules/bar/NetworkIndicator.qml +++ b/dotfiles/.config/quickshell/modules/bar/NetworkIndicator.qml @@ -1,4 +1,8 @@ import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Hyprland +import Quickshell.Io import Quickshell.Networking import "../../config" @@ -7,6 +11,12 @@ Item { implicitWidth: label.implicitWidth + 4 implicitHeight: Appearance.barHeight + // The bar's own PanelWindow — passed in from Bar.qml so this popup can + // anchor to the bar itself rather than to this icon, keeping it a fixed + // distance from the bar and the screen edge regardless of where this + // icon sits in the row. + required property var barWindow + readonly property var connectedDevice: { const devices = Networking.devices.values for (let i = 0; i < devices.length; i++) { @@ -16,6 +26,494 @@ Item { return null } + readonly property var wifiDevice: { + const devices = Networking.devices.values + for (let i = 0; i < devices.length; i++) { + if (devices[i].type === DeviceType.Wifi) + return devices[i] + } + return null + } + + readonly property var wiredDevice: { + const devices = Networking.devices.values + for (let i = 0; i < devices.length; i++) { + if (devices[i].type === DeviceType.Wired) + return devices[i] + } + return null + } + + // Only scan for networks while the panel is actually open. + Binding { + target: root.wifiDevice + property: "scannerEnabled" + value: panel.visible + when: root.wifiDevice !== null + } + + // --- WiFi row helpers, ported from omarchy's network panel Model.js + // (github.com/basecamp/omarchy shell/plugins/panels/network/Model.js). + // Rows are plain objects rather than the live WifiNetwork QObjects — + // omarchy's comment on wifiRow() explains why: NetworkManager churn + // (scans, APs disappearing) can destroy the object while a delegate + // still holds it, crashing quickshell. The actual object is re-resolved + // by SSID only when needed, via networkForSsid(). + function wifiRow(network) { + if (!network) return null + return { + connected: !!network.connected, + known: !!network.known, + ssid: network.name || "", + signal: Math.round((network.signalStrength || 0) * 100), + security: network.security + } + } + + function sortWifiRows(rows) { + const nets = rows.slice() + nets.sort(function(a, b) { + if (a.connected !== b.connected) return a.connected ? -1 : 1 + if (a.known !== b.known) return a.known ? -1 : 1 + return b.signal - a.signal + }) + return nets + } + + readonly property var wifiRows: root.wifiDevice + ? sortWifiRows(root.wifiDevice.networks.values.map(root.wifiRow).filter(function(r) { return r && r.ssid })) + : [] + + // Split for the "Known Networks" / "Other Networks" sections. wifiRows + // is already sorted connected-first then by signal, so filtering + // preserves that order within each group. + readonly property var knownWifiRows: root.wifiRows.filter(function(r) { return r.known }) + readonly property var otherWifiRows: root.wifiRows.filter(function(r) { return !r.known }) + + function networkForSsid(ssid) { + if (!root.wifiDevice) return null + const nets = root.wifiDevice.networks.values + for (let i = 0; i < nets.length; i++) { + if (nets[i].name === ssid) return nets[i] + } + return null + } + + // Only explicit passwordless security types skip the password prompt. + function requiresCredentials(security) { + return security !== WifiSecurityType.Open && security !== WifiSecurityType.Owe + } + + function wifiIcon(signal) { + // Back to the wifi-fan family (verified real ligatures) for the + // familiar look — the flat/wide shape itself is now corrected for + // by a vertical stretch transform on the Text that renders it, + // rather than by swapping to a differently-shaped icon. + if (signal >= 80) return "network_wifi" + if (signal >= 55) return "network_wifi_3_bar" + if (signal >= 30) return "network_wifi_2_bar" + return "network_wifi_1_bar" + } + + // Unlike WiFi, NetworkManager has no radio-killswitch-style "enabled" + // flag for wired devices — the closest equivalent is disconnecting the + // link and turning off autoconnect so it doesn't silently reconnect + // itself right back. + readonly property bool wiredOn: root.wiredDevice && root.wiredDevice.network + ? root.wiredDevice.network.connected + : false + + function setWiredEnabled(enabled) { + if (!root.wiredDevice) return + root.wiredDevice.autoconnect = enabled + if (enabled) { + if (root.wiredDevice.network) root.wiredDevice.network.connect() + } else if (root.wiredDevice.network) { + root.wiredDevice.network.disconnect() + } else { + root.wiredDevice.disconnect() + } + } + + // SSID of the row currently showing its inline password field, if any. + property string passwordSsid: "" + + // Wired connections don't have a user-facing "name" on the device or + // Network object — those just expose the interface name (e.g. "eth0"). + // The name shown in nmcli/NetworkManager ("Wired connection 1") lives on + // the connection's NMSettings profile instead, so pull it from there. + function wiredDisplayName() { + const net = root.wiredDevice && root.wiredDevice.network + const settings = net && net.nmSettings + if (settings && settings.length > 0 && settings[0].id) + return settings[0].id + return (root.wiredDevice && root.wiredDevice.name) || "Ethernet" + } + + // Name shown in the header title — the connected WiFi SSID, or the + // wired connection's display name, falling back to a generic label when + // offline. + readonly property string connectedNetworkName: { + if (!root.connectedDevice) return "Network" + if (root.connectedDevice.type === DeviceType.Wired) + return root.wiredDisplayName() + const row = root.wifiRows.find(function(r) { return r.connected }) + return (row && row.ssid) || "Network" + } + + function activateNetwork(net) { + if (net.connected) return + if (net.known || !root.requiresCredentials(net.security)) { + const n = root.networkForSsid(net.ssid) + if (n) n.connect() + } else { + root.passwordSsid = root.passwordSsid === net.ssid ? "" : net.ssid + } + } + + // --- Live stats -------------------------------------------------- + // None of this comes from Quickshell's Networking API — it only knows + // about devices/networks, not throughput, ping, or routing. So this + // block shells out, the same way the rest of the system does it: + // /sys/class/net/*/statistics for byte counters, `ip route` for the + // gateway, `ping` for latency/loss. All of it only runs while the + // panel is open. + + readonly property string ifaceName: root.connectedDevice ? root.connectedDevice.name : "" + + // NetworkDevice.address is the device's MAC, not an assigned IP — there + // is no IP property on Quickshell's Networking API at all, so this is + // shelled out the same way the gateway is below. + property string ipAddress: "—" + property string gateway: "—" + property string pingText: "—" + property string packetLossText: "—" + property real rxRate: 0 + property real txRate: 0 + property real rxTotal: 0 + property real txTotal: 0 + + property real _lastRx: -1 + property real _lastTx: -1 + property real _lastSampleTime: 0 + + function formatBytes(bytes) { + if (!isFinite(bytes) || bytes <= 0) return "0 B" + const units = ["B", "KB", "MB", "GB", "TB"] + let i = 0 + let v = bytes + while (v >= 1024 && i < units.length - 1) { v /= 1024; i++ } + return v.toFixed(i === 0 ? 0 : 2) + " " + units[i] + } + + function formatRate(bytesPerSec) { + return formatBytes(bytesPerSec) + "/s" + } + + readonly property var statRows: [ + { label: "Ping", value: root.pingText }, + { label: "Packet Loss", value: root.packetLossText }, + { label: "Receiving", value: root.formatRate(root.rxRate) }, + { label: "Sending", value: root.formatRate(root.txRate) }, + { label: "Downloaded", value: root.formatBytes(root.rxTotal) }, + { label: "Uploaded", value: root.formatBytes(root.txTotal) }, + { label: "IP Address", value: root.ipAddress }, + { label: "Gateway", value: root.gateway } + ] + + // Flattened into individual grid cells (label, value, label, value, ...) + // rather than paired items, so the two values in each row can each be + // right-aligned within their own half instead of just sitting next to + // their label. + readonly property var statCells: { + const cells = [] + for (let i = 0; i < root.statRows.length; i++) { + cells.push({ text: root.statRows[i].label, isLabel: true }) + cells.push({ text: root.statRows[i].value, isLabel: false }) + } + return cells + } + + // Shared by the stats grid and the (separate) DNS address grid below, + // so their label/value columns come out the same width even though + // they're two different GridLayout instances. IPv6 addresses are wider + // than this leaves room for — those values get elided rather than + // pushing the column wider. + readonly property int infoLabelColumnWidth: 90 + + // Caps how tall the Known/Other Networks list gets before it scrolls + // instead of continuing to grow the whole popup — roughly 5-6 rows. + // Everything above it (header, stats, DNS, wired) sizes normally, so + // once the network list hits this cap the popup's own height stops + // growing too. + readonly property int maxNetworkListHeight: 170 + + // --- DNS provider --------------------------------------------------- + // Scoped to just the active connection via nmcli, unlike omarchy's own + // DNS switcher (which shells out to a root-requiring helper that + // rewrites /etc/systemd/resolved.conf and a NetworkManager global-dns + // conf.d file system-wide). Modifying a connection you own is something + // NetworkManager lets an ordinary user do without a password prompt, so + // this stays scoped to nmcli connection modify + device reapply. + readonly property var dnsProviders: ["DHCP", "Cloudflare", "Google", "Custom"] + property string activeConnectionName: "" + // The connection's static override (empty under DHCP) — used only to + // work out which pill is "active" below. + property string dnsIpv4: "" + property string dnsIpv6: "" + // The device's actual effective DNS servers, whether they came from a + // static override or DHCP — this is what's displayed, so picking DHCP + // still shows real server addresses instead of going blank. + property string dnsEffectiveIpv4: "" + property string dnsEffectiveIpv6: "" + property bool dnsCustomOpen: false + property string dnsCustomText: "" + property bool dnsBusy: false + + // What's actually shown under the pills — the real servers currently in + // effect, not just the provider name, so a "Custom" entry or a + // provider swap is verifiable at a glance. Each family (dns.dns can + // list a primary + secondary server) becomes its own column, stacked + // primary-on-top-of-secondary within it — IPv4 on the left, IPv6 on + // the right. + // nmcli separates multiple servers with commas from `connection show` + // but with " | " from `device show` — split on either (and whitespace) + // so both sources parse correctly. + readonly property var dnsIpv4List: root.dnsEffectiveIpv4 ? root.dnsEffectiveIpv4.split(/[,|\s]+/).filter(function(s) { return s.length > 0 }) : [] + readonly property var dnsIpv6List: root.dnsEffectiveIpv6 ? root.dnsEffectiveIpv6.split(/[,|\s]+/).filter(function(s) { return s.length > 0 }) : [] + + // Flattened the same way statCells is, and fed into that same + // GridLayout as extra rows, so the DNS label/value columns line up + // pixel-for-pixel with the stats above them instead of being computed + // by a separate, independently-sized layout. IPv4 fills the left + // label:value pair, IPv6 the right one, row by row. + readonly property var dnsGridCells: { + if (root.dnsIpv4List.length === 0 && root.dnsIpv6List.length === 0) { + return [ + { text: "DNS", isLabel: true }, + { text: "Automatic (DHCP)", isLabel: false }, + { text: "", isLabel: true }, + { text: "", isLabel: false } + ] + } + const cells = [] + const rows = Math.max(root.dnsIpv4List.length, root.dnsIpv6List.length) + for (let i = 0; i < rows; i++) { + cells.push({ text: i < root.dnsIpv4List.length ? "DNS IPv4 " + (i + 1) : "", isLabel: true }) + cells.push({ text: i < root.dnsIpv4List.length ? root.dnsIpv4List[i] : "", isLabel: false }) + cells.push({ text: i < root.dnsIpv6List.length ? "DNS IPv6 " + (i + 1) : "", isLabel: true }) + cells.push({ text: i < root.dnsIpv6List.length ? root.dnsIpv6List[i] : "", isLabel: false }) + } + return cells + } + + readonly property string dnsProvider: { + const dns = (root.dnsIpv4 + " " + root.dnsIpv6).trim() + if (!dns) return "DHCP" + if (dns.indexOf("1.1.1.1") !== -1 || dns.indexOf("2606:4700:4700::1111") !== -1) return "Cloudflare" + if (dns.indexOf("8.8.8.8") !== -1 || dns.indexOf("2001:4860:4860::8888") !== -1) return "Google" + return "Custom" + } + + function setDns(provider) { + if (root.dnsBusy || !root.activeConnectionName) return + if (provider === "Custom") { + root.dnsCustomOpen = !root.dnsCustomOpen + return + } + root.dnsCustomOpen = false + if (provider === "Cloudflare") { + root.applyDns("1.1.1.1 1.0.0.1", "2606:4700:4700::1111 2606:4700:4700::1001", "yes") + } else if (provider === "Google") { + root.applyDns("8.8.8.8 8.8.4.4", "2001:4860:4860::8888 2001:4860:4860::8844", "yes") + } else { + root.applyDns("", "", "no") + } + } + + function submitCustomDns() { + const parts = root.dnsCustomText.split(/[\s,]+/).filter(function(s) { return s.length > 0 }) + if (parts.length === 0) return + const ipv4 = parts.filter(function(s) { return s.indexOf(":") === -1 }).join(" ") + const ipv6 = parts.filter(function(s) { return s.indexOf(":") !== -1 }).join(" ") + root.applyDns(ipv4, ipv6, "yes") + root.dnsCustomOpen = false + } + + function applyDns(ipv4, ipv6, ignoreAuto) { + if (!root.activeConnectionName) return + root.dnsBusy = true + dnsSetProc.command = ["nmcli", "connection", "modify", root.activeConnectionName, + "ipv4.ignore-auto-dns", ignoreAuto, "ipv4.dns", ipv4, + "ipv6.ignore-auto-dns", ignoreAuto, "ipv6.dns", ipv6] + dnsSetProc.running = true + } + + Process { + id: connNameProc + command: root.ifaceName ? ["nmcli", "-g", "GENERAL.CONNECTION", "device", "show", root.ifaceName] : ["true"] + stdout: StdioCollector { + id: connNameCollector + onStreamFinished: { + root.activeConnectionName = connNameCollector.text.trim() + if (!dnsQueryProc.running) dnsQueryProc.running = true + } + } + } + + Process { + id: dnsQueryProc + command: root.activeConnectionName + ? ["nmcli", "-g", "ipv4.dns,ipv6.dns", "connection", "show", root.activeConnectionName] + : ["true"] + stdout: StdioCollector { + id: dnsQueryCollector + onStreamFinished: { + // nmcli -g uses ':' as its field separator, so it + // backslash-escapes literal colons inside values (IPv6 + // addresses) to disambiguate them — unescape before display. + const lines = dnsQueryCollector.text.split("\n") + root.dnsIpv4 = (lines[0] || "").trim().replace(/\\:/g, ":") + root.dnsIpv6 = (lines[1] || "").trim().replace(/\\:/g, ":") + } + } + } + + // Effective DNS, from the device rather than the connection profile — + // under DHCP the profile's ipv4.dns/ipv6.dns are empty (nothing + // overridden), but the device still reports whatever DNS it actually + // negotiated. This is what dnsIpv4List/dnsIpv6List (the display grid) + // read from, so picking DHCP shows real servers instead of nothing. + Process { + id: dnsEffectiveProc + command: root.ifaceName ? ["nmcli", "-g", "IP4.DNS,IP6.DNS", "device", "show", root.ifaceName] : ["true"] + stdout: StdioCollector { + id: dnsEffectiveCollector + onStreamFinished: { + const lines = dnsEffectiveCollector.text.split("\n") + root.dnsEffectiveIpv4 = (lines[0] || "").trim().replace(/\\:/g, ":") + root.dnsEffectiveIpv6 = (lines[1] || "").trim().replace(/\\:/g, ":") + } + } + } + + // Applies the modified connection immediately (no reconnect/link drop) + // rather than requiring a manual toggle or full "connection up". + Process { + id: reapplyProc + stdout: StdioCollector {} + // The effective DNS (what dnsEffectiveProc reads) only updates once + // this actually finishes, so refresh it here rather than right + // after dnsSetProc — querying too early would still show the old + // servers. + onExited: (exitCode, exitStatus) => { + if (!dnsEffectiveProc.running) dnsEffectiveProc.running = true + } + } + + Process { + id: dnsSetProc + stdout: StdioCollector {} + onExited: (exitCode, exitStatus) => { + root.dnsBusy = false + if (root.ifaceName) { + reapplyProc.command = ["nmcli", "device", "reapply", root.ifaceName] + reapplyProc.running = true + } + if (!dnsQueryProc.running) dnsQueryProc.running = true + } + } + + Process { + id: gatewayProc + command: ["ip", "route", "show", "default"] + stdout: StdioCollector { + id: gatewayCollector + onStreamFinished: { + const m = gatewayCollector.text.match(/via\s+(\S+)/) + root.gateway = m ? m[1] : "—" + } + } + } + + Process { + id: ipProc + command: root.ifaceName ? ["ip", "-4", "-o", "addr", "show", "dev", root.ifaceName] : ["true"] + stdout: StdioCollector { + id: ipCollector + onStreamFinished: { + const m = ipCollector.text.match(/inet\s+([\d.]+)/) + root.ipAddress = m ? m[1] : "—" + } + } + } + + Process { + id: bytesProc + command: root.ifaceName + ? ["cat", "/sys/class/net/" + root.ifaceName + "/statistics/rx_bytes", + "/sys/class/net/" + root.ifaceName + "/statistics/tx_bytes"] + : ["true"] + stdout: StdioCollector { + id: bytesCollector + onStreamFinished: { + const lines = bytesCollector.text.trim().split("\n") + if (lines.length < 2) return + const rx = parseFloat(lines[0]) + const tx = parseFloat(lines[1]) + const now = Date.now() + if (root._lastSampleTime > 0 && root._lastRx >= 0) { + const dt = (now - root._lastSampleTime) / 1000 + if (dt > 0) { + root.rxRate = Math.max(0, (rx - root._lastRx) / dt) + root.txRate = Math.max(0, (tx - root._lastTx) / dt) + } + } + root.rxTotal = rx + root.txTotal = tx + root._lastRx = rx + root._lastTx = tx + root._lastSampleTime = now + } + } + } + + Process { + id: pingProc + command: ["ping", "-c", "4", "-W", "1", "1.1.1.1"] + stdout: StdioCollector { + id: pingCollector + onStreamFinished: { + const avgMatch = pingCollector.text.match(/=\s*[\d.]+\/([\d.]+)\//) + root.pingText = avgMatch ? Math.round(parseFloat(avgMatch[1])) + " ms" : "—" + const lossMatch = pingCollector.text.match(/([\d.]+)%\s*packet loss/) + root.packetLossText = lossMatch ? lossMatch[1] + "%" : "—" + } + } + } + + Timer { + interval: 1000 + repeat: true + triggeredOnStart: true + running: panel.visible && root.ifaceName !== "" + onTriggered: if (!bytesProc.running) bytesProc.running = true + } + + Timer { + interval: 5000 + repeat: true + triggeredOnStart: true + running: panel.visible && root.connectedDevice !== null + onTriggered: { + if (!pingProc.running) pingProc.running = true + if (!gatewayProc.running) gatewayProc.running = true + if (!ipProc.running) ipProc.running = true + if (!connNameProc.running) connNameProc.running = true + if (!dnsEffectiveProc.running) dnsEffectiveProc.running = true + } + } + Text { id: label anchors.centerIn: parent @@ -30,6 +528,598 @@ Item { } } - // TODO(follow-up): wire this up to a network panel (SSID list, connect/forget) - // once the control center lands. + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + panel.visible = !panel.visible + grab.active = panel.visible + } + } + + // --- Reusable pieces ----------------------------------------------- + + component ToggleSwitch: Item { + id: toggleRoot + property bool checked: false + signal toggled() + implicitWidth: 34 + implicitHeight: 18 + + Rectangle { + anchors.fill: parent + radius: 0 + color: toggleRoot.checked ? Colors.primary : Colors.muted + opacity: toggleRoot.checked ? 1 : 0.35 + } + Rectangle { + width: parent.height - 4 + height: parent.height - 4 + radius: 0 + color: Colors.background + anchors.verticalCenter: parent.verticalCenter + x: toggleRoot.checked ? parent.width - width - 2 : 2 + Behavior on x { NumberAnimation { duration: Appearance.animationDuration } } + } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: toggleRoot.toggled() + } + } + + component NetworkRow: Rectangle { + id: netRow + required property var net + signal activate() + readonly property bool passwordOpen: root.passwordSsid === netRow.net.ssid + width: parent ? parent.width : 0 + radius: 0 + implicitHeight: rowContent.implicitHeight + 12 + (passwordOpen ? passwordRow.implicitHeight + 6 : 0) + color: netRow.net.connected + ? Qt.rgba(Colors.primary.r, Colors.primary.g, Colors.primary.b, 0.18) + : (rowMouse.containsMouse ? Colors.primary : "transparent") + + function submit() { + const n = root.networkForSsid(netRow.net.ssid) + if (n && passwordInput.text.length > 0) n.connectWithPsk(passwordInput.text) + root.passwordSsid = "" + } + + Column { + anchors.fill: parent + anchors.margins: 6 + spacing: 6 + + RowLayout { + id: rowContent + width: parent.width + spacing: 8 + + Text { + Layout.preferredWidth: 20 + Layout.minimumWidth: 20 + Layout.maximumWidth: 20 + horizontalAlignment: Text.AlignLeft + text: root.wifiIcon(netRow.net.signal) + font.family: Appearance.iconFontFamily + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 14 + color: rowMouse.containsMouse ? Colors.primaryText : Colors.foreground + // The wifi-fan glyph itself is flat/wide — stretch it + // vertically to read taller while keeping its width (and + // the layout around it) unchanged. + transform: Scale { + origin.x: width / 2 + origin.y: height / 2 + yScale: 1.2 + } + } + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + + Text { + Layout.fillWidth: true + text: netRow.net.ssid + elide: Text.ElideRight + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSize + font.bold: netRow.net.connected + color: rowMouse.containsMouse ? Colors.primaryText : Colors.foreground + } + Text { + visible: netRow.net.connected + text: "Connected" + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: rowMouse.containsMouse ? Colors.primaryText : Colors.foreground + } + } + Text { + visible: root.requiresCredentials(netRow.net.security) + text: "lock" + font.family: Appearance.iconFontFamily + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 13 + color: rowMouse.containsMouse ? Colors.primaryText : Colors.muted + } + Text { + id: forgetIcon + visible: netRow.net.known + text: "close" + font.family: Appearance.iconFontFamily + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 14 + color: rowMouse.containsMouse ? Colors.primaryText : Colors.muted + } + } + + RowLayout { + id: passwordRow + visible: netRow.passwordOpen + width: parent.width + spacing: 6 + + Rectangle { + Layout.fillWidth: true + implicitHeight: 24 + radius: 0 + color: Colors.background + border.color: Colors.muted + border.width: 1 + + TextInput { + id: passwordInput + anchors.fill: parent + anchors.margins: 6 + echoMode: TextInput.Password + color: Colors.foreground + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + clip: true + focus: netRow.passwordOpen + onAccepted: netRow.submit() + // Explicit fallback alongside onAccepted, which only + // fires when acceptableInput is true — covers both + // the main Return key and numpad Enter directly. + Keys.onReturnPressed: netRow.submit() + Keys.onEnterPressed: netRow.submit() + } + } + + Text { + text: "check" + font.family: Appearance.iconFontFamily + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 16 + color: Colors.primary + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: netRow.submit() + } + } + } + } + + MouseArea { + id: rowMouse + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + height: rowContent.height + 12 + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + // forgetIcon has no MouseArea of its own — it's nested inside + // rowContent, which sits underneath this full-row area in + // stacking order, so a separate MouseArea there would just be + // shadowed. Dispatch by click position against this single area + // instead: netRow.mapFromItem() puts forgetIcon's edge into this + // MouseArea's coordinate space (its origin coincides with + // netRow's, since it's anchored flush to netRow's edges). + onClicked: (mouse) => { + if (netRow.net.known && forgetIcon.visible) { + const forgetLeft = netRow.mapFromItem(forgetIcon, 0, 0).x + if (mouse.x >= forgetLeft - 8) { + const n = root.networkForSsid(netRow.net.ssid) + if (n) n.forget() + return + } + } + netRow.activate() + } + } + } + + // --- Panel ----------------------------------------------------------- + + PopupWindow { + id: panel + visible: false + color: "transparent" + readonly property int contentWidth: 244 + readonly property int contentPadding: 16 + implicitWidth: contentWidth + 100 + contentPadding * 2 + implicitHeight: mainColumn.implicitHeight + contentPadding * 2 + // Wifi scan results appear/disappear while the panel is open, which + // changes mainColumn's height — animate that instead of snapping so + // the resize doesn't jump. + Behavior on implicitHeight { + NumberAnimation { duration: Appearance.animationDuration; easing.type: Easing.OutCubic } + } + + anchor { + window: root.barWindow + rect.x: 0 + rect.y: 0 + rect.width: root.barWindow.width + rect.height: root.barWindow.height + edges: Edges.Bottom | Edges.Right + gravity: Edges.Bottom | Edges.Left + margins.bottom: -4 + margins.right: 0 + } + + HyprlandFocusGrab { + id: grab + windows: [panel] + onCleared: panel.visible = false + } + + Rectangle { + anchors.fill: parent + radius: 0 + color: Colors.surface + border.color: Colors.muted + border.width: 3 + + Column { + id: mainColumn + anchors.fill: parent + anchors.margins: panel.contentPadding + spacing: 10 + + // Header + RowLayout { + width: parent.width + spacing: 8 + + Text { + text: label.text + font.family: Appearance.iconFontFamily + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 30 + color: Colors.foreground + } + + Text { + Layout.fillWidth: true + text: root.connectedNetworkName + elide: Text.ElideRight + font.family: Appearance.fontFamily + font.pixelSize: 18 + font.bold: true + color: Colors.foreground + } + } + + // Live stats — ping/loss/throughput/IP/gateway for whichever + // device is currently connected. + GridLayout { + width: parent.width + visible: root.connectedDevice !== null + columns: 4 + columnSpacing: 12 + rowSpacing: 5 + + Repeater { + model: root.statCells + Text { + required property var modelData + Layout.fillWidth: !modelData.isLabel + horizontalAlignment: modelData.isLabel ? Text.AlignLeft : Text.AlignRight + text: modelData.text + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: modelData.isLabel ? Colors.muted : Colors.foreground + } + } + } + + // DNS provider — modifies only the active connection via + // nmcli (see the block above), not a system-wide override. + Column { + width: parent.width + spacing: 6 + visible: root.connectedDevice !== null + + Text { + text: "DNS PROVIDER" + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.muted + } + + GridLayout { + width: parent.width + columns: 4 + columnSpacing: 6 + rowSpacing: 6 + + Repeater { + model: root.dnsProviders + Rectangle { + id: dnsPill + required property string modelData + readonly property bool active: root.dnsProvider === dnsPill.modelData + Layout.fillWidth: true + implicitHeight: 30 + radius: 0 + color: dnsPill.active + ? Colors.primary + : (dnsMouse.containsMouse ? Colors.surface : "transparent") + border.color: dnsPill.active ? Colors.primary : Colors.muted + border.width: 1 + opacity: root.dnsBusy ? 0.6 : 1 + + Text { + anchors.centerIn: parent + text: dnsPill.modelData + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: dnsPill.active ? Colors.primaryText : Colors.foreground + } + + MouseArea { + id: dnsMouse + anchors.fill: parent + enabled: !root.dnsBusy + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.setDns(dnsPill.modelData) + } + } + } + } + + // Current DNS servers — its own grid below the provider + // pills, sized from its own content rather than pinned + // to the stats grid's column widths above. + GridLayout { + width: parent.width + columns: 4 + columnSpacing: 12 + rowSpacing: 5 + + Repeater { + model: root.dnsGridCells + Text { + required property var modelData + Layout.fillWidth: !modelData.isLabel + horizontalAlignment: modelData.isLabel ? Text.AlignLeft : Text.AlignRight + text: modelData.text + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: modelData.isLabel ? Colors.muted : Colors.foreground + } + } + } + + RowLayout { + width: parent.width + visible: root.dnsCustomOpen + spacing: 6 + + Rectangle { + Layout.fillWidth: true + implicitHeight: 24 + radius: 0 + color: Colors.background + border.color: Colors.muted + border.width: 1 + + TextInput { + id: dnsCustomInput + anchors.fill: parent + anchors.margins: 6 + color: Colors.foreground + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + clip: true + focus: root.dnsCustomOpen + text: root.dnsCustomText + onTextChanged: root.dnsCustomText = text + onAccepted: root.submitCustomDns() + } + } + + Text { + text: "check" + font.family: Appearance.iconFontFamily + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 16 + color: Colors.primary + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: root.submitCustomDns() + } + } + } + } + + // Wired — toggle disconnects the link and disables autoconnect. + Column { + width: parent.width + spacing: 4 + visible: root.wiredDevice !== null + + RowLayout { + width: parent.width + Text { + Layout.fillWidth: true + text: "WIRED" + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSize + color: Colors.foreground + } + ToggleSwitch { + checked: root.wiredOn + onToggled: root.setWiredEnabled(!root.wiredOn) + } + } + RowLayout { + width: parent.width + spacing: 8 + Text { + text: "settings_ethernet" + font.family: Appearance.iconFontFamily + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 14 + color: Colors.foreground + } + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + + Text { + Layout.fillWidth: true + text: root.wiredDisplayName() + elide: Text.ElideRight + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSize + font.bold: root.wiredDevice && root.wiredDevice.hasLink + color: Colors.foreground + } + Text { + visible: root.wiredDevice && root.wiredDevice.hasLink + text: "Connected" + (root.wiredDevice && root.wiredDevice.linkSpeed > 0 ? " · " + root.wiredDevice.linkSpeed + " Mbps" : "") + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.foreground + } + Text { + visible: !(root.wiredDevice && root.wiredDevice.hasLink) + text: "Cable unplugged" + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.foreground + } + } + } + } + + // WiFi + Column { + width: parent.width + spacing: 6 + visible: root.wifiDevice !== null + + RowLayout { + width: parent.width + Text { + Layout.fillWidth: true + text: "WIFI" + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSize + color: Colors.foreground + } + ToggleSwitch { + checked: Networking.wifiEnabled + onToggled: Networking.wifiEnabled = !Networking.wifiEnabled + } + } + + Text { + width: parent.width + visible: Networking.wifiEnabled && root.wifiRows.length === 0 + text: "No networks found" + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.muted + } + + Text { + width: parent.width + visible: !Networking.wifiHardwareEnabled + text: "Wi-Fi is off in hardware" + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.muted + } + + // Known/Other Networks — capped height, scrolls once + // there are more networks than fit rather than growing + // the popup indefinitely. + Flickable { + id: networkListFlick + width: parent.width + height: Math.min(networkListColumn.implicitHeight, root.maxNetworkListHeight) + contentWidth: width + contentHeight: networkListColumn.implicitHeight + clip: true + boundsBehavior: Flickable.StopAtBounds + visible: networkListColumn.implicitHeight > 0 + + Column { + id: networkListColumn + width: networkListFlick.width + spacing: 6 + + Column { + width: parent.width + spacing: 4 + visible: Networking.wifiEnabled && root.knownWifiRows.length > 0 + + Text { + text: "Known Networks" + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.muted + } + Column { + width: parent.width + spacing: 2 + Repeater { + model: root.knownWifiRows + NetworkRow { + required property var modelData + net: modelData + onActivate: root.activateNetwork(net) + } + } + } + } + + Column { + width: parent.width + spacing: 4 + visible: Networking.wifiEnabled && root.otherWifiRows.length > 0 + + Text { + text: "Other Networks" + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.muted + } + Column { + width: parent.width + spacing: 2 + Repeater { + model: root.otherWifiRows + NetworkRow { + required property var modelData + net: modelData + onActivate: root.activateNetwork(net) + } + } + } + } + } + } + } + } + } + } } -- 2.49.1 From e35ceb14503a2265e319f7aabb2071c628784f7c Mon Sep 17 00:00:00 2001 From: thomas Date: Mon, 24 Aug 2026 15:10:55 +0100 Subject: [PATCH 04/44] added bluetooth widget --- dotfiles/.config/quickshell/modules/bar/Bar.qml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotfiles/.config/quickshell/modules/bar/Bar.qml b/dotfiles/.config/quickshell/modules/bar/Bar.qml index 0aa7c2b..a893543 100644 --- a/dotfiles/.config/quickshell/modules/bar/Bar.qml +++ b/dotfiles/.config/quickshell/modules/bar/Bar.qml @@ -48,7 +48,7 @@ PanelWindow { spacing: Appearance.spacing Tray {} NetworkIndicator { barWindow: bar } - BluetoothIndicator {} + BluetoothIndicator { barWindow: bar } Volume { barWindow: bar } PowerButton { barWindow: bar } } -- 2.49.1 From 2f9dfda41055dc260b75929a9a23ad624570f452 Mon Sep 17 00:00:00 2001 From: thomas Date: Mon, 24 Aug 2026 15:12:27 +0100 Subject: [PATCH 05/44] added initial bluetooth widget --- .../modules/bar/BluetoothIndicator.qml | 467 +++++++++++++++++- 1 file changed, 459 insertions(+), 8 deletions(-) diff --git a/dotfiles/.config/quickshell/modules/bar/BluetoothIndicator.qml b/dotfiles/.config/quickshell/modules/bar/BluetoothIndicator.qml index 8087ee1..09bef2b 100644 --- a/dotfiles/.config/quickshell/modules/bar/BluetoothIndicator.qml +++ b/dotfiles/.config/quickshell/modules/bar/BluetoothIndicator.qml @@ -1,4 +1,7 @@ import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Hyprland import Quickshell.Bluetooth import "../../config" @@ -7,16 +10,122 @@ Item { implicitWidth: label.implicitWidth + 4 implicitHeight: Appearance.barHeight + // The bar's own PanelWindow — passed in from Bar.qml so this popup can + // anchor to the bar itself rather than to this icon, keeping it a fixed + // distance from the bar and the screen edge regardless of where this + // icon sits in the row. + required property var barWindow + readonly property var adapter: Bluetooth.defaultAdapter - readonly property bool anyConnected: { - if (!adapter) return false - const devices = adapter.devices.values - for (let i = 0; i < devices.length; i++) { - if (devices[i].connected) return true - } - return false + + // Scan only while the panel is open — same convention as the WiFi + // scannerEnabled binding in NetworkIndicator.qml. + Binding { + target: root.adapter + property: "discovering" + value: panel.visible && root.adapter.enabled + when: root.adapter !== null } + // --- Device row helpers ---------------------------------------------- + // Rows are plain objects rather than the live BluetoothDevice QObject — + // BlueZ churn (pairing timeouts, a device dropping out of range mid-scan) + // can destroy the object while a delegate still holds it, the same + // crash risk noted for WiFi networks in NetworkIndicator.qml. The actual + // object is re-resolved by address only when an action needs it, via + // deviceForAddress(). + function deviceLabel(d) { + return (d.deviceName || d.name || d.address || "").trim() + } + + // BlueZ's Icon property is a freedesktop icon-theme name (verified + // against a real paired device on this machine: "audio-headset") — map + // the common categories to a Material Symbols glyph, falling back to a + // generic "devices_other" for anything unrecognized. + function deviceIcon(icon) { + const i = icon || "" + if (i.indexOf("headset") !== -1 || i.indexOf("headphone") !== -1) return "headphones" + if (i.indexOf("mouse") !== -1) return "mouse" + if (i.indexOf("keyboard") !== -1) return "keyboard" + if (i.indexOf("gaming") !== -1 || i.indexOf("joystick") !== -1) return "sports_esports" + if (i.indexOf("tablet") !== -1) return "tablet" + if (i.indexOf("camera") !== -1) return "videocam" + if (i.indexOf("printer") !== -1) return "print" + if (i.indexOf("watch") !== -1) return "watch" + if (i.indexOf("tv") !== -1) return "tv" + if (i.indexOf("computer") !== -1) return "computer" + if (i.indexOf("card") !== -1) return "speaker" + return "devices_other" + } + + function deviceRow(d) { + if (!d) return null + const label = root.deviceLabel(d) + if (!label) return null + return { + address: d.address || "", + label: label, + icon: root.deviceIcon(d.icon), + connected: !!d.connected, + paired: !!d.paired, + bonded: !!d.bonded, + trusted: !!d.trusted, + pairing: !!d.pairing, + batteryAvailable: !!d.batteryAvailable, + battery: d.battery || 0 + } + } + + readonly property var deviceRows: root.adapter + ? root.adapter.devices.values.map(root.deviceRow).filter(function(r) { return r !== null }) + : [] + + readonly property var connectedRows: root.deviceRows.filter(function(r) { return r.connected }) + readonly property var knownRows: root.deviceRows.filter(function(r) { + return !r.connected && (r.paired || r.bonded || r.trusted) + }) + readonly property var discoveredRows: root.deviceRows.filter(function(r) { + return !r.connected && !r.paired && !r.bonded && !r.trusted + }) + + function deviceForAddress(address) { + if (!root.adapter) return null + const devs = root.adapter.devices.values + for (let i = 0; i < devs.length; i++) { + if (devs[i].address === address) return devs[i] + } + return null + } + + function connectDevice(row) { + const d = root.deviceForAddress(row.address) + if (!d) return + // Already-paired devices just reconnect; a fresh discovery result + // has to pair first (BlueZ's own agent handles the SSP/passkey + // handshake — there's no PIN-entry surface exposed by this API). + if (row.paired || row.bonded || row.trusted) d.connect() + else d.pair() + } + + function disconnectDevice(row) { + const d = root.deviceForAddress(row.address) + if (d) d.disconnect() + } + + function forgetDevice(row) { + const d = root.deviceForAddress(row.address) + if (!d) return + if (d.connected) d.disconnect() + d.forget() + } + + readonly property bool anyConnected: root.connectedRows.length > 0 + + // Caps how tall the Paired/Available list gets before it scrolls + // instead of continuing to grow the popup — same convention as + // NetworkIndicator.qml's Known/Other Networks list. + readonly property int maxDeviceListHeight: 260 + Text { id: label anchors.centerIn: parent @@ -33,6 +142,348 @@ Item { MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor - onClicked: if (root.adapter) root.adapter.enabled = !root.adapter.enabled + onClicked: { + panel.visible = !panel.visible + grab.active = panel.visible + } + } + + // --- Reusable pieces --------------------------------------------------- + + component ToggleSwitch: Item { + id: toggleRoot + property bool checked: false + signal toggled() + implicitWidth: 34 + implicitHeight: 18 + + Rectangle { + anchors.fill: parent + radius: 0 + color: toggleRoot.checked ? Colors.primary : Colors.muted + opacity: toggleRoot.checked ? 1 : 0.35 + } + Rectangle { + width: parent.height - 4 + height: parent.height - 4 + radius: 0 + color: Colors.background + anchors.verticalCenter: parent.verticalCenter + x: toggleRoot.checked ? parent.width - width - 2 : 2 + Behavior on x { NumberAnimation { duration: Appearance.animationDuration } } + } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: toggleRoot.toggled() + } + } + + // One device row. `showForget` hides the forget (x) button for + // discovered-but-unpaired devices, which have nothing to forget yet. + component DeviceRow: Rectangle { + id: devRow + required property var dev + property bool showForget: true + signal activate() + width: parent ? parent.width : 0 + radius: 0 + implicitHeight: rowContent.implicitHeight + 12 + color: devRow.dev.connected + ? Qt.rgba(Colors.primary.r, Colors.primary.g, Colors.primary.b, 0.18) + : (rowMouse.containsMouse ? Colors.primary : "transparent") + + RowLayout { + id: rowContent + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: 6 + spacing: 8 + + Text { + Layout.preferredWidth: 20 + Layout.minimumWidth: 20 + Layout.maximumWidth: 20 + horizontalAlignment: Text.AlignLeft + text: devRow.dev.icon + font.family: Appearance.iconFontFamily + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 14 + color: rowMouse.containsMouse ? Colors.primaryText : Colors.foreground + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + + Text { + Layout.fillWidth: true + text: devRow.dev.label + elide: Text.ElideRight + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSize + font.bold: devRow.dev.connected + color: rowMouse.containsMouse ? Colors.primaryText : Colors.foreground + } + Text { + readonly property string statusText: { + if (devRow.dev.pairing) return "Pairing…" + if (devRow.dev.connected && devRow.dev.batteryAvailable) + return Math.round(devRow.dev.battery * 100) + "%" + if (devRow.dev.connected) return "Connected" + return "" + } + visible: statusText !== "" + text: statusText + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: rowMouse.containsMouse ? Colors.primaryText : Colors.muted + } + } + + Text { + id: forgetIcon + visible: devRow.showForget + text: "close" + font.family: Appearance.iconFontFamily + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 14 + color: rowMouse.containsMouse ? Colors.primaryText : Colors.muted + } + } + + MouseArea { + id: rowMouse + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + height: rowContent.height + 12 + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + // forgetIcon has no MouseArea of its own — nested inside + // rowContent, which sits underneath this full-row area in + // stacking order, so a separate MouseArea there would just be + // shadowed (see the identical fix in NetworkIndicator.qml's + // NetworkRow). Dispatch by click position against this single + // area instead. + onClicked: (mouse) => { + if (devRow.showForget && forgetIcon.visible) { + const forgetLeft = devRow.mapFromItem(forgetIcon, 0, 0).x + if (mouse.x >= forgetLeft - 8) { + root.forgetDevice(devRow.dev) + return + } + } + devRow.activate() + } + } + } + + // --- Panel ------------------------------------------------------------- + + PopupWindow { + id: panel + visible: false + color: "transparent" + readonly property int contentWidth: 244 + readonly property int contentPadding: 16 + implicitWidth: contentWidth + contentPadding * 2 + implicitHeight: mainColumn.implicitHeight + contentPadding * 2 + // Connected/paired/discovered devices come and go while the panel + // is open — animate the resize instead of snapping, same as the + // network panel's WiFi list. + Behavior on implicitHeight { + NumberAnimation { duration: Appearance.animationDuration; easing.type: Easing.OutCubic } + } + + anchor { + window: root.barWindow + rect.x: 0 + rect.y: 0 + rect.width: root.barWindow.width + rect.height: root.barWindow.height + edges: Edges.Bottom | Edges.Right + gravity: Edges.Bottom | Edges.Left + margins.bottom: -4 + margins.right: 0 + } + + HyprlandFocusGrab { + id: grab + windows: [panel] + onCleared: panel.visible = false + } + + Rectangle { + anchors.fill: parent + radius: 0 + color: Colors.surface + border.color: Colors.muted + border.width: 3 + + Column { + id: mainColumn + anchors.fill: parent + anchors.margins: panel.contentPadding + spacing: 10 + + // Header + RowLayout { + width: parent.width + spacing: 8 + + Text { + text: label.text + font.family: Appearance.iconFontFamily + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 30 + color: Colors.foreground + } + + Text { + Layout.fillWidth: true + text: "Bluetooth" + font.family: Appearance.fontFamily + font.pixelSize: 18 + font.bold: true + color: Colors.foreground + } + + ToggleSwitch { + visible: root.adapter !== null + checked: root.adapter !== null && root.adapter.enabled + onToggled: root.adapter.enabled = !root.adapter.enabled + } + } + + Text { + width: parent.width + visible: root.adapter === null + text: "No Bluetooth adapter" + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.muted + } + + Text { + width: parent.width + visible: root.adapter !== null && !root.adapter.enabled + text: "Turn Bluetooth on to see devices" + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.muted + } + + // Connected — always shown in full above the scrollable + // list, mirroring the network panel's layout. + Column { + width: parent.width + spacing: 4 + visible: root.connectedRows.length > 0 + + Text { + text: "Connected" + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.muted + } + Column { + width: parent.width + spacing: 2 + Repeater { + model: root.connectedRows + DeviceRow { + required property var modelData + dev: modelData + showForget: true + onActivate: root.disconnectDevice(dev) + } + } + } + } + + Text { + width: parent.width + visible: root.adapter !== null && root.adapter.enabled && root.deviceRows.length === 0 + text: (root.adapter && root.adapter.discovering) ? "Scanning for devices…" : "No devices found" + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.muted + } + + // Paired/Available — capped height, scrolls once there are + // more devices than fit rather than growing the popup + // indefinitely. + Flickable { + id: deviceListFlick + width: parent.width + height: Math.min(deviceListColumn.implicitHeight, root.maxDeviceListHeight) + contentWidth: width + contentHeight: deviceListColumn.implicitHeight + clip: true + boundsBehavior: Flickable.StopAtBounds + visible: deviceListColumn.implicitHeight > 0 + + Column { + id: deviceListColumn + width: deviceListFlick.width + spacing: 6 + + Column { + width: parent.width + spacing: 4 + visible: root.knownRows.length > 0 + + Text { + text: "Paired Devices" + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.muted + } + Column { + width: parent.width + spacing: 2 + Repeater { + model: root.knownRows + DeviceRow { + required property var modelData + dev: modelData + showForget: true + onActivate: root.connectDevice(dev) + } + } + } + } + + Column { + width: parent.width + spacing: 4 + visible: root.adapter !== null && root.adapter.discovering && root.discoveredRows.length > 0 + + Text { + text: "Available Devices" + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.muted + } + Column { + width: parent.width + spacing: 2 + Repeater { + model: root.discoveredRows + DeviceRow { + required property var modelData + dev: modelData + showForget: false + onActivate: root.connectDevice(dev) + } + } + } + } + } + } + } + } } } -- 2.49.1 From 461f4c3fffaccf6a7158888eb64040ebc180b825 Mon Sep 17 00:00:00 2001 From: thomas Date: Tue, 25 Aug 2026 07:25:27 +0100 Subject: [PATCH 06/44] added tailscale widget --- dotfiles/.config/quickshell/modules/bar/Bar.qml | 1 + 1 file changed, 1 insertion(+) diff --git a/dotfiles/.config/quickshell/modules/bar/Bar.qml b/dotfiles/.config/quickshell/modules/bar/Bar.qml index a893543..b7f7532 100644 --- a/dotfiles/.config/quickshell/modules/bar/Bar.qml +++ b/dotfiles/.config/quickshell/modules/bar/Bar.qml @@ -47,6 +47,7 @@ PanelWindow { } spacing: Appearance.spacing Tray {} + TailscaleIndicator { barWindow: bar } NetworkIndicator { barWindow: bar } BluetoothIndicator { barWindow: bar } Volume { barWindow: bar } -- 2.49.1 From 0eee14858b8bb124b6f809116d731b87042dea5b Mon Sep 17 00:00:00 2001 From: thomas Date: Tue, 25 Aug 2026 07:25:38 +0100 Subject: [PATCH 07/44] basic tailscale widget --- .../modules/bar/TailscaleIndicator.qml | 596 ++++++++++++++++++ 1 file changed, 596 insertions(+) create mode 100644 dotfiles/.config/quickshell/modules/bar/TailscaleIndicator.qml diff --git a/dotfiles/.config/quickshell/modules/bar/TailscaleIndicator.qml b/dotfiles/.config/quickshell/modules/bar/TailscaleIndicator.qml new file mode 100644 index 0000000..7ec96f6 --- /dev/null +++ b/dotfiles/.config/quickshell/modules/bar/TailscaleIndicator.qml @@ -0,0 +1,596 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Hyprland +import Quickshell.Io +import "../../config" + +Item { + id: root + implicitWidth: label.implicitWidth + 4 + implicitHeight: Appearance.barHeight + + // The bar's own PanelWindow — passed in from Bar.qml so this popup can + // anchor to the bar itself rather than to this icon, keeping it a fixed + // distance from the bar and the screen edge regardless of where this + // icon sits in the row. + required property var barWindow + + // Quickshell has no native Tailscale API — this shells out to the + // `tailscale` CLI, the same way NetworkIndicator.qml gets its live + // stats. `status --json` gives a full snapshot in one call. + readonly property var defaultStatus: ({ + ok: false, backendState: "Unknown", running: false, needsLogin: false, + authUrl: "", selfName: "", selfIp: "", tailnetName: "", + peers: [], exitNodePeers: [], activeExitNode: null + }) + property var status: defaultStatus + property bool busy: false + + function parseTailscaleStatus(text) { + try { + const data = JSON.parse(text) + const backendState = data.BackendState || "Stopped" + const self = data.Self || {} + const selfIps = (self.TailscaleIPs || []).filter(function(ip) { return /^100\./.test(ip) }) + const rawPeers = data.Peer || {} + const peers = [] + for (const id in rawPeers) { + const p = rawPeers[id] + if (!p || !p.Online) continue + const name = (p.HostName || "").trim() + if (!name) continue + const ips = (p.TailscaleIPs || []).filter(function(ip) { return /^100\./.test(ip) }) + peers.push({ + id: id, + name: name, + ip: ips.length > 0 ? ips[0] : "", + os: (p.OS || "").toLowerCase(), + exitNodeOption: !!p.ExitNodeOption, + exitNodeActive: !!p.ExitNode + }) + } + peers.sort(function(a, b) { return a.name.localeCompare(b.name) }) + return { + ok: true, + backendState: backendState, + running: backendState === "Running", + needsLogin: backendState === "NeedsLogin", + authUrl: data.AuthURL || "", + selfName: self.HostName || "", + selfIp: selfIps.length > 0 ? selfIps[0] : "", + tailnetName: (data.CurrentTailnet && data.CurrentTailnet.Name) || "", + peers: peers, + exitNodePeers: peers.filter(function(p) { return p.exitNodeOption }), + activeExitNode: peers.find(function(p) { return p.exitNodeActive }) || null + } + } catch (e) { + return root.defaultStatus + } + } + + function osIcon(os) { + if (os === "windows") return "desktop_windows" + if (os === "macos" || os === "ios") return "desktop_mac" + if (os === "linux") return "terminal" + return "devices_other" + } + + function toggleTailscale() { + if (root.busy) return + root.busy = true + actionProc.command = root.status.running ? ["tailscale", "down"] : ["tailscale", "up"] + actionProc.running = true + } + + function setExitNode(peer) { + if (root.busy) return + root.busy = true + exitNodeProc.command = ["tailscale", "set", "--exit-node=" + (peer ? peer.ip : "")] + exitNodeProc.running = true + } + + function copyToClipboard(text) { + if (text) Quickshell.clipboardText = text + } + + Process { + id: statusProc + command: ["tailscale", "status", "--json"] + stdout: StdioCollector { + id: statusCollector + onStreamFinished: root.status = root.parseTailscaleStatus(statusCollector.text) + } + } + + Process { + id: actionProc + stdout: StdioCollector {} + onExited: (exitCode, exitStatus) => { + root.busy = false + if (!statusProc.running) statusProc.running = true + } + } + + Process { + id: exitNodeProc + stdout: StdioCollector {} + onExited: (exitCode, exitStatus) => { + root.busy = false + if (!statusProc.running) statusProc.running = true + } + } + + Timer { + interval: 3000 + repeat: true + triggeredOnStart: true + running: panel.visible + onTriggered: if (!statusProc.running) statusProc.running = true + } + + // The real Tailscale mark: a 3x3 grid of dots where the center dot and + // its four edge-neighbors sit at full color (a "+" shape) and the four + // corners sit at a distinctly darker shade — not a flat monochrome + // glyph. Built from plain circles rather than the brand SVG (which + // encodes that dimming as fixed opacity on specific dots, unrelated to + // app state) so the full-color dots can still recolor with connection + // state. + component TailscaleLogo: Item { + id: logo + property color color: Colors.foreground + property real size: 16 + implicitWidth: size + implicitHeight: size + + readonly property real dotSize: logo.size / 4.5 + readonly property real dotGap: (logo.size - dotSize * 3) / 2 + // A real darker shade for the corner dots rather than partial + // transparency — opacity over a dark bar background nearly + // disappears; a distinct tone stays visible at any size. + readonly property color dimColor: Qt.darker(logo.color, 1.8) + + Grid { + anchors.centerIn: parent + columns: 3 + rows: 3 + spacing: logo.dotGap + + Repeater { + model: [0, 0, 0, 1, 1, 1, 0, 1, 0] + Rectangle { + required property int modelData + width: logo.dotSize + height: logo.dotSize + radius: width / 2 + color: modelData ? '#c9f0f0f0' : logo.dimColor + } + } + } + } + + TailscaleLogo { + id: label + anchors.centerIn: parent + size: 16 + color: root.status.running ? Colors.foreground : Colors.muted + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + panel.visible = !panel.visible + grab.active = panel.visible + } + } + + // --- Reusable pieces --------------------------------------------------- + + component ToggleSwitch: Item { + id: toggleRoot + property bool checked: false + signal toggled() + implicitWidth: 34 + implicitHeight: 18 + + Rectangle { + anchors.fill: parent + radius: 0 + color: toggleRoot.checked ? Colors.primary : Colors.muted + opacity: toggleRoot.checked ? 1 : 0.35 + } + Rectangle { + width: parent.height - 4 + height: parent.height - 4 + radius: 0 + color: Colors.background + anchors.verticalCenter: parent.verticalCenter + x: toggleRoot.checked ? parent.width - width - 2 : 2 + Behavior on x { NumberAnimation { duration: Appearance.animationDuration } } + } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: toggleRoot.toggled() + } + } + + // A selectable exit-node row (including the "None" entry that clears + // it) — highlighted like the active state elsewhere in this project's + // panels, click to activate. + component ExitNodeRow: Rectangle { + id: exitRow + property string label: "" + property string subIcon: "lan" + property bool active: false + signal activate() + width: parent ? parent.width : 0 + radius: 0 + implicitHeight: rowContent.implicitHeight + 12 + color: exitRow.active + ? Qt.rgba(Colors.primary.r, Colors.primary.g, Colors.primary.b, 0.18) + : (rowMouse.containsMouse ? Colors.primary : "transparent") + + RowLayout { + id: rowContent + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: 6 + spacing: 8 + + Text { + Layout.preferredWidth: 20 + Layout.minimumWidth: 20 + Layout.maximumWidth: 20 + horizontalAlignment: Text.AlignLeft + text: exitRow.subIcon + font.family: Appearance.iconFontFamily + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 14 + color: rowMouse.containsMouse ? Colors.primaryText : Colors.foreground + } + Text { + Layout.fillWidth: true + text: exitRow.label + elide: Text.ElideRight + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSize + font.bold: exitRow.active + color: rowMouse.containsMouse ? Colors.primaryText : Colors.foreground + } + Text { + visible: exitRow.active + text: "check_circle" + font.family: Appearance.iconFontFamily + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 14 + color: rowMouse.containsMouse ? Colors.primaryText : Colors.primary + } + } + + MouseArea { + id: rowMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: exitRow.activate() + } + } + + // --- Panel ------------------------------------------------------------- + + PopupWindow { + id: panel + visible: false + color: "transparent" + readonly property int contentWidth: 244 + readonly property int contentPadding: 16 + readonly property int infoLabelWidth: 70 + readonly property int maxListHeight: 220 + implicitWidth: contentWidth + contentPadding * 2 + implicitHeight: mainColumn.implicitHeight + contentPadding * 2 + Behavior on implicitHeight { + NumberAnimation { duration: Appearance.animationDuration; easing.type: Easing.OutCubic } + } + + anchor { + window: root.barWindow + rect.x: 0 + rect.y: 0 + rect.width: root.barWindow.width + rect.height: root.barWindow.height + edges: Edges.Bottom | Edges.Right + gravity: Edges.Bottom | Edges.Left + margins.bottom: -4 + margins.right: 0 + } + + HyprlandFocusGrab { + id: grab + windows: [panel] + onCleared: panel.visible = false + } + + Rectangle { + anchors.fill: parent + radius: 0 + color: Colors.surface + border.color: Colors.muted + border.width: 3 + + Column { + id: mainColumn + anchors.fill: parent + anchors.margins: panel.contentPadding + spacing: 10 + + // Header + RowLayout { + width: parent.width + spacing: 8 + + TailscaleLogo { + size: 30 + color: label.color + } + + Text { + Layout.fillWidth: true + text: "Tailscale" + font.family: Appearance.fontFamily + font.pixelSize: 18 + font.bold: true + color: Colors.foreground + } + + ToggleSwitch { + checked: root.status.running + onToggled: root.toggleTailscale() + } + } + + // Sign-in prompt — only ever appears if the daemon reports + // NeedsLogin with a real auth URL to open. + Rectangle { + width: parent.width + visible: root.status.needsLogin && root.status.authUrl !== "" + radius: 0 + color: signInMouse.containsMouse ? Colors.primary : "transparent" + border.color: Colors.muted + border.width: 1 + implicitHeight: signInRow.implicitHeight + 12 + + RowLayout { + id: signInRow + anchors.fill: parent + anchors.margins: 6 + spacing: 8 + Text { + text: "open_in_new" + font.family: Appearance.iconFontFamily + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 14 + color: signInMouse.containsMouse ? Colors.primaryText : Colors.foreground + } + Text { + Layout.fillWidth: true + text: "Sign in to Tailscale" + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSize + color: signInMouse.containsMouse ? Colors.primaryText : Colors.foreground + } + } + MouseArea { + id: signInMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: Qt.openUrlExternally(root.status.authUrl) + } + } + + // Info grid — tailnet, your IP (click to copy), status. + GridLayout { + width: parent.width + visible: root.status.running + columns: 2 + columnSpacing: 12 + rowSpacing: 5 + + Text { + text: "Tailnet" + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + Layout.preferredWidth: panel.infoLabelWidth + Layout.minimumWidth: panel.infoLabelWidth + Layout.maximumWidth: panel.infoLabelWidth + color: Colors.muted + } + Text { + Layout.fillWidth: true + horizontalAlignment: Text.AlignRight + elide: Text.ElideRight + text: root.status.tailnetName || "—" + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.foreground + } + + Text { + text: "Your IP" + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + Layout.preferredWidth: panel.infoLabelWidth + Layout.minimumWidth: panel.infoLabelWidth + Layout.maximumWidth: panel.infoLabelWidth + color: Colors.muted + } + RowLayout { + Layout.fillWidth: true + spacing: 4 + Item { Layout.fillWidth: true } + Text { + text: root.status.selfIp || "—" + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.foreground + } + Text { + visible: root.status.selfIp !== "" + text: "content_copy" + font.family: Appearance.iconFontFamily + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 12 + color: copyMouse.containsMouse ? Colors.primary : Colors.muted + MouseArea { + id: copyMouse + anchors.fill: parent + anchors.margins: -4 + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.copyToClipboard(root.status.selfIp) + } + } + } + } + + Text { + width: parent.width + visible: !root.status.running && !root.status.needsLogin + text: "Tailscale is off" + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.muted + } + + // Peers + exit-node picker — capped height, scrolls once + // the tailnet has more devices than fit, same convention as + // NetworkIndicator.qml's Known/Other Networks list. + Flickable { + id: listFlick + width: parent.width + visible: root.status.running && listColumn.implicitHeight > 0 + height: Math.min(listColumn.implicitHeight, panel.maxListHeight) + contentWidth: width + contentHeight: listColumn.implicitHeight + clip: true + boundsBehavior: Flickable.StopAtBounds + + Column { + id: listColumn + width: listFlick.width + spacing: 6 + + Column { + width: parent.width + spacing: 4 + visible: root.status.exitNodePeers.length > 0 || root.status.activeExitNode !== null + + Text { + text: "Exit Node" + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.muted + } + Column { + width: parent.width + spacing: 2 + + ExitNodeRow { + label: "None" + subIcon: "link_off" + active: root.status.activeExitNode === null + onActivate: root.setExitNode(null) + } + Repeater { + model: root.status.exitNodePeers + ExitNodeRow { + required property var modelData + label: modelData.name + subIcon: "shield" + active: modelData.exitNodeActive + onActivate: root.setExitNode(modelData) + } + } + } + } + + Column { + width: parent.width + spacing: 4 + visible: root.status.peers.length > 0 + + Text { + text: "Peers" + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.muted + } + Column { + width: parent.width + spacing: 2 + + Repeater { + model: root.status.peers + Rectangle { + id: peerRow + required property var modelData + width: parent.width + radius: 0 + color: peerMouse.containsMouse ? Colors.primary : "transparent" + implicitHeight: peerContent.implicitHeight + 12 + + RowLayout { + id: peerContent + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: 6 + spacing: 8 + + Text { + Layout.preferredWidth: 20 + Layout.minimumWidth: 20 + Layout.maximumWidth: 20 + horizontalAlignment: Text.AlignLeft + text: root.osIcon(peerRow.modelData.os) + font.family: Appearance.iconFontFamily + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 14 + color: peerMouse.containsMouse ? Colors.primaryText : Colors.foreground + } + Text { + Layout.fillWidth: true + text: peerRow.modelData.name + elide: Text.ElideRight + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSize + color: peerMouse.containsMouse ? Colors.primaryText : Colors.foreground + } + Text { + text: peerRow.modelData.ip + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: peerMouse.containsMouse ? Colors.primaryText : Colors.muted + } + } + + MouseArea { + id: peerMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.copyToClipboard(peerRow.modelData.ip) + } + } + } + } + } + } + } + } + } + } +} -- 2.49.1 From 322804577f7a9ab9548d3dc16ecb29ad169cc2be Mon Sep 17 00:00:00 2001 From: thomas Date: Tue, 25 Aug 2026 09:33:45 +0100 Subject: [PATCH 08/44] added vpn exclusitivity to allow functions to coexist --- dotfiles/.config/quickshell/config/qmldir | 1 + 1 file changed, 1 insertion(+) diff --git a/dotfiles/.config/quickshell/config/qmldir b/dotfiles/.config/quickshell/config/qmldir index 26da31e..858c68b 100644 --- a/dotfiles/.config/quickshell/config/qmldir +++ b/dotfiles/.config/quickshell/config/qmldir @@ -1,3 +1,4 @@ module config singleton Colors 1.0 Colors.qml singleton Appearance 1.0 Appearance.qml +singleton VpnExclusivity 1.0 VpnExclusivity.qml -- 2.49.1 From 369179c057d8cf18caa14f963468f2b651584f15 Mon Sep 17 00:00:00 2001 From: thomas Date: Tue, 25 Aug 2026 09:34:04 +0100 Subject: [PATCH 09/44] added vpn exclusitivity to allow functions to coexist --- .../.config/quickshell/config/VpnExclusivity.qml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 dotfiles/.config/quickshell/config/VpnExclusivity.qml diff --git a/dotfiles/.config/quickshell/config/VpnExclusivity.qml b/dotfiles/.config/quickshell/config/VpnExclusivity.qml new file mode 100644 index 0000000..1859446 --- /dev/null +++ b/dotfiles/.config/quickshell/config/VpnExclusivity.qml @@ -0,0 +1,16 @@ +pragma Singleton +import QtQuick + +// Tailscale, Mullvad, and ProtonVPN all fight over the default route/DNS if +// more than one is active at once (see VpnIndicator.qml's header comment) — +// only one of the three should ever actually be connected. This singleton +// is the shared signal between the two widget files: VpnIndicator.qml +// writes Mullvad/ProtonVPN's live connection state here as it polls them; +// TailscaleIndicator.qml reads it to back off (`tailscale down`) rather +// than stay up alongside either one, and to refuse turning Tailscale back +// on while one is still connected. +QtObject { + property bool mullvadConnected: false + property bool protonConnected: false + readonly property bool commercialVpnActive: mullvadConnected || protonConnected +} -- 2.49.1 From 516f8eed82956589d490381c9e2da449dfdd7965 Mon Sep 17 00:00:00 2001 From: thomas Date: Tue, 25 Aug 2026 09:34:19 +0100 Subject: [PATCH 10/44] removed tray --- dotfiles/.config/quickshell/modules/bar/Bar.qml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dotfiles/.config/quickshell/modules/bar/Bar.qml b/dotfiles/.config/quickshell/modules/bar/Bar.qml index b7f7532..d04baa7 100644 --- a/dotfiles/.config/quickshell/modules/bar/Bar.qml +++ b/dotfiles/.config/quickshell/modules/bar/Bar.qml @@ -46,8 +46,9 @@ PanelWindow { rightMargin: Appearance.spacing } spacing: Appearance.spacing - Tray {} + //Tray {} TailscaleIndicator { barWindow: bar } + VpnIndicator { barWindow: bar } NetworkIndicator { barWindow: bar } BluetoothIndicator { barWindow: bar } Volume { barWindow: bar } -- 2.49.1 From 38069353161b6b32015e38301e6eaa0f23f322ae Mon Sep 17 00:00:00 2001 From: thomas Date: Tue, 25 Aug 2026 09:34:55 +0100 Subject: [PATCH 11/44] implimented multiple VPN connectors --- .../modules/bar/TailscaleIndicator.qml | 623 ++++++++++++++++-- .../quickshell/modules/bar/VpnIndicator.qml | 554 ++++++++++++++++ 2 files changed, 1115 insertions(+), 62 deletions(-) create mode 100644 dotfiles/.config/quickshell/modules/bar/VpnIndicator.qml diff --git a/dotfiles/.config/quickshell/modules/bar/TailscaleIndicator.qml b/dotfiles/.config/quickshell/modules/bar/TailscaleIndicator.qml index 7ec96f6..777a759 100644 --- a/dotfiles/.config/quickshell/modules/bar/TailscaleIndicator.qml +++ b/dotfiles/.config/quickshell/modules/bar/TailscaleIndicator.qml @@ -16,6 +16,12 @@ Item { // icon sits in the row. required property var barWindow + // How often to poll `tailscale status` in the background, in seconds. + // Overridable per-instance; clamped so a bad value can't hammer the CLI + // or effectively stop refreshing. + property int refreshIntervalSec: 30 + readonly property int _refreshIntervalMs: Math.max(5, Math.min(3600, refreshIntervalSec)) * 1000 + // Quickshell has no native Tailscale API — this shells out to the // `tailscale` CLI, the same way NetworkIndicator.qml gets its live // stats. `status --json` gives a full snapshot in one call. @@ -27,6 +33,38 @@ Item { property var status: defaultStatus property bool busy: false + // Whether the `tailscale` binary itself is on PATH — distinct from + // whether the daemon is running/connected. Starts optimistic so the + // panel doesn't flash a "not installed" message before the first check + // (whichProc, below) has had a chance to land. + property bool installed: true + + // Optimistic desired power state, so the header switch flips the + // instant you click it instead of waiting on the next poll. -1 means + // "no override, just follow root.status.running"; 0/1 means a toggle + // is in flight and hasn't been confirmed by a poll yet. + property int _desired: -1 + readonly property bool active: _desired === -1 ? root.status.running : (_desired === 1) + + // Surfaced under the header for toggle failures and the login-link + // timeout; cleared automatically after a few seconds. + property string actionStatus: "" + property bool _loginPending: false + property bool _authUrlOpened: false + + // Set when the VPN-exclusivity guard (below) forces Tailscale down — + // as opposed to you turning it off yourself — so it knows to bring + // Tailscale back up automatically once Mullvad/ProtonVPN disconnect, + // without overriding a deliberate manual "off". + property bool _forcedDownByVpn: false + + // Other logged-in Tailscale accounts on this machine (`tailscale switch + // --list`) — separate tailnets/identities you can flip between, not + // logins for other VPN products. Only ever shown when there's more than + // one, same as omarchy's panel. + property var accounts: [] + property string switchingAccountId: "" + function parseTailscaleStatus(text) { try { const data = JSON.parse(text) @@ -41,10 +79,14 @@ Item { const name = (p.HostName || "").trim() if (!name) continue const ips = (p.TailscaleIPs || []).filter(function(ip) { return /^100\./.test(ip) }) + const ip6s = (p.TailscaleIPs || []).filter(function(ip) { return /^fd7a:115c:a1e0:/i.test(ip) }) + const dnsName = String(p.DNSName || "").replace(/\.$/, "") peers.push({ id: id, name: name, ip: ips.length > 0 ? ips[0] : "", + ip6: ip6s.length > 0 ? ip6s[0] : "", + dnsName: dnsName, os: (p.OS || "").toLowerCase(), exitNodeOption: !!p.ExitNodeOption, exitNodeActive: !!p.ExitNode @@ -76,13 +118,99 @@ Item { return "devices_other" } - function toggleTailscale() { - if (root.busy) return + function accountLabel(account) { + if (!account) return "Unknown account" + return account.nickname || account.tailnet || account.account || account.id || "Unknown account" + } + + function parseAccounts(text) { + try { + const data = JSON.parse(text) + if (!Array.isArray(data)) return [] + return data.map(function(a) { + return { + id: String(a.id || a.ID || ""), + nickname: String(a.nickname || a.Nickname || ""), + tailnet: String(a.tailnet || a.Tailnet || ""), + account: String(a.account || a.Account || ""), + selected: a.selected === true || a.Selected === true + } + }).filter(function(a) { return a.id !== "" }) + } catch (e) { + return [] + } + } + + // Applies a freshly-parsed status snapshot and reacts to what changed: + // clears the optimistic toggle once reality catches up, and auto-opens + // a login link the moment the daemon reports one. + function applyStatus(text) { + const next = root.parseTailscaleStatus(text) + root.status = next + if (root._desired !== -1 && next.running === (root._desired === 1)) root._desired = -1 + if (next.running) { + root._loginPending = false + root._authUrlOpened = false + loginTimeoutTimer.stop() + // Tailscale, Mullvad, and ProtonVPN all fight over the default + // route/DNS if more than one is active — see VpnIndicator.qml. + // Tailscale is the one that backs off, rather than leave you + // with a broken connection until you notice and fix it by hand. + if (VpnExclusivity.commercialVpnActive && !root.busy) { + root.busy = true + root._desired = 0 + root._forcedDownByVpn = true + root.actionStatus = "Tailscale stopped — a VPN provider is active" + actionStatusTimer.restart() + actionProc.command = ["tailscale", "down"] + actionProc.running = true + } + } else if (next.needsLogin && next.authUrl !== "" && !root._authUrlOpened) { + root.openAuthUrl(next.authUrl) + } + } + + function openAuthUrl(url) { + if (root._authUrlOpened || !url) return + root._authUrlOpened = true + root._loginPending = false + loginTimeoutTimer.stop() + Qt.openUrlExternally(url) + } + + function bringTailscaleUp() { + if (root.busy || !root.installed) return root.busy = true - actionProc.command = root.status.running ? ["tailscale", "down"] : ["tailscale", "up"] + root._desired = 1 + root._loginPending = true + root._authUrlOpened = false + root.actionStatus = "" + actionProc.command = ["tailscale", "up"] + loginTimeoutTimer.restart() actionProc.running = true } + function toggleTailscale() { + if (root.busy || !root.installed) return + // Any manual action overrides the guard's own memory of why + // Tailscale went down — a deliberate "off" here should stick even + // once the VPN it deferred to eventually disconnects. + root._forcedDownByVpn = false + if (root.active) { + root.busy = true + root._desired = 0 + actionProc.command = ["tailscale", "down"] + actionProc.running = true + return + } + if (VpnExclusivity.commercialVpnActive) { + root.actionStatus = "Disconnect Mullvad/ProtonVPN first" + actionStatusTimer.restart() + return + } + root.bringTailscaleUp() + } + function setExitNode(peer) { if (root.busy) return root.busy = true @@ -94,20 +222,68 @@ Item { if (text) Quickshell.clipboardText = text } + function switchAccount(id) { + if (root.busy || !id || root.switchingAccountId !== "" || switchProc.running) return + root.busy = true + root.switchingAccountId = id + switchProc.command = ["tailscale", "switch", id] + switchProc.running = true + } + + function refresh() { + if (!root.installed) { + if (!whichProc.running) whichProc.running = true + return + } + if (!statusProc.running) { + statusProc.running = true + pollWatchdog.restart() + } + if (!accountsProc.running) accountsProc.running = true + } + + // Detects whether the CLI is on PATH at all, separately from whether a + // poll succeeded — a poll can fail for lots of reasons (daemon down, + // permissions), but only this tells us the binary is actually missing. + Process { + id: whichProc + command: ["which", "tailscale"] + running: true + stdout: StdioCollector {} + onExited: (exitCode, exitStatus) => { + root.installed = exitCode === 0 + if (root.installed) root.refresh() + } + } + Process { id: statusProc command: ["tailscale", "status", "--json"] stdout: StdioCollector { id: statusCollector - onStreamFinished: root.status = root.parseTailscaleStatus(statusCollector.text) + onStreamFinished: root.applyStatus(statusCollector.text) + } + onExited: (exitCode, exitStatus) => { + // A poll that fails outright is worth double-checking against + // `which` — covers the CLI having been uninstalled after this + // widget started up. + if (exitCode !== 0 && !whichProc.running) whichProc.running = true } } Process { id: actionProc stdout: StdioCollector {} + stderr: StdioCollector {} onExited: (exitCode, exitStatus) => { root.busy = false + if (exitCode !== 0) { + root._desired = -1 + root._loginPending = false + loginTimeoutTimer.stop() + root.actionStatus = "Tailscale command failed" + actionStatusTimer.restart() + } if (!statusProc.running) statusProc.running = true } } @@ -121,12 +297,104 @@ Item { } } + Process { + id: accountsProc + command: ["tailscale", "switch", "--list", "--json"] + stdout: StdioCollector { + id: accountsCollector + onStreamFinished: root.accounts = root.parseAccounts(accountsCollector.text) + } + onExited: (exitCode, exitStatus) => { + if (exitCode !== 0) root.accounts = [] + } + } + + Process { + id: switchProc + stdout: StdioCollector {} + stderr: StdioCollector {} + onExited: (exitCode, exitStatus) => { + root.busy = false + root.switchingAccountId = "" + if (exitCode !== 0) { + root.actionStatus = "Account switch failed" + actionStatusTimer.restart() + } + if (!statusProc.running) statusProc.running = true + if (!accountsProc.running) accountsProc.running = true + } + } + + // Auto-resumes Tailscale once neither commercial VPN is connected + // anymore — but only if it was this guard that took Tailscale down in + // the first place, never overriding a manual "off". + Connections { + target: VpnExclusivity + function onCommercialVpnActiveChanged() { + if (!VpnExclusivity.commercialVpnActive && root._forcedDownByVpn && !root.active) { + root._forcedDownByVpn = false + root.actionStatus = "Resuming Tailscale — VPN disconnected" + actionStatusTimer.restart() + root.bringTailscaleUp() + } + } + } + + // Background poll, independent of whether the panel is open, so the bar + // icon itself stays live. Timer { - interval: 3000 + id: refreshTimer + interval: root._refreshIntervalMs repeat: true + running: true triggeredOnStart: true - running: panel.visible - onTriggered: if (!statusProc.running) statusProc.running = true + onTriggered: root.refresh() + } + + // Right after launch tailscaled often hasn't come up yet — poll faster + // for the first ~30s until it reports running, instead of waiting out a + // full refresh interval. + Timer { + id: startupRamp + property int ticks: 0 + interval: 2000 + repeat: true + running: true + onTriggered: { + ticks += 1 + if (root.status.running || ticks >= 15) running = false + else root.refresh() + } + } + + // Every poll is skipped while its own process is still running, so one + // that never exits (a flaky network can make `tailscale status` hang) + // would silently stop the panel refreshing at all. Reap it well inside + // the refresh interval so the next tick starts clean. + Timer { + id: pollWatchdog + interval: 15000 + repeat: false + onTriggered: if (statusProc.running) statusProc.running = false + } + + Timer { + id: actionStatusTimer + interval: 4000 + repeat: false + onTriggered: root.actionStatus = "" + } + + Timer { + id: loginTimeoutTimer + interval: 10000 + repeat: false + onTriggered: { + if (!root._loginPending || root._authUrlOpened) return + root._loginPending = false + root.actionStatus = "Tailscale login link not available yet" + actionStatusTimer.restart() + } } // The real Tailscale mark: a 3x3 grid of dots where the center dot and @@ -135,11 +403,15 @@ Item { // glyph. Built from plain circles rather than the brand SVG (which // encodes that dimming as fixed opacity on specific dots, unrelated to // app state) so the full-color dots can still recolor with connection - // state. + // state. A diagonal slash overlays it when fully off, and a small + // warning badge appears when the daemon wants a login. component TailscaleLogo: Item { id: logo property color color: Colors.foreground property real size: 16 + property bool crossed: false + property bool warning: false + property color badgeColor: Colors.urgent implicitWidth: size implicitHeight: size @@ -167,13 +439,46 @@ Item { } } } + + Rectangle { + visible: logo.crossed + anchors.centerIn: parent + width: parent.width * 1.2 + height: Math.max(1.5, parent.height * 0.13) + radius: height / 2 + color: logo.color + rotation: -45 + } + + Rectangle { + visible: logo.warning + width: Math.max(7, logo.size * 0.42) + height: width + radius: width / 2 + color: logo.badgeColor + border.color: Colors.surface + border.width: 1 + anchors.right: parent.right + anchors.bottom: parent.bottom + + Text { + anchors.centerIn: parent + text: "!" + color: Colors.surface + font.family: Appearance.fontFamily + font.pixelSize: Math.max(6, parent.height * 0.75) + font.bold: true + } + } } TailscaleLogo { id: label anchors.centerIn: parent size: 16 - color: root.status.running ? Colors.foreground : Colors.muted + color: root.active ? Colors.foreground : Colors.muted + crossed: !root.active && !root.status.needsLogin + warning: root.status.needsLogin } MouseArea { @@ -279,6 +584,201 @@ Item { } } + // A logged-in Tailscale account row — click to switch to it. Spins its + // glyph while the switch is in flight, same convention as the exit-node + // rotation below. + component AccountRow: Rectangle { + id: accountRow + property var account: null + readonly property bool selected: accountRow.account && accountRow.account.selected === true + readonly property bool switching: accountRow.account && root.switchingAccountId === accountRow.account.id + width: parent ? parent.width : 0 + radius: 0 + implicitHeight: accountContent.implicitHeight + 12 + color: accountRow.selected + ? Qt.rgba(Colors.primary.r, Colors.primary.g, Colors.primary.b, 0.18) + : (accountMouse.containsMouse ? Colors.primary : "transparent") + + RowLayout { + id: accountContent + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: 6 + spacing: 8 + + Text { + Layout.preferredWidth: 20 + Layout.minimumWidth: 20 + Layout.maximumWidth: 20 + horizontalAlignment: Text.AlignLeft + text: "person" + font.family: Appearance.iconFontFamily + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 14 + color: accountMouse.containsMouse ? Colors.primaryText : Colors.foreground + opacity: accountRow.switching ? 0.45 : 1 + + SequentialAnimation on opacity { + running: accountRow.switching + loops: Animation.Infinite + NumberAnimation { to: 1.0; duration: 420; easing.type: Easing.InOutQuad } + NumberAnimation { to: 0.45; duration: 420; easing.type: Easing.InOutQuad } + } + } + Text { + Layout.fillWidth: true + text: root.accountLabel(accountRow.account) + elide: Text.ElideRight + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSize + font.bold: accountRow.selected + color: accountMouse.containsMouse ? Colors.primaryText : Colors.foreground + } + Text { + visible: accountRow.selected + text: "check_circle" + font.family: Appearance.iconFontFamily + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 14 + color: accountMouse.containsMouse ? Colors.primaryText : Colors.primary + } + } + + MouseArea { + id: accountMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: if (accountRow.account) root.switchAccount(accountRow.account.id) + } + } + + // A peer row that expands in place into up to four copyable identifiers + // (name / DNS name / IPv6 / IPv4) instead of copying a single fixed + // field on click. + component PeerRow: Column { + id: peerRow + required property var peer + width: parent ? parent.width : 0 + spacing: 2 + property bool expanded: false + + readonly property var copyOptions: { + const opts = [] + if (peerRow.peer.name) opts.push({ sub: "Name", value: peerRow.peer.name }) + if (peerRow.peer.dnsName) opts.push({ sub: "DNS name", value: peerRow.peer.dnsName }) + if (peerRow.peer.ip6) opts.push({ sub: "IPv6", value: peerRow.peer.ip6 }) + if (peerRow.peer.ip) opts.push({ sub: "IPv4", value: peerRow.peer.ip }) + return opts + } + + Rectangle { + width: parent.width + radius: 0 + color: summaryMouse.containsMouse ? Colors.primary + : (peerRow.expanded ? Qt.rgba(Colors.primary.r, Colors.primary.g, Colors.primary.b, 0.18) : "transparent") + implicitHeight: peerContent.implicitHeight + 12 + + RowLayout { + id: peerContent + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: 6 + spacing: 8 + + Text { + Layout.preferredWidth: 20 + Layout.minimumWidth: 20 + Layout.maximumWidth: 20 + horizontalAlignment: Text.AlignLeft + text: root.osIcon(peerRow.peer.os) + font.family: Appearance.iconFontFamily + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 14 + color: summaryMouse.containsMouse ? Colors.primaryText : Colors.foreground + } + Text { + Layout.fillWidth: true + text: peerRow.peer.name + elide: Text.ElideRight + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSize + color: summaryMouse.containsMouse ? Colors.primaryText : Colors.foreground + } + Text { + text: peerRow.expanded ? "expand_less" : "content_copy" + font.family: Appearance.iconFontFamily + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 14 + color: summaryMouse.containsMouse ? Colors.primaryText : Colors.muted + } + } + + MouseArea { + id: summaryMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: peerRow.expanded = !peerRow.expanded + } + } + + Column { + width: parent.width + visible: peerRow.expanded + spacing: 1 + + Repeater { + model: peerRow.copyOptions + delegate: Rectangle { + required property var modelData + width: parent.width + radius: 0 + color: optionMouse.containsMouse ? Colors.primary : "transparent" + implicitHeight: optionRow.implicitHeight + 8 + + RowLayout { + id: optionRow + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: 26 + anchors.rightMargin: 8 + spacing: 8 + + Text { + Layout.fillWidth: true + text: modelData.value + elide: Text.ElideRight + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: optionMouse.containsMouse ? Colors.primaryText : Colors.foreground + } + Text { + text: modelData.sub + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: optionMouse.containsMouse ? Colors.primaryText : Colors.muted + } + } + + MouseArea { + id: optionMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + root.copyToClipboard(modelData.value) + peerRow.expanded = false + } + } + } + } + } + } + // --- Panel ------------------------------------------------------------- PopupWindow { @@ -295,8 +795,14 @@ Item { NumberAnimation { duration: Appearance.animationDuration; easing.type: Easing.OutCubic } } + onVisibleChanged: if (visible) root.refresh() + anchor { window: root.barWindow + // Unlike anchor.item, anchor.window doesn't default rect to the + // window's own bounds — set it explicitly so edges/gravity below + // resolve against the full bar (and thus the true screen edges) + // instead of a near-zero rect at the window's origin. rect.x: 0 rect.y: 0 rect.width: root.barWindow.width @@ -334,6 +840,8 @@ Item { TailscaleLogo { size: 30 color: label.color + crossed: label.crossed + warning: label.warning } Text { @@ -346,13 +854,51 @@ Item { } ToggleSwitch { - checked: root.status.running + checked: root.active onToggled: root.toggleTailscale() } } + Text { + visible: !root.installed || root.actionStatus !== "" + width: parent.width + text: !root.installed ? "Tailscale CLI not found on PATH" : root.actionStatus + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.muted + wrapMode: Text.WordWrap + } + + // Other logged-in accounts — only worth a section once + // there's actually a choice to make. + Column { + width: parent.width + visible: root.accounts.length > 1 + spacing: 4 + + Text { + text: "Accounts" + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.muted + } + Column { + width: parent.width + spacing: 2 + + Repeater { + model: root.accounts + AccountRow { + required property var modelData + account: modelData + } + } + } + } + // Sign-in prompt — only ever appears if the daemon reports - // NeedsLogin with a real auth URL to open. + // NeedsLogin with a real auth URL to open. Doubles as the + // manual fallback if the automatic browser-open above missed. Rectangle { width: parent.width visible: root.status.needsLogin && root.status.authUrl !== "" @@ -387,7 +933,7 @@ Item { anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor - onClicked: Qt.openUrlExternally(root.status.authUrl) + onClicked: root.openAuthUrl(root.status.authUrl) } } @@ -458,7 +1004,7 @@ Item { Text { width: parent.width - visible: !root.status.running && !root.status.needsLogin + visible: root.installed && !root.status.running && !root.status.needsLogin text: "Tailscale is off" font.family: Appearance.fontFamily font.pixelSize: Appearance.fontSizeSmall @@ -534,56 +1080,9 @@ Item { Repeater { model: root.status.peers - Rectangle { - id: peerRow + PeerRow { required property var modelData - width: parent.width - radius: 0 - color: peerMouse.containsMouse ? Colors.primary : "transparent" - implicitHeight: peerContent.implicitHeight + 12 - - RowLayout { - id: peerContent - anchors.left: parent.left - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - anchors.margins: 6 - spacing: 8 - - Text { - Layout.preferredWidth: 20 - Layout.minimumWidth: 20 - Layout.maximumWidth: 20 - horizontalAlignment: Text.AlignLeft - text: root.osIcon(peerRow.modelData.os) - font.family: Appearance.iconFontFamily - font.variableAxes: Appearance.iconFontAxes - font.pixelSize: 14 - color: peerMouse.containsMouse ? Colors.primaryText : Colors.foreground - } - Text { - Layout.fillWidth: true - text: peerRow.modelData.name - elide: Text.ElideRight - font.family: Appearance.fontFamily - font.pixelSize: Appearance.fontSize - color: peerMouse.containsMouse ? Colors.primaryText : Colors.foreground - } - Text { - text: peerRow.modelData.ip - font.family: Appearance.fontFamily - font.pixelSize: Appearance.fontSizeSmall - color: peerMouse.containsMouse ? Colors.primaryText : Colors.muted - } - } - - MouseArea { - id: peerMouse - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: root.copyToClipboard(peerRow.modelData.ip) - } + peer: modelData } } } diff --git a/dotfiles/.config/quickshell/modules/bar/VpnIndicator.qml b/dotfiles/.config/quickshell/modules/bar/VpnIndicator.qml new file mode 100644 index 0000000..288ce5b --- /dev/null +++ b/dotfiles/.config/quickshell/modules/bar/VpnIndicator.qml @@ -0,0 +1,554 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Hyprland +import Quickshell.Io +import "../../config" + +// A switcher between commercial VPN providers, distinct from +// TailscaleIndicator.qml — Tailscale is a mesh overlay network you generally +// leave running; these are traditional single-tunnel VPN clients where only +// one makes sense to have active at a time. Connecting one here disconnects +// the other first. +// +// V1 scope: connect (provider's own "fastest"/default) and disconnect per +// provider, live status, no region/server picker yet. Provider CLIs: +// - Mullvad: official `mullvad` CLI (mullvad-vpn-daemon package), has +// real JSON status output. +// - ProtonVPN: official `protonvpn` CLI (proton-vpn-cli package), plain- +// text status only — parsed defensively since its exact wording isn't +// guaranteed across versions. +Item { + id: root + implicitWidth: label.implicitWidth + 4 + implicitHeight: Appearance.barHeight + + required property var barWindow + + property int refreshIntervalSec: 20 + readonly property int _refreshIntervalMs: Math.max(5, Math.min(3600, refreshIntervalSec)) * 1000 + + // Whether each CLI is on PATH at all. Starts optimistic so the panel + // doesn't flash "not installed" before the first `which` check lands. + property bool mullvadInstalled: true + property bool protonInstalled: true + + // "disconnected" | "connecting" | "connected" | "disconnecting" + property string mullvadState: "disconnected" + property string mullvadDetail: "" + property string protonState: "disconnected" + property string protonDetail: "" + + readonly property bool anyConnected: mullvadState === "connected" || protonState === "connected" + + // Set while switching from one provider to the other: holds which one + // to connect once the current one has finished disconnecting. + property string pendingConnect: "" + + // Surfaced under the header for a failed connect/disconnect; cleared + // automatically after a few seconds. + property string actionStatus: "" + + function shortLine(text) { + const line = String(text || "").trim().split("\n")[0] || "" + return line.length > 80 ? line.substring(0, 77) + "…" : line + } + + function applyMullvadStatus(text) { + try { + const data = JSON.parse(text) + const state = String(data.state || "disconnected") + const loc = data.details && data.details.location + root.mullvadState = state + root.mullvadDetail = loc ? [loc.city, loc.country].filter(Boolean).join(", ") : "" + } catch (e) { + root.mullvadState = "disconnected" + root.mullvadDetail = "" + } + VpnExclusivity.mullvadConnected = root.mullvadState === "connected" + } + + // No --json here — ProtonVPN's CLI only prints plain text, so this + // just looks for a "Status: …" line and, best-effort, a following + // "Server:"/"Country:" line if the CLI happens to print one. + function applyProtonStatus(text) { + const body = String(text || "") + const statusMatch = body.match(/^Status:\s*(.+)$/mi) + const raw = statusMatch ? statusMatch[1].trim().toLowerCase() : "" + root.protonState = raw.indexOf("connected") === 0 ? "connected" : "disconnected" + const serverMatch = body.match(/^Server:\s*(.+)$/mi) + const countryMatch = body.match(/^Country:\s*(.+)$/mi) + root.protonDetail = serverMatch ? serverMatch[1].trim() : (countryMatch ? countryMatch[1].trim() : "") + VpnExclusivity.protonConnected = root.protonState === "connected" + } + + function refreshMullvad() { + if (!root.mullvadInstalled) { + if (!mullvadWhichProc.running) mullvadWhichProc.running = true + return + } + if (!mullvadStatusProc.running) { + mullvadStatusProc.running = true + mullvadPollWatchdog.restart() + } + } + + function refreshProton() { + if (!root.protonInstalled) { + if (!protonWhichProc.running) protonWhichProc.running = true + return + } + if (!protonStatusProc.running) { + protonStatusProc.running = true + protonPollWatchdog.restart() + } + } + + function refresh() { + root.refreshMullvad() + root.refreshProton() + } + + function connectMullvad() { + if (mullvadConnectProc.running || mullvadDisconnectProc.running) return + if (root.protonState !== "disconnected") { + root.pendingConnect = "mullvad" + root.disconnectProton() + return + } + root.pendingConnect = "" + mullvadConnectProc.running = true + } + + function connectProton() { + if (protonConnectProc.running || protonDisconnectProc.running) return + if (root.mullvadState !== "disconnected") { + root.pendingConnect = "protonvpn" + root.disconnectMullvad() + return + } + root.pendingConnect = "" + protonConnectProc.running = true + } + + function disconnectMullvad() { + if (mullvadDisconnectProc.running) return + mullvadDisconnectProc.running = true + } + + function disconnectProton() { + if (protonDisconnectProc.running) return + protonDisconnectProc.running = true + } + + function toggleMullvad() { + if (mullvadConnectProc.running || mullvadDisconnectProc.running) return + if (root.mullvadState === "connected") root.disconnectMullvad() + else root.connectMullvad() + } + + function toggleProton() { + if (protonConnectProc.running || protonDisconnectProc.running) return + if (root.protonState === "connected") root.disconnectProton() + else root.connectProton() + } + + // Tailscale fights Mullvad/ProtonVPN for the default route/DNS, so fire + // this right after either one finishes connecting — a harmless no-op + // if Tailscale was already down. TailscaleIndicator.qml also backs + // itself off reactively (via VpnExclusivity) as a backstop, but doing + // it here too avoids waiting out its poll interval. + Process { + id: tailscaleDownProc + command: ["tailscale", "down"] + stdout: StdioCollector {} + stderr: StdioCollector {} + } + + // --- Mullvad --------------------------------------------------------- + + Process { + id: mullvadWhichProc + command: ["which", "mullvad"] + running: true + stdout: StdioCollector {} + onExited: (exitCode, exitStatus) => { + root.mullvadInstalled = exitCode === 0 + if (root.mullvadInstalled) root.refreshMullvad() + } + } + + Process { + id: mullvadStatusProc + command: ["mullvad", "status", "--json"] + stdout: StdioCollector { + id: mullvadStatusCollector + onStreamFinished: root.applyMullvadStatus(mullvadStatusCollector.text) + } + onExited: (exitCode, exitStatus) => { + if (exitCode !== 0 && !mullvadWhichProc.running) mullvadWhichProc.running = true + } + } + + Process { + id: mullvadConnectProc + command: ["mullvad", "connect", "--wait"] + stdout: StdioCollector {} + stderr: StdioCollector { id: mullvadConnectErr } + onExited: (exitCode, exitStatus) => { + if (exitCode !== 0) { + root.actionStatus = "Mullvad: " + root.shortLine(mullvadConnectErr.text || "connect failed") + actionStatusTimer.restart() + } else if (!tailscaleDownProc.running) { + tailscaleDownProc.running = true + } + root.refreshMullvad() + } + } + + Process { + id: mullvadDisconnectProc + command: ["mullvad", "disconnect", "--wait"] + stdout: StdioCollector {} + stderr: StdioCollector { id: mullvadDisconnectErr } + onExited: (exitCode, exitStatus) => { + if (exitCode !== 0) { + root.actionStatus = "Mullvad: " + root.shortLine(mullvadDisconnectErr.text || "disconnect failed") + actionStatusTimer.restart() + } + root.refreshMullvad() + if (root.pendingConnect === "mullvad") { root.pendingConnect = ""; root.connectMullvad() } + else if (root.pendingConnect === "protonvpn") { root.pendingConnect = ""; root.connectProton() } + } + } + + Timer { + id: mullvadPollWatchdog + interval: 15000 + repeat: false + onTriggered: if (mullvadStatusProc.running) mullvadStatusProc.running = false + } + + // --- ProtonVPN --------------------------------------------------------- + + Process { + id: protonWhichProc + command: ["which", "protonvpn"] + running: true + stdout: StdioCollector {} + onExited: (exitCode, exitStatus) => { + root.protonInstalled = exitCode === 0 + if (root.protonInstalled) root.refreshProton() + } + } + + Process { + id: protonStatusProc + command: ["protonvpn", "status"] + stdout: StdioCollector { + id: protonStatusCollector + onStreamFinished: root.applyProtonStatus(protonStatusCollector.text) + } + onExited: (exitCode, exitStatus) => { + if (exitCode !== 0 && !protonWhichProc.running) protonWhichProc.running = true + } + } + + Process { + id: protonConnectProc + command: ["protonvpn", "connect"] + stdout: StdioCollector {} + stderr: StdioCollector { id: protonConnectErr } + onExited: (exitCode, exitStatus) => { + if (exitCode !== 0) { + root.actionStatus = "ProtonVPN: " + root.shortLine(protonConnectErr.text || "connect failed") + actionStatusTimer.restart() + } else if (!tailscaleDownProc.running) { + tailscaleDownProc.running = true + } + root.refreshProton() + } + } + + Process { + id: protonDisconnectProc + command: ["protonvpn", "disconnect"] + stdout: StdioCollector {} + stderr: StdioCollector { id: protonDisconnectErr } + onExited: (exitCode, exitStatus) => { + if (exitCode !== 0) { + root.actionStatus = "ProtonVPN: " + root.shortLine(protonDisconnectErr.text || "disconnect failed") + actionStatusTimer.restart() + } + root.refreshProton() + if (root.pendingConnect === "mullvad") { root.pendingConnect = ""; root.connectMullvad() } + else if (root.pendingConnect === "protonvpn") { root.pendingConnect = ""; root.connectProton() } + } + } + + Timer { + id: protonPollWatchdog + interval: 15000 + repeat: false + onTriggered: if (protonStatusProc.running) protonStatusProc.running = false + } + + // --- Shared timers ----------------------------------------------------- + + Timer { + id: refreshTimer + interval: root._refreshIntervalMs + repeat: true + running: true + triggeredOnStart: true + onTriggered: root.refresh() + } + + Timer { + id: actionStatusTimer + interval: 4000 + repeat: false + onTriggered: root.actionStatus = "" + } + + // --- Bar icon ------------------------------------------------------------ + + Text { + id: label + anchors.centerIn: parent + font.family: Appearance.iconFontFamily + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: Appearance.iconSize + color: root.anyConnected ? Colors.primary : Colors.muted + text: "vpn_lock" + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + panel.visible = !panel.visible + grab.active = panel.visible + } + } + + // --- Reusable pieces ----------------------------------------------------- + + component ToggleSwitch: Item { + id: toggleRoot + property bool checked: false + property bool busy: false + signal toggled() + implicitWidth: 34 + implicitHeight: 18 + opacity: toggleRoot.enabled ? 1 : 0.4 + + Rectangle { + anchors.fill: parent + radius: 0 + color: toggleRoot.checked ? Colors.primary : Colors.muted + opacity: (toggleRoot.checked ? 1 : 0.35) * (toggleRoot.busy ? 0.6 : 1) + } + Rectangle { + width: parent.height - 4 + height: parent.height - 4 + radius: 0 + color: Colors.background + anchors.verticalCenter: parent.verticalCenter + x: toggleRoot.checked ? parent.width - width - 2 : 2 + Behavior on x { NumberAnimation { duration: Appearance.animationDuration } } + } + MouseArea { + anchors.fill: parent + enabled: !toggleRoot.busy + cursorShape: toggleRoot.busy ? Qt.ArrowCursor : Qt.PointingHandCursor + onClicked: toggleRoot.toggled() + } + } + + // A single provider's row — icon, name, live status line, and a toggle + // that connects/disconnects it (routed back through root so it can + // enforce the one-provider-at-a-time rule). + component ProviderRow: Rectangle { + id: provRow + property string label: "" + property string subIcon: "shield" + property string detailText: "" + property string state: "disconnected" + property bool installed: true + signal toggle() + width: parent ? parent.width : 0 + radius: 0 + implicitHeight: rowContent.implicitHeight + 12 + color: provRow.state === "connected" + ? Qt.rgba(Colors.primary.r, Colors.primary.g, Colors.primary.b, 0.18) + : "transparent" + + RowLayout { + id: rowContent + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: 6 + spacing: 8 + + Text { + Layout.preferredWidth: 20 + Layout.minimumWidth: 20 + Layout.maximumWidth: 20 + horizontalAlignment: Text.AlignHCenter + text: provRow.subIcon + font.family: Appearance.iconFontFamily + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 14 + color: provRow.state === "connected" ? Colors.primary : Colors.foreground + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 1 + + Text { + Layout.fillWidth: true + text: provRow.label + elide: Text.ElideRight + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSize + font.bold: provRow.state === "connected" + color: Colors.foreground + } + Text { + Layout.fillWidth: true + text: !provRow.installed ? "Not installed" + : provRow.state === "connecting" ? "Connecting…" + : provRow.state === "disconnecting" ? "Disconnecting…" + : provRow.state === "connected" ? (provRow.detailText || "Connected") + : "Disconnected" + elide: Text.ElideRight + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.muted + } + } + + ToggleSwitch { + enabled: provRow.installed + checked: provRow.state === "connected" || provRow.state === "connecting" + busy: provRow.state === "connecting" || provRow.state === "disconnecting" + onToggled: provRow.toggle() + } + } + } + + // --- Panel ----------------------------------------------------------- + + PopupWindow { + id: panel + visible: false + color: "transparent" + readonly property int contentWidth: 244 + readonly property int contentPadding: 16 + implicitWidth: contentWidth + contentPadding * 2 + implicitHeight: mainColumn.implicitHeight + contentPadding * 2 + Behavior on implicitHeight { + NumberAnimation { duration: Appearance.animationDuration; easing.type: Easing.OutCubic } + } + + onVisibleChanged: if (visible) root.refresh() + + anchor { + window: root.barWindow + rect.x: 0 + rect.y: 0 + rect.width: root.barWindow.width + rect.height: root.barWindow.height + edges: Edges.Bottom | Edges.Right + gravity: Edges.Bottom | Edges.Left + margins.bottom: -4 + margins.right: 0 + } + + HyprlandFocusGrab { + id: grab + windows: [panel] + onCleared: panel.visible = false + } + + Rectangle { + anchors.fill: parent + radius: 0 + color: Colors.surface + border.color: Colors.muted + border.width: 3 + + Column { + id: mainColumn + anchors.fill: parent + anchors.margins: panel.contentPadding + spacing: 10 + + RowLayout { + width: parent.width + spacing: 8 + + Text { + text: "vpn_lock" + font.family: Appearance.iconFontFamily + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 22 + color: root.anyConnected ? Colors.primary : Colors.foreground + } + Text { + Layout.fillWidth: true + text: "VPN" + font.family: Appearance.fontFamily + font.pixelSize: 18 + font.bold: true + color: Colors.foreground + } + } + + Text { + visible: root.actionStatus !== "" + width: parent.width + text: root.actionStatus + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.muted + wrapMode: Text.WordWrap + } + + Column { + width: parent.width + spacing: 2 + + ProviderRow { + label: "Mullvad" + subIcon: "shield" + state: root.mullvadState + detailText: root.mullvadDetail + installed: root.mullvadInstalled + onToggle: root.toggleMullvad() + } + ProviderRow { + label: "ProtonVPN" + subIcon: "gpp_good" + state: root.protonState + detailText: root.protonDetail + installed: root.protonInstalled + onToggle: root.toggleProton() + } + } + + Text { + width: parent.width + text: "Connecting one disconnects the other — only one VPN tunnel at a time." + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.muted + wrapMode: Text.WordWrap + } + } + } + } +} -- 2.49.1 From 9df5731ee1f3b226a708bd01ac7ad6d82a53e440 Mon Sep 17 00:00:00 2001 From: thomas Date: Tue, 25 Aug 2026 09:35:22 +0100 Subject: [PATCH 12/44] changed volume location and widget colours --- .../quickshell/modules/osd/VolumeOsd.qml | 71 +++++++++++++++---- 1 file changed, 59 insertions(+), 12 deletions(-) diff --git a/dotfiles/.config/quickshell/modules/osd/VolumeOsd.qml b/dotfiles/.config/quickshell/modules/osd/VolumeOsd.qml index b4381ee..1e32df5 100644 --- a/dotfiles/.config/quickshell/modules/osd/VolumeOsd.qml +++ b/dotfiles/.config/quickshell/modules/osd/VolumeOsd.qml @@ -11,10 +11,15 @@ PanelWindow { color: "transparent" exclusiveZone: 0 - anchors { bottom: true } - margins.bottom: 70 + // Top-right, just under the bar — same fixed distance from the bar and + // screen edge (Appearance.spacing) that every popup opened from the bar + // itself uses, so this lines up with them instead of floating + // bottom-center. + anchors { top: true; right: true } + margins.top: Appearance.spacing + margins.right: Appearance.spacing - implicitWidth: 240 + implicitWidth: 272 implicitHeight: 54 WlrLayershell.layer: WlrLayer.Overlay @@ -59,19 +64,61 @@ PanelWindow { } } - Rectangle { + Item { + id: slider Layout.fillWidth: true - height: 6 - radius: 3 - color: Colors.muted + implicitHeight: 16 + + readonly property real value: osd.sink && osd.sink.audio ? osd.sink.audio.volume : 0 + readonly property bool enabled: !!osd.sink Rectangle { - height: parent.height - radius: 3 - color: Colors.primary - width: parent.width * (osd.sink && osd.sink.audio ? osd.sink.audio.volume : 0) - Behavior on width { NumberAnimation { duration: Appearance.animationDuration } } + id: track + anchors.verticalCenter: parent.verticalCenter + width: parent.width + height: 4 + radius: 0 + color: Colors.muted + opacity: 0.35 } + Rectangle { + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + width: track.width * Math.max(0, Math.min(1, slider.value)) + height: 4 + radius: 0 + color: Colors.primary + opacity: slider.enabled ? 1 : 0.4 + } + Rectangle { + width: 12 + height: 12 + radius: 6 + color: Colors.primary + opacity: slider.enabled ? 1 : 0.4 + anchors.verticalCenter: parent.verticalCenter + x: Math.max(0, Math.min(track.width - width, track.width * slider.value - width / 2)) + } + MouseArea { + anchors.fill: parent + enabled: slider.enabled + cursorShape: Qt.PointingHandCursor + function updateFromX(mx) { + if (!osd.sink || !osd.sink.audio) return + osd.sink.audio.volume = Math.max(0, Math.min(1, mx / track.width)) + } + onPressed: (mouse) => updateFromX(mouse.x) + onPositionChanged: (mouse) => { if (pressed) updateFromX(mouse.x) } + } + } + + Text { + Layout.preferredWidth: 32 + horizontalAlignment: Text.AlignRight + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.muted + text: Math.round(slider.value * 100) + "%" } } } -- 2.49.1 From 7b0cf9934bc1c045a76323f6c94012ab845e754a Mon Sep 17 00:00:00 2001 From: thomas Date: Wed, 26 Aug 2026 20:14:43 +0100 Subject: [PATCH 13/44] migration: lua --- .../hypr/{ => appearance}/animations.lua | 5 ++-- .../.config/hypr/{ => appearance}/colors.lua | 0 .../hypr/{ => appearance}/decorations.lua | 2 +- dotfiles/.config/hypr/{ => core}/env.lua | 0 dotfiles/.config/hypr/{ => core}/hyprmod.lua | 0 dotfiles/.config/hypr/{ => core}/settings.lua | 0 dotfiles/.config/hypr/{ => core}/startup.lua | 0 dotfiles/.config/hypr/hyprland.lua | 28 +++++++++++-------- .../.config/hypr/{ => input}/keybinds.lua | 0 .../.config/hypr/{ => layout}/monitors.lua | 0 .../.config/hypr/{ => layout}/windowrules.lua | 0 .../.config/hypr/{ => layout}/workspaces.lua | 0 .../hypr/{ => presets}/anim-00-default.lua | 0 .../hypr/{ => presets}/anim-01-default-v2.lua | 0 .../hypr/{ => presets}/anim-03-disable.lua | 0 .../.config/hypr/{ => presets}/anim-end-4.lua | 0 .../hypr/{ => presets}/anim-hyde-default.lua | 0 .../{ => presets}/anim-hyde-minimal-1.lua | 0 .../{ => presets}/anim-hyde-minimal-2.lua | 0 .../{ => presets}/anim-hyde-optimized.lua | 0 .../hypr/{ => presets}/anim-hyde-vertical.lua | 0 .../hypr/{ => presets}/anim-mahaveer-1.lua | 0 .../hypr/{ => presets}/anim-mahaveer-2.lua | 0 .../hypr/{ => presets}/anim-ml4w-classic.lua | 0 .../hypr/{ => presets}/anim-ml4w-dynamic.lua | 0 .../hypr/{ => presets}/anim-ml4w-fast.lua | 0 .../hypr/{ => presets}/anim-ml4w-high.lua | 0 .../hypr/{ => presets}/anim-ml4w-moving.lua | 0 .../hypr/{ => presets}/anim-ml4w-standard.lua | 0 29 files changed, 20 insertions(+), 15 deletions(-) rename dotfiles/.config/hypr/{ => appearance}/animations.lua (89%) rename dotfiles/.config/hypr/{ => appearance}/colors.lua (100%) rename dotfiles/.config/hypr/{ => appearance}/decorations.lua (97%) rename dotfiles/.config/hypr/{ => core}/env.lua (100%) rename dotfiles/.config/hypr/{ => core}/hyprmod.lua (100%) rename dotfiles/.config/hypr/{ => core}/settings.lua (100%) rename dotfiles/.config/hypr/{ => core}/startup.lua (100%) rename dotfiles/.config/hypr/{ => input}/keybinds.lua (100%) rename dotfiles/.config/hypr/{ => layout}/monitors.lua (100%) rename dotfiles/.config/hypr/{ => layout}/windowrules.lua (100%) rename dotfiles/.config/hypr/{ => layout}/workspaces.lua (100%) rename dotfiles/.config/hypr/{ => presets}/anim-00-default.lua (100%) rename dotfiles/.config/hypr/{ => presets}/anim-01-default-v2.lua (100%) rename dotfiles/.config/hypr/{ => presets}/anim-03-disable.lua (100%) rename dotfiles/.config/hypr/{ => presets}/anim-end-4.lua (100%) rename dotfiles/.config/hypr/{ => presets}/anim-hyde-default.lua (100%) rename dotfiles/.config/hypr/{ => presets}/anim-hyde-minimal-1.lua (100%) rename dotfiles/.config/hypr/{ => presets}/anim-hyde-minimal-2.lua (100%) rename dotfiles/.config/hypr/{ => presets}/anim-hyde-optimized.lua (100%) rename dotfiles/.config/hypr/{ => presets}/anim-hyde-vertical.lua (100%) rename dotfiles/.config/hypr/{ => presets}/anim-mahaveer-1.lua (100%) rename dotfiles/.config/hypr/{ => presets}/anim-mahaveer-2.lua (100%) rename dotfiles/.config/hypr/{ => presets}/anim-ml4w-classic.lua (100%) rename dotfiles/.config/hypr/{ => presets}/anim-ml4w-dynamic.lua (100%) rename dotfiles/.config/hypr/{ => presets}/anim-ml4w-fast.lua (100%) rename dotfiles/.config/hypr/{ => presets}/anim-ml4w-high.lua (100%) rename dotfiles/.config/hypr/{ => presets}/anim-ml4w-moving.lua (100%) rename dotfiles/.config/hypr/{ => presets}/anim-ml4w-standard.lua (100%) diff --git a/dotfiles/.config/hypr/animations.lua b/dotfiles/.config/hypr/appearance/animations.lua similarity index 89% rename from dotfiles/.config/hypr/animations.lua rename to dotfiles/.config/hypr/appearance/animations.lua index e5d3b7c..e402fb2 100644 --- a/dotfiles/.config/hypr/animations.lua +++ b/dotfiles/.config/hypr/appearance/animations.lua @@ -2,8 +2,9 @@ -- Ported from UserConfigs/UserAnimations.conf (the active preset) -- -- The 14 alternative presets from animations/*.conf were also ported, flat, as --- anim-*.lua (see the plan) — none of them are wired in. To switch styles, replace --- the body of this file with `require("anim-")`, e.g. require("anim-ml4w-classic"). +-- anim-*.lua (see the plan) — none of them are wired in. They now live in ../presets/. +-- To switch styles, replace the body of this file with `require("presets.anim-")`, +-- e.g. require("presets.anim-ml4w-classic"). hl.config({ animations = { diff --git a/dotfiles/.config/hypr/colors.lua b/dotfiles/.config/hypr/appearance/colors.lua similarity index 100% rename from dotfiles/.config/hypr/colors.lua rename to dotfiles/.config/hypr/appearance/colors.lua diff --git a/dotfiles/.config/hypr/decorations.lua b/dotfiles/.config/hypr/appearance/decorations.lua similarity index 97% rename from dotfiles/.config/hypr/decorations.lua rename to dotfiles/.config/hypr/appearance/decorations.lua index 0d6374e..d031ea0 100644 --- a/dotfiles/.config/hypr/decorations.lua +++ b/dotfiles/.config/hypr/appearance/decorations.lua @@ -6,7 +6,7 @@ -- Animation - https://wiki.hyprland.org/Configuring/Animations/ -- Decoration - https://wiki.hyprland.org/Configuring/Variables/#decoration -local colors = require("colors") +local colors = require("appearance.colors") hl.config({ general = { diff --git a/dotfiles/.config/hypr/env.lua b/dotfiles/.config/hypr/core/env.lua similarity index 100% rename from dotfiles/.config/hypr/env.lua rename to dotfiles/.config/hypr/core/env.lua diff --git a/dotfiles/.config/hypr/hyprmod.lua b/dotfiles/.config/hypr/core/hyprmod.lua similarity index 100% rename from dotfiles/.config/hypr/hyprmod.lua rename to dotfiles/.config/hypr/core/hyprmod.lua diff --git a/dotfiles/.config/hypr/settings.lua b/dotfiles/.config/hypr/core/settings.lua similarity index 100% rename from dotfiles/.config/hypr/settings.lua rename to dotfiles/.config/hypr/core/settings.lua diff --git a/dotfiles/.config/hypr/startup.lua b/dotfiles/.config/hypr/core/startup.lua similarity index 100% rename from dotfiles/.config/hypr/startup.lua rename to dotfiles/.config/hypr/core/startup.lua diff --git a/dotfiles/.config/hypr/hyprland.lua b/dotfiles/.config/hypr/hyprland.lua index dfcbf5b..197ec06 100644 --- a/dotfiles/.config/hypr/hyprland.lua +++ b/dotfiles/.config/hypr/hyprland.lua @@ -2,9 +2,13 @@ -- Ported from hyprland.conf — see https://wiki.hyprland.org/ -- -- This mirrors the old source= chain 1:1, just as require()'d Lua modules instead --- of hyprlang includes. See each file for its origin. The animations/ preset library +-- of hyprlang includes. See each file for its origin. The presets/ preset library -- (anim-*.lua) is also ported but intentionally not required from here — only the --- currently-active style (animations.lua) is wired in, exactly like before. +-- currently-active style (appearance/animations.lua) is wired in, exactly like before. +-- +-- Modules live in subfolders sorted by concern (core/, appearance/, layout/, input/, +-- presets/) — require() paths below use dotted module names, which Lua resolves to +-- the matching subfolder (e.g. require("core.env") -> core/env.lua). -- Initial boot script: applies initial wallpapers, theming, new settings etc. -- suggest not changing/deleting this, including the reference file at ~/.config/hypr/.initial_startup_done @@ -14,17 +18,17 @@ hl.on("hyprland.start", function() end) -- ## This is where you want to start tinkering -require("startup") -- put your start-up packages in startup.lua -require("env") -- Environment variables to load -require("windowrules") -- all about Hyprland Window Rules and Layer Rules -require("decorations") -- Decorations config -require("animations") -- Animation config -require("keybinds") -- Put your own keybinds here -require("settings") -- Main Hyprland Settings +require("core.startup") -- put your start-up packages in core/startup.lua +require("core.env") -- Environment variables to load +require("layout.windowrules") -- all about Hyprland Window Rules and Layer Rules +require("appearance.decorations") -- Decorations config +require("appearance.animations") -- Animation config +require("input.keybinds") -- Put your own keybinds here +require("core.settings") -- Main Hyprland Settings -- nwg-displays / hyprdynamicmonitors -require("monitors") -require("workspaces") +require("layout.monitors") +require("layout.workspaces") -- NOTE: the original hyprland.conf used a plain `exec` (not `exec-once`) here, so it -- re-ran on every `hyprctl reload`, not just at startup. hl.on("hyprland.start", ...) @@ -35,4 +39,4 @@ hl.on("hyprland.start", function() end) -- HyprMod managed settings -require("hyprmod") +require("core.hyprmod") diff --git a/dotfiles/.config/hypr/keybinds.lua b/dotfiles/.config/hypr/input/keybinds.lua similarity index 100% rename from dotfiles/.config/hypr/keybinds.lua rename to dotfiles/.config/hypr/input/keybinds.lua diff --git a/dotfiles/.config/hypr/monitors.lua b/dotfiles/.config/hypr/layout/monitors.lua similarity index 100% rename from dotfiles/.config/hypr/monitors.lua rename to dotfiles/.config/hypr/layout/monitors.lua diff --git a/dotfiles/.config/hypr/windowrules.lua b/dotfiles/.config/hypr/layout/windowrules.lua similarity index 100% rename from dotfiles/.config/hypr/windowrules.lua rename to dotfiles/.config/hypr/layout/windowrules.lua diff --git a/dotfiles/.config/hypr/workspaces.lua b/dotfiles/.config/hypr/layout/workspaces.lua similarity index 100% rename from dotfiles/.config/hypr/workspaces.lua rename to dotfiles/.config/hypr/layout/workspaces.lua diff --git a/dotfiles/.config/hypr/anim-00-default.lua b/dotfiles/.config/hypr/presets/anim-00-default.lua similarity index 100% rename from dotfiles/.config/hypr/anim-00-default.lua rename to dotfiles/.config/hypr/presets/anim-00-default.lua diff --git a/dotfiles/.config/hypr/anim-01-default-v2.lua b/dotfiles/.config/hypr/presets/anim-01-default-v2.lua similarity index 100% rename from dotfiles/.config/hypr/anim-01-default-v2.lua rename to dotfiles/.config/hypr/presets/anim-01-default-v2.lua diff --git a/dotfiles/.config/hypr/anim-03-disable.lua b/dotfiles/.config/hypr/presets/anim-03-disable.lua similarity index 100% rename from dotfiles/.config/hypr/anim-03-disable.lua rename to dotfiles/.config/hypr/presets/anim-03-disable.lua diff --git a/dotfiles/.config/hypr/anim-end-4.lua b/dotfiles/.config/hypr/presets/anim-end-4.lua similarity index 100% rename from dotfiles/.config/hypr/anim-end-4.lua rename to dotfiles/.config/hypr/presets/anim-end-4.lua diff --git a/dotfiles/.config/hypr/anim-hyde-default.lua b/dotfiles/.config/hypr/presets/anim-hyde-default.lua similarity index 100% rename from dotfiles/.config/hypr/anim-hyde-default.lua rename to dotfiles/.config/hypr/presets/anim-hyde-default.lua diff --git a/dotfiles/.config/hypr/anim-hyde-minimal-1.lua b/dotfiles/.config/hypr/presets/anim-hyde-minimal-1.lua similarity index 100% rename from dotfiles/.config/hypr/anim-hyde-minimal-1.lua rename to dotfiles/.config/hypr/presets/anim-hyde-minimal-1.lua diff --git a/dotfiles/.config/hypr/anim-hyde-minimal-2.lua b/dotfiles/.config/hypr/presets/anim-hyde-minimal-2.lua similarity index 100% rename from dotfiles/.config/hypr/anim-hyde-minimal-2.lua rename to dotfiles/.config/hypr/presets/anim-hyde-minimal-2.lua diff --git a/dotfiles/.config/hypr/anim-hyde-optimized.lua b/dotfiles/.config/hypr/presets/anim-hyde-optimized.lua similarity index 100% rename from dotfiles/.config/hypr/anim-hyde-optimized.lua rename to dotfiles/.config/hypr/presets/anim-hyde-optimized.lua diff --git a/dotfiles/.config/hypr/anim-hyde-vertical.lua b/dotfiles/.config/hypr/presets/anim-hyde-vertical.lua similarity index 100% rename from dotfiles/.config/hypr/anim-hyde-vertical.lua rename to dotfiles/.config/hypr/presets/anim-hyde-vertical.lua diff --git a/dotfiles/.config/hypr/anim-mahaveer-1.lua b/dotfiles/.config/hypr/presets/anim-mahaveer-1.lua similarity index 100% rename from dotfiles/.config/hypr/anim-mahaveer-1.lua rename to dotfiles/.config/hypr/presets/anim-mahaveer-1.lua diff --git a/dotfiles/.config/hypr/anim-mahaveer-2.lua b/dotfiles/.config/hypr/presets/anim-mahaveer-2.lua similarity index 100% rename from dotfiles/.config/hypr/anim-mahaveer-2.lua rename to dotfiles/.config/hypr/presets/anim-mahaveer-2.lua diff --git a/dotfiles/.config/hypr/anim-ml4w-classic.lua b/dotfiles/.config/hypr/presets/anim-ml4w-classic.lua similarity index 100% rename from dotfiles/.config/hypr/anim-ml4w-classic.lua rename to dotfiles/.config/hypr/presets/anim-ml4w-classic.lua diff --git a/dotfiles/.config/hypr/anim-ml4w-dynamic.lua b/dotfiles/.config/hypr/presets/anim-ml4w-dynamic.lua similarity index 100% rename from dotfiles/.config/hypr/anim-ml4w-dynamic.lua rename to dotfiles/.config/hypr/presets/anim-ml4w-dynamic.lua diff --git a/dotfiles/.config/hypr/anim-ml4w-fast.lua b/dotfiles/.config/hypr/presets/anim-ml4w-fast.lua similarity index 100% rename from dotfiles/.config/hypr/anim-ml4w-fast.lua rename to dotfiles/.config/hypr/presets/anim-ml4w-fast.lua diff --git a/dotfiles/.config/hypr/anim-ml4w-high.lua b/dotfiles/.config/hypr/presets/anim-ml4w-high.lua similarity index 100% rename from dotfiles/.config/hypr/anim-ml4w-high.lua rename to dotfiles/.config/hypr/presets/anim-ml4w-high.lua diff --git a/dotfiles/.config/hypr/anim-ml4w-moving.lua b/dotfiles/.config/hypr/presets/anim-ml4w-moving.lua similarity index 100% rename from dotfiles/.config/hypr/anim-ml4w-moving.lua rename to dotfiles/.config/hypr/presets/anim-ml4w-moving.lua diff --git a/dotfiles/.config/hypr/anim-ml4w-standard.lua b/dotfiles/.config/hypr/presets/anim-ml4w-standard.lua similarity index 100% rename from dotfiles/.config/hypr/anim-ml4w-standard.lua rename to dotfiles/.config/hypr/presets/anim-ml4w-standard.lua -- 2.49.1 From bc242ae956fc50d60a25d39d9c45135126d13165 Mon Sep 17 00:00:00 2001 From: Thomas Williams Date: Wed, 26 Aug 2026 20:21:08 +0100 Subject: [PATCH 14/44] updated to add quickshell --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a9a2898..47ff190 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ List of programs and tools I use. |📁 File Manager | [thunar](https://wiki.archlinux.org/title/Thunar) | |📝 Editor | [vs code](https://github.com/microsoft/vscode) / [neovim (changing to soon)](https://github.com/neovim/neovim) | |🌐 Browser | [zen](https://github.com/zen-browser/desktop) / [firefox](https://github.com/topics/firefox-browser) / [startpage](https://blog-bucket-personal.s3.eu-west-2.amazonaws.com/Screenshot_02-May_08-44-23_6614.png) | -|📊 Bar | [waybar](https://github.com/Alexays/Waybar) | +|📊 Bar | [waybar](https://github.com/Alexays/Waybar) / [quickshell](https://quickshell.org/) | |🚀 Launcher | [rofi](https://github.com/davatorium/rofi) | |🔒 Lockscreen | [hyprlock](https://github.com/hyprwm/hyprlock) | |🚪 Login Menu | [sddm](https://github.com/sddm/sddm) | -- 2.49.1 From 98f9a77b2396644de6c3cdf81b77bffcf899ca4f Mon Sep 17 00:00:00 2001 From: thomas Date: Wed, 26 Aug 2026 21:05:03 +0100 Subject: [PATCH 15/44] feature: float bitwarden extension window --- dotfiles/.config/hypr/core/startup.lua | 3 + .../hypr/scripts/FloatBitwardenPopup.sh | 73 +++++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100755 dotfiles/.config/hypr/scripts/FloatBitwardenPopup.sh diff --git a/dotfiles/.config/hypr/core/startup.lua b/dotfiles/.config/hypr/core/startup.lua index 6824928..1878add 100644 --- a/dotfiles/.config/hypr/core/startup.lua +++ b/dotfiles/.config/hypr/core/startup.lua @@ -33,6 +33,9 @@ hl.on("hyprland.start", function() -- Starting hypridle to start hyprlock hl.exec_cmd("hypridle") + -- Float + center the Bitwarden extension pop-out (window rules can't catch it — see the script) + hl.exec_cmd(scriptsDir .. "/FloatBitwardenPopup.sh") + hl.dispatch(hl.dsp.focus({ workspace = 3 })) -- NOTE: the trailing "&&" here is a stray leftover from the original .conf (dangling, does nothing) — ported as-is diff --git a/dotfiles/.config/hypr/scripts/FloatBitwardenPopup.sh b/dotfiles/.config/hypr/scripts/FloatBitwardenPopup.sh new file mode 100755 index 0000000..2c772fa --- /dev/null +++ b/dotfiles/.config/hypr/scripts/FloatBitwardenPopup.sh @@ -0,0 +1,73 @@ +#!/bin/bash +# Float + resize (to 781x702) + center the Bitwarden browser-extension pop-out window. +# +# Why this exists instead of a window rule: Hyprland's `float`/`center`/`size` +# rules are "static" — evaluated exactly once, at the instant a window is +# created — and this popup opens with the generic title "Zen Browser" (same +# initialTitle/initialClass as the main browser window), only updating to +# "Extension: (Bitwarden Password Manager) - Bitwarden..." a moment after +# it's already mapped. By then the static rule has already missed its one +# chance and there's no field available at creation time to tell the popup +# apart from a normal browser window. See layout/windowrules.lua and +# https://github.com/hyprwm/Hyprland/issues/7402 for the same problem class. +# +# No socat/ncat is installed, so instead of an event-driven IPC listener this +# just polls `hyprctl clients` at a short interval with jq (already +# installed). +# +# Dispatch syntax note: Hyprland 0.55+'s Lua config changed `hyprctl dispatch` +# to take a literal Lua expression calling hl.dsp.* (e.g. +# `hyprctl dispatch 'hl.dsp.window.center()'`), not the old +# "centerwindow"-style dispatcher strings. Verified live against the real +# API in /usr/share/hypr/stubs/hl.meta.lua before wiring this in. +# +# Why it re-asserts for a few seconds instead of firing once: right after the +# popup leaves the tiled layout, Hyprland briefly re-tiles the sibling window +# to fill the freed space, and during that reflow this popup was observed +# reporting `floating: false` again for one poll tick — snapping it back to +# its old tiled geometry. Re-asserting float+center for a short settle window +# after first detection rides that out. After the settle window it's left +# alone, so a manual re-tile/move by you later isn't fought. + +TITLE_PATTERN='Extension: \(Bitwarden Password Manager\)' +POLL_INTERVAL=0.3 +SETTLE_TICKS=6 # ~2s of re-assertion after first detection, at POLL_INTERVAL +TARGET_WIDTH=781 +TARGET_HEIGHT=702 + +declare -A ticks_left # address -> remaining re-assert ticks + +while true; do + sleep "$POLL_INTERVAL" + + # Forget any tracked window that's no longer open. + open_addresses=$(hyprctl clients -j | jq -r '.[].address') + for addr in "${!ticks_left[@]}"; do + grep -qF "$addr" <<<"$open_addresses" || unset 'ticks_left[$addr]' + done + + # Process substitution (not a pipe) so the while loop runs in this shell + # and its writes to ticks_left actually persist across iterations. + while IFS= read -r win; do + title=$(jq -r '.title' <<<"$win") + [[ "$title" =~ $TITLE_PATTERN ]] || continue + + address=$(jq -r '.address' <<<"$win") + floating=$(jq -r '.floating' <<<"$win") + + # First time we've seen this window: start its settle window. + [[ -v ticks_left[$address] ]] || ticks_left[$address]=$SETTLE_TICKS + + # Already floating and past its settle window: leave it alone. + [[ "$floating" == "true" && ${ticks_left[$address]} -le 0 ]] && continue + + hyprctl dispatch "hl.dsp.focus({ window = \"address:$address\" })" >/dev/null + hyprctl dispatch 'hl.dsp.window.float({ action = "enable" })' >/dev/null + # resize({x=W,y=H}) sets the absolute size (not a delta) — verified live. + # Must happen before center(), since resizing shifts what "centered" means. + hyprctl dispatch "hl.dsp.window.resize({ x = $TARGET_WIDTH, y = $TARGET_HEIGHT })" >/dev/null + hyprctl dispatch 'hl.dsp.window.center()' >/dev/null + + ticks_left[$address]=$((ticks_left[$address] - 1)) + done < <(hyprctl clients -j | jq -c '.[] | {address, title, floating}') +done -- 2.49.1 From d3856ada0d94251e52ce5b0dc90ebd6ab77780d2 Mon Sep 17 00:00:00 2001 From: thomas Date: Thu, 27 Aug 2026 00:38:50 +0100 Subject: [PATCH 16/44] utility: checking all dependencies --- .../scripts/CheckQuickshellSetup.sh | 259 ++++++++++++++++++ 1 file changed, 259 insertions(+) create mode 100755 dotfiles/.config/quickshell/scripts/CheckQuickshellSetup.sh diff --git a/dotfiles/.config/quickshell/scripts/CheckQuickshellSetup.sh b/dotfiles/.config/quickshell/scripts/CheckQuickshellSetup.sh new file mode 100755 index 0000000..1c8f61c --- /dev/null +++ b/dotfiles/.config/quickshell/scripts/CheckQuickshellSetup.sh @@ -0,0 +1,259 @@ +#!/bin/bash +# Pre-flight check for everything this quickshell config depends on outside +# its own QML — fonts, the compositor, backing D-Bus services, and the +# external CLIs individual bar modules shell out to. Run this after +# installing/stowing quickshell on a new machine, before assuming the bar +# is just going to work. +# +# Usage: ./CheckQuickshellSetup.sh [path-to-quickshell-config] +# Defaults to $XDG_CONFIG_HOME/quickshell or ~/.config/quickshell. + +set -uo pipefail + +QS_DIR="${1:-${XDG_CONFIG_HOME:-$HOME/.config}/quickshell}" + +PASS=0 +WARN=0 +FAIL=0 + +c_green="\033[0;32m"; c_yellow="\033[0;33m"; c_red="\033[0;31m"; c_reset="\033[0m" + +pass() { printf " ${c_green}✓${c_reset} %s\n" "$1"; PASS=$((PASS+1)); } +warn() { printf " ${c_yellow}!${c_reset} %s\n" "$1"; WARN=$((WARN+1)); } +fail() { printf " ${c_red}✗${c_reset} %s\n" "$1"; FAIL=$((FAIL+1)); } +section() { printf "\n%s\n" "$1"; } + +svc_state() { + # active / inactive / missing / no-systemctl. $1 = unit, $2 = --user (optional) + # NB: capture-then-here-string, not `cmd | grep -q`. Under `set -o + # pipefail`, grep -q exits the instant it finds a match, SIGPIPE-ing a + # still-writing producer — that producer's non-zero exit (not grep's + # success) becomes the pipeline's status, which reads as "not found" + # even when it was. Verified this actually happens with fc-list below. + if ! command -v systemctl >/dev/null 2>&1; then echo "no-systemctl"; return; fi + local unit_files + unit_files=$(systemctl ${2:-} list-unit-files "$1" 2>/dev/null) + if grep -q "$1" <<< "$unit_files"; then + systemctl ${2:-} is-active "$1" 2>/dev/null || echo "inactive" + else + echo "missing" + fi +} + +# --- 0. Quickshell itself + config files present ---------------------------- + +section "Quickshell core" + +if command -v quickshell >/dev/null 2>&1; then + ver=$(quickshell --version 2>&1 | head -1) + pass "quickshell found ($(command -v quickshell)) — $ver" +else + fail "quickshell binary not found on PATH — install it before anything else here matters" +fi + +for f in "shell.qml" "config/Appearance.qml" "config/Colors.qml" "config/qmldir"; do + if [ -f "$QS_DIR/$f" ]; then + pass "found $f" + else + fail "missing $QS_DIR/$f — dotfiles not fully stowed/symlinked here?" + fi +done + +# Nearly every module (ActiveWindow, Workspaces, PowerButton, PanelWindow's +# layer-shell surfaces, HyprlandFocusGrab) assumes a live Hyprland session. +if [ -n "${HYPRLAND_INSTANCE_SIGNATURE:-}" ] && command -v hyprctl >/dev/null 2>&1 && hyprctl monitors >/dev/null 2>&1; then + pass "Hyprland is the running compositor" +else + fail "Hyprland doesn't appear to be running — Quickshell.Hyprland and the layer-shell bar will not function" +fi + +# --- 1. Fonts ----------------------------------------------------------------- + +section "Fonts" + +if command -v fc-list >/dev/null 2>&1; then + # Captured once, then grepped via here-string — see the pipefail/SIGPIPE + # note on svc_state() above; fc-list's output is exactly what triggers it. + fc_out=$(fc-list) + + if grep -qi "Material Symbols Outlined" <<< "$fc_out"; then + pass "Material Symbols Outlined installed" + else + fail "Material Symbols Outlined not found — install 'ttf-material-symbols-variable' (AUR); every glyph icon in the bar/panels will render as fallback text without it" + fi + + if grep -qi "CaskaydiaCove Nerd Font Mono" <<< "$fc_out"; then + pass "CaskaydiaCove Nerd Font Mono installed" + else + fail "CaskaydiaCove Nerd Font Mono not found — install a Cascadia Code Nerd Font package; clock/labels/window title use it" + fi +else + warn "fc-list not found (fontconfig missing?) — can't verify fonts" +fi + +# --- 2. Backing services for native Quickshell modules ---------------------- +# These modules talk to system services directly over D-Bus rather than +# shelling out — the service being up is the actual dependency, not a CLI. + +section "Backing services (native QML modules)" + +st=$(svc_state NetworkManager.service) +case "$st" in + active) pass "NetworkManager.service active — Quickshell.Networking (NetworkIndicator)" ;; + missing) fail "NetworkManager not installed — required by NetworkIndicator.qml" ;; + *) fail "NetworkManager.service is $st — run: sudo systemctl enable --now NetworkManager" ;; +esac + +st=$(svc_state bluetooth.service) +case "$st" in + active) pass "bluetooth.service active — Quickshell.Bluetooth (BluetoothIndicator)" ;; + missing) fail "bluez not installed — required by BluetoothIndicator.qml" ;; + *) fail "bluetooth.service is $st — run: sudo systemctl enable --now bluetooth" ;; +esac + +# Pipewire/wireplumber are user-session services, not system ones. +st=$(svc_state pipewire.service --user) +case "$st" in + active) pass "pipewire.service (user) active — Quickshell.Services.Pipewire (Volume/VolumeOsd)" ;; + missing) fail "pipewire not installed — required by Volume.qml/VolumeOsd.qml" ;; + *) fail "pipewire.service (user) is $st — run: systemctl --user enable --now pipewire pipewire-pulse wireplumber" ;; +esac + +st=$(svc_state wireplumber.service --user) +case "$st" in + active) pass "wireplumber.service (user) active" ;; + missing) warn "wireplumber not installed — pipewire falls back to pipewire-media-session if present, otherwise volume control may misbehave" ;; + *) fail "wireplumber.service (user) is $st — run: systemctl --user enable --now wireplumber" ;; +esac + +# --- 3. External CLIs shelled out to via Process ---------------------------- + +section "External CLIs (Quickshell.Io Process calls)" + +check_bin() { + # $1 = binary, $2 = which module(s) use it, $3 = fail|warn on missing + if command -v "$1" >/dev/null 2>&1; then + pass "$1 found — used by $2" + elif [ "$3" = "warn" ]; then + warn "$1 not found — used by $2 (optional on this machine)" + else + fail "$1 not found — used by $2" + fi +} + +check_bin nmcli "NetworkIndicator.qml (DNS provider panel, connection queries)" fail +check_bin ip "NetworkIndicator.qml (gateway/IP lookups)" fail +check_bin ping "NetworkIndicator.qml (latency/packet-loss stat)" fail +check_bin cat "NetworkIndicator.qml (/sys/class/net throughput counters)" fail +check_bin systemctl "PowerButton.qml (suspend/hibernate/reboot/poweroff)" fail +check_bin tailscale "TailscaleIndicator.qml / VpnExclusivity" warn +check_bin mullvad "VpnIndicator.qml / VpnExclusivity" warn +check_bin protonvpn "VpnIndicator.qml / VpnExclusivity" warn + +have_tailscale=0; have_mullvad=0; have_proton=0 +command -v tailscale >/dev/null 2>&1 && have_tailscale=1 +command -v mullvad >/dev/null 2>&1 && have_mullvad=1 +command -v protonvpn >/dev/null 2>&1 && have_proton=1 + +if [ $have_mullvad -eq 0 ] && [ $have_proton -eq 0 ]; then + warn "neither Mullvad nor ProtonVPN CLI is installed — VpnIndicator.qml will show both as 'Not installed'" +fi + +# --- 4. VPN daemons + functional status (Mullvad/ProtonVPN/Tailscale) ------- +# The exclusivity logic (VpnExclusivity.qml backing off Tailscale when a +# commercial VPN connects) is pure QML and ships with the dotfiles — it +# needs no setup of its own. What varies per machine is whether these three +# are actually installed, enabled, and callable without a password prompt. + +section "VPN dual-setup (Mullvad/ProtonVPN + Tailscale)" + +if [ $have_tailscale -eq 1 ]; then + st=$(svc_state tailscaled.service) + case "$st" in + active) pass "tailscaled.service is active" ;; + missing) fail "tailscaled.service not found — reinstall tailscale" ;; + *) fail "tailscaled.service is $st — run: sudo systemctl enable --now tailscaled" ;; + esac + + if out=$(timeout 5 tailscale status --json 2>&1); then + if python3 -c "import json,sys; json.load(sys.stdin)" >/dev/null 2>&1 <<< "$out"; then + pass "tailscale status --json returns valid JSON" + else + warn "tailscale status --json returned non-JSON output" + fi + else + fail "tailscale status --json failed: $(echo "$out" | head -1)" + fi +fi + +if [ $have_mullvad -eq 1 ]; then + st=$(svc_state mullvad-daemon.service) + case "$st" in + active) pass "mullvad-daemon.service is active" ;; + missing) fail "mullvad-daemon.service not found — reinstall the Mullvad package" ;; + *) fail "mullvad-daemon.service is $st — run: sudo systemctl enable --now mullvad-daemon" ;; + esac + + if out=$(timeout 5 mullvad status --json 2>&1); then + if python3 -c "import json,sys; json.load(sys.stdin)" >/dev/null 2>&1 <<< "$out"; then + pass "mullvad status --json returns valid JSON" + else + warn "mullvad status --json returned non-JSON output" + fi + else + fail "mullvad status --json failed: $(echo "$out" | head -1)" + fi +fi + +if [ $have_proton -eq 1 ]; then + # ProtonVPN's CLI drives connections through NetworkManager rather than + # its own daemon — already checked above, just re-verified here in context. + if out=$(timeout 5 protonvpn status 2>&1); then + if grep -qi "^Status:" <<< "$out"; then + pass "protonvpn status returns a parseable 'Status:' line" + else + warn "protonvpn status has no 'Status:' line — applyProtonStatus() in VpnIndicator.qml may misread it: $(echo "$out" | head -1)" + fi + else + fail "protonvpn status failed: $(echo "$out" | head -1)" + fi +fi + +ts_up=0; mv_up=0; pv_up=0 +# Same capture-then-here-string reasoning as above — these outputs are small +# enough that the SIGPIPE race is unlikely, but there's no upside to relying +# on that instead of just being consistent. +[ $have_tailscale -eq 1 ] && grep -q '"BackendState":"Running"' <<< "$(tailscale status --json 2>/dev/null)" && ts_up=1 +[ $have_mullvad -eq 1 ] && grep -qi "^Connected" <<< "$(mullvad status 2>/dev/null)" && mv_up=1 +[ $have_proton -eq 1 ] && grep -qi "^Status:\s*Connected" <<< "$(protonvpn status 2>/dev/null)" && pv_up=1 + +active_count=$((ts_up + mv_up + pv_up)) +if [ $active_count -le 1 ]; then + pass "at most one tunnel active right now (tailscale=$ts_up mullvad=$mv_up protonvpn=$pv_up)" +else + warn "more than one tunnel is up right now (tailscale=$ts_up mullvad=$mv_up protonvpn=$pv_up) — bring extras down manually; quickshell only enforces exclusivity when you toggle from its own UI" +fi + +# --- 5. System tray (currently disabled in Bar.qml) ------------------------- + +section "System tray (Tray.qml)" + +if grep -q "^\s*//\s*Tray\s*{}" "$QS_DIR/modules/bar/Bar.qml" 2>/dev/null; then + warn "Tray {} is commented out in Bar.qml — skipping icon-theme check (nothing to render)" +elif [ -f "$QS_DIR/modules/bar/Bar.qml" ]; then + if command -v gtk-update-icon-cache >/dev/null 2>&1 || [ -d /usr/share/icons/hicolor ]; then + pass "an icon theme (at least hicolor) is present for Quickshell.Services.SystemTray icon lookups" + else + warn "no icon theme found — tray icons may render blank" + fi +fi + +# --- Summary ----------------------------------------------------------------- + +section "Summary" +printf " ${c_green}%d passed${c_reset}, ${c_yellow}%d warnings${c_reset}, ${c_red}%d failed${c_reset}\n" "$PASS" "$WARN" "$FAIL" + +if [ $FAIL -gt 0 ]; then + exit 1 +fi +exit 0 -- 2.49.1 From b7a92c9a59d8ecc4de1b697c72b3f40967626e1f Mon Sep 17 00:00:00 2001 From: thomas Date: Thu, 27 Aug 2026 07:40:53 +0100 Subject: [PATCH 17/44] Changes: replaced icons with material icons --- .../.config/quickshell/config/Appearance.qml | 4 ++ .../modules/bar/BluetoothIndicator.qml | 8 ++-- .../modules/bar/NetworkIndicator.qml | 48 +++++++++++++++---- .../quickshell/modules/bar/PowerButton.qml | 6 +-- .../modules/bar/TailscaleIndicator.qml | 16 +++---- .../.config/quickshell/modules/bar/Volume.qml | 6 +-- .../quickshell/modules/bar/VpnIndicator.qml | 6 +-- .../quickshell/modules/osd/VolumeOsd.qml | 2 +- 8 files changed, 65 insertions(+), 31 deletions(-) diff --git a/dotfiles/.config/quickshell/config/Appearance.qml b/dotfiles/.config/quickshell/config/Appearance.qml index a83c8a0..324b40f 100644 --- a/dotfiles/.config/quickshell/config/Appearance.qml +++ b/dotfiles/.config/quickshell/config/Appearance.qml @@ -4,6 +4,10 @@ import QtQuick QtObject { readonly property string fontFamily: "CaskaydiaCove Nerd Font Mono" readonly property string iconFontFamily: "Material Symbols Outlined" + // Same glyph set, softer/rounded terminals — used where a module wants + // a visually distinct icon style from the rest of the bar (currently + // just BluetoothIndicator.qml) without switching every icon over. + readonly property string iconFontFamilyRounded: "Material Symbols Rounded" // Material Symbols is a variable font. Left unset, glyphs fall back to the // font's default optical size (24px), which is tuned for larger rendering // and makes some icons (e.g. "lock") look heavier/bigger than others when diff --git a/dotfiles/.config/quickshell/modules/bar/BluetoothIndicator.qml b/dotfiles/.config/quickshell/modules/bar/BluetoothIndicator.qml index 09bef2b..3b6e9ac 100644 --- a/dotfiles/.config/quickshell/modules/bar/BluetoothIndicator.qml +++ b/dotfiles/.config/quickshell/modules/bar/BluetoothIndicator.qml @@ -129,7 +129,7 @@ Item { Text { id: label anchors.centerIn: parent - font.family: Appearance.iconFontFamily + font.family: Appearance.iconFontFamilyRounded font.variableAxes: Appearance.iconFontAxes font.pixelSize: 16 color: (root.adapter && root.adapter.enabled) ? Colors.foreground : Colors.muted @@ -207,7 +207,7 @@ Item { Layout.maximumWidth: 20 horizontalAlignment: Text.AlignLeft text: devRow.dev.icon - font.family: Appearance.iconFontFamily + font.family: Appearance.iconFontFamilyRounded font.variableAxes: Appearance.iconFontAxes font.pixelSize: 14 color: rowMouse.containsMouse ? Colors.primaryText : Colors.foreground @@ -246,7 +246,7 @@ Item { id: forgetIcon visible: devRow.showForget text: "close" - font.family: Appearance.iconFontFamily + font.family: Appearance.iconFontFamilyRounded font.variableAxes: Appearance.iconFontAxes font.pixelSize: 14 color: rowMouse.containsMouse ? Colors.primaryText : Colors.muted @@ -335,7 +335,7 @@ Item { Text { text: label.text - font.family: Appearance.iconFontFamily + font.family: Appearance.iconFontFamilyRounded font.variableAxes: Appearance.iconFontAxes font.pixelSize: 30 color: Colors.foreground diff --git a/dotfiles/.config/quickshell/modules/bar/NetworkIndicator.qml b/dotfiles/.config/quickshell/modules/bar/NetworkIndicator.qml index 260b4a5..357effb 100644 --- a/dotfiles/.config/quickshell/modules/bar/NetworkIndicator.qml +++ b/dotfiles/.config/quickshell/modules/bar/NetworkIndicator.qml @@ -161,6 +161,14 @@ Item { return (row && row.ssid) || "Network" } + // Signal strength of the currently-connected WiFi network, if any — lets + // the bar icon show the same wifi-fan glyph (via wifiIcon()) as the + // connected row inside the popup, instead of a generic "wifi" icon. + readonly property int connectedWifiSignal: { + const row = root.wifiRows.find(function(r) { return r.connected }) + return row ? row.signal : 0 + } + function activateNetwork(net) { if (net.connected) return if (net.known || !root.requiresCredentials(net.security)) { @@ -517,14 +525,27 @@ Item { Text { id: label anchors.centerIn: parent - font.family: Appearance.iconFontFamily + font.family: Appearance.iconFontFamilyRounded font.variableAxes: Appearance.iconFontAxes font.pixelSize: 16 color: root.connectedDevice ? Colors.foreground : Colors.muted + // Wired/disconnected use their own distinct glyphs; only the WiFi + // case renders one of wifiIcon()'s flat/wide wifi-fan glyphs, so + // only that case needs the vertical-stretch correction below. + readonly property bool isWifiFan: root.connectedDevice !== null && root.connectedDevice.type !== DeviceType.Wired text: { if (!root.connectedDevice) return "wifi_off" - return root.connectedDevice.type === DeviceType.Wired ? "settings_ethernet" : "wifi" + if (root.connectedDevice.type === DeviceType.Wired) + return "settings_ethernet" + return root.wifiIcon(root.connectedWifiSignal) + } + // Same correction as the matching glyph inside the popup's + // NetworkRow — the wifi-fan family is inherently flat/wide. + transform: Scale { + origin.x: label.width / 2 + origin.y: label.height / 2 + yScale: label.isWifiFan ? 1.2 : 1.0 } } @@ -602,7 +623,7 @@ Item { Layout.maximumWidth: 20 horizontalAlignment: Text.AlignLeft text: root.wifiIcon(netRow.net.signal) - font.family: Appearance.iconFontFamily + font.family: Appearance.iconFontFamilyRounded font.variableAxes: Appearance.iconFontAxes font.pixelSize: 14 color: rowMouse.containsMouse ? Colors.primaryText : Colors.foreground @@ -639,7 +660,7 @@ Item { Text { visible: root.requiresCredentials(netRow.net.security) text: "lock" - font.family: Appearance.iconFontFamily + font.family: Appearance.iconFontFamilyRounded font.variableAxes: Appearance.iconFontAxes font.pixelSize: 13 color: rowMouse.containsMouse ? Colors.primaryText : Colors.muted @@ -648,7 +669,7 @@ Item { id: forgetIcon visible: netRow.net.known text: "close" - font.family: Appearance.iconFontFamily + font.family: Appearance.iconFontFamilyRounded font.variableAxes: Appearance.iconFontAxes font.pixelSize: 14 color: rowMouse.containsMouse ? Colors.primaryText : Colors.muted @@ -690,7 +711,7 @@ Item { Text { text: "check" - font.family: Appearance.iconFontFamily + font.family: Appearance.iconFontFamilyRounded font.variableAxes: Appearance.iconFontAxes font.pixelSize: 16 color: Colors.primary @@ -786,11 +807,20 @@ Item { spacing: 8 Text { + id: headerIcon text: label.text - font.family: Appearance.iconFontFamily + font.family: Appearance.iconFontFamilyRounded font.variableAxes: Appearance.iconFontAxes font.pixelSize: 30 color: Colors.foreground + // Mirrors the bar icon's own correction — see label's + // isWifiFan/transform above — since this just + // displays the same text at a larger size. + transform: Scale { + origin.x: headerIcon.width / 2 + origin.y: headerIcon.height / 2 + yScale: label.isWifiFan ? 1.2 : 1.0 + } } Text { @@ -936,7 +966,7 @@ Item { Text { text: "check" - font.family: Appearance.iconFontFamily + font.family: Appearance.iconFontFamilyRounded font.variableAxes: Appearance.iconFontAxes font.pixelSize: 16 color: Colors.primary @@ -974,7 +1004,7 @@ Item { spacing: 8 Text { text: "settings_ethernet" - font.family: Appearance.iconFontFamily + font.family: Appearance.iconFontFamilyRounded font.variableAxes: Appearance.iconFontAxes font.pixelSize: 14 color: Colors.foreground diff --git a/dotfiles/.config/quickshell/modules/bar/PowerButton.qml b/dotfiles/.config/quickshell/modules/bar/PowerButton.qml index 82b8d5b..4bd8531 100644 --- a/dotfiles/.config/quickshell/modules/bar/PowerButton.qml +++ b/dotfiles/.config/quickshell/modules/bar/PowerButton.qml @@ -19,7 +19,7 @@ Item { Text { id: label anchors.centerIn: parent - font.family: Appearance.iconFontFamily + font.family: Appearance.iconFontFamilyRounded font.variableAxes: Appearance.iconFontAxes font.pixelSize: 16 color: Colors.foreground @@ -91,7 +91,7 @@ Item { Text { text: label.text - font.family: Appearance.iconFontFamily + font.family: Appearance.iconFontFamilyRounded font.variableAxes: Appearance.iconFontAxes font.pixelSize: 30 color: Colors.foreground @@ -140,7 +140,7 @@ Item { Layout.maximumWidth: 25 horizontalAlignment: Text.AlignLeft text: entry.modelData.icon - font.family: Appearance.iconFontFamily + font.family: Appearance.iconFontFamilyRounded font.variableAxes: Appearance.iconFontAxes font.pixelSize: entry.modelData.iconSize || 15 opacity: entry.modelData.disabled ? 0.4 : 1 diff --git a/dotfiles/.config/quickshell/modules/bar/TailscaleIndicator.qml b/dotfiles/.config/quickshell/modules/bar/TailscaleIndicator.qml index 777a759..49ea8a3 100644 --- a/dotfiles/.config/quickshell/modules/bar/TailscaleIndicator.qml +++ b/dotfiles/.config/quickshell/modules/bar/TailscaleIndicator.qml @@ -551,7 +551,7 @@ Item { Layout.maximumWidth: 20 horizontalAlignment: Text.AlignLeft text: exitRow.subIcon - font.family: Appearance.iconFontFamily + font.family: Appearance.iconFontFamilyRounded font.variableAxes: Appearance.iconFontAxes font.pixelSize: 14 color: rowMouse.containsMouse ? Colors.primaryText : Colors.foreground @@ -568,7 +568,7 @@ Item { Text { visible: exitRow.active text: "check_circle" - font.family: Appearance.iconFontFamily + font.family: Appearance.iconFontFamilyRounded font.variableAxes: Appearance.iconFontAxes font.pixelSize: 14 color: rowMouse.containsMouse ? Colors.primaryText : Colors.primary @@ -613,7 +613,7 @@ Item { Layout.maximumWidth: 20 horizontalAlignment: Text.AlignLeft text: "person" - font.family: Appearance.iconFontFamily + font.family: Appearance.iconFontFamilyRounded font.variableAxes: Appearance.iconFontAxes font.pixelSize: 14 color: accountMouse.containsMouse ? Colors.primaryText : Colors.foreground @@ -638,7 +638,7 @@ Item { Text { visible: accountRow.selected text: "check_circle" - font.family: Appearance.iconFontFamily + font.family: Appearance.iconFontFamilyRounded font.variableAxes: Appearance.iconFontAxes font.pixelSize: 14 color: accountMouse.containsMouse ? Colors.primaryText : Colors.primary @@ -694,7 +694,7 @@ Item { Layout.maximumWidth: 20 horizontalAlignment: Text.AlignLeft text: root.osIcon(peerRow.peer.os) - font.family: Appearance.iconFontFamily + font.family: Appearance.iconFontFamilyRounded font.variableAxes: Appearance.iconFontAxes font.pixelSize: 14 color: summaryMouse.containsMouse ? Colors.primaryText : Colors.foreground @@ -709,7 +709,7 @@ Item { } Text { text: peerRow.expanded ? "expand_less" : "content_copy" - font.family: Appearance.iconFontFamily + font.family: Appearance.iconFontFamilyRounded font.variableAxes: Appearance.iconFontAxes font.pixelSize: 14 color: summaryMouse.containsMouse ? Colors.primaryText : Colors.muted @@ -915,7 +915,7 @@ Item { spacing: 8 Text { text: "open_in_new" - font.family: Appearance.iconFontFamily + font.family: Appearance.iconFontFamilyRounded font.variableAxes: Appearance.iconFontAxes font.pixelSize: 14 color: signInMouse.containsMouse ? Colors.primaryText : Colors.foreground @@ -986,7 +986,7 @@ Item { Text { visible: root.status.selfIp !== "" text: "content_copy" - font.family: Appearance.iconFontFamily + font.family: Appearance.iconFontFamilyRounded font.variableAxes: Appearance.iconFontAxes font.pixelSize: 12 color: copyMouse.containsMouse ? Colors.primary : Colors.muted diff --git a/dotfiles/.config/quickshell/modules/bar/Volume.qml b/dotfiles/.config/quickshell/modules/bar/Volume.qml index 8c893fa..35d749c 100644 --- a/dotfiles/.config/quickshell/modules/bar/Volume.qml +++ b/dotfiles/.config/quickshell/modules/bar/Volume.qml @@ -95,7 +95,7 @@ Item { Text { id: label anchors.centerIn: parent - font.family: Appearance.iconFontFamily + font.family: Appearance.iconFontFamilyRounded font.variableAxes: Appearance.iconFontAxes font.pixelSize: 16 color: Colors.foreground @@ -245,7 +245,7 @@ Item { Layout.maximumWidth: 18 horizontalAlignment: Text.AlignHCenter text: deviceRow.icon - font.family: Appearance.iconFontFamily + font.family: Appearance.iconFontFamilyRounded font.variableAxes: Appearance.iconFontAxes font.pixelSize: 14 color: rowMouse.containsMouse ? Colors.primaryText : Colors.foreground @@ -325,7 +325,7 @@ Item { Text { text: label.text - font.family: Appearance.iconFontFamily + font.family: Appearance.iconFontFamilyRounded font.variableAxes: Appearance.iconFontAxes font.pixelSize: 40 color: Colors.foreground diff --git a/dotfiles/.config/quickshell/modules/bar/VpnIndicator.qml b/dotfiles/.config/quickshell/modules/bar/VpnIndicator.qml index 288ce5b..8cde9aa 100644 --- a/dotfiles/.config/quickshell/modules/bar/VpnIndicator.qml +++ b/dotfiles/.config/quickshell/modules/bar/VpnIndicator.qml @@ -316,7 +316,7 @@ Item { Text { id: label anchors.centerIn: parent - font.family: Appearance.iconFontFamily + font.family: Appearance.iconFontFamilyRounded font.variableAxes: Appearance.iconFontAxes font.pixelSize: Appearance.iconSize color: root.anyConnected ? Colors.primary : Colors.muted @@ -398,7 +398,7 @@ Item { Layout.maximumWidth: 20 horizontalAlignment: Text.AlignHCenter text: provRow.subIcon - font.family: Appearance.iconFontFamily + font.family: Appearance.iconFontFamilyRounded font.variableAxes: Appearance.iconFontAxes font.pixelSize: 14 color: provRow.state === "connected" ? Colors.primary : Colors.foreground @@ -493,7 +493,7 @@ Item { Text { text: "vpn_lock" - font.family: Appearance.iconFontFamily + font.family: Appearance.iconFontFamilyRounded font.variableAxes: Appearance.iconFontAxes font.pixelSize: 22 color: root.anyConnected ? Colors.primary : Colors.foreground diff --git a/dotfiles/.config/quickshell/modules/osd/VolumeOsd.qml b/dotfiles/.config/quickshell/modules/osd/VolumeOsd.qml index 1e32df5..52f6e68 100644 --- a/dotfiles/.config/quickshell/modules/osd/VolumeOsd.qml +++ b/dotfiles/.config/quickshell/modules/osd/VolumeOsd.qml @@ -53,7 +53,7 @@ PanelWindow { spacing: 12 Text { - font.family: Appearance.iconFontFamily + font.family: Appearance.iconFontFamilyRounded font.variableAxes: Appearance.iconFontAxes font.pixelSize: 18 color: Colors.foreground -- 2.49.1 From 57e5c0c7b397cefd74092f48b35b59883ece4b58 Mon Sep 17 00:00:00 2001 From: thomas Date: Thu, 27 Aug 2026 07:42:37 +0100 Subject: [PATCH 18/44] Fixes: removed extra padding on icon --- .../quickshell/modules/bar/BluetoothIndicator.qml | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/dotfiles/.config/quickshell/modules/bar/BluetoothIndicator.qml b/dotfiles/.config/quickshell/modules/bar/BluetoothIndicator.qml index 3b6e9ac..93ed5ae 100644 --- a/dotfiles/.config/quickshell/modules/bar/BluetoothIndicator.qml +++ b/dotfiles/.config/quickshell/modules/bar/BluetoothIndicator.qml @@ -7,7 +7,15 @@ import "../../config" Item { id: root - implicitWidth: label.implicitWidth + 4 + // Independent left/right breathing room around the glyph. Material + // Symbols icons aren't monospaced — each one's font-reported advance + // width (label.implicitWidth) bakes in its own side bearings, so the + // same "+4, centered" math that every other bar icon uses can still + // look tighter/looser icon-to-icon. Tune these two by eye instead of + // the icon's own bounding box to even it out against its neighbours. + property real iconLeftPadding: 0 + property real iconRightPadding: 0 + implicitWidth: root.iconLeftPadding + label.implicitWidth + root.iconRightPadding implicitHeight: Appearance.barHeight // The bar's own PanelWindow — passed in from Bar.qml so this popup can @@ -128,7 +136,9 @@ Item { Text { id: label - anchors.centerIn: parent + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.leftMargin: root.iconLeftPadding font.family: Appearance.iconFontFamilyRounded font.variableAxes: Appearance.iconFontAxes font.pixelSize: 16 -- 2.49.1 From 90d068a87803f66b8951ed22071b1434777f318b Mon Sep 17 00:00:00 2001 From: thomas Date: Thu, 27 Aug 2026 07:48:27 +0100 Subject: [PATCH 19/44] Fix: fixed tailscale logo sizing to match others --- .../.config/quickshell/modules/bar/TailscaleIndicator.qml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/dotfiles/.config/quickshell/modules/bar/TailscaleIndicator.qml b/dotfiles/.config/quickshell/modules/bar/TailscaleIndicator.qml index 49ea8a3..8420ae2 100644 --- a/dotfiles/.config/quickshell/modules/bar/TailscaleIndicator.qml +++ b/dotfiles/.config/quickshell/modules/bar/TailscaleIndicator.qml @@ -432,8 +432,8 @@ Item { model: [0, 0, 0, 1, 1, 1, 0, 1, 0] Rectangle { required property int modelData - width: logo.dotSize - height: logo.dotSize + width: logo.dotSize - 1 + height: logo.dotSize - 1 radius: width / 2 color: modelData ? '#c9f0f0f0' : logo.dimColor } @@ -838,7 +838,7 @@ Item { spacing: 8 TailscaleLogo { - size: 30 + size: 25 color: label.color crossed: label.crossed warning: label.warning -- 2.49.1 From 7a1073646a29253e3f1d3d1e3ae6875d014b792c Mon Sep 17 00:00:00 2001 From: thomas Date: Thu, 27 Aug 2026 07:49:36 +0100 Subject: [PATCH 20/44] Fix: changed sizing of logo to be consistant --- dotfiles/.config/quickshell/modules/bar/BluetoothIndicator.qml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotfiles/.config/quickshell/modules/bar/BluetoothIndicator.qml b/dotfiles/.config/quickshell/modules/bar/BluetoothIndicator.qml index 93ed5ae..3009b17 100644 --- a/dotfiles/.config/quickshell/modules/bar/BluetoothIndicator.qml +++ b/dotfiles/.config/quickshell/modules/bar/BluetoothIndicator.qml @@ -347,7 +347,7 @@ Item { text: label.text font.family: Appearance.iconFontFamilyRounded font.variableAxes: Appearance.iconFontAxes - font.pixelSize: 30 + font.pixelSize: 23 color: Colors.foreground } -- 2.49.1 From fb79f0a37e8291c28ad668709a12f4e84c6d6987 Mon Sep 17 00:00:00 2001 From: thomas Date: Thu, 27 Aug 2026 08:12:00 +0100 Subject: [PATCH 21/44] Fix: corrected wifi sizing --- dotfiles/.config/quickshell/modules/bar/NetworkIndicator.qml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dotfiles/.config/quickshell/modules/bar/NetworkIndicator.qml b/dotfiles/.config/quickshell/modules/bar/NetworkIndicator.qml index 357effb..2de87df 100644 --- a/dotfiles/.config/quickshell/modules/bar/NetworkIndicator.qml +++ b/dotfiles/.config/quickshell/modules/bar/NetworkIndicator.qml @@ -621,11 +621,12 @@ Item { Layout.preferredWidth: 20 Layout.minimumWidth: 20 Layout.maximumWidth: 20 + Layout.alignment: Qt.AlignVCenter horizontalAlignment: Text.AlignLeft text: root.wifiIcon(netRow.net.signal) font.family: Appearance.iconFontFamilyRounded font.variableAxes: Appearance.iconFontAxes - font.pixelSize: 14 + font.pixelSize: 16 color: rowMouse.containsMouse ? Colors.primaryText : Colors.foreground // The wifi-fan glyph itself is flat/wide — stretch it // vertically to read taller while keeping its width (and -- 2.49.1 From 3184c612677f3f3e10760538b819bfdea30e5edd Mon Sep 17 00:00:00 2001 From: thomas Date: Thu, 27 Aug 2026 08:16:51 +0100 Subject: [PATCH 22/44] Fix: increased icon size --- dotfiles/.config/quickshell/modules/bar/Volume.qml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dotfiles/.config/quickshell/modules/bar/Volume.qml b/dotfiles/.config/quickshell/modules/bar/Volume.qml index 35d749c..3a44110 100644 --- a/dotfiles/.config/quickshell/modules/bar/Volume.qml +++ b/dotfiles/.config/quickshell/modules/bar/Volume.qml @@ -7,7 +7,7 @@ import "../../config" Item { id: root - implicitWidth: label.implicitWidth + 4 + implicitWidth: label.implicitWidth implicitHeight: Appearance.barHeight // The bar's own PanelWindow — passed in from Bar.qml so this popup can @@ -97,7 +97,7 @@ Item { anchors.centerIn: parent font.family: Appearance.iconFontFamilyRounded font.variableAxes: Appearance.iconFontAxes - font.pixelSize: 16 + font.pixelSize: 18 color: Colors.foreground text: (root.outputMuted || root.outputVolume <= 0) ? "volume_off" : (root.outputVolume < 0.5 ? "volume_down" : "volume_up") -- 2.49.1 From 44e73f5a9d7af63cf5dee159239e5fef05b10aaf Mon Sep 17 00:00:00 2001 From: thomas Date: Thu, 27 Aug 2026 08:17:37 +0100 Subject: [PATCH 23/44] Fix: corrected padding for bar icon --- dotfiles/.config/quickshell/modules/bar/TailscaleIndicator.qml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotfiles/.config/quickshell/modules/bar/TailscaleIndicator.qml b/dotfiles/.config/quickshell/modules/bar/TailscaleIndicator.qml index 8420ae2..e98f281 100644 --- a/dotfiles/.config/quickshell/modules/bar/TailscaleIndicator.qml +++ b/dotfiles/.config/quickshell/modules/bar/TailscaleIndicator.qml @@ -7,7 +7,7 @@ import "../../config" Item { id: root - implicitWidth: label.implicitWidth + 4 + implicitWidth: label.implicitWidth implicitHeight: Appearance.barHeight // The bar's own PanelWindow — passed in from Bar.qml so this popup can -- 2.49.1 From 4bdb590899cc1c4da0f96d1787e55d99895a65fe Mon Sep 17 00:00:00 2001 From: thomas Date: Thu, 27 Aug 2026 08:48:08 +0100 Subject: [PATCH 24/44] Feature: added connected indicator to VPN --- .../quickshell/modules/bar/VpnIndicator.qml | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/dotfiles/.config/quickshell/modules/bar/VpnIndicator.qml b/dotfiles/.config/quickshell/modules/bar/VpnIndicator.qml index 8cde9aa..98e8b75 100644 --- a/dotfiles/.config/quickshell/modules/bar/VpnIndicator.qml +++ b/dotfiles/.config/quickshell/modules/bar/VpnIndicator.qml @@ -313,6 +313,13 @@ Item { // --- Bar icon ------------------------------------------------------------ + // A fixed, wallpaper-independent green rather than Colors.primary — the + // glyph itself doesn't change shape between states (unlike Tailscale's + // crossed-out logo), so "connected" needs to read as a shape cue (a + // badge appearing) rather than rely on a color swap that can end up + // low-contrast depending on the current pywal palette. + readonly property color connectedBadgeColor: "#3ddc84" + Text { id: label anchors.centerIn: parent @@ -323,6 +330,23 @@ Item { text: "vpn_lock" } + // Small solid dot badge, bottom-right of the glyph, shown only while a + // tunnel is actually up — a shape appearing/disappearing reads at a + // glance even when the icon tint alone wouldn't stand out. + Rectangle { + visible: root.anyConnected + width: 6 + height: 6 + radius: 3 + color: root.connectedBadgeColor + border.color: Colors.background + border.width: 1 + anchors.right: label.right + anchors.bottom: label.bottom + anchors.rightMargin: 0 + anchors.bottomMargin: 1 + } + MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor @@ -524,7 +548,7 @@ Item { ProviderRow { label: "Mullvad" - subIcon: "shield" + subIcon: "gpp_good" state: root.mullvadState detailText: root.mullvadDetail installed: root.mullvadInstalled -- 2.49.1 From 00d7dcb411f78a415517c61c108f142dfc5df5b2 Mon Sep 17 00:00:00 2001 From: thomas Date: Thu, 27 Aug 2026 08:50:29 +0100 Subject: [PATCH 25/44] Fix: changed icon for power --- dotfiles/.config/quickshell/modules/bar/PowerButton.qml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dotfiles/.config/quickshell/modules/bar/PowerButton.qml b/dotfiles/.config/quickshell/modules/bar/PowerButton.qml index 4bd8531..fcdd225 100644 --- a/dotfiles/.config/quickshell/modules/bar/PowerButton.qml +++ b/dotfiles/.config/quickshell/modules/bar/PowerButton.qml @@ -7,7 +7,7 @@ import "../../config" Item { id: root - implicitWidth: label.implicitWidth + 4 + implicitWidth: label.implicitWidth + 2 implicitHeight: Appearance.barHeight // The bar's own PanelWindow — passed in from Bar.qml so this popup can @@ -23,7 +23,7 @@ Item { font.variableAxes: Appearance.iconFontAxes font.pixelSize: 16 color: Colors.foreground - text: "power_settings_new" + text: "settings_power" } MouseArea { -- 2.49.1 From 00e4368abe3d414f19210004a4610083dba989ea Mon Sep 17 00:00:00 2001 From: thomas Date: Thu, 27 Aug 2026 09:18:42 +0100 Subject: [PATCH 26/44] Fix: corrected icons --- dotfiles/.config/quickshell/modules/bar/BluetoothIndicator.qml | 2 +- dotfiles/.config/quickshell/modules/bar/PowerButton.qml | 2 +- dotfiles/.config/quickshell/modules/bar/Volume.qml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/dotfiles/.config/quickshell/modules/bar/BluetoothIndicator.qml b/dotfiles/.config/quickshell/modules/bar/BluetoothIndicator.qml index 3009b17..7002f67 100644 --- a/dotfiles/.config/quickshell/modules/bar/BluetoothIndicator.qml +++ b/dotfiles/.config/quickshell/modules/bar/BluetoothIndicator.qml @@ -347,7 +347,7 @@ Item { text: label.text font.family: Appearance.iconFontFamilyRounded font.variableAxes: Appearance.iconFontAxes - font.pixelSize: 23 + font.pixelSize: 21 color: Colors.foreground } diff --git a/dotfiles/.config/quickshell/modules/bar/PowerButton.qml b/dotfiles/.config/quickshell/modules/bar/PowerButton.qml index fcdd225..9478137 100644 --- a/dotfiles/.config/quickshell/modules/bar/PowerButton.qml +++ b/dotfiles/.config/quickshell/modules/bar/PowerButton.qml @@ -93,7 +93,7 @@ Item { text: label.text font.family: Appearance.iconFontFamilyRounded font.variableAxes: Appearance.iconFontAxes - font.pixelSize: 30 + font.pixelSize: 23 color: Colors.foreground } diff --git a/dotfiles/.config/quickshell/modules/bar/Volume.qml b/dotfiles/.config/quickshell/modules/bar/Volume.qml index 3a44110..f77b285 100644 --- a/dotfiles/.config/quickshell/modules/bar/Volume.qml +++ b/dotfiles/.config/quickshell/modules/bar/Volume.qml @@ -327,7 +327,7 @@ Item { text: label.text font.family: Appearance.iconFontFamilyRounded font.variableAxes: Appearance.iconFontAxes - font.pixelSize: 40 + font.pixelSize: 30 color: Colors.foreground } -- 2.49.1 From cede2a16900a6e9c9756401914e36a6b7765a952 Mon Sep 17 00:00:00 2001 From: thomas Date: Thu, 27 Aug 2026 09:18:57 +0100 Subject: [PATCH 27/44] Feature: Password input --- .../modules/bar/NetworkIndicator.qml | 193 ++++++++++++------ 1 file changed, 131 insertions(+), 62 deletions(-) diff --git a/dotfiles/.config/quickshell/modules/bar/NetworkIndicator.qml b/dotfiles/.config/quickshell/modules/bar/NetworkIndicator.qml index 2de87df..f2ef2cd 100644 --- a/dotfiles/.config/quickshell/modules/bar/NetworkIndicator.qml +++ b/dotfiles/.config/quickshell/modules/bar/NetworkIndicator.qml @@ -44,7 +44,13 @@ Item { return null } - // Only scan for networks while the panel is actually open. + // Only scan for networks while the panel is actually open. Deliberately + // NOT paused while a password prompt is up: scannerEnabled: false stops + // NetworkManager from refreshing its AP cache at all, so the existing + // list quietly ages out with nothing to replace it — the fix for typing + // being interrupted by scan-driven refreshes lives below instead (the + // password field itself is pulled out of the repeated row so refreshes + // can't touch it), not here. Binding { target: root.wifiDevice property: "scannerEnabled" @@ -135,8 +141,17 @@ Item { } } - // SSID of the row currently showing its inline password field, if any. + // SSID awaiting a password, if any. The input itself lives in a single + // persistent widget below the network list (see passwordEntryInput), + // not inside the per-row NetworkRow delegate — wifiRows is rebuilt from + // scratch on every scan result, and a Repeater over a plain array has no + // way to diff that, so it destroys and recreates every delegate each + // time. Keeping the field outside that churn is what lets typing survive + // a scan landing mid-keystroke. property string passwordSsid: "" + // Cleared whenever the target SSID changes so stale text from a + // cancelled/previous prompt never carries over to the next one. + onPasswordSsidChanged: passwordEntryInput.text = "" // Wired connections don't have a user-facing "name" on the device or // Network object — those just expose the interface name (e.g. "eth0"). @@ -179,6 +194,16 @@ Item { } } + function submitPassword() { + const n = root.networkForSsid(root.passwordSsid) + if (n && passwordEntryInput.text.length > 0) n.connectWithPsk(passwordEntryInput.text) + root.passwordSsid = "" + } + + function cancelPassword() { + root.passwordSsid = "" + } + // --- Live stats -------------------------------------------------- // None of this comes from Quickshell's Networking API — it only knows // about devices/networks, not throughput, ping, or routing. So this @@ -529,13 +554,16 @@ Item { font.variableAxes: Appearance.iconFontAxes font.pixelSize: 16 color: root.connectedDevice ? Colors.foreground : Colors.muted - // Wired/disconnected use their own distinct glyphs; only the WiFi - // case renders one of wifiIcon()'s flat/wide wifi-fan glyphs, so - // only that case needs the vertical-stretch correction below. - readonly property bool isWifiFan: root.connectedDevice !== null && root.connectedDevice.type !== DeviceType.Wired + // Only Wired renders its own distinct glyph; disconnected uses + // "signal_wifi_off" — the actual off companion to the + // network_wifi/signal_wifi fan family used below (not "wifi_off", + // which belongs to a visually different, newer glyph family and + // looked inconsistent next to the connected bars) — so it needs the + // same vertical-stretch correction as the rest of that family. + readonly property bool isWifiFan: root.connectedDevice === null || root.connectedDevice.type !== DeviceType.Wired text: { if (!root.connectedDevice) - return "wifi_off" + return "signal_wifi_off" if (root.connectedDevice.type === DeviceType.Wired) return "settings_ethernet" return root.wifiIcon(root.connectedWifiSignal) @@ -593,20 +621,17 @@ Item { id: netRow required property var net signal activate() + // Still tracked for the highlight below — the actual input lives + // outside this (repeatedly rebuilt) delegate, in the persistent + // password entry widget under the network list. readonly property bool passwordOpen: root.passwordSsid === netRow.net.ssid width: parent ? parent.width : 0 radius: 0 - implicitHeight: rowContent.implicitHeight + 12 + (passwordOpen ? passwordRow.implicitHeight + 6 : 0) - color: netRow.net.connected + implicitHeight: rowContent.implicitHeight + 12 + color: (netRow.net.connected || netRow.passwordOpen) ? Qt.rgba(Colors.primary.r, Colors.primary.g, Colors.primary.b, 0.18) : (rowMouse.containsMouse ? Colors.primary : "transparent") - function submit() { - const n = root.networkForSsid(netRow.net.ssid) - if (n && passwordInput.text.length > 0) n.connectWithPsk(passwordInput.text) - root.passwordSsid = "" - } - Column { anchors.fill: parent anchors.margins: 6 @@ -676,53 +701,6 @@ Item { color: rowMouse.containsMouse ? Colors.primaryText : Colors.muted } } - - RowLayout { - id: passwordRow - visible: netRow.passwordOpen - width: parent.width - spacing: 6 - - Rectangle { - Layout.fillWidth: true - implicitHeight: 24 - radius: 0 - color: Colors.background - border.color: Colors.muted - border.width: 1 - - TextInput { - id: passwordInput - anchors.fill: parent - anchors.margins: 6 - echoMode: TextInput.Password - color: Colors.foreground - font.family: Appearance.fontFamily - font.pixelSize: Appearance.fontSizeSmall - clip: true - focus: netRow.passwordOpen - onAccepted: netRow.submit() - // Explicit fallback alongside onAccepted, which only - // fires when acceptableInput is true — covers both - // the main Return key and numpad Enter directly. - Keys.onReturnPressed: netRow.submit() - Keys.onEnterPressed: netRow.submit() - } - } - - Text { - text: "check" - font.family: Appearance.iconFontFamilyRounded - font.variableAxes: Appearance.iconFontAxes - font.pixelSize: 16 - color: Colors.primary - MouseArea { - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - onClicked: netRow.submit() - } - } - } } MouseArea { @@ -1080,6 +1058,97 @@ Item { color: Colors.muted } + // Password entry — a single persistent widget rather + // than living inside the NetworkRow delegate for + // root.passwordSsid, so a wifi scan landing mid-keystroke + // (which rebuilds the whole Known/Other Networks list + // below) can't destroy the focused TextInput and wipe + // out what's been typed. See passwordSsid's own comment. + Rectangle { + width: parent.width + visible: root.passwordSsid !== "" + radius: 0 + color: Colors.background + border.color: Colors.muted + border.width: 1 + implicitHeight: passwordEntryColumn.implicitHeight + 12 + + Column { + id: passwordEntryColumn + anchors.fill: parent + anchors.margins: 6 + spacing: 6 + + Text { + width: parent.width + text: "Password for “" + root.passwordSsid + "”" + elide: Text.ElideRight + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.muted + } + + RowLayout { + width: parent.width + spacing: 6 + + Rectangle { + Layout.fillWidth: true + implicitHeight: 24 + radius: 0 + color: Colors.surface + border.color: Colors.muted + border.width: 1 + + TextInput { + id: passwordEntryInput + anchors.fill: parent + anchors.margins: 6 + echoMode: TextInput.Password + color: Colors.foreground + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + clip: true + focus: root.passwordSsid !== "" + onAccepted: root.submitPassword() + // Explicit fallback alongside onAccepted, which only + // fires when acceptableInput is true — covers both + // the main Return key and numpad Enter directly. + Keys.onReturnPressed: root.submitPassword() + Keys.onEnterPressed: root.submitPassword() + Keys.onEscapePressed: root.cancelPassword() + } + } + + Text { + text: "check" + font.family: Appearance.iconFontFamilyRounded + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 16 + color: Colors.primary + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: root.submitPassword() + } + } + + Text { + text: "close" + font.family: Appearance.iconFontFamilyRounded + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 16 + color: Colors.muted + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: root.cancelPassword() + } + } + } + } + } + // Known/Other Networks — capped height, scrolls once // there are more networks than fit rather than growing // the popup indefinitely. -- 2.49.1 From 86921f7aab741e39e15b76ff356205830cbc090e Mon Sep 17 00:00:00 2001 From: thomas Date: Thu, 27 Aug 2026 09:39:32 +0100 Subject: [PATCH 28/44] Feature: new selectors --- .../modules/bar/BluetoothIndicator.qml | 14 ++++++ .../modules/bar/NetworkIndicator.qml | 49 ++++++++++++++++++- .../modules/bar/TailscaleIndicator.qml | 14 ++++++ .../quickshell/modules/bar/VpnIndicator.qml | 16 ++++++ 4 files changed, 92 insertions(+), 1 deletion(-) diff --git a/dotfiles/.config/quickshell/modules/bar/BluetoothIndicator.qml b/dotfiles/.config/quickshell/modules/bar/BluetoothIndicator.qml index 7002f67..0b94c16 100644 --- a/dotfiles/.config/quickshell/modules/bar/BluetoothIndicator.qml +++ b/dotfiles/.config/quickshell/modules/bar/BluetoothIndicator.qml @@ -173,6 +173,18 @@ Item { color: toggleRoot.checked ? Colors.primary : Colors.muted opacity: toggleRoot.checked ? 1 : 0.35 } + // Hover-only outline, offset outside the switch itself rather than + // drawn on its edge — the switch lags behind a click until the + // backend confirms it (e.g. the adapter's enabled state), so this + // is just a "you can click this" cue, not click feedback. + Rectangle { + anchors.fill: parent + anchors.margins: -4 + radius: 0 + color: "transparent" + border.color: Colors.foreground + border.width: toggleMouse.containsMouse ? 1 : 0 + } Rectangle { width: parent.height - 4 height: parent.height - 4 @@ -183,7 +195,9 @@ Item { Behavior on x { NumberAnimation { duration: Appearance.animationDuration } } } MouseArea { + id: toggleMouse anchors.fill: parent + hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: toggleRoot.toggled() } diff --git a/dotfiles/.config/quickshell/modules/bar/NetworkIndicator.qml b/dotfiles/.config/quickshell/modules/bar/NetworkIndicator.qml index f2ef2cd..7347b3b 100644 --- a/dotfiles/.config/quickshell/modules/bar/NetworkIndicator.qml +++ b/dotfiles/.config/quickshell/modules/bar/NetworkIndicator.qml @@ -601,6 +601,18 @@ Item { color: toggleRoot.checked ? Colors.primary : Colors.muted opacity: toggleRoot.checked ? 1 : 0.35 } + // Hover-only outline, offset outside the switch itself rather than + // drawn on its edge — the switch lags behind a click until the + // backend confirms it (e.g. Networking.wifiEnabled), so this is just + // a "you can click this" cue, not click feedback. + Rectangle { + anchors.fill: parent + anchors.margins: -4 + radius: 0 + color: "transparent" + border.color: Colors.foreground + border.width: toggleMouse.containsMouse ? 1 : 0 + } Rectangle { width: parent.height - 4 height: parent.height - 4 @@ -611,7 +623,9 @@ Item { Behavior on x { NumberAnimation { duration: Appearance.animationDuration } } } MouseArea { + id: toggleMouse anchors.fill: parent + hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: toggleRoot.toggled() } @@ -631,6 +645,22 @@ Item { color: (netRow.net.connected || netRow.passwordOpen) ? Qt.rgba(Colors.primary.r, Colors.primary.g, Colors.primary.b, 0.18) : (rowMouse.containsMouse ? Colors.primary : "transparent") + border.color: (netRow.net.connected || netRow.passwordOpen) ? Colors.primary : Colors.muted + border.width: 1 + + // Hover-only outline, offset outside the row rather than drawn on + // its own edge — same treatment as ToggleSwitch's halo and the DNS + // pills. networkListColumn insets its rows by 3px on each side + // specifically to give this room without getting cropped by the + // Flickable's clip. + Rectangle { + anchors.fill: parent + anchors.margins: -3 + radius: 0 + color: "transparent" + border.color: Colors.foreground + border.width: rowMouse.containsMouse ? 1 : 0 + } Column { anchors.fill: parent @@ -872,6 +902,18 @@ Item { border.width: 1 opacity: root.dnsBusy ? 0.6 : 1 + // Hover-only outline, offset outside the pill + // itself rather than drawn on its own border — + // same treatment as ToggleSwitch's halo. + Rectangle { + anchors.fill: parent + anchors.margins: -3 + radius: 0 + color: "transparent" + border.color: Colors.foreground + border.width: dnsMouse.containsMouse ? 1 : 0 + } + Text { anchors.centerIn: parent text: dnsPill.modelData @@ -1164,7 +1206,12 @@ Item { Column { id: networkListColumn - width: networkListFlick.width + // Inset 3px on each side (rather than flush with + // the Flickable) so NetworkRow's hover halo below + // has room to sit outside the row without being + // cropped by the Flickable's own clip. + x: 3 + width: networkListFlick.width - 6 spacing: 6 Column { diff --git a/dotfiles/.config/quickshell/modules/bar/TailscaleIndicator.qml b/dotfiles/.config/quickshell/modules/bar/TailscaleIndicator.qml index e98f281..5b9f47d 100644 --- a/dotfiles/.config/quickshell/modules/bar/TailscaleIndicator.qml +++ b/dotfiles/.config/quickshell/modules/bar/TailscaleIndicator.qml @@ -505,6 +505,18 @@ Item { color: toggleRoot.checked ? Colors.primary : Colors.muted opacity: toggleRoot.checked ? 1 : 0.35 } + // Hover-only outline, offset outside the switch itself rather than + // drawn on its edge — the switch lags behind a click until the + // backend confirms it (e.g. root.active), so this is just a "you + // can click this" cue, not click feedback. + Rectangle { + anchors.fill: parent + anchors.margins: -4 + radius: 0 + color: "transparent" + border.color: Colors.foreground + border.width: toggleMouse.containsMouse ? 1 : 0 + } Rectangle { width: parent.height - 4 height: parent.height - 4 @@ -515,7 +527,9 @@ Item { Behavior on x { NumberAnimation { duration: Appearance.animationDuration } } } MouseArea { + id: toggleMouse anchors.fill: parent + hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: toggleRoot.toggled() } diff --git a/dotfiles/.config/quickshell/modules/bar/VpnIndicator.qml b/dotfiles/.config/quickshell/modules/bar/VpnIndicator.qml index 98e8b75..aa0d93f 100644 --- a/dotfiles/.config/quickshell/modules/bar/VpnIndicator.qml +++ b/dotfiles/.config/quickshell/modules/bar/VpnIndicator.qml @@ -373,6 +373,20 @@ Item { color: toggleRoot.checked ? Colors.primary : Colors.muted opacity: (toggleRoot.checked ? 1 : 0.35) * (toggleRoot.busy ? 0.6 : 1) } + // Hover-only outline, offset outside the switch itself rather than + // drawn on its edge — the switch lags behind a click until the + // connect/disconnect process actually finishes, so this is just a + // "you can click this" cue, not click feedback. toggleMouse.containsMouse + // is already false while busy, since a disabled MouseArea doesn't + // track hover. + Rectangle { + anchors.fill: parent + anchors.margins: -4 + radius: 0 + color: "transparent" + border.color: Colors.foreground + border.width: toggleMouse.containsMouse ? 1 : 0 + } Rectangle { width: parent.height - 4 height: parent.height - 4 @@ -383,8 +397,10 @@ Item { Behavior on x { NumberAnimation { duration: Appearance.animationDuration } } } MouseArea { + id: toggleMouse anchors.fill: parent enabled: !toggleRoot.busy + hoverEnabled: true cursorShape: toggleRoot.busy ? Qt.ArrowCursor : Qt.PointingHandCursor onClicked: toggleRoot.toggled() } -- 2.49.1 From 8515f3f5ce360b50a33f4c3f84523b67a0b0690a Mon Sep 17 00:00:00 2001 From: thomas Date: Thu, 27 Aug 2026 10:22:51 +0100 Subject: [PATCH 29/44] Fix: Added borders to selectors --- .../modules/bar/TailscaleIndicator.qml | 69 +++++++++++++++++-- 1 file changed, 65 insertions(+), 4 deletions(-) diff --git a/dotfiles/.config/quickshell/modules/bar/TailscaleIndicator.qml b/dotfiles/.config/quickshell/modules/bar/TailscaleIndicator.qml index 5b9f47d..ed70c8d 100644 --- a/dotfiles/.config/quickshell/modules/bar/TailscaleIndicator.qml +++ b/dotfiles/.config/quickshell/modules/bar/TailscaleIndicator.qml @@ -551,6 +551,28 @@ Item { ? Qt.rgba(Colors.primary.r, Colors.primary.g, Colors.primary.b, 0.18) : (rowMouse.containsMouse ? Colors.primary : "transparent") + // Colors.primaryText (used below whenever hovered) assumes a solid + // Colors.primary fill behind it — true for a hovered *inactive* row, + // but an *active* row's background stays the translucent tint above + // even while hovered, so primaryText (dark, meant for a bright solid + // fill) reads poorly against it. This hardcoded near-white (same one + // used for TailscaleLogo's bright dots, for the same "needs to be + // reliably visible" reason) is used instead for that one case. + readonly property color activeHoverColor: "#c9f0f0f0" + + // Hover-only outline, offset outside the row rather than drawn on + // its own edge — same treatment as NetworkIndicator.qml's NetworkRow. + // listColumn below is inset by 3px to give this room without being + // cropped by listFlick's clip. + Rectangle { + anchors.fill: parent + anchors.margins: -3 + radius: 0 + color: "transparent" + border.color: Colors.foreground + border.width: rowMouse.containsMouse ? 1 : 0 + } + RowLayout { id: rowContent anchors.left: parent.left @@ -568,7 +590,7 @@ Item { font.family: Appearance.iconFontFamilyRounded font.variableAxes: Appearance.iconFontAxes font.pixelSize: 14 - color: rowMouse.containsMouse ? Colors.primaryText : Colors.foreground + color: rowMouse.containsMouse ? (exitRow.active ? exitRow.activeHoverColor : Colors.primaryText) : Colors.foreground } Text { Layout.fillWidth: true @@ -577,7 +599,7 @@ Item { font.family: Appearance.fontFamily font.pixelSize: Appearance.fontSize font.bold: exitRow.active - color: rowMouse.containsMouse ? Colors.primaryText : Colors.foreground + color: rowMouse.containsMouse ? (exitRow.active ? exitRow.activeHoverColor : Colors.primaryText) : Colors.foreground } Text { visible: exitRow.active @@ -585,7 +607,7 @@ Item { font.family: Appearance.iconFontFamilyRounded font.variableAxes: Appearance.iconFontAxes font.pixelSize: 14 - color: rowMouse.containsMouse ? Colors.primaryText : Colors.primary + color: rowMouse.containsMouse ? exitRow.activeHoverColor : Colors.primary } } @@ -613,6 +635,18 @@ Item { ? Qt.rgba(Colors.primary.r, Colors.primary.g, Colors.primary.b, 0.18) : (accountMouse.containsMouse ? Colors.primary : "transparent") + // Hover-only outline, offset outside the row — same treatment as + // ExitNodeRow/NetworkRow. Not inside a clipped Flickable, so no + // inset needed on its container. + Rectangle { + anchors.fill: parent + anchors.margins: -3 + radius: 0 + color: "transparent" + border.color: Colors.foreground + border.width: accountMouse.containsMouse ? 1 : 0 + } + RowLayout { id: accountContent anchors.left: parent.left @@ -694,6 +728,18 @@ Item { : (peerRow.expanded ? Qt.rgba(Colors.primary.r, Colors.primary.g, Colors.primary.b, 0.18) : "transparent") implicitHeight: peerContent.implicitHeight + 12 + // Hover-only outline, offset outside the row — same treatment as + // ExitNodeRow/NetworkRow. listColumn is inset by 3px to give this + // room without being cropped by listFlick's clip. + Rectangle { + anchors.fill: parent + anchors.margins: -3 + radius: 0 + color: "transparent" + border.color: Colors.foreground + border.width: summaryMouse.containsMouse ? 1 : 0 + } + RowLayout { id: peerContent anchors.left: parent.left @@ -753,6 +799,17 @@ Item { color: optionMouse.containsMouse ? Colors.primary : "transparent" implicitHeight: optionRow.implicitHeight + 8 + // Hover-only outline, offset outside the row — same + // treatment as the rest of this file's selectable rows. + Rectangle { + anchors.fill: parent + anchors.margins: -3 + radius: 0 + color: "transparent" + border.color: Colors.foreground + border.width: optionMouse.containsMouse ? 1 : 0 + } + RowLayout { id: optionRow anchors.left: parent.left @@ -1040,7 +1097,11 @@ Item { Column { id: listColumn - width: listFlick.width + // Inset 3px on each side so ExitNodeRow/PeerRow's + // hover halo has room without being cropped by + // listFlick's clip. + x: 3 + width: listFlick.width - 6 spacing: 6 Column { -- 2.49.1 From 185b75239330436f6d419b5cbc3efedcf41c226b Mon Sep 17 00:00:00 2001 From: thomas Date: Thu, 27 Aug 2026 10:23:13 +0100 Subject: [PATCH 30/44] Fix: removed previous border --- dotfiles/.config/quickshell/modules/bar/NetworkIndicator.qml | 2 -- 1 file changed, 2 deletions(-) diff --git a/dotfiles/.config/quickshell/modules/bar/NetworkIndicator.qml b/dotfiles/.config/quickshell/modules/bar/NetworkIndicator.qml index 7347b3b..266d893 100644 --- a/dotfiles/.config/quickshell/modules/bar/NetworkIndicator.qml +++ b/dotfiles/.config/quickshell/modules/bar/NetworkIndicator.qml @@ -645,8 +645,6 @@ Item { color: (netRow.net.connected || netRow.passwordOpen) ? Qt.rgba(Colors.primary.r, Colors.primary.g, Colors.primary.b, 0.18) : (rowMouse.containsMouse ? Colors.primary : "transparent") - border.color: (netRow.net.connected || netRow.passwordOpen) ? Colors.primary : Colors.muted - border.width: 1 // Hover-only outline, offset outside the row rather than drawn on // its own edge — same treatment as ToggleSwitch's halo and the DNS -- 2.49.1 From c8a9c30fc50421acf82dd578b9e85b34828f8970 Mon Sep 17 00:00:00 2001 From: thomas Date: Thu, 27 Aug 2026 10:45:36 +0100 Subject: [PATCH 31/44] Fix: hover button fix --- .../modules/bar/NetworkIndicator.qml | 31 ++++++++++++++----- .../modules/bar/TailscaleIndicator.qml | 11 +++++-- 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/dotfiles/.config/quickshell/modules/bar/NetworkIndicator.qml b/dotfiles/.config/quickshell/modules/bar/NetworkIndicator.qml index 266d893..cb27faa 100644 --- a/dotfiles/.config/quickshell/modules/bar/NetworkIndicator.qml +++ b/dotfiles/.config/quickshell/modules/bar/NetworkIndicator.qml @@ -645,6 +645,16 @@ Item { color: (netRow.net.connected || netRow.passwordOpen) ? Qt.rgba(Colors.primary.r, Colors.primary.g, Colors.primary.b, 0.18) : (rowMouse.containsMouse ? Colors.primary : "transparent") + readonly property bool active: netRow.net.connected || netRow.passwordOpen + + // Colors.primaryText (used below whenever hovered) assumes a solid + // Colors.primary fill behind it — true for a hovered *inactive* row, + // but an active row's background stays the translucent tint above + // even while hovered, so primaryText (dark, meant for a bright solid + // fill) reads poorly against it. This hardcoded near-white (same one + // TailscaleIndicator.qml uses for the same reason) is used instead + // for that one case. + readonly property color activeHoverColor: "#c9f0f0f0" // Hover-only outline, offset outside the row rather than drawn on // its own edge — same treatment as ToggleSwitch's halo and the DNS @@ -680,7 +690,7 @@ Item { font.family: Appearance.iconFontFamilyRounded font.variableAxes: Appearance.iconFontAxes font.pixelSize: 16 - color: rowMouse.containsMouse ? Colors.primaryText : Colors.foreground + color: rowMouse.containsMouse ? (netRow.active ? netRow.activeHoverColor : Colors.primaryText) : Colors.foreground // The wifi-fan glyph itself is flat/wide — stretch it // vertically to read taller while keeping its width (and // the layout around it) unchanged. @@ -701,14 +711,14 @@ Item { font.family: Appearance.fontFamily font.pixelSize: Appearance.fontSize font.bold: netRow.net.connected - color: rowMouse.containsMouse ? Colors.primaryText : Colors.foreground + color: rowMouse.containsMouse ? (netRow.active ? netRow.activeHoverColor : Colors.primaryText) : Colors.foreground } Text { visible: netRow.net.connected text: "Connected" font.family: Appearance.fontFamily font.pixelSize: Appearance.fontSizeSmall - color: rowMouse.containsMouse ? Colors.primaryText : Colors.foreground + color: rowMouse.containsMouse ? (netRow.active ? netRow.activeHoverColor : Colors.primaryText) : Colors.foreground } } Text { @@ -717,7 +727,7 @@ Item { font.family: Appearance.iconFontFamilyRounded font.variableAxes: Appearance.iconFontAxes font.pixelSize: 13 - color: rowMouse.containsMouse ? Colors.primaryText : Colors.muted + color: rowMouse.containsMouse ? (netRow.active ? netRow.activeHoverColor : Colors.primaryText) : Colors.muted } Text { id: forgetIcon @@ -726,7 +736,7 @@ Item { font.family: Appearance.iconFontFamilyRounded font.variableAxes: Appearance.iconFontAxes font.pixelSize: 14 - color: rowMouse.containsMouse ? Colors.primaryText : Colors.muted + color: rowMouse.containsMouse ? (netRow.active ? netRow.activeHoverColor : Colors.primaryText) : Colors.muted } } } @@ -1204,12 +1214,19 @@ Item { Column { id: networkListColumn - // Inset 3px on each side (rather than flush with + // Inset 3px on every side (rather than flush with // the Flickable) so NetworkRow's hover halo below // has room to sit outside the row without being - // cropped by the Flickable's own clip. + // cropped by the Flickable's own clip. Left/right + // needs the manual x/width shift (children bind + // width: parent.width, which ignores Column's own + // padding); top/bottom just needs padding, since + // that's along Column's own layout axis and the + // Flickable's contentHeight tracks it. x: 3 width: networkListFlick.width - 6 + topPadding: 3 + bottomPadding: 3 spacing: 6 Column { diff --git a/dotfiles/.config/quickshell/modules/bar/TailscaleIndicator.qml b/dotfiles/.config/quickshell/modules/bar/TailscaleIndicator.qml index ed70c8d..ed23d33 100644 --- a/dotfiles/.config/quickshell/modules/bar/TailscaleIndicator.qml +++ b/dotfiles/.config/quickshell/modules/bar/TailscaleIndicator.qml @@ -1097,11 +1097,18 @@ Item { Column { id: listColumn - // Inset 3px on each side so ExitNodeRow/PeerRow's + // Inset 3px on every side so ExitNodeRow/PeerRow's // hover halo has room without being cropped by - // listFlick's clip. + // listFlick's clip. Left/right needs the manual + // x/width shift (children bind width: parent.width, + // which ignores Column's own padding); top/bottom + // just needs padding, since that's along Column's + // own layout axis and Flickable's contentHeight + // tracks it. x: 3 width: listFlick.width - 6 + topPadding: 3 + bottomPadding: 3 spacing: 6 Column { -- 2.49.1 From ba8a565cda09fc020bcfed62628688c58fb1c721 Mon Sep 17 00:00:00 2001 From: thomas Date: Thu, 27 Aug 2026 10:45:50 +0100 Subject: [PATCH 32/44] Feature: added scanner indicator --- .../modules/bar/BluetoothIndicator.qml | 71 +++++++++++++++++-- 1 file changed, 65 insertions(+), 6 deletions(-) diff --git a/dotfiles/.config/quickshell/modules/bar/BluetoothIndicator.qml b/dotfiles/.config/quickshell/modules/bar/BluetoothIndicator.qml index 0b94c16..2f7235f 100644 --- a/dotfiles/.config/quickshell/modules/bar/BluetoothIndicator.qml +++ b/dotfiles/.config/quickshell/modules/bar/BluetoothIndicator.qml @@ -217,6 +217,29 @@ Item { ? Qt.rgba(Colors.primary.r, Colors.primary.g, Colors.primary.b, 0.18) : (rowMouse.containsMouse ? Colors.primary : "transparent") + // Colors.primaryText (used below whenever hovered) assumes a solid + // Colors.primary fill behind it — true for a hovered *disconnected* + // row, but a connected row's background stays the translucent tint + // above even while hovered, so primaryText (dark, meant for a bright + // solid fill) reads poorly against it. This hardcoded near-white + // (same one NetworkIndicator.qml/TailscaleIndicator.qml use for the + // same reason) is used instead for that one case. + readonly property color activeHoverColor: "#c9f0f0f0" + + // Hover-only outline, offset outside the row rather than drawn on + // its own edge — same treatment as NetworkIndicator.qml's NetworkRow. + // The lists this feeds are inset by 3px (see deviceListColumn and + // the Connected section below) so this has room without being + // cropped by deviceListFlick's clip. + Rectangle { + anchors.fill: parent + anchors.margins: -3 + radius: 0 + color: "transparent" + border.color: Colors.foreground + border.width: rowMouse.containsMouse ? 1 : 0 + } + RowLayout { id: rowContent anchors.left: parent.left @@ -234,7 +257,7 @@ Item { font.family: Appearance.iconFontFamilyRounded font.variableAxes: Appearance.iconFontAxes font.pixelSize: 14 - color: rowMouse.containsMouse ? Colors.primaryText : Colors.foreground + color: rowMouse.containsMouse ? (devRow.dev.connected ? devRow.activeHoverColor : Colors.primaryText) : Colors.foreground } ColumnLayout { @@ -248,7 +271,7 @@ Item { font.family: Appearance.fontFamily font.pixelSize: Appearance.fontSize font.bold: devRow.dev.connected - color: rowMouse.containsMouse ? Colors.primaryText : Colors.foreground + color: rowMouse.containsMouse ? (devRow.dev.connected ? devRow.activeHoverColor : Colors.primaryText) : Colors.foreground } Text { readonly property string statusText: { @@ -262,7 +285,7 @@ Item { text: statusText font.family: Appearance.fontFamily font.pixelSize: Appearance.fontSizeSmall - color: rowMouse.containsMouse ? Colors.primaryText : Colors.muted + color: rowMouse.containsMouse ? (devRow.dev.connected ? devRow.activeHoverColor : Colors.primaryText) : Colors.muted } } @@ -273,7 +296,7 @@ Item { font.family: Appearance.iconFontFamilyRounded font.variableAxes: Appearance.iconFontAxes font.pixelSize: 14 - color: rowMouse.containsMouse ? Colors.primaryText : Colors.muted + color: rowMouse.containsMouse ? (devRow.dev.connected ? devRow.activeHoverColor : Colors.primaryText) : Colors.muted } } @@ -374,6 +397,29 @@ Item { color: Colors.foreground } + // Spins continuously while root.adapter.discovering is + // true — a persistent "still searching" cue independent + // of the list itself, unlike the "Scanning for devices…" + // text below (which only shows while the device list is + // still empty). + Text { + id: scanSpinner + visible: root.adapter !== null && root.adapter.discovering + text: "progress_activity" + font.family: Appearance.iconFontFamilyRounded + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 16 + color: Colors.muted + + RotationAnimation on rotation { + running: scanSpinner.visible + loops: Animation.Infinite + from: 0 + to: 360 + duration: 900 + } + } + ToggleSwitch { visible: root.adapter !== null checked: root.adapter !== null && root.adapter.enabled @@ -413,7 +459,10 @@ Item { color: Colors.muted } Column { - width: parent.width + // Inset 3px on each side to match deviceListColumn + // below, so DeviceRow's hover halo has room here too. + x: 3 + width: parent.width - 6 spacing: 2 Repeater { model: root.connectedRows @@ -451,7 +500,17 @@ Item { Column { id: deviceListColumn - width: deviceListFlick.width + // Inset 3px on every side so DeviceRow's hover halo + // has room without being cropped by deviceListFlick's + // clip. Left/right needs the manual x/width shift + // (children bind width: parent.width, which ignores + // Column's own padding); top/bottom just needs + // padding, since that's along Column's own layout + // axis and Flickable's contentHeight tracks it. + x: 3 + width: deviceListFlick.width - 6 + topPadding: 3 + bottomPadding: 3 spacing: 6 Column { -- 2.49.1 From 03ea182c8c666c865865dda04be6873ae71b99a6 Mon Sep 17 00:00:00 2001 From: thomas Date: Thu, 27 Aug 2026 10:53:56 +0100 Subject: [PATCH 33/44] Feature: Added wifi scanning indicator --- .../modules/bar/NetworkIndicator.qml | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/dotfiles/.config/quickshell/modules/bar/NetworkIndicator.qml b/dotfiles/.config/quickshell/modules/bar/NetworkIndicator.qml index cb27faa..bf8d5a8 100644 --- a/dotfiles/.config/quickshell/modules/bar/NetworkIndicator.qml +++ b/dotfiles/.config/quickshell/modules/bar/NetworkIndicator.qml @@ -90,6 +90,24 @@ Item { ? sortWifiRows(root.wifiDevice.networks.values.map(root.wifiRow).filter(function(r) { return r && r.ssid })) : [] + // Quickshell's WifiDevice has no "a scan is in flight" signal — only + // scannerEnabled, the on/off request, which stays true for as long as + // the panel is open rather than pulsing per scan. wifiRows re-deriving + // to a new array (as it does on every actual network-list change — see + // the comment on wifiRow() above) is the closest available proxy for + // "a scan just landed", so pulse briefly off that instead. + property bool wifiJustScanned: false + onWifiRowsChanged: { + root.wifiJustScanned = true + wifiScanPulseTimer.restart() + } + + Timer { + id: wifiScanPulseTimer + interval: 1200 + onTriggered: root.wifiJustScanned = false + } + // Split for the "Known Networks" / "Other Networks" sections. wifiRows // is already sorted connected-first then by signal, so filtering // preserves that order within each group. @@ -1084,6 +1102,32 @@ Item { font.pixelSize: Appearance.fontSize color: Colors.foreground } + + // Spins briefly each time a scan actually lands — + // same "still searching" cue as + // BluetoothIndicator.qml's spinner, but pulsed via + // wifiJustScanned (see above) rather than staying on + // for the whole time the panel is open, since + // WifiDevice has no per-scan signal of its own to + // key off. + Text { + id: wifiScanSpinner + visible: root.wifiDevice !== null && root.wifiDevice.scannerEnabled && root.wifiJustScanned + text: "progress_activity" + font.family: Appearance.iconFontFamilyRounded + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 16 + color: Colors.muted + + RotationAnimation on rotation { + running: wifiScanSpinner.visible + loops: Animation.Infinite + from: 0 + to: 360 + duration: 900 + } + } + ToggleSwitch { checked: Networking.wifiEnabled onToggled: Networking.wifiEnabled = !Networking.wifiEnabled -- 2.49.1 From ef06a967577632a852b616cb0659fe83f6b8b653 Mon Sep 17 00:00:00 2001 From: thomas Date: Thu, 27 Aug 2026 11:13:09 +0100 Subject: [PATCH 34/44] UI: new header & current connected interface --- .../modules/bar/NetworkIndicator.qml | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/dotfiles/.config/quickshell/modules/bar/NetworkIndicator.qml b/dotfiles/.config/quickshell/modules/bar/NetworkIndicator.qml index bf8d5a8..099ba15 100644 --- a/dotfiles/.config/quickshell/modules/bar/NetworkIndicator.qml +++ b/dotfiles/.config/quickshell/modules/bar/NetworkIndicator.qml @@ -860,7 +860,7 @@ Item { Text { Layout.fillWidth: true - text: root.connectedNetworkName + text: "Network" elide: Text.ElideRight font.family: Appearance.fontFamily font.pixelSize: 18 @@ -869,6 +869,34 @@ Item { } } + // Row rather than RowLayout — RowLayout is built for + // constraint-based stretching (Layout.fillWidth etc.), and + // without either Text opting into that, its sizing pass can + // still leave more room between them than spacing:0 implies. + // Row has no such stretch logic: spacing is exactly the gap + // you get, so these two sit flush (plus one small gap) no + // matter what width is bound above them. + Row { + width: parent.width + spacing: 4 + + Text { + text: "Connected Interface:" + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.muted + } + + Text { + text: root.connectedNetworkName + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.foreground + font.bold: true + } + + } + // Live stats — ping/loss/throughput/IP/gateway for whichever // device is currently connected. GridLayout { -- 2.49.1 From 3ae5c5730378a50c49d52167631bed2e5496d2f9 Mon Sep 17 00:00:00 2001 From: thomas Date: Thu, 27 Aug 2026 11:21:41 +0100 Subject: [PATCH 35/44] Feature: Added Volume Sink --- dotfiles/.config/quickshell/modules/bar/Volume.qml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/dotfiles/.config/quickshell/modules/bar/Volume.qml b/dotfiles/.config/quickshell/modules/bar/Volume.qml index f77b285..1845cf0 100644 --- a/dotfiles/.config/quickshell/modules/bar/Volume.qml +++ b/dotfiles/.config/quickshell/modules/bar/Volume.qml @@ -40,6 +40,12 @@ Item { enabled: panel.visible } + PwNodePeakMonitor { + id: outputPeak + node: root.sink + enabled: panel.visible + } + // --- Device label/icon heuristics, ported from omarchy's audio panel // Model.js (github.com/basecamp/omarchy shell/plugins/panels/audio/Model.js), // adapted to this project's Material Symbols icon set instead of Nerd Font. @@ -375,6 +381,11 @@ Item { onMoved: (v) => { if (root.sink && root.sink.audio) root.sink.audio.volume = v } } + LevelMeter { + width: parent.width + level: outputPeak.peak + } + Column { width: parent.width spacing: 2 @@ -425,6 +436,7 @@ Item { level: inputPeak.peak } + Column { width: parent.width spacing: 2 -- 2.49.1 From 622afb2100bc09dbb74ae437605ee269b4183526 Mon Sep 17 00:00:00 2001 From: thomas Date: Thu, 27 Aug 2026 11:28:57 +0100 Subject: [PATCH 36/44] UI: fixed selectors & added spacing --- .../.config/quickshell/modules/bar/Volume.qml | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/dotfiles/.config/quickshell/modules/bar/Volume.qml b/dotfiles/.config/quickshell/modules/bar/Volume.qml index 1845cf0..bdbf0e7 100644 --- a/dotfiles/.config/quickshell/modules/bar/Volume.qml +++ b/dotfiles/.config/quickshell/modules/bar/Volume.qml @@ -240,6 +240,19 @@ Item { color: rowMouse.containsMouse ? Colors.primary : (deviceRow.active ? Qt.rgba(Colors.primary.r, Colors.primary.g, Colors.primary.b, 0.18) : "transparent") + // Hover-only outline, offset outside the row rather than drawn on + // its own edge — same treatment as the other bar widgets' selectable + // rows (e.g. NetworkIndicator.qml's NetworkRow). Not inside a + // clipped Flickable here, so no inset needed on its container. + Rectangle { + anchors.fill: parent + anchors.margins: -3 + radius: 0 + color: "transparent" + border.color: Colors.foreground + border.width: rowMouse.containsMouse ? 1 : 0 + } + RowLayout { anchors.fill: parent anchors.leftMargin: 8 @@ -388,7 +401,7 @@ Item { Column { width: parent.width - spacing: 2 + spacing: 4 Repeater { model: root.candidateSinks DeviceRow { @@ -439,7 +452,7 @@ Item { Column { width: parent.width - spacing: 2 + spacing: 4 Repeater { model: root.candidateSources DeviceRow { -- 2.49.1 From 3f4e3514a20f1030a1f5c8ee748baeea5ac42e65 Mon Sep 17 00:00:00 2001 From: thomas Date: Thu, 27 Aug 2026 11:45:14 +0100 Subject: [PATCH 37/44] Fix: Icons now map correctly to device --- .../.config/quickshell/modules/bar/Volume.qml | 35 ++++++++++++++++--- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/dotfiles/.config/quickshell/modules/bar/Volume.qml b/dotfiles/.config/quickshell/modules/bar/Volume.qml index bdbf0e7..9759de1 100644 --- a/dotfiles/.config/quickshell/modules/bar/Volume.qml +++ b/dotfiles/.config/quickshell/modules/bar/Volume.qml @@ -73,27 +73,52 @@ Item { p["device.icon-name"] || "", p["device.product.name"] || ""].join(" ").toLowerCase() } + // Quickshell's Pipewire API only exposes a node's own properties, not + // its parent Device — so device.api (unlike device.form-factor or the + // richer device.icon-name, which only live on the Device object) is the + // one reliable Bluetooth signal actually present here: it's set on + // every node PipeWire hands off to a bluez5-backed sink/source. Checking + // it directly beats sniffing the word "bluetooth" out of node names, + // which usually just say "bluez_output..." and never match. + function isBluetoothNode(node) { + var p = (node && node.properties) || {} + return p["device.api"] === "bluez5" + } + + // PipeWire's own device.form-factor (which would call ACCENTUM a + // "headset" outright) isn't reachable from here for the same reason — + // it lives on the Device object, not the node. And some devices (a + // generic USB DAC like an EKSA headset) have no form-factor anywhere in + // PipeWire at all, headset or not, so no property-based check can catch + // them. This override list is the deliberate manual escape hatch for + // exactly that case — add a distinctive substring from the product name + // (device.product.name/description, matched case-insensitively via + // deviceBlob) whenever a new no-metadata device shows up misclassified. + readonly property var headphoneNameOverrides: ["e900plus"] + function isHeadphoneNode(node) { var blob = deviceBlob(node) - return blob.indexOf("headphone") !== -1 || blob.indexOf("headset") !== -1 + if (blob.indexOf("headphone") !== -1 || blob.indexOf("headset") !== -1 || blob.indexOf("earbud") !== -1 || blob.indexOf("earphone") !== -1 - || blob.indexOf("airpod") !== -1 + || blob.indexOf("airpod") !== -1) + return true + return root.headphoneNameOverrides.some(function(needle) { return blob.indexOf(needle) !== -1 }) } function sinkIcon(node) { if (!node) return "speaker" if (isHeadphoneNode(node)) return "headphones" + if (isBluetoothNode(node)) return "bluetooth_audio" var blob = deviceBlob(node) - if (blob.indexOf("bluetooth") !== -1) return "bluetooth_audio" if (blob.indexOf("hdmi") !== -1 || blob.indexOf("display") !== -1) return "tv" return "speaker" } function sourceIcon(node) { if (!node) return "mic" + if (isHeadphoneNode(node)) return "headphones" + if (isBluetoothNode(node)) return "bluetooth_audio" var blob = deviceBlob(node) - if (blob.indexOf("headset") !== -1) return "headphones" - if (blob.indexOf("bluetooth") !== -1) return "bluetooth_audio" if (blob.indexOf("webcam") !== -1 || blob.indexOf("camera") !== -1) return "videocam" return "mic" } -- 2.49.1 From 4b0b5c72baf6dcf68fd3377763eb218c924fb1b5 Mon Sep 17 00:00:00 2001 From: thomas Date: Thu, 27 Aug 2026 13:02:02 +0100 Subject: [PATCH 38/44] UI: Fixed padding error --- dotfiles/.config/quickshell/modules/bar/Volume.qml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotfiles/.config/quickshell/modules/bar/Volume.qml b/dotfiles/.config/quickshell/modules/bar/Volume.qml index 9759de1..637d971 100644 --- a/dotfiles/.config/quickshell/modules/bar/Volume.qml +++ b/dotfiles/.config/quickshell/modules/bar/Volume.qml @@ -359,7 +359,7 @@ Item { Column { id: mainColumn anchors.fill: parent - anchors.margins: panel.contentPadding + anchors.margins: panel.contentPadding - 2 spacing: 10 // Header -- 2.49.1 From fcce5f304b7edb2a7a67772dab1688190f1f4bca Mon Sep 17 00:00:00 2001 From: thomas Date: Thu, 27 Aug 2026 13:02:34 +0100 Subject: [PATCH 39/44] UI: Fixed hover colours --- .../modules/bar/BluetoothIndicator.qml | 26 ++++------ .../modules/bar/NetworkIndicator.qml | 29 +++++------ .../quickshell/modules/bar/PowerButton.qml | 49 +++++++++++++------ .../modules/bar/TailscaleIndicator.qml | 34 ++++++------- 4 files changed, 72 insertions(+), 66 deletions(-) diff --git a/dotfiles/.config/quickshell/modules/bar/BluetoothIndicator.qml b/dotfiles/.config/quickshell/modules/bar/BluetoothIndicator.qml index 2f7235f..bcfca55 100644 --- a/dotfiles/.config/quickshell/modules/bar/BluetoothIndicator.qml +++ b/dotfiles/.config/quickshell/modules/bar/BluetoothIndicator.qml @@ -213,18 +213,12 @@ Item { width: parent ? parent.width : 0 radius: 0 implicitHeight: rowContent.implicitHeight + 12 - color: devRow.dev.connected - ? Qt.rgba(Colors.primary.r, Colors.primary.g, Colors.primary.b, 0.18) - : (rowMouse.containsMouse ? Colors.primary : "transparent") - - // Colors.primaryText (used below whenever hovered) assumes a solid - // Colors.primary fill behind it — true for a hovered *disconnected* - // row, but a connected row's background stays the translucent tint - // above even while hovered, so primaryText (dark, meant for a bright - // solid fill) reads poorly against it. This hardcoded near-white - // (same one NetworkIndicator.qml/TailscaleIndicator.qml use for the - // same reason) is used instead for that one case. - readonly property color activeHoverColor: "#c9f0f0f0" + // Hover checked first (same ordering as Volume.qml's DeviceRow): + // hovering always shows a solid Colors.primary fill, even for an + // already-connected row, so the primaryText used below never has to + // sit on top of the dimmer translucent tint instead. + color: rowMouse.containsMouse ? Colors.primary + : (devRow.dev.connected ? Qt.rgba(Colors.primary.r, Colors.primary.g, Colors.primary.b, 0.18) : "transparent") // Hover-only outline, offset outside the row rather than drawn on // its own edge — same treatment as NetworkIndicator.qml's NetworkRow. @@ -257,7 +251,7 @@ Item { font.family: Appearance.iconFontFamilyRounded font.variableAxes: Appearance.iconFontAxes font.pixelSize: 14 - color: rowMouse.containsMouse ? (devRow.dev.connected ? devRow.activeHoverColor : Colors.primaryText) : Colors.foreground + color: rowMouse.containsMouse ? Colors.primaryText : Colors.foreground } ColumnLayout { @@ -271,7 +265,7 @@ Item { font.family: Appearance.fontFamily font.pixelSize: Appearance.fontSize font.bold: devRow.dev.connected - color: rowMouse.containsMouse ? (devRow.dev.connected ? devRow.activeHoverColor : Colors.primaryText) : Colors.foreground + color: rowMouse.containsMouse ? Colors.primaryText : Colors.foreground } Text { readonly property string statusText: { @@ -285,7 +279,7 @@ Item { text: statusText font.family: Appearance.fontFamily font.pixelSize: Appearance.fontSizeSmall - color: rowMouse.containsMouse ? (devRow.dev.connected ? devRow.activeHoverColor : Colors.primaryText) : Colors.muted + color: rowMouse.containsMouse ? Colors.primaryText : Colors.muted } } @@ -296,7 +290,7 @@ Item { font.family: Appearance.iconFontFamilyRounded font.variableAxes: Appearance.iconFontAxes font.pixelSize: 14 - color: rowMouse.containsMouse ? (devRow.dev.connected ? devRow.activeHoverColor : Colors.primaryText) : Colors.muted + color: rowMouse.containsMouse ? Colors.primaryText : Colors.muted } } diff --git a/dotfiles/.config/quickshell/modules/bar/NetworkIndicator.qml b/dotfiles/.config/quickshell/modules/bar/NetworkIndicator.qml index 099ba15..097805e 100644 --- a/dotfiles/.config/quickshell/modules/bar/NetworkIndicator.qml +++ b/dotfiles/.config/quickshell/modules/bar/NetworkIndicator.qml @@ -660,19 +660,12 @@ Item { width: parent ? parent.width : 0 radius: 0 implicitHeight: rowContent.implicitHeight + 12 - color: (netRow.net.connected || netRow.passwordOpen) - ? Qt.rgba(Colors.primary.r, Colors.primary.g, Colors.primary.b, 0.18) - : (rowMouse.containsMouse ? Colors.primary : "transparent") - readonly property bool active: netRow.net.connected || netRow.passwordOpen - - // Colors.primaryText (used below whenever hovered) assumes a solid - // Colors.primary fill behind it — true for a hovered *inactive* row, - // but an active row's background stays the translucent tint above - // even while hovered, so primaryText (dark, meant for a bright solid - // fill) reads poorly against it. This hardcoded near-white (same one - // TailscaleIndicator.qml uses for the same reason) is used instead - // for that one case. - readonly property color activeHoverColor: "#c9f0f0f0" + // Hover checked first (same ordering as Volume.qml's DeviceRow): + // hovering always shows a solid Colors.primary fill, even for an + // already-active row, so the primaryText used below never has to + // sit on top of the dimmer translucent tint instead. + color: rowMouse.containsMouse ? Colors.primary + : ((netRow.net.connected || netRow.passwordOpen) ? Qt.rgba(Colors.primary.r, Colors.primary.g, Colors.primary.b, 0.18) : "transparent") // Hover-only outline, offset outside the row rather than drawn on // its own edge — same treatment as ToggleSwitch's halo and the DNS @@ -708,7 +701,7 @@ Item { font.family: Appearance.iconFontFamilyRounded font.variableAxes: Appearance.iconFontAxes font.pixelSize: 16 - color: rowMouse.containsMouse ? (netRow.active ? netRow.activeHoverColor : Colors.primaryText) : Colors.foreground + color: rowMouse.containsMouse ? Colors.primaryText : Colors.foreground // The wifi-fan glyph itself is flat/wide — stretch it // vertically to read taller while keeping its width (and // the layout around it) unchanged. @@ -729,14 +722,14 @@ Item { font.family: Appearance.fontFamily font.pixelSize: Appearance.fontSize font.bold: netRow.net.connected - color: rowMouse.containsMouse ? (netRow.active ? netRow.activeHoverColor : Colors.primaryText) : Colors.foreground + color: rowMouse.containsMouse ? Colors.primaryText : Colors.foreground } Text { visible: netRow.net.connected text: "Connected" font.family: Appearance.fontFamily font.pixelSize: Appearance.fontSizeSmall - color: rowMouse.containsMouse ? (netRow.active ? netRow.activeHoverColor : Colors.primaryText) : Colors.foreground + color: rowMouse.containsMouse ? Colors.primaryText : Colors.foreground } } Text { @@ -745,7 +738,7 @@ Item { font.family: Appearance.iconFontFamilyRounded font.variableAxes: Appearance.iconFontAxes font.pixelSize: 13 - color: rowMouse.containsMouse ? (netRow.active ? netRow.activeHoverColor : Colors.primaryText) : Colors.muted + color: rowMouse.containsMouse ? Colors.primaryText : Colors.muted } Text { id: forgetIcon @@ -754,7 +747,7 @@ Item { font.family: Appearance.iconFontFamilyRounded font.variableAxes: Appearance.iconFontAxes font.pixelSize: 14 - color: rowMouse.containsMouse ? (netRow.active ? netRow.activeHoverColor : Colors.primaryText) : Colors.muted + color: rowMouse.containsMouse ? Colors.primaryText : Colors.muted } } } diff --git a/dotfiles/.config/quickshell/modules/bar/PowerButton.qml b/dotfiles/.config/quickshell/modules/bar/PowerButton.qml index 9478137..45cefa2 100644 --- a/dotfiles/.config/quickshell/modules/bar/PowerButton.qml +++ b/dotfiles/.config/quickshell/modules/bar/PowerButton.qml @@ -41,10 +41,12 @@ Item { id: menu visible: false color: "transparent" - implicitWidth: 210 - // Same padding as Volume.qml's panel. + // Same contentWidth/contentPadding split as the other panels + // (Volume.qml, NetworkIndicator.qml, etc.) rather than a single + // magic implicitWidth. + readonly property int contentWidth: 178 readonly property int contentPadding: 16 - readonly property int labelOffset: -135 + implicitWidth: contentWidth + contentPadding * 2 implicitHeight: mainColumn.implicitHeight + contentPadding * 2 anchor { @@ -122,36 +124,55 @@ Item { { label: "Shut down", icon: "power_settings_new", cmd: ["systemctl", "poweroff"] } ] + // Same shape as this project's other selectable rows + // (e.g. VpnIndicator.qml's ProviderRow, + // TailscaleIndicator.qml's ExitNodeRow): radius 0, a + // fixed-width icon column followed by a fillWidth + // label, and an offset hover halo rather than one + // drawn on the row's own edge. delegate: Rectangle { id: entry required property var modelData width: parent ? parent.width : 0 - height: 40 - radius: Appearance.radiusSmall + radius: 0 + implicitHeight: entryContent.implicitHeight + 12 color: (!entry.modelData.disabled && entryMouse.containsMouse) ? Colors.primary : "transparent" - RowLayout { + Rectangle { anchors.fill: parent - anchors.leftMargin: 8 + anchors.margins: -3 + radius: 0 + color: "transparent" + border.color: Colors.foreground + border.width: entryMouse.containsMouse ? 1 : 0 + } + + RowLayout { + id: entryContent + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: 6 spacing: 8 + Text { - Layout.preferredWidth: 25 - Layout.minimumWidth: 25 - Layout.maximumWidth: 25 + Layout.preferredWidth: 20 + Layout.minimumWidth: 20 + Layout.maximumWidth: 20 horizontalAlignment: Text.AlignLeft text: entry.modelData.icon font.family: Appearance.iconFontFamilyRounded font.variableAxes: Appearance.iconFontAxes - font.pixelSize: entry.modelData.iconSize || 15 + font.pixelSize: 14 opacity: entry.modelData.disabled ? 0.4 : 1 color: entryMouse.containsMouse ? Colors.primaryText : Colors.foreground } Text { - Layout.leftMargin: menu.labelOffset + (entry.modelData.textOffset || 0) + Layout.fillWidth: true text: entry.modelData.label - horizontalAlignment: Text.AlignLeft + elide: Text.ElideRight font.family: Appearance.fontFamily - font.pixelSize: 16 + font.pixelSize: Appearance.fontSize opacity: entry.modelData.disabled ? 0.4 : 1 color: entryMouse.containsMouse ? Colors.primaryText : Colors.foreground } diff --git a/dotfiles/.config/quickshell/modules/bar/TailscaleIndicator.qml b/dotfiles/.config/quickshell/modules/bar/TailscaleIndicator.qml index ed23d33..b39cd38 100644 --- a/dotfiles/.config/quickshell/modules/bar/TailscaleIndicator.qml +++ b/dotfiles/.config/quickshell/modules/bar/TailscaleIndicator.qml @@ -547,18 +547,12 @@ Item { width: parent ? parent.width : 0 radius: 0 implicitHeight: rowContent.implicitHeight + 12 - color: exitRow.active - ? Qt.rgba(Colors.primary.r, Colors.primary.g, Colors.primary.b, 0.18) - : (rowMouse.containsMouse ? Colors.primary : "transparent") - - // Colors.primaryText (used below whenever hovered) assumes a solid - // Colors.primary fill behind it — true for a hovered *inactive* row, - // but an *active* row's background stays the translucent tint above - // even while hovered, so primaryText (dark, meant for a bright solid - // fill) reads poorly against it. This hardcoded near-white (same one - // used for TailscaleLogo's bright dots, for the same "needs to be - // reliably visible" reason) is used instead for that one case. - readonly property color activeHoverColor: "#c9f0f0f0" + // Hover checked first (same ordering as Volume.qml's DeviceRow): + // hovering always shows a solid Colors.primary fill, even for an + // already-active row, so the primaryText used below never has to + // sit on top of the dimmer translucent tint instead. + color: rowMouse.containsMouse ? Colors.primary + : (exitRow.active ? Qt.rgba(Colors.primary.r, Colors.primary.g, Colors.primary.b, 0.18) : "transparent") // Hover-only outline, offset outside the row rather than drawn on // its own edge — same treatment as NetworkIndicator.qml's NetworkRow. @@ -590,7 +584,7 @@ Item { font.family: Appearance.iconFontFamilyRounded font.variableAxes: Appearance.iconFontAxes font.pixelSize: 14 - color: rowMouse.containsMouse ? (exitRow.active ? exitRow.activeHoverColor : Colors.primaryText) : Colors.foreground + color: rowMouse.containsMouse ? Colors.primaryText : Colors.foreground } Text { Layout.fillWidth: true @@ -599,7 +593,7 @@ Item { font.family: Appearance.fontFamily font.pixelSize: Appearance.fontSize font.bold: exitRow.active - color: rowMouse.containsMouse ? (exitRow.active ? exitRow.activeHoverColor : Colors.primaryText) : Colors.foreground + color: rowMouse.containsMouse ? Colors.primaryText : Colors.foreground } Text { visible: exitRow.active @@ -607,7 +601,7 @@ Item { font.family: Appearance.iconFontFamilyRounded font.variableAxes: Appearance.iconFontAxes font.pixelSize: 14 - color: rowMouse.containsMouse ? exitRow.activeHoverColor : Colors.primary + color: rowMouse.containsMouse ? Colors.primaryText : Colors.primary } } @@ -631,9 +625,13 @@ Item { width: parent ? parent.width : 0 radius: 0 implicitHeight: accountContent.implicitHeight + 12 - color: accountRow.selected - ? Qt.rgba(Colors.primary.r, Colors.primary.g, Colors.primary.b, 0.18) - : (accountMouse.containsMouse ? Colors.primary : "transparent") + // Hover checked first (same ordering as Volume.qml's DeviceRow): + // hovering always shows a solid Colors.primary fill, even for an + // already-selected row, so the primaryText the texts below already + // assume on hover is always correct (this row had the same + // active-first ordering bug as ExitNodeRow, just never reported). + color: accountMouse.containsMouse ? Colors.primary + : (accountRow.selected ? Qt.rgba(Colors.primary.r, Colors.primary.g, Colors.primary.b, 0.18) : "transparent") // Hover-only outline, offset outside the row — same treatment as // ExitNodeRow/NetworkRow. Not inside a clipped Flickable, so no -- 2.49.1 From c2c6675f4b83c074edd7decb0b8620b8e95b8162 Mon Sep 17 00:00:00 2001 From: thomas Date: Thu, 27 Aug 2026 13:18:46 +0100 Subject: [PATCH 40/44] Feature: Added battery percentage/view --- .../.config/quickshell/modules/bar/Bar.qml | 1 + .../modules/bar/BatteryIndicator.qml | 422 ++++++++++++++++++ 2 files changed, 423 insertions(+) create mode 100644 dotfiles/.config/quickshell/modules/bar/BatteryIndicator.qml diff --git a/dotfiles/.config/quickshell/modules/bar/Bar.qml b/dotfiles/.config/quickshell/modules/bar/Bar.qml index d04baa7..149caa7 100644 --- a/dotfiles/.config/quickshell/modules/bar/Bar.qml +++ b/dotfiles/.config/quickshell/modules/bar/Bar.qml @@ -52,6 +52,7 @@ PanelWindow { NetworkIndicator { barWindow: bar } BluetoothIndicator { barWindow: bar } Volume { barWindow: bar } + BatteryIndicator { barWindow: bar } PowerButton { barWindow: bar } } } diff --git a/dotfiles/.config/quickshell/modules/bar/BatteryIndicator.qml b/dotfiles/.config/quickshell/modules/bar/BatteryIndicator.qml new file mode 100644 index 0000000..82a966b --- /dev/null +++ b/dotfiles/.config/quickshell/modules/bar/BatteryIndicator.qml @@ -0,0 +1,422 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Hyprland +import Quickshell.Services.UPower +import "../../config" + +// Battery + power-profile panel, ported from omarchy's power panel +// (github.com/basecamp/omarchy shell/plugins/panels/power/{Model.js,Panel.qml}) +// onto this project's own conventions: Quickshell's native UPower/PowerProfiles +// bindings instead of omarchy's `omarchy-battery-status` / +// `omarchy-powerprofiles-list` / `omarchy-powerprofiles-set` helper scripts +// (which don't exist outside omarchy), and Material Symbols glyphs instead of +// Nerd Font ones. The charge-threshold ("Holding"/limited-charge) concept from +// the original is hardware-specific (vendor charge-limit sysfs knobs) and has +// no equivalent in Quickshell's UPower binding, so it's dropped rather than +// guessed at; "Battery health" (device.healthPercentage) is shown in its +// place, using data UPower actually reports. +Item { + id: root + + readonly property var device: UPower.displayDevice + // isLaptopBattery (type === Battery && powerSupply === true) is the + // Quickshell-recommended check for "is this actually a battery" — more + // precise than isPresent, which the docs note is only meaningful once + // you already know the device is of Battery type. + readonly property bool batteryPresent: !!(root.device && root.device.ready && root.device.isLaptopBattery) + + // Hidden entirely on a desktop with no battery — RowLayout skips + // invisible children for both sizing and spacing, so this just vanishes + // from the bar rather than leaving a gap. + visible: root.batteryPresent + implicitWidth: root.batteryPresent ? label.implicitWidth : 0 + implicitHeight: Appearance.barHeight + + // The bar's own PanelWindow — passed in from Bar.qml so this popup can + // anchor to the bar itself rather than to this icon, keeping it a fixed + // distance from the bar and the screen edge regardless of where this + // icon sits in the row. + required property var barWindow + + readonly property bool onBattery: UPower.onBattery + // Named deviceState, not "state" — Item already has a built-in `state` + // property (the property-based state-machine one) that this would + // otherwise collide with. + readonly property int deviceState: root.batteryPresent ? root.device.state : UPowerDeviceState.Unknown + readonly property bool fullyCharged: root.deviceState === UPowerDeviceState.FullyCharged + readonly property bool charging: root.batteryPresent && !root.onBattery && !root.fullyCharged + // device.percentage is a 0..1 fraction despite the name (same convention + // Volume.qml relies on for Pipewire's audio.volume). + readonly property real fraction: root.batteryPresent ? Math.max(0, Math.min(1, root.device.percentage)) : 0 + readonly property bool low: root.batteryPresent && root.fraction <= 0.15 && !root.charging + + function batteryIcon() { + if (!root.batteryPresent) return "battery_unknown" + if (root.fullyCharged) return "battery_full" + if (root.charging) return "battery_charging_full" + if (root.fraction <= 0.1) return "battery_alert" + const bars = ["battery_0_bar", "battery_1_bar", "battery_2_bar", "battery_3_bar", "battery_4_bar", "battery_5_bar", "battery_full"] + return bars[Math.max(0, Math.min(bars.length - 1, Math.round(root.fraction * (bars.length - 1))))] + } + + function formatDuration(seconds) { + const s = Math.max(0, Math.round(seconds || 0)) + if (s <= 0) return "—" + const h = Math.floor(s / 3600) + const m = Math.round((s % 3600) / 60) + return h > 0 ? (h + "h " + m + "m") : (m + "m") + } + + readonly property string modeLabel: { + if (!root.batteryPresent) return "" + if (root.fullyCharged) return "Fully charged" + return root.onBattery ? "On battery" : "Charging" + } + + // Cute rotating status phrases while current is actually flowing — + // ported as-is from omarchy's power panel, which pairs the same phrase + // sets with the same charging/on-battery states. + readonly property var chargingPhrases: [ + "Pumping power", "Injecting electrons", "Pouring juice", "Amassing watts", + "Hoarding joules", "Sucking volts", "Topping reserves", "Soaking amps", "Inhaling kilowatts" + ] + readonly property var onBatteryPhrases: [ + "Slurping power", "Spending joules", "Draining watts", "Burning electrons", + "Sipping juice", "Spending coulombs", "Bleeding amps", "Guzzling volts", "Munching reserves" + ] + readonly property var activePhrases: root.fullyCharged ? [] + : (root.charging ? root.chargingPhrases : (root.onBattery ? root.onBatteryPhrases : [])) + readonly property bool rotatingPhrases: root.activePhrases.length > 0 + property int phraseIndex: 0 + readonly property string heroStatusText: root.rotatingPhrases + ? root.activePhrases[root.phraseIndex % root.activePhrases.length] + : root.modeLabel + + // Only rotates while the panel is open and actually charging/discharging. + Timer { + id: phraseTimer + interval: 2800 + running: panel.visible && root.rotatingPhrases + repeat: true + triggeredOnStart: false + onTriggered: phraseSwap.restart() + } + + SequentialAnimation { + id: phraseSwap + PropertyAnimation { target: heroStatus; property: "opacity"; to: 0.0; duration: 180; easing.type: Easing.OutQuad } + ScriptAction { + script: { + const n = root.activePhrases.length + if (n > 0) root.phraseIndex = (root.phraseIndex + 1) % n + } + } + PropertyAnimation { target: heroStatus; property: "opacity"; to: 1.0; duration: 260; easing.type: Easing.InQuad } + } + + // If we leave a rotating state mid-swap (e.g. unplugged mid-fade), + // snap back to full opacity so the new state reads immediately. + Connections { + target: root + function onRotatingPhrasesChanged() { + if (!root.rotatingPhrases) { + phraseSwap.stop() + heroStatus.opacity = 1.0 + } + } + } + + // --- Power profile list ------------------------------------------------ + // power-profiles-daemon only ever exposes these three fixed profiles; + // Performance is conditional on hasPerformanceProfile (e.g. absent on + // battery-only/thermally-limited hardware) — same gating omarchy's own + // Model.js applies via its profile-list script. + readonly property var profileEntries: { + const entries = [ + { value: PowerProfile.PowerSaver, label: "Saver", icon: "eco" }, + { value: PowerProfile.Balanced, label: "Balanced", icon: "balance" } + ] + if (PowerProfiles.hasPerformanceProfile) + entries.push({ value: PowerProfile.Performance, label: "Performance", icon: "bolt" }) + return entries + } + + Text { + id: label + anchors.centerIn: parent + font.family: Appearance.iconFontFamilyRounded + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 16 + color: root.low ? Colors.urgent : Colors.foreground + text: root.batteryIcon() + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + panel.visible = !panel.visible + grab.active = panel.visible + } + } + + // --- Reusable pieces ----------------------------------------------- + + component ProfileButton: Rectangle { + id: profBtn + required property string label + required property string icon + required property bool active + signal activate() + width: parent ? parent.width : 0 + radius: 0 + implicitHeight: 44 + color: mouse.containsMouse ? Colors.primary + : (profBtn.active ? Qt.rgba(Colors.primary.r, Colors.primary.g, Colors.primary.b, 0.18) : "transparent") + + // Hover-only outline, offset outside the button rather than drawn on + // its own edge — same treatment as this project's other selectable + // rows (e.g. Volume.qml's DeviceRow). + Rectangle { + anchors.fill: parent + anchors.margins: -3 + radius: 0 + color: "transparent" + border.color: Colors.foreground + border.width: mouse.containsMouse ? 1 : 0 + } + + Column { + anchors.centerIn: parent + spacing: 2 + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: profBtn.icon + font.family: Appearance.iconFontFamilyRounded + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 18 + color: mouse.containsMouse ? Colors.primaryText : Colors.foreground + } + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: profBtn.label + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + font.bold: profBtn.active + color: mouse.containsMouse ? Colors.primaryText : Colors.foreground + } + } + + MouseArea { + id: mouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: profBtn.activate() + } + } + + component StatRow: RowLayout { + id: statRow + width: parent.width + property string label: "" + property string value: "" + Text { + Layout.fillWidth: true + text: statRow.label + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.muted + } + Text { + text: statRow.value + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.muted + } + } + + // --- Panel ----------------------------------------------------------- + + PopupWindow { + id: panel + visible: false + color: "transparent" + readonly property int contentWidth: 244 + readonly property int contentPadding: 16 + implicitWidth: contentWidth + contentPadding * 2 + implicitHeight: mainColumn.implicitHeight + contentPadding * 2 + + anchor { + window: root.barWindow + // Unlike anchor.item, anchor.window doesn't default rect to the + // window's own bounds — set it explicitly so edges/gravity below + // resolve against the full bar (and thus the true screen edges) + // instead of a near-zero rect at the window's origin. + rect.x: 0 + rect.y: 0 + rect.width: root.barWindow.width + rect.height: root.barWindow.height + edges: Edges.Bottom | Edges.Right + gravity: Edges.Bottom | Edges.Left + margins.bottom: -4 + margins.right: 0 + } + + HyprlandFocusGrab { + id: grab + windows: [panel] + onCleared: panel.visible = false + } + + Rectangle { + anchors.fill: parent + radius: 0 + color: Colors.surface + border.color: Colors.muted + border.width: 3 + + Column { + id: mainColumn + anchors.fill: parent + anchors.margins: panel.contentPadding + spacing: 10 + + // Hero: icon · title/status · percentage + RowLayout { + width: parent.width + spacing: 10 + + Text { + text: root.batteryIcon() + font.family: Appearance.iconFontFamilyRounded + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 30 + color: root.low ? Colors.urgent : Colors.foreground + } + + Column { + Layout.fillWidth: true + spacing: 2 + Text { + width: parent.width + text: "Battery" + elide: Text.ElideRight + font.family: Appearance.fontFamily + font.pixelSize: 18 + font.bold: true + color: Colors.foreground + } + Text { + id: heroStatus + width: parent.width + text: root.heroStatusText + elide: Text.ElideRight + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.muted + } + } + + Text { + text: Math.round(root.fraction * 100) + "%" + font.family: Appearance.fontFamily + font.pixelSize: 20 + font.bold: true + color: Colors.foreground + } + } + + // Charge level bar + Item { + width: parent.width + height: 4 + + Rectangle { + anchors.fill: parent + radius: 0 + color: Colors.muted + opacity: 0.35 + } + Rectangle { + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + radius: 0 + color: root.low ? Colors.urgent : Colors.primary + width: Math.max(height, parent.width * root.fraction) + Behavior on width { NumberAnimation { duration: 320; easing.type: Easing.OutCubic } } + } + } + + // Stats + RowLayout { + width: parent.width + spacing: 16 + + Column { + Layout.fillWidth: true + spacing: 4 + StatRow { label: "STATE"; value: root.modeLabel } + StatRow { + label: "HEALTH" + value: (root.batteryPresent && root.device.healthSupported) + ? Math.round(root.device.healthPercentage) + "%" : "—" + } + } + + Column { + Layout.fillWidth: true + spacing: 4 + StatRow { + label: root.onBattery ? "TIME LEFT" : "TIME TO FULL" + value: root.batteryPresent + ? root.formatDuration(root.onBattery ? root.device.timeToEmpty : root.device.timeToFull) + : "—" + } + StatRow { + label: "RATE" + value: root.batteryPresent ? Math.abs(root.device.changeRate).toFixed(1) + " W" : "—" + } + } + } + + // Power profile picker + Column { + width: parent.width + spacing: 6 + + Text { + text: "POWER PROFILE" + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.muted + } + + Row { + id: profileRow + width: parent.width + spacing: 6 + + readonly property real cellWidth: root.profileEntries.length > 0 + ? (width - spacing * (root.profileEntries.length - 1)) / root.profileEntries.length + : 0 + + Repeater { + model: root.profileEntries + ProfileButton { + required property var modelData + width: profileRow.cellWidth + label: modelData.label + icon: modelData.icon + active: PowerProfiles.profile === modelData.value + onActivate: PowerProfiles.profile = modelData.value + } + } + } + } + } + } + } +} -- 2.49.1 From 9e532023bd4ef302d788b16be5c1b9516b432a24 Mon Sep 17 00:00:00 2001 From: thomas Date: Thu, 27 Aug 2026 14:06:21 +0100 Subject: [PATCH 41/44] Fix: Corrected battery behavior --- .../modules/bar/BatteryIndicator.qml | 221 ++++++++++++++---- 1 file changed, 172 insertions(+), 49 deletions(-) diff --git a/dotfiles/.config/quickshell/modules/bar/BatteryIndicator.qml b/dotfiles/.config/quickshell/modules/bar/BatteryIndicator.qml index 82a966b..ca6d3e7 100644 --- a/dotfiles/.config/quickshell/modules/bar/BatteryIndicator.qml +++ b/dotfiles/.config/quickshell/modules/bar/BatteryIndicator.qml @@ -2,6 +2,7 @@ import QtQuick import QtQuick.Layouts import Quickshell import Quickshell.Hyprland +import Quickshell.Io import Quickshell.Services.UPower import "../../config" @@ -14,7 +15,7 @@ import "../../config" // Nerd Font ones. The charge-threshold ("Holding"/limited-charge) concept from // the original is hardware-specific (vendor charge-limit sysfs knobs) and has // no equivalent in Quickshell's UPower binding, so it's dropped rather than -// guessed at; "Battery health" (device.healthPercentage) is shown in its +// guessed at; "Battery health" (computed from sysfs energy_full/design) is shown in its // place, using data UPower actually reports. Item { id: root @@ -51,10 +52,100 @@ Item { readonly property real fraction: root.batteryPresent ? Math.max(0, Math.min(1, root.device.percentage)) : 0 readonly property bool low: root.batteryPresent && root.fraction <= 0.15 && !root.charging + // Quickshell's UPower binding has no charge-cycle property (see the + // file-header comment on why the old charge-until threshold was + // dropped) — it comes straight from sysfs instead. + // + // device.nativePath turned out to be empty on real hardware (tested + // live), so rather than build a path from it, a shell glob finds + // whichever BAT* directory actually exists — same shell-out-to-a-CLI + // convention TailscaleIndicator.qml uses for its own live stats. + // This barely changes during a session, so one read at startup is + // enough; no polling timer. + property int cycleCount: -1 + + Process { + id: cycleCountProc + command: ["sh", "-c", "cat \"$(ls -d /sys/class/power_supply/BAT* 2>/dev/null | head -n1)/cycle_count\" 2>/dev/null"] + running: root.batteryPresent + stdout: StdioCollector { + id: cycleCountCollector + onStreamFinished: { + const n = parseInt(cycleCountCollector.text.trim(), 10) + root.cycleCount = (isNaN(n) || n <= 0) ? -1 : n + } + } + } + + // device.healthPercentage/healthSupported come from upowerd over D-Bus, + // and upowerd has been observed reporting energy-full as a flat 0 (a + // live daemon glitch, `upower -i` shows it too) while sysfs still has + // the real numbers underneath — same class of unreliability that put + // cycle_count on sysfs above instead of trusting upowerd's own + // charge-cycles field. Health is computed the same way UPower computes + // it (current full-charge capacity over the battery's original design + // capacity), just read directly so a daemon hiccup doesn't blank it. + property real energyFullWh: -1 + property real energyFullDesignWh: -1 + readonly property real healthPercent: (root.energyFullWh > 0 && root.energyFullDesignWh > 0) + ? Math.min(100, (root.energyFullWh / root.energyFullDesignWh) * 100) : -1 + + Process { + id: energyFullProc + command: ["sh", "-c", "cat \"$(ls -d /sys/class/power_supply/BAT* 2>/dev/null | head -n1)/energy_full\" 2>/dev/null"] + running: root.batteryPresent + stdout: StdioCollector { + id: energyFullCollector + onStreamFinished: { + const n = parseFloat(energyFullCollector.text.trim()) + root.energyFullWh = (isNaN(n) || n <= 0) ? -1 : (n / 1000000) + } + } + } + + Process { + id: energyFullDesignProc + command: ["sh", "-c", "cat \"$(ls -d /sys/class/power_supply/BAT* 2>/dev/null | head -n1)/energy_full_design\" 2>/dev/null"] + running: root.batteryPresent + stdout: StdioCollector { + id: energyFullDesignCollector + onStreamFinished: { + const n = parseFloat(energyFullDesignCollector.text.trim()) + root.energyFullDesignWh = (isNaN(n) || n <= 0) ? -1 : (n / 1000000) + } + } + } + + // Raw UPower state string (Charging/Discharging/PendingCharge/…) — more + // granular than modeLabel below, which collapses those down to the + // three phrases the hero text and STATE row show. + readonly property string rawStateLabel: root.batteryPresent + ? UPowerDeviceState.toString(root.deviceState) : "—" + + // Material Symbols' charging glyphs aren't an evenly-spaced "_bar" + // scale like the non-charging ones below — they're fixed at these + // seven breakpoints (confirmed against the installed font's ligature + // table), so this picks the highest one at or below the current + // percentage rather than rounding to an index. + function chargingIcon(fraction) { + const percent = fraction * 100 + const thresholds = [20, 30, 50, 60, 80, 90, 100] + const icons = [ + "battery_charging_20", "battery_charging_30", "battery_charging_50", "battery_charging_60", + "battery_charging_80", "battery_charging_90", "battery_charging_full" + ] + for (let i = thresholds.length - 1; i >= 0; i--) { + if (percent >= thresholds[i]) return icons[i] + } + // Below 20% there's no lower charging glyph — it's still the + // truthful floor of what's available while current is flowing in. + return icons[0] + } + function batteryIcon() { if (!root.batteryPresent) return "battery_unknown" if (root.fullyCharged) return "battery_full" - if (root.charging) return "battery_charging_full" + if (root.charging) return root.chargingIcon(root.fraction) if (root.fraction <= 0.1) return "battery_alert" const bars = ["battery_0_bar", "battery_1_bar", "battery_2_bar", "battery_3_bar", "battery_4_bar", "battery_5_bar", "battery_full"] return bars[Math.max(0, Math.min(bars.length - 1, Math.round(root.fraction * (bars.length - 1))))] @@ -74,6 +165,34 @@ Item { return root.onBattery ? "On battery" : "Charging" } + // Info grid rows — same shape as NetworkIndicator.qml's statRows/ + // statCells: a flat label/value list, flattened again into individual + // grid cells (label, value, label, value, …) so a 4-column GridLayout + // lays them out as two label/value pairs per row. + readonly property var statRows: [ + { + label: "HEALTH", + value: root.healthPercent > 0 ? Math.round(root.healthPercent) + "%" : "—" + }, + { + label: root.onBattery ? "TIME LEFT" : "TIME TO FULL", + value: root.batteryPresent + ? root.formatDuration(root.onBattery ? root.device.timeToEmpty : root.device.timeToFull) + : "—" + }, + { label: "STATUS", value: root.rawStateLabel }, + { label: "CYCLES", value: root.cycleCount > 0 ? String(root.cycleCount) : "—" } + ] + + readonly property var statCells: { + const cells = [] + for (let i = 0; i < root.statRows.length; i++) { + cells.push({ text: root.statRows[i].label, isLabel: true }) + cells.push({ text: root.statRows[i].value, isLabel: false }) + } + return cells + } + // Cute rotating status phrases while current is actually flowing — // ported as-is from omarchy's power panel, which pairs the same phrase // sets with the same charging/on-battery states. @@ -142,6 +261,34 @@ Item { return entries } + // --- Automatic power profile -------------------------------------- + // Battery-state-driven, on top of the manual picker above: below 20% + // → Power Saver (protect what's left), plugged in → Performance, + // unplugged and healthy → Balanced. Low battery wins even if a weak + // charger is still plugged in — running low is the more urgent signal. + // Performance only gets requested when the daemon actually reports it + // as available, falling back to Balanced instead of erroring on + // hardware without a performance profile (e.g. the ERROR this exact + // widget hit before power-profiles-daemon was installed). + // + // This only ever writes when the *target* changes (crossing the 20% + // line, plugging/unplugging) rather than on every poll, so a manual + // pick from the buttons above still sticks until the next real + // transition instead of being fought immediately. + readonly property int desiredProfile: { + if (root.fraction <= 0.20) return PowerProfile.PowerSaver + if (!root.onBattery) return PowerProfiles.hasPerformanceProfile ? PowerProfile.Performance : PowerProfile.Balanced + return PowerProfile.Balanced + } + + function applyDesiredProfile() { + if (!root.batteryPresent) return + if (PowerProfiles.profile !== root.desiredProfile) PowerProfiles.profile = root.desiredProfile + } + + onDesiredProfileChanged: root.applyDesiredProfile() + Component.onCompleted: Qt.callLater(root.applyDesiredProfile) + Text { id: label anchors.centerIn: parent @@ -217,33 +364,16 @@ Item { } } - component StatRow: RowLayout { - id: statRow - width: parent.width - property string label: "" - property string value: "" - Text { - Layout.fillWidth: true - text: statRow.label - font.family: Appearance.fontFamily - font.pixelSize: Appearance.fontSizeSmall - color: Colors.muted - } - Text { - text: statRow.value - font.family: Appearance.fontFamily - font.pixelSize: Appearance.fontSizeSmall - color: Colors.muted - } - } - // --- Panel ----------------------------------------------------------- PopupWindow { id: panel visible: false color: "transparent" - readonly property int contentWidth: 244 + // Wider than before (244) to give the 2-column info grid's + // label/value pairs enough room per half without eliding — + // same reasoning as NetworkIndicator.qml's own wider panel. + readonly property int contentWidth: 280 readonly property int contentPadding: 16 implicitWidth: contentWidth + contentPadding * 2 implicitHeight: mainColumn.implicitHeight + contentPadding * 2 @@ -350,34 +480,27 @@ Item { } } - // Stats - RowLayout { + // Stats — a 2-column, 2-row info grid, same layout + // NetworkIndicator.qml uses for its own live stats: a + // 4-column GridLayout fed a flattened label/value/label/value + // list, so each row holds two label/value pairs side by side + // with the values right-aligned against their own half. + GridLayout { width: parent.width - spacing: 16 + columns: 4 + columnSpacing: 12 + rowSpacing: 5 - Column { - Layout.fillWidth: true - spacing: 4 - StatRow { label: "STATE"; value: root.modeLabel } - StatRow { - label: "HEALTH" - value: (root.batteryPresent && root.device.healthSupported) - ? Math.round(root.device.healthPercentage) + "%" : "—" - } - } - - Column { - Layout.fillWidth: true - spacing: 4 - StatRow { - label: root.onBattery ? "TIME LEFT" : "TIME TO FULL" - value: root.batteryPresent - ? root.formatDuration(root.onBattery ? root.device.timeToEmpty : root.device.timeToFull) - : "—" - } - StatRow { - label: "RATE" - value: root.batteryPresent ? Math.abs(root.device.changeRate).toFixed(1) + " W" : "—" + Repeater { + model: root.statCells + Text { + required property var modelData + Layout.fillWidth: !modelData.isLabel + horizontalAlignment: modelData.isLabel ? Text.AlignLeft : Text.AlignRight + text: modelData.text + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: modelData.isLabel ? Colors.muted : Colors.foreground } } } -- 2.49.1 From d6f79685f041e9dc4325c162b72ff1c8ae7e7ff9 Mon Sep 17 00:00:00 2001 From: thomas Date: Thu, 27 Aug 2026 14:07:52 +0100 Subject: [PATCH 42/44] Feature: Extra functionablity to bluetooth actions --- .../modules/bar/BluetoothIndicator.qml | 226 ++++++++++++++---- 1 file changed, 174 insertions(+), 52 deletions(-) diff --git a/dotfiles/.config/quickshell/modules/bar/BluetoothIndicator.qml b/dotfiles/.config/quickshell/modules/bar/BluetoothIndicator.qml index bcfca55..575b561 100644 --- a/dotfiles/.config/quickshell/modules/bar/BluetoothIndicator.qml +++ b/dotfiles/.config/quickshell/modules/bar/BluetoothIndicator.qml @@ -127,6 +127,11 @@ Item { d.forget() } + function setTrusted(row, trusted) { + const d = root.deviceForAddress(row.address) + if (d) d.trusted = trusted + } + readonly property bool anyConnected: root.connectedRows.length > 0 // Caps how tall the Paired/Available list gets before it scrolls @@ -203,8 +208,13 @@ Item { } } - // One device row. `showForget` hides the forget (x) button for - // discovered-but-unpaired devices, which have nothing to forget yet. + // One device row. `showForget` gates the whole trailing action cluster — + // trust toggle, disconnect, forget — for discovered-but-unpaired devices, + // which have none of those to act on yet (a plain click there pairs + // instead, via activate()). For a paired/connected row, the row body + // itself still connects/disconnects (activate()) same as before; the + // actions below are the explicit, discoverable versions of that plus + // the two this row previously had no way to reach at all. component DeviceRow: Rectangle { id: devRow required property var dev @@ -213,6 +223,27 @@ Item { width: parent ? parent.width : 0 radius: 0 implicitHeight: rowContent.implicitHeight + 12 + + // Which action icon (if any) the pointer is currently over — drives + // both that icon's own hover color below and rowMouse's click + // dispatch. Derived from rowMouse.mouseX/containsMouse rather than a + // MouseArea on each icon: those icons live inside rowContent, which + // sits underneath rowMouse in stacking order, so a nested MouseArea + // there would just be shadowed by it (same reason + // NetworkIndicator.qml's NetworkRow dispatches by position too). + // Checked right-to-left since that's the order the icons are laid + // out in, so an earlier icon's "from here to the row's right edge" + // zone doesn't swallow the ones after it. + readonly property string hoveredAction: { + if (!rowMouse.containsMouse) return "" + if (devRow.showForget && forgetIcon.visible + && rowMouse.mouseX >= devRow.mapFromItem(forgetIcon, 0, 0).x - 8) return "forget" + if (devRow.showForget && trustIcon.visible + && rowMouse.mouseX >= devRow.mapFromItem(trustIcon, 0, 0).x - 8) return "trust" + if (disconnectIcon.visible + && rowMouse.mouseX >= devRow.mapFromItem(disconnectIcon, 0, 0).x - 8) return "disconnect" + return "" + } // Hover checked first (same ordering as Volume.qml's DeviceRow): // hovering always shows a solid Colors.primary fill, even for an // already-connected row, so the primaryText used below never has to @@ -283,14 +314,97 @@ Item { } } - Text { + // Each action below is a fixed-size slot with its own + // hover-halo outline — offset outside the slot rather than drawn + // on its own edge, same treatment as devRow's own outline above + // and every other hoverable box in this file (ToggleSwitch, + // DeviceRow itself, etc.) — instead of just the color swap the + // row's own icons get. + + // Disconnect without forgetting — same effect as clicking the + // row body on a Connected row (activate() below calls + // disconnectDevice too), just given its own explicit, always- + // visible icon instead of relying on that being guessed. + Item { + id: disconnectIcon + visible: devRow.showForget && devRow.dev.connected + Layout.preferredWidth: 20 + Layout.preferredHeight: 20 + + Rectangle { + anchors.fill: parent + anchors.margins: -3 + radius: 0 + color: "transparent" + border.color: Colors.foreground + border.width: devRow.hoveredAction === "disconnect" ? 1 : 0 + } + Text { + anchors.centerIn: parent + text: "link_off" + font.family: Appearance.iconFontFamilyRounded + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 14 + // Stays primaryText (dark) rather than switching to + // Colors.primary while targeted — devRow's own fill is + // already Colors.primary on hover, so that would've put + // a primary-colored glyph on a primary-colored + // background. The outline Rectangle above is what shows + // which slot is targeted. + color: rowMouse.containsMouse ? Colors.primaryText : Colors.muted + } + } + + // Trust toggle — full brightness when trusted, dimmed when not, + // same "state via opacity" convention PowerButton.qml uses for + // its disabled entries. + Item { + id: trustIcon + visible: devRow.showForget + Layout.preferredWidth: 20 + Layout.preferredHeight: 20 + + Rectangle { + anchors.fill: parent + anchors.margins: -3 + radius: 0 + color: "transparent" + border.color: Colors.foreground + border.width: devRow.hoveredAction === "trust" ? 1 : 0 + } + Text { + anchors.centerIn: parent + text: "verified_user" + opacity: devRow.dev.trusted ? 1 : 0.4 + font.family: Appearance.iconFontFamilyRounded + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 14 + color: rowMouse.containsMouse ? Colors.primaryText : Colors.muted + } + } + + Item { id: forgetIcon visible: devRow.showForget - text: "close" - font.family: Appearance.iconFontFamilyRounded - font.variableAxes: Appearance.iconFontAxes - font.pixelSize: 14 - color: rowMouse.containsMouse ? Colors.primaryText : Colors.muted + Layout.preferredWidth: 20 + Layout.preferredHeight: 20 + + Rectangle { + anchors.fill: parent + anchors.margins: -3 + radius: 0 + color: "transparent" + border.color: Colors.foreground + border.width: devRow.hoveredAction === "forget" ? 1 : 0 + } + Text { + anchors.centerIn: parent + text: "close" + font.family: Appearance.iconFontFamilyRounded + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 14 + color: rowMouse.containsMouse ? Colors.primaryText : Colors.muted + } } } @@ -302,21 +416,15 @@ Item { height: rowContent.height + 12 hoverEnabled: true cursorShape: Qt.PointingHandCursor - // forgetIcon has no MouseArea of its own — nested inside - // rowContent, which sits underneath this full-row area in - // stacking order, so a separate MouseArea there would just be - // shadowed (see the identical fix in NetworkIndicator.qml's - // NetworkRow). Dispatch by click position against this single - // area instead. - onClicked: (mouse) => { - if (devRow.showForget && forgetIcon.visible) { - const forgetLeft = devRow.mapFromItem(forgetIcon, 0, 0).x - if (mouse.x >= forgetLeft - 8) { - root.forgetDevice(devRow.dev) - return - } + // Dispatches on devRow.hoveredAction rather than its own + // MouseArea per icon — see that property's comment above. + onClicked: { + switch (devRow.hoveredAction) { + case "forget": root.forgetDevice(devRow.dev); break + case "trust": root.setTrusted(devRow.dev, !devRow.dev.trusted); break + case "disconnect": root.disconnectDevice(devRow.dev); break + default: devRow.activate() } - devRow.activate() } } } @@ -327,7 +435,11 @@ Item { id: panel visible: false color: "transparent" - readonly property int contentWidth: 244 + // Wider than the other panels' usual 244 — each device row now ends + // in a row of three 20px action slots (disconnect/trust/forget) + // instead of a single close icon, and the name column needs room to + // not immediately start eliding against that. + readonly property int contentWidth: 300 readonly property int contentPadding: 16 implicitWidth: contentWidth + contentPadding * 2 implicitHeight: mainColumn.implicitHeight + contentPadding * 2 @@ -470,6 +582,42 @@ Item { } } + // Paired — always shown in full, same as Connected above and + // for the same reason: these are one click away from + // reconnecting (root.connectDevice reconnects rather than + // re-pairing whenever a row is already paired/bonded/trusted), + // so they shouldn't be buried in the capped-height scroll area + // below or hidden behind an active scan turning up other + // nearby devices. + Column { + width: parent.width + spacing: 4 + visible: root.knownRows.length > 0 + + Text { + text: "Paired Devices" + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.muted + } + Column { + // Inset 3px on each side to match deviceListColumn + // below, so DeviceRow's hover halo has room here too. + x: 3 + width: parent.width - 6 + spacing: 2 + Repeater { + model: root.knownRows + DeviceRow { + required property var modelData + dev: modelData + showForget: true + onActivate: root.connectDevice(dev) + } + } + } + } + Text { width: parent.width visible: root.adapter !== null && root.adapter.enabled && root.deviceRows.length === 0 @@ -479,9 +627,9 @@ Item { color: Colors.muted } - // Paired/Available — capped height, scrolls once there are - // more devices than fit rather than growing the popup - // indefinitely. + // Available — freshly discovered, unpaired devices only. + // Capped height, scrolls once there are more than fit rather + // than growing the popup indefinitely. Flickable { id: deviceListFlick width: parent.width @@ -507,32 +655,6 @@ Item { bottomPadding: 3 spacing: 6 - Column { - width: parent.width - spacing: 4 - visible: root.knownRows.length > 0 - - Text { - text: "Paired Devices" - font.family: Appearance.fontFamily - font.pixelSize: Appearance.fontSizeSmall - color: Colors.muted - } - Column { - width: parent.width - spacing: 2 - Repeater { - model: root.knownRows - DeviceRow { - required property var modelData - dev: modelData - showForget: true - onActivate: root.connectDevice(dev) - } - } - } - } - Column { width: parent.width spacing: 4 -- 2.49.1 From c64cf5ff1295dd1b56f3f354e8184766a9fc9de4 Mon Sep 17 00:00:00 2001 From: thomas Date: Thu, 27 Aug 2026 14:11:27 +0100 Subject: [PATCH 43/44] Fix: Restored "time to charged" --- .../modules/bar/BatteryIndicator.qml | 66 ++++++++++++++++++- 1 file changed, 63 insertions(+), 3 deletions(-) diff --git a/dotfiles/.config/quickshell/modules/bar/BatteryIndicator.qml b/dotfiles/.config/quickshell/modules/bar/BatteryIndicator.qml index ca6d3e7..8192180 100644 --- a/dotfiles/.config/quickshell/modules/bar/BatteryIndicator.qml +++ b/dotfiles/.config/quickshell/modules/bar/BatteryIndicator.qml @@ -116,6 +116,68 @@ Item { } } + // device.timeToFull/timeToEmpty are computed by Quickshell from + // energy/energyCapacity/changeRate — when upowerd's EnergyFull glitches + // to 0 (the same daemon flake HEALTH routes around above), that formula + // divides by a bogus capacity and comes out negative rather than a + // real estimate (confirmed live: timeToFull sat at -3751 while `upower + // -i` itself still reported a normal 26.7 minutes). This recomputes + // the same estimate straight from sysfs as a fallback, only used when + // Quickshell's own value isn't positive. Unlike cycle_count/design + // capacity above, energy_now and power_now change continuously while + // (dis)charging, so this polls on a timer instead of reading once. + property real sysfsEnergyNowWh: -1 + property real sysfsPowerNowW: -1 + + readonly property real fallbackTimeToFullSeconds: (root.sysfsPowerNowW > 0 && root.energyFullWh > 0 && root.sysfsEnergyNowWh >= 0) + ? Math.max(0, (root.energyFullWh - root.sysfsEnergyNowWh) / root.sysfsPowerNowW * 3600) : -1 + readonly property real fallbackTimeToEmptySeconds: (root.sysfsPowerNowW > 0 && root.sysfsEnergyNowWh > 0) + ? (root.sysfsEnergyNowWh / root.sysfsPowerNowW * 3600) : -1 + + // Prefers upowerd's own live estimate when it's sane; only falls back + // to the sysfs-computed one when upowerd's has gone zero/negative. + readonly property real timeRemainingSeconds: { + const primary = root.onBattery ? root.device.timeToEmpty : root.device.timeToFull + if (primary > 0) return primary + return root.onBattery ? root.fallbackTimeToEmptySeconds : root.fallbackTimeToFullSeconds + } + + Timer { + id: sysfsRateTimer + interval: 15000 + repeat: true + running: root.batteryPresent + triggeredOnStart: true + onTriggered: { + if (!sysfsEnergyNowProc.running) sysfsEnergyNowProc.running = true + if (!sysfsPowerNowProc.running) sysfsPowerNowProc.running = true + } + } + + Process { + id: sysfsEnergyNowProc + command: ["sh", "-c", "cat \"$(ls -d /sys/class/power_supply/BAT* 2>/dev/null | head -n1)/energy_now\" 2>/dev/null"] + stdout: StdioCollector { + id: sysfsEnergyNowCollector + onStreamFinished: { + const n = parseFloat(sysfsEnergyNowCollector.text.trim()) + root.sysfsEnergyNowWh = (isNaN(n) || n < 0) ? -1 : (n / 1000000) + } + } + } + + Process { + id: sysfsPowerNowProc + command: ["sh", "-c", "cat \"$(ls -d /sys/class/power_supply/BAT* 2>/dev/null | head -n1)/power_now\" 2>/dev/null"] + stdout: StdioCollector { + id: sysfsPowerNowCollector + onStreamFinished: { + const n = parseFloat(sysfsPowerNowCollector.text.trim()) + root.sysfsPowerNowW = (isNaN(n) || n <= 0) ? -1 : (n / 1000000) + } + } + } + // Raw UPower state string (Charging/Discharging/PendingCharge/…) — more // granular than modeLabel below, which collapses those down to the // three phrases the hero text and STATE row show. @@ -176,9 +238,7 @@ Item { }, { label: root.onBattery ? "TIME LEFT" : "TIME TO FULL", - value: root.batteryPresent - ? root.formatDuration(root.onBattery ? root.device.timeToEmpty : root.device.timeToFull) - : "—" + value: root.batteryPresent ? root.formatDuration(root.timeRemainingSeconds) : "—" }, { label: "STATUS", value: root.rawStateLabel }, { label: "CYCLES", value: root.cycleCount > 0 ? String(root.cycleCount) : "—" } -- 2.49.1 From e5809efc6ade533df865f052f841955d3b390f1f Mon Sep 17 00:00:00 2001 From: thomas Date: Thu, 27 Aug 2026 14:17:03 +0100 Subject: [PATCH 44/44] UI: Moved Updates to bluetooth devices inside bluetooth box --- .../modules/bar/BluetoothIndicator.qml | 115 +++++++++++++++++- 1 file changed, 111 insertions(+), 4 deletions(-) diff --git a/dotfiles/.config/quickshell/modules/bar/BluetoothIndicator.qml b/dotfiles/.config/quickshell/modules/bar/BluetoothIndicator.qml index 575b561..1a6b830 100644 --- a/dotfiles/.config/quickshell/modules/bar/BluetoothIndicator.qml +++ b/dotfiles/.config/quickshell/modules/bar/BluetoothIndicator.qml @@ -79,6 +79,7 @@ Item { bonded: !!d.bonded, trusted: !!d.trusted, pairing: !!d.pairing, + state: d.state, batteryAvailable: !!d.batteryAvailable, battery: d.battery || 0 } @@ -105,19 +106,107 @@ Item { return null } + // --- Action feedback --------------------------------------------------- + // BlueZ operations here are fire-and-forget from QML's side — connect()/ + // disconnect()/pair()/forget() report failure only via a C++-side log + // line (see quickshell's bluetooth/device.cpp), nothing reaches QML. So + // "did it work" has to be inferred from the device's own properties + // instead of a success/failure callback. Shown as a line right under the + // specific row it's about (DeviceRow.statusText below) rather than a + // panel-wide banner — actionStatusAddress is how a row knows a message + // is meant for it; since it's address-keyed rather than tied to a + // particular DeviceRow instance, the message still finds its row even + // when the device jumps sections (Paired → Connected on a successful + // connect, say), because that's a new DeviceRow for the same address. + // - busyStatus is purely reactive — it just reads pairing/state off + // whichever row is currently mid-operation, however that operation + // was triggered. It's not shown directly (each row already derives + // the same "Connecting…"/"Disconnecting…" off its own dev.state); + // it only exists here to tell resolvePendingAction when to fire. + // - pendingAction+the busyStatus watcher below is how a *specific* + // click gets its own outcome message: it snapshots what was asked + // for, waits for busyStatus to clear, then checks whether the + // expected end state (connected/paired) actually landed. + readonly property string busyStatus: { + for (const r of root.deviceRows) { + if (r.pairing) return "pairing:" + r.address + if (r.state === BluetoothDeviceState.Connecting) return "connecting:" + r.address + if (r.state === BluetoothDeviceState.Disconnecting) return "disconnecting:" + r.address + } + return "" + } + + property var pendingAction: null + property string actionStatus: "" + property string actionStatusAddress: "" + + // Checks whether pendingAction's expected end state actually landed and + // reports it, then clears pendingAction. Called both when busyStatus + // clears normally and from pendingTimeoutTimer below — an operation + // that resolves inside a single binding re-evaluation (a device already + // in range reconnecting near-instantly, say) can flip busyStatus from "" + // straight back to "" without ever visibly changing, which would + // otherwise leave pendingAction stuck forever and misattribute whatever + // busyStatus transition happens to come along next. + function resolvePendingAction() { + if (!root.pendingAction) return + const p = root.pendingAction + root.pendingAction = null + const d = root.deviceForAddress(p.address) + root.actionStatusAddress = p.address + if (p.kind === "disconnect") { + root.actionStatus = (d && !d.connected) ? "Disconnected" : "Couldn't disconnect" + } else if (p.kind === "pair") { + root.actionStatus = (d && d.paired) ? "Paired" : "Couldn't pair" + } else { + root.actionStatus = (d && d.connected) ? "Connected" : "Couldn't connect" + } + actionStatusTimer.restart() + } + + onBusyStatusChanged: if (root.busyStatus === "") root.resolvePendingAction() + + Timer { + id: actionStatusTimer + interval: 3500 + repeat: false + onTriggered: root.actionStatus = "" + } + + // Backstop for the "resolved before busyStatus ever visibly changed" + // case above, and a genuine timeout for an operation that's just stuck + // (device out of range, etc.) — either way, restarted whenever + // pendingAction is (re)set, so it always reflects the latest click. + Timer { + id: pendingTimeoutTimer + interval: 8000 + repeat: false + onTriggered: root.resolvePendingAction() + } + function connectDevice(row) { const d = root.deviceForAddress(row.address) if (!d) return // Already-paired devices just reconnect; a fresh discovery result // has to pair first (BlueZ's own agent handles the SSP/passkey // handshake — there's no PIN-entry surface exposed by this API). - if (row.paired || row.bonded || row.trusted) d.connect() - else d.pair() + if (row.paired || row.bonded || row.trusted) { + root.pendingAction = { address: row.address, kind: "connect" } + pendingTimeoutTimer.restart() + d.connect() + } else { + root.pendingAction = { address: row.address, kind: "pair" } + pendingTimeoutTimer.restart() + d.pair() + } } function disconnectDevice(row) { const d = root.deviceForAddress(row.address) - if (d) d.disconnect() + if (!d) return + root.pendingAction = { address: row.address, kind: "disconnect" } + pendingTimeoutTimer.restart() + d.disconnect() } function forgetDevice(row) { @@ -125,11 +214,18 @@ Item { if (!d) return if (d.connected) d.disconnect() d.forget() + // No feedback line here — forgetting removes this row from every + // list immediately, so there's nothing left to show a message + // under. The row vanishing is the confirmation. } function setTrusted(row, trusted) { const d = root.deviceForAddress(row.address) - if (d) d.trusted = trusted + if (!d) return + d.trusted = trusted + root.actionStatusAddress = row.address + root.actionStatus = trusted ? "Trusting" : "No longer trusting" + actionStatusTimer.restart() } readonly property bool anyConnected: root.connectedRows.length > 0 @@ -301,6 +397,17 @@ Item { Text { readonly property string statusText: { if (devRow.dev.pairing) return "Pairing…" + if (devRow.dev.state === BluetoothDeviceState.Connecting) return "Connecting…" + if (devRow.dev.state === BluetoothDeviceState.Disconnecting) return "Disconnecting…" + // The outcome of the last action taken on this + // specific device (connect/disconnect/pair/trust) — + // see the "Action feedback" comment above + // root.busyStatus. Matched by address rather than by + // this being "the same row" that was clicked, since + // a successful connect/disconnect moves the device + // to a different section (a new DeviceRow entirely). + if (root.actionStatusAddress === devRow.dev.address && root.actionStatus !== "") + return root.actionStatus if (devRow.dev.connected && devRow.dev.batteryAvailable) return Math.round(devRow.dev.battery * 100) + "%" if (devRow.dev.connected) return "Connected" -- 2.49.1