From 3d0a18866f60efdfcee6cc49a3db9f88deac189c Mon Sep 17 00:00:00 2001 From: thomas Date: Mon, 24 Aug 2026 15:07:18 +0100 Subject: [PATCH] implimented network widget --- .../modules/bar/NetworkIndicator.qml | 1094 ++++++++++++++++- 1 file changed, 1092 insertions(+), 2 deletions(-) diff --git a/dotfiles/.config/quickshell/modules/bar/NetworkIndicator.qml b/dotfiles/.config/quickshell/modules/bar/NetworkIndicator.qml index e15be89..260b4a5 100644 --- a/dotfiles/.config/quickshell/modules/bar/NetworkIndicator.qml +++ b/dotfiles/.config/quickshell/modules/bar/NetworkIndicator.qml @@ -1,4 +1,8 @@ import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Hyprland +import Quickshell.Io import Quickshell.Networking import "../../config" @@ -7,6 +11,12 @@ Item { implicitWidth: label.implicitWidth + 4 implicitHeight: Appearance.barHeight + // The bar's own PanelWindow — passed in from Bar.qml so this popup can + // anchor to the bar itself rather than to this icon, keeping it a fixed + // distance from the bar and the screen edge regardless of where this + // icon sits in the row. + required property var barWindow + readonly property var connectedDevice: { const devices = Networking.devices.values for (let i = 0; i < devices.length; i++) { @@ -16,6 +26,494 @@ Item { return null } + readonly property var wifiDevice: { + const devices = Networking.devices.values + for (let i = 0; i < devices.length; i++) { + if (devices[i].type === DeviceType.Wifi) + return devices[i] + } + return null + } + + readonly property var wiredDevice: { + const devices = Networking.devices.values + for (let i = 0; i < devices.length; i++) { + if (devices[i].type === DeviceType.Wired) + return devices[i] + } + return null + } + + // Only scan for networks while the panel is actually open. + Binding { + target: root.wifiDevice + property: "scannerEnabled" + value: panel.visible + when: root.wifiDevice !== null + } + + // --- WiFi row helpers, ported from omarchy's network panel Model.js + // (github.com/basecamp/omarchy shell/plugins/panels/network/Model.js). + // Rows are plain objects rather than the live WifiNetwork QObjects — + // omarchy's comment on wifiRow() explains why: NetworkManager churn + // (scans, APs disappearing) can destroy the object while a delegate + // still holds it, crashing quickshell. The actual object is re-resolved + // by SSID only when needed, via networkForSsid(). + function wifiRow(network) { + if (!network) return null + return { + connected: !!network.connected, + known: !!network.known, + ssid: network.name || "", + signal: Math.round((network.signalStrength || 0) * 100), + security: network.security + } + } + + function sortWifiRows(rows) { + const nets = rows.slice() + nets.sort(function(a, b) { + if (a.connected !== b.connected) return a.connected ? -1 : 1 + if (a.known !== b.known) return a.known ? -1 : 1 + return b.signal - a.signal + }) + return nets + } + + readonly property var wifiRows: root.wifiDevice + ? sortWifiRows(root.wifiDevice.networks.values.map(root.wifiRow).filter(function(r) { return r && r.ssid })) + : [] + + // Split for the "Known Networks" / "Other Networks" sections. wifiRows + // is already sorted connected-first then by signal, so filtering + // preserves that order within each group. + readonly property var knownWifiRows: root.wifiRows.filter(function(r) { return r.known }) + readonly property var otherWifiRows: root.wifiRows.filter(function(r) { return !r.known }) + + function networkForSsid(ssid) { + if (!root.wifiDevice) return null + const nets = root.wifiDevice.networks.values + for (let i = 0; i < nets.length; i++) { + if (nets[i].name === ssid) return nets[i] + } + return null + } + + // Only explicit passwordless security types skip the password prompt. + function requiresCredentials(security) { + return security !== WifiSecurityType.Open && security !== WifiSecurityType.Owe + } + + function wifiIcon(signal) { + // Back to the wifi-fan family (verified real ligatures) for the + // familiar look — the flat/wide shape itself is now corrected for + // by a vertical stretch transform on the Text that renders it, + // rather than by swapping to a differently-shaped icon. + if (signal >= 80) return "network_wifi" + if (signal >= 55) return "network_wifi_3_bar" + if (signal >= 30) return "network_wifi_2_bar" + return "network_wifi_1_bar" + } + + // Unlike WiFi, NetworkManager has no radio-killswitch-style "enabled" + // flag for wired devices — the closest equivalent is disconnecting the + // link and turning off autoconnect so it doesn't silently reconnect + // itself right back. + readonly property bool wiredOn: root.wiredDevice && root.wiredDevice.network + ? root.wiredDevice.network.connected + : false + + function setWiredEnabled(enabled) { + if (!root.wiredDevice) return + root.wiredDevice.autoconnect = enabled + if (enabled) { + if (root.wiredDevice.network) root.wiredDevice.network.connect() + } else if (root.wiredDevice.network) { + root.wiredDevice.network.disconnect() + } else { + root.wiredDevice.disconnect() + } + } + + // SSID of the row currently showing its inline password field, if any. + property string passwordSsid: "" + + // Wired connections don't have a user-facing "name" on the device or + // Network object — those just expose the interface name (e.g. "eth0"). + // The name shown in nmcli/NetworkManager ("Wired connection 1") lives on + // the connection's NMSettings profile instead, so pull it from there. + function wiredDisplayName() { + const net = root.wiredDevice && root.wiredDevice.network + const settings = net && net.nmSettings + if (settings && settings.length > 0 && settings[0].id) + return settings[0].id + return (root.wiredDevice && root.wiredDevice.name) || "Ethernet" + } + + // Name shown in the header title — the connected WiFi SSID, or the + // wired connection's display name, falling back to a generic label when + // offline. + readonly property string connectedNetworkName: { + if (!root.connectedDevice) return "Network" + if (root.connectedDevice.type === DeviceType.Wired) + return root.wiredDisplayName() + const row = root.wifiRows.find(function(r) { return r.connected }) + return (row && row.ssid) || "Network" + } + + function activateNetwork(net) { + if (net.connected) return + if (net.known || !root.requiresCredentials(net.security)) { + const n = root.networkForSsid(net.ssid) + if (n) n.connect() + } else { + root.passwordSsid = root.passwordSsid === net.ssid ? "" : net.ssid + } + } + + // --- Live stats -------------------------------------------------- + // None of this comes from Quickshell's Networking API — it only knows + // about devices/networks, not throughput, ping, or routing. So this + // block shells out, the same way the rest of the system does it: + // /sys/class/net/*/statistics for byte counters, `ip route` for the + // gateway, `ping` for latency/loss. All of it only runs while the + // panel is open. + + readonly property string ifaceName: root.connectedDevice ? root.connectedDevice.name : "" + + // NetworkDevice.address is the device's MAC, not an assigned IP — there + // is no IP property on Quickshell's Networking API at all, so this is + // shelled out the same way the gateway is below. + property string ipAddress: "—" + property string gateway: "—" + property string pingText: "—" + property string packetLossText: "—" + property real rxRate: 0 + property real txRate: 0 + property real rxTotal: 0 + property real txTotal: 0 + + property real _lastRx: -1 + property real _lastTx: -1 + property real _lastSampleTime: 0 + + function formatBytes(bytes) { + if (!isFinite(bytes) || bytes <= 0) return "0 B" + const units = ["B", "KB", "MB", "GB", "TB"] + let i = 0 + let v = bytes + while (v >= 1024 && i < units.length - 1) { v /= 1024; i++ } + return v.toFixed(i === 0 ? 0 : 2) + " " + units[i] + } + + function formatRate(bytesPerSec) { + return formatBytes(bytesPerSec) + "/s" + } + + readonly property var statRows: [ + { label: "Ping", value: root.pingText }, + { label: "Packet Loss", value: root.packetLossText }, + { label: "Receiving", value: root.formatRate(root.rxRate) }, + { label: "Sending", value: root.formatRate(root.txRate) }, + { label: "Downloaded", value: root.formatBytes(root.rxTotal) }, + { label: "Uploaded", value: root.formatBytes(root.txTotal) }, + { label: "IP Address", value: root.ipAddress }, + { label: "Gateway", value: root.gateway } + ] + + // Flattened into individual grid cells (label, value, label, value, ...) + // rather than paired items, so the two values in each row can each be + // right-aligned within their own half instead of just sitting next to + // their label. + readonly property var statCells: { + const cells = [] + for (let i = 0; i < root.statRows.length; i++) { + cells.push({ text: root.statRows[i].label, isLabel: true }) + cells.push({ text: root.statRows[i].value, isLabel: false }) + } + return cells + } + + // Shared by the stats grid and the (separate) DNS address grid below, + // so their label/value columns come out the same width even though + // they're two different GridLayout instances. IPv6 addresses are wider + // than this leaves room for — those values get elided rather than + // pushing the column wider. + readonly property int infoLabelColumnWidth: 90 + + // Caps how tall the Known/Other Networks list gets before it scrolls + // instead of continuing to grow the whole popup — roughly 5-6 rows. + // Everything above it (header, stats, DNS, wired) sizes normally, so + // once the network list hits this cap the popup's own height stops + // growing too. + readonly property int maxNetworkListHeight: 170 + + // --- DNS provider --------------------------------------------------- + // Scoped to just the active connection via nmcli, unlike omarchy's own + // DNS switcher (which shells out to a root-requiring helper that + // rewrites /etc/systemd/resolved.conf and a NetworkManager global-dns + // conf.d file system-wide). Modifying a connection you own is something + // NetworkManager lets an ordinary user do without a password prompt, so + // this stays scoped to nmcli connection modify + device reapply. + readonly property var dnsProviders: ["DHCP", "Cloudflare", "Google", "Custom"] + property string activeConnectionName: "" + // The connection's static override (empty under DHCP) — used only to + // work out which pill is "active" below. + property string dnsIpv4: "" + property string dnsIpv6: "" + // The device's actual effective DNS servers, whether they came from a + // static override or DHCP — this is what's displayed, so picking DHCP + // still shows real server addresses instead of going blank. + property string dnsEffectiveIpv4: "" + property string dnsEffectiveIpv6: "" + property bool dnsCustomOpen: false + property string dnsCustomText: "" + property bool dnsBusy: false + + // What's actually shown under the pills — the real servers currently in + // effect, not just the provider name, so a "Custom" entry or a + // provider swap is verifiable at a glance. Each family (dns.dns can + // list a primary + secondary server) becomes its own column, stacked + // primary-on-top-of-secondary within it — IPv4 on the left, IPv6 on + // the right. + // nmcli separates multiple servers with commas from `connection show` + // but with " | " from `device show` — split on either (and whitespace) + // so both sources parse correctly. + readonly property var dnsIpv4List: root.dnsEffectiveIpv4 ? root.dnsEffectiveIpv4.split(/[,|\s]+/).filter(function(s) { return s.length > 0 }) : [] + readonly property var dnsIpv6List: root.dnsEffectiveIpv6 ? root.dnsEffectiveIpv6.split(/[,|\s]+/).filter(function(s) { return s.length > 0 }) : [] + + // Flattened the same way statCells is, and fed into that same + // GridLayout as extra rows, so the DNS label/value columns line up + // pixel-for-pixel with the stats above them instead of being computed + // by a separate, independently-sized layout. IPv4 fills the left + // label:value pair, IPv6 the right one, row by row. + readonly property var dnsGridCells: { + if (root.dnsIpv4List.length === 0 && root.dnsIpv6List.length === 0) { + return [ + { text: "DNS", isLabel: true }, + { text: "Automatic (DHCP)", isLabel: false }, + { text: "", isLabel: true }, + { text: "", isLabel: false } + ] + } + const cells = [] + const rows = Math.max(root.dnsIpv4List.length, root.dnsIpv6List.length) + for (let i = 0; i < rows; i++) { + cells.push({ text: i < root.dnsIpv4List.length ? "DNS IPv4 " + (i + 1) : "", isLabel: true }) + cells.push({ text: i < root.dnsIpv4List.length ? root.dnsIpv4List[i] : "", isLabel: false }) + cells.push({ text: i < root.dnsIpv6List.length ? "DNS IPv6 " + (i + 1) : "", isLabel: true }) + cells.push({ text: i < root.dnsIpv6List.length ? root.dnsIpv6List[i] : "", isLabel: false }) + } + return cells + } + + readonly property string dnsProvider: { + const dns = (root.dnsIpv4 + " " + root.dnsIpv6).trim() + if (!dns) return "DHCP" + if (dns.indexOf("1.1.1.1") !== -1 || dns.indexOf("2606:4700:4700::1111") !== -1) return "Cloudflare" + if (dns.indexOf("8.8.8.8") !== -1 || dns.indexOf("2001:4860:4860::8888") !== -1) return "Google" + return "Custom" + } + + function setDns(provider) { + if (root.dnsBusy || !root.activeConnectionName) return + if (provider === "Custom") { + root.dnsCustomOpen = !root.dnsCustomOpen + return + } + root.dnsCustomOpen = false + if (provider === "Cloudflare") { + root.applyDns("1.1.1.1 1.0.0.1", "2606:4700:4700::1111 2606:4700:4700::1001", "yes") + } else if (provider === "Google") { + root.applyDns("8.8.8.8 8.8.4.4", "2001:4860:4860::8888 2001:4860:4860::8844", "yes") + } else { + root.applyDns("", "", "no") + } + } + + function submitCustomDns() { + const parts = root.dnsCustomText.split(/[\s,]+/).filter(function(s) { return s.length > 0 }) + if (parts.length === 0) return + const ipv4 = parts.filter(function(s) { return s.indexOf(":") === -1 }).join(" ") + const ipv6 = parts.filter(function(s) { return s.indexOf(":") !== -1 }).join(" ") + root.applyDns(ipv4, ipv6, "yes") + root.dnsCustomOpen = false + } + + function applyDns(ipv4, ipv6, ignoreAuto) { + if (!root.activeConnectionName) return + root.dnsBusy = true + dnsSetProc.command = ["nmcli", "connection", "modify", root.activeConnectionName, + "ipv4.ignore-auto-dns", ignoreAuto, "ipv4.dns", ipv4, + "ipv6.ignore-auto-dns", ignoreAuto, "ipv6.dns", ipv6] + dnsSetProc.running = true + } + + Process { + id: connNameProc + command: root.ifaceName ? ["nmcli", "-g", "GENERAL.CONNECTION", "device", "show", root.ifaceName] : ["true"] + stdout: StdioCollector { + id: connNameCollector + onStreamFinished: { + root.activeConnectionName = connNameCollector.text.trim() + if (!dnsQueryProc.running) dnsQueryProc.running = true + } + } + } + + Process { + id: dnsQueryProc + command: root.activeConnectionName + ? ["nmcli", "-g", "ipv4.dns,ipv6.dns", "connection", "show", root.activeConnectionName] + : ["true"] + stdout: StdioCollector { + id: dnsQueryCollector + onStreamFinished: { + // nmcli -g uses ':' as its field separator, so it + // backslash-escapes literal colons inside values (IPv6 + // addresses) to disambiguate them — unescape before display. + const lines = dnsQueryCollector.text.split("\n") + root.dnsIpv4 = (lines[0] || "").trim().replace(/\\:/g, ":") + root.dnsIpv6 = (lines[1] || "").trim().replace(/\\:/g, ":") + } + } + } + + // Effective DNS, from the device rather than the connection profile — + // under DHCP the profile's ipv4.dns/ipv6.dns are empty (nothing + // overridden), but the device still reports whatever DNS it actually + // negotiated. This is what dnsIpv4List/dnsIpv6List (the display grid) + // read from, so picking DHCP shows real servers instead of nothing. + Process { + id: dnsEffectiveProc + command: root.ifaceName ? ["nmcli", "-g", "IP4.DNS,IP6.DNS", "device", "show", root.ifaceName] : ["true"] + stdout: StdioCollector { + id: dnsEffectiveCollector + onStreamFinished: { + const lines = dnsEffectiveCollector.text.split("\n") + root.dnsEffectiveIpv4 = (lines[0] || "").trim().replace(/\\:/g, ":") + root.dnsEffectiveIpv6 = (lines[1] || "").trim().replace(/\\:/g, ":") + } + } + } + + // Applies the modified connection immediately (no reconnect/link drop) + // rather than requiring a manual toggle or full "connection up". + Process { + id: reapplyProc + stdout: StdioCollector {} + // The effective DNS (what dnsEffectiveProc reads) only updates once + // this actually finishes, so refresh it here rather than right + // after dnsSetProc — querying too early would still show the old + // servers. + onExited: (exitCode, exitStatus) => { + if (!dnsEffectiveProc.running) dnsEffectiveProc.running = true + } + } + + Process { + id: dnsSetProc + stdout: StdioCollector {} + onExited: (exitCode, exitStatus) => { + root.dnsBusy = false + if (root.ifaceName) { + reapplyProc.command = ["nmcli", "device", "reapply", root.ifaceName] + reapplyProc.running = true + } + if (!dnsQueryProc.running) dnsQueryProc.running = true + } + } + + Process { + id: gatewayProc + command: ["ip", "route", "show", "default"] + stdout: StdioCollector { + id: gatewayCollector + onStreamFinished: { + const m = gatewayCollector.text.match(/via\s+(\S+)/) + root.gateway = m ? m[1] : "—" + } + } + } + + Process { + id: ipProc + command: root.ifaceName ? ["ip", "-4", "-o", "addr", "show", "dev", root.ifaceName] : ["true"] + stdout: StdioCollector { + id: ipCollector + onStreamFinished: { + const m = ipCollector.text.match(/inet\s+([\d.]+)/) + root.ipAddress = m ? m[1] : "—" + } + } + } + + Process { + id: bytesProc + command: root.ifaceName + ? ["cat", "/sys/class/net/" + root.ifaceName + "/statistics/rx_bytes", + "/sys/class/net/" + root.ifaceName + "/statistics/tx_bytes"] + : ["true"] + stdout: StdioCollector { + id: bytesCollector + onStreamFinished: { + const lines = bytesCollector.text.trim().split("\n") + if (lines.length < 2) return + const rx = parseFloat(lines[0]) + const tx = parseFloat(lines[1]) + const now = Date.now() + if (root._lastSampleTime > 0 && root._lastRx >= 0) { + const dt = (now - root._lastSampleTime) / 1000 + if (dt > 0) { + root.rxRate = Math.max(0, (rx - root._lastRx) / dt) + root.txRate = Math.max(0, (tx - root._lastTx) / dt) + } + } + root.rxTotal = rx + root.txTotal = tx + root._lastRx = rx + root._lastTx = tx + root._lastSampleTime = now + } + } + } + + Process { + id: pingProc + command: ["ping", "-c", "4", "-W", "1", "1.1.1.1"] + stdout: StdioCollector { + id: pingCollector + onStreamFinished: { + const avgMatch = pingCollector.text.match(/=\s*[\d.]+\/([\d.]+)\//) + root.pingText = avgMatch ? Math.round(parseFloat(avgMatch[1])) + " ms" : "—" + const lossMatch = pingCollector.text.match(/([\d.]+)%\s*packet loss/) + root.packetLossText = lossMatch ? lossMatch[1] + "%" : "—" + } + } + } + + Timer { + interval: 1000 + repeat: true + triggeredOnStart: true + running: panel.visible && root.ifaceName !== "" + onTriggered: if (!bytesProc.running) bytesProc.running = true + } + + Timer { + interval: 5000 + repeat: true + triggeredOnStart: true + running: panel.visible && root.connectedDevice !== null + onTriggered: { + if (!pingProc.running) pingProc.running = true + if (!gatewayProc.running) gatewayProc.running = true + if (!ipProc.running) ipProc.running = true + if (!connNameProc.running) connNameProc.running = true + if (!dnsEffectiveProc.running) dnsEffectiveProc.running = true + } + } + Text { id: label anchors.centerIn: parent @@ -30,6 +528,598 @@ Item { } } - // TODO(follow-up): wire this up to a network panel (SSID list, connect/forget) - // once the control center lands. + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + panel.visible = !panel.visible + grab.active = panel.visible + } + } + + // --- Reusable pieces ----------------------------------------------- + + component ToggleSwitch: Item { + id: toggleRoot + property bool checked: false + signal toggled() + implicitWidth: 34 + implicitHeight: 18 + + Rectangle { + anchors.fill: parent + radius: 0 + color: toggleRoot.checked ? Colors.primary : Colors.muted + opacity: toggleRoot.checked ? 1 : 0.35 + } + Rectangle { + width: parent.height - 4 + height: parent.height - 4 + radius: 0 + color: Colors.background + anchors.verticalCenter: parent.verticalCenter + x: toggleRoot.checked ? parent.width - width - 2 : 2 + Behavior on x { NumberAnimation { duration: Appearance.animationDuration } } + } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: toggleRoot.toggled() + } + } + + component NetworkRow: Rectangle { + id: netRow + required property var net + signal activate() + readonly property bool passwordOpen: root.passwordSsid === netRow.net.ssid + width: parent ? parent.width : 0 + radius: 0 + implicitHeight: rowContent.implicitHeight + 12 + (passwordOpen ? passwordRow.implicitHeight + 6 : 0) + color: netRow.net.connected + ? Qt.rgba(Colors.primary.r, Colors.primary.g, Colors.primary.b, 0.18) + : (rowMouse.containsMouse ? Colors.primary : "transparent") + + function submit() { + const n = root.networkForSsid(netRow.net.ssid) + if (n && passwordInput.text.length > 0) n.connectWithPsk(passwordInput.text) + root.passwordSsid = "" + } + + Column { + anchors.fill: parent + anchors.margins: 6 + spacing: 6 + + RowLayout { + id: rowContent + width: parent.width + spacing: 8 + + Text { + Layout.preferredWidth: 20 + Layout.minimumWidth: 20 + Layout.maximumWidth: 20 + horizontalAlignment: Text.AlignLeft + text: root.wifiIcon(netRow.net.signal) + font.family: Appearance.iconFontFamily + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 14 + color: rowMouse.containsMouse ? Colors.primaryText : Colors.foreground + // The wifi-fan glyph itself is flat/wide — stretch it + // vertically to read taller while keeping its width (and + // the layout around it) unchanged. + transform: Scale { + origin.x: width / 2 + origin.y: height / 2 + yScale: 1.2 + } + } + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + + Text { + Layout.fillWidth: true + text: netRow.net.ssid + elide: Text.ElideRight + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSize + font.bold: netRow.net.connected + color: rowMouse.containsMouse ? Colors.primaryText : Colors.foreground + } + Text { + visible: netRow.net.connected + text: "Connected" + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: rowMouse.containsMouse ? Colors.primaryText : Colors.foreground + } + } + Text { + visible: root.requiresCredentials(netRow.net.security) + text: "lock" + font.family: Appearance.iconFontFamily + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 13 + color: rowMouse.containsMouse ? Colors.primaryText : Colors.muted + } + Text { + id: forgetIcon + visible: netRow.net.known + text: "close" + font.family: Appearance.iconFontFamily + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 14 + color: rowMouse.containsMouse ? Colors.primaryText : Colors.muted + } + } + + RowLayout { + id: passwordRow + visible: netRow.passwordOpen + width: parent.width + spacing: 6 + + Rectangle { + Layout.fillWidth: true + implicitHeight: 24 + radius: 0 + color: Colors.background + border.color: Colors.muted + border.width: 1 + + TextInput { + id: passwordInput + anchors.fill: parent + anchors.margins: 6 + echoMode: TextInput.Password + color: Colors.foreground + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + clip: true + focus: netRow.passwordOpen + onAccepted: netRow.submit() + // Explicit fallback alongside onAccepted, which only + // fires when acceptableInput is true — covers both + // the main Return key and numpad Enter directly. + Keys.onReturnPressed: netRow.submit() + Keys.onEnterPressed: netRow.submit() + } + } + + Text { + text: "check" + font.family: Appearance.iconFontFamily + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 16 + color: Colors.primary + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: netRow.submit() + } + } + } + } + + MouseArea { + id: rowMouse + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + height: rowContent.height + 12 + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + // forgetIcon has no MouseArea of its own — it's nested inside + // rowContent, which sits underneath this full-row area in + // stacking order, so a separate MouseArea there would just be + // shadowed. Dispatch by click position against this single area + // instead: netRow.mapFromItem() puts forgetIcon's edge into this + // MouseArea's coordinate space (its origin coincides with + // netRow's, since it's anchored flush to netRow's edges). + onClicked: (mouse) => { + if (netRow.net.known && forgetIcon.visible) { + const forgetLeft = netRow.mapFromItem(forgetIcon, 0, 0).x + if (mouse.x >= forgetLeft - 8) { + const n = root.networkForSsid(netRow.net.ssid) + if (n) n.forget() + return + } + } + netRow.activate() + } + } + } + + // --- Panel ----------------------------------------------------------- + + PopupWindow { + id: panel + visible: false + color: "transparent" + readonly property int contentWidth: 244 + readonly property int contentPadding: 16 + implicitWidth: contentWidth + 100 + contentPadding * 2 + implicitHeight: mainColumn.implicitHeight + contentPadding * 2 + // Wifi scan results appear/disappear while the panel is open, which + // changes mainColumn's height — animate that instead of snapping so + // the resize doesn't jump. + Behavior on implicitHeight { + NumberAnimation { duration: Appearance.animationDuration; easing.type: Easing.OutCubic } + } + + anchor { + window: root.barWindow + rect.x: 0 + rect.y: 0 + rect.width: root.barWindow.width + rect.height: root.barWindow.height + edges: Edges.Bottom | Edges.Right + gravity: Edges.Bottom | Edges.Left + margins.bottom: -4 + margins.right: 0 + } + + HyprlandFocusGrab { + id: grab + windows: [panel] + onCleared: panel.visible = false + } + + Rectangle { + anchors.fill: parent + radius: 0 + color: Colors.surface + border.color: Colors.muted + border.width: 3 + + Column { + id: mainColumn + anchors.fill: parent + anchors.margins: panel.contentPadding + spacing: 10 + + // Header + RowLayout { + width: parent.width + spacing: 8 + + Text { + text: label.text + font.family: Appearance.iconFontFamily + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 30 + color: Colors.foreground + } + + Text { + Layout.fillWidth: true + text: root.connectedNetworkName + elide: Text.ElideRight + font.family: Appearance.fontFamily + font.pixelSize: 18 + font.bold: true + color: Colors.foreground + } + } + + // Live stats — ping/loss/throughput/IP/gateway for whichever + // device is currently connected. + GridLayout { + width: parent.width + visible: root.connectedDevice !== null + columns: 4 + columnSpacing: 12 + rowSpacing: 5 + + Repeater { + model: root.statCells + Text { + required property var modelData + Layout.fillWidth: !modelData.isLabel + horizontalAlignment: modelData.isLabel ? Text.AlignLeft : Text.AlignRight + text: modelData.text + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: modelData.isLabel ? Colors.muted : Colors.foreground + } + } + } + + // DNS provider — modifies only the active connection via + // nmcli (see the block above), not a system-wide override. + Column { + width: parent.width + spacing: 6 + visible: root.connectedDevice !== null + + Text { + text: "DNS PROVIDER" + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.muted + } + + GridLayout { + width: parent.width + columns: 4 + columnSpacing: 6 + rowSpacing: 6 + + Repeater { + model: root.dnsProviders + Rectangle { + id: dnsPill + required property string modelData + readonly property bool active: root.dnsProvider === dnsPill.modelData + Layout.fillWidth: true + implicitHeight: 30 + radius: 0 + color: dnsPill.active + ? Colors.primary + : (dnsMouse.containsMouse ? Colors.surface : "transparent") + border.color: dnsPill.active ? Colors.primary : Colors.muted + border.width: 1 + opacity: root.dnsBusy ? 0.6 : 1 + + Text { + anchors.centerIn: parent + text: dnsPill.modelData + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: dnsPill.active ? Colors.primaryText : Colors.foreground + } + + MouseArea { + id: dnsMouse + anchors.fill: parent + enabled: !root.dnsBusy + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.setDns(dnsPill.modelData) + } + } + } + } + + // Current DNS servers — its own grid below the provider + // pills, sized from its own content rather than pinned + // to the stats grid's column widths above. + GridLayout { + width: parent.width + columns: 4 + columnSpacing: 12 + rowSpacing: 5 + + Repeater { + model: root.dnsGridCells + Text { + required property var modelData + Layout.fillWidth: !modelData.isLabel + horizontalAlignment: modelData.isLabel ? Text.AlignLeft : Text.AlignRight + text: modelData.text + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: modelData.isLabel ? Colors.muted : Colors.foreground + } + } + } + + RowLayout { + width: parent.width + visible: root.dnsCustomOpen + spacing: 6 + + Rectangle { + Layout.fillWidth: true + implicitHeight: 24 + radius: 0 + color: Colors.background + border.color: Colors.muted + border.width: 1 + + TextInput { + id: dnsCustomInput + anchors.fill: parent + anchors.margins: 6 + color: Colors.foreground + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + clip: true + focus: root.dnsCustomOpen + text: root.dnsCustomText + onTextChanged: root.dnsCustomText = text + onAccepted: root.submitCustomDns() + } + } + + Text { + text: "check" + font.family: Appearance.iconFontFamily + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 16 + color: Colors.primary + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: root.submitCustomDns() + } + } + } + } + + // Wired — toggle disconnects the link and disables autoconnect. + Column { + width: parent.width + spacing: 4 + visible: root.wiredDevice !== null + + RowLayout { + width: parent.width + Text { + Layout.fillWidth: true + text: "WIRED" + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSize + color: Colors.foreground + } + ToggleSwitch { + checked: root.wiredOn + onToggled: root.setWiredEnabled(!root.wiredOn) + } + } + RowLayout { + width: parent.width + spacing: 8 + Text { + text: "settings_ethernet" + font.family: Appearance.iconFontFamily + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 14 + color: Colors.foreground + } + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + + Text { + Layout.fillWidth: true + text: root.wiredDisplayName() + elide: Text.ElideRight + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSize + font.bold: root.wiredDevice && root.wiredDevice.hasLink + color: Colors.foreground + } + Text { + visible: root.wiredDevice && root.wiredDevice.hasLink + text: "Connected" + (root.wiredDevice && root.wiredDevice.linkSpeed > 0 ? " · " + root.wiredDevice.linkSpeed + " Mbps" : "") + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.foreground + } + Text { + visible: !(root.wiredDevice && root.wiredDevice.hasLink) + text: "Cable unplugged" + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.foreground + } + } + } + } + + // WiFi + Column { + width: parent.width + spacing: 6 + visible: root.wifiDevice !== null + + RowLayout { + width: parent.width + Text { + Layout.fillWidth: true + text: "WIFI" + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSize + color: Colors.foreground + } + ToggleSwitch { + checked: Networking.wifiEnabled + onToggled: Networking.wifiEnabled = !Networking.wifiEnabled + } + } + + Text { + width: parent.width + visible: Networking.wifiEnabled && root.wifiRows.length === 0 + text: "No networks found" + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.muted + } + + Text { + width: parent.width + visible: !Networking.wifiHardwareEnabled + text: "Wi-Fi is off in hardware" + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.muted + } + + // Known/Other Networks — capped height, scrolls once + // there are more networks than fit rather than growing + // the popup indefinitely. + Flickable { + id: networkListFlick + width: parent.width + height: Math.min(networkListColumn.implicitHeight, root.maxNetworkListHeight) + contentWidth: width + contentHeight: networkListColumn.implicitHeight + clip: true + boundsBehavior: Flickable.StopAtBounds + visible: networkListColumn.implicitHeight > 0 + + Column { + id: networkListColumn + width: networkListFlick.width + spacing: 6 + + Column { + width: parent.width + spacing: 4 + visible: Networking.wifiEnabled && root.knownWifiRows.length > 0 + + Text { + text: "Known Networks" + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.muted + } + Column { + width: parent.width + spacing: 2 + Repeater { + model: root.knownWifiRows + NetworkRow { + required property var modelData + net: modelData + onActivate: root.activateNetwork(net) + } + } + } + } + + Column { + width: parent.width + spacing: 4 + visible: Networking.wifiEnabled && root.otherWifiRows.length > 0 + + Text { + text: "Other Networks" + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.muted + } + Column { + width: parent.width + spacing: 2 + Repeater { + model: root.otherWifiRows + NetworkRow { + required property var modelData + net: modelData + onActivate: root.activateNetwork(net) + } + } + } + } + } + } + } + } + } + } }