Files
att_menu/modules/WifiPanel.qml
T
2026-07-26 21:16:55 +02:00

403 lines
13 KiB
QML

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
}
}
}
}