275 lines
9.9 KiB
QML
275 lines
9.9 KiB
QML
// 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 {
|
|
model: 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()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|