60 Commits

Author SHA1 Message Date
03b043d9d3 Merge branch 'features/adding_quickshell' of ssh://gitea.thomasleewilliams.co.uk:2222/thomas/dotfiles into features/adding_quickshell 2026-08-27 14:51:01 +01:00
1ff92f3b79 Merge pull request 'features/monitor_options' (#5) from features/monitor_options into features/adding_quickshell
Reviewed-on: #5
2026-08-27 14:46:07 +01:00
e5809efc6a UI: Moved Updates to bluetooth devices inside bluetooth box 2026-08-27 14:17:03 +01:00
c64cf5ff12 Fix: Restored "time to charged" 2026-08-27 14:11:27 +01:00
d6f79685f0 Feature: Extra functionablity to bluetooth actions 2026-08-27 14:07:52 +01:00
9e532023bd Fix: Corrected battery behavior 2026-08-27 14:06:21 +01:00
c2c6675f4b Feature: Added battery percentage/view 2026-08-27 13:18:46 +01:00
fcce5f304b UI: Fixed hover colours 2026-08-27 13:02:34 +01:00
4b0b5c72ba UI: Fixed padding error 2026-08-27 13:02:02 +01:00
3f4e3514a2 Fix: Icons now map correctly to device 2026-08-27 11:45:14 +01:00
622afb2100 UI: fixed selectors & added spacing 2026-08-27 11:28:57 +01:00
3ae5c57303 Feature: Added Volume Sink 2026-08-27 11:21:41 +01:00
ef06a96757 UI: new header & current connected interface 2026-08-27 11:13:09 +01:00
03ea182c8c Feature: Added wifi scanning indicator 2026-08-27 10:53:56 +01:00
ba8a565cda Feature: added scanner indicator 2026-08-27 10:45:50 +01:00
c8a9c30fc5 Fix: hover button fix 2026-08-27 10:45:36 +01:00
185b752393 Fix: removed previous border 2026-08-27 10:23:13 +01:00
8515f3f5ce Fix: Added borders to selectors 2026-08-27 10:22:51 +01:00
86921f7aab Feature: new selectors 2026-08-27 09:39:32 +01:00
cede2a1690 Feature: Password input 2026-08-27 09:18:57 +01:00
00e4368abe Fix: corrected icons 2026-08-27 09:18:42 +01:00
00d7dcb411 Fix: changed icon for power 2026-08-27 08:50:29 +01:00
4bdb590899 Feature: added connected indicator to VPN 2026-08-27 08:48:08 +01:00
44e73f5a9d Fix: corrected padding for bar icon 2026-08-27 08:17:37 +01:00
3184c61267 Fix: increased icon size 2026-08-27 08:16:51 +01:00
fb79f0a37e Fix: corrected wifi sizing 2026-08-27 08:12:00 +01:00
7a1073646a Fix: changed sizing of logo to be consistant 2026-08-27 07:49:36 +01:00
90d068a878 Fix: fixed tailscale logo sizing to match others 2026-08-27 07:48:27 +01:00
57e5c0c7b3 Fixes: removed extra padding on icon 2026-08-27 07:42:37 +01:00
b7a92c9a59 Changes: replaced icons with material icons 2026-08-27 07:40:53 +01:00
d3856ada0d utility: checking all dependencies 2026-08-27 00:38:50 +01:00
3601f52600 Merge pull request 'feature: float bitwarden extension window' (#4) from features/floating_bitwarden_extension into features/adding_quickshell
fine by me
2026-08-26 21:09:06 +01:00
98f9a77b23 feature: float bitwarden extension window 2026-08-26 21:05:03 +01:00
bc242ae956 updated to add quickshell 2026-08-26 20:21:08 +01:00
4237b2ed9e Merge pull request 'migration: lua' (#2) from features/hypr_lua_config into features/adding_quickshell
Reviewed-on: #2

pulling into changes
2026-08-26 20:17:28 +01:00
7b0cf9934b migration: lua 2026-08-26 20:14:43 +01:00
9df5731ee1 changed volume location and widget colours 2026-08-25 09:35:22 +01:00
3806935316 implimented multiple VPN connectors 2026-08-25 09:34:55 +01:00
516f8eed82 removed tray 2026-08-25 09:34:19 +01:00
369179c057 added vpn exclusitivity to allow functions to coexist 2026-08-25 09:34:04 +01:00
322804577f added vpn exclusitivity to allow functions to coexist 2026-08-25 09:33:45 +01:00
0eee14858b basic tailscale widget 2026-08-25 07:25:38 +01:00
461f4c3fff added tailscale widget 2026-08-25 07:25:27 +01:00
2f9dfda410 added initial bluetooth widget 2026-08-24 15:12:27 +01:00
e35ceb1450 added bluetooth widget 2026-08-24 15:10:55 +01:00
3d0a18866f implimented network widget 2026-08-24 15:07:18 +01:00
6c415d6fbb added starting funtionality 2026-08-24 15:06:58 +01:00
c4b4a3bd26 added quickshell config 2026-08-24 10:03:59 +01:00
866ee0bb9b Merge pull request 'features/updating_fonts' (#1) from features/updating_fonts into main
Reviewed-on: #1
2026-08-21 09:22:44 +01:00
e77c08a0a2 changed spacings for battery 2026-08-21 09:20:17 +01:00
491509e864 corrected padding so all items are verticallt alligned 2026-08-21 08:38:49 +01:00
c4604f8e0f changed spacing for date 2026-08-21 08:34:48 +01:00
30d5f4c926 adjusted spacing 2026-08-21 08:32:42 +01:00
90d7042995 changed text for alt & updated spacing 2026-08-21 08:28:16 +01:00
b6714cf20b changed spacing for bitwarden launcher 2026-08-21 08:22:28 +01:00
8877835ae0 fixed spacing for bluetooth and wireplumber 2026-08-21 08:20:40 +01:00
32dd10c83d returned wifi icons 2026-08-21 07:46:02 +01:00
f3df5e4f3d changed ethernet and wifi icons 2026-08-21 07:43:56 +01:00
74953850fb updating fonts 2026-08-21 07:21:51 +01:00
04e50bad3f reverted back to config working on laptop 2026-08-21 06:44:17 +01:00
52 changed files with 6036 additions and 54 deletions

View File

@@ -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) |

View File

@@ -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 = {

View File

@@ -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 = {

View File

@@ -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

View File

@@ -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")

View 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

View 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
}

View 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
}
}
}

View 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
}

