feat(quickshell): expand network popout with VPN, info, and collapsible sections

Rework the minimal wifi-list popout into a full network panel:
- compact current-connection line (SSID + signal, or Wired/off state)
- VPN section toggling connections via nmcli (Networking API has no VPN state)
- collapsible available-networks list; scanner now gated on shown AND expanded
- collapsible IP details (address/gateway/DNS) via nmcli device show
- nm-connection-editor launcher

Add a shared Divider component for popout section rules and register it in qmldir.
Rebind Super+N from moonarch-vpn to the quickshell network popout toggle.
This commit is contained in:
2026-07-08 10:21:34 +02:00
parent a229ee9c6a
commit c1c2af1abd
4 changed files with 409 additions and 13 deletions
+1 -1
View File
@@ -138,7 +138,7 @@ binds {
Super+E { spawn-sh "xdg-open ~"; }
Super+N { spawn-sh "moonarch-vpn"; }
Super+N hotkey-overlay-title="Network: quickshell popout" { spawn "qs" "-c" "moonarch" "ipc" "call" "popout" "toggle" "network"; }
Mod+Return hotkey-overlay-title="Open a Terminal: foot" { spawn "foot"; }
Mod+Space hotkey-overlay-title="Run an Application: walker" { spawn "walker"; }
@@ -0,0 +1,9 @@
// ABOUTME: Thin horizontal separator for popout sections — a 1px surface rule with slight transparency.
// ABOUTME: Consumers set the width (typically the panel's contentWidth) to group content blocks.
import QtQuick
Rectangle {
height: 1
color: Theme.surface1
opacity: 0.6
}
@@ -1,6 +1,9 @@
// ABOUTME: Network popout (minimal) — wifi enable toggle + list of networks to connect/disconnect.
// ABOUTME: No PSK entry for new secured networks (out of scope); list/hover pattern like the other popouts.
// ABOUTME: Network popout — compact current-connection info, VPN toggles (nmcli), a collapsible
// ABOUTME: available-networks list, collapsible IP details (nmcli), and a launcher for nm-connection-editor.
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Networking
PopoutPanel {
@@ -12,10 +15,22 @@ PopoutPanel {
onToggled: Networking.wifiEnabled = !Networking.wifiEnabled
}
// --- Devices / connection state (quickshell Networking API) ------------------
readonly property var wifiDev: {
const ds = Networking.devices ? Networking.devices.values : [];
return ds.find(d => d && d.type === DeviceType.Wifi) || null;
}
readonly property var wifiConn: {
if (!root.wifiDev || !root.wifiDev.networks)
return null;
return root.wifiDev.networks.values.find(n => n && n.connected) || null;
}
readonly property var wiredDev: {
const ds = Networking.devices ? Networking.devices.values : [];
return ds.find(d => d && d.type === DeviceType.Wired && d.connected) || null;
}
// The device that carries the active connection — wifi wins, else a connected wired device.
readonly property var activeDev: root.wifiConn ? root.wifiDev : root.wiredDev
readonly property var nets: {
if (!root.wifiDev || !root.wifiDev.networks)
return [];
@@ -26,32 +41,288 @@ PopoutPanel {
});
}
// Content stays loaded even when closed (for the slide animation), so gate scanning on this
// Content stays loaded even when closed (for the slide animation), so gate scanning/polling on this
// popout actually being the shown one rather than on component lifecycle.
readonly property bool shown: root.popouts && root.popouts.hasCurrent
onShownChanged: if (root.wifiDev)
root.wifiDev.scannerEnabled = root.shown
Component.onCompleted: if (root.wifiDev)
root.wifiDev.scannerEnabled = root.shown
// --- Collapsible section state ----------------------------------------------
property bool netsExpanded: false
property bool infoExpanded: false
// Only scan while the popout is shown AND the network list is actually expanded.
function syncScanner() {
if (root.wifiDev)
root.wifiDev.scannerEnabled = root.shown && root.netsExpanded;
}
onShownChanged: root.syncScanner()
onNetsExpandedChanged: root.syncScanner()
Component.onCompleted: root.syncScanner()
Component.onDestruction: if (root.wifiDev)
root.wifiDev.scannerEnabled = false
// --- VPN via nmcli ----------------------------------------------------------
// quickshell's Networking API exposes no VPN state, so shell out to nmcli.
property var vpns: [] // [{ name, uuid, active }]
// nmcli -t escapes literal ':' and '\' in field values with a backslash; split respecting that.
function splitNm(line) {
const parts = [];
let cur = "";
for (let i = 0; i < line.length; i++) {
const c = line[i];
if (c === "\\" && i + 1 < line.length) {
cur += line[i + 1];
i++;
} else if (c === ":") {
parts.push(cur);
cur = "";
} else {
cur += c;
}
}
parts.push(cur);
return parts;
}
function parseVpns(txt) {
const out = [];
for (const line of txt.split("\n")) {
if (!line)
continue;
const f = root.splitNm(line);
if (f.length < 4)
continue;
const type = f[2];
if (type === "vpn" || type === "wireguard")
out.push({
name: f[0],
uuid: f[1],
active: f[3] === "yes"
});
}
root.vpns = out;
}
Process {
id: vpnListProc
command: ["nmcli", "-t", "-f", "NAME,UUID,TYPE,ACTIVE", "connection", "show"]
stdout: StdioCollector {
id: vpnOut
}
onExited: (code, status) => root.parseVpns(code === 0 ? vpnOut.text : "")
}
function refreshVpns() {
if (!vpnListProc.running)
vpnListProc.running = true;
}
Process {
id: vpnActionProc
onExited: (code, status) => root.refreshVpns()
}
function toggleVpn(v) {
if (vpnActionProc.running)
return;
vpnActionProc.command = ["nmcli", "connection", v.active ? "down" : "up", v.uuid];
vpnActionProc.running = true;
}
// Poll while shown — nmcli has no cheap change signal and the popout is transient.
Timer {
interval: 2000
running: root.shown
repeat: true
triggeredOnStart: true
onTriggered: root.refreshVpns()
}
// --- Network information via nmcli ------------------------------------------
property var infoRows: [] // [{ key, val }]
function parseInfo(txt) {
let conn = "", ip = "", gw = "";
const dns = [];
for (const line of txt.split("\n")) {
if (!line)
continue;
const idx = line.indexOf(":");
if (idx < 0)
continue;
const key = line.slice(0, idx);
const val = line.slice(idx + 1);
if (key.startsWith("IP4.ADDRESS") && !ip)
ip = val;
else if (key === "IP4.GATEWAY")
gw = val;
else if (key.startsWith("IP4.DNS") && val)
dns.push(val);
else if (key === "GENERAL.CONNECTION")
conn = val;
}
const rows = [];
if (conn)
rows.push({ key: qsTr("Connection"), val: conn });
if (ip)
rows.push({ key: qsTr("IP"), val: ip });
if (gw)
rows.push({ key: qsTr("Gateway"), val: gw });
if (dns.length)
rows.push({ key: qsTr("DNS"), val: dns.join(", ") });
root.infoRows = rows;
}
Process {
id: infoProc
stdout: StdioCollector {
id: infoOut
}
onExited: (code, status) => root.parseInfo(code === 0 ? infoOut.text : "")
}
function refreshInfo() {
if (infoProc.running)
return;
const dev = root.activeDev;
if (!dev || !dev.name) {
root.infoRows = [];
return;
}
infoProc.command = ["nmcli", "-t", "-f", "IP4.ADDRESS,IP4.GATEWAY,IP4.DNS,GENERAL.CONNECTION", "device", "show", dev.name];
infoProc.running = true;
}
onInfoExpandedChanged: if (root.infoExpanded)
root.refreshInfo()
// ============================ Layout ========================================
// --- Compact current-connection info (SSID + signal; IP lives in the details section) ---
Text {
visible: root.wifiDev === null
width: root.contentWidth
text: qsTr("No Wi-Fi device")
color: Theme.subtext0
text: {
if (root.wifiConn)
return root.wifiConn.name + " · " + Math.round(root.wifiConn.signalStrength * 100) + "%";
if (root.wiredDev)
return qsTr("Wired");
return Networking.wifiEnabled ? qsTr("Not connected") : qsTr("Wi-Fi off");
}
elide: Text.ElideRight
color: Theme.text
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
Divider {
width: root.contentWidth
}
// --- VPN section (hidden when no VPN connections are defined) ---
Column {
width: root.contentWidth
spacing: 2
visible: root.vpns.length > 0
Text {
text: qsTr("VPN")
color: Theme.subtext0
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.bold: true
}
Repeater {
model: root.vpns
delegate: ListRow {
id: vpnRow
required property var modelData
width: parent.width
onClicked: root.toggleVpn(vpnRow.modelData)
Row {
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 6
Text {
id: vpnMarker
anchors.verticalCenter: parent.verticalCenter
width: 12
text: vpnRow.modelData.active ? String.fromCodePoint(0xf012c) : "" // md-check
color: Theme.accent
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
Text {
anchors.verticalCenter: parent.verticalCenter
width: parent.width - vpnMarker.width - parent.spacing
text: vpnRow.modelData.name
elide: Text.ElideRight
color: Theme.text
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
}
}
}
}
Divider {
width: root.contentWidth
visible: root.vpns.length > 0
}
// --- Available networks (collapsible) ---
ListRow {
id: netsHeader
width: root.contentWidth
onClicked: root.netsExpanded = !root.netsExpanded
Row {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
spacing: 6
Text {
anchors.verticalCenter: parent.verticalCenter
width: 12
text: root.netsExpanded ? String.fromCodePoint(0xf0140) : String.fromCodePoint(0xf0142) // md-chevron down/right
color: Theme.subtext0
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: qsTr("Available networks")
color: Theme.text
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
}
}
Column {
width: root.contentWidth
spacing: 2
visible: root.wifiDev !== null && Networking.wifiEnabled
visible: root.netsExpanded
Text {
visible: root.wifiDev === null
width: parent.width
text: qsTr("No Wi-Fi device")
color: Theme.subtext0
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
Text {
visible: root.wifiDev !== null && !Networking.wifiEnabled
width: parent.width
text: qsTr("Wi-Fi is off")
color: Theme.subtext0
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
Repeater {
model: root.nets
model: (root.wifiDev !== null && Networking.wifiEnabled) ? root.nets : []
delegate: ListRow {
id: netRow
required property var modelData
@@ -117,4 +388,119 @@ PopoutPanel {
}
}
}
Divider {
width: root.contentWidth
}
// --- Network information (collapsible, nmcli details) ---
ListRow {
id: infoHeader
width: root.contentWidth
onClicked: root.infoExpanded = !root.infoExpanded
Row {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
spacing: 6
Text {
anchors.verticalCenter: parent.verticalCenter
width: 12
text: root.infoExpanded ? String.fromCodePoint(0xf0140) : String.fromCodePoint(0xf0142) // md-chevron down/right
color: Theme.subtext0
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: qsTr("Network information")
color: Theme.text
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
}
}
Column {
width: root.contentWidth
spacing: 2
visible: root.infoExpanded
Text {
visible: root.infoRows.length === 0
width: parent.width
leftPadding: 6
text: qsTr("No details")
color: Theme.subtext0
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize - 1
}
Repeater {
model: root.infoRows
delegate: Item {
id: infoLine
required property var modelData
width: parent.width
height: 20
Text {
id: infoKey
anchors.left: parent.left
anchors.leftMargin: 6
anchors.verticalCenter: parent.verticalCenter
width: 78
text: infoLine.modelData.key
color: Theme.subtext0
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize - 1
}
Text {
anchors.left: infoKey.right
anchors.right: parent.right
anchors.rightMargin: 6
anchors.verticalCenter: parent.verticalCenter
text: infoLine.modelData.val
elide: Text.ElideRight
color: Theme.text
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize - 1
}
}
}
}
// --- Network manager launcher ---
ListRow {
id: managerRow
width: root.contentWidth
onClicked: {
Quickshell.execDetached(["nm-connection-editor"]);
if (root.popouts)
root.popouts.close();
}
Row {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
spacing: 6
Text {
anchors.verticalCenter: parent.verticalCenter
width: 12
text: String.fromCodePoint(0xf0493) // md-cog
color: Theme.subtext0
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: qsTr("Network manager")
color: Theme.text
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
}
}
}
+1
View File
@@ -10,6 +10,7 @@ BluetoothPopout BluetoothPopout.qml
Clock Clock.qml
CpuGovIndicator CpuGovIndicator.qml
CpuGovPopout CpuGovPopout.qml
Divider Divider.qml
IconButton IconButton.qml
IdleInhibitorIndicator IdleInhibitorIndicator.qml
ListRow ListRow.qml