import Quickshell import Quickshell.Wayland import Quickshell.Networking import QtQuick import qs.config import qs.components import qs.services // The wifi menu: radio power and the networks in range. Same full-screen layer // surface as the launcher, so click-away and Escape work everywhere. // // NetworkManager is the backend Quickshell speaks; on a machine configured with // `wifi.backend=iwd` the connections still go through iwd underneath, without // this having to race NetworkManager's own policy. PanelWindow { id: root required property var bar readonly property string screenName: root.bar.screen ? root.bar.screen.name : "" visible: Session.isOpen("wifi", root.screenName) screen: root.bar.screen WlrLayershell.layer: WlrLayer.Overlay WlrLayershell.namespace: Config.namespace + "-wifi" // Exclusive so the passphrase field receives keys immediately; the surface // is unmapped while closed, so nothing is held hostage. WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive anchors { left: true right: true top: true bottom: true } exclusionMode: ExclusionMode.Ignore color: "transparent" // ---- Data ------------------------------------------------------------ readonly property WifiDevice device: Networking.devices.values.find(d => d.type === DeviceType.Wifi) ?? null readonly property bool wifiOn: Networking.wifiEnabled && Networking.wifiHardwareEnabled readonly property var networks: { if (!root.device || !root.wifiOn) return []; // Sorted by signal in quarters rather than by the raw value: strength // moves a little on every scan, and rows must not reshuffle under the // cursor for that. Connected first, then saved, then strongest. const bars = n => Math.min(3, Math.floor(n.signalStrength * 4)); return [...root.device.networks.values].sort((a, b) => { if (a.connected !== b.connected) return a.connected ? -1 : 1; if (a.known !== b.known) return a.known ? -1 : 1; if (bars(a) !== bars(b)) return bars(b) - bars(a); return a.name.localeCompare(b.name); }); } // Network waiting on a passphrase, or null. property var pskNetwork: null // Network we last asked to connect, watched for a failure report. property var pendingNetwork: null property string error: "" function secured(network: var): bool { // Owe is opportunistic encryption: nothing to ask the user for. return network.security !== WifiSecurityType.Open && network.security !== WifiSecurityType.Owe; } function activate(network: var): void { if (!network) return; root.error = ""; if (network.connected || network.state === ConnectionState.Connecting) { network.disconnect(); return; } // A saved network has its secrets in NetworkManager already. if (!network.known && root.secured(network)) { root.promptFor(network); return; } root.pendingNetwork = network; network.connect(); } function promptFor(network: var): void { root.pskNetwork = network; psk.text = ""; psk.forceActiveFocus(); } function submitPsk(): void { const network = root.pskNetwork; if (!network || psk.text === "") return; root.pskNetwork = null; root.pendingNetwork = network; network.connectWithPsk(psk.text); psk.text = ""; keyHandler.forceActiveFocus(); } function cancelPsk(): void { root.pskNetwork = null; psk.text = ""; keyHandler.forceActiveFocus(); } // Right click drops a saved network. Without it a mistyped passphrase would // be saved by NetworkManager and retried forever with no way out from here. function forget(network: var): void { if (!network || !network.known) return; root.error = ""; network.forget(); } Connections { target: root.pendingNetwork function onConnectionFailed(reason: int): void { switch (reason) { case ConnectionFailReason.NoSecrets: root.error = "Wrong password for " + root.pendingNetwork.name; root.promptFor(root.pendingNetwork); break; case ConnectionFailReason.WifiAuthTimeout: root.error = "Authentication timed out"; break; case ConnectionFailReason.WifiNetworkLost: root.error = "Network went out of range"; break; default: root.error = "Could not connect to " + root.pendingNetwork.name; } } } // Scanning costs power, so it only runs while the menu is on screen. As a // Binding rather than a one-shot, an adapter that appears later still gets // switched on. Binding { target: root.device property: "scannerEnabled" value: root.visible when: root.device !== null } onVisibleChanged: { if (root.visible) { list.currentIndex = 0; root.pskNetwork = null; root.error = ""; keyHandler.forceActiveFocus(); } } // ---- Chrome ---------------------------------------------------------- // Click-away scrim. MouseArea { anchors.fill: parent onClicked: Session.close() } Item { id: keyHandler anchors.fill: parent focus: true Keys.onEscapePressed: Session.close() Keys.onDownPressed: list.currentIndex = Math.min(list.currentIndex + 1, list.count - 1) Keys.onUpPressed: list.currentIndex = Math.max(list.currentIndex - 1, 0) Keys.onReturnPressed: root.activate(root.networks[list.currentIndex]) Keys.onEnterPressed: root.activate(root.networks[list.currentIndex]) } Rectangle { id: menu // Anchored to the panel's right edge, under the button that opens it. anchors { right: parent.right bottom: parent.bottom rightMargin: Theme.padding bottomMargin: Theme.barHeight + Theme.padding } readonly property int maxHeight: Math.min(Config.wifiMenuHeight, root.height - Theme.barHeight - Theme.padding * 2) readonly property int bodyHeight: root.wifiOn && list.count > 0 ? list.contentHeight : 48 readonly property int footerHeight: footer.visible ? footer.height + Theme.padding : 0 width: Math.min(Config.wifiMenuWidth, root.width - Theme.padding * 2) height: Math.min(menu.maxHeight, Theme.padding * 4 + header.height + Theme.padding * 2 + menu.bodyHeight + menu.footerHeight) color: Theme.surface radius: Theme.radius * 2 border.width: Theme.borderWidth border.color: Theme.border // Swallow clicks so they do not reach the scrim behind. MouseArea { anchors.fill: parent } Item { id: header anchors { left: parent.left right: parent.right top: parent.top margins: Theme.padding * 2 } height: 24 WifiGlyph { id: headerGlyph anchors.verticalCenter: parent.verticalCenter size: 18 struck: !root.wifiOn level: root.wifiOn ? 3 : 0 color: root.wifiOn ? Theme.text : Theme.textDim dimColor: Theme.border } Text { anchors { left: headerGlyph.right right: toggle.left leftMargin: Theme.padding rightMargin: Theme.padding verticalCenter: parent.verticalCenter } elide: Text.ElideRight text: Networking.wifiHardwareEnabled ? "Wi-Fi" : "Wi-Fi (blocked)" color: Theme.text font.family: Theme.fontFamily font.pixelSize: Theme.fontSize font.bold: true } ToggleSwitch { id: toggle anchors { right: parent.right verticalCenter: parent.verticalCenter } checked: Networking.wifiEnabled enabled: Networking.wifiHardwareEnabled // NetworkManager reports the new state back, so `checked` stays // bound to it rather than to what was clicked. onToggled: on => Networking.wifiEnabled = on } } Rectangle { id: separator anchors { left: parent.left right: parent.right top: header.bottom topMargin: Theme.padding * 2 } height: Theme.borderWidth color: Theme.border } ListView { id: list anchors { left: parent.left right: parent.right top: separator.bottom bottom: footer.visible ? footer.top : parent.bottom margins: Theme.padding topMargin: Theme.padding } visible: root.wifiOn clip: true spacing: 1 boundsBehavior: Flickable.StopAtBounds keyNavigationEnabled: false model: root.networks delegate: WifiNetworkEntry { required property var modelData required property int index network: modelData width: list.width selected: list.currentIndex === index onActivated: root.activate(modelData) onForgetRequested: root.forget(modelData) onEntered: list.currentIndex = index } } Text { anchors { left: parent.left right: parent.right top: separator.bottom bottom: parent.bottom margins: Theme.padding * 2 } visible: !root.wifiOn || list.count === 0 horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter wrapMode: Text.WordWrap text: !root.device ? "No wifi adapter" : !Networking.wifiHardwareEnabled ? "Wi-Fi is blocked by hardware" : !Networking.wifiEnabled ? "Wi-Fi is off" : "Scanning…" color: Theme.textDim font.family: Theme.fontFamily font.pixelSize: Theme.fontSize } // Passphrase prompt, and where failures are reported. Item { id: footer anchors { left: parent.left right: parent.right bottom: parent.bottom margins: Theme.padding * 2 } visible: root.pskNetwork !== null || root.error !== "" height: root.pskNetwork !== null ? 30 : errorText.implicitHeight Rectangle { anchors.fill: parent visible: root.pskNetwork !== null radius: Theme.radius color: Theme.background border.width: Theme.borderWidth border.color: psk.activeFocus ? Theme.accent : Theme.border TextInput { id: psk anchors.fill: parent anchors.leftMargin: Theme.padding * 2 anchors.rightMargin: Theme.padding * 2 verticalAlignment: TextInput.AlignVCenter echoMode: TextInput.Password color: Theme.text font.family: Theme.fontFamily font.pixelSize: Theme.fontSize selectByMouse: true selectionColor: Theme.accent Keys.onEscapePressed: root.cancelPsk() Keys.onReturnPressed: root.submitPsk() Keys.onEnterPressed: root.submitPsk() Text { anchors.fill: parent verticalAlignment: Text.AlignVCenter visible: psk.text === "" text: root.pskNetwork ? "Password for " + root.pskNetwork.name : "" color: Theme.textDim font: psk.font elide: Text.ElideRight } } } Text { id: errorText anchors.fill: parent visible: root.pskNetwork === null && root.error !== "" verticalAlignment: Text.AlignVCenter wrapMode: Text.WordWrap text: root.error color: Theme.danger font.family: Theme.fontFamily font.pixelSize: Theme.fontSizeSmall } } } }