From c3a47f24e80c9ce31cd73c7d913f89caf841e628 Mon Sep 17 00:00:00 2001 From: Nikhil Sharma <48005807+ThEditor@users.noreply.github.com> Date: Sat, 26 Jul 2025 09:00:59 +0530 Subject: feat: improve network popout (#268) * feat: network popout (saved networks only) * fix: rem unfinished forget network * network: some fixes --------- Co-authored-by: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> --- config/BarConfig.qml | 1 + modules/bar/popouts/Network.qml | 219 ++++++++++++++++++++++++++++++++++++++-- services/Network.qml | 123 +++++++++++++++++++++- 3 files changed, 333 insertions(+), 10 deletions(-) diff --git a/config/BarConfig.qml b/config/BarConfig.qml index 472bd4b..c8a8bba 100644 --- a/config/BarConfig.qml +++ b/config/BarConfig.qml @@ -24,5 +24,6 @@ JsonObject { property int windowPreviewSize: 400 property int trayMenuWidth: 300 property int batteryWidth: 250 + property int networkWidth: 320 } } diff --git a/modules/bar/popouts/Network.qml b/modules/bar/popouts/Network.qml index d2b056d..b7e1270 100644 --- a/modules/bar/popouts/Network.qml +++ b/modules/bar/popouts/Network.qml @@ -1,22 +1,229 @@ +pragma ComponentBehavior: Bound + import qs.widgets import qs.services import qs.config +import qs.utils +import Quickshell import QtQuick +import QtQuick.Layouts -Column { +ColumnLayout { id: root - spacing: Appearance.spacing.normal + property string connectingToSsid: "" + + spacing: Appearance.spacing.small + width: Config.bar.sizes.networkWidth StyledText { - text: qsTr("Connected to: %1").arg(Network.active?.ssid ?? "None") + Layout.topMargin: Appearance.padding.normal + Layout.rightMargin: Appearance.padding.small + text: qsTr("WiFi %1").arg(Network.wifiEnabled ? "enabled" : "disabled") + font.weight: 500 } - StyledText { - text: qsTr("Strength: %1/100").arg(Network.active?.strength ?? 0) + Toggle { + label: qsTr("Enabled") + checked: Network.wifiEnabled + toggle.onToggled: Network.enableWifi(checked) } StyledText { - text: qsTr("Frequency: %1 MHz").arg(Network.active?.frequency ?? 0) + Layout.topMargin: Appearance.spacing.small + Layout.rightMargin: Appearance.padding.small + text: qsTr("%1 networks available").arg(Network.networks.length) + color: Colours.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.small + } + + Repeater { + model: ScriptModel { + values: [...Network.networks].sort((a, b) => { + if (a.active !== b.active) + return b.active - a.active; + return b.strength - a.strength; + }).slice(0, 8) + } + + RowLayout { + id: networkItem + + required property var modelData + readonly property bool isConnecting: root.connectingToSsid === modelData.ssid + readonly property bool loading: networkItem.isConnecting + + Layout.fillWidth: true + Layout.rightMargin: Appearance.padding.small + spacing: Appearance.spacing.small + + opacity: 0 + scale: 0.7 + + Component.onCompleted: { + opacity = 1; + scale = 1; + } + + Behavior on opacity { + Anim {} + } + + Behavior on scale { + Anim {} + } + + MaterialIcon { + text: Icons.getNetworkIcon(networkItem.modelData.strength) + color: networkItem.modelData.active ? Colours.palette.m3primary : Colours.palette.m3onSurfaceVariant + } + + MaterialIcon { + visible: networkItem.modelData.isSecure + text: "lock" + font.pointSize: Appearance.font.size.small + } + + StyledText { + Layout.leftMargin: Appearance.spacing.small / 2 + Layout.rightMargin: Appearance.spacing.small / 2 + Layout.fillWidth: true + text: networkItem.modelData.ssid + elide: Text.ElideRight + font.weight: networkItem.modelData.active ? 500 : 400 + color: networkItem.modelData.active ? Colours.palette.m3primary : Colours.palette.m3onSurface + } + + StyledRect { + id: connectBtn + + implicitWidth: implicitHeight + implicitHeight: connectIcon.implicitHeight + Appearance.padding.small + + radius: Appearance.rounding.full + color: networkItem.modelData.active ? Colours.palette.m3primary : Colours.palette.m3surface + + StyledBusyIndicator { + anchors.centerIn: parent + + implicitWidth: implicitHeight + implicitHeight: connectIcon.implicitHeight + + running: opacity > 0 + opacity: networkItem.loading ? 1 : 0 + + Behavior on opacity { + Anim {} + } + } + + StateLayer { + color: networkItem.modelData.active ? Colours.palette.m3onPrimary : Colours.palette.m3onSurface + disabled: networkItem.loading || !Network.wifiEnabled + + function onClicked(): void { + if (networkItem.modelData.active) { + Network.disconnectFromNetwork(); + } else { + root.connectingToSsid = networkItem.modelData.ssid; + Network.connectToNetwork(networkItem.modelData.ssid, ""); + } + } + } + + MaterialIcon { + id: connectIcon + + anchors.centerIn: parent + animate: true + text: networkItem.modelData.active ? "link_off" : "link" + color: networkItem.modelData.active ? Colours.palette.m3onPrimary : Colours.palette.m3onSurface + + opacity: networkItem.loading ? 0 : 1 + + Behavior on opacity { + Anim {} + } + } + } + } + } + + StyledRect { + Layout.topMargin: Appearance.spacing.small + Layout.fillWidth: true + implicitHeight: rescanBtn.implicitHeight + Appearance.padding.small * 2 + + radius: Appearance.rounding.normal + color: Network.scanning ? Colours.palette.m3surfaceContainer : Colours.palette.m3primaryContainer + + StateLayer { + color: Network.scanning ? Colours.palette.m3onSurface : Colours.palette.m3onPrimaryContainer + enabled: !Network.scanning && Network.wifiEnabled + + function onClicked(): void { + Network.rescanWifi(); + } + } + + RowLayout { + id: rescanBtn + anchors.centerIn: parent + spacing: Appearance.spacing.small + + MaterialIcon { + text: Network.scanning ? "refresh" : "wifi_find" + color: Network.scanning ? Colours.palette.m3onSurface : Colours.palette.m3onPrimaryContainer + + RotationAnimation on rotation { + running: Network.scanning + loops: Animation.Infinite + from: 0 + to: 360 + duration: 1000 + } + } + + StyledText { + text: Network.scanning ? qsTr("Scanning...") : qsTr("Rescan networks") + color: Network.scanning ? Colours.palette.m3onSurface : Colours.palette.m3onPrimaryContainer + } + } + } + + // Reset connecting state when network changes + Connections { + target: Network + + function onActiveChanged(): void { + if (Network.active && root.connectingToSsid === Network.active.ssid) { + root.connectingToSsid = ""; + } + } + } + + component Toggle: RowLayout { + required property string label + property alias checked: toggle.checked + property alias toggle: toggle + + Layout.fillWidth: true + Layout.rightMargin: Appearance.padding.small + spacing: Appearance.spacing.normal + + StyledText { + Layout.fillWidth: true + text: parent.label + } + + StyledSwitch { + id: toggle + } + } + + component Anim: NumberAnimation { + duration: Appearance.anim.durations.normal + easing.type: Easing.BezierSpline + easing.bezierCurve: Appearance.anim.curves.standard } } diff --git a/services/Network.qml b/services/Network.qml index 8515ef5..74b05dc 100644 --- a/services/Network.qml +++ b/services/Network.qml @@ -9,9 +9,45 @@ Singleton { readonly property list networks: [] readonly property AccessPoint active: networks.find(n => n.active) ?? null + property bool wifiEnabled: true + property bool scanning: false reloadableId: "network" + function enableWifi(enabled: bool): void { + const cmd = enabled ? "on" : "off"; + enableWifiProcess.command = ["nmcli", "radio", "wifi", cmd]; + enableWifiProcess.running = true; + } + + function toggleWifi(): void { + const cmd = wifiEnabled ? "off" : "on"; + enableWifiProcess.command = ["nmcli", "radio", "wifi", cmd]; + enableWifiProcess.running = true; + } + + function rescanWifi(): void { + scanning = true; + rescanProcess.running = true; + } + + function connectToNetwork(ssid: string, password: string): void { + // TODO: Implement password + connectProcess.command = ["nmcli", "conn", "up", ssid]; + connectProcess.running = true; + } + + function disconnectFromNetwork(): void { + if (active) { + disconnectProcess.command = ["nmcli", "connection", "down", active.ssid]; + disconnectProcess.running = true; + } + } + + function getWifiStatus(): void { + wifiStatusProcess.running = true; + } + Process { running: true command: ["nmcli", "m"] @@ -20,10 +56,63 @@ Singleton { } } + Process { + id: wifiStatusProcess + command: ["nmcli", "radio", "wifi"] + environment: ({ + LANG: "C", + LC_ALL: "C" + }) + stdout: StdioCollector { + onStreamFinished: { + root.wifiEnabled = text.trim() === "enabled"; + } + } + Component.onCompleted: running = true + } + + Process { + id: enableWifiProcess + stdout: SplitParser { + onRead: { + getWifiStatus(); + getNetworks.running = true; + } + } + } + + Process { + id: rescanProcess + command: ["nmcli", "dev", "wifi", "list", "--rescan", "yes"] + stdout: SplitParser { + onRead: { + scanning = false; + getNetworks.running = true; + } + } + } + + Process { + id: connectProcess + stdout: SplitParser { + onRead: getNetworks.running = true + } + stderr: SplitParser { + onRead: console.warn("Network connection error:", data) + } + } + + Process { + id: disconnectProcess + stdout: SplitParser { + onRead: getNetworks.running = true + } + } + Process { id: getNetworks running: true - command: ["nmcli", "-g", "ACTIVE,SIGNAL,FREQ,SSID,BSSID", "d", "w"] + command: ["nmcli", "-g", "ACTIVE,SIGNAL,FREQ,SSID,BSSID,SECURITY", "d", "w"] environment: ({ LANG: "C", LC_ALL: "C" @@ -34,16 +123,40 @@ Singleton { const rep = new RegExp("\\\\:", "g"); const rep2 = new RegExp(PLACEHOLDER, "g"); - const networks = text.trim().split("\n").map(n => { + const allNetworks = text.trim().split("\n").map(n => { const net = n.replace(rep, PLACEHOLDER).split(":"); return { active: net[0] === "yes", strength: parseInt(net[1]), frequency: parseInt(net[2]), ssid: net[3], - bssid: net[4]?.replace(rep2, ":") ?? "" + bssid: net[4]?.replace(rep2, ":") ?? "", + security: net[5] || "" }; - }); + }).filter(n => n.ssid && n.ssid.length > 0); + + // Group networks by SSID and prioritize connected ones + const networkMap = new Map(); + for (const network of allNetworks) { + const existing = networkMap.get(network.ssid); + if (!existing) { + networkMap.set(network.ssid, network); + } else { + // Prioritize active/connected networks + if (network.active && !existing.active) { + networkMap.set(network.ssid, network); + } else if (!network.active && !existing.active) { + // If both are inactive, keep the one with better signal + if (network.strength > existing.strength) { + networkMap.set(network.ssid, network); + } + } + // If existing is active and new is not, keep existing + } + } + + const networks = Array.from(networkMap.values()); + const rNetworks = root.networks; const destroyed = rNetworks.filter(rn => !networks.find(n => n.frequency === rn.frequency && n.ssid === rn.ssid && n.bssid === rn.bssid)); @@ -71,6 +184,8 @@ Singleton { readonly property int strength: lastIpcObject.strength readonly property int frequency: lastIpcObject.frequency readonly property bool active: lastIpcObject.active + readonly property string security: lastIpcObject.security + readonly property bool isSecure: security.length > 0 } Component { -- cgit v1.2.3-freya