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) | 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 92% rename from dotfiles/.config/hypr/startup.lua rename to dotfiles/.config/hypr/core/startup.lua index 6824928..1878add 100644 --- a/dotfiles/.config/hypr/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/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 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 diff --git a/dotfiles/.config/quickshell/config/Appearance.qml b/dotfiles/.config/quickshell/config/Appearance.qml new file mode 100644 index 0000000..324b40f --- /dev/null +++ b/dotfiles/.config/quickshell/config/Appearance.qml @@ -0,0 +1,29 @@ +pragma Singleton +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 + // 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..62a35b1 --- /dev/null +++ b/dotfiles/.config/quickshell/config/Colors.qml @@ -0,0 +1,62 @@ +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) + // 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 + // 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/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 +} diff --git a/dotfiles/.config/quickshell/config/qmldir b/dotfiles/.config/quickshell/config/qmldir new file mode 100644 index 0000000..858c68b --- /dev/null +++ b/dotfiles/.config/quickshell/config/qmldir @@ -0,0 +1,4 @@ +module config +singleton Colors 1.0 Colors.qml +singleton Appearance 1.0 Appearance.qml +singleton VpnExclusivity 1.0 VpnExclusivity.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..149caa7 --- /dev/null +++ b/dotfiles/.config/quickshell/modules/bar/Bar.qml @@ -0,0 +1,58 @@ +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 {} + TailscaleIndicator { barWindow: bar } + VpnIndicator { barWindow: bar } + 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..8192180 --- /dev/null +++ b/dotfiles/.config/quickshell/modules/bar/BatteryIndicator.qml @@ -0,0 +1,605 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Hyprland +import Quickshell.Io +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" (computed from sysfs energy_full/design) 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 + + // 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) + } + } + } + + // 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. + 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 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))))] + } + + 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" + } + + // 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.timeRemainingSeconds) : "β€”" + }, + { 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. + 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 + } + + // --- 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 + 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() + } + } + + // --- Panel ----------------------------------------------------------- + + PopupWindow { + id: panel + visible: false + color: "transparent" + // 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 + + 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 β€” 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 + 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 + } + } + } + + // 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 + } + } + } + } + } + } + } +} diff --git a/dotfiles/.config/quickshell/modules/bar/BluetoothIndicator.qml b/dotfiles/.config/quickshell/modules/bar/BluetoothIndicator.qml new file mode 100644 index 0000000..1a6b830 --- /dev/null +++ b/dotfiles/.config/quickshell/modules/bar/BluetoothIndicator.qml @@ -0,0 +1,795 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Hyprland +import Quickshell.Bluetooth +import "../../config" + +Item { + id: root + // 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 + // 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 + + // 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, + state: d.state, + 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 + } + + // --- 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) { + 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) return + root.pendingAction = { address: row.address, kind: "disconnect" } + pendingTimeoutTimer.restart() + d.disconnect() + } + + function forgetDevice(row) { + const d = root.deviceForAddress(row.address) + 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) 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 + + // 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.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.leftMargin: root.iconLeftPadding + font.family: Appearance.iconFontFamilyRounded + 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: { + 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 + } + // 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 + 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 { + id: toggleMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: toggleRoot.toggled() + } + } + + // 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 + property bool showForget: true + signal activate() + 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 + // 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. + // 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 + 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.iconFontFamilyRounded + 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.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" + return "" + } + visible: statusText !== "" + text: statusText + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: rowMouse.containsMouse ? Colors.primaryText : Colors.muted + } + } + + // 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 + 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 + } + } + } + + MouseArea { + id: rowMouse + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + height: rowContent.height + 12 + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + // 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() + } + } + } + } + + // --- Panel ------------------------------------------------------------- + + PopupWindow { + id: panel + visible: false + color: "transparent" + // 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 + // 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.iconFontFamilyRounded + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 21 + color: Colors.foreground + } + + Text { + Layout.fillWidth: true + text: "Bluetooth" + font.family: Appearance.fontFamily + font.pixelSize: 18 + font.bold: true + 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 + 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 { + // 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 + DeviceRow { + required property var modelData + dev: modelData + showForget: true + onActivate: root.disconnectDevice(dev) + } + } + } + } + + // 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 + text: (root.adapter && root.adapter.discovering) ? "Scanning for devices…" : "No devices found" + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.muted + } + + // 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 + height: Math.min(deviceListColumn.implicitHeight, root.maxDeviceListHeight) + contentWidth: width + contentHeight: deviceListColumn.implicitHeight + clip: true + boundsBehavior: Flickable.StopAtBounds + visible: deviceListColumn.implicitHeight > 0 + + Column { + id: deviceListColumn + // 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 { + 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) + } + } + } + } + } + } + } + } + } +} 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..097805e --- /dev/null +++ b/dotfiles/.config/quickshell/modules/bar/NetworkIndicator.qml @@ -0,0 +1,1352 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Hyprland +import Quickshell.Io +import Quickshell.Networking +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 + + 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 + } + + 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. 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" + 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 })) + : [] + + // 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. + 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 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"). + // 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" + } + + // 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)) { + const n = root.networkForSsid(net.ssid) + if (n) n.connect() + } else { + root.passwordSsid = root.passwordSsid === net.ssid ? "" : net.ssid + } + } + + 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 + // 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 + font.family: Appearance.iconFontFamilyRounded + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 16 + color: root.connectedDevice ? Colors.foreground : Colors.muted + // 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 "signal_wifi_off" + 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 + } + } + + 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 + } + // 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 + 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 { + id: toggleMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: toggleRoot.toggled() + } + } + + component NetworkRow: Rectangle { + 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 + // 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 + // 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 + anchors.margins: 6 + spacing: 6 + + RowLayout { + id: rowContent + width: parent.width + spacing: 8 + + Text { + 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: 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 + // 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.iconFontFamilyRounded + 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.iconFontFamilyRounded + 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 β€” 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 { + id: headerIcon + text: label.text + 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 { + Layout.fillWidth: true + text: "Network" + elide: Text.ElideRight + font.family: Appearance.fontFamily + font.pixelSize: 18 + font.bold: true + color: Colors.foreground + } + } + + // 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 { + 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 + + // 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 + 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.iconFontFamilyRounded + 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.iconFontFamilyRounded + 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 + } + + // 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 + } + } + + 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 + } + + // 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. + 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 + // 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. 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 { + 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) + } + } + } + } + } + } + } + } + } + } +} diff --git a/dotfiles/.config/quickshell/modules/bar/PowerButton.qml b/dotfiles/.config/quickshell/modules/bar/PowerButton.qml new file mode 100644 index 0000000..45cefa2 --- /dev/null +++ b/dotfiles/.config/quickshell/modules/bar/PowerButton.qml @@ -0,0 +1,200 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import Quickshell.Hyprland +import "../../config" + +Item { + id: root + implicitWidth: label.implicitWidth + 2 + 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 + font.family: Appearance.iconFontFamilyRounded + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 16 + color: Colors.foreground + text: "settings_power" + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + menu.visible = !menu.visible + grab.active = menu.visible + } + } + + Process { id: proc } + + PopupWindow { + id: menu + visible: false + color: "transparent" + // 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 + 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: [menu] + onCleared: menu.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: menu.contentPadding + spacing: 10 + + // 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 + + Text { + text: label.text + font.family: Appearance.iconFontFamilyRounded + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 23 + color: 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"] } + ] + + // 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 + radius: 0 + implicitHeight: entryContent.implicitHeight + 12 + color: (!entry.modelData.disabled && entryMouse.containsMouse) ? Colors.primary : "transparent" + + Rectangle { + anchors.fill: parent + 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: 20 + Layout.minimumWidth: 20 + Layout.maximumWidth: 20 + horizontalAlignment: Text.AlignLeft + text: entry.modelData.icon + font.family: Appearance.iconFontFamilyRounded + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 14 + opacity: entry.modelData.disabled ? 0.4 : 1 + color: entryMouse.containsMouse ? Colors.primaryText : Colors.foreground + } + Text { + Layout.fillWidth: true + text: entry.modelData.label + elide: Text.ElideRight + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSize + opacity: entry.modelData.disabled ? 0.4 : 1 + color: entryMouse.containsMouse ? Colors.primaryText : Colors.foreground + } + } + + 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/TailscaleIndicator.qml b/dotfiles/.config/quickshell/modules/bar/TailscaleIndicator.qml new file mode 100644 index 0000000..b39cd38 --- /dev/null +++ b/dotfiles/.config/quickshell/modules/bar/TailscaleIndicator.qml @@ -0,0 +1,1175 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Hyprland +import Quickshell.Io +import "../../config" + +Item { + id: root + implicitWidth: label.implicitWidth + 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 + + // 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. + 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 + + // 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) + 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) }) + 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 + }) + } + 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 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 + 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 + exitNodeProc.command = ["tailscale", "set", "--exit-node=" + (peer ? peer.ip : "")] + exitNodeProc.running = true + } + + function copyToClipboard(text) { + 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.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 + } + } + + Process { + id: exitNodeProc + stdout: StdioCollector {} + onExited: (exitCode, exitStatus) => { + root.busy = false + if (!statusProc.running) statusProc.running = true + } + } + + 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 { + id: refreshTimer + interval: root._refreshIntervalMs + repeat: true + running: true + triggeredOnStart: 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 + // 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. 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 + + 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 - 1 + height: logo.dotSize - 1 + radius: width / 2 + color: modelData ? '#c9f0f0f0' : logo.dimColor + } + } + } + + 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.active ? Colors.foreground : Colors.muted + crossed: !root.active && !root.status.needsLogin + warning: root.status.needsLogin + } + + 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 + } + // 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 + 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 { + id: toggleMouse + anchors.fill: parent + hoverEnabled: true + 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 + // 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. + // 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 + 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.iconFontFamilyRounded + 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.iconFontFamilyRounded + 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() + } + } + + // 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 + // 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 + // 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 + 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.iconFontFamilyRounded + 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.iconFontFamilyRounded + 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 + + // 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 + 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.iconFontFamilyRounded + 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.iconFontFamilyRounded + 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 + + // 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 + 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 { + 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 } + } + + 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 + 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: 25 + color: label.color + crossed: label.crossed + warning: label.warning + } + + Text { + Layout.fillWidth: true + text: "Tailscale" + font.family: Appearance.fontFamily + font.pixelSize: 18 + font.bold: true + color: Colors.foreground + } + + ToggleSwitch { + 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. Doubles as the + // manual fallback if the automatic browser-open above missed. + 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.iconFontFamilyRounded + 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: root.openAuthUrl(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.iconFontFamilyRounded + 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.installed && !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 + // Inset 3px on every side so ExitNodeRow/PeerRow's + // hover halo has room without being cropped by + // 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 { + 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 + PeerRow { + required property var modelData + peer: modelData + } + } + } + } + } + } + } + } + } +} 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..637d971 --- /dev/null +++ b/dotfiles/.config/quickshell/modules/bar/Volume.qml @@ -0,0 +1,496 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Hyprland +import Quickshell.Services.Pipewire +import "../../config" + +Item { + id: root + implicitWidth: label.implicitWidth + 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 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 + } + + 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. + 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() + } + + // 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) + if (blob.indexOf("headphone") !== -1 || blob.indexOf("headset") !== -1 + || blob.indexOf("earbud") !== -1 || blob.indexOf("earphone") !== -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("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("webcam") !== -1 || blob.indexOf("camera") !== -1) return "videocam" + return "mic" + } + + Text { + id: label + anchors.centerIn: parent + font.family: Appearance.iconFontFamilyRounded + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 18 + color: Colors.foreground + text: (root.outputMuted || root.outputVolume <= 0) ? "volume_off" + : (root.outputVolume < 0.5 ? "volume_down" : "volume_up") + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + panel.visible = !panel.visible + grab.active = panel.visible + } + 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)) + } + } + + // --- 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") + + // 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 + anchors.rightMargin: 8 + spacing: 8 + Text { + Layout.preferredWidth: 18 + Layout.minimumWidth: 18 + Layout.maximumWidth: 18 + horizontalAlignment: Text.AlignHCenter + text: deviceRow.icon + font.family: Appearance.iconFontFamilyRounded + 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 - 2 + spacing: 10 + + // Header + RowLayout { + width: parent.width + spacing: 8 + + Text { + text: label.text + font.family: Appearance.iconFontFamilyRounded + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 30 + 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 } + } + + LevelMeter { + width: parent.width + level: outputPeak.peak + } + + Column { + width: parent.width + spacing: 4 + 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: 4 + 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 + } + } + } + } + } + } + } +} diff --git a/dotfiles/.config/quickshell/modules/bar/VpnIndicator.qml b/dotfiles/.config/quickshell/modules/bar/VpnIndicator.qml new file mode 100644 index 0000000..aa0d93f --- /dev/null +++ b/dotfiles/.config/quickshell/modules/bar/VpnIndicator.qml @@ -0,0 +1,594 @@ +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 ------------------------------------------------------------ + + // 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 + font.family: Appearance.iconFontFamilyRounded + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: Appearance.iconSize + color: root.anyConnected ? Colors.primary : Colors.muted + 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 + 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) + } + // 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 + 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 { + id: toggleMouse + anchors.fill: parent + enabled: !toggleRoot.busy + hoverEnabled: true + 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.iconFontFamilyRounded + 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.iconFontFamilyRounded + 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: "gpp_good" + 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 + } + } + } + } +} 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..52f6e68 --- /dev/null +++ b/dotfiles/.config/quickshell/modules/osd/VolumeOsd.qml @@ -0,0 +1,125 @@ +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 + + // 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: 272 + 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.iconFontFamilyRounded + 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" + } + } + + Item { + id: slider + Layout.fillWidth: true + implicitHeight: 16 + + readonly property real value: osd.sink && osd.sink.audio ? osd.sink.audio.volume : 0 + readonly property bool enabled: !!osd.sink + + 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, 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) + "%" + } + } + } +} 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 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 {} +}