View 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

View 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 : ""
}

View 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 }
}
}

View 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
}
}
}
}
}
}
}
}

View 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)
}
}
}
}
}
}
}
}
}
}

View 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")
}
}

File diff suppressed because it is too large Load Diff

View 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
}
}
}
}
}
}
}
}
}

File diff suppressed because it is too large Load Diff

View 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()
}
}
}
}
}

View 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
}
}
}
}
}
}
}
}

View 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
}
}
}
}
}

View 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)
}
}
}
}

View 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) + "%"
}
}
}
}

View 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

View 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 {}
}

View File

@@ -1,15 +1,15 @@
{ {
"modules-left": ["custom/notification", "battery", "clock#date", "clock","custom/updater", "custom/launcher"], "modules-left": ["custom/notification", "battery", "clock#date", "clock","custom/updater", "custom/launcher"],
"modules-center": ["hyprland/workspaces"], "modules-center": ["hyprland/workspaces"],
"modules-right": ["network", "bluetooth", "pulseaudio", "custom/power"], "modules-right": ["network", "bluetooth", "wireplumber", "wireplumber#microphone", "custom/power"],
"sway/mode": { "sway/mode": {
"format": "<span style=\"italic\">{}</span>" "format": "<span style=\"italic\">{}</span>"
}, },
"network": { "network": {
"format-wifi": "<span color='#962fb5' font_family='NotoSansMono Nerd Font' size='16pt' baseline_shift='-3.10pt'>{icon}</span><span letter_spacing='1000'> </span>{essid} ({signalStrength}%)", "format-wifi": "<span color='#962fb5'>{icon}<span letter_spacing='500'> </span></span>{essid}({signalStrength}%)",
"format-ethernet": "<span color='#962fb5' font_family='NotoSansMono Nerd Font' size='17pt' baseline_shift='-3.10pt'>{icon}</span><span letter_spacing='1000'> </span>Ethernet", "format-ethernet": "<span color='#962fb5'font='bold'>{icon}<span letter_spacing='500'> </span></span><span rise='1000'>Ethernet</span>",
"format-disconnected": "<span font_family='NotoSansMono Nerd Font' size='16pt' baseline_shift='-3.10pt'>󰅛</span><span letter_spacing='1000'> </span>Not Connected", "format-disconnected": "󰅛<span letter_spacing='500'> </span><span rise='1000'>Not Connected</span>",
"tooltip-format": "{ipaddr} via {gwaddr}", "tooltip-format": "{ipaddr} via {gwaddr}",
"tooltip-format-disconnected": "Disconnected", "tooltip-format-disconnected": "Disconnected",
"max-length": 50, "max-length": 50,
@@ -17,7 +17,7 @@
"on-click-right": "kitty -e nmtui", "on-click-right": "kitty -e nmtui",
"format-icons": { "format-icons": {
"wifi": ["󰤯", "󰤟", "󰤢", "󰤥", "󰤨"], "wifi": ["󰤯", "󰤟", "󰤢", "󰤥", "󰤨"],
"ethernet": "", "ethernet": "󰈀",
"disconnected": "󰅛" "disconnected": "󰅛"
} }
}, },
@@ -33,10 +33,10 @@
"spacing": 10 "spacing": 10
}, },
"clock": { "clock": {
"format": "<span size='16pt' baseline_shift='-3.10pt'></span><span letter_spacing='1000'> </span>{:%H:%M}" "format": "<span rise='300'>{:%H:%M}</span>"
}, },
"clock#date": { "clock#date": {
"format": " {:%A, %B %d, %Y}", "format": " <span rise='300'>{:%A, %B %d, %Y}</span>",
"tooltip-format": "<tt><small>{calendar}</small></tt>", "tooltip-format": "<tt><small>{calendar}</small></tt>",
"calendar": { "calendar": {
"mode" : "year", "mode" : "year",
@@ -55,27 +55,30 @@
"on-click-right": "mode" "on-click-right": "mode"
} }
}, },
"pulseaudio": { "wireplumber": {
"format": "{icon}{volume}% {format_source}", "format": "<span rise='300'>{icon}</span>{volume}%",
"format-muted": " {format_source}", "format-muted": "",
"format-bluetooth": "{icon} {volume}% {format_source}", "format-bluetooth": "{icon}{volume}%",
"format-bluetooth-muted": " {format_source}", "format-bluetooth-muted": "",
"format-source": "<span font_family='NotoSansMono Nerd Font' size='17pt' baseline_shift='-3.20pt'></span><span letter_spacing='200'> </span>{volume}%", "format-icons": [
"format-source-muted": "<span font_family='NotoSansMono Nerd Font' size='17pt' baseline_shift='-3.20pt'></span><span letter_spacing='200'> </span>{volume}%", "<span color='#808080'><span letter_spacing='500'> </span></span>",
"format-icons": { "<span color='#ffff66'><span letter_spacing='500'> </span></span>",
"default": [ "<span color='#00ff7f'><span letter_spacing='3000'> </span></span>"
"<span color='#808080' font_family='NotoSansMono Nerd Font' size='17pt' baseline_shift='-3.20pt'></span><span letter_spacing='600'> </span>", ],
"<span color='#ffff66' font_family='NotoSansMono Nerd Font' size='17pt' baseline_shift='-3.20pt'></span><span letter_spacing='600'> </span>", "on-click": "pwvucontrol"
"<span color='#00ff7f' font_family='NotoSansMono Nerd Font' size='17pt' baseline_shift='-3.20pt'></span><span letter_spacing='600'> </span>"] },
}, "wireplumber#microphone": {
"on-click": "pavucontrol" "node-type": "Audio/Source",
"format": "<span letter_spacing='100'> </span><span rise='300'>{volume}%</span>",
"format-muted": "<span letter_spacing='2000'> </span>{volume}%",
"on-click": "pwvucontrol"
}, },
"bluetooth": { "bluetooth": {
"format-on": "<span color='#00ff7f' size='13pt' baseline_shift='-2.0pt'></span> On", "format-on": "<span color='#00ff7f'><span letter_spacing='500'> </span></span>On",
"format-off": "<span color='#888888' size='13pt' baseline_shift='-2.00pt'>󰂲</span> Off", "format-off": "<span color='#888888'>󰂲 Off</span>",
"format-disabled": "<span color='#888888' size='13pt' baseline_shift='-2.00pt'>󰂲</span> Off", "format-disabled": "<span color='#888888'>󰂲 Off</span>",
"format-connected": "<span color='#0478d6' size='13pt' baseline_shift='-2.00pt'>󰂱</span> {device_alias}", "format-connected": "<span color='#0478d6'>󰂱<span letter_spacing='500'> </span></span>{device_alias}",
"format-connected-battery": "<span color='#0478d6' size='13pt' baseline_shift='-2.00pt'>󰂱 </span> {device_alias} <span color='#0478d6' size='16pt' baseline_shift='-3.10pt'>󰥈 </span> {device_battery_percentage}%", "format-connected-battery": "<span color='#0478d6'>󰂱<span letter_spacing='500'> </span></span>{device_alias}<span letter_spacing='500'> </span><span color='#0478d6'>󰥈<span letter_spacing='500'> </span></span>{device_battery_percentage}%",
"tooltip-format": "{controller_alias}\t{controller_address}\n\n{num_connections} connected", "tooltip-format": "{controller_alias}\t{controller_address}\n\n{num_connections} connected",
"tooltip-format-connected": "{controller_alias}\t{controller_address}\n\n{num_connections} connected\n\n{device_enumerate}", "tooltip-format-connected": "{controller_alias}\t{controller_address}\n\n{num_connections} connected\n\n{device_enumerate}",
"tooltip-format-enumerate-connected": "{device_alias}\t{device_address}", "tooltip-format-enumerate-connected": "{device_alias}\t{device_address}",
@@ -97,7 +100,7 @@
"on-click-right": "swaymsg 'move scratchpad'" "on-click-right": "swaymsg 'move scratchpad'"
}, },
"custom/launcher": { "custom/launcher": {
"format":" ", "format":" ",
"tooltip-format": "Click To Launch Bitwarden", "tooltip-format": "Click To Launch Bitwarden",
"on-click": "exec bitwarden-desktop" "on-click": "exec bitwarden-desktop"
}, },
@@ -106,11 +109,11 @@
"warning": 30, "warning": 30,
"critical": 15 "critical": 15
}, },
"format": "{icon} {capacity}%", "format": "{icon}<span letter_spacing='1'> </span>{capacity}%",
"format-charging": "{icon} {capacity}%", "format-charging": "{icon}<span letter_spacing='1'> </span>{capacity}%",
"format-plugged": "{icon} {capacity}%", "format-plugged": "{icon}<span letter_spacing='1'> </span>{capacity}%",
"format-alt": "{time} {icon}", "format-alt": "{time}<span letter_spacing='1'> </span>{icon}",
"format-full": "󰁹 {capacity}%", "format-full": "󰁹<span letter_spacing='1'> </span>{capacity}%",
"format-icons": { "format-icons": {
"default": ["󰁺", "󰁻", "󰁼", "󰁽", "󰁾", "󰁿", "󰂀", "󰂁", "󰂂"], "default": ["󰁺", "󰁻", "󰁼", "󰁽", "󰁾", "󰁿", "󰂀", "󰂁", "󰂂"],
"charging": ["󰢜", "󰂆", "󰂇", "󰂈", "󰢝", "󰂉", "󰢞", "󰂊", "󰂋"], "charging": ["󰢜", "󰂆", "󰂇", "󰂈", "󰢝", "󰂉", "󰢞", "󰂊", "󰂋"],
@@ -137,13 +140,13 @@
"sort-by-number": true "sort-by-number": true
}, },
"custom/updater": { "custom/updater": {
"format": "<span color='#962fb5' font_family='NotoSansMono Nerd Font' size='16pt' baseline_shift='-3.10pt'>󰏖</span><span letter_spacing='1000'> </span>{}", "format": "󰏖 <span rise='300'>{}</span>",
"exec": "checkupdates | wc -l", "exec": "checkupdates | wc -l",
"exec-if": "[[ $(checkupdates | wc -l) ]]", "exec-if": "[[ $(checkupdates | wc -l) ]]",
"interval": 1300, "interval": 1300,
"on-click": "$HOME/.config/waybar/scripts/update-distro.sh", "on-click": "$HOME/.config/waybar/scripts/update-distro.sh",
"tooltip": true, "tooltip": true,
"tooltip-format": "Left Click: Update System\nArch (w/ notification)\nFedora, OpenSuse, Debian/Ubuntu click to update" "tooltip-format": "Left Click: Update System\nRight Click: Update Package Count\nArch (w/ notification)"
}, },
"custom/notification": { "custom/notification": {

View File

@@ -3,7 +3,8 @@
font-family: font-family:
Font Awesome, Font Awesome,
Roboto, Roboto,
"NotoSansMono Nerd Font", "NotoSansM Nerd Font",
"NotoSansMono Nerd Font",
"Font Awesome 6 Free", "Font Awesome 6 Free",
Arial, Arial,
sans-serif; sans-serif;
@@ -18,10 +19,11 @@ window#waybar {
/*-----module groups----*/ /*-----module groups----*/
.modules-right { .modules-right {
margin: 2px 10px 0 0; margin: 2px 10px 0 0;
padding-top: 1px;
} }
#custom-notification { #custom-notification {
font-family: "NotoSansMono Nerd Font"; font-family: "NotoSansM Nerd Font", "NotoSansMono Nerd Font";
font-size: 13px; font-size: 13px;
background-color: rgba(0, 43, 51, 0.85); background-color: rgba(0, 43, 51, 0.85);
border-radius: 20px; border-radius: 20px;
@@ -39,6 +41,7 @@ window#waybar {
.modules-left { .modules-left {
margin: 2px 0 0 5px; margin: 2px 0 0 5px;
padding-top: 1px;
} }
/*-----modules indv----*/ /*-----modules indv----*/
#workspaces button { #workspaces button {
@@ -70,7 +73,8 @@ window#waybar {
border-radius: 20px; border-radius: 20px;
margin: 5px 3px; margin: 5px 3px;
} }
#pulseaudio { #wireplumber,
#wireplumber-microphone {
padding: 5px 10px 5px 15px; padding: 5px 10px 5px 15px;
background-color: rgba(0, 43, 51, 0.85); background-color: rgba(0, 43, 51, 0.85);
border-radius: 20px; border-radius: 20px;
@@ -120,7 +124,8 @@ window#waybar {
#idle_inhibitor.activated { #idle_inhibitor.activated {
color: #2dcc36; color: #2dcc36;
} }
#pulseaudio.muted { #wireplumber.muted,
#wireplumber-microphone.muted {
color: #cc3436; color: #cc3436;
} }
#battery.charging { #battery.charging {