11 Commits

37 changed files with 1825 additions and 29 deletions

View File

@@ -59,7 +59,7 @@ List of programs and tools I use.
|📁 File Manager | [thunar](https://wiki.archlinux.org/title/Thunar) | |📁 File Manager | [thunar](https://wiki.archlinux.org/title/Thunar) |
|📝 Editor | [vs code](https://github.com/microsoft/vscode) / [neovim (changing to soon)](https://github.com/neovim/neovim) | |📝 Editor | [vs code](https://github.com/microsoft/vscode) / [neovim (changing to soon)](https://github.com/neovim/neovim) |
|🌐 Browser | [zen](https://github.com/zen-browser/desktop) / [firefox](https://github.com/topics/firefox-browser) / [startpage](https://blog-bucket-personal.s3.eu-west-2.amazonaws.com/Screenshot_02-May_08-44-23_6614.png) | |🌐 Browser | [zen](https://github.com/zen-browser/desktop) / [firefox](https://github.com/topics/firefox-browser) / [startpage](https://blog-bucket-personal.s3.eu-west-2.amazonaws.com/Screenshot_02-May_08-44-23_6614.png) |
|📊 Bar | [waybar](https://github.com/Alexays/Waybar) | |📊 Bar | [waybar](https://github.com/Alexays/Waybar) / [quickshell](https://quickshell.org/) |
|🚀 Launcher | [rofi](https://github.com/davatorium/rofi) | |🚀 Launcher | [rofi](https://github.com/davatorium/rofi) |
|🔒 Lockscreen | [hyprlock](https://github.com/hyprwm/hyprlock) | |🔒 Lockscreen | [hyprlock](https://github.com/hyprwm/hyprlock) |
|🚪 Login Menu | [sddm](https://github.com/sddm/sddm) | |🚪 Login Menu | [sddm](https://github.com/sddm/sddm) |

View File

@@ -2,8 +2,9 @@
-- Ported from UserConfigs/UserAnimations.conf (the active preset) -- Ported from UserConfigs/UserAnimations.conf (the active preset)
-- --
-- The 14 alternative presets from animations/*.conf were also ported, flat, as -- The 14 alternative presets from animations/*.conf were also ported, flat, as
-- anim-*.lua (see the plan) — none of them are wired in. To switch styles, replace -- anim-*.lua (see the plan) — none of them are wired in. They now live in ../presets/.
-- the body of this file with `require("anim-<preset>")`, e.g. require("anim-ml4w-classic"). -- To switch styles, replace the body of this file with `require("presets.anim-<preset>")`,
-- e.g. require("presets.anim-ml4w-classic").
hl.config({ hl.config({
animations = { animations = {

View File

@@ -6,7 +6,7 @@
-- Animation - https://wiki.hyprland.org/Configuring/Animations/ -- Animation - https://wiki.hyprland.org/Configuring/Animations/
-- Decoration - https://wiki.hyprland.org/Configuring/Variables/#decoration -- Decoration - https://wiki.hyprland.org/Configuring/Variables/#decoration
local colors = require("colors") local colors = require("appearance.colors")
hl.config({ hl.config({
general = { general = {

View File

@@ -33,6 +33,9 @@ hl.on("hyprland.start", function()
-- Starting hypridle to start hyprlock -- Starting hypridle to start hyprlock
hl.exec_cmd("hypridle") hl.exec_cmd("hypridle")
-- Float + center the Bitwarden extension pop-out (window rules can't catch it — see the script)
hl.exec_cmd(scriptsDir .. "/FloatBitwardenPopup.sh")
hl.dispatch(hl.dsp.focus({ workspace = 3 })) hl.dispatch(hl.dsp.focus({ workspace = 3 }))
-- NOTE: the trailing "&&" here is a stray leftover from the original .conf (dangling, does nothing) — ported as-is -- NOTE: the trailing "&&" here is a stray leftover from the original .conf (dangling, does nothing) — ported as-is

View File

@@ -2,9 +2,13 @@
-- Ported from hyprland.conf — see https://wiki.hyprland.org/ -- Ported from hyprland.conf — see https://wiki.hyprland.org/
-- --
-- This mirrors the old source= chain 1:1, just as require()'d Lua modules instead -- This mirrors the old source= chain 1:1, just as require()'d Lua modules instead
-- of hyprlang includes. See each file for its origin. The animations/ preset library -- of hyprlang includes. See each file for its origin. The presets/ preset library
-- (anim-*.lua) is also ported but intentionally not required from here — only the -- (anim-*.lua) is also ported but intentionally not required from here — only the
-- currently-active style (animations.lua) is wired in, exactly like before. -- currently-active style (appearance/animations.lua) is wired in, exactly like before.
--
-- Modules live in subfolders sorted by concern (core/, appearance/, layout/, input/,
-- presets/) — require() paths below use dotted module names, which Lua resolves to
-- the matching subfolder (e.g. require("core.env") -> core/env.lua).
-- Initial boot script: applies initial wallpapers, theming, new settings etc. -- Initial boot script: applies initial wallpapers, theming, new settings etc.
-- suggest not changing/deleting this, including the reference file at ~/.config/hypr/.initial_startup_done -- suggest not changing/deleting this, including the reference file at ~/.config/hypr/.initial_startup_done
@@ -14,17 +18,17 @@ hl.on("hyprland.start", function()
end) end)
-- ## This is where you want to start tinkering -- ## This is where you want to start tinkering
require("startup") -- put your start-up packages in startup.lua require("core.startup") -- put your start-up packages in core/startup.lua
require("env") -- Environment variables to load require("core.env") -- Environment variables to load
require("windowrules") -- all about Hyprland Window Rules and Layer Rules require("layout.windowrules") -- all about Hyprland Window Rules and Layer Rules
require("decorations") -- Decorations config require("appearance.decorations") -- Decorations config
require("animations") -- Animation config require("appearance.animations") -- Animation config
require("keybinds") -- Put your own keybinds here require("input.keybinds") -- Put your own keybinds here
require("settings") -- Main Hyprland Settings require("core.settings") -- Main Hyprland Settings
-- nwg-displays / hyprdynamicmonitors -- nwg-displays / hyprdynamicmonitors
require("monitors") require("layout.monitors")
require("workspaces") require("layout.workspaces")
-- NOTE: the original hyprland.conf used a plain `exec` (not `exec-once`) here, so it -- NOTE: the original hyprland.conf used a plain `exec` (not `exec-once`) here, so it
-- re-ran on every `hyprctl reload`, not just at startup. hl.on("hyprland.start", ...) -- re-ran on every `hyprctl reload`, not just at startup. hl.on("hyprland.start", ...)
@@ -35,4 +39,4 @@ hl.on("hyprland.start", function()
end) end)
-- HyprMod managed settings -- HyprMod managed settings
require("hyprmod") require("core.hyprmod")

View File

@@ -0,0 +1,73 @@
#!/bin/bash
# Float + resize (to 781x702) + center the Bitwarden browser-extension pop-out window.
#
# Why this exists instead of a window rule: Hyprland's `float`/`center`/`size`
# rules are "static" — evaluated exactly once, at the instant a window is
# created — and this popup opens with the generic title "Zen Browser" (same
# initialTitle/initialClass as the main browser window), only updating to
# "Extension: (Bitwarden Password Manager) - Bitwarden..." a moment after
# it's already mapped. By then the static rule has already missed its one
# chance and there's no field available at creation time to tell the popup
# apart from a normal browser window. See layout/windowrules.lua and
# https://github.com/hyprwm/Hyprland/issues/7402 for the same problem class.
#
# No socat/ncat is installed, so instead of an event-driven IPC listener this
# just polls `hyprctl clients` at a short interval with jq (already
# installed).
#
# Dispatch syntax note: Hyprland 0.55+'s Lua config changed `hyprctl dispatch`
# to take a literal Lua expression calling hl.dsp.* (e.g.
# `hyprctl dispatch 'hl.dsp.window.center()'`), not the old
# "centerwindow"-style dispatcher strings. Verified live against the real
# API in /usr/share/hypr/stubs/hl.meta.lua before wiring this in.
#
# Why it re-asserts for a few seconds instead of firing once: right after the
# popup leaves the tiled layout, Hyprland briefly re-tiles the sibling window
# to fill the freed space, and during that reflow this popup was observed
# reporting `floating: false` again for one poll tick — snapping it back to
# its old tiled geometry. Re-asserting float+center for a short settle window
# after first detection rides that out. After the settle window it's left
# alone, so a manual re-tile/move by you later isn't fought.
TITLE_PATTERN='Extension: \(Bitwarden Password Manager\)'
POLL_INTERVAL=0.3
SETTLE_TICKS=6 # ~2s of re-assertion after first detection, at POLL_INTERVAL
TARGET_WIDTH=781
TARGET_HEIGHT=702
declare -A ticks_left # address -> remaining re-assert ticks
while true; do
sleep "$POLL_INTERVAL"
# Forget any tracked window that's no longer open.
open_addresses=$(hyprctl clients -j | jq -r '.[].address')
for addr in "${!ticks_left[@]}"; do
grep -qF "$addr" <<<"$open_addresses" || unset 'ticks_left[$addr]'
done
# Process substitution (not a pipe) so the while loop runs in this shell
# and its writes to ticks_left actually persist across iterations.
while IFS= read -r win; do
title=$(jq -r '.title' <<<"$win")
[[ "$title" =~ $TITLE_PATTERN ]] || continue
address=$(jq -r '.address' <<<"$win")
floating=$(jq -r '.floating' <<<"$win")
# First time we've seen this window: start its settle window.
[[ -v ticks_left[$address] ]] || ticks_left[$address]=$SETTLE_TICKS
# Already floating and past its settle window: leave it alone.
[[ "$floating" == "true" && ${ticks_left[$address]} -le 0 ]] && continue
hyprctl dispatch "hl.dsp.focus({ window = \"address:$address\" })" >/dev/null
hyprctl dispatch 'hl.dsp.window.float({ action = "enable" })' >/dev/null
# resize({x=W,y=H}) sets the absolute size (not a delta) — verified live.
# Must happen before center(), since resizing shifts what "centered" means.
hyprctl dispatch "hl.dsp.window.resize({ x = $TARGET_WIDTH, y = $TARGET_HEIGHT })" >/dev/null
hyprctl dispatch 'hl.dsp.window.center()' >/dev/null
ticks_left[$address]=$((ticks_left[$address] - 1))
done < <(hyprctl clients -j | jq -c '.[] | {address, title, floating}')
done

View File

@@ -0,0 +1,16 @@
pragma Singleton
import QtQuick
// Tailscale, Mullvad, and ProtonVPN all fight over the default route/DNS if
// more than one is active at once (see VpnIndicator.qml's header comment) —
// only one of the three should ever actually be connected. This singleton
// is the shared signal between the two widget files: VpnIndicator.qml
// writes Mullvad/ProtonVPN's live connection state here as it polls them;
// TailscaleIndicator.qml reads it to back off (`tailscale down`) rather
// than stay up alongside either one, and to refuse turning Tailscale back
// on while one is still connected.
QtObject {
property bool mullvadConnected: false
property bool protonConnected: false
readonly property bool commercialVpnActive: mullvadConnected || protonConnected
}

View File

@@ -1,3 +1,4 @@
module config module config
singleton Colors 1.0 Colors.qml singleton Colors 1.0 Colors.qml
singleton Appearance 1.0 Appearance.qml singleton Appearance 1.0 Appearance.qml
singleton VpnExclusivity 1.0 VpnExclusivity.qml

View File

@@ -46,7 +46,9 @@ PanelWindow {
rightMargin: Appearance.spacing rightMargin: Appearance.spacing
} }
spacing: Appearance.spacing spacing: Appearance.spacing
Tray {} //Tray {}
TailscaleIndicator { barWindow: bar }
VpnIndicator { barWindow: bar }
NetworkIndicator { barWindow: bar } NetworkIndicator { barWindow: bar }
BluetoothIndicator { barWindow: bar } BluetoothIndicator { barWindow: bar }
Volume { barWindow: bar } Volume { barWindow: bar }

File diff suppressed because it is too large Load Diff

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

View File

@@ -11,10 +11,15 @@ PanelWindow {
color: "transparent" color: "transparent"
exclusiveZone: 0 exclusiveZone: 0
anchors { bottom: true } // Top-right, just under the bar — same fixed distance from the bar and
margins.bottom: 70 // 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: 240 implicitWidth: 272
implicitHeight: 54 implicitHeight: 54
WlrLayershell.layer: WlrLayer.Overlay WlrLayershell.layer: WlrLayer.Overlay
@@ -59,19 +64,61 @@ PanelWindow {
} }
} }
Rectangle { Item {
id: slider
Layout.fillWidth: true Layout.fillWidth: true
height: 6 implicitHeight: 16
radius: 3
color: Colors.muted readonly property real value: osd.sink && osd.sink.audio ? osd.sink.audio.volume : 0
readonly property bool enabled: !!osd.sink
Rectangle { Rectangle {
height: parent.height id: track
radius: 3 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 color: Colors.primary
width: parent.width * (osd.sink && osd.sink.audio ? osd.sink.audio.volume : 0) opacity: slider.enabled ? 1 : 0.4
Behavior on width { NumberAnimation { duration: Appearance.animationDuration } } }
} 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) + "%"
} }
} }
} }