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:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user