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
439 lines
16 KiB
QML
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")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|