Compare commits
16 Commits
features/u
...
bc242ae956
| Author | SHA1 | Date | |
|---|---|---|---|
| bc242ae956 | |||
| 4237b2ed9e | |||
| 7b0cf9934b | |||
| 9df5731ee1 | |||
| 3806935316 | |||
| 516f8eed82 | |||
| 369179c057 | |||
| 322804577f | |||
| 0eee14858b | |||
| 461f4c3fff | |||
| 2f9dfda410 | |||
| e35ceb1450 | |||
| 3d0a18866f | |||
| 6c415d6fbb | |||
| c4b4a3bd26 | |||
| 866ee0bb9b |
@@ -59,7 +59,7 @@ List of programs and tools I use.
|
|||||||
|📁 File Manager | [thunar](https://wiki.archlinux.org/title/Thunar) |
|
|📁 File Manager | [thunar](https://wiki.archlinux.org/title/Thunar) |
|
||||||
|📝 Editor | [vs code](https://github.com/microsoft/vscode) / [neovim (changing to soon)](https://github.com/neovim/neovim) |
|
|📝 Editor | [vs code](https://github.com/microsoft/vscode) / [neovim (changing to soon)](https://github.com/neovim/neovim) |
|
||||||
|🌐 Browser | [zen](https://github.com/zen-browser/desktop) / [firefox](https://github.com/topics/firefox-browser) / [startpage](https://blog-bucket-personal.s3.eu-west-2.amazonaws.com/Screenshot_02-May_08-44-23_6614.png) |
|
|🌐 Browser | [zen](https://github.com/zen-browser/desktop) / [firefox](https://github.com/topics/firefox-browser) / [startpage](https://blog-bucket-personal.s3.eu-west-2.amazonaws.com/Screenshot_02-May_08-44-23_6614.png) |
|
||||||
|📊 Bar | [waybar](https://github.com/Alexays/Waybar) |
|
|📊 Bar | [waybar](https://github.com/Alexays/Waybar) / [quickshell](https://quickshell.org/) |
|
||||||
|🚀 Launcher | [rofi](https://github.com/davatorium/rofi) |
|
|🚀 Launcher | [rofi](https://github.com/davatorium/rofi) |
|
||||||
|🔒 Lockscreen | [hyprlock](https://github.com/hyprwm/hyprlock) |
|
|🔒 Lockscreen | [hyprlock](https://github.com/hyprwm/hyprlock) |
|
||||||
|🚪 Login Menu | [sddm](https://github.com/sddm/sddm) |
|
|🚪 Login Menu | [sddm](https://github.com/sddm/sddm) |
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
-- Ported from UserConfigs/UserAnimations.conf (the active preset)
|
-- Ported from UserConfigs/UserAnimations.conf (the active preset)
|
||||||
--
|
--
|
||||||
-- The 14 alternative presets from animations/*.conf were also ported, flat, as
|
-- The 14 alternative presets from animations/*.conf were also ported, flat, as
|
||||||
-- anim-*.lua (see the plan) — none of them are wired in. To switch styles, replace
|
-- anim-*.lua (see the plan) — none of them are wired in. They now live in ../presets/.
|
||||||
-- the body of this file with `require("anim-<preset>")`, e.g. require("anim-ml4w-classic").
|
-- To switch styles, replace the body of this file with `require("presets.anim-<preset>")`,
|
||||||
|
-- e.g. require("presets.anim-ml4w-classic").
|
||||||
|
|
||||||
hl.config({
|
hl.config({
|
||||||
animations = {
|
animations = {
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
-- Animation - https://wiki.hyprland.org/Configuring/Animations/
|
-- Animation - https://wiki.hyprland.org/Configuring/Animations/
|
||||||
-- Decoration - https://wiki.hyprland.org/Configuring/Variables/#decoration
|
-- Decoration - https://wiki.hyprland.org/Configuring/Variables/#decoration
|
||||||
|
|
||||||
local colors = require("colors")
|
local colors = require("appearance.colors")
|
||||||
|
|
||||||
hl.config({
|
hl.config({
|
||||||
general = {
|
general = {
|
||||||
@@ -2,9 +2,13 @@
|
|||||||
-- Ported from hyprland.conf — see https://wiki.hyprland.org/
|
-- Ported from hyprland.conf — see https://wiki.hyprland.org/
|
||||||
--
|
--
|
||||||
-- This mirrors the old source= chain 1:1, just as require()'d Lua modules instead
|
-- This mirrors the old source= chain 1:1, just as require()'d Lua modules instead
|
||||||
-- of hyprlang includes. See each file for its origin. The animations/ preset library
|
-- of hyprlang includes. See each file for its origin. The presets/ preset library
|
||||||
-- (anim-*.lua) is also ported but intentionally not required from here — only the
|
-- (anim-*.lua) is also ported but intentionally not required from here — only the
|
||||||
-- currently-active style (animations.lua) is wired in, exactly like before.
|
-- currently-active style (appearance/animations.lua) is wired in, exactly like before.
|
||||||
|
--
|
||||||
|
-- Modules live in subfolders sorted by concern (core/, appearance/, layout/, input/,
|
||||||
|
-- presets/) — require() paths below use dotted module names, which Lua resolves to
|
||||||
|
-- the matching subfolder (e.g. require("core.env") -> core/env.lua).
|
||||||
|
|
||||||
-- Initial boot script: applies initial wallpapers, theming, new settings etc.
|
-- Initial boot script: applies initial wallpapers, theming, new settings etc.
|
||||||
-- suggest not changing/deleting this, including the reference file at ~/.config/hypr/.initial_startup_done
|
-- suggest not changing/deleting this, including the reference file at ~/.config/hypr/.initial_startup_done
|
||||||
@@ -14,17 +18,17 @@ hl.on("hyprland.start", function()
|
|||||||
end)
|
end)
|
||||||
|
|
||||||
-- ## This is where you want to start tinkering
|
-- ## This is where you want to start tinkering
|
||||||
require("startup") -- put your start-up packages in startup.lua
|
require("core.startup") -- put your start-up packages in core/startup.lua
|
||||||
require("env") -- Environment variables to load
|
require("core.env") -- Environment variables to load
|
||||||
require("windowrules") -- all about Hyprland Window Rules and Layer Rules
|
require("layout.windowrules") -- all about Hyprland Window Rules and Layer Rules
|
||||||
require("decorations") -- Decorations config
|
require("appearance.decorations") -- Decorations config
|
||||||
require("animations") -- Animation config
|
require("appearance.animations") -- Animation config
|
||||||
require("keybinds") -- Put your own keybinds here
|
require("input.keybinds") -- Put your own keybinds here
|
||||||
require("settings") -- Main Hyprland Settings
|
require("core.settings") -- Main Hyprland Settings
|
||||||
|
|
||||||
-- nwg-displays / hyprdynamicmonitors
|
-- nwg-displays / hyprdynamicmonitors
|
||||||
require("monitors")
|
require("layout.monitors")
|
||||||
require("workspaces")
|
require("layout.workspaces")
|
||||||
|
|
||||||
-- NOTE: the original hyprland.conf used a plain `exec` (not `exec-once`) here, so it
|
-- NOTE: the original hyprland.conf used a plain `exec` (not `exec-once`) here, so it
|
||||||
-- re-ran on every `hyprctl reload`, not just at startup. hl.on("hyprland.start", ...)
|
-- re-ran on every `hyprctl reload`, not just at startup. hl.on("hyprland.start", ...)
|
||||||
@@ -35,4 +39,4 @@ hl.on("hyprland.start", function()
|
|||||||
end)
|
end)
|
||||||
|
|
||||||
-- HyprMod managed settings
|
-- HyprMod managed settings
|
||||||
require("hyprmod")
|
require("core.hyprmod")
|
||||||
|
|||||||
25
dotfiles/.config/quickshell/config/Appearance.qml
Normal file
25
dotfiles/.config/quickshell/config/Appearance.qml
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
pragma Singleton
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
QtObject {
|
||||||
|
readonly property string fontFamily: "CaskaydiaCove Nerd Font Mono"
|
||||||
|
readonly property string iconFontFamily: "Material Symbols Outlined"
|
||||||
|
// Material Symbols is a variable font. Left unset, glyphs fall back to the
|
||||||
|
// font's default optical size (24px), which is tuned for larger rendering
|
||||||
|
// and makes some icons (e.g. "lock") look heavier/bigger than others when
|
||||||
|
// drawn small — pin "opsz" near the actual render size to keep icons
|
||||||
|
// visually consistent. See Appearance.iconSize for the matching pixelSize.
|
||||||
|
readonly property var iconFontAxes: ({ "FILL": 0, "wght": 400, "GRAD": 0, "opsz": 20 })
|
||||||
|
readonly property int iconSize: 16
|
||||||
|
|
||||||
|
readonly property int fontSize: 13
|
||||||
|
readonly property int fontSizeSmall: 11
|
||||||
|
|
||||||
|
readonly property int radius: 14
|
||||||
|
readonly property int radiusSmall: 8
|
||||||
|
|
||||||
|
readonly property int barHeight: 34
|
||||||
|
readonly property int spacing: 10
|
||||||
|
|
||||||
|
readonly property int animationDuration: 150
|
||||||
|
}
|
||||||
62
dotfiles/.config/quickshell/config/Colors.qml
Normal file
62
dotfiles/.config/quickshell/config/Colors.qml
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
pragma Singleton
|
||||||
|
import QtQuick
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
|
||||||
|
// Wallpaper-driven palette, sourced from pywal's ~/.cache/wal/colors.json.
|
||||||
|
// Run `wal -i /path/to/wallpaper.png` to (re)generate it — this file watches
|
||||||
|
// the cache and updates live, no shell restart needed. Until that's been run
|
||||||
|
// once, everything falls back to a hand-picked dark palette below.
|
||||||
|
QtObject {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
readonly property bool active: _parsed !== null
|
||||||
|
property var _parsed: null
|
||||||
|
|
||||||
|
// --- Fallback palette (used until pywal has run) ---
|
||||||
|
readonly property color fallbackBg: "#1a1b26"
|
||||||
|
readonly property color fallbackFg: "#c0caf5"
|
||||||
|
readonly property var fallbackPalette: [
|
||||||
|
"#15161e", "#f7768e", "#9ece6a", "#e0af68",
|
||||||
|
"#7aa2f7", "#bb9af7", "#7dcfff", "#a9b1d6",
|
||||||
|
"#414868", "#f7768e", "#9ece6a", "#e0af68",
|
||||||
|
"#7aa2f7", "#bb9af7", "#7dcfff", "#c0caf5"
|
||||||
|
]
|
||||||
|
|
||||||
|
function paletteColor(i) {
|
||||||
|
if (_parsed && _parsed.colors)
|
||||||
|
return _parsed.colors["color" + i] || fallbackPalette[i]
|
||||||
|
return fallbackPalette[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly property color background: _parsed && _parsed.special ? _parsed.special.background : fallbackBg
|
||||||
|
readonly property color foreground: _parsed && _parsed.special ? _parsed.special.foreground : fallbackFg
|
||||||
|
readonly property color primary: paletteColor(4)
|
||||||
|
// Named primaryText (not "onPrimary") because QML treats any "onXxx"
|
||||||
|
// identifier as a signal handler slot, not a regular property.
|
||||||
|
readonly property color primaryText: background
|
||||||
|
readonly property color urgent: paletteColor(1)
|
||||||
|
// paletteColor(8) alone renders quite dark/low-contrast for text (it's
|
||||||
|
// meant as a muted *background* tone in most wal palettes) — lighten it
|
||||||
|
// so muted labels stay legible against the bar's dark background.
|
||||||
|
readonly property color muted: Qt.lighter(paletteColor(8), 2.2)
|
||||||
|
readonly property color surface: Qt.lighter(background, 1.35)
|
||||||
|
|
||||||
|
// QtObject has no default property, so the FileView is attached via a
|
||||||
|
// property assignment rather than as a plain child element.
|
||||||
|
readonly property FileView _walFile: FileView {
|
||||||
|
path: Quickshell.env("HOME") + "/.cache/wal/colors.json"
|
||||||
|
watchChanges: true
|
||||||
|
onLoaded: {
|
||||||
|
try {
|
||||||
|
root._parsed = JSON.parse(text())
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Colors: failed to parse pywal cache:", e)
|
||||||
|
root._parsed = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onLoadFailed: (error) => {
|
||||||
|
root._parsed = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
16
dotfiles/.config/quickshell/config/VpnExclusivity.qml
Normal file
16
dotfiles/.config/quickshell/config/VpnExclusivity.qml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
pragma Singleton
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
// Tailscale, Mullvad, and ProtonVPN all fight over the default route/DNS if
|
||||||
|
// more than one is active at once (see VpnIndicator.qml's header comment) —
|
||||||
|
// only one of the three should ever actually be connected. This singleton
|
||||||
|
// is the shared signal between the two widget files: VpnIndicator.qml
|
||||||
|
// writes Mullvad/ProtonVPN's live connection state here as it polls them;
|
||||||
|
// TailscaleIndicator.qml reads it to back off (`tailscale down`) rather
|
||||||
|
// than stay up alongside either one, and to refuse turning Tailscale back
|
||||||
|
// on while one is still connected.
|
||||||
|
QtObject {
|
||||||
|
property bool mullvadConnected: false
|
||||||
|
property bool protonConnected: false
|
||||||
|
readonly property bool commercialVpnActive: mullvadConnected || protonConnected
|
||||||
|
}
|
||||||
4
dotfiles/.config/quickshell/config/qmldir
Normal file
4
dotfiles/.config/quickshell/config/qmldir
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
module config
|
||||||
|
singleton Colors 1.0 Colors.qml
|
||||||
|
singleton Appearance 1.0 Appearance.qml
|
||||||
|
singleton VpnExclusivity 1.0 VpnExclusivity.qml
|
||||||
13
dotfiles/.config/quickshell/modules/bar/ActiveWindow.qml
Normal file
13
dotfiles/.config/quickshell/modules/bar/ActiveWindow.qml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import Quickshell.Hyprland
|
||||||
|
import "../../config"
|
||||||
|
|
||||||
|
Text {
|
||||||
|
Layout.maximumWidth: 320
|
||||||
|
elide: Text.ElideRight
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSizeSmall
|
||||||
|
color: Colors.muted
|
||||||
|
text: Hyprland.activeToplevel ? Hyprland.activeToplevel.title : ""
|
||||||
|
}
|
||||||
57
dotfiles/.config/quickshell/modules/bar/Bar.qml
Normal file
57
dotfiles/.config/quickshell/modules/bar/Bar.qml
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import Quickshell
|
||||||
|
import Quickshell.Wayland
|
||||||
|
import QtQuick
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import "../../config"
|
||||||
|
|
||||||
|
PanelWindow {
|
||||||
|
id: bar
|
||||||
|
|
||||||
|
anchors {
|
||||||
|
top: true
|
||||||
|
left: true
|
||||||
|
right: true
|
||||||
|
}
|
||||||
|
implicitHeight: Appearance.barHeight
|
||||||
|
exclusiveZone: implicitHeight
|
||||||
|
color: "transparent"
|
||||||
|
|
||||||
|
WlrLayershell.layer: WlrLayer.Top
|
||||||
|
WlrLayershell.namespace: "quickshell:bar"
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
color: Colors.background
|
||||||
|
}
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
anchors {
|
||||||
|
left: parent.left
|
||||||
|
verticalCenter: parent.verticalCenter
|
||||||
|
leftMargin: Appearance.spacing
|
||||||
|
}
|
||||||
|
spacing: Appearance.spacing
|
||||||
|
Workspaces { screen: bar.screen }
|
||||||
|
ActiveWindow {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Clock {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
}
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
anchors {
|
||||||
|
right: parent.right
|
||||||
|
verticalCenter: parent.verticalCenter
|
||||||
|
rightMargin: Appearance.spacing
|
||||||
|
}
|
||||||
|
spacing: Appearance.spacing
|
||||||
|
//Tray {}
|
||||||
|
TailscaleIndicator { barWindow: bar }
|
||||||
|
VpnIndicator { barWindow: bar }
|
||||||
|
NetworkIndicator { barWindow: bar }
|
||||||
|
BluetoothIndicator { barWindow: bar }
|
||||||
|
Volume { barWindow: bar }
|
||||||
|
PowerButton { barWindow: bar }
|
||||||
|
}
|
||||||
|
}
|
||||||
489
dotfiles/.config/quickshell/modules/bar/BluetoothIndicator.qml
Normal file
489
dotfiles/.config/quickshell/modules/bar/BluetoothIndicator.qml
Normal file
@@ -0,0 +1,489 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Hyprland
|
||||||
|
import Quickshell.Bluetooth
|
||||||
|
import "../../config"
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
implicitWidth: label.implicitWidth + 4
|
||||||
|
implicitHeight: Appearance.barHeight
|
||||||
|
|
||||||
|
// The bar's own PanelWindow — passed in from Bar.qml so this popup can
|
||||||
|
// anchor to the bar itself rather than to this icon, keeping it a fixed
|
||||||
|
// distance from the bar and the screen edge regardless of where this
|
||||||
|
// icon sits in the row.
|
||||||
|
required property var barWindow
|
||||||
|
|
||||||
|
readonly property var adapter: Bluetooth.defaultAdapter
|
||||||
|
|
||||||
|
// Scan only while the panel is open — same convention as the WiFi
|
||||||
|
// scannerEnabled binding in NetworkIndicator.qml.
|
||||||
|
Binding {
|
||||||
|
target: root.adapter
|
||||||
|
property: "discovering"
|
||||||
|
value: panel.visible && root.adapter.enabled
|
||||||
|
when: root.adapter !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Device row helpers ----------------------------------------------
|
||||||
|
// Rows are plain objects rather than the live BluetoothDevice QObject —
|
||||||
|
// BlueZ churn (pairing timeouts, a device dropping out of range mid-scan)
|
||||||
|
// can destroy the object while a delegate still holds it, the same
|
||||||
|
// crash risk noted for WiFi networks in NetworkIndicator.qml. The actual
|
||||||
|
// object is re-resolved by address only when an action needs it, via
|
||||||
|
// deviceForAddress().
|
||||||
|
function deviceLabel(d) {
|
||||||
|
return (d.deviceName || d.name || d.address || "").trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
// BlueZ's Icon property is a freedesktop icon-theme name (verified
|
||||||
|
// against a real paired device on this machine: "audio-headset") — map
|
||||||
|
// the common categories to a Material Symbols glyph, falling back to a
|
||||||
|
// generic "devices_other" for anything unrecognized.
|
||||||
|
function deviceIcon(icon) {
|
||||||
|
const i = icon || ""
|
||||||
|
if (i.indexOf("headset") !== -1 || i.indexOf("headphone") !== -1) return "headphones"
|
||||||
|
if (i.indexOf("mouse") !== -1) return "mouse"
|
||||||
|
if (i.indexOf("keyboard") !== -1) return "keyboard"
|
||||||
|
if (i.indexOf("gaming") !== -1 || i.indexOf("joystick") !== -1) return "sports_esports"
|
||||||
|
if (i.indexOf("tablet") !== -1) return "tablet"
|
||||||
|
if (i.indexOf("camera") !== -1) return "videocam"
|
||||||
|
if (i.indexOf("printer") !== -1) return "print"
|
||||||
|
if (i.indexOf("watch") !== -1) return "watch"
|
||||||
|
if (i.indexOf("tv") !== -1) return "tv"
|
||||||
|
if (i.indexOf("computer") !== -1) return "computer"
|
||||||
|
if (i.indexOf("card") !== -1) return "speaker"
|
||||||
|
return "devices_other"
|
||||||
|
}
|
||||||
|
|
||||||
|
function deviceRow(d) {
|
||||||
|
if (!d) return null
|
||||||
|
const label = root.deviceLabel(d)
|
||||||
|
if (!label) return null
|
||||||
|
return {
|
||||||
|
address: d.address || "",
|
||||||
|
label: label,
|
||||||
|
icon: root.deviceIcon(d.icon),
|
||||||
|
connected: !!d.connected,
|
||||||
|
paired: !!d.paired,
|
||||||
|
bonded: !!d.bonded,
|
||||||
|
trusted: !!d.trusted,
|
||||||
|
pairing: !!d.pairing,
|
||||||
|
batteryAvailable: !!d.batteryAvailable,
|
||||||
|
battery: d.battery || 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly property var deviceRows: root.adapter
|
||||||
|
? root.adapter.devices.values.map(root.deviceRow).filter(function(r) { return r !== null })
|
||||||
|
: []
|
||||||
|
|
||||||
|
readonly property var connectedRows: root.deviceRows.filter(function(r) { return r.connected })
|
||||||
|
readonly property var knownRows: root.deviceRows.filter(function(r) {
|
||||||
|
return !r.connected && (r.paired || r.bonded || r.trusted)
|
||||||
|
})
|
||||||
|
readonly property var discoveredRows: root.deviceRows.filter(function(r) {
|
||||||
|
return !r.connected && !r.paired && !r.bonded && !r.trusted
|
||||||
|
})
|
||||||
|
|
||||||
|
function deviceForAddress(address) {
|
||||||
|
if (!root.adapter) return null
|
||||||
|
const devs = root.adapter.devices.values
|
||||||
|
for (let i = 0; i < devs.length; i++) {
|
||||||
|
if (devs[i].address === address) return devs[i]
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectDevice(row) {
|
||||||
|
const d = root.deviceForAddress(row.address)
|
||||||
|
if (!d) return
|
||||||
|
// Already-paired devices just reconnect; a fresh discovery result
|
||||||
|
// has to pair first (BlueZ's own agent handles the SSP/passkey
|
||||||
|
// handshake — there's no PIN-entry surface exposed by this API).
|
||||||
|
if (row.paired || row.bonded || row.trusted) d.connect()
|
||||||
|
else d.pair()
|
||||||
|
}
|
||||||
|
|
||||||
|
function disconnectDevice(row) {
|
||||||
|
const d = root.deviceForAddress(row.address)
|
||||||
|
if (d) d.disconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
function forgetDevice(row) {
|
||||||
|
const d = root.deviceForAddress(row.address)
|
||||||
|
if (!d) return
|
||||||
|
if (d.connected) d.disconnect()
|
||||||
|
d.forget()
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly property bool anyConnected: root.connectedRows.length > 0
|
||||||
|
|
||||||
|
// Caps how tall the Paired/Available list gets before it scrolls
|
||||||
|
// instead of continuing to grow the popup — same convention as
|
||||||
|
// NetworkIndicator.qml's Known/Other Networks list.
|
||||||
|
readonly property int maxDeviceListHeight: 260
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: label
|
||||||
|
anchors.centerIn: parent
|
||||||
|
font.family: Appearance.iconFontFamily
|
||||||
|
font.variableAxes: Appearance.iconFontAxes
|
||||||
|
font.pixelSize: 16
|
||||||
|
color: (root.adapter && root.adapter.enabled) ? Colors.foreground : Colors.muted
|
||||||
|
text: {
|
||||||
|
if (!root.adapter || !root.adapter.enabled) return "bluetooth_disabled"
|
||||||
|
return root.anyConnected ? "bluetooth_connected" : "bluetooth"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
anchors.fill: parent
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onClicked: {
|
||||||
|
panel.visible = !panel.visible
|
||||||
|
grab.active = panel.visible
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Reusable pieces ---------------------------------------------------
|
||||||
|
|
||||||
|
component ToggleSwitch: Item {
|
||||||
|
id: toggleRoot
|
||||||
|
property bool checked: false
|
||||||
|
signal toggled()
|
||||||
|
implicitWidth: 34
|
||||||
|
implicitHeight: 18
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
radius: 0
|
||||||
|
color: toggleRoot.checked ? Colors.primary : Colors.muted
|
||||||
|
opacity: toggleRoot.checked ? 1 : 0.35
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
width: parent.height - 4
|
||||||
|
height: parent.height - 4
|
||||||
|
radius: 0
|
||||||
|
color: Colors.background
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
x: toggleRoot.checked ? parent.width - width - 2 : 2
|
||||||
|
Behavior on x { NumberAnimation { duration: Appearance.animationDuration } }
|
||||||
|
}
|
||||||
|
MouseArea {
|
||||||
|
anchors.fill: parent
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onClicked: toggleRoot.toggled()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One device row. `showForget` hides the forget (x) button for
|
||||||
|
// discovered-but-unpaired devices, which have nothing to forget yet.
|
||||||
|
component DeviceRow: Rectangle {
|
||||||
|
id: devRow
|
||||||
|
required property var dev
|
||||||
|
property bool showForget: true
|
||||||
|
signal activate()
|
||||||
|
width: parent ? parent.width : 0
|
||||||
|
radius: 0
|
||||||
|
implicitHeight: rowContent.implicitHeight + 12
|
||||||
|
color: devRow.dev.connected
|
||||||
|
? Qt.rgba(Colors.primary.r, Colors.primary.g, Colors.primary.b, 0.18)
|
||||||
|
: (rowMouse.containsMouse ? Colors.primary : "transparent")
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
id: rowContent
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
anchors.margins: 6
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
Text {
|
||||||
|
Layout.preferredWidth: 20
|
||||||
|
Layout.minimumWidth: 20
|
||||||
|
Layout.maximumWidth: 20
|
||||||
|
horizontalAlignment: Text.AlignLeft
|
||||||
|
text: devRow.dev.icon
|
||||||
|
font.family: Appearance.iconFontFamily
|
||||||
|
font.variableAxes: Appearance.iconFontAxes
|
||||||
|
font.pixelSize: 14
|
||||||
|
color: rowMouse.containsMouse ? Colors.primaryText : Colors.foreground
|
||||||
|
}
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 0
|
||||||
|
|
||||||
|
Text {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: devRow.dev.label
|
||||||
|
elide: Text.ElideRight
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSize
|
||||||
|
font.bold: devRow.dev.connected
|
||||||
|
color: rowMouse.containsMouse ? Colors.primaryText : Colors.foreground
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
readonly property string statusText: {
|
||||||
|
if (devRow.dev.pairing) return "Pairing…"
|
||||||
|
if (devRow.dev.connected && devRow.dev.batteryAvailable)
|
||||||
|
return Math.round(devRow.dev.battery * 100) + "%"
|
||||||
|
if (devRow.dev.connected) return "Connected"
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
visible: statusText !== ""
|
||||||
|
text: statusText
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSizeSmall
|
||||||
|
color: rowMouse.containsMouse ? Colors.primaryText : Colors.muted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: forgetIcon
|
||||||
|
visible: devRow.showForget
|
||||||
|
text: "close"
|
||||||
|
font.family: Appearance.iconFontFamily
|
||||||
|
font.variableAxes: Appearance.iconFontAxes
|
||||||
|
font.pixelSize: 14
|
||||||
|
color: rowMouse.containsMouse ? Colors.primaryText : Colors.muted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: rowMouse
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.top: parent.top
|
||||||
|
height: rowContent.height + 12
|
||||||
|
hoverEnabled: true
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
// forgetIcon has no MouseArea of its own — nested inside
|
||||||
|
// rowContent, which sits underneath this full-row area in
|
||||||
|
// stacking order, so a separate MouseArea there would just be
|
||||||
|
// shadowed (see the identical fix in NetworkIndicator.qml's
|
||||||
|
// NetworkRow). Dispatch by click position against this single
|
||||||
|
// area instead.
|
||||||
|
onClicked: (mouse) => {
|
||||||
|
if (devRow.showForget && forgetIcon.visible) {
|
||||||
|
const forgetLeft = devRow.mapFromItem(forgetIcon, 0, 0).x
|
||||||
|
if (mouse.x >= forgetLeft - 8) {
|
||||||
|
root.forgetDevice(devRow.dev)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
devRow.activate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Panel -------------------------------------------------------------
|
||||||
|
|
||||||
|
PopupWindow {
|
||||||
|
id: panel
|
||||||
|
visible: false
|
||||||
|
color: "transparent"
|
||||||
|
readonly property int contentWidth: 244
|
||||||
|
readonly property int contentPadding: 16
|
||||||
|
implicitWidth: contentWidth + contentPadding * 2
|
||||||
|
implicitHeight: mainColumn.implicitHeight + contentPadding * 2
|
||||||
|
// Connected/paired/discovered devices come and go while the panel
|
||||||
|
// is open — animate the resize instead of snapping, same as the
|
||||||
|
// network panel's WiFi list.
|
||||||
|
Behavior on implicitHeight {
|
||||||
|
NumberAnimation { duration: Appearance.animationDuration; easing.type: Easing.OutCubic }
|
||||||
|
}
|
||||||
|
|
||||||
|
anchor {
|
||||||
|
window: root.barWindow
|
||||||
|
rect.x: 0
|
||||||
|
rect.y: 0
|
||||||
|
rect.width: root.barWindow.width
|
||||||
|
rect.height: root.barWindow.height
|
||||||
|
edges: Edges.Bottom | Edges.Right
|
||||||
|
gravity: Edges.Bottom | Edges.Left
|
||||||
|
margins.bottom: -4
|
||||||
|
margins.right: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
HyprlandFocusGrab {
|
||||||
|
id: grab
|
||||||
|
windows: [panel]
|
||||||
|
onCleared: panel.visible = false
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
radius: 0
|
||||||
|
color: Colors.surface
|
||||||
|
border.color: Colors.muted
|
||||||
|
border.width: 3
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: mainColumn
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: panel.contentPadding
|
||||||
|
spacing: 10
|
||||||
|
|
||||||
|
// Header
|
||||||
|
RowLayout {
|
||||||
|
width: parent.width
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: label.text
|
||||||
|
font.family: Appearance.iconFontFamily
|
||||||
|
font.variableAxes: Appearance.iconFontAxes
|
||||||
|
font.pixelSize: 30
|
||||||
|
color: Colors.foreground
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: "Bluetooth"
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: 18
|
||||||
|
font.bold: true
|
||||||
|
color: Colors.foreground
|
||||||
|
}
|
||||||
|
|
||||||
|
ToggleSwitch {
|
||||||
|
visible: root.adapter !== null
|
||||||
|
checked: root.adapter !== null && root.adapter.enabled
|
||||||
|
onToggled: root.adapter.enabled = !root.adapter.enabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
visible: root.adapter === null
|
||||||
|
text: "No Bluetooth adapter"
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSizeSmall
|
||||||
|
color: Colors.muted
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
visible: root.adapter !== null && !root.adapter.enabled
|
||||||
|
text: "Turn Bluetooth on to see devices"
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSizeSmall
|
||||||
|
color: Colors.muted
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connected — always shown in full above the scrollable
|
||||||
|
// list, mirroring the network panel's layout.
|
||||||
|
Column {
|
||||||
|
width: parent.width
|
||||||
|
spacing: 4
|
||||||
|
visible: root.connectedRows.length > 0
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: "Connected"
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSizeSmall
|
||||||
|
color: Colors.muted
|
||||||
|
}
|
||||||
|
Column {
|
||||||
|
width: parent.width
|
||||||
|
spacing: 2
|
||||||
|
Repeater {
|
||||||
|
model: root.connectedRows
|
||||||
|
DeviceRow {
|
||||||
|
required property var modelData
|
||||||
|
dev: modelData
|
||||||
|
showForget: true
|
||||||
|
onActivate: root.disconnectDevice(dev)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
visible: root.adapter !== null && root.adapter.enabled && root.deviceRows.length === 0
|
||||||
|
text: (root.adapter && root.adapter.discovering) ? "Scanning for devices…" : "No devices found"
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSizeSmall
|
||||||
|
color: Colors.muted
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paired/Available — capped height, scrolls once there are
|
||||||
|
// more devices than fit rather than growing the popup
|
||||||
|
// indefinitely.
|
||||||
|
Flickable {
|
||||||
|
id: deviceListFlick
|
||||||
|
width: parent.width
|
||||||
|
height: Math.min(deviceListColumn.implicitHeight, root.maxDeviceListHeight)
|
||||||
|
contentWidth: width
|
||||||
|
contentHeight: deviceListColumn.implicitHeight
|
||||||
|
clip: true
|
||||||
|
boundsBehavior: Flickable.StopAtBounds
|
||||||
|
visible: deviceListColumn.implicitHeight > 0
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: deviceListColumn
|
||||||
|
width: deviceListFlick.width
|
||||||
|
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 {
|
||||||
|
width: parent.width
|
||||||
|
spacing: 4
|
||||||
|
visible: root.adapter !== null && root.adapter.discovering && root.discoveredRows.length > 0
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: "Available Devices"
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSizeSmall
|
||||||
|
color: Colors.muted
|
||||||
|
}
|
||||||
|
Column {
|
||||||
|
width: parent.width
|
||||||
|
spacing: 2
|
||||||
|
Repeater {
|
||||||
|
model: root.discoveredRows
|
||||||
|
DeviceRow {
|
||||||
|
required property var modelData
|
||||||
|
dev: modelData
|
||||||
|
showForget: false
|
||||||
|
onActivate: root.connectDevice(dev)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
18
dotfiles/.config/quickshell/modules/bar/Clock.qml
Normal file
18
dotfiles/.config/quickshell/modules/bar/Clock.qml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import QtQuick
|
||||||
|
import "../../config"
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: root
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSize
|
||||||
|
font.bold: true
|
||||||
|
color: Colors.foreground
|
||||||
|
text: Qt.formatDateTime(new Date(), "ddd d MMM hh:mm")
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
interval: 1000
|
||||||
|
running: true
|
||||||
|
repeat: true
|
||||||
|
onTriggered: root.text = Qt.formatDateTime(new Date(), "ddd d MMM hh:mm")
|
||||||
|
}
|
||||||
|
}
|
||||||
1125
dotfiles/.config/quickshell/modules/bar/NetworkIndicator.qml
Normal file
1125
dotfiles/.config/quickshell/modules/bar/NetworkIndicator.qml
Normal file
File diff suppressed because it is too large
Load Diff
179
dotfiles/.config/quickshell/modules/bar/PowerButton.qml
Normal file
179
dotfiles/.config/quickshell/modules/bar/PowerButton.qml
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import Quickshell.Hyprland
|
||||||
|
import "../../config"
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
implicitWidth: label.implicitWidth + 4
|
||||||
|
implicitHeight: Appearance.barHeight
|
||||||
|
|
||||||
|
// The bar's own PanelWindow — passed in from Bar.qml so this popup can
|
||||||
|
// anchor to the bar itself rather than to this icon, keeping it a fixed
|
||||||
|
// distance from the bar and the screen edge regardless of where this
|
||||||
|
// icon sits in the row.
|
||||||
|
required property var barWindow
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: label
|
||||||
|
anchors.centerIn: parent
|
||||||
|
font.family: Appearance.iconFontFamily
|
||||||
|
font.variableAxes: Appearance.iconFontAxes
|
||||||
|
font.pixelSize: 16
|
||||||
|
color: Colors.foreground
|
||||||
|
text: "power_settings_new"
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
anchors.fill: parent
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onClicked: {
|
||||||
|
menu.visible = !menu.visible
|
||||||
|
grab.active = menu.visible
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process { id: proc }
|
||||||
|
|
||||||
|
PopupWindow {
|
||||||
|
id: menu
|
||||||
|
visible: false
|
||||||
|
color: "transparent"
|
||||||
|
implicitWidth: 210
|
||||||
|
// Same padding as Volume.qml's panel.
|
||||||
|
readonly property int contentPadding: 16
|
||||||
|
readonly property int labelOffset: -135
|
||||||
|
implicitHeight: mainColumn.implicitHeight + contentPadding * 2
|
||||||
|
|
||||||
|
anchor {
|
||||||
|
window: root.barWindow
|
||||||
|
// Unlike anchor.item, anchor.window doesn't default rect to the
|
||||||
|
// window's own bounds — set it explicitly so edges/gravity below
|
||||||
|
// resolve against the full bar (and thus the true screen edges)
|
||||||
|
// instead of a near-zero rect at the window's origin.
|
||||||
|
rect.x: 0
|
||||||
|
rect.y: 0
|
||||||
|
rect.width: root.barWindow.width
|
||||||
|
rect.height: root.barWindow.height
|
||||||
|
edges: Edges.Bottom | Edges.Right
|
||||||
|
gravity: Edges.Bottom | Edges.Left
|
||||||
|
margins.bottom: -4
|
||||||
|
margins.right: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
HyprlandFocusGrab {
|
||||||
|
id: grab
|
||||||
|
windows: [menu]
|
||||||
|
onCleared: menu.visible = false
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
radius: 0
|
||||||
|
color: Colors.surface
|
||||||
|
border.color: Colors.muted
|
||||||
|
border.width: 3
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: mainColumn
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: menu.contentPadding
|
||||||
|
spacing: 10
|
||||||
|
|
||||||
|
// Header — same title layout as Volume.qml's panel: the bar
|
||||||
|
// icon large, plus a bold title.
|
||||||
|
RowLayout {
|
||||||
|
id: header
|
||||||
|
width: parent.width
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: label.text
|
||||||
|
font.family: Appearance.iconFontFamily
|
||||||
|
font.variableAxes: Appearance.iconFontAxes
|
||||||
|
font.pixelSize: 30
|
||||||
|
color: Colors.foreground
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: "Power"
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: 18
|
||||||
|
font.bold: true
|
||||||
|
color: Colors.foreground
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
width: parent.width
|
||||||
|
spacing: 2
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
id: list
|
||||||
|
model: [
|
||||||
|
{ label: "Lock", icon: "lock", cmd: ["hyprlock"] },
|
||||||
|
{ label: "Suspend", icon: "bedtime", cmd: ["systemctl", "suspend"] },
|
||||||
|
{ label: "Hibernate", icon: "ac_unit", cmd: ["systemctl", "hibernate"] },
|
||||||
|
{ label: "Log out", icon: "logout", cmd: ["hyprctl", "dispatch", "exit"] },
|
||||||
|
{ label: "Reboot", icon: "restart_alt", cmd: ["systemctl", "reboot"] },
|
||||||
|
{ label: "Shut down", icon: "power_settings_new", cmd: ["systemctl", "poweroff"] }
|
||||||
|
]
|
||||||
|
|
||||||
|
delegate: Rectangle {
|
||||||
|
id: entry
|
||||||
|
required property var modelData
|
||||||
|
width: parent ? parent.width : 0
|
||||||
|
height: 40
|
||||||
|
radius: Appearance.radiusSmall
|
||||||
|
color: (!entry.modelData.disabled && entryMouse.containsMouse) ? Colors.primary : "transparent"
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.leftMargin: 8
|
||||||
|
spacing: 8
|
||||||
|
Text {
|
||||||
|
Layout.preferredWidth: 25
|
||||||
|
Layout.minimumWidth: 25
|
||||||
|
Layout.maximumWidth: 25
|
||||||
|
horizontalAlignment: Text.AlignLeft
|
||||||
|
text: entry.modelData.icon
|
||||||
|
font.family: Appearance.iconFontFamily
|
||||||
|
font.variableAxes: Appearance.iconFontAxes
|
||||||
|
font.pixelSize: entry.modelData.iconSize || 15
|
||||||
|
opacity: entry.modelData.disabled ? 0.4 : 1
|
||||||
|
color: entryMouse.containsMouse ? Colors.primaryText : Colors.foreground
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
Layout.leftMargin: menu.labelOffset + (entry.modelData.textOffset || 0)
|
||||||
|
text: entry.modelData.label
|
||||||
|
horizontalAlignment: Text.AlignLeft
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: 16
|
||||||
|
opacity: entry.modelData.disabled ? 0.4 : 1
|
||||||
|
color: entryMouse.containsMouse ? Colors.primaryText : Colors.foreground
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: entryMouse
|
||||||
|
anchors.fill: parent
|
||||||
|
enabled: !entry.modelData.disabled
|
||||||
|
hoverEnabled: true
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onClicked: {
|
||||||
|
proc.command = entry.modelData.cmd
|
||||||
|
proc.running = true
|
||||||
|
menu.visible = false
|
||||||
|
grab.active = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1095
dotfiles/.config/quickshell/modules/bar/TailscaleIndicator.qml
Normal file
1095
dotfiles/.config/quickshell/modules/bar/TailscaleIndicator.qml
Normal file
File diff suppressed because it is too large
Load Diff
32
dotfiles/.config/quickshell/modules/bar/Tray.qml
Normal file
32
dotfiles/.config/quickshell/modules/bar/Tray.qml
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import Quickshell.Services.SystemTray
|
||||||
|
import Quickshell.Widgets
|
||||||
|
import "../../config"
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
spacing: Appearance.spacing * 0.6
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: SystemTray.items
|
||||||
|
|
||||||
|
delegate: IconImage {
|
||||||
|
id: trayIcon
|
||||||
|
required property var modelData
|
||||||
|
implicitWidth: 16
|
||||||
|
implicitHeight: 16
|
||||||
|
source: modelData.icon
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
anchors.fill: parent
|
||||||
|
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
||||||
|
onClicked: (mouse) => {
|
||||||
|
if (mouse.button === Qt.LeftButton)
|
||||||
|
trayIcon.modelData.activate()
|
||||||
|
else
|
||||||
|
trayIcon.modelData.secondaryActivate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
446
dotfiles/.config/quickshell/modules/bar/Volume.qml
Normal file
446
dotfiles/.config/quickshell/modules/bar/Volume.qml
Normal file
@@ -0,0 +1,446 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Hyprland
|
||||||
|
import Quickshell.Services.Pipewire
|
||||||
|
import "../../config"
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
implicitWidth: label.implicitWidth + 4
|
||||||
|
implicitHeight: Appearance.barHeight
|
||||||
|
|
||||||
|
// The bar's own PanelWindow — passed in from Bar.qml so this popup can
|
||||||
|
// anchor to the bar itself rather than to this icon, keeping it a fixed
|
||||||
|
// distance from the bar and the screen edge regardless of where this
|
||||||
|
// icon sits in the row.
|
||||||
|
required property var barWindow
|
||||||
|
|
||||||
|
readonly property var sink: Pipewire.defaultAudioSink
|
||||||
|
readonly property var source: Pipewire.defaultAudioSource
|
||||||
|
readonly property var nodes: Pipewire.nodes ? Pipewire.nodes.values : []
|
||||||
|
|
||||||
|
readonly property real outputVolume: (root.sink && root.sink.audio) ? root.sink.audio.volume : 0
|
||||||
|
readonly property bool outputMuted: (root.sink && root.sink.audio) ? root.sink.audio.muted : false
|
||||||
|
readonly property real inputVolume: (root.source && root.source.audio) ? root.source.audio.volume : 0
|
||||||
|
readonly property bool inputMuted: (root.source && root.source.audio) ? root.source.audio.muted : false
|
||||||
|
|
||||||
|
readonly property var candidateSinks: root.nodes.filter(function(n) { return n && n.isSink && !n.isStream })
|
||||||
|
readonly property var candidateSources: root.nodes.filter(function(n) { return n && !n.isSink && !n.isStream && n.audio })
|
||||||
|
|
||||||
|
PwObjectTracker { objects: root.candidateSinks }
|
||||||
|
PwObjectTracker { objects: root.candidateSources }
|
||||||
|
|
||||||
|
// Live mic level for the input gauge below — separate from inputVolume
|
||||||
|
// (the configured gain), this tracks the actual incoming signal.
|
||||||
|
// Only runs while the panel is open, to skip metering when unused.
|
||||||
|
PwNodePeakMonitor {
|
||||||
|
id: inputPeak
|
||||||
|
node: root.source
|
||||||
|
enabled: panel.visible
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Device label/icon heuristics, ported from omarchy's audio panel
|
||||||
|
// Model.js (github.com/basecamp/omarchy shell/plugins/panels/audio/Model.js),
|
||||||
|
// adapted to this project's Material Symbols icon set instead of Nerd Font.
|
||||||
|
function friendlyDeviceLabel(text) {
|
||||||
|
var out = String(text || "").trim()
|
||||||
|
out = out.replace(/^sof-soundwire\s+/i, "")
|
||||||
|
out = out.replace(/^built-?in audio\s+/i, "")
|
||||||
|
out = out.replace(/\s+Output$/i, "")
|
||||||
|
out = out.replace(/\s+Input$/i, "")
|
||||||
|
out = out.replace(/Microphones/gi, "Microphone")
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function deviceLabel(node) {
|
||||||
|
if (!node) return "Unknown"
|
||||||
|
var nick = friendlyDeviceLabel(node.nickname || "")
|
||||||
|
if (nick) return nick
|
||||||
|
return friendlyDeviceLabel(node.description || node.name || "Unknown")
|
||||||
|
}
|
||||||
|
|
||||||
|
function deviceBlob(node) {
|
||||||
|
if (!node) return ""
|
||||||
|
var p = node.properties || {}
|
||||||
|
return [node.name, node.description, node.nickname,
|
||||||
|
p["device.icon-name"] || "", p["device.product.name"] || ""].join(" ").toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHeadphoneNode(node) {
|
||||||
|
var blob = deviceBlob(node)
|
||||||
|
return blob.indexOf("headphone") !== -1 || blob.indexOf("headset") !== -1
|
||||||
|
|| blob.indexOf("earbud") !== -1 || blob.indexOf("earphone") !== -1
|
||||||
|
|| blob.indexOf("airpod") !== -1
|
||||||
|
}
|
||||||
|
|
||||||
|
function sinkIcon(node) {
|
||||||
|
if (!node) return "speaker"
|
||||||
|
if (isHeadphoneNode(node)) return "headphones"
|
||||||
|
var blob = deviceBlob(node)
|
||||||
|
if (blob.indexOf("bluetooth") !== -1) return "bluetooth_audio"
|
||||||
|
if (blob.indexOf("hdmi") !== -1 || blob.indexOf("display") !== -1) return "tv"
|
||||||
|
return "speaker"
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceIcon(node) {
|
||||||
|
if (!node) return "mic"
|
||||||
|
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"
|
||||||
|
return "mic"
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: label
|
||||||
|
anchors.centerIn: parent
|
||||||
|
font.family: Appearance.iconFontFamily
|
||||||
|
font.variableAxes: Appearance.iconFontAxes
|
||||||
|
font.pixelSize: 16
|
||||||
|
color: Colors.foreground
|
||||||
|
text: (root.outputMuted || root.outputVolume <= 0) ? "volume_off"
|
||||||
|
: (root.outputVolume < 0.5 ? "volume_down" : "volume_up")
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
anchors.fill: parent
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onClicked: {
|
||||||
|
panel.visible = !panel.visible
|
||||||
|
grab.active = panel.visible
|
||||||
|
}
|
||||||
|
onWheel: (wheel) => {
|
||||||
|
if (!root.sink || !root.sink.audio) return
|
||||||
|
const step = 0.05
|
||||||
|
const delta = wheel.angleDelta.y > 0 ? step : -step
|
||||||
|
root.sink.audio.volume = Math.max(0, Math.min(1, root.sink.audio.volume + delta))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Reusable pieces -----------------------------------------------
|
||||||
|
|
||||||
|
component VolumeSlider: Item {
|
||||||
|
id: sliderRoot
|
||||||
|
property real value: 0
|
||||||
|
property bool enabled: true
|
||||||
|
signal moved(real newValue)
|
||||||
|
implicitHeight: 16
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: track
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: parent.width
|
||||||
|
height: 4
|
||||||
|
radius: 0
|
||||||
|
color: Colors.muted
|
||||||
|
opacity: 0.35
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
anchors.left: parent.left
|
||||||
|
width: track.width * Math.max(0, Math.min(1, sliderRoot.value))
|
||||||
|
height: 4
|
||||||
|
radius: 0
|
||||||
|
color: Colors.primary
|
||||||
|
opacity: sliderRoot.enabled ? 1 : 0.4
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
width: 12
|
||||||
|
height: 12
|
||||||
|
radius: 6
|
||||||
|
color: Colors.primary
|
||||||
|
opacity: sliderRoot.enabled ? 1 : 0.4
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
x: Math.max(0, Math.min(track.width - width, track.width * sliderRoot.value - width / 2))
|
||||||
|
}
|
||||||
|
MouseArea {
|
||||||
|
anchors.fill: parent
|
||||||
|
enabled: sliderRoot.enabled
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
function updateFromX(mx) {
|
||||||
|
sliderRoot.moved(Math.max(0, Math.min(1, mx / track.width)))
|
||||||
|
}
|
||||||
|
onPressed: (mouse) => updateFromX(mouse.x)
|
||||||
|
onPositionChanged: (mouse) => { if (pressed) updateFromX(mouse.x) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read-only live level gauge — same track styling as VolumeSlider but no
|
||||||
|
// handle/interaction, filled from an actual signal level instead of a
|
||||||
|
// configured value.
|
||||||
|
component LevelMeter: Item {
|
||||||
|
id: meterRoot
|
||||||
|
property real level: 0
|
||||||
|
implicitHeight: 4
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: parent.width
|
||||||
|
height: 4
|
||||||
|
radius: 0
|
||||||
|
color: Colors.muted
|
||||||
|
opacity: 0.25
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
anchors.left: parent.left
|
||||||
|
height: 4
|
||||||
|
radius: 0
|
||||||
|
color: Colors.paletteColor(2)
|
||||||
|
width: parent.width * Math.max(0, Math.min(1, meterRoot.level))
|
||||||
|
Behavior on width { NumberAnimation { duration: 80 } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
component ToggleSwitch: Item {
|
||||||
|
id: toggleRoot
|
||||||
|
property bool checked: false
|
||||||
|
signal toggled()
|
||||||
|
implicitWidth: 34
|
||||||
|
implicitHeight: 18
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
radius: 0
|
||||||
|
color: toggleRoot.checked ? Colors.primary : Colors.muted
|
||||||
|
opacity: toggleRoot.checked ? 1 : 0.35
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
width: parent.height - 4
|
||||||
|
height: parent.height - 4
|
||||||
|
radius: 0
|
||||||
|
color: Colors.background
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
x: toggleRoot.checked ? parent.width - width - 2 : 2
|
||||||
|
Behavior on x { NumberAnimation { duration: Appearance.animationDuration } }
|
||||||
|
}
|
||||||
|
MouseArea {
|
||||||
|
anchors.fill: parent
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onClicked: toggleRoot.toggled()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
component DeviceRow: Rectangle {
|
||||||
|
id: deviceRow
|
||||||
|
required property var node
|
||||||
|
required property string icon
|
||||||
|
required property bool active
|
||||||
|
signal activate()
|
||||||
|
width: parent ? parent.width : 0
|
||||||
|
height: 30
|
||||||
|
radius: 0
|
||||||
|
color: rowMouse.containsMouse ? Colors.primary
|
||||||
|
: (deviceRow.active ? Qt.rgba(Colors.primary.r, Colors.primary.g, Colors.primary.b, 0.18) : "transparent")
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.leftMargin: 8
|
||||||
|
anchors.rightMargin: 8
|
||||||
|
spacing: 8
|
||||||
|
Text {
|
||||||
|
Layout.preferredWidth: 18
|
||||||
|
Layout.minimumWidth: 18
|
||||||
|
Layout.maximumWidth: 18
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
text: deviceRow.icon
|
||||||
|
font.family: Appearance.iconFontFamily
|
||||||
|
font.variableAxes: Appearance.iconFontAxes
|
||||||
|
font.pixelSize: 14
|
||||||
|
color: rowMouse.containsMouse ? Colors.primaryText : Colors.foreground
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: root.deviceLabel(deviceRow.node)
|
||||||
|
elide: Text.ElideRight
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSize
|
||||||
|
font.bold: deviceRow.active
|
||||||
|
color: rowMouse.containsMouse ? Colors.primaryText : Colors.foreground
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MouseArea {
|
||||||
|
id: rowMouse
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onClicked: deviceRow.activate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Panel -----------------------------------------------------------
|
||||||
|
|
||||||
|
PopupWindow {
|
||||||
|
id: panel
|
||||||
|
visible: false
|
||||||
|
color: "transparent"
|
||||||
|
// Padding between the panel's border and its content. implicitWidth
|
||||||
|
// is contentWidth + padding on both sides, so the content area
|
||||||
|
// itself stays a fixed size regardless of how much padding surrounds it.
|
||||||
|
readonly property int contentWidth: 244
|
||||||
|
readonly property int contentPadding: 16
|
||||||
|
implicitWidth: contentWidth + contentPadding * 2
|
||||||
|
implicitHeight: mainColumn.implicitHeight + contentPadding * 2
|
||||||
|
|
||||||
|
anchor {
|
||||||
|
window: root.barWindow
|
||||||
|
// Unlike anchor.item, anchor.window doesn't default rect to the
|
||||||
|
// window's own bounds — set it explicitly so edges/gravity below
|
||||||
|
// resolve against the full bar (and thus the true screen edges)
|
||||||
|
// instead of a near-zero rect at the window's origin.
|
||||||
|
rect.x: 0
|
||||||
|
rect.y: 0
|
||||||
|
rect.width: root.barWindow.width
|
||||||
|
rect.height: root.barWindow.height
|
||||||
|
edges: Edges.Bottom | Edges.Right
|
||||||
|
gravity: Edges.Bottom | Edges.Left
|
||||||
|
margins.bottom: -4
|
||||||
|
margins.right: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
HyprlandFocusGrab {
|
||||||
|
id: grab
|
||||||
|
windows: [panel]
|
||||||
|
onCleared: panel.visible = false
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
radius: 0
|
||||||
|
color: Colors.surface
|
||||||
|
border.color: Colors.muted
|
||||||
|
border.width: 3
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: mainColumn
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: panel.contentPadding
|
||||||
|
spacing: 10
|
||||||
|
|
||||||
|
// Header
|
||||||
|
RowLayout {
|
||||||
|
width: parent.width
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: label.text
|
||||||
|
font.family: Appearance.iconFontFamily
|
||||||
|
font.variableAxes: Appearance.iconFontAxes
|
||||||
|
font.pixelSize: 40
|
||||||
|
color: Colors.foreground
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: "Audio"
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: 18
|
||||||
|
font.bold: true
|
||||||
|
color: Colors.foreground
|
||||||
|
}
|
||||||
|
|
||||||
|
ToggleSwitch {
|
||||||
|
checked: !root.outputMuted
|
||||||
|
onToggled: if (root.sink && root.sink.audio) root.sink.audio.muted = !root.sink.audio.muted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output
|
||||||
|
Column {
|
||||||
|
width: parent.width
|
||||||
|
spacing: 6
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
width: parent.width
|
||||||
|
Text {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: "OUTPUT"
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSizeSmall
|
||||||
|
color: Colors.muted
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: Math.round(root.outputVolume * 100) + "%"
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSizeSmall
|
||||||
|
color: Colors.muted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VolumeSlider {
|
||||||
|
width: parent.width
|
||||||
|
value: root.outputVolume
|
||||||
|
enabled: !!root.sink
|
||||||
|
onMoved: (v) => { if (root.sink && root.sink.audio) root.sink.audio.volume = v }
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
width: parent.width
|
||||||
|
spacing: 2
|
||||||
|
Repeater {
|
||||||
|
model: root.candidateSinks
|
||||||
|
DeviceRow {
|
||||||
|
required property var modelData
|
||||||
|
node: modelData
|
||||||
|
icon: root.sinkIcon(modelData)
|
||||||
|
active: !!(root.sink && modelData && root.sink.id === modelData.id)
|
||||||
|
onActivate: Pipewire.preferredDefaultAudioSink = modelData
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Input
|
||||||
|
Column {
|
||||||
|
width: parent.width
|
||||||
|
spacing: 6
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
width: parent.width
|
||||||
|
Text {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: "INPUT"
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSizeSmall
|
||||||
|
color: Colors.muted
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: Math.round(root.inputVolume * 100) + "%"
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSizeSmall
|
||||||
|
color: Colors.muted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VolumeSlider {
|
||||||
|
width: parent.width
|
||||||
|
value: root.inputVolume
|
||||||
|
enabled: !!root.source
|
||||||
|
onMoved: (v) => { if (root.source && root.source.audio) root.source.audio.volume = v }
|
||||||
|
}
|
||||||
|
|
||||||
|
LevelMeter {
|
||||||
|
width: parent.width
|
||||||
|
level: inputPeak.peak
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
width: parent.width
|
||||||
|
spacing: 2
|
||||||
|
Repeater {
|
||||||
|
model: root.candidateSources
|
||||||
|
DeviceRow {
|
||||||
|
required property var modelData
|
||||||
|
node: modelData
|
||||||
|
icon: root.sourceIcon(modelData)
|
||||||
|
active: !!(root.source && modelData && root.source.id === modelData.id)
|
||||||
|
onActivate: Pipewire.preferredDefaultAudioSource = modelData
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
554
dotfiles/.config/quickshell/modules/bar/VpnIndicator.qml
Normal file
554
dotfiles/.config/quickshell/modules/bar/VpnIndicator.qml
Normal file
@@ -0,0 +1,554 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Hyprland
|
||||||
|
import Quickshell.Io
|
||||||
|
import "../../config"
|
||||||
|
|
||||||
|
// A switcher between commercial VPN providers, distinct from
|
||||||
|
// TailscaleIndicator.qml — Tailscale is a mesh overlay network you generally
|
||||||
|
// leave running; these are traditional single-tunnel VPN clients where only
|
||||||
|
// one makes sense to have active at a time. Connecting one here disconnects
|
||||||
|
// the other first.
|
||||||
|
//
|
||||||
|
// V1 scope: connect (provider's own "fastest"/default) and disconnect per
|
||||||
|
// provider, live status, no region/server picker yet. Provider CLIs:
|
||||||
|
// - Mullvad: official `mullvad` CLI (mullvad-vpn-daemon package), has
|
||||||
|
// real JSON status output.
|
||||||
|
// - ProtonVPN: official `protonvpn` CLI (proton-vpn-cli package), plain-
|
||||||
|
// text status only — parsed defensively since its exact wording isn't
|
||||||
|
// guaranteed across versions.
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
implicitWidth: label.implicitWidth + 4
|
||||||
|
implicitHeight: Appearance.barHeight
|
||||||
|
|
||||||
|
required property var barWindow
|
||||||
|
|
||||||
|
property int refreshIntervalSec: 20
|
||||||
|
readonly property int _refreshIntervalMs: Math.max(5, Math.min(3600, refreshIntervalSec)) * 1000
|
||||||
|
|
||||||
|
// Whether each CLI is on PATH at all. Starts optimistic so the panel
|
||||||
|
// doesn't flash "not installed" before the first `which` check lands.
|
||||||
|
property bool mullvadInstalled: true
|
||||||
|
property bool protonInstalled: true
|
||||||
|
|
||||||
|
// "disconnected" | "connecting" | "connected" | "disconnecting"
|
||||||
|
property string mullvadState: "disconnected"
|
||||||
|
property string mullvadDetail: ""
|
||||||
|
property string protonState: "disconnected"
|
||||||
|
property string protonDetail: ""
|
||||||
|
|
||||||
|
readonly property bool anyConnected: mullvadState === "connected" || protonState === "connected"
|
||||||
|
|
||||||
|
// Set while switching from one provider to the other: holds which one
|
||||||
|
// to connect once the current one has finished disconnecting.
|
||||||
|
property string pendingConnect: ""
|
||||||
|
|
||||||
|
// Surfaced under the header for a failed connect/disconnect; cleared
|
||||||
|
// automatically after a few seconds.
|
||||||
|
property string actionStatus: ""
|
||||||
|
|
||||||
|
function shortLine(text) {
|
||||||
|
const line = String(text || "").trim().split("\n")[0] || ""
|
||||||
|
return line.length > 80 ? line.substring(0, 77) + "…" : line
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyMullvadStatus(text) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(text)
|
||||||
|
const state = String(data.state || "disconnected")
|
||||||
|
const loc = data.details && data.details.location
|
||||||
|
root.mullvadState = state
|
||||||
|
root.mullvadDetail = loc ? [loc.city, loc.country].filter(Boolean).join(", ") : ""
|
||||||
|
} catch (e) {
|
||||||
|
root.mullvadState = "disconnected"
|
||||||
|
root.mullvadDetail = ""
|
||||||
|
}
|
||||||
|
VpnExclusivity.mullvadConnected = root.mullvadState === "connected"
|
||||||
|
}
|
||||||
|
|
||||||
|
// No --json here — ProtonVPN's CLI only prints plain text, so this
|
||||||
|
// just looks for a "Status: …" line and, best-effort, a following
|
||||||
|
// "Server:"/"Country:" line if the CLI happens to print one.
|
||||||
|
function applyProtonStatus(text) {
|
||||||
|
const body = String(text || "")
|
||||||
|
const statusMatch = body.match(/^Status:\s*(.+)$/mi)
|
||||||
|
const raw = statusMatch ? statusMatch[1].trim().toLowerCase() : ""
|
||||||
|
root.protonState = raw.indexOf("connected") === 0 ? "connected" : "disconnected"
|
||||||
|
const serverMatch = body.match(/^Server:\s*(.+)$/mi)
|
||||||
|
const countryMatch = body.match(/^Country:\s*(.+)$/mi)
|
||||||
|
root.protonDetail = serverMatch ? serverMatch[1].trim() : (countryMatch ? countryMatch[1].trim() : "")
|
||||||
|
VpnExclusivity.protonConnected = root.protonState === "connected"
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshMullvad() {
|
||||||
|
if (!root.mullvadInstalled) {
|
||||||
|
if (!mullvadWhichProc.running) mullvadWhichProc.running = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!mullvadStatusProc.running) {
|
||||||
|
mullvadStatusProc.running = true
|
||||||
|
mullvadPollWatchdog.restart()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshProton() {
|
||||||
|
if (!root.protonInstalled) {
|
||||||
|
if (!protonWhichProc.running) protonWhichProc.running = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!protonStatusProc.running) {
|
||||||
|
protonStatusProc.running = true
|
||||||
|
protonPollWatchdog.restart()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
root.refreshMullvad()
|
||||||
|
root.refreshProton()
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectMullvad() {
|
||||||
|
if (mullvadConnectProc.running || mullvadDisconnectProc.running) return
|
||||||
|
if (root.protonState !== "disconnected") {
|
||||||
|
root.pendingConnect = "mullvad"
|
||||||
|
root.disconnectProton()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
root.pendingConnect = ""
|
||||||
|
mullvadConnectProc.running = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectProton() {
|
||||||
|
if (protonConnectProc.running || protonDisconnectProc.running) return
|
||||||
|
if (root.mullvadState !== "disconnected") {
|
||||||
|
root.pendingConnect = "protonvpn"
|
||||||
|
root.disconnectMullvad()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
root.pendingConnect = ""
|
||||||
|
protonConnectProc.running = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function disconnectMullvad() {
|
||||||
|
if (mullvadDisconnectProc.running) return
|
||||||
|
mullvadDisconnectProc.running = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function disconnectProton() {
|
||||||
|
if (protonDisconnectProc.running) return
|
||||||
|
protonDisconnectProc.running = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleMullvad() {
|
||||||
|
if (mullvadConnectProc.running || mullvadDisconnectProc.running) return
|
||||||
|
if (root.mullvadState === "connected") root.disconnectMullvad()
|
||||||
|
else root.connectMullvad()
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleProton() {
|
||||||
|
if (protonConnectProc.running || protonDisconnectProc.running) return
|
||||||
|
if (root.protonState === "connected") root.disconnectProton()
|
||||||
|
else root.connectProton()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tailscale fights Mullvad/ProtonVPN for the default route/DNS, so fire
|
||||||
|
// this right after either one finishes connecting — a harmless no-op
|
||||||
|
// if Tailscale was already down. TailscaleIndicator.qml also backs
|
||||||
|
// itself off reactively (via VpnExclusivity) as a backstop, but doing
|
||||||
|
// it here too avoids waiting out its poll interval.
|
||||||
|
Process {
|
||||||
|
id: tailscaleDownProc
|
||||||
|
command: ["tailscale", "down"]
|
||||||
|
stdout: StdioCollector {}
|
||||||
|
stderr: StdioCollector {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Mullvad ---------------------------------------------------------
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: mullvadWhichProc
|
||||||
|
command: ["which", "mullvad"]
|
||||||
|
running: true
|
||||||
|
stdout: StdioCollector {}
|
||||||
|
onExited: (exitCode, exitStatus) => {
|
||||||
|
root.mullvadInstalled = exitCode === 0
|
||||||
|
if (root.mullvadInstalled) root.refreshMullvad()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: mullvadStatusProc
|
||||||
|
command: ["mullvad", "status", "--json"]
|
||||||
|
stdout: StdioCollector {
|
||||||
|
id: mullvadStatusCollector
|
||||||
|
onStreamFinished: root.applyMullvadStatus(mullvadStatusCollector.text)
|
||||||
|
}
|
||||||
|
onExited: (exitCode, exitStatus) => {
|
||||||
|
if (exitCode !== 0 && !mullvadWhichProc.running) mullvadWhichProc.running = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: mullvadConnectProc
|
||||||
|
command: ["mullvad", "connect", "--wait"]
|
||||||
|
stdout: StdioCollector {}
|
||||||
|
stderr: StdioCollector { id: mullvadConnectErr }
|
||||||
|
onExited: (exitCode, exitStatus) => {
|
||||||
|
if (exitCode !== 0) {
|
||||||
|
root.actionStatus = "Mullvad: " + root.shortLine(mullvadConnectErr.text || "connect failed")
|
||||||
|
actionStatusTimer.restart()
|
||||||
|
} else if (!tailscaleDownProc.running) {
|
||||||
|
tailscaleDownProc.running = true
|
||||||
|
}
|
||||||
|
root.refreshMullvad()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: mullvadDisconnectProc
|
||||||
|
command: ["mullvad", "disconnect", "--wait"]
|
||||||
|
stdout: StdioCollector {}
|
||||||
|
stderr: StdioCollector { id: mullvadDisconnectErr }
|
||||||
|
onExited: (exitCode, exitStatus) => {
|
||||||
|
if (exitCode !== 0) {
|
||||||
|
root.actionStatus = "Mullvad: " + root.shortLine(mullvadDisconnectErr.text || "disconnect failed")
|
||||||
|
actionStatusTimer.restart()
|
||||||
|
}
|
||||||
|
root.refreshMullvad()
|
||||||
|
if (root.pendingConnect === "mullvad") { root.pendingConnect = ""; root.connectMullvad() }
|
||||||
|
else if (root.pendingConnect === "protonvpn") { root.pendingConnect = ""; root.connectProton() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: mullvadPollWatchdog
|
||||||
|
interval: 15000
|
||||||
|
repeat: false
|
||||||
|
onTriggered: if (mullvadStatusProc.running) mullvadStatusProc.running = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ProtonVPN ---------------------------------------------------------
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: protonWhichProc
|
||||||
|
command: ["which", "protonvpn"]
|
||||||
|
running: true
|
||||||
|
stdout: StdioCollector {}
|
||||||
|
onExited: (exitCode, exitStatus) => {
|
||||||
|
root.protonInstalled = exitCode === 0
|
||||||
|
if (root.protonInstalled) root.refreshProton()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: protonStatusProc
|
||||||
|
command: ["protonvpn", "status"]
|
||||||
|
stdout: StdioCollector {
|
||||||
|
id: protonStatusCollector
|
||||||
|
onStreamFinished: root.applyProtonStatus(protonStatusCollector.text)
|
||||||
|
}
|
||||||
|
onExited: (exitCode, exitStatus) => {
|
||||||
|
if (exitCode !== 0 && !protonWhichProc.running) protonWhichProc.running = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: protonConnectProc
|
||||||
|
command: ["protonvpn", "connect"]
|
||||||
|
stdout: StdioCollector {}
|
||||||
|
stderr: StdioCollector { id: protonConnectErr }
|
||||||
|
onExited: (exitCode, exitStatus) => {
|
||||||
|
if (exitCode !== 0) {
|
||||||
|
root.actionStatus = "ProtonVPN: " + root.shortLine(protonConnectErr.text || "connect failed")
|
||||||
|
actionStatusTimer.restart()
|
||||||
|
} else if (!tailscaleDownProc.running) {
|
||||||
|
tailscaleDownProc.running = true
|
||||||
|
}
|
||||||
|
root.refreshProton()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: protonDisconnectProc
|
||||||
|
command: ["protonvpn", "disconnect"]
|
||||||
|
stdout: StdioCollector {}
|
||||||
|
stderr: StdioCollector { id: protonDisconnectErr }
|
||||||
|
onExited: (exitCode, exitStatus) => {
|
||||||
|
if (exitCode !== 0) {
|
||||||
|
root.actionStatus = "ProtonVPN: " + root.shortLine(protonDisconnectErr.text || "disconnect failed")
|
||||||
|
actionStatusTimer.restart()
|
||||||
|
}
|
||||||
|
root.refreshProton()
|
||||||
|
if (root.pendingConnect === "mullvad") { root.pendingConnect = ""; root.connectMullvad() }
|
||||||
|
else if (root.pendingConnect === "protonvpn") { root.pendingConnect = ""; root.connectProton() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: protonPollWatchdog
|
||||||
|
interval: 15000
|
||||||
|
repeat: false
|
||||||
|
onTriggered: if (protonStatusProc.running) protonStatusProc.running = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Shared timers -----------------------------------------------------
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: refreshTimer
|
||||||
|
interval: root._refreshIntervalMs
|
||||||
|
repeat: true
|
||||||
|
running: true
|
||||||
|
triggeredOnStart: true
|
||||||
|
onTriggered: root.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: actionStatusTimer
|
||||||
|
interval: 4000
|
||||||
|
repeat: false
|
||||||
|
onTriggered: root.actionStatus = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Bar icon ------------------------------------------------------------
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: label
|
||||||
|
anchors.centerIn: parent
|
||||||
|
font.family: Appearance.iconFontFamily
|
||||||
|
font.variableAxes: Appearance.iconFontAxes
|
||||||
|
font.pixelSize: Appearance.iconSize
|
||||||
|
color: root.anyConnected ? Colors.primary : Colors.muted
|
||||||
|
text: "vpn_lock"
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
anchors.fill: parent
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onClicked: {
|
||||||
|
panel.visible = !panel.visible
|
||||||
|
grab.active = panel.visible
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Reusable pieces -----------------------------------------------------
|
||||||
|
|
||||||
|
component ToggleSwitch: Item {
|
||||||
|
id: toggleRoot
|
||||||
|
property bool checked: false
|
||||||
|
property bool busy: false
|
||||||
|
signal toggled()
|
||||||
|
implicitWidth: 34
|
||||||
|
implicitHeight: 18
|
||||||
|
opacity: toggleRoot.enabled ? 1 : 0.4
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
radius: 0
|
||||||
|
color: toggleRoot.checked ? Colors.primary : Colors.muted
|
||||||
|
opacity: (toggleRoot.checked ? 1 : 0.35) * (toggleRoot.busy ? 0.6 : 1)
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
width: parent.height - 4
|
||||||
|
height: parent.height - 4
|
||||||
|
radius: 0
|
||||||
|
color: Colors.background
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
x: toggleRoot.checked ? parent.width - width - 2 : 2
|
||||||
|
Behavior on x { NumberAnimation { duration: Appearance.animationDuration } }
|
||||||
|
}
|
||||||
|
MouseArea {
|
||||||
|
anchors.fill: parent
|
||||||
|
enabled: !toggleRoot.busy
|
||||||
|
cursorShape: toggleRoot.busy ? Qt.ArrowCursor : Qt.PointingHandCursor
|
||||||
|
onClicked: toggleRoot.toggled()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A single provider's row — icon, name, live status line, and a toggle
|
||||||
|
// that connects/disconnects it (routed back through root so it can
|
||||||
|
// enforce the one-provider-at-a-time rule).
|
||||||
|
component ProviderRow: Rectangle {
|
||||||
|
id: provRow
|
||||||
|
property string label: ""
|
||||||
|
property string subIcon: "shield"
|
||||||
|
property string detailText: ""
|
||||||
|
property string state: "disconnected"
|
||||||
|
property bool installed: true
|
||||||
|
signal toggle()
|
||||||
|
width: parent ? parent.width : 0
|
||||||
|
radius: 0
|
||||||
|
implicitHeight: rowContent.implicitHeight + 12
|
||||||
|
color: provRow.state === "connected"
|
||||||
|
? Qt.rgba(Colors.primary.r, Colors.primary.g, Colors.primary.b, 0.18)
|
||||||
|
: "transparent"
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
id: rowContent
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
anchors.margins: 6
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
Text {
|
||||||
|
Layout.preferredWidth: 20
|
||||||
|
Layout.minimumWidth: 20
|
||||||
|
Layout.maximumWidth: 20
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
text: provRow.subIcon
|
||||||
|
font.family: Appearance.iconFontFamily
|
||||||
|
font.variableAxes: Appearance.iconFontAxes
|
||||||
|
font.pixelSize: 14
|
||||||
|
color: provRow.state === "connected" ? Colors.primary : Colors.foreground
|
||||||
|
}
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 1
|
||||||
|
|
||||||
|
Text {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: provRow.label
|
||||||
|
elide: Text.ElideRight
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSize
|
||||||
|
font.bold: provRow.state === "connected"
|
||||||
|
color: Colors.foreground
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: !provRow.installed ? "Not installed"
|
||||||
|
: provRow.state === "connecting" ? "Connecting…"
|
||||||
|
: provRow.state === "disconnecting" ? "Disconnecting…"
|
||||||
|
: provRow.state === "connected" ? (provRow.detailText || "Connected")
|
||||||
|
: "Disconnected"
|
||||||
|
elide: Text.ElideRight
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSizeSmall
|
||||||
|
color: Colors.muted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ToggleSwitch {
|
||||||
|
enabled: provRow.installed
|
||||||
|
checked: provRow.state === "connected" || provRow.state === "connecting"
|
||||||
|
busy: provRow.state === "connecting" || provRow.state === "disconnecting"
|
||||||
|
onToggled: provRow.toggle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Panel -----------------------------------------------------------
|
||||||
|
|
||||||
|
PopupWindow {
|
||||||
|
id: panel
|
||||||
|
visible: false
|
||||||
|
color: "transparent"
|
||||||
|
readonly property int contentWidth: 244
|
||||||
|
readonly property int contentPadding: 16
|
||||||
|
implicitWidth: contentWidth + contentPadding * 2
|
||||||
|
implicitHeight: mainColumn.implicitHeight + contentPadding * 2
|
||||||
|
Behavior on implicitHeight {
|
||||||
|
NumberAnimation { duration: Appearance.animationDuration; easing.type: Easing.OutCubic }
|
||||||
|
}
|
||||||
|
|
||||||
|
onVisibleChanged: if (visible) root.refresh()
|
||||||
|
|
||||||
|
anchor {
|
||||||
|
window: root.barWindow
|
||||||
|
rect.x: 0
|
||||||
|
rect.y: 0
|
||||||
|
rect.width: root.barWindow.width
|
||||||
|
rect.height: root.barWindow.height
|
||||||
|
edges: Edges.Bottom | Edges.Right
|
||||||
|
gravity: Edges.Bottom | Edges.Left
|
||||||
|
margins.bottom: -4
|
||||||
|
margins.right: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
HyprlandFocusGrab {
|
||||||
|
id: grab
|
||||||
|
windows: [panel]
|
||||||
|
onCleared: panel.visible = false
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
radius: 0
|
||||||
|
color: Colors.surface
|
||||||
|
border.color: Colors.muted
|
||||||
|
border.width: 3
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: mainColumn
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: panel.contentPadding
|
||||||
|
spacing: 10
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
width: parent.width
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: "vpn_lock"
|
||||||
|
font.family: Appearance.iconFontFamily
|
||||||
|
font.variableAxes: Appearance.iconFontAxes
|
||||||
|
font.pixelSize: 22
|
||||||
|
color: root.anyConnected ? Colors.primary : Colors.foreground
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: "VPN"
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: 18
|
||||||
|
font.bold: true
|
||||||
|
color: Colors.foreground
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
visible: root.actionStatus !== ""
|
||||||
|
width: parent.width
|
||||||
|
text: root.actionStatus
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSizeSmall
|
||||||
|
color: Colors.muted
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
width: parent.width
|
||||||
|
spacing: 2
|
||||||
|
|
||||||
|
ProviderRow {
|
||||||
|
label: "Mullvad"
|
||||||
|
subIcon: "shield"
|
||||||
|
state: root.mullvadState
|
||||||
|
detailText: root.mullvadDetail
|
||||||
|
installed: root.mullvadInstalled
|
||||||
|
onToggle: root.toggleMullvad()
|
||||||
|
}
|
||||||
|
ProviderRow {
|
||||||
|
label: "ProtonVPN"
|
||||||
|
subIcon: "gpp_good"
|
||||||
|
state: root.protonState
|
||||||
|
detailText: root.protonDetail
|
||||||
|
installed: root.protonInstalled
|
||||||
|
onToggle: root.toggleProton()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
text: "Connecting one disconnects the other — only one VPN tunnel at a time."
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSizeSmall
|
||||||
|
color: Colors.muted
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
44
dotfiles/.config/quickshell/modules/bar/Workspaces.qml
Normal file
44
dotfiles/.config/quickshell/modules/bar/Workspaces.qml
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import Quickshell.Hyprland
|
||||||
|
import "../../config"
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
id: root
|
||||||
|
property var screen
|
||||||
|
|
||||||
|
spacing: 4
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: Hyprland.workspaces
|
||||||
|
|
||||||
|
delegate: Rectangle {
|
||||||
|
id: pill
|
||||||
|
required property var modelData
|
||||||
|
visible: modelData.monitor && root.screen && modelData.monitor.name === root.screen.name
|
||||||
|
|
||||||
|
implicitWidth: 26
|
||||||
|
implicitHeight: 22
|
||||||
|
radius: Appearance.radiusSmall
|
||||||
|
color: modelData.urgent ? Colors.urgent
|
||||||
|
: modelData.active ? Colors.primary
|
||||||
|
: "transparent"
|
||||||
|
|
||||||
|
Behavior on color { ColorAnimation { duration: Appearance.animationDuration } }
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: modelData.name
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSizeSmall
|
||||||
|
color: modelData.active ? Colors.primaryText : Colors.foreground
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
anchors.fill: parent
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onClicked: Hyprland.dispatch("workspace " + modelData.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
125
dotfiles/.config/quickshell/modules/osd/VolumeOsd.qml
Normal file
125
dotfiles/.config/quickshell/modules/osd/VolumeOsd.qml
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import Quickshell
|
||||||
|
import Quickshell.Wayland
|
||||||
|
import Quickshell.Services.Pipewire
|
||||||
|
import QtQuick
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import "../../config"
|
||||||
|
|
||||||
|
PanelWindow {
|
||||||
|
id: osd
|
||||||
|
visible: false
|
||||||
|
color: "transparent"
|
||||||
|
exclusiveZone: 0
|
||||||
|
|
||||||
|
// Top-right, just under the bar — same fixed distance from the bar and
|
||||||
|
// screen edge (Appearance.spacing) that every popup opened from the bar
|
||||||
|
// itself uses, so this lines up with them instead of floating
|
||||||
|
// bottom-center.
|
||||||
|
anchors { top: true; right: true }
|
||||||
|
margins.top: Appearance.spacing
|
||||||
|
margins.right: Appearance.spacing
|
||||||
|
|
||||||
|
implicitWidth: 272
|
||||||
|
implicitHeight: 54
|
||||||
|
|
||||||
|
WlrLayershell.layer: WlrLayer.Overlay
|
||||||
|
WlrLayershell.namespace: "quickshell:osd"
|
||||||
|
|
||||||
|
readonly property var sink: Pipewire.defaultAudioSink
|
||||||
|
PwObjectTracker { objects: osd.sink ? [osd.sink] : [] }
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: hideTimer
|
||||||
|
interval: 1400
|
||||||
|
onTriggered: osd.visible = false
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: osd.sink ? osd.sink.audio : null
|
||||||
|
function onVolumeChanged() { osd.visible = true; hideTimer.restart() }
|
||||||
|
function onMutedChanged() { osd.visible = true; hideTimer.restart() }
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
radius: Appearance.radius
|
||||||
|
color: Colors.surface
|
||||||
|
border.color: Colors.muted
|
||||||
|
border.width: 1
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 14
|
||||||
|
spacing: 12
|
||||||
|
|
||||||
|
Text {
|
||||||
|
font.family: Appearance.iconFontFamily
|
||||||
|
font.variableAxes: Appearance.iconFontAxes
|
||||||
|
font.pixelSize: 18
|
||||||
|
color: Colors.foreground
|
||||||
|
text: {
|
||||||
|
const audio = osd.sink ? osd.sink.audio : null
|
||||||
|
if (!audio || audio.muted || audio.volume <= 0) return "volume_off"
|
||||||
|
return audio.volume < 0.5 ? "volume_down" : "volume_up"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: slider
|
||||||
|
Layout.fillWidth: true
|
||||||
|
implicitHeight: 16
|
||||||
|
|
||||||
|
readonly property real value: osd.sink && osd.sink.audio ? osd.sink.audio.volume : 0
|
||||||
|
readonly property bool enabled: !!osd.sink
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: track
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: parent.width
|
||||||
|
height: 4
|
||||||
|
radius: 0
|
||||||
|
color: Colors.muted
|
||||||
|
opacity: 0.35
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
anchors.left: parent.left
|
||||||
|
width: track.width * Math.max(0, Math.min(1, slider.value))
|
||||||
|
height: 4
|
||||||
|
radius: 0
|
||||||
|
color: Colors.primary
|
||||||
|
opacity: slider.enabled ? 1 : 0.4
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
width: 12
|
||||||
|
height: 12
|
||||||
|
radius: 6
|
||||||
|
color: Colors.primary
|
||||||
|
opacity: slider.enabled ? 1 : 0.4
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
x: Math.max(0, Math.min(track.width - width, track.width * slider.value - width / 2))
|
||||||
|
}
|
||||||
|
MouseArea {
|
||||||
|
anchors.fill: parent
|
||||||
|
enabled: slider.enabled
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
function updateFromX(mx) {
|
||||||
|
if (!osd.sink || !osd.sink.audio) return
|
||||||
|
osd.sink.audio.volume = Math.max(0, Math.min(1, mx / track.width))
|
||||||
|
}
|
||||||
|
onPressed: (mouse) => updateFromX(mouse.x)
|
||||||
|
onPositionChanged: (mouse) => { if (pressed) updateFromX(mouse.x) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
Layout.preferredWidth: 32
|
||||||
|
horizontalAlignment: Text.AlignRight
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSizeSmall
|
||||||
|
color: Colors.muted
|
||||||
|
text: Math.round(slider.value * 100) + "%"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
16
dotfiles/.config/quickshell/shell.qml
Normal file
16
dotfiles/.config/quickshell/shell.qml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import Quickshell
|
||||||
|
import "./modules/bar"
|
||||||
|
import "./modules/osd"
|
||||||
|
|
||||||
|
ShellRoot {
|
||||||
|
Variants {
|
||||||
|
model: Quickshell.screens
|
||||||
|
|
||||||
|
Bar {
|
||||||
|
required property var modelData
|
||||||
|
screen: modelData
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VolumeOsd {}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user