Files
Panama/config/dot/quickshell/modules/quicksettings/QuickSettingsPanel.qml
T
Gabriel Brown 36fdd4e076 Give the VPN a toggle, an indicator, and a way back
Turning on a WireGuard profile whose server was unreachable used to cost the
whole network stack, and the only way out was nmcli typed into a terminal.
Quickshell's Networking module has no VPN surface, so this arrives as the one
sanctioned nmcli exception: a helper that lists, raises and lowers profiles,
a service that watches NetworkManager for changes made anywhere, a quick
settings tile (left-click toggles the most recently used profile, right-click
picks among them), and a bar glyph while a tunnel is up.

The safety property is in the helper, where it cannot be skipped: activation
waits a bounded 25 seconds, and a failure is rolled back down and reported
instead of leaving a black-hole default route. The contract pins exactly that,
against a stateful stub NetworkManager.

Claude-Session: https://claude.ai/code/session_01Epx9ZC1gwm81K3jm9x9CKh
2026-08-23 10:32:17 -04:00

439 lines
16 KiB
QML

// The contents of the quick settings popover — GNOME's system menu, in order:
// toggle grid, detail lists, sliders, footer.
//
// Kept separate from QuickSettings.qml (the PanelWindow) so it can be dropped
// into a FloatingWindow for testing: layer-shell surfaces cannot map outside a
// wlroots compositor.
import QtQuick
import Quickshell
import Quickshell.Widgets
import Quickshell.Networking
import Quickshell.Bluetooth
import Quickshell.Services.Pipewire
import qs.config
import qs.services
import qs.widgets
Item {
id: root
implicitWidth: Theme.controlCenterWidth
implicitHeight: content.implicitHeight + Theme.popoverPadding * 2
// "" | "wifi" | "vpn" | "bluetooth" | "sink" | "source". Only one detail
// list is open at a time, so the panel never grows past the screen.
property string expandedSection: ""
function expand(name: string): void {
root.expandedSection = root.expandedSection === name ? "" : name;
}
// Collapse everything — called when the popover closes so it reopens in a
// known state.
function reset(): void {
root.expandedSection = "";
}
// ── System state ────────────────────────────────────────────────────────
readonly property var wifiDevice: {
for (const device of Networking.devices.values) {
if (device.type === DeviceType.Wifi)
return device;
}
return null;
}
readonly property var wiredDevice: {
for (const device of Networking.devices.values) {
if (device.type === DeviceType.Wired && device.connected)
return device;
}
return null;
}
readonly property string ssid: {
if (!root.wifiDevice || !root.wifiDevice.networks)
return "";
for (const network of root.wifiDevice.networks.values) {
if (network.connected)
return network.name;
}
return "";
}
readonly property var btAdapter: Bluetooth.defaultAdapter
readonly property string btSummary: {
if (!root.btAdapter)
return "Unavailable";
if (!root.btAdapter.enabled)
return "Off";
for (const device of Bluetooth.devices.values) {
if (device.connected)
return device.name || device.address;
}
return "On";
}
readonly property int cellWidth: (content.width - Theme.itemSpacing) / 2
Column {
id: content
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: Theme.popoverPadding
spacing: Theme.itemSpacing
// ── Toggle grid ─────────────────────────────────────────────────────
Grid {
columns: 2
spacing: Theme.itemSpacing
Toggle {
width: root.cellWidth
icon: Networking.wifiEnabled ? "network-wireless-symbolic" : "network-wireless-offline-symbolic"
label: "Wi-Fi"
active: Networking.wifiEnabled
enabled: Networking.wifiHardwareEnabled
sublabel: {
if (!Networking.wifiEnabled)
return "Off";
return root.ssid !== "" ? root.ssid : "Not connected";
}
onToggled: Networking.wifiEnabled = !Networking.wifiEnabled
onExpanded: root.expand("wifi")
}
// Only present when there is a wired device at all -- most machines
// this runs on have one, but a laptop without a dock does not, and
// an Ethernet tile there would be a control for absent hardware.
Toggle {
width: root.cellWidth
visible: Connectivity.wiredDevice !== null
icon: Connectivity.wiredOn
? "network-wired-symbolic"
: "network-wired-disconnected-symbolic"
label: "Ethernet"
active: Connectivity.wiredOn
enabled: Connectivity.wiredAvailable
sublabel: {
if (Connectivity.wiredOn)
return Connectivity.wiredDevice.linkSpeed > 0
? Connectivity.wiredDevice.linkSpeed + " Mb/s" : "Connected";
return "Off";
}
onToggled: Connectivity.setWired(!Connectivity.wiredOn)
}
// Only when a VPN profile is saved at all: a machine with none has
// nothing to toggle, and the tile would be a control for absent
// configuration -- same reasoning as the Ethernet tile above.
Toggle {
width: root.cellWidth
visible: Vpn.available
icon: "network-vpn-symbolic"
label: "VPN"
active: Vpn.anyActive
enabled: !Vpn.busy
sublabel: {
if (Vpn.busy)
return "Working…";
return Vpn.anyActive ? Vpn.activeSummary : "Off";
}
onToggled: Vpn.toggle()
onExpanded: root.expand("vpn")
}
Toggle {
width: root.cellWidth
icon: root.btAdapter && root.btAdapter.enabled ? "bluetooth-active-symbolic" : "bluetooth-disabled-symbolic"
label: "Bluetooth"
active: root.btAdapter !== null && root.btAdapter.enabled
enabled: root.btAdapter !== null
sublabel: root.btSummary
onToggled: {
if (root.btAdapter)
root.btAdapter.enabled = !root.btAdapter.enabled;
}
onExpanded: root.expand("bluetooth")
}
Toggle {
width: root.cellWidth
icon: "preferences-desktop-screensaver-symbolic"
label: "Caffeine"
sublabel: Caffeine.enabled ? "Staying awake" : "Off"
active: Caffeine.enabled
onToggled: Caffeine.toggle()
}
Toggle {
width: root.cellWidth
icon: ColorScheme.dark ? "weather-clear-night-symbolic" : "weather-clear-symbolic"
label: "Appearance"
sublabel: ColorScheme.dark ? "Dark" : "Light"
// "active" reads as the non-default state across this grid, and
// dark is what Panama ships, so light is the lit one.
active: !ColorScheme.dark
// Writes the preference and stops. ColorScheme propagates it to
// GTK, the portal, the terminals, the launcher, btop, tmux and
// the lock screen; nothing here needs to know that list.
onToggled: SystemSettings.commitPreference("colorScheme",
ColorScheme.dark ? "light" : "dark")
}
Toggle {
width: root.cellWidth
icon: "night-light-symbolic"
label: "Night Light"
active: NightLight.active
sublabel: {
if (NightLight.automatic)
return NightLight.active ? "Scheduled · on" : "Scheduled";
return NightLight.active ? NightLight.temperature + "K" : "Off";
}
onToggled: NightLight.toggle()
// No detail list to open, so the secondary action flips the
// schedule on and off instead.
onExpanded: NightLight.automatic = !NightLight.automatic
}
}
RowButton {
width: content.width
icon: "preferences-system-time-symbolic"
iconFallback: "appointment-soon-symbolic"
label: FocusSession.active ? "Focus · " + FocusSession.workspaceLabel : "Start focus session"
sublabel: FocusSession.active ? FocusSession.remainingText + (FocusSession.paused ? " · paused" : " remaining") : Settings.focusDurationMinutes + " minutes · current workspace"
selected: FocusSession.active
onClicked: {
ShellState.close();
FocusSession.reveal();
}
}
// ── Detail lists ────────────────────────────────────────────────────
Section {
width: content.width
expanded: root.expandedSection === "wifi"
WifiList {
anchors.left: parent.left
anchors.right: parent.right
device: root.wifiDevice
active: root.expandedSection === "wifi"
}
}
Section {
width: content.width
expanded: root.expandedSection === "vpn"
VpnList {
anchors.left: parent.left
anchors.right: parent.right
}
}
Section {
width: content.width
expanded: root.expandedSection === "bluetooth"
BluetoothList {
anchors.left: parent.left
anchors.right: parent.right
active: root.expandedSection === "bluetooth"
}
}
Rectangle {
width: content.width
height: 1
color: Theme.alpha(Theme.fg, 0.1)
}
// ── Power ───────────────────────────────────────────────────────────
// Only where a power-profiles daemon is actually running. A desktop
// without one should not show a control that cannot do anything.
RowButton {
visible: PowerProfiles.available
width: content.width
icon: {
switch (PowerProfiles.active) {
case "power-saver": return "power-profile-power-saver-symbolic";
case "performance": return "power-profile-performance-symbolic";
default: return "power-profile-balanced-symbolic";
}
}
iconFallback: "preferences-system-power-symbolic"
label: "Power profile"
sublabel: PowerProfiles.degraded !== ""
? PowerProfiles.label(PowerProfiles.active) + " · limited"
: PowerProfiles.label(PowerProfiles.active)
selected: root.expandedSection === "power"
onClicked: root.expand("power")
}
Section {
width: content.width
expanded: root.expandedSection === "power"
PowerProfileList {
anchors.left: parent.left
anchors.right: parent.right
}
}
// ── Sliders ─────────────────────────────────────────────────────────
AudioSlider {
width: content.width
node: Pipewire.defaultAudioSink
output: true
expanded: root.expandedSection === "sink"
onToggleExpanded: root.expand("sink")
}
Section {
width: content.width
expanded: root.expandedSection === "sink"
AudioDeviceList {
anchors.left: parent.left
anchors.right: parent.right
output: true
}
}
AudioSlider {
width: content.width
node: Pipewire.defaultAudioSource
output: false
expanded: root.expandedSection === "source"
onToggleExpanded: root.expand("source")
}
Section {
width: content.width
expanded: root.expandedSection === "source"
AudioDeviceList {
anchors.left: parent.left
anchors.right: parent.right
output: false
}
}
BrightnessControl {
width: content.width
}
// ── Connected life ─────────────────────────────────────────────────
HomeControls {
width: content.width
expanded: root.expandedSection === "home"
onToggleExpanded: root.expand("home")
}
PhoneControls {
width: content.width
expanded: root.expandedSection === "phone"
onToggleExpanded: root.expand("phone")
}
Rectangle {
width: content.width
height: 1
color: Theme.alpha(Theme.fg, 0.1)
}
// ── Footer ──────────────────────────────────────────────────────────
Item {
width: content.width
height: 36
// The account's own picture, the same file the lock screen and the
// login screen read. A generic glyph sat here while a real avatar
// was already set, which made the desktop look like it did not know
// whose it was.
Item {
id: avatar
anchors.left: parent.left
anchors.leftMargin: 4
anchors.verticalCenter: parent.verticalCenter
width: 22
height: 22
ClippingRectangle {
anchors.fill: parent
radius: width / 2
color: "transparent"
visible: UserAccounts.avatarUrl !== ""
Image {
anchors.fill: parent
source: UserAccounts.avatarUrl
fillMode: Image.PreserveAspectCrop
// Replaced in place when the picture changes, so the
// cache has to be told to let go of the old one.
cache: false
asynchronous: true
sourceSize.width: 44
sourceSize.height: 44
}
}
ThemedIcon {
anchors.centerIn: parent
visible: UserAccounts.avatarUrl === ""
size: 20
icon: "avatar-default-symbolic"
iconFallback: "user-info-symbolic"
}
}
Text {
anchors.left: avatar.right
anchors.leftMargin: 10
anchors.right: actions.left
anchors.rightMargin: 8
anchors.verticalCenter: parent.verticalCenter
text: UserAccounts.me
? UserAccounts.displayName(UserAccounts.me)
: (Quickshell.env("USER") || "user")
color: Theme.fg
elide: Text.ElideRight
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.Medium
}
Row {
id: actions
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 4
IconButton {
size: 32
iconSize: 17
icon: "preferences-system-symbolic"
onClicked: {
ShellState.close();
ShellState.openSettings("home");
}
}
IconButton {
size: 32
iconSize: 17
icon: "system-shutdown-symbolic"
onClicked: ShellState.open("powermenu")
}
}
}
}
}