// The wifi picker. This is the reason the quick settings panel exists: the // user must never have to drop to nmcli to join a network. // // Everything here goes through Quickshell.Networking (NetworkManager over // DBus) — no shelling out. import QtQuick import Quickshell import Quickshell.Widgets import Quickshell.Networking import qs.config import qs.widgets Item { id: root // The WifiDevice, or null while NetworkManager is still enumerating. property var device: null // True while the section is open. Drives the NM scan request so we are not // burning radio time scanning for a list nobody is looking at. property bool active: false property alias maxHeight: list.maxHeight // SSID whose inline password field is open, and the last failure. property string passwordFor: "" property string failedSsid: "" property string failedText: "" implicitHeight: list.implicitHeight // GNOME's ordering: the current network, then saved ones, then by signal. readonly property var networks: { if (!root.device || !root.device.networks) return []; const list = root.device.networks.values.slice(); list.sort((a, b) => { if (a.connected !== b.connected) return a.connected ? -1 : 1; if (a.known !== b.known) return a.known ? -1 : 1; return b.signalStrength - a.signalStrength; }); return list; } function syncScanner(): void { if (root.device) root.device.scannerEnabled = root.active; } onActiveChanged: root.syncScanner() onDeviceChanged: root.syncScanner() Component.onCompleted: root.syncScanner() Component.onDestruction: { if (root.device) root.device.scannerEnabled = false; } function signalIcon(strength: real): string { if (strength >= 0.8) return "network-wireless-signal-excellent-symbolic"; if (strength >= 0.55) return "network-wireless-signal-good-symbolic"; if (strength >= 0.3) return "network-wireless-signal-ok-symbolic"; if (strength > 0.05) return "network-wireless-signal-weak-symbolic"; return "network-wireless-signal-none-symbolic"; } // NM's failure reasons are useful but not English. function failureText(reason): string { switch (reason) { case ConnectionFailReason.NoSecrets: return "Wrong password"; case ConnectionFailReason.WifiAuthTimeout: return "Authentication timed out"; case ConnectionFailReason.WifiNetworkLost: return "Network out of range"; default: return "Couldn't connect"; } } function isSecured(network): bool { return network.security !== WifiSecurityType.Open && network.security !== WifiSecurityType.Owe && network.security !== WifiSecurityType.Unknown; } // Left click on a row. Saved and open networks connect immediately; // anything else reveals the password field rather than failing silently. function activate(network): void { root.failedSsid = ""; if (network.connected) return; if (network.known || !root.isSecured(network)) { root.passwordFor = ""; network.connect(); return; } root.passwordFor = root.passwordFor === network.name ? "" : network.name; } ScrollColumn { id: list anchors.fill: parent maxHeight: 340 Item { width: parent.width height: 30 visible: root.networks.length > 0 Text { anchors.left: parent.left anchors.leftMargin: 12 anchors.verticalCenter: parent.verticalCenter text: "Available networks" color: Theme.fgMuted font.family: Theme.fontFamily font.pixelSize: Theme.fontSizeSmall font.weight: Font.DemiBold } } Text { width: parent.width visible: root.networks.length === 0 topPadding: 12 bottomPadding: 12 horizontalAlignment: Text.AlignHCenter text: root.device ? (Networking.wifiEnabled ? "Scanning…" : "Wi-Fi is off") : "No Wi-Fi adapter" color: Theme.fgMuted font.family: Theme.fontFamily font.pixelSize: Theme.fontSize } Repeater { // root.networks is a fresh array on every signal-strength tick (the // sort comparator reads signalStrength/connected/known, so any of // those changing on ANY network recomputes the whole list). Handing // that straight to Repeater would reset the model and rebuild every // delegate each tick, blowing away whichever row has its password // Section open and focused. ScriptModel diffs by object identity // (WifiNetwork instances are unique QObjects) and turns a reorder // into move operations, so existing delegates -- and their expanded // state -- survive. model: ScriptModel { values: root.networks } Column { id: entry required property var modelData width: parent.width spacing: 0 readonly property bool secured: root.isSecured(entry.modelData) readonly property string ssid: entry.modelData.name // Reported by NM when a connection attempt gives up. Bad // passwords land here, so re-open the field to retry. Connections { target: entry.modelData function onConnectionFailed(reason) { root.failedSsid = entry.ssid; root.failedText = root.failureText(reason); if (entry.secured && !entry.modelData.known) root.passwordFor = entry.ssid; } } RowButton { width: parent.width icon: root.signalIcon(entry.modelData.signalStrength) iconFallback: "network-wireless-symbolic" label: entry.ssid !== "" ? entry.ssid : "Hidden network" selected: entry.modelData.connected sublabel: { if (entry.modelData.stateChanging) return "Connecting…"; if (entry.modelData.connected) return "Connected"; if (root.failedSsid === entry.ssid) return root.failedText; if (entry.modelData.known) return "Saved"; return ""; } onClicked: root.activate(entry.modelData) ThemedIcon { anchors.verticalCenter: parent.verticalCenter size: 14 visible: entry.secured tint: Theme.fgMuted icon: "network-wireless-encrypted-symbolic" iconFallback: "changes-prevent-symbolic" } IconButton { visible: entry.modelData.connected size: 26 iconSize: 13 icon: "window-close-symbolic" onClicked: entry.modelData.disconnect() } } Section { id: pskSection width: parent.width expanded: root.passwordFor === entry.ssid onExpandedChanged: { if (pskSection.expanded) pskInput.forceActiveFocus(); else pskInput.text = ""; } Rectangle { anchors.left: parent.left anchors.right: parent.right anchors.leftMargin: 10 anchors.rightMargin: 10 height: 42 y: 4 radius: Theme.cardRadius - 2 border.width: 1 border.color: Theme.alpha(Theme.accent, 0.4) color: Theme.alpha(Theme.fg, 0.08) TextInput { id: pskInput anchors.left: parent.left anchors.leftMargin: 12 anchors.right: sendButton.left anchors.rightMargin: 6 anchors.verticalCenter: parent.verticalCenter echoMode: TextInput.Password color: Theme.fg selectionColor: Theme.alpha(Theme.accent, 0.5) selectedTextColor: Theme.fg font.family: Theme.fontFamily font.pixelSize: Theme.fontSize clip: true onAccepted: { if (pskInput.text.length > 0) { root.failedSsid = ""; entry.modelData.connectWithPsk(pskInput.text); root.passwordFor = ""; } } Text { anchors.fill: parent verticalAlignment: Text.AlignVCenter visible: pskInput.text === "" text: "Password" color: Theme.fgMuted font: pskInput.font } } IconButton { id: sendButton anchors.right: parent.right anchors.rightMargin: 4 anchors.verticalCenter: parent.verticalCenter size: 30 iconSize: 15 icon: "go-next-symbolic" onClicked: pskInput.accepted() } } } } } } }