Compare commits
62 Commits
features/f
...
7cb7f10ff6
| Author | SHA1 | Date | |
|---|---|---|---|
| 7cb7f10ff6 | |||
| 88484f8495 | |||
| 1babf2e334 | |||
| cc78daea77 | |||
| 0415804b67 | |||
| 75aa6b945b | |||
| 8b65136048 | |||
| 4bd3d327c6 | |||
| 786a3d191a | |||
| ad5efc9597 | |||
| 850da708c2 | |||
| de39c40460 | |||
| 72db6ca784 | |||
| 84828e58bc | |||
| 26b7bf4ecc | |||
| 5b58c27e80 | |||
| 82be653c04 | |||
| d395a155af | |||
| 243c90a809 | |||
| 38df0301f2 | |||
| 714fadc997 | |||
| 99e2225b51 | |||
| 4aaa1d2d98 | |||
| 14b7e748d4 | |||
| a0b9ea29fe | |||
| 007843db6e | |||
| 764652d826 | |||
| 8dedfe30fa | |||
| bc54a4b5b7 | |||
| d306270dc8 | |||
| 03b043d9d3 | |||
| 1ff92f3b79 | |||
| e5809efc6a | |||
| c64cf5ff12 | |||
| d6f79685f0 | |||
| 9e532023bd | |||
| c2c6675f4b | |||
| fcce5f304b | |||
| 4b0b5c72ba | |||
| 3f4e3514a2 | |||
| 622afb2100 | |||
| 3ae5c57303 | |||
| ef06a96757 | |||
| 03ea182c8c | |||
| ba8a565cda | |||
| c8a9c30fc5 | |||
| 185b752393 | |||
| 8515f3f5ce | |||
| 86921f7aab | |||
| cede2a1690 | |||
| 00e4368abe | |||
| 00d7dcb411 | |||
| 4bdb590899 | |||
| 44e73f5a9d | |||
| 3184c61267 | |||
| fb79f0a37e | |||
| 7a1073646a | |||
| 90d068a878 | |||
| 57e5c0c7b3 | |||
| b7a92c9a59 | |||
| d3856ada0d | |||
| 3601f52600 |
@@ -46,6 +46,11 @@ stow -d ~/Projects/dotfiles/ -t ~/ dotfiles
|
|||||||
|
|
||||||
This will sync wallpapers/config files to where they are usually located
|
This will sync wallpapers/config files to where they are usually located
|
||||||
|
|
||||||
|
System-level config (e.g. pacman options) lives in `system/` and is installed with sudo:
|
||||||
|
```console
|
||||||
|
~/Projects/dotfiles/system/install.sh
|
||||||
|
```
|
||||||
|
|
||||||
## 📦 Programs
|
## 📦 Programs
|
||||||
|
|
||||||
List of programs and tools I use.
|
List of programs and tools I use.
|
||||||
|
|||||||
1
dotfiles/.config/environment.d/ssh-agent.conf
Normal file
1
dotfiles/.config/environment.d/ssh-agent.conf
Normal file
@@ -0,0 +1 @@
|
|||||||
|
SSH_AUTH_SOCK=${HOME}/.bitwarden-ssh-agent.sock
|
||||||
@@ -3,5 +3,6 @@ file:///home/thomas/Documents
|
|||||||
file:///home/thomas/Downloads
|
file:///home/thomas/Downloads
|
||||||
file:///home/thomas/Pictures
|
file:///home/thomas/Pictures
|
||||||
file:///home/thomas/Music
|
file:///home/thomas/Music
|
||||||
|
file:///home/thomas/Videos
|
||||||
smb://192.168.1.84/media/
|
smb://192.168.1.84/media/
|
||||||
smb://192.168.1.85/music/
|
smb://192.168.1.85/music/
|
||||||
|
|||||||
@@ -51,10 +51,10 @@ env = ELECTRON_OZONE_PLATFORM_HINT,auto # auto selects Wayland if possible, X11
|
|||||||
# This is from Hyprland Wiki. Below will be activated nvidia gpu detected
|
# This is from Hyprland Wiki. Below will be activated nvidia gpu detected
|
||||||
# See hyprland wiki https://wiki.hyprland.org/Nvidia/#environment-variables
|
# See hyprland wiki https://wiki.hyprland.org/Nvidia/#environment-variables
|
||||||
|
|
||||||
#env = LIBVA_DRIVER_NAME,nvidia
|
env = LIBVA_DRIVER_NAME,nvidia
|
||||||
#env = __GLX_VENDOR_LIBRARY_NAME,nvidia
|
env = __GLX_VENDOR_LIBRARY_NAME,nvidia
|
||||||
#env = NVD_BACKEND,direct
|
env = NVD_BACKEND,direct
|
||||||
#env = GSK_RENDERER,ngl
|
env = GSK_RENDERER,ngl
|
||||||
|
|
||||||
# additional ENV's for nvidia. Caution, activate with care
|
# additional ENV's for nvidia. Caution, activate with care
|
||||||
#env = GBM_BACKEND,nvidia-drm
|
#env = GBM_BACKEND,nvidia-drm
|
||||||
@@ -87,3 +87,6 @@ env = ELECTRON_OZONE_PLATFORM_HINT,auto # auto selects Wayland if possible, X11
|
|||||||
# env = HYPRLAND_NO_RT,1 # Disables realtime priority setting by Hyprland.
|
# env = HYPRLAND_NO_RT,1 # Disables realtime priority setting by Hyprland.
|
||||||
# env = HYPRLAND_NO_SD_NOTIFY,1 # If systemd, disables the 'sd_notify' calls.
|
# env = HYPRLAND_NO_SD_NOTIFY,1 # If systemd, disables the 'sd_notify' calls.
|
||||||
# env = HYPRLAND_NO_SD_VARS,1 # Disables management of variables in systemd and dbus activation environments.
|
# env = HYPRLAND_NO_SD_VARS,1 # Disables management of variables in systemd and dbus activation environments.
|
||||||
|
|
||||||
|
# Bitwarden SSH agent (so GUI apps like VS Code see it, not just shells)
|
||||||
|
env = SSH_AUTH_SOCK,/home/thomas/.bitwarden-ssh-agent.sock
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ decoration {
|
|||||||
rounding = 10
|
rounding = 10
|
||||||
|
|
||||||
active_opacity = 1.0
|
active_opacity = 1.0
|
||||||
inactive_opacity = 0.9
|
inactive_opacity = 1.0
|
||||||
fullscreen_opacity = 1.0
|
fullscreen_opacity = 1.0
|
||||||
|
|
||||||
dim_inactive = true
|
dim_inactive = true
|
||||||
|
|||||||
@@ -8,5 +8,5 @@
|
|||||||
# <<<<< TUI AUTO START
|
# <<<<< TUI AUTO START
|
||||||
monitor=desc:LG Electronics LG ULTRAWIDE 0x0002E68A,2560x1080@60.00000,1920x-740,1.00000000,transform,3,vrr,0
|
monitor=desc:LG Electronics LG ULTRAWIDE 0x0002E68A,2560x1080@60.00000,1920x-740,1.00000000,transform,3,vrr,0
|
||||||
monitor=desc:HP Inc. HP 524pf VNG447017D,1920x1080@100.00000,-1920x0,1.00000000,transform,0,vrr,0
|
monitor=desc:HP Inc. HP 524pf VNG447017D,1920x1080@100.00000,-1920x0,1.00000000,transform,0,vrr,0
|
||||||
monitor=desc:Acer Technologies VG240Y S 0x126191DE,1920x1080@165.00301,0x0,1.00000000,transform,0,vrr,1,bitdepth,10,cm,wide
|
monitor=desc:Acer Technologies VG240Y S 0x126191DE,1920x1080@165.00301,0x0,1.00000000,transform,0,vrr,0,bitdepth,10,cm,wide
|
||||||
# <<<<< TUI AUTO END
|
# <<<<< TUI AUTO END
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ hl.config({
|
|||||||
rounding = 10,
|
rounding = 10,
|
||||||
|
|
||||||
active_opacity = 1.0,
|
active_opacity = 1.0,
|
||||||
inactive_opacity = 0.9,
|
inactive_opacity = 1.0,
|
||||||
fullscreen_opacity = 1.0,
|
fullscreen_opacity = 1.0,
|
||||||
|
|
||||||
dim_inactive = true,
|
dim_inactive = true,
|
||||||
|
|||||||
@@ -49,10 +49,10 @@ hl.env("ELECTRON_OZONE_PLATFORM_HINT", "auto") -- auto selects Wayland if possib
|
|||||||
-- This is from the Hyprland wiki. Uncomment below if an nvidia GPU is detected.
|
-- This is from the Hyprland wiki. Uncomment below if an nvidia GPU is detected.
|
||||||
-- See hyprland wiki https://wiki.hyprland.org/Nvidia/#environment-variables
|
-- See hyprland wiki https://wiki.hyprland.org/Nvidia/#environment-variables
|
||||||
|
|
||||||
-- hl.env("LIBVA_DRIVER_NAME", "nvidia")
|
hl.env("LIBVA_DRIVER_NAME", "nvidia")
|
||||||
-- hl.env("__GLX_VENDOR_LIBRARY_NAME", "nvidia")
|
hl.env("__GLX_VENDOR_LIBRARY_NAME", "nvidia")
|
||||||
-- hl.env("NVD_BACKEND", "direct")
|
hl.env("NVD_BACKEND", "direct")
|
||||||
-- hl.env("GSK_RENDERER", "ngl")
|
hl.env("GSK_RENDERER", "ngl")
|
||||||
|
|
||||||
-- additional ENV's for nvidia. Caution, activate with care
|
-- additional ENV's for nvidia. Caution, activate with care
|
||||||
-- hl.env("GBM_BACKEND", "nvidia-drm")
|
-- hl.env("GBM_BACKEND", "nvidia-drm")
|
||||||
@@ -85,3 +85,6 @@ hl.env("ELECTRON_OZONE_PLATFORM_HINT", "auto") -- auto selects Wayland if possib
|
|||||||
-- hl.env("HYPRLAND_NO_RT", "1") -- Disables realtime priority setting by Hyprland.
|
-- hl.env("HYPRLAND_NO_RT", "1") -- Disables realtime priority setting by Hyprland.
|
||||||
-- hl.env("HYPRLAND_NO_SD_NOTIFY", "1") -- If systemd, disables the 'sd_notify' calls.
|
-- hl.env("HYPRLAND_NO_SD_NOTIFY", "1") -- If systemd, disables the 'sd_notify' calls.
|
||||||
-- hl.env("HYPRLAND_NO_SD_VARS", "1") -- Disables management of variables in systemd and dbus activation environments.
|
-- hl.env("HYPRLAND_NO_SD_VARS", "1") -- Disables management of variables in systemd and dbus activation environments.
|
||||||
|
|
||||||
|
-- Bitwarden SSH agent (so GUI apps like VS Code see it, not just shells)
|
||||||
|
hl.env("SSH_AUTH_SOCK", os.getenv("HOME") .. "/.bitwarden-ssh-agent.sock")
|
||||||
|
|||||||
@@ -5,6 +5,11 @@
|
|||||||
-- whatever it last wrote. If you reinstall HyprMod, prefer letting its own "Migrate to Lua…"
|
-- whatever it last wrote. If you reinstall HyprMod, prefer letting its own "Migrate to Lua…"
|
||||||
-- wizard take over managing this file instead of hand-editing it going forward.
|
-- wizard take over managing this file instead of hand-editing it going forward.
|
||||||
|
|
||||||
|
-- vrr forced off globally: the per-monitor vrr = 0 flag below does NOT override this when
|
||||||
|
-- misc.vrr is 1 (tested live on Hyprland 0.56.2 — DP-2 stayed vrr:true until this global
|
||||||
|
-- was changed). DP-2 (Acer VG240Y S) flickers under VRR when idle in fullscreen (mouse
|
||||||
|
-- stationary -> no repaint damage -> frame delivery stalls near the panel's VRR floor).
|
||||||
|
-- The other two monitors don't negotiate VRR at all, so this has no effect on them.
|
||||||
hl.config({
|
hl.config({
|
||||||
general = {
|
general = {
|
||||||
allow_tearing = true,
|
allow_tearing = true,
|
||||||
@@ -13,12 +18,12 @@ hl.config({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
misc = {
|
misc = {
|
||||||
vrr = 1,
|
vrr = 0,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
-- Monitors
|
-- Monitors
|
||||||
hl.monitor({ output = "DP-2", mode = "1920x1080@165.00Hz", position = "0x0", scale = 1, bitdepth = 10, cm = "wide" })
|
hl.monitor({ output = "DP-2", mode = "1920x1080@165.00Hz", position = "0x0", scale = 1, bitdepth = 10, cm = "wide", vrr = 0 })
|
||||||
|
|
||||||
-- Keybinds
|
-- Keybinds
|
||||||
-- (unbind = SUPER SHIFT, Print / unbind = SUPER, up from the source .conf have no Lua equivalent needed —
|
-- (unbind = SUPER SHIFT, Print / unbind = SUPER, up from the source .conf have no Lua equivalent needed —
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ hl.on("hyprland.start", function()
|
|||||||
hl.exec_cmd("dbus-update-activation-environment --systemd WAYLAND_DISPLAY XDG_CURRENT_DESKTOP")
|
hl.exec_cmd("dbus-update-activation-environment --systemd WAYLAND_DISPLAY XDG_CURRENT_DESKTOP")
|
||||||
hl.exec_cmd("systemctl --user import-environment WAYLAND_DISPLAY XDG_CURRENT_DESKTOP")
|
hl.exec_cmd("systemctl --user import-environment WAYLAND_DISPLAY XDG_CURRENT_DESKTOP")
|
||||||
|
|
||||||
|
-- Sunshine (virtual monitor only on desktop; skipped on laptops, detected by a system battery)
|
||||||
|
hl.exec_cmd("sleep 1 && ! ls -d /sys/class/power_supply/BAT* >/dev/null 2>&1 && hyprctl output create headless")
|
||||||
|
|
||||||
-- Polkit (Polkit Gnome / KDE)
|
-- Polkit (Polkit Gnome / KDE)
|
||||||
hl.exec_cmd(scriptsDir .. "/Polkit.sh")
|
hl.exec_cmd(scriptsDir .. "/Polkit.sh")
|
||||||
|
|
||||||
@@ -23,7 +26,7 @@ hl.on("hyprland.start", function()
|
|||||||
-- hl.exec_cmd("ags")
|
-- hl.exec_cmd("ags")
|
||||||
-- hl.exec_cmd("blueman-applet")
|
-- hl.exec_cmd("blueman-applet")
|
||||||
-- hl.exec_cmd("rog-control-center")
|
-- hl.exec_cmd("rog-control-center")
|
||||||
hl.exec_cmd("waybar")
|
-- hl.exec_cmd("waybar")
|
||||||
hl.exec_cmd("qs") -- quickshell AGS Desktop Overview alternative
|
hl.exec_cmd("qs") -- quickshell AGS Desktop Overview alternative
|
||||||
|
|
||||||
-- clipboard manager
|
-- clipboard manager
|
||||||
@@ -31,7 +34,10 @@ hl.on("hyprland.start", function()
|
|||||||
hl.exec_cmd("wl-paste --type image --watch cliphist store")
|
hl.exec_cmd("wl-paste --type image --watch cliphist store")
|
||||||
|
|
||||||
-- Starting hypridle to start hyprlock
|
-- Starting hypridle to start hyprlock
|
||||||
hl.exec_cmd("hypridle")
|
-- hl.exec_cmd("hypridle")
|
||||||
|
|
||||||
|
-- Sunshine for streaming media
|
||||||
|
hl.exec_cmd("sunshine")
|
||||||
|
|
||||||
-- Float + center the Bitwarden extension pop-out (window rules can't catch it — see the script)
|
-- Float + center the Bitwarden extension pop-out (window rules can't catch it — see the script)
|
||||||
hl.exec_cmd(scriptsDir .. "/FloatBitwardenPopup.sh")
|
hl.exec_cmd(scriptsDir .. "/FloatBitwardenPopup.sh")
|
||||||
@@ -43,6 +49,7 @@ hl.on("hyprland.start", function()
|
|||||||
hl.exec_cmd("sleep 3 && vesktop")
|
hl.exec_cmd("sleep 3 && vesktop")
|
||||||
hl.dispatch(hl.dsp.focus({ workspace = 1 }))
|
hl.dispatch(hl.dsp.focus({ workspace = 1 }))
|
||||||
hl.exec_cmd("bitwarden-desktop --ozone-platform-hint=auto --silent")
|
hl.exec_cmd("bitwarden-desktop --ozone-platform-hint=auto --silent")
|
||||||
|
hl.exec_cmd("seafile-applet")
|
||||||
|
|
||||||
-- Here are a list of features available but disabled by default
|
-- Here are a list of features available but disabled by default
|
||||||
-- hl.exec_cmd("awww-daemon --format xrgb && awww img " .. os.getenv("HOME") .. "/Pictures/wallpapers/mecha-nostalgia.png") -- persistent wallpaper
|
-- hl.exec_cmd("awww-daemon --format xrgb && awww img " .. os.getenv("HOME") .. "/Pictures/wallpapers/mecha-nostalgia.png") -- persistent wallpaper
|
||||||
|
|||||||
@@ -2,10 +2,15 @@
|
|||||||
|
|
||||||
general:allow_tearing = 1
|
general:allow_tearing = 1
|
||||||
general:snap:enabled = 0
|
general:snap:enabled = 0
|
||||||
misc:vrr = 1
|
# vrr forced off globally: the per-monitor vrr,0 flag below does NOT override this when
|
||||||
|
# misc:vrr is 1 (tested live on Hyprland 0.56.2 — DP-2 stayed vrr:true until this global
|
||||||
|
# was changed). DP-2 (Acer VG240Y S) flickers under VRR when idle in fullscreen (mouse
|
||||||
|
# stationary -> no repaint damage -> frame delivery stalls near the panel's VRR floor).
|
||||||
|
# The other two monitors don't negotiate VRR at all, so this has no effect on them.
|
||||||
|
misc:vrr = 0
|
||||||
|
|
||||||
# Monitors
|
# Monitors
|
||||||
monitor = DP-2, 1920x1080@165.00Hz, 0x0, 1, bitdepth, 10, cm, wide
|
monitor = DP-2, 1920x1080@165.00Hz, 0x0, 1, bitdepth, 10, cm, wide, vrr, 0
|
||||||
|
|
||||||
# Keybinds
|
# Keybinds
|
||||||
unbind = SUPER SHIFT, Print
|
unbind = SUPER SHIFT, Print
|
||||||
|
|||||||
@@ -10,5 +10,6 @@
|
|||||||
-- <<<<< TUI AUTO START
|
-- <<<<< TUI AUTO START
|
||||||
hl.monitor({ output = "desc:LG Electronics LG ULTRAWIDE 0x0002E68A", mode = "2560x1080@60.00000", position = "1920x-740", scale = 1.00000000, transform = 3, vrr = 0 })
|
hl.monitor({ output = "desc:LG Electronics LG ULTRAWIDE 0x0002E68A", mode = "2560x1080@60.00000", position = "1920x-740", scale = 1.00000000, transform = 3, vrr = 0 })
|
||||||
hl.monitor({ output = "desc:HP Inc. HP 524pf VNG447017D", mode = "1920x1080@100.00000", position = "-1920x0", scale = 1.00000000, transform = 0, vrr = 0 })
|
hl.monitor({ output = "desc:HP Inc. HP 524pf VNG447017D", mode = "1920x1080@100.00000", position = "-1920x0", scale = 1.00000000, transform = 0, vrr = 0 })
|
||||||
hl.monitor({ output = "desc:Acer Technologies VG240Y S 0x126191DE", mode = "1920x1080@165.00301", position = "0x0", scale = 1.00000000, transform = 0, vrr = 1, bitdepth = 10, cm = "wide" })
|
hl.monitor({ output = "desc:Acer Technologies VG240Y S 0x126191DE", mode = "1920x1080@165.00301", position = "0x0", scale = 1.00000000, transform = 0, vrr = 0, bitdepth = 10, cm = "wide" })
|
||||||
|
hl.monitor({ output = "HEADLESS-1", mode = "1728x1080@60.00000", position = "4480x0", scale = 1.00000000, transform = 0, vrr = 0 })
|
||||||
-- <<<<< TUI AUTO END
|
-- <<<<< TUI AUTO END
|
||||||
|
|||||||
@@ -137,8 +137,8 @@ hl.window_rule({ workspace = "3", match = { class = "^([Vv]esktop|[Dd]iscord|[Ss
|
|||||||
hl.window_rule({ size = "20% 20%", match = { class = "^([Pp]lexamp)" } })
|
hl.window_rule({ size = "20% 20%", match = { class = "^([Pp]lexamp)" } })
|
||||||
|
|
||||||
-- FLOAT
|
-- FLOAT
|
||||||
hl.window_rule({ name = "windowrule-75", float = true, opacity = "0.9 0.7", size = "monitor_w*0.7 monitor_h*0.7", match = { tag = "wallpaper*" } })
|
hl.window_rule({ name = "windowrule-75", float = true, opacity = "0.9 0.9", size = "monitor_w*0.7 monitor_h*0.7", match = { tag = "wallpaper*" } })
|
||||||
hl.window_rule({ name = "windowrule-76", float = true, opacity = "0.8 0.7", size = "monitor_w*0.7 monitor_h*0.7", match = { tag = "settings*" } })
|
hl.window_rule({ name = "windowrule-76", float = true, opacity = "0.8 0.8", size = "monitor_w*0.7 monitor_h*0.7", match = { tag = "settings*" } })
|
||||||
hl.window_rule({ name = "windowrule-77", float = true, match = { tag = "viewer*" } })
|
hl.window_rule({ name = "windowrule-77", float = true, match = { tag = "viewer*" } })
|
||||||
hl.window_rule({ name = "windowrule-78", float = true, match = { class = "([Zz]oom|onedriver|onedriver-launcher)$" } })
|
hl.window_rule({ name = "windowrule-78", float = true, match = { class = "([Zz]oom|onedriver|onedriver-launcher)$" } })
|
||||||
hl.window_rule({ name = "windowrule-79", float = true, match = { class = "(org.gnome.Calculator)", title = "(Calculator)" } })
|
hl.window_rule({ name = "windowrule-79", float = true, match = { class = "(org.gnome.Calculator)", title = "(Calculator)" } })
|
||||||
@@ -162,17 +162,17 @@ hl.window_rule({ name = "windowrule-92", float = true, center = true, size = "mo
|
|||||||
-- END of float popups and dialogue #######
|
-- END of float popups and dialogue #######
|
||||||
|
|
||||||
-- OPACITY
|
-- OPACITY
|
||||||
-- hl.window_rule({ opacity = "0.9 0.7", match = { tag = "browser*" } })
|
-- hl.window_rule({ opacity = "0.9 0.9", match = { tag = "browser*" } })
|
||||||
-- hl.window_rule({ opacity = "0.9 0.8", match = { tag = "projects*" } })
|
-- hl.window_rule({ opacity = "0.9 0.9", match = { tag = "projects*" } })
|
||||||
-- hl.window_rule({ opacity = "0.94 0.86", match = { tag = "im*" } })
|
-- hl.window_rule({ opacity = "0.94 0.94", match = { tag = "im*" } })
|
||||||
-- hl.window_rule({ opacity = "0.94 0.86", match = { tag = "multimedia*" } })
|
-- hl.window_rule({ opacity = "0.94 0.94", match = { tag = "multimedia*" } })
|
||||||
-- hl.window_rule({ opacity = "0.9 0.8", match = { tag = "file-manager*" } })
|
-- hl.window_rule({ opacity = "0.9 0.9", match = { tag = "file-manager*" } })
|
||||||
hl.window_rule({ name = "windowrule-93", opacity = "0.8 0.7", match = { tag = "terminal*" } })
|
hl.window_rule({ name = "windowrule-93", opacity = "0.8 0.8", match = { tag = "terminal*" } })
|
||||||
-- hl.window_rule({ opacity = "0.82 0.75", match = { tag = "viewer*" } })
|
-- hl.window_rule({ opacity = "0.82 0.82", match = { tag = "viewer*" } })
|
||||||
hl.window_rule({ name = "windowrule-94", opacity = "0.8 0.7", match = { class = "^(gedit|org.gnome.TextEditor|mousepad)$" } })
|
hl.window_rule({ name = "windowrule-94", opacity = "0.8 0.8", match = { class = "^(gedit|org.gnome.TextEditor|mousepad)$" } })
|
||||||
hl.window_rule({ name = "windowrule-95", opacity = "0.9 0.8", match = { class = "^(deluge)$" } })
|
hl.window_rule({ name = "windowrule-95", opacity = "0.9 0.9", match = { class = "^(deluge)$" } })
|
||||||
hl.window_rule({ name = "windowrule-96", opacity = "0.9 0.8", match = { class = "^(seahorse)$" } }) -- gnome-keyring gui
|
hl.window_rule({ name = "windowrule-96", opacity = "0.9 0.9", match = { class = "^(seahorse)$" } }) -- gnome-keyring gui
|
||||||
-- hl.window_rule({ opacity = "0.95 0.75", match = { title = "^(Picture-in-Picture)$" } })
|
-- hl.window_rule({ opacity = "0.95 0.95", match = { title = "^(Picture-in-Picture)$" } })
|
||||||
|
|
||||||
-- SIZE
|
-- SIZE
|
||||||
-- hl.window_rule({ size = "25% 25%", match = { title = "^(Firefox)$" } })
|
-- hl.window_rule({ size = "25% 25%", match = { title = "^(Firefox)$" } })
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
hl.workspace_rule({ workspace = 1, monitor = "DP-2", default = true })
|
hl.workspace_rule({ workspace = 1, monitor = "DP-2", default = true })
|
||||||
hl.workspace_rule({ workspace = 2, monitor = "DP-1", default = true })
|
hl.workspace_rule({ workspace = 2, monitor = "DP-1", default = true })
|
||||||
hl.workspace_rule({ workspace = 3, monitor = "HDMI-A-1", default = true })
|
hl.workspace_rule({ workspace = 3, monitor = "HDMI-A-1", default = true })
|
||||||
|
hl.workspace_rule({ workspace = 9, monitor = "HEADLESS-1", default = true })
|
||||||
-- hl.workspace_rule({ workspace = 4, monitor = "eDP-1" })
|
-- hl.workspace_rule({ workspace = 4, monitor = "eDP-1" })
|
||||||
-- hl.workspace_rule({ workspace = 5, monitor = "DP-2" })
|
-- hl.workspace_rule({ workspace = 5, monitor = "DP-2" })
|
||||||
-- hl.workspace_rule({ workspace = 6, monitor = "DP-2" })
|
-- hl.workspace_rule({ workspace = 6, monitor = "DP-2" })
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
accent_primary = "#007acc"
|
|
||||||
accent_secondary = "#06b6d4"
|
|
||||||
background = "#1e1e1e"
|
|
||||||
foreground = "#d4d4d8"
|
|
||||||
opacity = 0.91
|
|
||||||
BIN
dotfiles/.config/quickshell/assets/bitwarden.png
Normal file
BIN
dotfiles/.config/quickshell/assets/bitwarden.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 294 KiB |
27
dotfiles/.config/quickshell/assets/sunshine.svg
Normal file
27
dotfiles/.config/quickshell/assets/sunshine.svg
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Generator: Adobe Illustrator 22.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||||
|
<svg version="1.1" id="Sunshine_Logo" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px"
|
||||||
|
y="0px" width="256px" height="256px" viewBox="10 6 236 245" style="enable-background:new 0 0 256 256;" xml:space="preserve">
|
||||||
|
<style type="text/css">
|
||||||
|
.st0{fill:#FDD107;}
|
||||||
|
.st1{fill:#F89A1C;}
|
||||||
|
.st2{fill:#EF3E23;}
|
||||||
|
.st3{fill:#F26222;}
|
||||||
|
</style>
|
||||||
|
<path class="st0" d="M118.7688675,20.7120476c0,0-63.8333359,26-74.3333359,83.8333282s37.1666718,91.5,86.3333282,75.3333282
|
||||||
|
s70.3333435-51,81.8333435-86.9999924c0,0-9.3333435,100.4999924-96.1666718,115.4999924s-118.1666641-50-82.1666641-119.8333282
|
||||||
|
C44.2688675,67.0453796,80.5188675,29.6287155,118.7688675,20.7120476z"/>
|
||||||
|
<path class="st1" d="M118.7688675,20.7120476c0,0-41.125,3.6666679-83.25,61.0416679s-28.125,139.125,34.25,149.375
|
||||||
|
s115.8749924-44.875,133.5-82.375s15.1666718-61.4583282,9.75-77.8749924c0,0,0.6666718,36.4166641-13.3333282,59.6666641
|
||||||
|
s-29.75,46.3333282-65.0833282,62.1666718s-74.1666718,13.75-95.4166718-19.25s-5.9166641-76.0833359-0.2916641-85.3333359
|
||||||
|
S72.3938675,33.7953796,118.7688675,20.7120476z"/>
|
||||||
|
<path class="st2" d="M73.0188675,39.6287155c0,0,38.125-28.125,76.8749924-28.125s63,28.25,68.5,52.25s6,54.125-11.5,87.6249924
|
||||||
|
s-37.375,56-79.1249924,76.125s-84.625,2.75-84.625,2.75s25.9769745,25.8750153,71.0509872,16.5
|
||||||
|
c45.0740051-9.375,82.2406769-40.875,98.4073486-69.5s28.7916565-57.3749924,27.6666565-92.2499924s-23.75-54.5-31.25-60.25
|
||||||
|
s-23.1875-17.8125-58.1875-16.5625S86.4563675,29.8162155,73.0188675,39.6287155z"/>
|
||||||
|
<path class="st3" d="M73.0188675,39.6287155c0,0,35-32.8125,82.4374924-32.8125s69.1875,24.8125,78.875,44.6875
|
||||||
|
s21.8125,70-12.1875,122.9999924s-74.625,67.375-93.625,71.625s-42.4311447,4.269165-59.1114044-1.2299957
|
||||||
|
c0,0,35.1947479,8.3966675,66.7780762-7.4366608s51.6666718-32.1666718,74.0833282-68.8333435
|
||||||
|
s25.9166718-72.7499924,22.1666718-93.9166565s-12.1666718-42.4166718-36.5-56.3333359s-56.7291718-10.531251-74.4791641-4.531251
|
||||||
|
S91.9876175,26.4099655,73.0188675,39.6287155z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -4,6 +4,10 @@ import QtQuick
|
|||||||
QtObject {
|
QtObject {
|
||||||
readonly property string fontFamily: "CaskaydiaCove Nerd Font Mono"
|
readonly property string fontFamily: "CaskaydiaCove Nerd Font Mono"
|
||||||
readonly property string iconFontFamily: "Material Symbols Outlined"
|
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
|
// 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
|
// 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
|
// and makes some icons (e.g. "lock") look heavier/bigger than others when
|
||||||
|
|||||||
72
dotfiles/.config/quickshell/config/SystemUpdates.qml
Normal file
72
dotfiles/.config/quickshell/config/SystemUpdates.qml
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
pragma Singleton
|
||||||
|
import QtQuick
|
||||||
|
import Quickshell.Io
|
||||||
|
|
||||||
|
// Shared state for SystemUpdate.qml, the bar's package-update indicator.
|
||||||
|
// Bar.qml is instantiated once per screen (see shell.qml's Variants), so
|
||||||
|
// without this being a singleton, plugging in extra monitors would mean
|
||||||
|
// running `checkupdates` that many times in parallel on every poll — which
|
||||||
|
// isn't just wasteful, it's actively unreliable: concurrent invocations
|
||||||
|
// race over checkupdates' own sync directory and can intermittently report
|
||||||
|
// 0 pending updates. One shared Process here means exactly one `checkupdates`
|
||||||
|
// run at a time, and every bar's icon reflects the same result.
|
||||||
|
QtObject {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
// checkupdates syncs a throwaway copy of the pacman db, so it's not
|
||||||
|
// free to run often — matches the old waybar custom/updater module's
|
||||||
|
// ~21.7 minute poll interval (see .config/waybar/config).
|
||||||
|
readonly property int refreshIntervalMs: 1300 * 1000
|
||||||
|
|
||||||
|
property int updateCount: 0
|
||||||
|
property bool checking: false
|
||||||
|
property bool updating: false
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
if (_checkProc.running) return
|
||||||
|
root.checking = true
|
||||||
|
_checkProc.running = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// scriptPath: absolute path to update-system.sh (resolved by the caller
|
||||||
|
// via Quickshell.shellPath, which isn't available as a plain import
|
||||||
|
// here without pulling in the whole Quickshell singleton for one call).
|
||||||
|
function runUpdate(scriptPath) {
|
||||||
|
if (_updateProc.running) return
|
||||||
|
root.updating = true
|
||||||
|
_updateProc.command = [scriptPath]
|
||||||
|
_updateProc.running = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// QtObject has no default property, so these are attached via property
|
||||||
|
// assignment rather than as plain child elements (same as Colors.qml's
|
||||||
|
// FileView).
|
||||||
|
readonly property Process _checkProc: Process {
|
||||||
|
command: ["sh", "-c", "checkupdates 2>/dev/null | wc -l"]
|
||||||
|
stdout: StdioCollector {
|
||||||
|
id: checkCollector
|
||||||
|
onStreamFinished: {
|
||||||
|
root.updateCount = parseInt(checkCollector.text.trim(), 10) || 0
|
||||||
|
root.checking = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runs update-system.sh, which opens its own terminal and blocks until
|
||||||
|
// that terminal is closed — so this only exits once the user's actually
|
||||||
|
// done updating, which is exactly when a re-check matters.
|
||||||
|
readonly property Process _updateProc: Process {
|
||||||
|
onExited: {
|
||||||
|
root.updating = false
|
||||||
|
root.refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly property Timer _timer: Timer {
|
||||||
|
interval: root.refreshIntervalMs
|
||||||
|
running: true
|
||||||
|
repeat: true
|
||||||
|
triggeredOnStart: true
|
||||||
|
onTriggered: root.refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
242
dotfiles/.config/quickshell/config/WeatherData.qml
Normal file
242
dotfiles/.config/quickshell/config/WeatherData.qml
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
pragma Singleton
|
||||||
|
import QtQuick
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
|
||||||
|
// Live weather for the bar's Weather.qml icon. A singleton for the same
|
||||||
|
// reason as SystemUpdates.qml: Bar.qml is instantiated once per monitor
|
||||||
|
// (shell.qml's Variants), so without this being shared, every extra screen
|
||||||
|
// would mean another set of network requests running in parallel.
|
||||||
|
//
|
||||||
|
// Location is auto-detected from the public IP (via ipwho.is, no API key)
|
||||||
|
// and cached to disk (~/.cache/quickshell/weather-location.json) so it's
|
||||||
|
// only ever looked up once, not on every quickshell restart — IP-geolocation
|
||||||
|
// free tiers rate-limit fast, and a desktop's location barely ever changes
|
||||||
|
// anyway. Only the weather itself re-polls on a timer. Conditions come from
|
||||||
|
// Open-Meteo (api.open-meteo.com) — also free and keyless, and it does its
|
||||||
|
// own °C/km-h conversion server-side via query params.
|
||||||
|
//
|
||||||
|
// Loosely modeled on omarchy's weather panel
|
||||||
|
// (github.com/omacom/omarchy, quattro branch,
|
||||||
|
// shell/plugins/panels/weather/Model.js) for the WMO weather-code
|
||||||
|
// grouping, but the icons are this bar's own Material Symbols glyphs
|
||||||
|
// rather than pulling in a separate icon font just for this.
|
||||||
|
QtObject {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
// Location barely ever changes for a desktop machine, so it's fetched
|
||||||
|
// once (and cached); the weather itself is worth re-checking
|
||||||
|
// periodically.
|
||||||
|
readonly property int refreshIntervalMs: 15 * 60 * 1000
|
||||||
|
|
||||||
|
property bool locating: true
|
||||||
|
property bool loading: false
|
||||||
|
property bool hasLocation: false
|
||||||
|
property string locationName: ""
|
||||||
|
|
||||||
|
property real latitude: 0
|
||||||
|
property real longitude: 0
|
||||||
|
|
||||||
|
function saveLocation() {
|
||||||
|
_locationCache.setText(JSON.stringify({
|
||||||
|
latitude: root.latitude,
|
||||||
|
longitude: root.longitude,
|
||||||
|
name: root.locationName
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called from Weather.qml's search box once you pick a result — always
|
||||||
|
// wins over whatever IP-based guess was cached before, and persists so
|
||||||
|
// it sticks across restarts.
|
||||||
|
function selectLocation(name, lat, lon) {
|
||||||
|
root.latitude = lat
|
||||||
|
root.longitude = lon
|
||||||
|
root.locationName = name
|
||||||
|
root.hasLocation = true
|
||||||
|
root.locating = false
|
||||||
|
root.saveLocation()
|
||||||
|
root.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
property bool searching: false
|
||||||
|
property var searchResults: []
|
||||||
|
|
||||||
|
function search(query) {
|
||||||
|
const q = String(query || "").trim()
|
||||||
|
if (q.length < 2) {
|
||||||
|
root.searchResults = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
root.searching = true
|
||||||
|
_searchProc.command = ["curl", "-s", "--max-time", "6",
|
||||||
|
"https://geocoding-api.open-meteo.com/v1/search?count=5&language=en&format=json&name=" + encodeURIComponent(q)]
|
||||||
|
_searchProc.running = true
|
||||||
|
}
|
||||||
|
|
||||||
|
property real tempC: 0
|
||||||
|
property real feelsLikeC: 0
|
||||||
|
property int humidity: 0
|
||||||
|
property real windKmh: 0
|
||||||
|
property int weatherCode: 0
|
||||||
|
property bool isDay: true
|
||||||
|
|
||||||
|
// [{date, code, maxC, minC}] — tomorrow onward; today is already
|
||||||
|
// covered by the current-conditions fields above.
|
||||||
|
property var forecast: []
|
||||||
|
|
||||||
|
function locate() {
|
||||||
|
if (_locateProc.running) return
|
||||||
|
root.locating = true
|
||||||
|
_locateProc.running = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
if (!root.hasLocation || _weatherProc.running) return
|
||||||
|
root.loading = true
|
||||||
|
_weatherProc.command = ["curl", "-s", "--max-time", "8",
|
||||||
|
"https://api.open-meteo.com/v1/forecast?latitude=" + root.latitude
|
||||||
|
+ "&longitude=" + root.longitude
|
||||||
|
+ "¤t=temperature_2m,apparent_temperature,relative_humidity_2m,weather_code,is_day,wind_speed_10m"
|
||||||
|
+ "&daily=weather_code,temperature_2m_max,temperature_2m_min"
|
||||||
|
+ "&temperature_unit=celsius&wind_speed_unit=kmh&timezone=auto&forecast_days=4"]
|
||||||
|
_weatherProc.running = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Material Symbols Rounded glyph for a WMO weather code (see
|
||||||
|
// https://open-meteo.com/en/docs#weathervariables), grouped the same
|
||||||
|
// way omarchy's Model.js groups them into its own icon set.
|
||||||
|
function iconFor(code, day) {
|
||||||
|
if (code === 0) return day ? "sunny" : "clear_night"
|
||||||
|
if (code === 1 || code === 2) return day ? "partly_cloudy_day" : "partly_cloudy_night"
|
||||||
|
if (code === 3) return "cloud"
|
||||||
|
if (code === 45 || code === 48) return "foggy"
|
||||||
|
if ([51, 53, 55, 56, 57, 61, 63, 65, 66, 67, 80, 81, 82].indexOf(code) !== -1) return "rainy"
|
||||||
|
if ([71, 73, 75, 77, 85, 86].indexOf(code) !== -1) return "weather_snowy"
|
||||||
|
if (code === 95 || code === 96 || code === 99) return "thunderstorm"
|
||||||
|
return "cloud"
|
||||||
|
}
|
||||||
|
|
||||||
|
// QtObject has no default property, so these are attached via property
|
||||||
|
// assignment rather than as plain child elements (same as Colors.qml's
|
||||||
|
// FileView and SystemUpdates.qml's Process/Timer).
|
||||||
|
|
||||||
|
// Read once at startup; onLoaded/onLoadFailed (below) decide whether a
|
||||||
|
// network lookup is still needed.
|
||||||
|
readonly property FileView _locationCache: FileView {
|
||||||
|
path: Quickshell.env("HOME") + "/.cache/quickshell/weather-location.json"
|
||||||
|
onLoaded: {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(text())
|
||||||
|
const lat = parseFloat(data.latitude)
|
||||||
|
const lon = parseFloat(data.longitude)
|
||||||
|
if (!isNaN(lat) && !isNaN(lon)) {
|
||||||
|
root.latitude = lat
|
||||||
|
root.longitude = lon
|
||||||
|
root.locationName = data.name || ""
|
||||||
|
root.hasLocation = true
|
||||||
|
root.locating = false
|
||||||
|
root.refresh()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// fall through to a fresh lookup below
|
||||||
|
}
|
||||||
|
root.locate()
|
||||||
|
}
|
||||||
|
onLoadFailed: root.locate()
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly property Process _locateProc: Process {
|
||||||
|
command: ["curl", "-s", "--max-time", "6", "https://ipwho.is/"]
|
||||||
|
stdout: StdioCollector {
|
||||||
|
id: locateCollector
|
||||||
|
onStreamFinished: {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(locateCollector.text)
|
||||||
|
const lat = parseFloat(data.latitude)
|
||||||
|
const lon = parseFloat(data.longitude)
|
||||||
|
root.hasLocation = data.success !== false && !isNaN(lat) && !isNaN(lon)
|
||||||
|
if (root.hasLocation) {
|
||||||
|
root.latitude = lat
|
||||||
|
root.longitude = lon
|
||||||
|
root.locationName = data.city || data.region || data.country || ""
|
||||||
|
root.saveLocation()
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Weather: locate parse failed:", e)
|
||||||
|
root.hasLocation = false
|
||||||
|
}
|
||||||
|
root.locating = false
|
||||||
|
if (root.hasLocation) root.refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly property Process _weatherProc: Process {
|
||||||
|
stdout: StdioCollector {
|
||||||
|
id: weatherCollector
|
||||||
|
onStreamFinished: {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(weatherCollector.text)
|
||||||
|
const current = data.current || {}
|
||||||
|
root.tempC = current.temperature_2m
|
||||||
|
root.feelsLikeC = current.apparent_temperature
|
||||||
|
root.humidity = current.relative_humidity_2m
|
||||||
|
root.windKmh = current.wind_speed_10m
|
||||||
|
root.weatherCode = current.weather_code
|
||||||
|
root.isDay = current.is_day === 1
|
||||||
|
|
||||||
|
const daily = data.daily || {}
|
||||||
|
const times = daily.time || []
|
||||||
|
const days = []
|
||||||
|
// Start at 1 — index 0 is today, already shown as the
|
||||||
|
// current conditions above; this is "what's coming".
|
||||||
|
for (let i = 1; i < times.length && days.length < 3; i++) {
|
||||||
|
days.push({
|
||||||
|
date: times[i],
|
||||||
|
code: (daily.weather_code || [])[i],
|
||||||
|
maxC: (daily.temperature_2m_max || [])[i],
|
||||||
|
minC: (daily.temperature_2m_min || [])[i]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
root.forecast = days
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Weather: failed to parse Open-Meteo response:", e)
|
||||||
|
}
|
||||||
|
root.loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly property Process _searchProc: Process {
|
||||||
|
stdout: StdioCollector {
|
||||||
|
id: searchCollector
|
||||||
|
onStreamFinished: {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(searchCollector.text)
|
||||||
|
const results = data.results || []
|
||||||
|
root.searchResults = results.filter(function(r) {
|
||||||
|
return r && r.name && r.latitude !== undefined && r.longitude !== undefined
|
||||||
|
}).map(function(r) {
|
||||||
|
return {
|
||||||
|
name: r.name,
|
||||||
|
description: [r.admin1, r.country].filter(function(p) { return !!p }).join(", "),
|
||||||
|
latitude: r.latitude,
|
||||||
|
longitude: r.longitude
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
root.searchResults = []
|
||||||
|
}
|
||||||
|
root.searching = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly property Timer _timer: Timer {
|
||||||
|
interval: root.refreshIntervalMs
|
||||||
|
running: true
|
||||||
|
repeat: true
|
||||||
|
onTriggered: root.refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,3 +2,5 @@ module config
|
|||||||
singleton Colors 1.0 Colors.qml
|
singleton Colors 1.0 Colors.qml
|
||||||
singleton Appearance 1.0 Appearance.qml
|
singleton Appearance 1.0 Appearance.qml
|
||||||
singleton VpnExclusivity 1.0 VpnExclusivity.qml
|
singleton VpnExclusivity 1.0 VpnExclusivity.qml
|
||||||
|
singleton SystemUpdates 1.0 SystemUpdates.qml
|
||||||
|
singleton WeatherData 1.0 WeatherData.qml
|
||||||
|
|||||||
@@ -35,8 +35,12 @@ PanelWindow {
|
|||||||
ActiveWindow {}
|
ActiveWindow {}
|
||||||
}
|
}
|
||||||
|
|
||||||
Clock {
|
RowLayout {
|
||||||
anchors.centerIn: parent
|
anchors.centerIn: parent
|
||||||
|
spacing: Appearance.spacing
|
||||||
|
Weather { barWindow: bar }
|
||||||
|
Clock {}
|
||||||
|
SystemUpdate { barWindow: bar }
|
||||||
}
|
}
|
||||||
|
|
||||||
RowLayout {
|
RowLayout {
|
||||||
@@ -46,12 +50,13 @@ PanelWindow {
|
|||||||
rightMargin: Appearance.spacing
|
rightMargin: Appearance.spacing
|
||||||
}
|
}
|
||||||
spacing: Appearance.spacing
|
spacing: Appearance.spacing
|
||||||
//Tray {}
|
PinnedTray {}
|
||||||
TailscaleIndicator { barWindow: bar }
|
TailscaleIndicator { barWindow: bar }
|
||||||
VpnIndicator { barWindow: bar }
|
VpnIndicator { barWindow: bar }
|
||||||
NetworkIndicator { barWindow: bar }
|
NetworkIndicator { barWindow: bar }
|
||||||
BluetoothIndicator { barWindow: bar }
|
BluetoothIndicator { barWindow: bar }
|
||||||
Volume { barWindow: bar }
|
Volume { barWindow: bar }
|
||||||
|
BatteryIndicator { barWindow: bar }
|
||||||
PowerButton { barWindow: bar }
|
PowerButton { barWindow: bar }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
605
dotfiles/.config/quickshell/modules/bar/BatteryIndicator.qml
Normal file
605
dotfiles/.config/quickshell/modules/bar/BatteryIndicator.qml
Normal file
@@ -0,0 +1,605 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Hyprland
|
||||||
|
import Quickshell.Io
|
||||||
|
import Quickshell.Services.UPower
|
||||||
|
import "../../config"
|
||||||
|
|
||||||
|
// Battery + power-profile panel, ported from omarchy's power panel
|
||||||
|
// (github.com/basecamp/omarchy shell/plugins/panels/power/{Model.js,Panel.qml})
|
||||||
|
// onto this project's own conventions: Quickshell's native UPower/PowerProfiles
|
||||||
|
// bindings instead of omarchy's `omarchy-battery-status` /
|
||||||
|
// `omarchy-powerprofiles-list` / `omarchy-powerprofiles-set` helper scripts
|
||||||
|
// (which don't exist outside omarchy), and Material Symbols glyphs instead of
|
||||||
|
// Nerd Font ones. The charge-threshold ("Holding"/limited-charge) concept from
|
||||||
|
// the original is hardware-specific (vendor charge-limit sysfs knobs) and has
|
||||||
|
// no equivalent in Quickshell's UPower binding, so it's dropped rather than
|
||||||
|
// guessed at; "Battery health" (computed from sysfs energy_full/design) is shown in its
|
||||||
|
// place, using data UPower actually reports.
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
readonly property var device: UPower.displayDevice
|
||||||
|
// isLaptopBattery (type === Battery && powerSupply === true) is the
|
||||||
|
// Quickshell-recommended check for "is this actually a battery" — more
|
||||||
|
// precise than isPresent, which the docs note is only meaningful once
|
||||||
|
// you already know the device is of Battery type.
|
||||||
|
readonly property bool batteryPresent: !!(root.device && root.device.ready && root.device.isLaptopBattery)
|
||||||
|
|
||||||
|
// Hidden entirely on a desktop with no battery — RowLayout skips
|
||||||
|
// invisible children for both sizing and spacing, so this just vanishes
|
||||||
|
// from the bar rather than leaving a gap.
|
||||||
|
visible: root.batteryPresent
|
||||||
|
implicitWidth: root.batteryPresent ? label.implicitWidth : 0
|
||||||
|
implicitHeight: Appearance.barHeight
|
||||||
|
|
||||||
|
// The bar's own PanelWindow — passed in from Bar.qml so this popup can
|
||||||
|
// anchor to the bar itself rather than to this icon, keeping it a fixed
|
||||||
|
// distance from the bar and the screen edge regardless of where this
|
||||||
|
// icon sits in the row.
|
||||||
|
required property var barWindow
|
||||||
|
|
||||||
|
readonly property bool onBattery: UPower.onBattery
|
||||||
|
// Named deviceState, not "state" — Item already has a built-in `state`
|
||||||
|
// property (the property-based state-machine one) that this would
|
||||||
|
// otherwise collide with.
|
||||||
|
readonly property int deviceState: root.batteryPresent ? root.device.state : UPowerDeviceState.Unknown
|
||||||
|
readonly property bool fullyCharged: root.deviceState === UPowerDeviceState.FullyCharged
|
||||||
|
readonly property bool charging: root.batteryPresent && !root.onBattery && !root.fullyCharged
|
||||||
|
// device.percentage is a 0..1 fraction despite the name (same convention
|
||||||
|
// Volume.qml relies on for Pipewire's audio.volume).
|
||||||
|
readonly property real fraction: root.batteryPresent ? Math.max(0, Math.min(1, root.device.percentage)) : 0
|
||||||
|
readonly property bool low: root.batteryPresent && root.fraction <= 0.15 && !root.charging
|
||||||
|
|
||||||
|
// Quickshell's UPower binding has no charge-cycle property (see the
|
||||||
|
// file-header comment on why the old charge-until threshold was
|
||||||
|
// dropped) — it comes straight from sysfs instead.
|
||||||
|
//
|
||||||
|
// device.nativePath turned out to be empty on real hardware (tested
|
||||||
|
// live), so rather than build a path from it, a shell glob finds
|
||||||
|
// whichever BAT* directory actually exists — same shell-out-to-a-CLI
|
||||||
|
// convention TailscaleIndicator.qml uses for its own live stats.
|
||||||
|
// This barely changes during a session, so one read at startup is
|
||||||
|
// enough; no polling timer.
|
||||||
|
property int cycleCount: -1
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: cycleCountProc
|
||||||
|
command: ["sh", "-c", "cat \"$(ls -d /sys/class/power_supply/BAT* 2>/dev/null | head -n1)/cycle_count\" 2>/dev/null"]
|
||||||
|
running: root.batteryPresent
|
||||||
|
stdout: StdioCollector {
|
||||||
|
id: cycleCountCollector
|
||||||
|
onStreamFinished: {
|
||||||
|
const n = parseInt(cycleCountCollector.text.trim(), 10)
|
||||||
|
root.cycleCount = (isNaN(n) || n <= 0) ? -1 : n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// device.healthPercentage/healthSupported come from upowerd over D-Bus,
|
||||||
|
// and upowerd has been observed reporting energy-full as a flat 0 (a
|
||||||
|
// live daemon glitch, `upower -i` shows it too) while sysfs still has
|
||||||
|
// the real numbers underneath — same class of unreliability that put
|
||||||
|
// cycle_count on sysfs above instead of trusting upowerd's own
|
||||||
|
// charge-cycles field. Health is computed the same way UPower computes
|
||||||
|
// it (current full-charge capacity over the battery's original design
|
||||||
|
// capacity), just read directly so a daemon hiccup doesn't blank it.
|
||||||
|
property real energyFullWh: -1
|
||||||
|
property real energyFullDesignWh: -1
|
||||||
|
readonly property real healthPercent: (root.energyFullWh > 0 && root.energyFullDesignWh > 0)
|
||||||
|
? Math.min(100, (root.energyFullWh / root.energyFullDesignWh) * 100) : -1
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: energyFullProc
|
||||||
|
command: ["sh", "-c", "cat \"$(ls -d /sys/class/power_supply/BAT* 2>/dev/null | head -n1)/energy_full\" 2>/dev/null"]
|
||||||
|
running: root.batteryPresent
|
||||||
|
stdout: StdioCollector {
|
||||||
|
id: energyFullCollector
|
||||||
|
onStreamFinished: {
|
||||||
|
const n = parseFloat(energyFullCollector.text.trim())
|
||||||
|
root.energyFullWh = (isNaN(n) || n <= 0) ? -1 : (n / 1000000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: energyFullDesignProc
|
||||||
|
command: ["sh", "-c", "cat \"$(ls -d /sys/class/power_supply/BAT* 2>/dev/null | head -n1)/energy_full_design\" 2>/dev/null"]
|
||||||
|
running: root.batteryPresent
|
||||||
|
stdout: StdioCollector {
|
||||||
|
id: energyFullDesignCollector
|
||||||
|
onStreamFinished: {
|
||||||
|
const n = parseFloat(energyFullDesignCollector.text.trim())
|
||||||
|
root.energyFullDesignWh = (isNaN(n) || n <= 0) ? -1 : (n / 1000000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// device.timeToFull/timeToEmpty are computed by Quickshell from
|
||||||
|
// energy/energyCapacity/changeRate — when upowerd's EnergyFull glitches
|
||||||
|
// to 0 (the same daemon flake HEALTH routes around above), that formula
|
||||||
|
// divides by a bogus capacity and comes out negative rather than a
|
||||||
|
// real estimate (confirmed live: timeToFull sat at -3751 while `upower
|
||||||
|
// -i` itself still reported a normal 26.7 minutes). This recomputes
|
||||||
|
// the same estimate straight from sysfs as a fallback, only used when
|
||||||
|
// Quickshell's own value isn't positive. Unlike cycle_count/design
|
||||||
|
// capacity above, energy_now and power_now change continuously while
|
||||||
|
// (dis)charging, so this polls on a timer instead of reading once.
|
||||||
|
property real sysfsEnergyNowWh: -1
|
||||||
|
property real sysfsPowerNowW: -1
|
||||||
|
|
||||||
|
readonly property real fallbackTimeToFullSeconds: (root.sysfsPowerNowW > 0 && root.energyFullWh > 0 && root.sysfsEnergyNowWh >= 0)
|
||||||
|
? Math.max(0, (root.energyFullWh - root.sysfsEnergyNowWh) / root.sysfsPowerNowW * 3600) : -1
|
||||||
|
readonly property real fallbackTimeToEmptySeconds: (root.sysfsPowerNowW > 0 && root.sysfsEnergyNowWh > 0)
|
||||||
|
? (root.sysfsEnergyNowWh / root.sysfsPowerNowW * 3600) : -1
|
||||||
|
|
||||||
|
// Prefers upowerd's own live estimate when it's sane; only falls back
|
||||||
|
// to the sysfs-computed one when upowerd's has gone zero/negative.
|
||||||
|
readonly property real timeRemainingSeconds: {
|
||||||
|
const primary = root.onBattery ? root.device.timeToEmpty : root.device.timeToFull
|
||||||
|
if (primary > 0) return primary
|
||||||
|
return root.onBattery ? root.fallbackTimeToEmptySeconds : root.fallbackTimeToFullSeconds
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: sysfsRateTimer
|
||||||
|
interval: 15000
|
||||||
|
repeat: true
|
||||||
|
running: root.batteryPresent
|
||||||
|
triggeredOnStart: true
|
||||||
|
onTriggered: {
|
||||||
|
if (!sysfsEnergyNowProc.running) sysfsEnergyNowProc.running = true
|
||||||
|
if (!sysfsPowerNowProc.running) sysfsPowerNowProc.running = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: sysfsEnergyNowProc
|
||||||
|
command: ["sh", "-c", "cat \"$(ls -d /sys/class/power_supply/BAT* 2>/dev/null | head -n1)/energy_now\" 2>/dev/null"]
|
||||||
|
stdout: StdioCollector {
|
||||||
|
id: sysfsEnergyNowCollector
|
||||||
|
onStreamFinished: {
|
||||||
|
const n = parseFloat(sysfsEnergyNowCollector.text.trim())
|
||||||
|
root.sysfsEnergyNowWh = (isNaN(n) || n < 0) ? -1 : (n / 1000000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: sysfsPowerNowProc
|
||||||
|
command: ["sh", "-c", "cat \"$(ls -d /sys/class/power_supply/BAT* 2>/dev/null | head -n1)/power_now\" 2>/dev/null"]
|
||||||
|
stdout: StdioCollector {
|
||||||
|
id: sysfsPowerNowCollector
|
||||||
|
onStreamFinished: {
|
||||||
|
const n = parseFloat(sysfsPowerNowCollector.text.trim())
|
||||||
|
root.sysfsPowerNowW = (isNaN(n) || n <= 0) ? -1 : (n / 1000000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Raw UPower state string (Charging/Discharging/PendingCharge/…) — more
|
||||||
|
// granular than modeLabel below, which collapses those down to the
|
||||||
|
// three phrases the hero text and STATE row show.
|
||||||
|
readonly property string rawStateLabel: root.batteryPresent
|
||||||
|
? UPowerDeviceState.toString(root.deviceState) : "—"
|
||||||
|
|
||||||
|
// Material Symbols' charging glyphs aren't an evenly-spaced "_bar"
|
||||||
|
// scale like the non-charging ones below — they're fixed at these
|
||||||
|
// seven breakpoints (confirmed against the installed font's ligature
|
||||||
|
// table), so this picks the highest one at or below the current
|
||||||
|
// percentage rather than rounding to an index.
|
||||||
|
function chargingIcon(fraction) {
|
||||||
|
const percent = fraction * 100
|
||||||
|
const thresholds = [20, 30, 50, 60, 80, 90, 100]
|
||||||
|
const icons = [
|
||||||
|
"battery_charging_20", "battery_charging_30", "battery_charging_50", "battery_charging_60",
|
||||||
|
"battery_charging_80", "battery_charging_90", "battery_charging_full"
|
||||||
|
]
|
||||||
|
for (let i = thresholds.length - 1; i >= 0; i--) {
|
||||||
|
if (percent >= thresholds[i]) return icons[i]
|
||||||
|
}
|
||||||
|
// Below 20% there's no lower charging glyph — it's still the
|
||||||
|
// truthful floor of what's available while current is flowing in.
|
||||||
|
return icons[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
function batteryIcon() {
|
||||||
|
if (!root.batteryPresent) return "battery_unknown"
|
||||||
|
if (root.fullyCharged) return "battery_full"
|
||||||
|
if (root.charging) return root.chargingIcon(root.fraction)
|
||||||
|
if (root.fraction <= 0.1) return "battery_alert"
|
||||||
|
const bars = ["battery_0_bar", "battery_1_bar", "battery_2_bar", "battery_3_bar", "battery_4_bar", "battery_5_bar", "battery_full"]
|
||||||
|
return bars[Math.max(0, Math.min(bars.length - 1, Math.round(root.fraction * (bars.length - 1))))]
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(seconds) {
|
||||||
|
const s = Math.max(0, Math.round(seconds || 0))
|
||||||
|
if (s <= 0) return "—"
|
||||||
|
const h = Math.floor(s / 3600)
|
||||||
|
const m = Math.round((s % 3600) / 60)
|
||||||
|
return h > 0 ? (h + "h " + m + "m") : (m + "m")
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly property string modeLabel: {
|
||||||
|
if (!root.batteryPresent) return ""
|
||||||
|
if (root.fullyCharged) return "Fully charged"
|
||||||
|
return root.onBattery ? "On battery" : "Charging"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Info grid rows — same shape as NetworkIndicator.qml's statRows/
|
||||||
|
// statCells: a flat label/value list, flattened again into individual
|
||||||
|
// grid cells (label, value, label, value, …) so a 4-column GridLayout
|
||||||
|
// lays them out as two label/value pairs per row.
|
||||||
|
readonly property var statRows: [
|
||||||
|
{
|
||||||
|
label: "HEALTH",
|
||||||
|
value: root.healthPercent > 0 ? Math.round(root.healthPercent) + "%" : "—"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: root.onBattery ? "TIME LEFT" : "TIME TO FULL",
|
||||||
|
value: root.batteryPresent ? root.formatDuration(root.timeRemainingSeconds) : "—"
|
||||||
|
},
|
||||||
|
{ label: "STATUS", value: root.rawStateLabel },
|
||||||
|
{ label: "CYCLES", value: root.cycleCount > 0 ? String(root.cycleCount) : "—" }
|
||||||
|
]
|
||||||
|
|
||||||
|
readonly property var statCells: {
|
||||||
|
const cells = []
|
||||||
|
for (let i = 0; i < root.statRows.length; i++) {
|
||||||
|
cells.push({ text: root.statRows[i].label, isLabel: true })
|
||||||
|
cells.push({ text: root.statRows[i].value, isLabel: false })
|
||||||
|
}
|
||||||
|
return cells
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cute rotating status phrases while current is actually flowing —
|
||||||
|
// ported as-is from omarchy's power panel, which pairs the same phrase
|
||||||
|
// sets with the same charging/on-battery states.
|
||||||
|
readonly property var chargingPhrases: [
|
||||||
|
"Pumping power", "Injecting electrons", "Pouring juice", "Amassing watts",
|
||||||
|
"Hoarding joules", "Sucking volts", "Topping reserves", "Soaking amps", "Inhaling kilowatts"
|
||||||
|
]
|
||||||
|
readonly property var onBatteryPhrases: [
|
||||||
|
"Slurping power", "Spending joules", "Draining watts", "Burning electrons",
|
||||||
|
"Sipping juice", "Spending coulombs", "Bleeding amps", "Guzzling volts", "Munching reserves"
|
||||||
|
]
|
||||||
|
readonly property var activePhrases: root.fullyCharged ? []
|
||||||
|
: (root.charging ? root.chargingPhrases : (root.onBattery ? root.onBatteryPhrases : []))
|
||||||
|
readonly property bool rotatingPhrases: root.activePhrases.length > 0
|
||||||
|
property int phraseIndex: 0
|
||||||
|
readonly property string heroStatusText: root.rotatingPhrases
|
||||||
|
? root.activePhrases[root.phraseIndex % root.activePhrases.length]
|
||||||
|
: root.modeLabel
|
||||||
|
|
||||||
|
// Only rotates while the panel is open and actually charging/discharging.
|
||||||
|
Timer {
|
||||||
|
id: phraseTimer
|
||||||
|
interval: 2800
|
||||||
|
running: panel.visible && root.rotatingPhrases
|
||||||
|
repeat: true
|
||||||
|
triggeredOnStart: false
|
||||||
|
onTriggered: phraseSwap.restart()
|
||||||
|
}
|
||||||
|
|
||||||
|
SequentialAnimation {
|
||||||
|
id: phraseSwap
|
||||||
|
PropertyAnimation { target: heroStatus; property: "opacity"; to: 0.0; duration: 180; easing.type: Easing.OutQuad }
|
||||||
|
ScriptAction {
|
||||||
|
script: {
|
||||||
|
const n = root.activePhrases.length
|
||||||
|
if (n > 0) root.phraseIndex = (root.phraseIndex + 1) % n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PropertyAnimation { target: heroStatus; property: "opacity"; to: 1.0; duration: 260; easing.type: Easing.InQuad }
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we leave a rotating state mid-swap (e.g. unplugged mid-fade),
|
||||||
|
// snap back to full opacity so the new state reads immediately.
|
||||||
|
Connections {
|
||||||
|
target: root
|
||||||
|
function onRotatingPhrasesChanged() {
|
||||||
|
if (!root.rotatingPhrases) {
|
||||||
|
phraseSwap.stop()
|
||||||
|
heroStatus.opacity = 1.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Power profile list ------------------------------------------------
|
||||||
|
// power-profiles-daemon only ever exposes these three fixed profiles;
|
||||||
|
// Performance is conditional on hasPerformanceProfile (e.g. absent on
|
||||||
|
// battery-only/thermally-limited hardware) — same gating omarchy's own
|
||||||
|
// Model.js applies via its profile-list script.
|
||||||
|
readonly property var profileEntries: {
|
||||||
|
const entries = [
|
||||||
|
{ value: PowerProfile.PowerSaver, label: "Saver", icon: "eco" },
|
||||||
|
{ value: PowerProfile.Balanced, label: "Balanced", icon: "balance" }
|
||||||
|
]
|
||||||
|
if (PowerProfiles.hasPerformanceProfile)
|
||||||
|
entries.push({ value: PowerProfile.Performance, label: "Performance", icon: "bolt" })
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Automatic power profile --------------------------------------
|
||||||
|
// Battery-state-driven, on top of the manual picker above: below 20%
|
||||||
|
// → Power Saver (protect what's left), plugged in → Performance,
|
||||||
|
// unplugged and healthy → Balanced. Low battery wins even if a weak
|
||||||
|
// charger is still plugged in — running low is the more urgent signal.
|
||||||
|
// Performance only gets requested when the daemon actually reports it
|
||||||
|
// as available, falling back to Balanced instead of erroring on
|
||||||
|
// hardware without a performance profile (e.g. the ERROR this exact
|
||||||
|
// widget hit before power-profiles-daemon was installed).
|
||||||
|
//
|
||||||
|
// This only ever writes when the *target* changes (crossing the 20%
|
||||||
|
// line, plugging/unplugging) rather than on every poll, so a manual
|
||||||
|
// pick from the buttons above still sticks until the next real
|
||||||
|
// transition instead of being fought immediately.
|
||||||
|
readonly property int desiredProfile: {
|
||||||
|
if (root.fraction <= 0.20) return PowerProfile.PowerSaver
|
||||||
|
if (!root.onBattery) return PowerProfiles.hasPerformanceProfile ? PowerProfile.Performance : PowerProfile.Balanced
|
||||||
|
return PowerProfile.Balanced
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyDesiredProfile() {
|
||||||
|
if (!root.batteryPresent) return
|
||||||
|
if (PowerProfiles.profile !== root.desiredProfile) PowerProfiles.profile = root.desiredProfile
|
||||||
|
}
|
||||||
|
|
||||||
|
onDesiredProfileChanged: root.applyDesiredProfile()
|
||||||
|
Component.onCompleted: Qt.callLater(root.applyDesiredProfile)
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: label
|
||||||
|
anchors.centerIn: parent
|
||||||
|
font.family: Appearance.iconFontFamilyRounded
|
||||||
|
font.variableAxes: Appearance.iconFontAxes
|
||||||
|
font.pixelSize: 16
|
||||||
|
color: root.low ? Colors.urgent : Colors.foreground
|
||||||
|
text: root.batteryIcon()
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
anchors.fill: parent
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onClicked: {
|
||||||
|
panel.visible = !panel.visible
|
||||||
|
grab.active = panel.visible
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Reusable pieces -----------------------------------------------
|
||||||
|
|
||||||
|
component ProfileButton: Rectangle {
|
||||||
|
id: profBtn
|
||||||
|
required property string label
|
||||||
|
required property string icon
|
||||||
|
required property bool active
|
||||||
|
signal activate()
|
||||||
|
width: parent ? parent.width : 0
|
||||||
|
radius: 0
|
||||||
|
implicitHeight: 44
|
||||||
|
color: mouse.containsMouse ? Colors.primary
|
||||||
|
: (profBtn.active ? Qt.rgba(Colors.primary.r, Colors.primary.g, Colors.primary.b, 0.18) : "transparent")
|
||||||
|
|
||||||
|
// Hover-only outline, offset outside the button rather than drawn on
|
||||||
|
// its own edge — same treatment as this project's other selectable
|
||||||
|
// rows (e.g. Volume.qml's DeviceRow).
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: -3
|
||||||
|
radius: 0
|
||||||
|
color: "transparent"
|
||||||
|
border.color: Colors.foreground
|
||||||
|
border.width: mouse.containsMouse ? 1 : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
spacing: 2
|
||||||
|
Text {
|
||||||
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
|
text: profBtn.icon
|
||||||
|
font.family: Appearance.iconFontFamilyRounded
|
||||||
|
font.variableAxes: Appearance.iconFontAxes
|
||||||
|
font.pixelSize: 18
|
||||||
|
color: mouse.containsMouse ? Colors.primaryText : Colors.foreground
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
|
text: profBtn.label
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSizeSmall
|
||||||
|
font.bold: profBtn.active
|
||||||
|
color: mouse.containsMouse ? Colors.primaryText : Colors.foreground
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: mouse
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onClicked: profBtn.activate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Panel -----------------------------------------------------------
|
||||||
|
|
||||||
|
PopupWindow {
|
||||||
|
id: panel
|
||||||
|
visible: false
|
||||||
|
color: "transparent"
|
||||||
|
// Wider than before (244) to give the 2-column info grid's
|
||||||
|
// label/value pairs enough room per half without eliding —
|
||||||
|
// same reasoning as NetworkIndicator.qml's own wider panel.
|
||||||
|
readonly property int contentWidth: 280
|
||||||
|
readonly property int contentPadding: 16
|
||||||
|
implicitWidth: contentWidth + contentPadding * 2
|
||||||
|
implicitHeight: mainColumn.implicitHeight + contentPadding * 2
|
||||||
|
|
||||||
|
anchor {
|
||||||
|
window: root.barWindow
|
||||||
|
// Unlike anchor.item, anchor.window doesn't default rect to the
|
||||||
|
// window's own bounds — set it explicitly so edges/gravity below
|
||||||
|
// resolve against the full bar (and thus the true screen edges)
|
||||||
|
// instead of a near-zero rect at the window's origin.
|
||||||
|
rect.x: 0
|
||||||
|
rect.y: 0
|
||||||
|
rect.width: root.barWindow.width
|
||||||
|
rect.height: root.barWindow.height
|
||||||
|
edges: Edges.Bottom | Edges.Right
|
||||||
|
gravity: Edges.Bottom | Edges.Left
|
||||||
|
margins.bottom: -4
|
||||||
|
margins.right: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
HyprlandFocusGrab {
|
||||||
|
id: grab
|
||||||
|
windows: [panel]
|
||||||
|
onCleared: panel.visible = false
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
radius: 0
|
||||||
|
color: Colors.surface
|
||||||
|
border.color: Colors.muted
|
||||||
|
border.width: 3
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: mainColumn
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: panel.contentPadding
|
||||||
|
spacing: 10
|
||||||
|
|
||||||
|
// Hero: icon · title/status · percentage
|
||||||
|
RowLayout {
|
||||||
|
width: parent.width
|
||||||
|
spacing: 10
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: root.batteryIcon()
|
||||||
|
font.family: Appearance.iconFontFamilyRounded
|
||||||
|
font.variableAxes: Appearance.iconFontAxes
|
||||||
|
font.pixelSize: 30
|
||||||
|
color: root.low ? Colors.urgent : Colors.foreground
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 2
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
text: "Battery"
|
||||||
|
elide: Text.ElideRight
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: 18
|
||||||
|
font.bold: true
|
||||||
|
color: Colors.foreground
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
id: heroStatus
|
||||||
|
width: parent.width
|
||||||
|
text: root.heroStatusText
|
||||||
|
elide: Text.ElideRight
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSizeSmall
|
||||||
|
color: Colors.muted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: Math.round(root.fraction * 100) + "%"
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: 20
|
||||||
|
font.bold: true
|
||||||
|
color: Colors.foreground
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Charge level bar
|
||||||
|
Item {
|
||||||
|
width: parent.width
|
||||||
|
height: 4
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
radius: 0
|
||||||
|
color: Colors.muted
|
||||||
|
opacity: 0.35
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
radius: 0
|
||||||
|
color: root.low ? Colors.urgent : Colors.primary
|
||||||
|
width: Math.max(height, parent.width * root.fraction)
|
||||||
|
Behavior on width { NumberAnimation { duration: 320; easing.type: Easing.OutCubic } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stats — a 2-column, 2-row info grid, same layout
|
||||||
|
// NetworkIndicator.qml uses for its own live stats: a
|
||||||
|
// 4-column GridLayout fed a flattened label/value/label/value
|
||||||
|
// list, so each row holds two label/value pairs side by side
|
||||||
|
// with the values right-aligned against their own half.
|
||||||
|
GridLayout {
|
||||||
|
width: parent.width
|
||||||
|
columns: 4
|
||||||
|
columnSpacing: 12
|
||||||
|
rowSpacing: 5
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: root.statCells
|
||||||
|
Text {
|
||||||
|
required property var modelData
|
||||||
|
Layout.fillWidth: !modelData.isLabel
|
||||||
|
horizontalAlignment: modelData.isLabel ? Text.AlignLeft : Text.AlignRight
|
||||||
|
text: modelData.text
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSizeSmall
|
||||||
|
color: modelData.isLabel ? Colors.muted : Colors.foreground
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Power profile picker
|
||||||
|
Column {
|
||||||
|
width: parent.width
|
||||||
|
spacing: 6
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: "POWER PROFILE"
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSizeSmall
|
||||||
|
color: Colors.muted
|
||||||
|
}
|
||||||
|
|
||||||
|
Row {
|
||||||
|
id: profileRow
|
||||||
|
width: parent.width
|
||||||
|
spacing: 6
|
||||||
|
|
||||||
|
readonly property real cellWidth: root.profileEntries.length > 0
|
||||||
|
? (width - spacing * (root.profileEntries.length - 1)) / root.profileEntries.length
|
||||||
|
: 0
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: root.profileEntries
|
||||||
|
ProfileButton {
|
||||||
|
required property var modelData
|
||||||
|
width: profileRow.cellWidth
|
||||||
|
label: modelData.label
|
||||||
|
icon: modelData.icon
|
||||||
|
active: PowerProfiles.profile === modelData.value
|
||||||
|
onActivate: PowerProfiles.profile = modelData.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,15 @@ import "../../config"
|
|||||||
|
|
||||||
Item {
|
Item {
|
||||||
id: root
|
id: root
|
||||||
implicitWidth: label.implicitWidth + 4
|
// Independent left/right breathing room around the glyph. Material
|
||||||
|
// Symbols icons aren't monospaced — each one's font-reported advance
|
||||||
|
// width (label.implicitWidth) bakes in its own side bearings, so the
|
||||||
|
// same "+4, centered" math that every other bar icon uses can still
|
||||||
|
// look tighter/looser icon-to-icon. Tune these two by eye instead of
|
||||||
|
// the icon's own bounding box to even it out against its neighbours.
|
||||||
|
property real iconLeftPadding: 0
|
||||||
|
property real iconRightPadding: 0
|
||||||
|
implicitWidth: root.iconLeftPadding + label.implicitWidth + root.iconRightPadding
|
||||||
implicitHeight: Appearance.barHeight
|
implicitHeight: Appearance.barHeight
|
||||||
|
|
||||||
// The bar's own PanelWindow — passed in from Bar.qml so this popup can
|
// The bar's own PanelWindow — passed in from Bar.qml so this popup can
|
||||||
@@ -71,6 +79,7 @@ Item {
|
|||||||
bonded: !!d.bonded,
|
bonded: !!d.bonded,
|
||||||
trusted: !!d.trusted,
|
trusted: !!d.trusted,
|
||||||
pairing: !!d.pairing,
|
pairing: !!d.pairing,
|
||||||
|
state: d.state,
|
||||||
batteryAvailable: !!d.batteryAvailable,
|
batteryAvailable: !!d.batteryAvailable,
|
||||||
battery: d.battery || 0
|
battery: d.battery || 0
|
||||||
}
|
}
|
||||||
@@ -97,19 +106,107 @@ Item {
|
|||||||
return null
|
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) {
|
function connectDevice(row) {
|
||||||
const d = root.deviceForAddress(row.address)
|
const d = root.deviceForAddress(row.address)
|
||||||
if (!d) return
|
if (!d) return
|
||||||
// Already-paired devices just reconnect; a fresh discovery result
|
// Already-paired devices just reconnect; a fresh discovery result
|
||||||
// has to pair first (BlueZ's own agent handles the SSP/passkey
|
// has to pair first (BlueZ's own agent handles the SSP/passkey
|
||||||
// handshake — there's no PIN-entry surface exposed by this API).
|
// handshake — there's no PIN-entry surface exposed by this API).
|
||||||
if (row.paired || row.bonded || row.trusted) d.connect()
|
if (row.paired || row.bonded || row.trusted) {
|
||||||
else d.pair()
|
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) {
|
function disconnectDevice(row) {
|
||||||
const d = root.deviceForAddress(row.address)
|
const d = root.deviceForAddress(row.address)
|
||||||
if (d) d.disconnect()
|
if (!d) return
|
||||||
|
root.pendingAction = { address: row.address, kind: "disconnect" }
|
||||||
|
pendingTimeoutTimer.restart()
|
||||||
|
d.disconnect()
|
||||||
}
|
}
|
||||||
|
|
||||||
function forgetDevice(row) {
|
function forgetDevice(row) {
|
||||||
@@ -117,6 +214,18 @@ Item {
|
|||||||
if (!d) return
|
if (!d) return
|
||||||
if (d.connected) d.disconnect()
|
if (d.connected) d.disconnect()
|
||||||
d.forget()
|
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
|
readonly property bool anyConnected: root.connectedRows.length > 0
|
||||||
@@ -128,8 +237,10 @@ Item {
|
|||||||
|
|
||||||
Text {
|
Text {
|
||||||
id: label
|
id: label
|
||||||
anchors.centerIn: parent
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
font.family: Appearance.iconFontFamily
|
anchors.left: parent.left
|
||||||
|
anchors.leftMargin: root.iconLeftPadding
|
||||||
|
font.family: Appearance.iconFontFamilyRounded
|
||||||
font.variableAxes: Appearance.iconFontAxes
|
font.variableAxes: Appearance.iconFontAxes
|
||||||
font.pixelSize: 16
|
font.pixelSize: 16
|
||||||
color: (root.adapter && root.adapter.enabled) ? Colors.foreground : Colors.muted
|
color: (root.adapter && root.adapter.enabled) ? Colors.foreground : Colors.muted
|
||||||
@@ -163,6 +274,18 @@ Item {
|
|||||||
color: toggleRoot.checked ? Colors.primary : Colors.muted
|
color: toggleRoot.checked ? Colors.primary : Colors.muted
|
||||||
opacity: toggleRoot.checked ? 1 : 0.35
|
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 {
|
Rectangle {
|
||||||
width: parent.height - 4
|
width: parent.height - 4
|
||||||
height: parent.height - 4
|
height: parent.height - 4
|
||||||
@@ -173,14 +296,21 @@ Item {
|
|||||||
Behavior on x { NumberAnimation { duration: Appearance.animationDuration } }
|
Behavior on x { NumberAnimation { duration: Appearance.animationDuration } }
|
||||||
}
|
}
|
||||||
MouseArea {
|
MouseArea {
|
||||||
|
id: toggleMouse
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
cursorShape: Qt.PointingHandCursor
|
cursorShape: Qt.PointingHandCursor
|
||||||
onClicked: toggleRoot.toggled()
|
onClicked: toggleRoot.toggled()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// One device row. `showForget` hides the forget (x) button for
|
// One device row. `showForget` gates the whole trailing action cluster —
|
||||||
// discovered-but-unpaired devices, which have nothing to forget yet.
|
// 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 {
|
component DeviceRow: Rectangle {
|
||||||
id: devRow
|
id: devRow
|
||||||
required property var dev
|
required property var dev
|
||||||
@@ -189,9 +319,47 @@ Item {
|
|||||||
width: parent ? parent.width : 0
|
width: parent ? parent.width : 0
|
||||||
radius: 0
|
radius: 0
|
||||||
implicitHeight: rowContent.implicitHeight + 12
|
implicitHeight: rowContent.implicitHeight + 12
|
||||||
color: devRow.dev.connected
|
|
||||||
? Qt.rgba(Colors.primary.r, Colors.primary.g, Colors.primary.b, 0.18)
|
// Which action icon (if any) the pointer is currently over — drives
|
||||||
: (rowMouse.containsMouse ? Colors.primary : "transparent")
|
// 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 {
|
RowLayout {
|
||||||
id: rowContent
|
id: rowContent
|
||||||
@@ -207,7 +375,7 @@ Item {
|
|||||||
Layout.maximumWidth: 20
|
Layout.maximumWidth: 20
|
||||||
horizontalAlignment: Text.AlignLeft
|
horizontalAlignment: Text.AlignLeft
|
||||||
text: devRow.dev.icon
|
text: devRow.dev.icon
|
||||||
font.family: Appearance.iconFontFamily
|
font.family: Appearance.iconFontFamilyRounded
|
||||||
font.variableAxes: Appearance.iconFontAxes
|
font.variableAxes: Appearance.iconFontAxes
|
||||||
font.pixelSize: 14
|
font.pixelSize: 14
|
||||||
color: rowMouse.containsMouse ? Colors.primaryText : Colors.foreground
|
color: rowMouse.containsMouse ? Colors.primaryText : Colors.foreground
|
||||||
@@ -229,6 +397,17 @@ Item {
|
|||||||
Text {
|
Text {
|
||||||
readonly property string statusText: {
|
readonly property string statusText: {
|
||||||
if (devRow.dev.pairing) return "Pairing…"
|
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)
|
if (devRow.dev.connected && devRow.dev.batteryAvailable)
|
||||||
return Math.round(devRow.dev.battery * 100) + "%"
|
return Math.round(devRow.dev.battery * 100) + "%"
|
||||||
if (devRow.dev.connected) return "Connected"
|
if (devRow.dev.connected) return "Connected"
|
||||||
@@ -242,17 +421,100 @@ Item {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 {
|
Text {
|
||||||
id: forgetIcon
|
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
|
visible: devRow.showForget
|
||||||
text: "close"
|
Layout.preferredWidth: 20
|
||||||
font.family: Appearance.iconFontFamily
|
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.variableAxes: Appearance.iconFontAxes
|
||||||
font.pixelSize: 14
|
font.pixelSize: 14
|
||||||
color: rowMouse.containsMouse ? Colors.primaryText : Colors.muted
|
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 {
|
MouseArea {
|
||||||
id: rowMouse
|
id: rowMouse
|
||||||
anchors.left: parent.left
|
anchors.left: parent.left
|
||||||
@@ -261,22 +523,16 @@ Item {
|
|||||||
height: rowContent.height + 12
|
height: rowContent.height + 12
|
||||||
hoverEnabled: true
|
hoverEnabled: true
|
||||||
cursorShape: Qt.PointingHandCursor
|
cursorShape: Qt.PointingHandCursor
|
||||||
// forgetIcon has no MouseArea of its own — nested inside
|
// Dispatches on devRow.hoveredAction rather than its own
|
||||||
// rowContent, which sits underneath this full-row area in
|
// MouseArea per icon — see that property's comment above.
|
||||||
// stacking order, so a separate MouseArea there would just be
|
onClicked: {
|
||||||
// shadowed (see the identical fix in NetworkIndicator.qml's
|
switch (devRow.hoveredAction) {
|
||||||
// NetworkRow). Dispatch by click position against this single
|
case "forget": root.forgetDevice(devRow.dev); break
|
||||||
// area instead.
|
case "trust": root.setTrusted(devRow.dev, !devRow.dev.trusted); break
|
||||||
onClicked: (mouse) => {
|
case "disconnect": root.disconnectDevice(devRow.dev); break
|
||||||
if (devRow.showForget && forgetIcon.visible) {
|
default: devRow.activate()
|
||||||
const forgetLeft = devRow.mapFromItem(forgetIcon, 0, 0).x
|
|
||||||
if (mouse.x >= forgetLeft - 8) {
|
|
||||||
root.forgetDevice(devRow.dev)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
devRow.activate()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,7 +542,11 @@ Item {
|
|||||||
id: panel
|
id: panel
|
||||||
visible: false
|
visible: false
|
||||||
color: "transparent"
|
color: "transparent"
|
||||||
readonly property int contentWidth: 244
|
// Wider than the other panels' usual 244 — each device row now ends
|
||||||
|
// in a row of three 20px action slots (disconnect/trust/forget)
|
||||||
|
// instead of a single close icon, and the name column needs room to
|
||||||
|
// not immediately start eliding against that.
|
||||||
|
readonly property int contentWidth: 300
|
||||||
readonly property int contentPadding: 16
|
readonly property int contentPadding: 16
|
||||||
implicitWidth: contentWidth + contentPadding * 2
|
implicitWidth: contentWidth + contentPadding * 2
|
||||||
implicitHeight: mainColumn.implicitHeight + contentPadding * 2
|
implicitHeight: mainColumn.implicitHeight + contentPadding * 2
|
||||||
@@ -335,9 +595,9 @@ Item {
|
|||||||
|
|
||||||
Text {
|
Text {
|
||||||
text: label.text
|
text: label.text
|
||||||
font.family: Appearance.iconFontFamily
|
font.family: Appearance.iconFontFamilyRounded
|
||||||
font.variableAxes: Appearance.iconFontAxes
|
font.variableAxes: Appearance.iconFontAxes
|
||||||
font.pixelSize: 30
|
font.pixelSize: 21
|
||||||
color: Colors.foreground
|
color: Colors.foreground
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,6 +610,29 @@ Item {
|
|||||||
color: Colors.foreground
|
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 {
|
ToggleSwitch {
|
||||||
visible: root.adapter !== null
|
visible: root.adapter !== null
|
||||||
checked: root.adapter !== null && root.adapter.enabled
|
checked: root.adapter !== null && root.adapter.enabled
|
||||||
@@ -389,7 +672,10 @@ Item {
|
|||||||
color: Colors.muted
|
color: Colors.muted
|
||||||
}
|
}
|
||||||
Column {
|
Column {
|
||||||
width: parent.width
|
// Inset 3px on each side to match deviceListColumn
|
||||||
|
// below, so DeviceRow's hover halo has room here too.
|
||||||
|
x: 3
|
||||||
|
width: parent.width - 6
|
||||||
spacing: 2
|
spacing: 2
|
||||||
Repeater {
|
Repeater {
|
||||||
model: root.connectedRows
|
model: root.connectedRows
|
||||||
@@ -403,6 +689,42 @@ Item {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Paired — always shown in full, same as Connected above and
|
||||||
|
// for the same reason: these are one click away from
|
||||||
|
// reconnecting (root.connectDevice reconnects rather than
|
||||||
|
// re-pairing whenever a row is already paired/bonded/trusted),
|
||||||
|
// so they shouldn't be buried in the capped-height scroll area
|
||||||
|
// below or hidden behind an active scan turning up other
|
||||||
|
// nearby devices.
|
||||||
|
Column {
|
||||||
|
width: parent.width
|
||||||
|
spacing: 4
|
||||||
|
visible: root.knownRows.length > 0
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: "Paired Devices"
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSizeSmall
|
||||||
|
color: Colors.muted
|
||||||
|
}
|
||||||
|
Column {
|
||||||
|
// Inset 3px on each side to match deviceListColumn
|
||||||
|
// below, so DeviceRow's hover halo has room here too.
|
||||||
|
x: 3
|
||||||
|
width: parent.width - 6
|
||||||
|
spacing: 2
|
||||||
|
Repeater {
|
||||||
|
model: root.knownRows
|
||||||
|
DeviceRow {
|
||||||
|
required property var modelData
|
||||||
|
dev: modelData
|
||||||
|
showForget: true
|
||||||
|
onActivate: root.connectDevice(dev)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Text {
|
Text {
|
||||||
width: parent.width
|
width: parent.width
|
||||||
visible: root.adapter !== null && root.adapter.enabled && root.deviceRows.length === 0
|
visible: root.adapter !== null && root.adapter.enabled && root.deviceRows.length === 0
|
||||||
@@ -412,9 +734,9 @@ Item {
|
|||||||
color: Colors.muted
|
color: Colors.muted
|
||||||
}
|
}
|
||||||
|
|
||||||
// Paired/Available — capped height, scrolls once there are
|
// Available — freshly discovered, unpaired devices only.
|
||||||
// more devices than fit rather than growing the popup
|
// Capped height, scrolls once there are more than fit rather
|
||||||
// indefinitely.
|
// than growing the popup indefinitely.
|
||||||
Flickable {
|
Flickable {
|
||||||
id: deviceListFlick
|
id: deviceListFlick
|
||||||
width: parent.width
|
width: parent.width
|
||||||
@@ -427,35 +749,19 @@ Item {
|
|||||||
|
|
||||||
Column {
|
Column {
|
||||||
id: deviceListColumn
|
id: deviceListColumn
|
||||||
width: deviceListFlick.width
|
// Inset 3px on every side so DeviceRow's hover halo
|
||||||
|
// has room without being cropped by deviceListFlick's
|
||||||
|
// clip. Left/right needs the manual x/width shift
|
||||||
|
// (children bind width: parent.width, which ignores
|
||||||
|
// Column's own padding); top/bottom just needs
|
||||||
|
// padding, since that's along Column's own layout
|
||||||
|
// axis and Flickable's contentHeight tracks it.
|
||||||
|
x: 3
|
||||||
|
width: deviceListFlick.width - 6
|
||||||
|
topPadding: 3
|
||||||
|
bottomPadding: 3
|
||||||
spacing: 6
|
spacing: 6
|
||||||
|
|
||||||
Column {
|
|
||||||
width: parent.width
|
|
||||||
spacing: 4
|
|
||||||
visible: root.knownRows.length > 0
|
|
||||||
|
|
||||||
Text {
|
|
||||||
text: "Paired Devices"
|
|
||||||
font.family: Appearance.fontFamily
|
|
||||||
font.pixelSize: Appearance.fontSizeSmall
|
|
||||||
color: Colors.muted
|
|
||||||
}
|
|
||||||
Column {
|
|
||||||
width: parent.width
|
|
||||||
spacing: 2
|
|
||||||
Repeater {
|
|
||||||
model: root.knownRows
|
|
||||||
DeviceRow {
|
|
||||||
required property var modelData
|
|
||||||
dev: modelData
|
|
||||||
showForget: true
|
|
||||||
onActivate: root.connectDevice(dev)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
width: parent.width
|
width: parent.width
|
||||||
spacing: 4
|
spacing: 4
|
||||||
|
|||||||
@@ -44,7 +44,13 @@ Item {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only scan for networks while the panel is actually open.
|
// Only scan for networks while the panel is actually open. Deliberately
|
||||||
|
// NOT paused while a password prompt is up: scannerEnabled: false stops
|
||||||
|
// NetworkManager from refreshing its AP cache at all, so the existing
|
||||||
|
// list quietly ages out with nothing to replace it — the fix for typing
|
||||||
|
// being interrupted by scan-driven refreshes lives below instead (the
|
||||||
|
// password field itself is pulled out of the repeated row so refreshes
|
||||||
|
// can't touch it), not here.
|
||||||
Binding {
|
Binding {
|
||||||
target: root.wifiDevice
|
target: root.wifiDevice
|
||||||
property: "scannerEnabled"
|
property: "scannerEnabled"
|
||||||
@@ -84,6 +90,24 @@ Item {
|
|||||||
? sortWifiRows(root.wifiDevice.networks.values.map(root.wifiRow).filter(function(r) { return r && r.ssid }))
|
? sortWifiRows(root.wifiDevice.networks.values.map(root.wifiRow).filter(function(r) { return r && r.ssid }))
|
||||||
: []
|
: []
|
||||||
|
|
||||||
|
// Quickshell's WifiDevice has no "a scan is in flight" signal — only
|
||||||
|
// scannerEnabled, the on/off request, which stays true for as long as
|
||||||
|
// the panel is open rather than pulsing per scan. wifiRows re-deriving
|
||||||
|
// to a new array (as it does on every actual network-list change — see
|
||||||
|
// the comment on wifiRow() above) is the closest available proxy for
|
||||||
|
// "a scan just landed", so pulse briefly off that instead.
|
||||||
|
property bool wifiJustScanned: false
|
||||||
|
onWifiRowsChanged: {
|
||||||
|
root.wifiJustScanned = true
|
||||||
|
wifiScanPulseTimer.restart()
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: wifiScanPulseTimer
|
||||||
|
interval: 1200
|
||||||
|
onTriggered: root.wifiJustScanned = false
|
||||||
|
}
|
||||||
|
|
||||||
// Split for the "Known Networks" / "Other Networks" sections. wifiRows
|
// Split for the "Known Networks" / "Other Networks" sections. wifiRows
|
||||||
// is already sorted connected-first then by signal, so filtering
|
// is already sorted connected-first then by signal, so filtering
|
||||||
// preserves that order within each group.
|
// preserves that order within each group.
|
||||||
@@ -135,8 +159,17 @@ Item {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SSID of the row currently showing its inline password field, if any.
|
// SSID awaiting a password, if any. The input itself lives in a single
|
||||||
|
// persistent widget below the network list (see passwordEntryInput),
|
||||||
|
// not inside the per-row NetworkRow delegate — wifiRows is rebuilt from
|
||||||
|
// scratch on every scan result, and a Repeater over a plain array has no
|
||||||
|
// way to diff that, so it destroys and recreates every delegate each
|
||||||
|
// time. Keeping the field outside that churn is what lets typing survive
|
||||||
|
// a scan landing mid-keystroke.
|
||||||
property string passwordSsid: ""
|
property string passwordSsid: ""
|
||||||
|
// Cleared whenever the target SSID changes so stale text from a
|
||||||
|
// cancelled/previous prompt never carries over to the next one.
|
||||||
|
onPasswordSsidChanged: passwordEntryInput.text = ""
|
||||||
|
|
||||||
// Wired connections don't have a user-facing "name" on the device or
|
// Wired connections don't have a user-facing "name" on the device or
|
||||||
// Network object — those just expose the interface name (e.g. "eth0").
|
// Network object — those just expose the interface name (e.g. "eth0").
|
||||||
@@ -161,6 +194,14 @@ Item {
|
|||||||
return (row && row.ssid) || "Network"
|
return (row && row.ssid) || "Network"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Signal strength of the currently-connected WiFi network, if any — lets
|
||||||
|
// the bar icon show the same wifi-fan glyph (via wifiIcon()) as the
|
||||||
|
// connected row inside the popup, instead of a generic "wifi" icon.
|
||||||
|
readonly property int connectedWifiSignal: {
|
||||||
|
const row = root.wifiRows.find(function(r) { return r.connected })
|
||||||
|
return row ? row.signal : 0
|
||||||
|
}
|
||||||
|
|
||||||
function activateNetwork(net) {
|
function activateNetwork(net) {
|
||||||
if (net.connected) return
|
if (net.connected) return
|
||||||
if (net.known || !root.requiresCredentials(net.security)) {
|
if (net.known || !root.requiresCredentials(net.security)) {
|
||||||
@@ -171,6 +212,92 @@ Item {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function submitPassword() {
|
||||||
|
const n = root.networkForSsid(root.passwordSsid)
|
||||||
|
if (n && passwordEntryInput.text.length > 0) n.connectWithPsk(passwordEntryInput.text)
|
||||||
|
root.passwordSsid = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelPassword() {
|
||||||
|
root.passwordSsid = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Captive portal ---------------------------------------------------
|
||||||
|
// NetworkManager tells "portal" (a walled-garden sign-in page sits
|
||||||
|
// between us and the real internet) apart from "full" connectivity only
|
||||||
|
// via an active probe — nmcli's cached CONNECTIVITY field just reflects
|
||||||
|
// NM's own internal check, which runs every few minutes and is far too
|
||||||
|
// slow to catch a portal right after joining a network. So this forces a
|
||||||
|
// fresh probe itself, same shell-out approach as the stats below, and
|
||||||
|
// runs it whenever we're connected — not gated to panel.visible like
|
||||||
|
// those — since the whole point is noticing this before the popup is
|
||||||
|
// ever opened.
|
||||||
|
//
|
||||||
|
// "limited" is treated the same as "portal", not just "full" vs.
|
||||||
|
// everything-else: NM only reports "portal" when the probe request
|
||||||
|
// actually comes back *different* (redirected/rewritten) from what it
|
||||||
|
// expected. Plenty of real captive portals instead black-hole every
|
||||||
|
// request that isn't to their own login domain, so the probe just times
|
||||||
|
// out — which is also why this is slow to resolve — and NM reports
|
||||||
|
// "limited" (it got a route/gateway, but the internet check itself
|
||||||
|
// never landed) rather than "portal". From the user's side both look
|
||||||
|
// identical: connected, but no real internet.
|
||||||
|
property string connectivityState: "unknown"
|
||||||
|
readonly property bool connectivityRestricted: root.connectivityState === "portal" || root.connectivityState === "limited"
|
||||||
|
|
||||||
|
// A plain, unencrypted HTTP page whose only job is to be interceptable
|
||||||
|
// by a captive portal's redirect. An https:// URL mostly just gets
|
||||||
|
// silently blocked instead of redirected, which looks identical to the
|
||||||
|
// portal not existing at all.
|
||||||
|
readonly property string captivePortalCheckUrl: "http://neverssl.com/"
|
||||||
|
|
||||||
|
// Auto-opened once per network join (mirrors TailscaleIndicator's
|
||||||
|
// openAuthUrl/_authUrlOpened) so flapping connectivity on the same
|
||||||
|
// network doesn't keep popping a new browser tab — the manual "Open"
|
||||||
|
// button in the panel banner below still works regardless.
|
||||||
|
property bool _portalAutoOpened: false
|
||||||
|
property bool _wasConnected: false
|
||||||
|
onConnectedDeviceChanged: {
|
||||||
|
const isConnected = root.connectedDevice !== null
|
||||||
|
if (isConnected && !root._wasConnected) {
|
||||||
|
root._portalAutoOpened = false
|
||||||
|
root.connectivityState = "unknown"
|
||||||
|
}
|
||||||
|
root._wasConnected = isConnected
|
||||||
|
}
|
||||||
|
|
||||||
|
onConnectivityRestrictedChanged: {
|
||||||
|
if (root.connectivityRestricted && !root._portalAutoOpened)
|
||||||
|
root.openCaptivePortal()
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCaptivePortal() {
|
||||||
|
root._portalAutoOpened = true
|
||||||
|
Qt.openUrlExternally(root.captivePortalCheckUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: connectivityProc
|
||||||
|
command: ["nmcli", "networking", "connectivity", "check"]
|
||||||
|
stdout: StdioCollector {
|
||||||
|
id: connectivityCollector
|
||||||
|
onStreamFinished: root.connectivityState = connectivityCollector.text.trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: connectivityTimer
|
||||||
|
// Poll quickly while things are still unresolved (fresh join, or an
|
||||||
|
// active portal we're waiting to clear) and back off once
|
||||||
|
// connectivity is confirmed full, rather than shelling out every
|
||||||
|
// few seconds forever for no reason.
|
||||||
|
interval: root.connectivityState === "full" ? 20000 : 3000
|
||||||
|
repeat: true
|
||||||
|
triggeredOnStart: true
|
||||||
|
running: root.connectedDevice !== null
|
||||||
|
onTriggered: if (!connectivityProc.running) connectivityProc.running = true
|
||||||
|
}
|
||||||
|
|
||||||
// --- Live stats --------------------------------------------------
|
// --- Live stats --------------------------------------------------
|
||||||
// None of this comes from Quickshell's Networking API — it only knows
|
// None of this comes from Quickshell's Networking API — it only knows
|
||||||
// about devices/networks, not throughput, ping, or routing. So this
|
// about devices/networks, not throughput, ping, or routing. So this
|
||||||
@@ -517,14 +644,58 @@ Item {
|
|||||||
Text {
|
Text {
|
||||||
id: label
|
id: label
|
||||||
anchors.centerIn: parent
|
anchors.centerIn: parent
|
||||||
font.family: Appearance.iconFontFamily
|
font.family: Appearance.iconFontFamilyRounded
|
||||||
font.variableAxes: Appearance.iconFontAxes
|
font.variableAxes: Appearance.iconFontAxes
|
||||||
font.pixelSize: 16
|
font.pixelSize: 16
|
||||||
color: root.connectedDevice ? Colors.foreground : Colors.muted
|
color: root.connectedDevice ? Colors.foreground : Colors.muted
|
||||||
|
// Only Wired renders its own distinct glyph; disconnected uses
|
||||||
|
// "signal_wifi_off" — the actual off companion to the
|
||||||
|
// network_wifi/signal_wifi fan family used below (not "wifi_off",
|
||||||
|
// which belongs to a visually different, newer glyph family and
|
||||||
|
// looked inconsistent next to the connected bars) — so it needs the
|
||||||
|
// same vertical-stretch correction as the rest of that family.
|
||||||
|
readonly property bool isWifiFan: root.connectedDevice === null || root.connectedDevice.type !== DeviceType.Wired
|
||||||
text: {
|
text: {
|
||||||
if (!root.connectedDevice)
|
if (!root.connectedDevice)
|
||||||
return "wifi_off"
|
return "signal_wifi_off"
|
||||||
return root.connectedDevice.type === DeviceType.Wired ? "settings_ethernet" : "wifi"
|
if (root.connectedDevice.type === DeviceType.Wired)
|
||||||
|
return "settings_ethernet"
|
||||||
|
return root.wifiIcon(root.connectedWifiSignal)
|
||||||
|
}
|
||||||
|
// Same correction as the matching glyph inside the popup's
|
||||||
|
// NetworkRow — the wifi-fan family is inherently flat/wide.
|
||||||
|
transform: Scale {
|
||||||
|
origin.x: label.width / 2
|
||||||
|
origin.y: label.height / 2
|
||||||
|
yScale: label.isWifiFan ? 1.2 : 1.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Small warning badge over the icon's corner when the connection looks
|
||||||
|
// restricted (a captive portal, confirmed or just suspected — see
|
||||||
|
// connectivityRestricted above) — same treatment as TailscaleIndicator's
|
||||||
|
// login-needed badge, so "this needs your attention" reads the same way
|
||||||
|
// across indicators.
|
||||||
|
Rectangle {
|
||||||
|
visible: root.connectivityRestricted
|
||||||
|
width: Math.max(7, label.font.pixelSize * 0.42)
|
||||||
|
height: width
|
||||||
|
radius: width / 2
|
||||||
|
color: Colors.urgent
|
||||||
|
border.color: Colors.surface
|
||||||
|
border.width: 1
|
||||||
|
anchors.right: label.right
|
||||||
|
anchors.bottom: label.bottom
|
||||||
|
anchors.rightMargin: -1
|
||||||
|
anchors.bottomMargin: -1
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: "!"
|
||||||
|
color: Colors.surface
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Math.max(6, parent.height * 0.75)
|
||||||
|
font.bold: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -552,6 +723,18 @@ Item {
|
|||||||
color: toggleRoot.checked ? Colors.primary : Colors.muted
|
color: toggleRoot.checked ? Colors.primary : Colors.muted
|
||||||
opacity: toggleRoot.checked ? 1 : 0.35
|
opacity: toggleRoot.checked ? 1 : 0.35
|
||||||
}
|
}
|
||||||
|
// Hover-only outline, offset outside the switch itself rather than
|
||||||
|
// drawn on its edge — the switch lags behind a click until the
|
||||||
|
// backend confirms it (e.g. Networking.wifiEnabled), so this is just
|
||||||
|
// a "you can click this" cue, not click feedback.
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: -4
|
||||||
|
radius: 0
|
||||||
|
color: "transparent"
|
||||||
|
border.color: Colors.foreground
|
||||||
|
border.width: toggleMouse.containsMouse ? 1 : 0
|
||||||
|
}
|
||||||
Rectangle {
|
Rectangle {
|
||||||
width: parent.height - 4
|
width: parent.height - 4
|
||||||
height: parent.height - 4
|
height: parent.height - 4
|
||||||
@@ -562,7 +745,9 @@ Item {
|
|||||||
Behavior on x { NumberAnimation { duration: Appearance.animationDuration } }
|
Behavior on x { NumberAnimation { duration: Appearance.animationDuration } }
|
||||||
}
|
}
|
||||||
MouseArea {
|
MouseArea {
|
||||||
|
id: toggleMouse
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
cursorShape: Qt.PointingHandCursor
|
cursorShape: Qt.PointingHandCursor
|
||||||
onClicked: toggleRoot.toggled()
|
onClicked: toggleRoot.toggled()
|
||||||
}
|
}
|
||||||
@@ -572,18 +757,32 @@ Item {
|
|||||||
id: netRow
|
id: netRow
|
||||||
required property var net
|
required property var net
|
||||||
signal activate()
|
signal activate()
|
||||||
|
// Still tracked for the highlight below — the actual input lives
|
||||||
|
// outside this (repeatedly rebuilt) delegate, in the persistent
|
||||||
|
// password entry widget under the network list.
|
||||||
readonly property bool passwordOpen: root.passwordSsid === netRow.net.ssid
|
readonly property bool passwordOpen: root.passwordSsid === netRow.net.ssid
|
||||||
width: parent ? parent.width : 0
|
width: parent ? parent.width : 0
|
||||||
radius: 0
|
radius: 0
|
||||||
implicitHeight: rowContent.implicitHeight + 12 + (passwordOpen ? passwordRow.implicitHeight + 6 : 0)
|
implicitHeight: rowContent.implicitHeight + 12
|
||||||
color: netRow.net.connected
|
// Hover checked first (same ordering as Volume.qml's DeviceRow):
|
||||||
? Qt.rgba(Colors.primary.r, Colors.primary.g, Colors.primary.b, 0.18)
|
// hovering always shows a solid Colors.primary fill, even for an
|
||||||
: (rowMouse.containsMouse ? Colors.primary : "transparent")
|
// already-active row, so the primaryText used below never has to
|
||||||
|
// sit on top of the dimmer translucent tint instead.
|
||||||
|
color: rowMouse.containsMouse ? Colors.primary
|
||||||
|
: ((netRow.net.connected || netRow.passwordOpen) ? Qt.rgba(Colors.primary.r, Colors.primary.g, Colors.primary.b, 0.18) : "transparent")
|
||||||
|
|
||||||
function submit() {
|
// Hover-only outline, offset outside the row rather than drawn on
|
||||||
const n = root.networkForSsid(netRow.net.ssid)
|
// its own edge — same treatment as ToggleSwitch's halo and the DNS
|
||||||
if (n && passwordInput.text.length > 0) n.connectWithPsk(passwordInput.text)
|
// pills. networkListColumn insets its rows by 3px on each side
|
||||||
root.passwordSsid = ""
|
// specifically to give this room without getting cropped by the
|
||||||
|
// Flickable's clip.
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: -3
|
||||||
|
radius: 0
|
||||||
|
color: "transparent"
|
||||||
|
border.color: Colors.foreground
|
||||||
|
border.width: rowMouse.containsMouse ? 1 : 0
|
||||||
}
|
}
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
@@ -600,11 +799,12 @@ Item {
|
|||||||
Layout.preferredWidth: 20
|
Layout.preferredWidth: 20
|
||||||
Layout.minimumWidth: 20
|
Layout.minimumWidth: 20
|
||||||
Layout.maximumWidth: 20
|
Layout.maximumWidth: 20
|
||||||
|
Layout.alignment: Qt.AlignVCenter
|
||||||
horizontalAlignment: Text.AlignLeft
|
horizontalAlignment: Text.AlignLeft
|
||||||
text: root.wifiIcon(netRow.net.signal)
|
text: root.wifiIcon(netRow.net.signal)
|
||||||
font.family: Appearance.iconFontFamily
|
font.family: Appearance.iconFontFamilyRounded
|
||||||
font.variableAxes: Appearance.iconFontAxes
|
font.variableAxes: Appearance.iconFontAxes
|
||||||
font.pixelSize: 14
|
font.pixelSize: 16
|
||||||
color: rowMouse.containsMouse ? Colors.primaryText : Colors.foreground
|
color: rowMouse.containsMouse ? Colors.primaryText : Colors.foreground
|
||||||
// The wifi-fan glyph itself is flat/wide — stretch it
|
// The wifi-fan glyph itself is flat/wide — stretch it
|
||||||
// vertically to read taller while keeping its width (and
|
// vertically to read taller while keeping its width (and
|
||||||
@@ -639,7 +839,7 @@ Item {
|
|||||||
Text {
|
Text {
|
||||||
visible: root.requiresCredentials(netRow.net.security)
|
visible: root.requiresCredentials(netRow.net.security)
|
||||||
text: "lock"
|
text: "lock"
|
||||||
font.family: Appearance.iconFontFamily
|
font.family: Appearance.iconFontFamilyRounded
|
||||||
font.variableAxes: Appearance.iconFontAxes
|
font.variableAxes: Appearance.iconFontAxes
|
||||||
font.pixelSize: 13
|
font.pixelSize: 13
|
||||||
color: rowMouse.containsMouse ? Colors.primaryText : Colors.muted
|
color: rowMouse.containsMouse ? Colors.primaryText : Colors.muted
|
||||||
@@ -648,59 +848,12 @@ Item {
|
|||||||
id: forgetIcon
|
id: forgetIcon
|
||||||
visible: netRow.net.known
|
visible: netRow.net.known
|
||||||
text: "close"
|
text: "close"
|
||||||
font.family: Appearance.iconFontFamily
|
font.family: Appearance.iconFontFamilyRounded
|
||||||
font.variableAxes: Appearance.iconFontAxes
|
font.variableAxes: Appearance.iconFontAxes
|
||||||
font.pixelSize: 14
|
font.pixelSize: 14
|
||||||
color: rowMouse.containsMouse ? Colors.primaryText : Colors.muted
|
color: rowMouse.containsMouse ? Colors.primaryText : Colors.muted
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
RowLayout {
|
|
||||||
id: passwordRow
|
|
||||||
visible: netRow.passwordOpen
|
|
||||||
width: parent.width
|
|
||||||
spacing: 6
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
implicitHeight: 24
|
|
||||||
radius: 0
|
|
||||||
color: Colors.background
|
|
||||||
border.color: Colors.muted
|
|
||||||
border.width: 1
|
|
||||||
|
|
||||||
TextInput {
|
|
||||||
id: passwordInput
|
|
||||||
anchors.fill: parent
|
|
||||||
anchors.margins: 6
|
|
||||||
echoMode: TextInput.Password
|
|
||||||
color: Colors.foreground
|
|
||||||
font.family: Appearance.fontFamily
|
|
||||||
font.pixelSize: Appearance.fontSizeSmall
|
|
||||||
clip: true
|
|
||||||
focus: netRow.passwordOpen
|
|
||||||
onAccepted: netRow.submit()
|
|
||||||
// Explicit fallback alongside onAccepted, which only
|
|
||||||
// fires when acceptableInput is true — covers both
|
|
||||||
// the main Return key and numpad Enter directly.
|
|
||||||
Keys.onReturnPressed: netRow.submit()
|
|
||||||
Keys.onEnterPressed: netRow.submit()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Text {
|
|
||||||
text: "check"
|
|
||||||
font.family: Appearance.iconFontFamily
|
|
||||||
font.variableAxes: Appearance.iconFontAxes
|
|
||||||
font.pixelSize: 16
|
|
||||||
color: Colors.primary
|
|
||||||
MouseArea {
|
|
||||||
anchors.fill: parent
|
|
||||||
cursorShape: Qt.PointingHandCursor
|
|
||||||
onClicked: netRow.submit()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
MouseArea {
|
MouseArea {
|
||||||
@@ -786,16 +939,25 @@ Item {
|
|||||||
spacing: 8
|
spacing: 8
|
||||||
|
|
||||||
Text {
|
Text {
|
||||||
|
id: headerIcon
|
||||||
text: label.text
|
text: label.text
|
||||||
font.family: Appearance.iconFontFamily
|
font.family: Appearance.iconFontFamilyRounded
|
||||||
font.variableAxes: Appearance.iconFontAxes
|
font.variableAxes: Appearance.iconFontAxes
|
||||||
font.pixelSize: 30
|
font.pixelSize: 30
|
||||||
color: Colors.foreground
|
color: Colors.foreground
|
||||||
|
// Mirrors the bar icon's own correction — see label's
|
||||||
|
// isWifiFan/transform above — since this just
|
||||||
|
// displays the same text at a larger size.
|
||||||
|
transform: Scale {
|
||||||
|
origin.x: headerIcon.width / 2
|
||||||
|
origin.y: headerIcon.height / 2
|
||||||
|
yScale: label.isWifiFan ? 1.2 : 1.0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Text {
|
Text {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
text: root.connectedNetworkName
|
text: "Network"
|
||||||
elide: Text.ElideRight
|
elide: Text.ElideRight
|
||||||
font.family: Appearance.fontFamily
|
font.family: Appearance.fontFamily
|
||||||
font.pixelSize: 18
|
font.pixelSize: 18
|
||||||
@@ -804,6 +966,116 @@ Item {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Row rather than RowLayout — RowLayout is built for
|
||||||
|
// constraint-based stretching (Layout.fillWidth etc.), and
|
||||||
|
// without either Text opting into that, its sizing pass can
|
||||||
|
// still leave more room between them than spacing:0 implies.
|
||||||
|
// Row has no such stretch logic: spacing is exactly the gap
|
||||||
|
// you get, so these two sit flush (plus one small gap) no
|
||||||
|
// matter what width is bound above them.
|
||||||
|
Row {
|
||||||
|
width: parent.width
|
||||||
|
spacing: 4
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: "Connected Interface:"
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSizeSmall
|
||||||
|
color: Colors.muted
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: root.connectedNetworkName
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSizeSmall
|
||||||
|
color: Colors.foreground
|
||||||
|
font.bold: true
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// Captive portal banner — shown when the connectivity probe
|
||||||
|
// above finds either a confirmed sign-in page or a
|
||||||
|
// "limited" result standing between this network and the
|
||||||
|
// real internet (see connectivityRestricted). openCaptivePortal()
|
||||||
|
// already fired once automatically when this was first
|
||||||
|
// detected; this stays up as a manual retry in case that tab
|
||||||
|
// got closed.
|
||||||
|
Rectangle {
|
||||||
|
width: parent.width
|
||||||
|
visible: root.connectivityRestricted
|
||||||
|
radius: 0
|
||||||
|
color: Qt.rgba(Colors.urgent.r, Colors.urgent.g, Colors.urgent.b, 0.18)
|
||||||
|
border.color: Colors.urgent
|
||||||
|
border.width: 1
|
||||||
|
implicitHeight: portalBannerRow.implicitHeight + 12
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
id: portalBannerRow
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 6
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: "lock_open"
|
||||||
|
font.family: Appearance.iconFontFamilyRounded
|
||||||
|
font.variableAxes: Appearance.iconFontAxes
|
||||||
|
font.pixelSize: 16
|
||||||
|
color: Colors.urgent
|
||||||
|
}
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 0
|
||||||
|
|
||||||
|
Text {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: root.connectivityState === "portal" ? "Sign-in required" : "No internet access"
|
||||||
|
elide: Text.ElideRight
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSize
|
||||||
|
font.bold: true
|
||||||
|
color: Colors.foreground
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: root.connectivityState === "portal"
|
||||||
|
? "This network needs a web sign-in first"
|
||||||
|
: "This network may need a web sign-in first"
|
||||||
|
elide: Text.ElideRight
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSizeSmall
|
||||||
|
color: Colors.muted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
implicitWidth: openPortalText.implicitWidth + 16
|
||||||
|
implicitHeight: 24
|
||||||
|
radius: 0
|
||||||
|
color: openPortalMouse.containsMouse ? Colors.urgent : "transparent"
|
||||||
|
border.color: Colors.urgent
|
||||||
|
border.width: 1
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: openPortalText
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: "Open"
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSizeSmall
|
||||||
|
color: openPortalMouse.containsMouse ? Colors.primaryText : Colors.urgent
|
||||||
|
}
|
||||||
|
MouseArea {
|
||||||
|
id: openPortalMouse
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onClicked: root.openCaptivePortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Live stats — ping/loss/throughput/IP/gateway for whichever
|
// Live stats — ping/loss/throughput/IP/gateway for whichever
|
||||||
// device is currently connected.
|
// device is currently connected.
|
||||||
GridLayout {
|
GridLayout {
|
||||||
@@ -863,6 +1135,18 @@ Item {
|
|||||||
border.width: 1
|
border.width: 1
|
||||||
opacity: root.dnsBusy ? 0.6 : 1
|
opacity: root.dnsBusy ? 0.6 : 1
|
||||||
|
|
||||||
|
// Hover-only outline, offset outside the pill
|
||||||
|
// itself rather than drawn on its own border —
|
||||||
|
// same treatment as ToggleSwitch's halo.
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: -3
|
||||||
|
radius: 0
|
||||||
|
color: "transparent"
|
||||||
|
border.color: Colors.foreground
|
||||||
|
border.width: dnsMouse.containsMouse ? 1 : 0
|
||||||
|
}
|
||||||
|
|
||||||
Text {
|
Text {
|
||||||
anchors.centerIn: parent
|
anchors.centerIn: parent
|
||||||
text: dnsPill.modelData
|
text: dnsPill.modelData
|
||||||
@@ -936,7 +1220,7 @@ Item {
|
|||||||
|
|
||||||
Text {
|
Text {
|
||||||
text: "check"
|
text: "check"
|
||||||
font.family: Appearance.iconFontFamily
|
font.family: Appearance.iconFontFamilyRounded
|
||||||
font.variableAxes: Appearance.iconFontAxes
|
font.variableAxes: Appearance.iconFontAxes
|
||||||
font.pixelSize: 16
|
font.pixelSize: 16
|
||||||
color: Colors.primary
|
color: Colors.primary
|
||||||
@@ -974,7 +1258,7 @@ Item {
|
|||||||
spacing: 8
|
spacing: 8
|
||||||
Text {
|
Text {
|
||||||
text: "settings_ethernet"
|
text: "settings_ethernet"
|
||||||
font.family: Appearance.iconFontFamily
|
font.family: Appearance.iconFontFamilyRounded
|
||||||
font.variableAxes: Appearance.iconFontAxes
|
font.variableAxes: Appearance.iconFontAxes
|
||||||
font.pixelSize: 14
|
font.pixelSize: 14
|
||||||
color: Colors.foreground
|
color: Colors.foreground
|
||||||
@@ -1025,6 +1309,32 @@ Item {
|
|||||||
font.pixelSize: Appearance.fontSize
|
font.pixelSize: Appearance.fontSize
|
||||||
color: Colors.foreground
|
color: Colors.foreground
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Spins briefly each time a scan actually lands —
|
||||||
|
// same "still searching" cue as
|
||||||
|
// BluetoothIndicator.qml's spinner, but pulsed via
|
||||||
|
// wifiJustScanned (see above) rather than staying on
|
||||||
|
// for the whole time the panel is open, since
|
||||||
|
// WifiDevice has no per-scan signal of its own to
|
||||||
|
// key off.
|
||||||
|
Text {
|
||||||
|
id: wifiScanSpinner
|
||||||
|
visible: root.wifiDevice !== null && root.wifiDevice.scannerEnabled && root.wifiJustScanned
|
||||||
|
text: "progress_activity"
|
||||||
|
font.family: Appearance.iconFontFamilyRounded
|
||||||
|
font.variableAxes: Appearance.iconFontAxes
|
||||||
|
font.pixelSize: 16
|
||||||
|
color: Colors.muted
|
||||||
|
|
||||||
|
RotationAnimation on rotation {
|
||||||
|
running: wifiScanSpinner.visible
|
||||||
|
loops: Animation.Infinite
|
||||||
|
from: 0
|
||||||
|
to: 360
|
||||||
|
duration: 900
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ToggleSwitch {
|
ToggleSwitch {
|
||||||
checked: Networking.wifiEnabled
|
checked: Networking.wifiEnabled
|
||||||
onToggled: Networking.wifiEnabled = !Networking.wifiEnabled
|
onToggled: Networking.wifiEnabled = !Networking.wifiEnabled
|
||||||
@@ -1049,6 +1359,97 @@ Item {
|
|||||||
color: Colors.muted
|
color: Colors.muted
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Password entry — a single persistent widget rather
|
||||||
|
// than living inside the NetworkRow delegate for
|
||||||
|
// root.passwordSsid, so a wifi scan landing mid-keystroke
|
||||||
|
// (which rebuilds the whole Known/Other Networks list
|
||||||
|
// below) can't destroy the focused TextInput and wipe
|
||||||
|
// out what's been typed. See passwordSsid's own comment.
|
||||||
|
Rectangle {
|
||||||
|
width: parent.width
|
||||||
|
visible: root.passwordSsid !== ""
|
||||||
|
radius: 0
|
||||||
|
color: Colors.background
|
||||||
|
border.color: Colors.muted
|
||||||
|
border.width: 1
|
||||||
|
implicitHeight: passwordEntryColumn.implicitHeight + 12
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: passwordEntryColumn
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 6
|
||||||
|
spacing: 6
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
text: "Password for “" + root.passwordSsid + "”"
|
||||||
|
elide: Text.ElideRight
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSizeSmall
|
||||||
|
color: Colors.muted
|
||||||
|
}
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
width: parent.width
|
||||||
|
spacing: 6
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
implicitHeight: 24
|
||||||
|
radius: 0
|
||||||
|
color: Colors.surface
|
||||||
|
border.color: Colors.muted
|
||||||
|
border.width: 1
|
||||||
|
|
||||||
|
TextInput {
|
||||||
|
id: passwordEntryInput
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 6
|
||||||
|
echoMode: TextInput.Password
|
||||||
|
color: Colors.foreground
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSizeSmall
|
||||||
|
clip: true
|
||||||
|
focus: root.passwordSsid !== ""
|
||||||
|
onAccepted: root.submitPassword()
|
||||||
|
// Explicit fallback alongside onAccepted, which only
|
||||||
|
// fires when acceptableInput is true — covers both
|
||||||
|
// the main Return key and numpad Enter directly.
|
||||||
|
Keys.onReturnPressed: root.submitPassword()
|
||||||
|
Keys.onEnterPressed: root.submitPassword()
|
||||||
|
Keys.onEscapePressed: root.cancelPassword()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: "check"
|
||||||
|
font.family: Appearance.iconFontFamilyRounded
|
||||||
|
font.variableAxes: Appearance.iconFontAxes
|
||||||
|
font.pixelSize: 16
|
||||||
|
color: Colors.primary
|
||||||
|
MouseArea {
|
||||||
|
anchors.fill: parent
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onClicked: root.submitPassword()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: "close"
|
||||||
|
font.family: Appearance.iconFontFamilyRounded
|
||||||
|
font.variableAxes: Appearance.iconFontAxes
|
||||||
|
font.pixelSize: 16
|
||||||
|
color: Colors.muted
|
||||||
|
MouseArea {
|
||||||
|
anchors.fill: parent
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onClicked: root.cancelPassword()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Known/Other Networks — capped height, scrolls once
|
// Known/Other Networks — capped height, scrolls once
|
||||||
// there are more networks than fit rather than growing
|
// there are more networks than fit rather than growing
|
||||||
// the popup indefinitely.
|
// the popup indefinitely.
|
||||||
@@ -1064,7 +1465,19 @@ Item {
|
|||||||
|
|
||||||
Column {
|
Column {
|
||||||
id: networkListColumn
|
id: networkListColumn
|
||||||
width: networkListFlick.width
|
// Inset 3px on every side (rather than flush with
|
||||||
|
// the Flickable) so NetworkRow's hover halo below
|
||||||
|
// has room to sit outside the row without being
|
||||||
|
// cropped by the Flickable's own clip. Left/right
|
||||||
|
// needs the manual x/width shift (children bind
|
||||||
|
// width: parent.width, which ignores Column's own
|
||||||
|
// padding); top/bottom just needs padding, since
|
||||||
|
// that's along Column's own layout axis and the
|
||||||
|
// Flickable's contentHeight tracks it.
|
||||||
|
x: 3
|
||||||
|
width: networkListFlick.width - 6
|
||||||
|
topPadding: 3
|
||||||
|
bottomPadding: 3
|
||||||
spacing: 6
|
spacing: 6
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
|
|||||||
110
dotfiles/.config/quickshell/modules/bar/PinnedTray.qml
Normal file
110
dotfiles/.config/quickshell/modules/bar/PinnedTray.qml
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import Quickshell.Services.SystemTray
|
||||||
|
import Quickshell.Widgets
|
||||||
|
import "../../config"
|
||||||
|
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
id: root
|
||||||
|
spacing: Appearance.spacing * 1.
|
||||||
|
|
||||||
|
|
||||||
|
readonly property var pinned: [
|
||||||
|
{
|
||||||
|
name: "Bitwarden",
|
||||||
|
icon: Qt.resolvedUrl("../../assets/bitwarden.png"),
|
||||||
|
binary: "bitwarden",
|
||||||
|
launch: ["bitwarden"],
|
||||||
|
secondary: ["pkill", "bitwarden"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Seafile",
|
||||||
|
icon: "seafile",
|
||||||
|
binary: "seafile-applet",
|
||||||
|
launch: ["seafile-applet"],
|
||||||
|
secondary: ["pkill", "seafile-applet"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Sunshine",
|
||||||
|
icon: Qt.resolvedUrl("../../assets/sunshine.svg"),
|
||||||
|
binary: "sunshine",
|
||||||
|
launch: ["systemctl", "--user", "start", "app-dev.lizardbyte.app.Sunshine.service"],
|
||||||
|
secondary: ["xdg-open", "https://xol.twhomelab.local:47990"],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
function findTrayItem(name) {
|
||||||
|
const needle = name.toLowerCase()
|
||||||
|
for (const item of SystemTray.items.values) {
|
||||||
|
if (item.id.toLowerCase().includes(needle) || item.title.toLowerCase().includes(needle))
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: root.pinned
|
||||||
|
|
||||||
|
delegate: Item {
|
||||||
|
id: slot
|
||||||
|
required property var modelData
|
||||||
|
readonly property var trayItem: root.findTrayItem(modelData.name)
|
||||||
|
readonly property bool running: trayItem !== null
|
||||||
|
property bool installed: false // Application installation check
|
||||||
|
|
||||||
|
visible: slot.installed
|
||||||
|
implicitWidth: Appearance.iconSize
|
||||||
|
implicitHeight: Appearance.iconSize
|
||||||
|
|
||||||
|
Process {
|
||||||
|
running: true
|
||||||
|
command: ["which", slot.modelData.binary]
|
||||||
|
onExited: (exitCode) => slot.installed = exitCode === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
IconImage {
|
||||||
|
anchors.fill: parent
|
||||||
|
// modelData.icon is a QUrl when it comes from
|
||||||
|
// Qt.resolvedUrl() (Bitwarden/Sunshine's local assets), not
|
||||||
|
// a QString — coerce to string before using String methods
|
||||||
|
// on it, or this throws instead of ever matching.
|
||||||
|
source: slot.running
|
||||||
|
? slot.trayItem.icon
|
||||||
|
: (String(slot.modelData.icon).includes("://")
|
||||||
|
? slot.modelData.icon
|
||||||
|
: Quickshell.iconPath(slot.modelData.icon))
|
||||||
|
opacity: slot.running ? 1.0 : 0.5
|
||||||
|
}
|
||||||
|
|
||||||
|
Process { id: launcher }
|
||||||
|
Process { id: secondaryProc }
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: -4
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
||||||
|
onClicked: (mouse) => {
|
||||||
|
if (mouse.button === Qt.RightButton) {
|
||||||
|
if (slot.modelData.secondary) {
|
||||||
|
if (!secondaryProc.running) {
|
||||||
|
secondaryProc.command = slot.modelData.secondary
|
||||||
|
secondaryProc.running = true
|
||||||
|
}
|
||||||
|
} else if (slot.running) {
|
||||||
|
slot.trayItem.secondaryActivate()
|
||||||
|
}
|
||||||
|
} else if (slot.running) {
|
||||||
|
slot.trayItem.activate()
|
||||||
|
} else if (!launcher.running) {
|
||||||
|
launcher.command = slot.modelData.launch
|
||||||
|
launcher.running = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ import "../../config"
|
|||||||
|
|
||||||
Item {
|
Item {
|
||||||
id: root
|
id: root
|
||||||
implicitWidth: label.implicitWidth + 4
|
implicitWidth: label.implicitWidth + 2
|
||||||
implicitHeight: Appearance.barHeight
|
implicitHeight: Appearance.barHeight
|
||||||
|
|
||||||
// The bar's own PanelWindow — passed in from Bar.qml so this popup can
|
// The bar's own PanelWindow — passed in from Bar.qml so this popup can
|
||||||
@@ -19,11 +19,11 @@ Item {
|
|||||||
Text {
|
Text {
|
||||||
id: label
|
id: label
|
||||||
anchors.centerIn: parent
|
anchors.centerIn: parent
|
||||||
font.family: Appearance.iconFontFamily
|
font.family: Appearance.iconFontFamilyRounded
|
||||||
font.variableAxes: Appearance.iconFontAxes
|
font.variableAxes: Appearance.iconFontAxes
|
||||||
font.pixelSize: 16
|
font.pixelSize: 16
|
||||||
color: Colors.foreground
|
color: Colors.foreground
|
||||||
text: "power_settings_new"
|
text: "settings_power"
|
||||||
}
|
}
|
||||||
|
|
||||||
MouseArea {
|
MouseArea {
|
||||||
@@ -41,10 +41,12 @@ Item {
|
|||||||
id: menu
|
id: menu
|
||||||
visible: false
|
visible: false
|
||||||
color: "transparent"
|
color: "transparent"
|
||||||
implicitWidth: 210
|
// Same contentWidth/contentPadding split as the other panels
|
||||||
// Same padding as Volume.qml's panel.
|
// (Volume.qml, NetworkIndicator.qml, etc.) rather than a single
|
||||||
|
// magic implicitWidth.
|
||||||
|
readonly property int contentWidth: 178
|
||||||
readonly property int contentPadding: 16
|
readonly property int contentPadding: 16
|
||||||
readonly property int labelOffset: -135
|
implicitWidth: contentWidth + contentPadding * 2
|
||||||
implicitHeight: mainColumn.implicitHeight + contentPadding * 2
|
implicitHeight: mainColumn.implicitHeight + contentPadding * 2
|
||||||
|
|
||||||
anchor {
|
anchor {
|
||||||
@@ -91,9 +93,9 @@ Item {
|
|||||||
|
|
||||||
Text {
|
Text {
|
||||||
text: label.text
|
text: label.text
|
||||||
font.family: Appearance.iconFontFamily
|
font.family: Appearance.iconFontFamilyRounded
|
||||||
font.variableAxes: Appearance.iconFontAxes
|
font.variableAxes: Appearance.iconFontAxes
|
||||||
font.pixelSize: 30
|
font.pixelSize: 23
|
||||||
color: Colors.foreground
|
color: Colors.foreground
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,36 +124,55 @@ Item {
|
|||||||
{ label: "Shut down", icon: "power_settings_new", cmd: ["systemctl", "poweroff"] }
|
{ 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 {
|
delegate: Rectangle {
|
||||||
id: entry
|
id: entry
|
||||||
required property var modelData
|
required property var modelData
|
||||||
width: parent ? parent.width : 0
|
width: parent ? parent.width : 0
|
||||||
height: 40
|
radius: 0
|
||||||
radius: Appearance.radiusSmall
|
implicitHeight: entryContent.implicitHeight + 12
|
||||||
color: (!entry.modelData.disabled && entryMouse.containsMouse) ? Colors.primary : "transparent"
|
color: (!entry.modelData.disabled && entryMouse.containsMouse) ? Colors.primary : "transparent"
|
||||||
|
|
||||||
RowLayout {
|
Rectangle {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
anchors.leftMargin: 8
|
anchors.margins: -3
|
||||||
|
radius: 0
|
||||||
|
color: "transparent"
|
||||||
|
border.color: Colors.foreground
|
||||||
|
border.width: entryMouse.containsMouse ? 1 : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
id: entryContent
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
anchors.margins: 6
|
||||||
spacing: 8
|
spacing: 8
|
||||||
|
|
||||||
Text {
|
Text {
|
||||||
Layout.preferredWidth: 25
|
Layout.preferredWidth: 20
|
||||||
Layout.minimumWidth: 25
|
Layout.minimumWidth: 20
|
||||||
Layout.maximumWidth: 25
|
Layout.maximumWidth: 20
|
||||||
horizontalAlignment: Text.AlignLeft
|
horizontalAlignment: Text.AlignLeft
|
||||||
text: entry.modelData.icon
|
text: entry.modelData.icon
|
||||||
font.family: Appearance.iconFontFamily
|
font.family: Appearance.iconFontFamilyRounded
|
||||||
font.variableAxes: Appearance.iconFontAxes
|
font.variableAxes: Appearance.iconFontAxes
|
||||||
font.pixelSize: entry.modelData.iconSize || 15
|
font.pixelSize: 14
|
||||||
opacity: entry.modelData.disabled ? 0.4 : 1
|
opacity: entry.modelData.disabled ? 0.4 : 1
|
||||||
color: entryMouse.containsMouse ? Colors.primaryText : Colors.foreground
|
color: entryMouse.containsMouse ? Colors.primaryText : Colors.foreground
|
||||||
}
|
}
|
||||||
Text {
|
Text {
|
||||||
Layout.leftMargin: menu.labelOffset + (entry.modelData.textOffset || 0)
|
Layout.fillWidth: true
|
||||||
text: entry.modelData.label
|
text: entry.modelData.label
|
||||||
horizontalAlignment: Text.AlignLeft
|
elide: Text.ElideRight
|
||||||
font.family: Appearance.fontFamily
|
font.family: Appearance.fontFamily
|
||||||
font.pixelSize: 16
|
font.pixelSize: Appearance.fontSize
|
||||||
opacity: entry.modelData.disabled ? 0.4 : 1
|
opacity: entry.modelData.disabled ? 0.4 : 1
|
||||||
color: entryMouse.containsMouse ? Colors.primaryText : Colors.foreground
|
color: entryMouse.containsMouse ? Colors.primaryText : Colors.foreground
|
||||||
}
|
}
|
||||||
|
|||||||
194
dotfiles/.config/quickshell/modules/bar/SystemUpdate.qml
Normal file
194
dotfiles/.config/quickshell/modules/bar/SystemUpdate.qml
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Hyprland
|
||||||
|
import "../../config"
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
implicitWidth: row.implicitWidth + 6
|
||||||
|
implicitHeight: Appearance.barHeight
|
||||||
|
|
||||||
|
|
||||||
|
required property var barWindow
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
id: row
|
||||||
|
anchors.centerIn: parent
|
||||||
|
spacing: 4
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: label
|
||||||
|
font.family: SystemUpdates.checking ? Appearance.iconFontFamilyRounded : Appearance.fontFamily
|
||||||
|
font.variableAxes: Appearance.iconFontAxes
|
||||||
|
font.pixelSize: SystemUpdates.checking ? 15 : 22
|
||||||
|
color: Colors.foreground
|
||||||
|
text: SystemUpdates.checking ? "progress_activity" : ""
|
||||||
|
|
||||||
|
RotationAnimation on rotation {
|
||||||
|
running: SystemUpdates.checking
|
||||||
|
loops: Animation.Infinite
|
||||||
|
from: 0
|
||||||
|
to: 360
|
||||||
|
duration: 900
|
||||||
|
onRunningChanged: if (!running) label.rotation = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSizeSmall
|
||||||
|
color: label.color
|
||||||
|
text: SystemUpdates.updateCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
anchors.fill: parent
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
||||||
|
onClicked: (mouse) => {
|
||||||
|
if (mouse.button === Qt.RightButton) {
|
||||||
|
SystemUpdates.refresh()
|
||||||
|
} else {
|
||||||
|
panel.visible = !panel.visible
|
||||||
|
grab.active = panel.visible
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PopupWindow {
|
||||||
|
id: panel
|
||||||
|
visible: false
|
||||||
|
color: "transparent"
|
||||||
|
readonly property int contentWidth: 200
|
||||||
|
readonly property int contentPadding: 16
|
||||||
|
implicitWidth: contentWidth + contentPadding * 2
|
||||||
|
implicitHeight: mainColumn.implicitHeight + contentPadding * 2
|
||||||
|
|
||||||
|
anchor {
|
||||||
|
window: root.barWindow
|
||||||
|
rect.x: 0
|
||||||
|
rect.y: 0
|
||||||
|
rect.width: root.barWindow.width
|
||||||
|
rect.height: root.barWindow.height
|
||||||
|
edges: Edges.Bottom
|
||||||
|
gravity: Edges.Bottom
|
||||||
|
margins.bottom: -4
|
||||||
|
}
|
||||||
|
|
||||||
|
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: label.text
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: 23
|
||||||
|
color: Colors.foreground
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: "System Update"
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: 18
|
||||||
|
font.bold: true
|
||||||
|
color: Colors.foreground
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSize
|
||||||
|
color: Colors.foreground
|
||||||
|
text: {
|
||||||
|
if (SystemUpdates.updating) return "Update running in a terminal…"
|
||||||
|
if (SystemUpdates.checking) return "Checking for updates…"
|
||||||
|
if (SystemUpdates.updateCount === 0) return "System is up to date."
|
||||||
|
return SystemUpdates.updateCount + (SystemUpdates.updateCount === 1 ? " update available." : " updates available.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: updateBtn
|
||||||
|
width: parent.width
|
||||||
|
radius: 0
|
||||||
|
implicitHeight: btnContent.implicitHeight + 12
|
||||||
|
enabled: !SystemUpdates.updating
|
||||||
|
opacity: SystemUpdates.updating ? 0.5 : 1
|
||||||
|
color: btnMouse.containsMouse ? Colors.primary : "transparent"
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: -3
|
||||||
|
radius: 0
|
||||||
|
color: "transparent"
|
||||||
|
border.color: Colors.foreground
|
||||||
|
border.width: btnMouse.containsMouse ? 1 : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
id: btnContent
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
anchors.margins: 6
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
Text {
|
||||||
|
Layout.preferredWidth: 20
|
||||||
|
horizontalAlignment: Text.AlignLeft
|
||||||
|
text: "download"
|
||||||
|
font.family: Appearance.iconFontFamilyRounded
|
||||||
|
font.variableAxes: Appearance.iconFontAxes
|
||||||
|
font.pixelSize: 14
|
||||||
|
color: btnMouse.containsMouse ? Colors.primaryText : Colors.foreground
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: "Update Now"
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSize
|
||||||
|
color: btnMouse.containsMouse ? Colors.primaryText : Colors.foreground
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: btnMouse
|
||||||
|
anchors.fill: parent
|
||||||
|
enabled: !SystemUpdates.updating
|
||||||
|
hoverEnabled: true
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onClicked: {
|
||||||
|
SystemUpdates.runUpdate(Quickshell.shellPath("scripts/update-system.sh"))
|
||||||
|
panel.visible = false
|
||||||
|
grab.active = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ import "../../config"
|
|||||||
|
|
||||||
Item {
|
Item {
|
||||||
id: root
|
id: root
|
||||||
implicitWidth: label.implicitWidth + 4
|
implicitWidth: label.implicitWidth
|
||||||
implicitHeight: Appearance.barHeight
|
implicitHeight: Appearance.barHeight
|
||||||
|
|
||||||
// The bar's own PanelWindow — passed in from Bar.qml so this popup can
|
// The bar's own PanelWindow — passed in from Bar.qml so this popup can
|
||||||
@@ -432,8 +432,8 @@ Item {
|
|||||||
model: [0, 0, 0, 1, 1, 1, 0, 1, 0]
|
model: [0, 0, 0, 1, 1, 1, 0, 1, 0]
|
||||||
Rectangle {
|
Rectangle {
|
||||||
required property int modelData
|
required property int modelData
|
||||||
width: logo.dotSize
|
width: logo.dotSize - 1
|
||||||
height: logo.dotSize
|
height: logo.dotSize - 1
|
||||||
radius: width / 2
|
radius: width / 2
|
||||||
color: modelData ? '#c9f0f0f0' : logo.dimColor
|
color: modelData ? '#c9f0f0f0' : logo.dimColor
|
||||||
}
|
}
|
||||||
@@ -505,6 +505,18 @@ Item {
|
|||||||
color: toggleRoot.checked ? Colors.primary : Colors.muted
|
color: toggleRoot.checked ? Colors.primary : Colors.muted
|
||||||
opacity: toggleRoot.checked ? 1 : 0.35
|
opacity: toggleRoot.checked ? 1 : 0.35
|
||||||
}
|
}
|
||||||
|
// Hover-only outline, offset outside the switch itself rather than
|
||||||
|
// drawn on its edge — the switch lags behind a click until the
|
||||||
|
// backend confirms it (e.g. root.active), so this is just a "you
|
||||||
|
// can click this" cue, not click feedback.
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: -4
|
||||||
|
radius: 0
|
||||||
|
color: "transparent"
|
||||||
|
border.color: Colors.foreground
|
||||||
|
border.width: toggleMouse.containsMouse ? 1 : 0
|
||||||
|
}
|
||||||
Rectangle {
|
Rectangle {
|
||||||
width: parent.height - 4
|
width: parent.height - 4
|
||||||
height: parent.height - 4
|
height: parent.height - 4
|
||||||
@@ -515,7 +527,9 @@ Item {
|
|||||||
Behavior on x { NumberAnimation { duration: Appearance.animationDuration } }
|
Behavior on x { NumberAnimation { duration: Appearance.animationDuration } }
|
||||||
}
|
}
|
||||||
MouseArea {
|
MouseArea {
|
||||||
|
id: toggleMouse
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
cursorShape: Qt.PointingHandCursor
|
cursorShape: Qt.PointingHandCursor
|
||||||
onClicked: toggleRoot.toggled()
|
onClicked: toggleRoot.toggled()
|
||||||
}
|
}
|
||||||
@@ -533,9 +547,25 @@ Item {
|
|||||||
width: parent ? parent.width : 0
|
width: parent ? parent.width : 0
|
||||||
radius: 0
|
radius: 0
|
||||||
implicitHeight: rowContent.implicitHeight + 12
|
implicitHeight: rowContent.implicitHeight + 12
|
||||||
color: exitRow.active
|
// Hover checked first (same ordering as Volume.qml's DeviceRow):
|
||||||
? Qt.rgba(Colors.primary.r, Colors.primary.g, Colors.primary.b, 0.18)
|
// hovering always shows a solid Colors.primary fill, even for an
|
||||||
: (rowMouse.containsMouse ? Colors.primary : "transparent")
|
// already-active row, so the primaryText used below never has to
|
||||||
|
// sit on top of the dimmer translucent tint instead.
|
||||||
|
color: rowMouse.containsMouse ? Colors.primary
|
||||||
|
: (exitRow.active ? Qt.rgba(Colors.primary.r, Colors.primary.g, Colors.primary.b, 0.18) : "transparent")
|
||||||
|
|
||||||
|
// Hover-only outline, offset outside the row rather than drawn on
|
||||||
|
// its own edge — same treatment as NetworkIndicator.qml's NetworkRow.
|
||||||
|
// listColumn below is inset by 3px to give this room without being
|
||||||
|
// cropped by listFlick's clip.
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: -3
|
||||||
|
radius: 0
|
||||||
|
color: "transparent"
|
||||||
|
border.color: Colors.foreground
|
||||||
|
border.width: rowMouse.containsMouse ? 1 : 0
|
||||||
|
}
|
||||||
|
|
||||||
RowLayout {
|
RowLayout {
|
||||||
id: rowContent
|
id: rowContent
|
||||||
@@ -551,7 +581,7 @@ Item {
|
|||||||
Layout.maximumWidth: 20
|
Layout.maximumWidth: 20
|
||||||
horizontalAlignment: Text.AlignLeft
|
horizontalAlignment: Text.AlignLeft
|
||||||
text: exitRow.subIcon
|
text: exitRow.subIcon
|
||||||
font.family: Appearance.iconFontFamily
|
font.family: Appearance.iconFontFamilyRounded
|
||||||
font.variableAxes: Appearance.iconFontAxes
|
font.variableAxes: Appearance.iconFontAxes
|
||||||
font.pixelSize: 14
|
font.pixelSize: 14
|
||||||
color: rowMouse.containsMouse ? Colors.primaryText : Colors.foreground
|
color: rowMouse.containsMouse ? Colors.primaryText : Colors.foreground
|
||||||
@@ -568,7 +598,7 @@ Item {
|
|||||||
Text {
|
Text {
|
||||||
visible: exitRow.active
|
visible: exitRow.active
|
||||||
text: "check_circle"
|
text: "check_circle"
|
||||||
font.family: Appearance.iconFontFamily
|
font.family: Appearance.iconFontFamilyRounded
|
||||||
font.variableAxes: Appearance.iconFontAxes
|
font.variableAxes: Appearance.iconFontAxes
|
||||||
font.pixelSize: 14
|
font.pixelSize: 14
|
||||||
color: rowMouse.containsMouse ? Colors.primaryText : Colors.primary
|
color: rowMouse.containsMouse ? Colors.primaryText : Colors.primary
|
||||||
@@ -595,9 +625,25 @@ Item {
|
|||||||
width: parent ? parent.width : 0
|
width: parent ? parent.width : 0
|
||||||
radius: 0
|
radius: 0
|
||||||
implicitHeight: accountContent.implicitHeight + 12
|
implicitHeight: accountContent.implicitHeight + 12
|
||||||
color: accountRow.selected
|
// Hover checked first (same ordering as Volume.qml's DeviceRow):
|
||||||
? Qt.rgba(Colors.primary.r, Colors.primary.g, Colors.primary.b, 0.18)
|
// hovering always shows a solid Colors.primary fill, even for an
|
||||||
: (accountMouse.containsMouse ? Colors.primary : "transparent")
|
// already-selected row, so the primaryText the texts below already
|
||||||
|
// assume on hover is always correct (this row had the same
|
||||||
|
// active-first ordering bug as ExitNodeRow, just never reported).
|
||||||
|
color: accountMouse.containsMouse ? Colors.primary
|
||||||
|
: (accountRow.selected ? Qt.rgba(Colors.primary.r, Colors.primary.g, Colors.primary.b, 0.18) : "transparent")
|
||||||
|
|
||||||
|
// Hover-only outline, offset outside the row — same treatment as
|
||||||
|
// ExitNodeRow/NetworkRow. Not inside a clipped Flickable, so no
|
||||||
|
// inset needed on its container.
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: -3
|
||||||
|
radius: 0
|
||||||
|
color: "transparent"
|
||||||
|
border.color: Colors.foreground
|
||||||
|
border.width: accountMouse.containsMouse ? 1 : 0
|
||||||
|
}
|
||||||
|
|
||||||
RowLayout {
|
RowLayout {
|
||||||
id: accountContent
|
id: accountContent
|
||||||
@@ -613,7 +659,7 @@ Item {
|
|||||||
Layout.maximumWidth: 20
|
Layout.maximumWidth: 20
|
||||||
horizontalAlignment: Text.AlignLeft
|
horizontalAlignment: Text.AlignLeft
|
||||||
text: "person"
|
text: "person"
|
||||||
font.family: Appearance.iconFontFamily
|
font.family: Appearance.iconFontFamilyRounded
|
||||||
font.variableAxes: Appearance.iconFontAxes
|
font.variableAxes: Appearance.iconFontAxes
|
||||||
font.pixelSize: 14
|
font.pixelSize: 14
|
||||||
color: accountMouse.containsMouse ? Colors.primaryText : Colors.foreground
|
color: accountMouse.containsMouse ? Colors.primaryText : Colors.foreground
|
||||||
@@ -638,7 +684,7 @@ Item {
|
|||||||
Text {
|
Text {
|
||||||
visible: accountRow.selected
|
visible: accountRow.selected
|
||||||
text: "check_circle"
|
text: "check_circle"
|
||||||
font.family: Appearance.iconFontFamily
|
font.family: Appearance.iconFontFamilyRounded
|
||||||
font.variableAxes: Appearance.iconFontAxes
|
font.variableAxes: Appearance.iconFontAxes
|
||||||
font.pixelSize: 14
|
font.pixelSize: 14
|
||||||
color: accountMouse.containsMouse ? Colors.primaryText : Colors.primary
|
color: accountMouse.containsMouse ? Colors.primaryText : Colors.primary
|
||||||
@@ -680,6 +726,18 @@ Item {
|
|||||||
: (peerRow.expanded ? Qt.rgba(Colors.primary.r, Colors.primary.g, Colors.primary.b, 0.18) : "transparent")
|
: (peerRow.expanded ? Qt.rgba(Colors.primary.r, Colors.primary.g, Colors.primary.b, 0.18) : "transparent")
|
||||||
implicitHeight: peerContent.implicitHeight + 12
|
implicitHeight: peerContent.implicitHeight + 12
|
||||||
|
|
||||||
|
// Hover-only outline, offset outside the row — same treatment as
|
||||||
|
// ExitNodeRow/NetworkRow. listColumn is inset by 3px to give this
|
||||||
|
// room without being cropped by listFlick's clip.
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: -3
|
||||||
|
radius: 0
|
||||||
|
color: "transparent"
|
||||||
|
border.color: Colors.foreground
|
||||||
|
border.width: summaryMouse.containsMouse ? 1 : 0
|
||||||
|
}
|
||||||
|
|
||||||
RowLayout {
|
RowLayout {
|
||||||
id: peerContent
|
id: peerContent
|
||||||
anchors.left: parent.left
|
anchors.left: parent.left
|
||||||
@@ -694,7 +752,7 @@ Item {
|
|||||||
Layout.maximumWidth: 20
|
Layout.maximumWidth: 20
|
||||||
horizontalAlignment: Text.AlignLeft
|
horizontalAlignment: Text.AlignLeft
|
||||||
text: root.osIcon(peerRow.peer.os)
|
text: root.osIcon(peerRow.peer.os)
|
||||||
font.family: Appearance.iconFontFamily
|
font.family: Appearance.iconFontFamilyRounded
|
||||||
font.variableAxes: Appearance.iconFontAxes
|
font.variableAxes: Appearance.iconFontAxes
|
||||||
font.pixelSize: 14
|
font.pixelSize: 14
|
||||||
color: summaryMouse.containsMouse ? Colors.primaryText : Colors.foreground
|
color: summaryMouse.containsMouse ? Colors.primaryText : Colors.foreground
|
||||||
@@ -709,7 +767,7 @@ Item {
|
|||||||
}
|
}
|
||||||
Text {
|
Text {
|
||||||
text: peerRow.expanded ? "expand_less" : "content_copy"
|
text: peerRow.expanded ? "expand_less" : "content_copy"
|
||||||
font.family: Appearance.iconFontFamily
|
font.family: Appearance.iconFontFamilyRounded
|
||||||
font.variableAxes: Appearance.iconFontAxes
|
font.variableAxes: Appearance.iconFontAxes
|
||||||
font.pixelSize: 14
|
font.pixelSize: 14
|
||||||
color: summaryMouse.containsMouse ? Colors.primaryText : Colors.muted
|
color: summaryMouse.containsMouse ? Colors.primaryText : Colors.muted
|
||||||
@@ -739,6 +797,17 @@ Item {
|
|||||||
color: optionMouse.containsMouse ? Colors.primary : "transparent"
|
color: optionMouse.containsMouse ? Colors.primary : "transparent"
|
||||||
implicitHeight: optionRow.implicitHeight + 8
|
implicitHeight: optionRow.implicitHeight + 8
|
||||||
|
|
||||||
|
// Hover-only outline, offset outside the row — same
|
||||||
|
// treatment as the rest of this file's selectable rows.
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: -3
|
||||||
|
radius: 0
|
||||||
|
color: "transparent"
|
||||||
|
border.color: Colors.foreground
|
||||||
|
border.width: optionMouse.containsMouse ? 1 : 0
|
||||||
|
}
|
||||||
|
|
||||||
RowLayout {
|
RowLayout {
|
||||||
id: optionRow
|
id: optionRow
|
||||||
anchors.left: parent.left
|
anchors.left: parent.left
|
||||||
@@ -838,7 +907,7 @@ Item {
|
|||||||
spacing: 8
|
spacing: 8
|
||||||
|
|
||||||
TailscaleLogo {
|
TailscaleLogo {
|
||||||
size: 30
|
size: 25
|
||||||
color: label.color
|
color: label.color
|
||||||
crossed: label.crossed
|
crossed: label.crossed
|
||||||
warning: label.warning
|
warning: label.warning
|
||||||
@@ -915,7 +984,7 @@ Item {
|
|||||||
spacing: 8
|
spacing: 8
|
||||||
Text {
|
Text {
|
||||||
text: "open_in_new"
|
text: "open_in_new"
|
||||||
font.family: Appearance.iconFontFamily
|
font.family: Appearance.iconFontFamilyRounded
|
||||||
font.variableAxes: Appearance.iconFontAxes
|
font.variableAxes: Appearance.iconFontAxes
|
||||||
font.pixelSize: 14
|
font.pixelSize: 14
|
||||||
color: signInMouse.containsMouse ? Colors.primaryText : Colors.foreground
|
color: signInMouse.containsMouse ? Colors.primaryText : Colors.foreground
|
||||||
@@ -986,7 +1055,7 @@ Item {
|
|||||||
Text {
|
Text {
|
||||||
visible: root.status.selfIp !== ""
|
visible: root.status.selfIp !== ""
|
||||||
text: "content_copy"
|
text: "content_copy"
|
||||||
font.family: Appearance.iconFontFamily
|
font.family: Appearance.iconFontFamilyRounded
|
||||||
font.variableAxes: Appearance.iconFontAxes
|
font.variableAxes: Appearance.iconFontAxes
|
||||||
font.pixelSize: 12
|
font.pixelSize: 12
|
||||||
color: copyMouse.containsMouse ? Colors.primary : Colors.muted
|
color: copyMouse.containsMouse ? Colors.primary : Colors.muted
|
||||||
@@ -1026,7 +1095,18 @@ Item {
|
|||||||
|
|
||||||
Column {
|
Column {
|
||||||
id: listColumn
|
id: listColumn
|
||||||
width: listFlick.width
|
// Inset 3px on every side so ExitNodeRow/PeerRow's
|
||||||
|
// hover halo has room without being cropped by
|
||||||
|
// listFlick's clip. Left/right needs the manual
|
||||||
|
// x/width shift (children bind width: parent.width,
|
||||||
|
// which ignores Column's own padding); top/bottom
|
||||||
|
// just needs padding, since that's along Column's
|
||||||
|
// own layout axis and Flickable's contentHeight
|
||||||
|
// tracks it.
|
||||||
|
x: 3
|
||||||
|
width: listFlick.width - 6
|
||||||
|
topPadding: 3
|
||||||
|
bottomPadding: 3
|
||||||
spacing: 6
|
spacing: 6
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import Quickshell.Widgets
|
|||||||
import "../../config"
|
import "../../config"
|
||||||
|
|
||||||
RowLayout {
|
RowLayout {
|
||||||
spacing: Appearance.spacing * 0.6
|
spacing: Appearance.spacing
|
||||||
|
|
||||||
Repeater {
|
Repeater {
|
||||||
model: SystemTray.items
|
model: SystemTray.items
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import "../../config"
|
|||||||
|
|
||||||
Item {
|
Item {
|
||||||
id: root
|
id: root
|
||||||
implicitWidth: label.implicitWidth + 4
|
implicitWidth: label.implicitWidth
|
||||||
implicitHeight: Appearance.barHeight
|
implicitHeight: Appearance.barHeight
|
||||||
|
|
||||||
// The bar's own PanelWindow — passed in from Bar.qml so this popup can
|
// The bar's own PanelWindow — passed in from Bar.qml so this popup can
|
||||||
@@ -40,6 +40,12 @@ Item {
|
|||||||
enabled: panel.visible
|
enabled: panel.visible
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PwNodePeakMonitor {
|
||||||
|
id: outputPeak
|
||||||
|
node: root.sink
|
||||||
|
enabled: panel.visible
|
||||||
|
}
|
||||||
|
|
||||||
// --- Device label/icon heuristics, ported from omarchy's audio panel
|
// --- Device label/icon heuristics, ported from omarchy's audio panel
|
||||||
// Model.js (github.com/basecamp/omarchy shell/plugins/panels/audio/Model.js),
|
// 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.
|
// adapted to this project's Material Symbols icon set instead of Nerd Font.
|
||||||
@@ -67,27 +73,52 @@ Item {
|
|||||||
p["device.icon-name"] || "", p["device.product.name"] || ""].join(" ").toLowerCase()
|
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) {
|
function isHeadphoneNode(node) {
|
||||||
var blob = deviceBlob(node)
|
var blob = deviceBlob(node)
|
||||||
return blob.indexOf("headphone") !== -1 || blob.indexOf("headset") !== -1
|
if (blob.indexOf("headphone") !== -1 || blob.indexOf("headset") !== -1
|
||||||
|| blob.indexOf("earbud") !== -1 || blob.indexOf("earphone") !== -1
|
|| blob.indexOf("earbud") !== -1 || blob.indexOf("earphone") !== -1
|
||||||
|| blob.indexOf("airpod") !== -1
|
|| blob.indexOf("airpod") !== -1)
|
||||||
|
return true
|
||||||
|
return root.headphoneNameOverrides.some(function(needle) { return blob.indexOf(needle) !== -1 })
|
||||||
}
|
}
|
||||||
|
|
||||||
function sinkIcon(node) {
|
function sinkIcon(node) {
|
||||||
if (!node) return "speaker"
|
if (!node) return "speaker"
|
||||||
if (isHeadphoneNode(node)) return "headphones"
|
if (isHeadphoneNode(node)) return "headphones"
|
||||||
|
if (isBluetoothNode(node)) return "bluetooth_audio"
|
||||||
var blob = deviceBlob(node)
|
var blob = deviceBlob(node)
|
||||||
if (blob.indexOf("bluetooth") !== -1) return "bluetooth_audio"
|
|
||||||
if (blob.indexOf("hdmi") !== -1 || blob.indexOf("display") !== -1) return "tv"
|
if (blob.indexOf("hdmi") !== -1 || blob.indexOf("display") !== -1) return "tv"
|
||||||
return "speaker"
|
return "speaker"
|
||||||
}
|
}
|
||||||
|
|
||||||
function sourceIcon(node) {
|
function sourceIcon(node) {
|
||||||
if (!node) return "mic"
|
if (!node) return "mic"
|
||||||
|
if (isHeadphoneNode(node)) return "headphones"
|
||||||
|
if (isBluetoothNode(node)) return "bluetooth_audio"
|
||||||
var blob = deviceBlob(node)
|
var blob = deviceBlob(node)
|
||||||
if (blob.indexOf("headset") !== -1) return "headphones"
|
|
||||||
if (blob.indexOf("bluetooth") !== -1) return "bluetooth_audio"
|
|
||||||
if (blob.indexOf("webcam") !== -1 || blob.indexOf("camera") !== -1) return "videocam"
|
if (blob.indexOf("webcam") !== -1 || blob.indexOf("camera") !== -1) return "videocam"
|
||||||
return "mic"
|
return "mic"
|
||||||
}
|
}
|
||||||
@@ -95,9 +126,9 @@ Item {
|
|||||||
Text {
|
Text {
|
||||||
id: label
|
id: label
|
||||||
anchors.centerIn: parent
|
anchors.centerIn: parent
|
||||||
font.family: Appearance.iconFontFamily
|
font.family: Appearance.iconFontFamilyRounded
|
||||||
font.variableAxes: Appearance.iconFontAxes
|
font.variableAxes: Appearance.iconFontAxes
|
||||||
font.pixelSize: 16
|
font.pixelSize: 18
|
||||||
color: Colors.foreground
|
color: Colors.foreground
|
||||||
text: (root.outputMuted || root.outputVolume <= 0) ? "volume_off"
|
text: (root.outputMuted || root.outputVolume <= 0) ? "volume_off"
|
||||||
: (root.outputVolume < 0.5 ? "volume_down" : "volume_up")
|
: (root.outputVolume < 0.5 ? "volume_down" : "volume_up")
|
||||||
@@ -234,6 +265,19 @@ Item {
|
|||||||
color: rowMouse.containsMouse ? Colors.primary
|
color: rowMouse.containsMouse ? Colors.primary
|
||||||
: (deviceRow.active ? Qt.rgba(Colors.primary.r, Colors.primary.g, Colors.primary.b, 0.18) : "transparent")
|
: (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 {
|
RowLayout {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
anchors.leftMargin: 8
|
anchors.leftMargin: 8
|
||||||
@@ -245,7 +289,7 @@ Item {
|
|||||||
Layout.maximumWidth: 18
|
Layout.maximumWidth: 18
|
||||||
horizontalAlignment: Text.AlignHCenter
|
horizontalAlignment: Text.AlignHCenter
|
||||||
text: deviceRow.icon
|
text: deviceRow.icon
|
||||||
font.family: Appearance.iconFontFamily
|
font.family: Appearance.iconFontFamilyRounded
|
||||||
font.variableAxes: Appearance.iconFontAxes
|
font.variableAxes: Appearance.iconFontAxes
|
||||||
font.pixelSize: 14
|
font.pixelSize: 14
|
||||||
color: rowMouse.containsMouse ? Colors.primaryText : Colors.foreground
|
color: rowMouse.containsMouse ? Colors.primaryText : Colors.foreground
|
||||||
@@ -315,7 +359,7 @@ Item {
|
|||||||
Column {
|
Column {
|
||||||
id: mainColumn
|
id: mainColumn
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
anchors.margins: panel.contentPadding
|
anchors.margins: panel.contentPadding - 2
|
||||||
spacing: 10
|
spacing: 10
|
||||||
|
|
||||||
// Header
|
// Header
|
||||||
@@ -325,9 +369,9 @@ Item {
|
|||||||
|
|
||||||
Text {
|
Text {
|
||||||
text: label.text
|
text: label.text
|
||||||
font.family: Appearance.iconFontFamily
|
font.family: Appearance.iconFontFamilyRounded
|
||||||
font.variableAxes: Appearance.iconFontAxes
|
font.variableAxes: Appearance.iconFontAxes
|
||||||
font.pixelSize: 40
|
font.pixelSize: 30
|
||||||
color: Colors.foreground
|
color: Colors.foreground
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -375,9 +419,14 @@ Item {
|
|||||||
onMoved: (v) => { if (root.sink && root.sink.audio) root.sink.audio.volume = v }
|
onMoved: (v) => { if (root.sink && root.sink.audio) root.sink.audio.volume = v }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LevelMeter {
|
||||||
|
width: parent.width
|
||||||
|
level: outputPeak.peak
|
||||||
|
}
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
width: parent.width
|
width: parent.width
|
||||||
spacing: 2
|
spacing: 4
|
||||||
Repeater {
|
Repeater {
|
||||||
model: root.candidateSinks
|
model: root.candidateSinks
|
||||||
DeviceRow {
|
DeviceRow {
|
||||||
@@ -425,9 +474,10 @@ Item {
|
|||||||
level: inputPeak.peak
|
level: inputPeak.peak
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
width: parent.width
|
width: parent.width
|
||||||
spacing: 2
|
spacing: 4
|
||||||
Repeater {
|
Repeater {
|
||||||
model: root.candidateSources
|
model: root.candidateSources
|
||||||
DeviceRow {
|
DeviceRow {
|
||||||
|
|||||||
@@ -45,9 +45,41 @@ Item {
|
|||||||
// to connect once the current one has finished disconnecting.
|
// to connect once the current one has finished disconnecting.
|
||||||
property string pendingConnect: ""
|
property string pendingConnect: ""
|
||||||
|
|
||||||
// Surfaced under the header for a failed connect/disconnect; cleared
|
// Surfaced under each provider's own row for a failed connect/
|
||||||
// automatically after a few seconds.
|
// disconnect/settings change; cleared automatically after a few
|
||||||
property string actionStatus: ""
|
// seconds. Kept separate per provider so an error from one doesn't
|
||||||
|
// show up hanging under the other's row.
|
||||||
|
property string mullvadActionStatus: ""
|
||||||
|
property string protonActionStatus: ""
|
||||||
|
|
||||||
|
// --- Mullvad advanced options (auto-connect, relay location, multihop) --
|
||||||
|
// ProtonVPN's CLI is plain-text-only and far less predictable across
|
||||||
|
// versions, so this extra panel is Mullvad-specific rather than a
|
||||||
|
// second ProviderRow feature.
|
||||||
|
|
||||||
|
property bool mullvadOptionsOpen: false
|
||||||
|
property bool mullvadAutoConnect: false
|
||||||
|
property bool mullvadMultihop: false
|
||||||
|
// Human-readable current relay constraint, e.g. "Any" or "Sweden" —
|
||||||
|
// resolved from `relay get`'s "country xx[ city yyy]" syntax against
|
||||||
|
// mullvadCountries once that list has loaded.
|
||||||
|
property string mullvadLocationLabel: "Any"
|
||||||
|
property string mullvadEntryLabel: "Any"
|
||||||
|
// [{name, code}], parsed from `mullvad relay list` and cached for the
|
||||||
|
// life of the panel — the full relay list rarely changes and re-parsing
|
||||||
|
// ~50 countries on every open would just be wasted work.
|
||||||
|
property var mullvadCountries: []
|
||||||
|
// "" | "exit" | "entry" — which location field's picker is open, if any.
|
||||||
|
property string mullvadPickerTarget: ""
|
||||||
|
property string mullvadPickerFilter: ""
|
||||||
|
|
||||||
|
readonly property var mullvadFilteredCountries: {
|
||||||
|
const f = root.mullvadPickerFilter.trim().toLowerCase()
|
||||||
|
if (!f) return root.mullvadCountries
|
||||||
|
return root.mullvadCountries.filter(function(c) {
|
||||||
|
return c.name.toLowerCase().indexOf(f) !== -1 || c.code.indexOf(f) !== -1
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function shortLine(text) {
|
function shortLine(text) {
|
||||||
const line = String(text || "").trim().split("\n")[0] || ""
|
const line = String(text || "").trim().split("\n")[0] || ""
|
||||||
@@ -82,6 +114,95 @@ Item {
|
|||||||
VpnExclusivity.protonConnected = root.protonState === "connected"
|
VpnExclusivity.protonConnected = root.protonState === "connected"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyMullvadAutoConnect(text) {
|
||||||
|
const m = String(text || "").match(/autoconnect:\s*(on|off)/i)
|
||||||
|
root.mullvadAutoConnect = !!m && m[1].toLowerCase() === "on"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolves relay get's "any" / "country xx" / "country xx city yyy" /
|
||||||
|
// bare-hostname syntax into a display label. Falls back to the raw
|
||||||
|
// country code (or the raw string) when mullvadCountries hasn't loaded
|
||||||
|
// yet, rather than showing nothing.
|
||||||
|
function describeMullvadLocation(raw) {
|
||||||
|
const s = String(raw || "").trim()
|
||||||
|
if (!s || s === "any") return "Any"
|
||||||
|
const m = s.match(/^country\s+([a-z]{2})\b/i)
|
||||||
|
if (!m) return s
|
||||||
|
const code = m[1].toLowerCase()
|
||||||
|
const country = root.mullvadCountries.find(function(c) { return c.code === code })
|
||||||
|
return country ? country.name : code.toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyMullvadRelay(text) {
|
||||||
|
const body = String(text || "")
|
||||||
|
const locMatch = body.match(/^\s*Location:\s*(.+)$/mi)
|
||||||
|
const multihopMatch = body.match(/^\s*Multihop state:\s*(\w+)/mi)
|
||||||
|
const entryMatch = body.match(/^\s*Multihop entry:\s*(.+)$/mi)
|
||||||
|
root.mullvadMultihop = !!multihopMatch && multihopMatch[1].toLowerCase() === "enabled"
|
||||||
|
root.mullvadLocationLabel = root.describeMullvadLocation(locMatch ? locMatch[1].trim() : "")
|
||||||
|
root.mullvadEntryLabel = root.describeMullvadLocation(entryMatch ? entryMatch[1].trim() : "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only country-level lines are of interest ("Sweden (se)", flush left) —
|
||||||
|
// city ("\tGothenburg (got) @ ...") and server lines are indented and
|
||||||
|
// their parenthesised code is never exactly two letters, so the regex
|
||||||
|
// alone is enough to tell them apart without checking indentation.
|
||||||
|
function applyMullvadCountries(text) {
|
||||||
|
const lines = String(text || "").split("\n")
|
||||||
|
const countries = []
|
||||||
|
for (const line of lines) {
|
||||||
|
const m = line.match(/^(\S.*) \(([a-z]{2})\)\s*$/)
|
||||||
|
if (m) countries.push({ name: m[1].trim(), code: m[2] })
|
||||||
|
}
|
||||||
|
countries.sort(function(a, b) { return a.name.localeCompare(b.name) })
|
||||||
|
root.mullvadCountries = countries
|
||||||
|
// Re-resolve any labels already showing raw codes now that names
|
||||||
|
// are available.
|
||||||
|
if (!mullvadRelayGetProc.running) mullvadRelayGetProc.running = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openMullvadOptions() {
|
||||||
|
root.mullvadOptionsOpen = !root.mullvadOptionsOpen
|
||||||
|
if (root.mullvadOptionsOpen) {
|
||||||
|
if (!mullvadAutoConnectGetProc.running) mullvadAutoConnectGetProc.running = true
|
||||||
|
if (!mullvadRelayGetProc.running) mullvadRelayGetProc.running = true
|
||||||
|
if (root.mullvadCountries.length === 0 && !mullvadRelayListProc.running) mullvadRelayListProc.running = true
|
||||||
|
} else {
|
||||||
|
root.mullvadPickerTarget = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openMullvadPicker(target) {
|
||||||
|
root.mullvadPickerFilter = ""
|
||||||
|
root.mullvadPickerTarget = root.mullvadPickerTarget === target ? "" : target
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickMullvadLocation(code) {
|
||||||
|
const target = root.mullvadPickerTarget
|
||||||
|
root.mullvadPickerTarget = ""
|
||||||
|
root.setMullvadLocation(target, code)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMullvadAutoConnect(on) {
|
||||||
|
if (mullvadAutoConnectSetProc.running) return
|
||||||
|
mullvadAutoConnectSetProc.command = ["mullvad", "auto-connect", "set", on ? "on" : "off"]
|
||||||
|
mullvadAutoConnectSetProc.running = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMullvadMultihop(on) {
|
||||||
|
if (mullvadRelaySetProc.running) return
|
||||||
|
mullvadRelaySetProc.command = ["mullvad", "relay", "set", "multihop", on ? "on" : "off"]
|
||||||
|
mullvadRelaySetProc.running = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMullvadLocation(target, code) {
|
||||||
|
if (mullvadRelaySetProc.running) return
|
||||||
|
mullvadRelaySetProc.command = target === "entry"
|
||||||
|
? ["mullvad", "relay", "set", "entry", "location", code]
|
||||||
|
: ["mullvad", "relay", "set", "location", code]
|
||||||
|
mullvadRelaySetProc.running = true
|
||||||
|
}
|
||||||
|
|
||||||
function refreshMullvad() {
|
function refreshMullvad() {
|
||||||
if (!root.mullvadInstalled) {
|
if (!root.mullvadInstalled) {
|
||||||
if (!mullvadWhichProc.running) mullvadWhichProc.running = true
|
if (!mullvadWhichProc.running) mullvadWhichProc.running = true
|
||||||
@@ -197,8 +318,8 @@ Item {
|
|||||||
stderr: StdioCollector { id: mullvadConnectErr }
|
stderr: StdioCollector { id: mullvadConnectErr }
|
||||||
onExited: (exitCode, exitStatus) => {
|
onExited: (exitCode, exitStatus) => {
|
||||||
if (exitCode !== 0) {
|
if (exitCode !== 0) {
|
||||||
root.actionStatus = "Mullvad: " + root.shortLine(mullvadConnectErr.text || "connect failed")
|
root.mullvadActionStatus = root.shortLine(mullvadConnectErr.text || "Connect failed")
|
||||||
actionStatusTimer.restart()
|
mullvadActionStatusTimer.restart()
|
||||||
} else if (!tailscaleDownProc.running) {
|
} else if (!tailscaleDownProc.running) {
|
||||||
tailscaleDownProc.running = true
|
tailscaleDownProc.running = true
|
||||||
}
|
}
|
||||||
@@ -213,8 +334,8 @@ Item {
|
|||||||
stderr: StdioCollector { id: mullvadDisconnectErr }
|
stderr: StdioCollector { id: mullvadDisconnectErr }
|
||||||
onExited: (exitCode, exitStatus) => {
|
onExited: (exitCode, exitStatus) => {
|
||||||
if (exitCode !== 0) {
|
if (exitCode !== 0) {
|
||||||
root.actionStatus = "Mullvad: " + root.shortLine(mullvadDisconnectErr.text || "disconnect failed")
|
root.mullvadActionStatus = root.shortLine(mullvadDisconnectErr.text || "Disconnect failed")
|
||||||
actionStatusTimer.restart()
|
mullvadActionStatusTimer.restart()
|
||||||
}
|
}
|
||||||
root.refreshMullvad()
|
root.refreshMullvad()
|
||||||
if (root.pendingConnect === "mullvad") { root.pendingConnect = ""; root.connectMullvad() }
|
if (root.pendingConnect === "mullvad") { root.pendingConnect = ""; root.connectMullvad() }
|
||||||
@@ -229,6 +350,77 @@ Item {
|
|||||||
onTriggered: if (mullvadStatusProc.running) mullvadStatusProc.running = false
|
onTriggered: if (mullvadStatusProc.running) mullvadStatusProc.running = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Mullvad advanced options: auto-connect, relay location, multihop --
|
||||||
|
// Only queried while the options section is open (see
|
||||||
|
// root.openMullvadOptions), not on the regular status poll — these
|
||||||
|
// don't change on their own the way tunnel state does.
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: mullvadAutoConnectGetProc
|
||||||
|
command: ["mullvad", "auto-connect", "get"]
|
||||||
|
stdout: StdioCollector {
|
||||||
|
id: mullvadAutoConnectGetCollector
|
||||||
|
onStreamFinished: root.applyMullvadAutoConnect(mullvadAutoConnectGetCollector.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: mullvadAutoConnectSetProc
|
||||||
|
stdout: StdioCollector {}
|
||||||
|
stderr: StdioCollector { id: mullvadAutoConnectSetErr }
|
||||||
|
onExited: (exitCode, exitStatus) => {
|
||||||
|
if (exitCode !== 0) {
|
||||||
|
root.mullvadActionStatus = root.shortLine(mullvadAutoConnectSetErr.text || "Auto-connect change failed")
|
||||||
|
mullvadActionStatusTimer.restart()
|
||||||
|
}
|
||||||
|
if (!mullvadAutoConnectGetProc.running) mullvadAutoConnectGetProc.running = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: mullvadRelayGetProc
|
||||||
|
command: ["mullvad", "relay", "get"]
|
||||||
|
stdout: StdioCollector {
|
||||||
|
id: mullvadRelayGetCollector
|
||||||
|
onStreamFinished: root.applyMullvadRelay(mullvadRelayGetCollector.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: mullvadRelayListProc
|
||||||
|
command: ["mullvad", "relay", "list"]
|
||||||
|
stdout: StdioCollector {
|
||||||
|
id: mullvadRelayListCollector
|
||||||
|
onStreamFinished: root.applyMullvadCountries(mullvadRelayListCollector.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: mullvadRelaySetProc
|
||||||
|
stdout: StdioCollector {}
|
||||||
|
stderr: StdioCollector { id: mullvadRelaySetErr }
|
||||||
|
onExited: (exitCode, exitStatus) => {
|
||||||
|
if (exitCode !== 0) {
|
||||||
|
root.mullvadActionStatus = root.shortLine(mullvadRelaySetErr.text || "Relay change failed")
|
||||||
|
mullvadActionStatusTimer.restart()
|
||||||
|
}
|
||||||
|
if (!mullvadRelayGetProc.running) mullvadRelayGetProc.running = true
|
||||||
|
// Relay constraint changes don't take effect on an already-open
|
||||||
|
// tunnel by themselves — kick a reconnect so switching location/
|
||||||
|
// multihop while connected actually moves the tunnel, instead of
|
||||||
|
// silently only applying on the next manual reconnect.
|
||||||
|
if (root.mullvadState === "connected" && !mullvadReconnectProc.running) mullvadReconnectProc.running = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: mullvadReconnectProc
|
||||||
|
command: ["mullvad", "reconnect"]
|
||||||
|
stdout: StdioCollector {}
|
||||||
|
stderr: StdioCollector {}
|
||||||
|
onExited: (exitCode, exitStatus) => root.refreshMullvad()
|
||||||
|
}
|
||||||
|
|
||||||
// --- ProtonVPN ---------------------------------------------------------
|
// --- ProtonVPN ---------------------------------------------------------
|
||||||
|
|
||||||
Process {
|
Process {
|
||||||
@@ -261,8 +453,8 @@ Item {
|
|||||||
stderr: StdioCollector { id: protonConnectErr }
|
stderr: StdioCollector { id: protonConnectErr }
|
||||||
onExited: (exitCode, exitStatus) => {
|
onExited: (exitCode, exitStatus) => {
|
||||||
if (exitCode !== 0) {
|
if (exitCode !== 0) {
|
||||||
root.actionStatus = "ProtonVPN: " + root.shortLine(protonConnectErr.text || "connect failed")
|
root.protonActionStatus = root.shortLine(protonConnectErr.text || "Connect failed")
|
||||||
actionStatusTimer.restart()
|
protonActionStatusTimer.restart()
|
||||||
} else if (!tailscaleDownProc.running) {
|
} else if (!tailscaleDownProc.running) {
|
||||||
tailscaleDownProc.running = true
|
tailscaleDownProc.running = true
|
||||||
}
|
}
|
||||||
@@ -277,8 +469,8 @@ Item {
|
|||||||
stderr: StdioCollector { id: protonDisconnectErr }
|
stderr: StdioCollector { id: protonDisconnectErr }
|
||||||
onExited: (exitCode, exitStatus) => {
|
onExited: (exitCode, exitStatus) => {
|
||||||
if (exitCode !== 0) {
|
if (exitCode !== 0) {
|
||||||
root.actionStatus = "ProtonVPN: " + root.shortLine(protonDisconnectErr.text || "disconnect failed")
|
root.protonActionStatus = root.shortLine(protonDisconnectErr.text || "Disconnect failed")
|
||||||
actionStatusTimer.restart()
|
protonActionStatusTimer.restart()
|
||||||
}
|
}
|
||||||
root.refreshProton()
|
root.refreshProton()
|
||||||
if (root.pendingConnect === "mullvad") { root.pendingConnect = ""; root.connectMullvad() }
|
if (root.pendingConnect === "mullvad") { root.pendingConnect = ""; root.connectMullvad() }
|
||||||
@@ -305,24 +497,55 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Timer {
|
Timer {
|
||||||
id: actionStatusTimer
|
id: mullvadActionStatusTimer
|
||||||
interval: 4000
|
interval: 4000
|
||||||
repeat: false
|
repeat: false
|
||||||
onTriggered: root.actionStatus = ""
|
onTriggered: root.mullvadActionStatus = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: protonActionStatusTimer
|
||||||
|
interval: 4000
|
||||||
|
repeat: false
|
||||||
|
onTriggered: root.protonActionStatus = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Bar icon ------------------------------------------------------------
|
// --- 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 {
|
Text {
|
||||||
id: label
|
id: label
|
||||||
anchors.centerIn: parent
|
anchors.centerIn: parent
|
||||||
font.family: Appearance.iconFontFamily
|
font.family: Appearance.iconFontFamilyRounded
|
||||||
font.variableAxes: Appearance.iconFontAxes
|
font.variableAxes: Appearance.iconFontAxes
|
||||||
font.pixelSize: Appearance.iconSize
|
font.pixelSize: Appearance.iconSize
|
||||||
color: root.anyConnected ? Colors.primary : Colors.muted
|
color: root.anyConnected ? Colors.primary : Colors.muted
|
||||||
text: "vpn_lock"
|
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 {
|
MouseArea {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
cursorShape: Qt.PointingHandCursor
|
cursorShape: Qt.PointingHandCursor
|
||||||
@@ -349,6 +572,20 @@ Item {
|
|||||||
color: toggleRoot.checked ? Colors.primary : Colors.muted
|
color: toggleRoot.checked ? Colors.primary : Colors.muted
|
||||||
opacity: (toggleRoot.checked ? 1 : 0.35) * (toggleRoot.busy ? 0.6 : 1)
|
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 {
|
Rectangle {
|
||||||
width: parent.height - 4
|
width: parent.height - 4
|
||||||
height: parent.height - 4
|
height: parent.height - 4
|
||||||
@@ -359,8 +596,10 @@ Item {
|
|||||||
Behavior on x { NumberAnimation { duration: Appearance.animationDuration } }
|
Behavior on x { NumberAnimation { duration: Appearance.animationDuration } }
|
||||||
}
|
}
|
||||||
MouseArea {
|
MouseArea {
|
||||||
|
id: toggleMouse
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
enabled: !toggleRoot.busy
|
enabled: !toggleRoot.busy
|
||||||
|
hoverEnabled: true
|
||||||
cursorShape: toggleRoot.busy ? Qt.ArrowCursor : Qt.PointingHandCursor
|
cursorShape: toggleRoot.busy ? Qt.ArrowCursor : Qt.PointingHandCursor
|
||||||
onClicked: toggleRoot.toggled()
|
onClicked: toggleRoot.toggled()
|
||||||
}
|
}
|
||||||
@@ -376,7 +615,13 @@ Item {
|
|||||||
property string detailText: ""
|
property string detailText: ""
|
||||||
property string state: "disconnected"
|
property string state: "disconnected"
|
||||||
property bool installed: true
|
property bool installed: true
|
||||||
|
// When true, shows a chevron that expands a provider-specific
|
||||||
|
// options section below this row (used for Mullvad's advanced
|
||||||
|
// options; ProtonVPN's row leaves this false).
|
||||||
|
property bool expandable: false
|
||||||
|
property bool expanded: false
|
||||||
signal toggle()
|
signal toggle()
|
||||||
|
signal expandToggle()
|
||||||
width: parent ? parent.width : 0
|
width: parent ? parent.width : 0
|
||||||
radius: 0
|
radius: 0
|
||||||
implicitHeight: rowContent.implicitHeight + 12
|
implicitHeight: rowContent.implicitHeight + 12
|
||||||
@@ -398,7 +643,7 @@ Item {
|
|||||||
Layout.maximumWidth: 20
|
Layout.maximumWidth: 20
|
||||||
horizontalAlignment: Text.AlignHCenter
|
horizontalAlignment: Text.AlignHCenter
|
||||||
text: provRow.subIcon
|
text: provRow.subIcon
|
||||||
font.family: Appearance.iconFontFamily
|
font.family: Appearance.iconFontFamilyRounded
|
||||||
font.variableAxes: Appearance.iconFontAxes
|
font.variableAxes: Appearance.iconFontAxes
|
||||||
font.pixelSize: 14
|
font.pixelSize: 14
|
||||||
color: provRow.state === "connected" ? Colors.primary : Colors.foreground
|
color: provRow.state === "connected" ? Colors.primary : Colors.foreground
|
||||||
@@ -437,8 +682,90 @@ Item {
|
|||||||
busy: provRow.state === "connecting" || provRow.state === "disconnecting"
|
busy: provRow.state === "connecting" || provRow.state === "disconnecting"
|
||||||
onToggled: provRow.toggle()
|
onToggled: provRow.toggle()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
visible: provRow.expandable
|
||||||
|
text: provRow.expanded ? "expand_less" : "expand_more"
|
||||||
|
font.family: Appearance.iconFontFamilyRounded
|
||||||
|
font.variableAxes: Appearance.iconFontAxes
|
||||||
|
font.pixelSize: 15
|
||||||
|
color: expandMouse.containsMouse ? Colors.foreground : Colors.muted
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: expandMouse
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: -5
|
||||||
|
hoverEnabled: true
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onClicked: provRow.expandToggle()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A single clickable "Location: Sweden ›" row inside the Mullvad
|
||||||
|
// options section — used for both the exit location and (when multihop
|
||||||
|
// is on) the entry location. Clicking routes back to root, which opens
|
||||||
|
// the shared country picker below rather than each field owning one.
|
||||||
|
component LocationField: Item {
|
||||||
|
id: locField
|
||||||
|
property string fieldLabel: ""
|
||||||
|
property string valueText: "Any"
|
||||||
|
property bool open: false
|
||||||
|
signal clicked()
|
||||||
|
width: parent ? parent.width : 0
|
||||||
|
implicitHeight: locRow.implicitHeight
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
id: locRow
|
||||||
|
width: parent.width
|
||||||
|
spacing: 6
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: locField.fieldLabel
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSizeSmall
|
||||||
|
color: Colors.muted
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
horizontalAlignment: Text.AlignRight
|
||||||
|
elide: Text.ElideRight
|
||||||
|
text: locField.valueText
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSizeSmall
|
||||||
|
font.bold: true
|
||||||
|
color: Colors.foreground
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: locField.open ? "expand_less" : "chevron_right"
|
||||||
|
font.family: Appearance.iconFontFamilyRounded
|
||||||
|
font.variableAxes: Appearance.iconFontAxes
|
||||||
|
font.pixelSize: 14
|
||||||
|
color: Colors.muted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MouseArea {
|
||||||
|
anchors.fill: parent
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onClicked: locField.clicked()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A failed connect/disconnect/settings-change message, shown directly
|
||||||
|
// under the provider row it came from rather than in one shared line
|
||||||
|
// for both providers — keeps an error attributed to whichever CLI it
|
||||||
|
// actually came from.
|
||||||
|
component StatusLine: Text {
|
||||||
|
property string message: ""
|
||||||
|
visible: message !== ""
|
||||||
|
width: parent ? parent.width : 0
|
||||||
|
text: message
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSizeSmall
|
||||||
|
color: Colors.muted
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
}
|
||||||
|
|
||||||
// --- Panel -----------------------------------------------------------
|
// --- Panel -----------------------------------------------------------
|
||||||
|
|
||||||
@@ -454,7 +781,18 @@ Item {
|
|||||||
NumberAnimation { duration: Appearance.animationDuration; easing.type: Easing.OutCubic }
|
NumberAnimation { duration: Appearance.animationDuration; easing.type: Easing.OutCubic }
|
||||||
}
|
}
|
||||||
|
|
||||||
onVisibleChanged: if (visible) root.refresh()
|
// Leaving mullvadOptionsOpen itself alone across opens (it's a
|
||||||
|
// deliberate "keep this expanded" choice), but the country picker
|
||||||
|
// is transient state scoped to one visit — closing the panel with
|
||||||
|
// it open would otherwise leave a stale filter/list waiting.
|
||||||
|
onVisibleChanged: {
|
||||||
|
if (visible) {
|
||||||
|
root.refresh()
|
||||||
|
} else {
|
||||||
|
root.mullvadPickerTarget = ""
|
||||||
|
root.mullvadPickerFilter = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
anchor {
|
anchor {
|
||||||
window: root.barWindow
|
window: root.barWindow
|
||||||
@@ -493,7 +831,7 @@ Item {
|
|||||||
|
|
||||||
Text {
|
Text {
|
||||||
text: "vpn_lock"
|
text: "vpn_lock"
|
||||||
font.family: Appearance.iconFontFamily
|
font.family: Appearance.iconFontFamilyRounded
|
||||||
font.variableAxes: Appearance.iconFontAxes
|
font.variableAxes: Appearance.iconFontAxes
|
||||||
font.pixelSize: 22
|
font.pixelSize: 22
|
||||||
color: root.anyConnected ? Colors.primary : Colors.foreground
|
color: root.anyConnected ? Colors.primary : Colors.foreground
|
||||||
@@ -508,28 +846,202 @@ Item {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
Column {
|
||||||
width: parent.width
|
width: parent.width
|
||||||
spacing: 2
|
spacing: 2
|
||||||
|
|
||||||
|
// Mullvad's connect row and its advanced options share a
|
||||||
|
// single bordered card — the border wraps the whole
|
||||||
|
// section (row, status line, and options together) and
|
||||||
|
// grows to include the options/picker as they open,
|
||||||
|
// rather than the options having a separate box of
|
||||||
|
// their own below an unbordered connect row.
|
||||||
|
Rectangle {
|
||||||
|
width: parent.width
|
||||||
|
radius: 0
|
||||||
|
color: Colors.background
|
||||||
|
border.color: Colors.muted
|
||||||
|
border.width: 1
|
||||||
|
implicitHeight: mullvadSectionColumn.implicitHeight + 16
|
||||||
|
clip: true
|
||||||
|
// Without this, the card snapped to its new size
|
||||||
|
// instantly while the popup around it was still
|
||||||
|
// animating open, so the border visibly lagged
|
||||||
|
// behind (or cut off) content like the country
|
||||||
|
// picker appearing/disappearing beneath it.
|
||||||
|
Behavior on implicitHeight {
|
||||||
|
NumberAnimation { duration: Appearance.animationDuration; easing.type: Easing.OutCubic }
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: mullvadSectionColumn
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.margins: 8
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
ProviderRow {
|
ProviderRow {
|
||||||
label: "Mullvad"
|
label: "Mullvad"
|
||||||
subIcon: "shield"
|
subIcon: "gpp_good"
|
||||||
state: root.mullvadState
|
state: root.mullvadState
|
||||||
detailText: root.mullvadDetail
|
detailText: root.mullvadDetail
|
||||||
installed: root.mullvadInstalled
|
installed: root.mullvadInstalled
|
||||||
|
expandable: root.mullvadInstalled
|
||||||
|
expanded: root.mullvadOptionsOpen
|
||||||
onToggle: root.toggleMullvad()
|
onToggle: root.toggleMullvad()
|
||||||
|
onExpandToggle: root.openMullvadOptions()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
StatusLine { message: root.mullvadActionStatus }
|
||||||
|
|
||||||
|
// Advanced options — auto-connect, exit/entry
|
||||||
|
// relay location, multihop.
|
||||||
|
Column {
|
||||||
|
id: mullvadOptionsColumn
|
||||||
|
width: parent.width
|
||||||
|
visible: root.mullvadOptionsOpen && root.mullvadInstalled
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
width: parent.width
|
||||||
|
Text {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: "Auto-connect"
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSizeSmall
|
||||||
|
color: Colors.foreground
|
||||||
|
}
|
||||||
|
ToggleSwitch {
|
||||||
|
checked: root.mullvadAutoConnect
|
||||||
|
busy: mullvadAutoConnectSetProc.running
|
||||||
|
onToggled: root.setMullvadAutoConnect(!root.mullvadAutoConnect)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
width: parent.width
|
||||||
|
Text {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: "Multihop"
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSizeSmall
|
||||||
|
color: Colors.foreground
|
||||||
|
}
|
||||||
|
ToggleSwitch {
|
||||||
|
checked: root.mullvadMultihop
|
||||||
|
busy: mullvadRelaySetProc.running
|
||||||
|
onToggled: root.setMullvadMultihop(!root.mullvadMultihop)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LocationField {
|
||||||
|
fieldLabel: root.mullvadMultihop ? "Exit" : "Location"
|
||||||
|
valueText: root.mullvadLocationLabel
|
||||||
|
open: root.mullvadPickerTarget === "exit"
|
||||||
|
onClicked: root.openMullvadPicker("exit")
|
||||||
|
}
|
||||||
|
|
||||||
|
LocationField {
|
||||||
|
visible: root.mullvadMultihop
|
||||||
|
fieldLabel: "Entry"
|
||||||
|
valueText: root.mullvadEntryLabel
|
||||||
|
open: root.mullvadPickerTarget === "entry"
|
||||||
|
onClicked: root.openMullvadPicker("entry")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared country picker for whichever LocationField
|
||||||
|
// was last clicked — filter box plus a capped,
|
||||||
|
// scrollable list ("Any" always pinned first).
|
||||||
|
Column {
|
||||||
|
width: parent.width
|
||||||
|
visible: root.mullvadPickerTarget !== ""
|
||||||
|
spacing: 6
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: parent.width
|
||||||
|
implicitHeight: 24
|
||||||
|
radius: 0
|
||||||
|
color: Colors.surface
|
||||||
|
border.color: Colors.muted
|
||||||
|
border.width: 1
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 6
|
||||||
|
visible: mullvadFilterInput.text.length === 0
|
||||||
|
text: "Search country…"
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSizeSmall
|
||||||
|
color: Colors.muted
|
||||||
|
}
|
||||||
|
|
||||||
|
TextInput {
|
||||||
|
id: mullvadFilterInput
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 6
|
||||||
|
color: Colors.foreground
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSizeSmall
|
||||||
|
clip: true
|
||||||
|
focus: root.mullvadPickerTarget !== ""
|
||||||
|
text: root.mullvadPickerFilter
|
||||||
|
onTextChanged: root.mullvadPickerFilter = text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Flickable {
|
||||||
|
id: mullvadPickerFlick
|
||||||
|
width: parent.width
|
||||||
|
height: Math.min(mullvadPickerColumn.implicitHeight, 150)
|
||||||
|
contentWidth: width
|
||||||
|
contentHeight: mullvadPickerColumn.implicitHeight
|
||||||
|
clip: true
|
||||||
|
boundsBehavior: Flickable.StopAtBounds
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: mullvadPickerColumn
|
||||||
|
width: mullvadPickerFlick.width
|
||||||
|
spacing: 1
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: [{ name: "Any", code: "any" }].concat(root.mullvadFilteredCountries)
|
||||||
|
Rectangle {
|
||||||
|
required property var modelData
|
||||||
|
width: parent.width
|
||||||
|
implicitHeight: 22
|
||||||
|
radius: 0
|
||||||
|
color: countryMouse.containsMouse ? Colors.primary : "transparent"
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
anchors.leftMargin: 4
|
||||||
|
anchors.rightMargin: 4
|
||||||
|
elide: Text.ElideRight
|
||||||
|
text: modelData.name
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSizeSmall
|
||||||
|
color: countryMouse.containsMouse ? Colors.primaryText : Colors.foreground
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: countryMouse
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onClicked: root.pickMullvadLocation(modelData.code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ProviderRow {
|
ProviderRow {
|
||||||
label: "ProtonVPN"
|
label: "ProtonVPN"
|
||||||
subIcon: "gpp_good"
|
subIcon: "gpp_good"
|
||||||
@@ -538,6 +1050,8 @@ Item {
|
|||||||
installed: root.protonInstalled
|
installed: root.protonInstalled
|
||||||
onToggle: root.toggleProton()
|
onToggle: root.toggleProton()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
StatusLine { message: root.protonActionStatus }
|
||||||
}
|
}
|
||||||
|
|
||||||
Text {
|
Text {
|
||||||
|
|||||||
413
dotfiles/.config/quickshell/modules/bar/Weather.qml
Normal file
413
dotfiles/.config/quickshell/modules/bar/Weather.qml
Normal file
@@ -0,0 +1,413 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Hyprland
|
||||||
|
import "../../config"
|
||||||
|
|
||||||
|
// Weather indicator — modeled on omarchy's weather bar pill
|
||||||
|
// (github.com/omacom/omarchy, quattro branch,
|
||||||
|
// shell/plugins/panels/weather) but rebuilt in this bar's own style: the
|
||||||
|
// actual location/polling logic lives in the WeatherData singleton (config/),
|
||||||
|
// shared across every monitor's bar; this is just the icon + panel UI,
|
||||||
|
// same split as SystemUpdate.qml.
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
implicitWidth: row.implicitWidth + 2
|
||||||
|
implicitHeight: Appearance.barHeight
|
||||||
|
|
||||||
|
// The bar's own PanelWindow — same barWindow-anchoring convention as
|
||||||
|
// every other popup-having module here (Volume.qml, SystemUpdate.qml,
|
||||||
|
// etc.), so the panel opens in a fixed spot rather than following this
|
||||||
|
// icon's exact position in its RowLayout.
|
||||||
|
required property var barWindow
|
||||||
|
|
||||||
|
readonly property bool busy: WeatherData.locating || WeatherData.loading
|
||||||
|
|
||||||
|
// Local to this popup instance — unlike WeatherData's own state, there's
|
||||||
|
// no need to share an in-progress search across monitors' bars.
|
||||||
|
property bool searchOpen: false
|
||||||
|
property string searchQuery: ""
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
id: row
|
||||||
|
anchors.centerIn: parent
|
||||||
|
spacing: 4
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: label
|
||||||
|
font.family: Appearance.iconFontFamilyRounded
|
||||||
|
font.variableAxes: Appearance.iconFontAxes
|
||||||
|
font.pixelSize: 18
|
||||||
|
color: Colors.foreground
|
||||||
|
// Same spinner treatment as SystemUpdate.qml/
|
||||||
|
// BluetoothIndicator.qml's scan spinner while the location or
|
||||||
|
// conditions are still loading.
|
||||||
|
text: root.busy ? "progress_activity" : (WeatherData.hasLocation ? WeatherData.iconFor(WeatherData.weatherCode, WeatherData.isDay) : "cloud_off")
|
||||||
|
|
||||||
|
RotationAnimation on rotation {
|
||||||
|
running: root.busy
|
||||||
|
loops: Animation.Infinite
|
||||||
|
from: 0
|
||||||
|
to: 360
|
||||||
|
duration: 900
|
||||||
|
onRunningChanged: if (!running) label.rotation = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
visible: WeatherData.hasLocation && !WeatherData.locating
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSizeSmall
|
||||||
|
color: Colors.foreground
|
||||||
|
text: Math.round(WeatherData.tempC) + "°"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
anchors.fill: parent
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
||||||
|
onClicked: (mouse) => {
|
||||||
|
if (mouse.button === Qt.RightButton) {
|
||||||
|
WeatherData.refresh()
|
||||||
|
} else {
|
||||||
|
panel.visible = !panel.visible
|
||||||
|
grab.active = panel.visible
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PopupWindow {
|
||||||
|
id: panel
|
||||||
|
visible: false
|
||||||
|
color: "transparent"
|
||||||
|
// Same contentWidth/contentPadding split as the other panels
|
||||||
|
// (Volume.qml, SystemUpdate.qml, etc.) rather than a single magic
|
||||||
|
// implicitWidth.
|
||||||
|
readonly property int contentWidth: 220
|
||||||
|
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. Bottom alone (no Left/Right)
|
||||||
|
// anchors to the bar's bottom-center, which lines up with this
|
||||||
|
// icon since it sits in the bar's centered RowLayout.
|
||||||
|
rect.x: 0
|
||||||
|
rect.y: 0
|
||||||
|
rect.width: root.barWindow.width
|
||||||
|
rect.height: root.barWindow.height
|
||||||
|
edges: Edges.Bottom
|
||||||
|
gravity: Edges.Bottom
|
||||||
|
margins.bottom: -4
|
||||||
|
}
|
||||||
|
|
||||||
|
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: label.text
|
||||||
|
font.family: Appearance.iconFontFamilyRounded
|
||||||
|
font.variableAxes: Appearance.iconFontAxes
|
||||||
|
font.pixelSize: 28
|
||||||
|
color: Colors.foreground
|
||||||
|
}
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 0
|
||||||
|
|
||||||
|
Text {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
elide: Text.ElideRight
|
||||||
|
text: {
|
||||||
|
if (WeatherData.locating) return "Locating…"
|
||||||
|
if (!WeatherData.hasLocation) return "Location unavailable"
|
||||||
|
return WeatherData.locationName || "Weather"
|
||||||
|
}
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: 18
|
||||||
|
font.bold: true
|
||||||
|
color: Colors.foreground
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
visible: WeatherData.hasLocation && !WeatherData.locating
|
||||||
|
text: WeatherData.loading ? "Updating…" : "Feels like " + Math.round(WeatherData.feelsLikeC) + "°"
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSizeSmall
|
||||||
|
color: Colors.muted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
visible: WeatherData.hasLocation && !WeatherData.locating
|
||||||
|
text: Math.round(WeatherData.tempC) + "°"
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: 28
|
||||||
|
font.bold: true
|
||||||
|
color: Colors.foreground
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggles the search box below — same tap target size
|
||||||
|
// and hover feedback as the picker-target rows in
|
||||||
|
// VpnIndicator.qml.
|
||||||
|
Text {
|
||||||
|
text: root.searchOpen ? "close" : "edit_location"
|
||||||
|
font.family: Appearance.iconFontFamilyRounded
|
||||||
|
font.variableAxes: Appearance.iconFontAxes
|
||||||
|
font.pixelSize: 16
|
||||||
|
color: editLocationMouse.containsMouse ? Colors.primary : Colors.muted
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: editLocationMouse
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: -4
|
||||||
|
hoverEnabled: true
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onClicked: {
|
||||||
|
root.searchOpen = !root.searchOpen
|
||||||
|
root.searchQuery = ""
|
||||||
|
WeatherData.searchResults = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// City search — always available regardless of how the
|
||||||
|
// current location was resolved (IP guess or a previous
|
||||||
|
// search), so a wrong auto-detected city is one click away
|
||||||
|
// from being fixed. Same search-box + result-list shape as
|
||||||
|
// VpnIndicator.qml's Mullvad location picker.
|
||||||
|
Column {
|
||||||
|
visible: root.searchOpen
|
||||||
|
width: parent.width
|
||||||
|
spacing: 6
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: parent.width
|
||||||
|
implicitHeight: 24
|
||||||
|
radius: 0
|
||||||
|
color: Colors.surface
|
||||||
|
border.color: Colors.muted
|
||||||
|
border.width: 1
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 6
|
||||||
|
visible: citySearchInput.text.length === 0
|
||||||
|
text: "Search city…"
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSizeSmall
|
||||||
|
color: Colors.muted
|
||||||
|
}
|
||||||
|
|
||||||
|
TextInput {
|
||||||
|
id: citySearchInput
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 6
|
||||||
|
color: Colors.foreground
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSizeSmall
|
||||||
|
clip: true
|
||||||
|
focus: root.searchOpen
|
||||||
|
text: root.searchQuery
|
||||||
|
onTextChanged: {
|
||||||
|
root.searchQuery = text
|
||||||
|
searchDebounce.restart()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: searchDebounce
|
||||||
|
interval: 400
|
||||||
|
onTriggered: WeatherData.search(root.searchQuery)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
visible: WeatherData.searching
|
||||||
|
text: "Searching…"
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSizeSmall
|
||||||
|
color: Colors.muted
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
visible: !WeatherData.searching && citySearchInput.text.length >= 2 && WeatherData.searchResults.length === 0
|
||||||
|
text: "No matches"
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSizeSmall
|
||||||
|
color: Colors.muted
|
||||||
|
}
|
||||||
|
|
||||||
|
Flickable {
|
||||||
|
width: parent.width
|
||||||
|
height: Math.min(resultsColumn.implicitHeight, 150)
|
||||||
|
contentWidth: width
|
||||||
|
contentHeight: resultsColumn.implicitHeight
|
||||||
|
clip: true
|
||||||
|
boundsBehavior: Flickable.StopAtBounds
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: resultsColumn
|
||||||
|
width: parent.width
|
||||||
|
spacing: 1
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: WeatherData.searchResults
|
||||||
|
|
||||||
|
delegate: Rectangle {
|
||||||
|
required property var modelData
|
||||||
|
width: parent.width
|
||||||
|
implicitHeight: resultText.implicitHeight + 8
|
||||||
|
radius: 0
|
||||||
|
color: resultMouse.containsMouse ? Colors.primary : "transparent"
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: resultText
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
anchors.margins: 4
|
||||||
|
elide: Text.ElideRight
|
||||||
|
text: modelData.description ? (modelData.name + " — " + modelData.description) : modelData.name
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSizeSmall
|
||||||
|
color: resultMouse.containsMouse ? Colors.primaryText : Colors.foreground
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: resultMouse
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onClicked: {
|
||||||
|
WeatherData.selectLocation(modelData.name, modelData.latitude, modelData.longitude)
|
||||||
|
root.searchOpen = false
|
||||||
|
root.searchQuery = ""
|
||||||
|
WeatherData.searchResults = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Humidity / wind — same two-stat layout, just laid out as
|
||||||
|
// a row of icon+value pairs rather than full sentences.
|
||||||
|
RowLayout {
|
||||||
|
visible: WeatherData.hasLocation && !WeatherData.locating
|
||||||
|
width: parent.width
|
||||||
|
spacing: 16
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
spacing: 4
|
||||||
|
Text {
|
||||||
|
text: "water_drop"
|
||||||
|
font.family: Appearance.iconFontFamilyRounded
|
||||||
|
font.variableAxes: Appearance.iconFontAxes
|
||||||
|
font.pixelSize: 14
|
||||||
|
color: Colors.muted
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: WeatherData.humidity + "%"
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSizeSmall
|
||||||
|
color: Colors.foreground
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
spacing: 4
|
||||||
|
Text {
|
||||||
|
text: "air"
|
||||||
|
font.family: Appearance.iconFontFamilyRounded
|
||||||
|
font.variableAxes: Appearance.iconFontAxes
|
||||||
|
font.pixelSize: 14
|
||||||
|
color: Colors.muted
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: Math.round(WeatherData.windKmh) + " km/h"
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSizeSmall
|
||||||
|
color: Colors.foreground
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
visible: WeatherData.forecast.length > 0
|
||||||
|
width: parent.width
|
||||||
|
height: 1
|
||||||
|
color: Colors.muted
|
||||||
|
}
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
visible: WeatherData.forecast.length > 0
|
||||||
|
width: parent.width
|
||||||
|
spacing: 4
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: WeatherData.forecast
|
||||||
|
|
||||||
|
delegate: ColumnLayout {
|
||||||
|
required property var modelData
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 2
|
||||||
|
|
||||||
|
Text {
|
||||||
|
Layout.alignment: Qt.AlignHCenter
|
||||||
|
text: Qt.formatDateTime(new Date(modelData.date + "T12:00:00"), "ddd")
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSizeSmall
|
||||||
|
color: Colors.muted
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
Layout.alignment: Qt.AlignHCenter
|
||||||
|
text: WeatherData.iconFor(modelData.code, true)
|
||||||
|
font.family: Appearance.iconFontFamilyRounded
|
||||||
|
font.variableAxes: Appearance.iconFontAxes
|
||||||
|
font.pixelSize: 18
|
||||||
|
color: Colors.foreground
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
Layout.alignment: Qt.AlignHCenter
|
||||||
|
text: Math.round(modelData.maxC) + "°/" + Math.round(modelData.minC) + "°"
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSizeSmall
|
||||||
|
color: Colors.foreground
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,7 +53,7 @@ PanelWindow {
|
|||||||
spacing: 12
|
spacing: 12
|
||||||
|
|
||||||
Text {
|
Text {
|
||||||
font.family: Appearance.iconFontFamily
|
font.family: Appearance.iconFontFamilyRounded
|
||||||
font.variableAxes: Appearance.iconFontAxes
|
font.variableAxes: Appearance.iconFontAxes
|
||||||
font.pixelSize: 18
|
font.pixelSize: 18
|
||||||
color: Colors.foreground
|
color: Colors.foreground
|
||||||
|
|||||||
259
dotfiles/.config/quickshell/scripts/CheckQuickshellSetup.sh
Executable file
259
dotfiles/.config/quickshell/scripts/CheckQuickshellSetup.sh
Executable file
@@ -0,0 +1,259 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Pre-flight check for everything this quickshell config depends on outside
|
||||||
|
# its own QML — fonts, the compositor, backing D-Bus services, and the
|
||||||
|
# external CLIs individual bar modules shell out to. Run this after
|
||||||
|
# installing/stowing quickshell on a new machine, before assuming the bar
|
||||||
|
# is just going to work.
|
||||||
|
#
|
||||||
|
# Usage: ./CheckQuickshellSetup.sh [path-to-quickshell-config]
|
||||||
|
# Defaults to $XDG_CONFIG_HOME/quickshell or ~/.config/quickshell.
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
QS_DIR="${1:-${XDG_CONFIG_HOME:-$HOME/.config}/quickshell}"
|
||||||
|
|
||||||
|
PASS=0
|
||||||
|
WARN=0
|
||||||
|
FAIL=0
|
||||||
|
|
||||||
|
c_green="\033[0;32m"; c_yellow="\033[0;33m"; c_red="\033[0;31m"; c_reset="\033[0m"
|
||||||
|
|
||||||
|
pass() { printf " ${c_green}✓${c_reset} %s\n" "$1"; PASS=$((PASS+1)); }
|
||||||
|
warn() { printf " ${c_yellow}!${c_reset} %s\n" "$1"; WARN=$((WARN+1)); }
|
||||||
|
fail() { printf " ${c_red}✗${c_reset} %s\n" "$1"; FAIL=$((FAIL+1)); }
|
||||||
|
section() { printf "\n%s\n" "$1"; }
|
||||||
|
|
||||||
|
svc_state() {
|
||||||
|
# active / inactive / missing / no-systemctl. $1 = unit, $2 = --user (optional)
|
||||||
|
# NB: capture-then-here-string, not `cmd | grep -q`. Under `set -o
|
||||||
|
# pipefail`, grep -q exits the instant it finds a match, SIGPIPE-ing a
|
||||||
|
# still-writing producer — that producer's non-zero exit (not grep's
|
||||||
|
# success) becomes the pipeline's status, which reads as "not found"
|
||||||
|
# even when it was. Verified this actually happens with fc-list below.
|
||||||
|
if ! command -v systemctl >/dev/null 2>&1; then echo "no-systemctl"; return; fi
|
||||||
|
local unit_files
|
||||||
|
unit_files=$(systemctl ${2:-} list-unit-files "$1" 2>/dev/null)
|
||||||
|
if grep -q "$1" <<< "$unit_files"; then
|
||||||
|
systemctl ${2:-} is-active "$1" 2>/dev/null || echo "inactive"
|
||||||
|
else
|
||||||
|
echo "missing"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- 0. Quickshell itself + config files present ----------------------------
|
||||||
|
|
||||||
|
section "Quickshell core"
|
||||||
|
|
||||||
|
if command -v quickshell >/dev/null 2>&1; then
|
||||||
|
ver=$(quickshell --version 2>&1 | head -1)
|
||||||
|
pass "quickshell found ($(command -v quickshell)) — $ver"
|
||||||
|
else
|
||||||
|
fail "quickshell binary not found on PATH — install it before anything else here matters"
|
||||||
|
fi
|
||||||
|
|
||||||
|
for f in "shell.qml" "config/Appearance.qml" "config/Colors.qml" "config/qmldir"; do
|
||||||
|
if [ -f "$QS_DIR/$f" ]; then
|
||||||
|
pass "found $f"
|
||||||
|
else
|
||||||
|
fail "missing $QS_DIR/$f — dotfiles not fully stowed/symlinked here?"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Nearly every module (ActiveWindow, Workspaces, PowerButton, PanelWindow's
|
||||||
|
# layer-shell surfaces, HyprlandFocusGrab) assumes a live Hyprland session.
|
||||||
|
if [ -n "${HYPRLAND_INSTANCE_SIGNATURE:-}" ] && command -v hyprctl >/dev/null 2>&1 && hyprctl monitors >/dev/null 2>&1; then
|
||||||
|
pass "Hyprland is the running compositor"
|
||||||
|
else
|
||||||
|
fail "Hyprland doesn't appear to be running — Quickshell.Hyprland and the layer-shell bar will not function"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- 1. Fonts -----------------------------------------------------------------
|
||||||
|
|
||||||
|
section "Fonts"
|
||||||
|
|
||||||
|
if command -v fc-list >/dev/null 2>&1; then
|
||||||
|
# Captured once, then grepped via here-string — see the pipefail/SIGPIPE
|
||||||
|
# note on svc_state() above; fc-list's output is exactly what triggers it.
|
||||||
|
fc_out=$(fc-list)
|
||||||
|
|
||||||
|
if grep -qi "Material Symbols Outlined" <<< "$fc_out"; then
|
||||||
|
pass "Material Symbols Outlined installed"
|
||||||
|
else
|
||||||
|
fail "Material Symbols Outlined not found — install 'ttf-material-symbols-variable' (AUR); every glyph icon in the bar/panels will render as fallback text without it"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if grep -qi "CaskaydiaCove Nerd Font Mono" <<< "$fc_out"; then
|
||||||
|
pass "CaskaydiaCove Nerd Font Mono installed"
|
||||||
|
else
|
||||||
|
fail "CaskaydiaCove Nerd Font Mono not found — install a Cascadia Code Nerd Font package; clock/labels/window title use it"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
warn "fc-list not found (fontconfig missing?) — can't verify fonts"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- 2. Backing services for native Quickshell modules ----------------------
|
||||||
|
# These modules talk to system services directly over D-Bus rather than
|
||||||
|
# shelling out — the service being up is the actual dependency, not a CLI.
|
||||||
|
|
||||||
|
section "Backing services (native QML modules)"
|
||||||
|
|
||||||
|
st=$(svc_state NetworkManager.service)
|
||||||
|
case "$st" in
|
||||||
|
active) pass "NetworkManager.service active — Quickshell.Networking (NetworkIndicator)" ;;
|
||||||
|
missing) fail "NetworkManager not installed — required by NetworkIndicator.qml" ;;
|
||||||
|
*) fail "NetworkManager.service is $st — run: sudo systemctl enable --now NetworkManager" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
st=$(svc_state bluetooth.service)
|
||||||
|
case "$st" in
|
||||||
|
active) pass "bluetooth.service active — Quickshell.Bluetooth (BluetoothIndicator)" ;;
|
||||||
|
missing) fail "bluez not installed — required by BluetoothIndicator.qml" ;;
|
||||||
|
*) fail "bluetooth.service is $st — run: sudo systemctl enable --now bluetooth" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Pipewire/wireplumber are user-session services, not system ones.
|
||||||
|
st=$(svc_state pipewire.service --user)
|
||||||
|
case "$st" in
|
||||||
|
active) pass "pipewire.service (user) active — Quickshell.Services.Pipewire (Volume/VolumeOsd)" ;;
|
||||||
|
missing) fail "pipewire not installed — required by Volume.qml/VolumeOsd.qml" ;;
|
||||||
|
*) fail "pipewire.service (user) is $st — run: systemctl --user enable --now pipewire pipewire-pulse wireplumber" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
st=$(svc_state wireplumber.service --user)
|
||||||
|
case "$st" in
|
||||||
|
active) pass "wireplumber.service (user) active" ;;
|
||||||
|
missing) warn "wireplumber not installed — pipewire falls back to pipewire-media-session if present, otherwise volume control may misbehave" ;;
|
||||||
|
*) fail "wireplumber.service (user) is $st — run: systemctl --user enable --now wireplumber" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# --- 3. External CLIs shelled out to via Process ----------------------------
|
||||||
|
|
||||||
|
section "External CLIs (Quickshell.Io Process calls)"
|
||||||
|
|
||||||
|
check_bin() {
|
||||||
|
# $1 = binary, $2 = which module(s) use it, $3 = fail|warn on missing
|
||||||
|
if command -v "$1" >/dev/null 2>&1; then
|
||||||
|
pass "$1 found — used by $2"
|
||||||
|
elif [ "$3" = "warn" ]; then
|
||||||
|
warn "$1 not found — used by $2 (optional on this machine)"
|
||||||
|
else
|
||||||
|
fail "$1 not found — used by $2"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
check_bin nmcli "NetworkIndicator.qml (DNS provider panel, connection queries)" fail
|
||||||
|
check_bin ip "NetworkIndicator.qml (gateway/IP lookups)" fail
|
||||||
|
check_bin ping "NetworkIndicator.qml (latency/packet-loss stat)" fail
|
||||||
|
check_bin cat "NetworkIndicator.qml (/sys/class/net throughput counters)" fail
|
||||||
|
check_bin systemctl "PowerButton.qml (suspend/hibernate/reboot/poweroff)" fail
|
||||||
|
check_bin tailscale "TailscaleIndicator.qml / VpnExclusivity" warn
|
||||||
|
check_bin mullvad "VpnIndicator.qml / VpnExclusivity" warn
|
||||||
|
check_bin protonvpn "VpnIndicator.qml / VpnExclusivity" warn
|
||||||
|
|
||||||
|
have_tailscale=0; have_mullvad=0; have_proton=0
|
||||||
|
command -v tailscale >/dev/null 2>&1 && have_tailscale=1
|
||||||
|
command -v mullvad >/dev/null 2>&1 && have_mullvad=1
|
||||||
|
command -v protonvpn >/dev/null 2>&1 && have_proton=1
|
||||||
|
|
||||||
|
if [ $have_mullvad -eq 0 ] && [ $have_proton -eq 0 ]; then
|
||||||
|
warn "neither Mullvad nor ProtonVPN CLI is installed — VpnIndicator.qml will show both as 'Not installed'"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- 4. VPN daemons + functional status (Mullvad/ProtonVPN/Tailscale) -------
|
||||||
|
# The exclusivity logic (VpnExclusivity.qml backing off Tailscale when a
|
||||||
|
# commercial VPN connects) is pure QML and ships with the dotfiles — it
|
||||||
|
# needs no setup of its own. What varies per machine is whether these three
|
||||||
|
# are actually installed, enabled, and callable without a password prompt.
|
||||||
|
|
||||||
|
section "VPN dual-setup (Mullvad/ProtonVPN + Tailscale)"
|
||||||
|
|
||||||
|
if [ $have_tailscale -eq 1 ]; then
|
||||||
|
st=$(svc_state tailscaled.service)
|
||||||
|
case "$st" in
|
||||||
|
active) pass "tailscaled.service is active" ;;
|
||||||
|
missing) fail "tailscaled.service not found — reinstall tailscale" ;;
|
||||||
|
*) fail "tailscaled.service is $st — run: sudo systemctl enable --now tailscaled" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if out=$(timeout 5 tailscale status --json 2>&1); then
|
||||||
|
if python3 -c "import json,sys; json.load(sys.stdin)" >/dev/null 2>&1 <<< "$out"; then
|
||||||
|
pass "tailscale status --json returns valid JSON"
|
||||||
|
else
|
||||||
|
warn "tailscale status --json returned non-JSON output"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
fail "tailscale status --json failed: $(echo "$out" | head -1)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ $have_mullvad -eq 1 ]; then
|
||||||
|
st=$(svc_state mullvad-daemon.service)
|
||||||
|
case "$st" in
|
||||||
|
active) pass "mullvad-daemon.service is active" ;;
|
||||||
|
missing) fail "mullvad-daemon.service not found — reinstall the Mullvad package" ;;
|
||||||
|
*) fail "mullvad-daemon.service is $st — run: sudo systemctl enable --now mullvad-daemon" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if out=$(timeout 5 mullvad status --json 2>&1); then
|
||||||
|
if python3 -c "import json,sys; json.load(sys.stdin)" >/dev/null 2>&1 <<< "$out"; then
|
||||||
|
pass "mullvad status --json returns valid JSON"
|
||||||
|
else
|
||||||
|
warn "mullvad status --json returned non-JSON output"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
fail "mullvad status --json failed: $(echo "$out" | head -1)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ $have_proton -eq 1 ]; then
|
||||||
|
# ProtonVPN's CLI drives connections through NetworkManager rather than
|
||||||
|
# its own daemon — already checked above, just re-verified here in context.
|
||||||
|
if out=$(timeout 5 protonvpn status 2>&1); then
|
||||||
|
if grep -qi "^Status:" <<< "$out"; then
|
||||||
|
pass "protonvpn status returns a parseable 'Status:' line"
|
||||||
|
else
|
||||||
|
warn "protonvpn status has no 'Status:' line — applyProtonStatus() in VpnIndicator.qml may misread it: $(echo "$out" | head -1)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
fail "protonvpn status failed: $(echo "$out" | head -1)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
ts_up=0; mv_up=0; pv_up=0
|
||||||
|
# Same capture-then-here-string reasoning as above — these outputs are small
|
||||||
|
# enough that the SIGPIPE race is unlikely, but there's no upside to relying
|
||||||
|
# on that instead of just being consistent.
|
||||||
|
[ $have_tailscale -eq 1 ] && grep -q '"BackendState":"Running"' <<< "$(tailscale status --json 2>/dev/null)" && ts_up=1
|
||||||
|
[ $have_mullvad -eq 1 ] && grep -qi "^Connected" <<< "$(mullvad status 2>/dev/null)" && mv_up=1
|
||||||
|
[ $have_proton -eq 1 ] && grep -qi "^Status:\s*Connected" <<< "$(protonvpn status 2>/dev/null)" && pv_up=1
|
||||||
|
|
||||||
|
active_count=$((ts_up + mv_up + pv_up))
|
||||||
|
if [ $active_count -le 1 ]; then
|
||||||
|
pass "at most one tunnel active right now (tailscale=$ts_up mullvad=$mv_up protonvpn=$pv_up)"
|
||||||
|
else
|
||||||
|
warn "more than one tunnel is up right now (tailscale=$ts_up mullvad=$mv_up protonvpn=$pv_up) — bring extras down manually; quickshell only enforces exclusivity when you toggle from its own UI"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- 5. System tray (currently disabled in Bar.qml) -------------------------
|
||||||
|
|
||||||
|
section "System tray (Tray.qml)"
|
||||||
|
|
||||||
|
if grep -q "^\s*//\s*Tray\s*{}" "$QS_DIR/modules/bar/Bar.qml" 2>/dev/null; then
|
||||||
|
warn "Tray {} is commented out in Bar.qml — skipping icon-theme check (nothing to render)"
|
||||||
|
elif [ -f "$QS_DIR/modules/bar/Bar.qml" ]; then
|
||||||
|
if command -v gtk-update-icon-cache >/dev/null 2>&1 || [ -d /usr/share/icons/hicolor ]; then
|
||||||
|
pass "an icon theme (at least hicolor) is present for Quickshell.Services.SystemTray icon lookups"
|
||||||
|
else
|
||||||
|
warn "no icon theme found — tray icons may render blank"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Summary -----------------------------------------------------------------
|
||||||
|
|
||||||
|
section "Summary"
|
||||||
|
printf " ${c_green}%d passed${c_reset}, ${c_yellow}%d warnings${c_reset}, ${c_red}%d failed${c_reset}\n" "$PASS" "$WARN" "$FAIL"
|
||||||
|
|
||||||
|
if [ $FAIL -gt 0 ]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
62
dotfiles/.config/quickshell/scripts/update-system.sh
Executable file
62
dotfiles/.config/quickshell/scripts/update-system.sh
Executable file
@@ -0,0 +1,62 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Launched by SystemUpdate.qml (top bar, next to the clock) — opens a
|
||||||
|
# terminal running whatever update command fits this distro, then notifies
|
||||||
|
# once it's done. Ported from the old waybar custom/updater module
|
||||||
|
# (.config/waybar/scripts/update-distro.sh); kept multi-distro even though
|
||||||
|
# this machine is Arch, since these dotfiles aren't.
|
||||||
|
#
|
||||||
|
# The window it opens is floated + centered by a matching Hyprland rule
|
||||||
|
# (system-update-terminal, in .config/hypr/UserConfigs/WindowRules.conf),
|
||||||
|
# same treatment as Bitwarden's window, rather than tiling into whatever
|
||||||
|
# workspace you happened to be on.
|
||||||
|
|
||||||
|
iDIR="$HOME/.config/swaync/images"
|
||||||
|
|
||||||
|
if ! command -v foot &> /dev/null; then
|
||||||
|
notify-send -i "$iDIR/error.png" "Need foot:" "foot terminal not found. Please install foot terminal."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Printed inside the terminal before the actual update command runs, so it
|
||||||
|
# reads as "updating" from the first frame rather than looking like an
|
||||||
|
# empty/stuck window while pacman/apt/etc. take a moment to start printing.
|
||||||
|
#
|
||||||
|
# foot runs this in a fresh `bash -c`, which doesn't inherit shell functions
|
||||||
|
# from this script's own environment (export -f only reaches bash processes
|
||||||
|
# forked from *this* one, not whatever foot execs) — so instead, the
|
||||||
|
# already-colored text is built once here with real escape bytes ($'...'),
|
||||||
|
# then %q-quoted into a self-contained `echo '...'` that reproduces those
|
||||||
|
# same bytes verbatim when the child bash parses it.
|
||||||
|
msg=" Updating System — please wait "
|
||||||
|
line=$(printf '=%.0s' $(seq 1 ${#msg}))
|
||||||
|
banner_text=$'\033[1;36m'"$line"$'\033[0m\n\033[1;36m'"$msg"$'\033[0m\n\033[1;36m'"$line"$'\033[0m\n\n'
|
||||||
|
BANNER="echo $(printf '%q' "$banner_text")"
|
||||||
|
|
||||||
|
if command -v paru &> /dev/null; then
|
||||||
|
CMD="paru -Syu"
|
||||||
|
LABEL="Arch-based system"
|
||||||
|
elif command -v yay &> /dev/null; then
|
||||||
|
CMD="yay -Syu"
|
||||||
|
LABEL="Arch-based system"
|
||||||
|
elif command -v dnf &> /dev/null; then
|
||||||
|
CMD="sudo dnf update --refresh -y"
|
||||||
|
LABEL="Fedora system"
|
||||||
|
elif command -v apt &> /dev/null; then
|
||||||
|
CMD="sudo apt update && sudo apt upgrade -y"
|
||||||
|
LABEL="Debian/Ubuntu system"
|
||||||
|
elif command -v zypper &> /dev/null; then
|
||||||
|
CMD="sudo zypper dup -y"
|
||||||
|
LABEL="openSUSE system"
|
||||||
|
else
|
||||||
|
notify-send -i "$iDIR/error.png" -u critical "Unsupported system" "This script does not support your distribution."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Keeps the terminal open (whether the update succeeded or failed — read
|
||||||
|
# always runs regardless, since it's ; not &&) until you press Enter,
|
||||||
|
# instead of foot closing the instant the update command exits and taking
|
||||||
|
# any output/errors with it.
|
||||||
|
WAIT_PROMPT="echo; read -p 'Update finished — press Enter to close...'"
|
||||||
|
|
||||||
|
foot -T update bash -c "$BANNER; $CMD; $WAIT_PROMPT"
|
||||||
|
notify-send -i "$iDIR/profile-pic.jpg" -u low "$LABEL" 'has been updated.'
|
||||||
@@ -4,7 +4,11 @@ import "./modules/osd"
|
|||||||
|
|
||||||
ShellRoot {
|
ShellRoot {
|
||||||
Variants {
|
Variants {
|
||||||
model: Quickshell.screens
|
// Exclude the Sunshine headless virtual monitor — nobody ever sees a
|
||||||
|
// bar there, but PanelWindow's exclusiveZone still reserves space for
|
||||||
|
// it, which left a black strip cut out of Steam Big Picture's
|
||||||
|
// fullscreen area on that output.
|
||||||
|
model: Quickshell.screens.filter(s => !s.name.startsWith("HEADLESS"))
|
||||||
|
|
||||||
Bar {
|
Bar {
|
||||||
required property var modelData
|
required property var modelData
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
"tooltip-format": "{ipaddr} via {gwaddr}",
|
"tooltip-format": "{ipaddr} via {gwaddr}",
|
||||||
"tooltip-format-disconnected": "Disconnected",
|
"tooltip-format-disconnected": "Disconnected",
|
||||||
"max-length": 50,
|
"max-length": 50,
|
||||||
"on-click": "orbit toggle top-right",
|
"on-click": "nm-connection-editor",
|
||||||
"on-click-right": "kitty -e nmtui",
|
"on-click-right": "kitty -e nmtui",
|
||||||
"format-icons": {
|
"format-icons": {
|
||||||
"wifi": ["", "", "", "", ""],
|
"wifi": ["", "", "", "", ""],
|
||||||
|
|||||||
13
dotfiles/.local/bin/Plex
Executable file
13
dotfiles/.local/bin/Plex
Executable file
@@ -0,0 +1,13 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Wrapper for the plex-desktop package's /usr/bin/Plex (-> /opt/plex-desktop/Plex.sh).
|
||||||
|
#
|
||||||
|
# QT_QUICK_CONTROLS_STYLE=org.hyprland.style is set globally in
|
||||||
|
# hypr/core/env.lua for other Qt/QML apps' theming (hyprland-qt-support), but
|
||||||
|
# Plex Desktop bundles its own private Qt/QML runtime that doesn't ship that
|
||||||
|
# style plugin — inheriting the var makes it fail to load its UI and exit
|
||||||
|
# immediately with "module \"org.hyprland.style\" is not installed" (see
|
||||||
|
# ~/.local/share/plex/Logs/Plex.log). This dir is first on $PATH, so it
|
||||||
|
# shadows /usr/bin/Plex for both a terminal launch and the .desktop entry
|
||||||
|
# (Exec=Plex is PATH-resolved) without touching the global env var.
|
||||||
|
unset QT_QUICK_CONTROLS_STYLE
|
||||||
|
exec /opt/plex-desktop/Plex.sh "$@"
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# User override of /usr/share/applications/tv.plex.PlexDesktop.desktop
|
||||||
|
# (~/.local/share/applications takes XDG precedence over /usr/share for the
|
||||||
|
# same filename, so this is picked up by app launchers instead automatically
|
||||||
|
# — no association/mimeapps changes needed).
|
||||||
|
#
|
||||||
|
# Only change from upstream: Exec unsets QT_QUICK_CONTROLS_STYLE and calls
|
||||||
|
# the package's Plex.sh directly, bypassing $PATH entirely. Plex Desktop
|
||||||
|
# bundles its own private Qt/QML runtime that doesn't ship the
|
||||||
|
# "org.hyprland.style" QQC2 style hypr/core/env.lua sets globally for other
|
||||||
|
# apps' theming — inheriting it makes Plex fail to load its UI and exit
|
||||||
|
# immediately with "module \"org.hyprland.style\" is not installed" (see
|
||||||
|
# ~/.local/share/plex/Logs/Plex.log). Going straight to the fixed install
|
||||||
|
# path (rather than relying on the .local/bin/Plex wrapper shadowing
|
||||||
|
# /usr/bin/Plex on $PATH) means this keeps working even if $PATH order ever
|
||||||
|
# changes.
|
||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=Plex
|
||||||
|
Comment=Plex client for desktop computers
|
||||||
|
Exec=env -u QT_QUICK_CONTROLS_STYLE /opt/plex-desktop/Plex.sh
|
||||||
|
Icon=tv.plex.PlexDesktop
|
||||||
|
Terminal=false
|
||||||
|
Categories=AudioVideo;Player;X-Media;
|
||||||
|
X-Flatpak=tv.plex.PlexDesktop
|
||||||
|
StartupWMClass=Plex
|
||||||
|
Name[en_US]=Plex
|
||||||
14
dotfiles/.ssh/config
Normal file
14
dotfiles/.ssh/config
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
Host 192.168.1.87
|
||||||
|
HostName 192.168.1.87
|
||||||
|
User thoams
|
||||||
|
ForwardAgent yes
|
||||||
|
|
||||||
|
Host 192.168.1.83
|
||||||
|
HostName 192.168.1.83
|
||||||
|
User thoams
|
||||||
|
ForwardAgent yes
|
||||||
|
|
||||||
|
Host 192.168.1.86
|
||||||
|
HostName 192.168.1.86
|
||||||
|
User thoams
|
||||||
|
ForwardAgent yes
|
||||||
@@ -23,7 +23,7 @@ source $ZSH/oh-my-zsh.sh
|
|||||||
|
|
||||||
# My alias
|
# My alias
|
||||||
|
|
||||||
alias cl='clear'
|
alias cl='clear && fastfetch'
|
||||||
alias zed='zeditor'
|
alias zed='zeditor'
|
||||||
alias ssh-gavin='ssh thoams@192.168.1.83'
|
alias ssh-gavin='ssh thoams@192.168.1.83'
|
||||||
alias ssh-raspberry-pi='ssh thoams@192.168.1.86'
|
alias ssh-raspberry-pi='ssh thoams@192.168.1.86'
|
||||||
@@ -51,3 +51,4 @@ export ZED_ALLOW_EMULATED_GPU=1
|
|||||||
fastfetch
|
fastfetch
|
||||||
|
|
||||||
eval "$(oh-my-posh init zsh --config $HOME/.config/ohmyposh/zen.toml)"
|
eval "$(oh-my-posh init zsh --config $HOME/.config/ohmyposh/zen.toml)"
|
||||||
|
export PATH="$HOME/.local/share/turritopsis/flutter-sdk/bin:$PATH"
|
||||||
|
|||||||
11
system/install.sh
Executable file
11
system/install.sh
Executable file
@@ -0,0 +1,11 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Installs system-level config that stow can't manage (needs root).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repo="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
|
||||||
|
# pacman: shared [options] snippet
|
||||||
|
sudo install -m644 "$repo/pacman/options.conf" /etc/pacman.d/options.conf
|
||||||
|
if ! grep -q '^Include = /etc/pacman.d/options.conf' /etc/pacman.conf; then
|
||||||
|
sudo sed -i '/^\[options\]/a Include = /etc/pacman.d/options.conf' /etc/pacman.conf
|
||||||
|
fi
|
||||||
5
system/pacman/options.conf
Normal file
5
system/pacman/options.conf
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Shared pacman [options], included from /etc/pacman.conf.
|
||||||
|
# Installed to /etc/pacman.d/options.conf by system/install.sh.
|
||||||
|
Color
|
||||||
|
#ILoveCandy
|
||||||
|
#VerbosePkgLists
|
||||||
Reference in New Issue
Block a user