Compare commits
25 Commits
features/m
...
ad5efc9597
| Author | SHA1 | Date | |
|---|---|---|---|
| ad5efc9597 | |||
| 850da708c2 | |||
| de39c40460 | |||
| 72db6ca784 | |||
| 84828e58bc | |||
| 26b7bf4ecc | |||
| 5b58c27e80 | |||
| 82be653c04 | |||
| d395a155af | |||
| 243c90a809 | |||
| 38df0301f2 | |||
| 714fadc997 | |||
| 99e2225b51 | |||
| 4aaa1d2d98 | |||
| 14b7e748d4 | |||
| a0b9ea29fe | |||
| 007843db6e | |||
| 764652d826 | |||
| 8dedfe30fa | |||
| bc54a4b5b7 | |||
| d306270dc8 | |||
| 03b043d9d3 | |||
| 1ff92f3b79 | |||
| c64cf5ff12 | |||
| 9e532023bd |
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/Pictures
|
||||
file:///home/thomas/Music
|
||||
file:///home/thomas/Videos
|
||||
smb://192.168.1.84/media/
|
||||
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
|
||||
# See hyprland wiki https://wiki.hyprland.org/Nvidia/#environment-variables
|
||||
|
||||
#env = LIBVA_DRIVER_NAME,nvidia
|
||||
#env = __GLX_VENDOR_LIBRARY_NAME,nvidia
|
||||
#env = NVD_BACKEND,direct
|
||||
#env = GSK_RENDERER,ngl
|
||||
env = LIBVA_DRIVER_NAME,nvidia
|
||||
env = __GLX_VENDOR_LIBRARY_NAME,nvidia
|
||||
env = NVD_BACKEND,direct
|
||||
env = GSK_RENDERER,ngl
|
||||
|
||||
# additional ENV's for nvidia. Caution, activate with care
|
||||
#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_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.
|
||||
|
||||
# Bitwarden SSH agent (so GUI apps like VS Code see it, not just shells)
|
||||
env = SSH_AUTH_SOCK,/home/thomas/.bitwarden-ssh-agent.sock
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
# <<<<< TUI AUTO START
|
||||
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: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
|
||||
|
||||
@@ -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.
|
||||
-- See hyprland wiki https://wiki.hyprland.org/Nvidia/#environment-variables
|
||||
|
||||
-- hl.env("LIBVA_DRIVER_NAME", "nvidia")
|
||||
-- hl.env("__GLX_VENDOR_LIBRARY_NAME", "nvidia")
|
||||
-- hl.env("NVD_BACKEND", "direct")
|
||||
-- hl.env("GSK_RENDERER", "ngl")
|
||||
hl.env("LIBVA_DRIVER_NAME", "nvidia")
|
||||
hl.env("__GLX_VENDOR_LIBRARY_NAME", "nvidia")
|
||||
hl.env("NVD_BACKEND", "direct")
|
||||
hl.env("GSK_RENDERER", "ngl")
|
||||
|
||||
-- additional ENV's for nvidia. Caution, activate with care
|
||||
-- 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_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.
|
||||
|
||||
-- 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…"
|
||||
-- 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({
|
||||
general = {
|
||||
allow_tearing = true,
|
||||
@@ -13,12 +18,12 @@ hl.config({
|
||||
},
|
||||
},
|
||||
misc = {
|
||||
vrr = 1,
|
||||
vrr = 0,
|
||||
},
|
||||
})
|
||||
|
||||
-- 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
|
||||
-- (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("systemctl --user import-environment WAYLAND_DISPLAY XDG_CURRENT_DESKTOP")
|
||||
|
||||
-- Sunshine
|
||||
hl.exec_cmd("sleep 1 && hyprctl output create headless")
|
||||
|
||||
-- Polkit (Polkit Gnome / KDE)
|
||||
hl.exec_cmd(scriptsDir .. "/Polkit.sh")
|
||||
|
||||
@@ -23,7 +26,7 @@ hl.on("hyprland.start", function()
|
||||
-- hl.exec_cmd("ags")
|
||||
-- hl.exec_cmd("blueman-applet")
|
||||
-- hl.exec_cmd("rog-control-center")
|
||||
hl.exec_cmd("waybar")
|
||||
-- hl.exec_cmd("waybar")
|
||||
hl.exec_cmd("qs") -- quickshell AGS Desktop Overview alternative
|
||||
|
||||
-- clipboard manager
|
||||
@@ -31,7 +34,10 @@ hl.on("hyprland.start", function()
|
||||
hl.exec_cmd("wl-paste --type image --watch cliphist store")
|
||||
|
||||
-- Starting hypridle to start hyprlock
|
||||
hl.exec_cmd("hypridle")
|
||||
-- hl.exec_cmd("hypridle")
|
||||
|
||||
-- Sunshine for streaming media
|
||||
h1.exec_cmd("sunshine")
|
||||
|
||||
-- Float + center the Bitwarden extension pop-out (window rules can't catch it — see the script)
|
||||
hl.exec_cmd(scriptsDir .. "/FloatBitwardenPopup.sh")
|
||||
|
||||
@@ -2,10 +2,15 @@
|
||||
|
||||
general:allow_tearing = 1
|
||||
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
|
||||
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
|
||||
unbind = SUPER SHIFT, Print
|
||||
|
||||
@@ -10,5 +10,6 @@
|
||||
-- <<<<< 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: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
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
hl.workspace_rule({ workspace = 1, monitor = "DP-2", 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 = 9, monitor = "HEADLESS-1", default = true })
|
||||
-- hl.workspace_rule({ workspace = 4, monitor = "eDP-1" })
|
||||
-- hl.workspace_rule({ workspace = 5, monitor = "DP-2" })
|
||||
-- hl.workspace_rule({ workspace = 6, monitor = "DP-2" })
|
||||
|
||||
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 |
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 Appearance 1.0 Appearance.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 {}
|
||||
}
|
||||
|
||||
Clock {
|
||||
RowLayout {
|
||||
anchors.centerIn: parent
|
||||
spacing: Appearance.spacing
|
||||
Weather { barWindow: bar }
|
||||
Clock {}
|
||||
SystemUpdate { barWindow: bar }
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
@@ -46,7 +50,7 @@ PanelWindow {
|
||||
rightMargin: Appearance.spacing
|
||||
}
|
||||
spacing: Appearance.spacing
|
||||
//Tray {}
|
||||
PinnedTray {}
|
||||
TailscaleIndicator { barWindow: bar }
|
||||
VpnIndicator { barWindow: bar }
|
||||
NetworkIndicator { barWindow: bar }
|
||||
|
||||
@@ -2,6 +2,7 @@ import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import Quickshell.Hyprland
|
||||
import Quickshell.Io
|
||||
import Quickshell.Services.UPower
|
||||
import "../../config"
|
||||
|
||||
@@ -14,7 +15,7 @@ import "../../config"
|
||||
// Nerd Font ones. The charge-threshold ("Holding"/limited-charge) concept from
|
||||
// the original is hardware-specific (vendor charge-limit sysfs knobs) and has
|
||||
// no equivalent in Quickshell's UPower binding, so it's dropped rather than
|
||||
// guessed at; "Battery health" (device.healthPercentage) is shown in its
|
||||
// guessed at; "Battery health" (computed from sysfs energy_full/design) is shown in its
|
||||
// place, using data UPower actually reports.
|
||||
Item {
|
||||
id: root
|
||||
@@ -51,10 +52,162 @@ Item {
|
||||
readonly property real fraction: root.batteryPresent ? Math.max(0, Math.min(1, root.device.percentage)) : 0
|
||||
readonly property bool low: root.batteryPresent && root.fraction <= 0.15 && !root.charging
|
||||
|
||||
// Quickshell's UPower binding has no charge-cycle property (see the
|
||||
// file-header comment on why the old charge-until threshold was
|
||||
// dropped) — it comes straight from sysfs instead.
|
||||
//
|
||||
// device.nativePath turned out to be empty on real hardware (tested
|
||||
// live), so rather than build a path from it, a shell glob finds
|
||||
// whichever BAT* directory actually exists — same shell-out-to-a-CLI
|
||||
// convention TailscaleIndicator.qml uses for its own live stats.
|
||||
// This barely changes during a session, so one read at startup is
|
||||
// enough; no polling timer.
|
||||
property int cycleCount: -1
|
||||
|
||||
Process {
|
||||
id: cycleCountProc
|
||||
command: ["sh", "-c", "cat \"$(ls -d /sys/class/power_supply/BAT* 2>/dev/null | head -n1)/cycle_count\" 2>/dev/null"]
|
||||
running: root.batteryPresent
|
||||
stdout: StdioCollector {
|
||||
id: cycleCountCollector
|
||||
onStreamFinished: {
|
||||
const n = parseInt(cycleCountCollector.text.trim(), 10)
|
||||
root.cycleCount = (isNaN(n) || n <= 0) ? -1 : n
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// device.healthPercentage/healthSupported come from upowerd over D-Bus,
|
||||
// and upowerd has been observed reporting energy-full as a flat 0 (a
|
||||
// live daemon glitch, `upower -i` shows it too) while sysfs still has
|
||||
// the real numbers underneath — same class of unreliability that put
|
||||
// cycle_count on sysfs above instead of trusting upowerd's own
|
||||
// charge-cycles field. Health is computed the same way UPower computes
|
||||
// it (current full-charge capacity over the battery's original design
|
||||
// capacity), just read directly so a daemon hiccup doesn't blank it.
|
||||
property real energyFullWh: -1
|
||||
property real energyFullDesignWh: -1
|
||||
readonly property real healthPercent: (root.energyFullWh > 0 && root.energyFullDesignWh > 0)
|
||||
? Math.min(100, (root.energyFullWh / root.energyFullDesignWh) * 100) : -1
|
||||
|
||||
Process {
|
||||
id: energyFullProc
|
||||
command: ["sh", "-c", "cat \"$(ls -d /sys/class/power_supply/BAT* 2>/dev/null | head -n1)/energy_full\" 2>/dev/null"]
|
||||
running: root.batteryPresent
|
||||
stdout: StdioCollector {
|
||||
id: energyFullCollector
|
||||
onStreamFinished: {
|
||||
const n = parseFloat(energyFullCollector.text.trim())
|
||||
root.energyFullWh = (isNaN(n) || n <= 0) ? -1 : (n / 1000000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: energyFullDesignProc
|
||||
command: ["sh", "-c", "cat \"$(ls -d /sys/class/power_supply/BAT* 2>/dev/null | head -n1)/energy_full_design\" 2>/dev/null"]
|
||||
running: root.batteryPresent
|
||||
stdout: StdioCollector {
|
||||
id: energyFullDesignCollector
|
||||
onStreamFinished: {
|
||||
const n = parseFloat(energyFullDesignCollector.text.trim())
|
||||
root.energyFullDesignWh = (isNaN(n) || n <= 0) ? -1 : (n / 1000000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 "battery_charging_full"
|
||||
if (root.charging) return root.chargingIcon(root.fraction)
|
||||
if (root.fraction <= 0.1) return "battery_alert"
|
||||
const bars = ["battery_0_bar", "battery_1_bar", "battery_2_bar", "battery_3_bar", "battery_4_bar", "battery_5_bar", "battery_full"]
|
||||
return bars[Math.max(0, Math.min(bars.length - 1, Math.round(root.fraction * (bars.length - 1))))]
|
||||
@@ -74,6 +227,32 @@ Item {
|
||||
return root.onBattery ? "On battery" : "Charging"
|
||||
}
|
||||
|
||||
// Info grid rows — same shape as NetworkIndicator.qml's statRows/
|
||||
// statCells: a flat label/value list, flattened again into individual
|
||||
// grid cells (label, value, label, value, …) so a 4-column GridLayout
|
||||
// lays them out as two label/value pairs per row.
|
||||
readonly property var statRows: [
|
||||
{
|
||||
label: "HEALTH",
|
||||
value: root.healthPercent > 0 ? Math.round(root.healthPercent) + "%" : "—"
|
||||
},
|
||||
{
|
||||
label: root.onBattery ? "TIME LEFT" : "TIME TO FULL",
|
||||
value: root.batteryPresent ? root.formatDuration(root.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.
|
||||
@@ -142,6 +321,34 @@ Item {
|
||||
return entries
|
||||
}
|
||||
|
||||
// --- Automatic power profile --------------------------------------
|
||||
// Battery-state-driven, on top of the manual picker above: below 20%
|
||||
// → Power Saver (protect what's left), plugged in → Performance,
|
||||
// unplugged and healthy → Balanced. Low battery wins even if a weak
|
||||
// charger is still plugged in — running low is the more urgent signal.
|
||||
// Performance only gets requested when the daemon actually reports it
|
||||
// as available, falling back to Balanced instead of erroring on
|
||||
// hardware without a performance profile (e.g. the ERROR this exact
|
||||
// widget hit before power-profiles-daemon was installed).
|
||||
//
|
||||
// This only ever writes when the *target* changes (crossing the 20%
|
||||
// line, plugging/unplugging) rather than on every poll, so a manual
|
||||
// pick from the buttons above still sticks until the next real
|
||||
// transition instead of being fought immediately.
|
||||
readonly property int desiredProfile: {
|
||||
if (root.fraction <= 0.20) return PowerProfile.PowerSaver
|
||||
if (!root.onBattery) return PowerProfiles.hasPerformanceProfile ? PowerProfile.Performance : PowerProfile.Balanced
|
||||
return PowerProfile.Balanced
|
||||
}
|
||||
|
||||
function applyDesiredProfile() {
|
||||
if (!root.batteryPresent) return
|
||||
if (PowerProfiles.profile !== root.desiredProfile) PowerProfiles.profile = root.desiredProfile
|
||||
}
|
||||
|
||||
onDesiredProfileChanged: root.applyDesiredProfile()
|
||||
Component.onCompleted: Qt.callLater(root.applyDesiredProfile)
|
||||
|
||||
Text {
|
||||
id: label
|
||||
anchors.centerIn: parent
|
||||
@@ -217,33 +424,16 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
component StatRow: RowLayout {
|
||||
id: statRow
|
||||
width: parent.width
|
||||
property string label: ""
|
||||
property string value: ""
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: statRow.label
|
||||
font.family: Appearance.fontFamily
|
||||
font.pixelSize: Appearance.fontSizeSmall
|
||||
color: Colors.muted
|
||||
}
|
||||
Text {
|
||||
text: statRow.value
|
||||
font.family: Appearance.fontFamily
|
||||
font.pixelSize: Appearance.fontSizeSmall
|
||||
color: Colors.muted
|
||||
}
|
||||
}
|
||||
|
||||
// --- Panel -----------------------------------------------------------
|
||||
|
||||
PopupWindow {
|
||||
id: panel
|
||||
visible: false
|
||||
color: "transparent"
|
||||
readonly property int contentWidth: 244
|
||||
// Wider than before (244) to give the 2-column info grid's
|
||||
// label/value pairs enough room per half without eliding —
|
||||
// same reasoning as NetworkIndicator.qml's own wider panel.
|
||||
readonly property int contentWidth: 280
|
||||
readonly property int contentPadding: 16
|
||||
implicitWidth: contentWidth + contentPadding * 2
|
||||
implicitHeight: mainColumn.implicitHeight + contentPadding * 2
|
||||
@@ -350,34 +540,27 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
// Stats
|
||||
RowLayout {
|
||||
// Stats — a 2-column, 2-row info grid, same layout
|
||||
// NetworkIndicator.qml uses for its own live stats: a
|
||||
// 4-column GridLayout fed a flattened label/value/label/value
|
||||
// list, so each row holds two label/value pairs side by side
|
||||
// with the values right-aligned against their own half.
|
||||
GridLayout {
|
||||
width: parent.width
|
||||
spacing: 16
|
||||
columns: 4
|
||||
columnSpacing: 12
|
||||
rowSpacing: 5
|
||||
|
||||
Column {
|
||||
Layout.fillWidth: true
|
||||
spacing: 4
|
||||
StatRow { label: "STATE"; value: root.modeLabel }
|
||||
StatRow {
|
||||
label: "HEALTH"
|
||||
value: (root.batteryPresent && root.device.healthSupported)
|
||||
? Math.round(root.device.healthPercentage) + "%" : "—"
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
Layout.fillWidth: true
|
||||
spacing: 4
|
||||
StatRow {
|
||||
label: root.onBattery ? "TIME LEFT" : "TIME TO FULL"
|
||||
value: root.batteryPresent
|
||||
? root.formatDuration(root.onBattery ? root.device.timeToEmpty : root.device.timeToFull)
|
||||
: "—"
|
||||
}
|
||||
StatRow {
|
||||
label: "RATE"
|
||||
value: root.batteryPresent ? Math.abs(root.device.changeRate).toFixed(1) + " W" : "—"
|
||||
Repeater {
|
||||
model: root.statCells
|
||||
Text {
|
||||
required property var modelData
|
||||
Layout.fillWidth: !modelData.isLabel
|
||||
horizontalAlignment: modelData.isLabel ? Text.AlignLeft : Text.AlignRight
|
||||
text: modelData.text
|
||||
font.family: Appearance.fontFamily
|
||||
font.pixelSize: Appearance.fontSizeSmall
|
||||
color: modelData.isLabel ? Colors.muted : Colors.foreground
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
103
dotfiles/.config/quickshell/modules/bar/PinnedTray.qml
Normal file
103
dotfiles/.config/quickshell/modules/bar/PinnedTray.qml
Normal file
@@ -0,0 +1,103 @@
|
||||
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: "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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import Quickshell.Widgets
|
||||
import "../../config"
|
||||
|
||||
RowLayout {
|
||||
spacing: Appearance.spacing * 0.6
|
||||
spacing: Appearance.spacing
|
||||
|
||||
Repeater {
|
||||
model: SystemTray.items
|
||||
|
||||
@@ -45,9 +45,41 @@ Item {
|
||||
// to connect once the current one has finished disconnecting.
|
||||
property string pendingConnect: ""
|
||||
|
||||
// Surfaced under the header for a failed connect/disconnect; cleared
|
||||
// automatically after a few seconds.
|
||||
property string actionStatus: ""
|
||||
// Surfaced under each provider's own row for a failed connect/
|
||||
// disconnect/settings change; cleared automatically after a few
|
||||
// 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) {
|
||||
const line = String(text || "").trim().split("\n")[0] || ""
|
||||
@@ -82,6 +114,95 @@ Item {
|
||||
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() {
|
||||
if (!root.mullvadInstalled) {
|
||||
if (!mullvadWhichProc.running) mullvadWhichProc.running = true
|
||||
@@ -197,8 +318,8 @@ Item {
|
||||
stderr: StdioCollector { id: mullvadConnectErr }
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
if (exitCode !== 0) {
|
||||
root.actionStatus = "Mullvad: " + root.shortLine(mullvadConnectErr.text || "connect failed")
|
||||
actionStatusTimer.restart()
|
||||
root.mullvadActionStatus = root.shortLine(mullvadConnectErr.text || "Connect failed")
|
||||
mullvadActionStatusTimer.restart()
|
||||
} else if (!tailscaleDownProc.running) {
|
||||
tailscaleDownProc.running = true
|
||||
}
|
||||
@@ -213,8 +334,8 @@ Item {
|
||||
stderr: StdioCollector { id: mullvadDisconnectErr }
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
if (exitCode !== 0) {
|
||||
root.actionStatus = "Mullvad: " + root.shortLine(mullvadDisconnectErr.text || "disconnect failed")
|
||||
actionStatusTimer.restart()
|
||||
root.mullvadActionStatus = root.shortLine(mullvadDisconnectErr.text || "Disconnect failed")
|
||||
mullvadActionStatusTimer.restart()
|
||||
}
|
||||
root.refreshMullvad()
|
||||
if (root.pendingConnect === "mullvad") { root.pendingConnect = ""; root.connectMullvad() }
|
||||
@@ -229,6 +350,77 @@ Item {
|
||||
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 ---------------------------------------------------------
|
||||
|
||||
Process {
|
||||
@@ -261,8 +453,8 @@ Item {
|
||||
stderr: StdioCollector { id: protonConnectErr }
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
if (exitCode !== 0) {
|
||||
root.actionStatus = "ProtonVPN: " + root.shortLine(protonConnectErr.text || "connect failed")
|
||||
actionStatusTimer.restart()
|
||||
root.protonActionStatus = root.shortLine(protonConnectErr.text || "Connect failed")
|
||||
protonActionStatusTimer.restart()
|
||||
} else if (!tailscaleDownProc.running) {
|
||||
tailscaleDownProc.running = true
|
||||
}
|
||||
@@ -277,8 +469,8 @@ Item {
|
||||
stderr: StdioCollector { id: protonDisconnectErr }
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
if (exitCode !== 0) {
|
||||
root.actionStatus = "ProtonVPN: " + root.shortLine(protonDisconnectErr.text || "disconnect failed")
|
||||
actionStatusTimer.restart()
|
||||
root.protonActionStatus = root.shortLine(protonDisconnectErr.text || "Disconnect failed")
|
||||
protonActionStatusTimer.restart()
|
||||
}
|
||||
root.refreshProton()
|
||||
if (root.pendingConnect === "mullvad") { root.pendingConnect = ""; root.connectMullvad() }
|
||||
@@ -305,10 +497,17 @@ Item {
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: actionStatusTimer
|
||||
id: mullvadActionStatusTimer
|
||||
interval: 4000
|
||||
repeat: false
|
||||
onTriggered: root.actionStatus = ""
|
||||
onTriggered: root.mullvadActionStatus = ""
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: protonActionStatusTimer
|
||||
interval: 4000
|
||||
repeat: false
|
||||
onTriggered: root.protonActionStatus = ""
|
||||
}
|
||||
|
||||
// --- Bar icon ------------------------------------------------------------
|
||||
@@ -416,7 +615,13 @@ Item {
|
||||
property string detailText: ""
|
||||
property string state: "disconnected"
|
||||
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 expandToggle()
|
||||
width: parent ? parent.width : 0
|
||||
radius: 0
|
||||
implicitHeight: rowContent.implicitHeight + 12
|
||||
@@ -477,9 +682,91 @@ Item {
|
||||
busy: provRow.state === "connecting" || provRow.state === "disconnecting"
|
||||
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 -----------------------------------------------------------
|
||||
|
||||
PopupWindow {
|
||||
@@ -494,7 +781,18 @@ Item {
|
||||
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 {
|
||||
window: root.barWindow
|
||||
@@ -548,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 {
|
||||
width: parent.width
|
||||
spacing: 2
|
||||
|
||||
ProviderRow {
|
||||
label: "Mullvad"
|
||||
subIcon: "gpp_good"
|
||||
state: root.mullvadState
|
||||
detailText: root.mullvadDetail
|
||||
installed: root.mullvadInstalled
|
||||
onToggle: root.toggleMullvad()
|
||||
// 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 {
|
||||
label: "Mullvad"
|
||||
subIcon: "gpp_good"
|
||||
state: root.mullvadState
|
||||
detailText: root.mullvadDetail
|
||||
installed: root.mullvadInstalled
|
||||
expandable: root.mullvadInstalled
|
||||
expanded: root.mullvadOptionsOpen
|
||||
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 {
|
||||
label: "ProtonVPN"
|
||||
subIcon: "gpp_good"
|
||||
@@ -578,6 +1050,8 @@ Item {
|
||||
installed: root.protonInstalled
|
||||
onToggle: root.toggleProton()
|
||||
}
|
||||
|
||||
StatusLine { message: root.protonActionStatus }
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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 {
|
||||
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 {
|
||||
required property var modelData
|
||||
|
||||
@@ -23,7 +23,7 @@ source $ZSH/oh-my-zsh.sh
|
||||
|
||||
# My alias
|
||||
|
||||
alias cl='clear'
|
||||
alias cl='clear && fastfetch'
|
||||
alias zed='zeditor'
|
||||
alias ssh-gavin='ssh thoams@192.168.1.83'
|
||||
alias ssh-raspberry-pi='ssh thoams@192.168.1.86'
|
||||
|
||||
Reference in New Issue
Block a user