Files
Panama/config/dot/quickshell/modules/quicksettings/WifiList.qml
T
Gabriel Brown e6b4d3c1a1 Stop lists and sliders from losing input under the user
WifiList and the notification toasts built their model from a plain
computed array, so any background property tick (a scan result, an
unrelated notification arriving) reassigned the whole array and the
Repeater destroyed and recreated every delegate -- including one with
an open, focused password field or an in-progress reply. Switched
both to a ScriptModel, which diffs by identity instead of resetting.

ValueSlider had its pointer-to-value mapping offset by 16px (the
hit-area margin was applied with the wrong sign), so 0% was
unreachable and every click landed to the right of where it was
placed -- affects every slider in the shell. SliderRow used -1 as a
sentinel for "nothing pending," which collides with legitimate
negative preference values like pointer sensitivity.

Dock intellihide read the globally focused workspace instead of each
monitor's own, so an empty workspace on one screen could hide the
dock on another; ActivityPanel rebuilt every row once a second during
a recording because the elapsed-time read lived in the model
construction instead of each row's own binding.

Claude-Session: https://claude.ai/code/session_01E6TJUAh41HaP25MVHWkhRZ
2026-08-18 21:22:58 -04:00

286 lines
11 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 {
// 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()
}
}
}
}
}
}
}