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
286 lines
11 KiB
QML
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()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|