Compare commits
50 Commits
features/u
...
d306270dc8
| Author | SHA1 | Date | |
|---|---|---|---|
| d306270dc8 | |||
| 03b043d9d3 | |||
| 1ff92f3b79 | |||
| e5809efc6a | |||
| c64cf5ff12 | |||
| d6f79685f0 | |||
| 9e532023bd | |||
| c2c6675f4b | |||
| fcce5f304b | |||
| 4b0b5c72ba | |||
| 3f4e3514a2 | |||
| 622afb2100 | |||
| 3ae5c57303 | |||
| ef06a96757 | |||
| 03ea182c8c | |||
| ba8a565cda | |||
| c8a9c30fc5 | |||
| 185b752393 | |||
| 8515f3f5ce | |||
| 86921f7aab | |||
| cede2a1690 | |||
| 00e4368abe | |||
| 00d7dcb411 | |||
| 4bdb590899 | |||
| 44e73f5a9d | |||
| 3184c61267 | |||
| fb79f0a37e | |||
| 7a1073646a | |||
| 90d068a878 | |||
| 57e5c0c7b3 | |||
| b7a92c9a59 | |||
| d3856ada0d | |||
| 3601f52600 | |||
| 98f9a77b23 | |||
| bc242ae956 | |||
| 4237b2ed9e | |||
| 7b0cf9934b | |||
| 9df5731ee1 | |||
| 3806935316 | |||
| 516f8eed82 | |||
| 369179c057 | |||
| 322804577f | |||
| 0eee14858b | |||
| 461f4c3fff | |||
| 2f9dfda410 | |||
| e35ceb1450 | |||
| 3d0a18866f | |||
| 6c415d6fbb | |||
| c4b4a3bd26 | |||
| 866ee0bb9b |
@@ -59,7 +59,7 @@ List of programs and tools I use.
|
|||||||
|📁 File Manager | [thunar](https://wiki.archlinux.org/title/Thunar) |
|
|📁 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) |
|
|📝 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) |
|
|🌐 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) |
|
|🚀 Launcher | [rofi](https://github.com/davatorium/rofi) |
|
||||||
|🔒 Lockscreen | [hyprlock](https://github.com/hyprwm/hyprlock) |
|
|🔒 Lockscreen | [hyprlock](https://github.com/hyprwm/hyprlock) |
|
||||||
|🚪 Login Menu | [sddm](https://github.com/sddm/sddm) |
|
|🚪 Login Menu | [sddm](https://github.com/sddm/sddm) |
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
-- Ported from UserConfigs/UserAnimations.conf (the active preset)
|
-- Ported from UserConfigs/UserAnimations.conf (the active preset)
|
||||||
--
|
--
|
||||||
-- The 14 alternative presets from animations/*.conf were also ported, flat, as
|
-- 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
|
-- anim-*.lua (see the plan) — none of them are wired in. They now live in ../presets/.
|
||||||
-- the body of this file with `require("anim-<preset>")`, e.g. require("anim-ml4w-classic").
|
-- To switch styles, replace the body of this file with `require("presets.anim-<preset>")`,
|
||||||
|
-- e.g. require("presets.anim-ml4w-classic").
|
||||||
|
|
||||||
hl.config({
|
hl.config({
|
||||||
animations = {
|
animations = {
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
-- Animation - https://wiki.hyprland.org/Configuring/Animations/
|
-- Animation - https://wiki.hyprland.org/Configuring/Animations/
|
||||||
-- Decoration - https://wiki.hyprland.org/Configuring/Variables/#decoration
|
-- Decoration - https://wiki.hyprland.org/Configuring/Variables/#decoration
|
||||||
|
|
||||||
local colors = require("colors")
|
local colors = require("appearance.colors")
|
||||||
|
|
||||||
hl.config({
|
hl.config({
|
||||||
general = {
|
general = {
|
||||||
@@ -33,6 +33,9 @@ hl.on("hyprland.start", function()
|
|||||||
-- Starting hypridle to start hyprlock
|
-- Starting hypridle to start hyprlock
|
||||||
hl.exec_cmd("hypridle")
|
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 }))
|
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
|
-- NOTE: the trailing "&&" here is a stray leftover from the original .conf (dangling, does nothing) — ported as-is
|
||||||
@@ -2,9 +2,13 @@
|
|||||||
-- Ported from hyprland.conf — see https://wiki.hyprland.org/
|
-- Ported from hyprland.conf — see https://wiki.hyprland.org/
|
||||||
--
|
--
|
||||||
-- This mirrors the old source= chain 1:1, just as require()'d Lua modules instead
|
-- 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
|
-- (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.
|
-- 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
|
-- 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)
|
end)
|
||||||
|
|
||||||
-- ## This is where you want to start tinkering
|
-- ## This is where you want to start tinkering
|
||||||
require("startup") -- put your start-up packages in startup.lua
|
require("core.startup") -- put your start-up packages in core/startup.lua
|
||||||
require("env") -- Environment variables to load
|
require("core.env") -- Environment variables to load
|
||||||
require("windowrules") -- all about Hyprland Window Rules and Layer Rules
|
require("layout.windowrules") -- all about Hyprland Window Rules and Layer Rules
|
||||||
require("decorations") -- Decorations config
|
require("appearance.decorations") -- Decorations config
|
||||||
require("animations") -- Animation config
|
require("appearance.animations") -- Animation config
|
||||||
require("keybinds") -- Put your own keybinds here
|
require("input.keybinds") -- Put your own keybinds here
|
||||||
require("settings") -- Main Hyprland Settings
|
require("core.settings") -- Main Hyprland Settings
|
||||||
|
|
||||||
-- nwg-displays / hyprdynamicmonitors
|
-- nwg-displays / hyprdynamicmonitors
|
||||||
require("monitors")
|
require("layout.monitors")
|
||||||
require("workspaces")
|
require("layout.workspaces")
|
||||||
|
|
||||||
-- NOTE: the original hyprland.conf used a plain `exec` (not `exec-once`) here, so it
|
-- 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", ...)
|
-- re-ran on every `hyprctl reload`, not just at startup. hl.on("hyprland.start", ...)
|
||||||
@@ -35,4 +39,4 @@ hl.on("hyprland.start", function()
|
|||||||
end)
|
end)
|
||||||
|
|
||||||
-- HyprMod managed settings
|
-- HyprMod managed settings
|
||||||
require("hyprmod")
|
require("core.hyprmod")
|
||||||
|
|||||||
73
dotfiles/.config/hypr/scripts/FloatBitwardenPopup.sh
Executable file
73
dotfiles/.config/hypr/scripts/FloatBitwardenPopup.sh
Executable file
@@ -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
|
||||||
29
dotfiles/.config/quickshell/config/Appearance.qml
Normal file
29
dotfiles/.config/quickshell/config/Appearance.qml
Normal file
@@ -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
|
||||||
|
}
|
||||||
62
dotfiles/.config/quickshell/config/Colors.qml
Normal file
62
dotfiles/.config/quickshell/config/Colors.qml
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
16
dotfiles/.config/quickshell/config/VpnExclusivity.qml
Normal file
16
dotfiles/.config/quickshell/config/VpnExclusivity.qml
Normal file
@@ -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
|
||||||
|
}
|
||||||
4
dotfiles/.config/quickshell/config/qmldir
Normal file
4
dotfiles/.config/quickshell/config/qmldir
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
module config
|
||||||
|
singleton Colors 1.0 Colors.qml
|
||||||
|
singleton Appearance 1.0 Appearance.qml
|
||||||
|
singleton VpnExclusivity 1.0 VpnExclusivity.qml
|
||||||
13
dotfiles/.config/quickshell/modules/bar/ActiveWindow.qml
Normal file
13
dotfiles/.config/quickshell/modules/bar/ActiveWindow.qml
Normal file
@@ -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 : ""
|
||||||
|
}
|
||||||
58
dotfiles/.config/quickshell/modules/bar/Bar.qml
Normal file
58
dotfiles/.config/quickshell/modules/bar/Bar.qml
Normal file
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
605
dotfiles/.config/quickshell/modules/bar/BatteryIndicator.qml
Normal file
605
dotfiles/.config/quickshell/modules/bar/BatteryIndicator.qml
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
795
dotfiles/.config/quickshell/modules/bar/BluetoothIndicator.qml
Normal file
795
dotfiles/.config/quickshell/modules/bar/BluetoothIndicator.qml
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
18
dotfiles/.config/quickshell/modules/bar/Clock.qml
Normal file
18
dotfiles/.config/quickshell/modules/bar/Clock.qml
Normal file
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
1352
dotfiles/.config/quickshell/modules/bar/NetworkIndicator.qml
Normal file
1352
dotfiles/.config/quickshell/modules/bar/NetworkIndicator.qml
Normal file
File diff suppressed because it is too large
Load Diff
200
dotfiles/.config/quickshell/modules/bar/PowerButton.qml
Normal file
200
dotfiles/.config/quickshell/modules/bar/PowerButton.qml
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1175
dotfiles/.config/quickshell/modules/bar/TailscaleIndicator.qml
Normal file
1175
dotfiles/.config/quickshell/modules/bar/TailscaleIndicator.qml
Normal file
File diff suppressed because it is too large
Load Diff
32
dotfiles/.config/quickshell/modules/bar/Tray.qml
Normal file
32
dotfiles/.config/quickshell/modules/bar/Tray.qml
Normal file
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
496
dotfiles/.config/quickshell/modules/bar/Volume.qml
Normal file
496
dotfiles/.config/quickshell/modules/bar/Volume.qml
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
594
dotfiles/.config/quickshell/modules/bar/VpnIndicator.qml
Normal file
594
dotfiles/.config/quickshell/modules/bar/VpnIndicator.qml
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
44
dotfiles/.config/quickshell/modules/bar/Workspaces.qml
Normal file
44
dotfiles/.config/quickshell/modules/bar/Workspaces.qml
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
125
dotfiles/.config/quickshell/modules/osd/VolumeOsd.qml
Normal file
125
dotfiles/.config/quickshell/modules/osd/VolumeOsd.qml
Normal file
@@ -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) + "%"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
259
dotfiles/.config/quickshell/scripts/CheckQuickshellSetup.sh
Executable file
259
dotfiles/.config/quickshell/scripts/CheckQuickshellSetup.sh
Executable file
@@ -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
|
||||||
16
dotfiles/.config/quickshell/shell.qml
Normal file
16
dotfiles/.config/quickshell/shell.qml
Normal file
@@ -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 {}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user