import QtQuick import QtQuick.Layouts import Quickshell import Quickshell.Hyprland import Quickshell.Io import Quickshell.Networking import "../../config" Item { id: root 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++) { if (devices[i].connected) return devices[i] } 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. Deliberately // NOT paused while a password prompt is up: scannerEnabled: false stops // NetworkManager from refreshing its AP cache at all, so the existing // list quietly ages out with nothing to replace it — the fix for typing // being interrupted by scan-driven refreshes lives below instead (the // password field itself is pulled out of the repeated row so refreshes // can't touch it), not here. 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 })) : [] // Quickshell's WifiDevice has no "a scan is in flight" signal — only // scannerEnabled, the on/off request, which stays true for as long as // the panel is open rather than pulsing per scan. wifiRows re-deriving // to a new array (as it does on every actual network-list change — see // the comment on wifiRow() above) is the closest available proxy for // "a scan just landed", so pulse briefly off that instead. property bool wifiJustScanned: false onWifiRowsChanged: { root.wifiJustScanned = true wifiScanPulseTimer.restart() } Timer { id: wifiScanPulseTimer interval: 1200 onTriggered: root.wifiJustScanned = false } // 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 awaiting a password, if any. The input itself lives in a single // persistent widget below the network list (see passwordEntryInput), // not inside the per-row NetworkRow delegate — wifiRows is rebuilt from // scratch on every scan result, and a Repeater over a plain array has no // way to diff that, so it destroys and recreates every delegate each // time. Keeping the field outside that churn is what lets typing survive // a scan landing mid-keystroke. property string passwordSsid: "" // Cleared whenever the target SSID changes so stale text from a // cancelled/previous prompt never carries over to the next one. onPasswordSsidChanged: passwordEntryInput.text = "" // 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" } // Signal strength of the currently-connected WiFi network, if any — lets // the bar icon show the same wifi-fan glyph (via wifiIcon()) as the // connected row inside the popup, instead of a generic "wifi" icon. readonly property int connectedWifiSignal: { const row = root.wifiRows.find(function(r) { return r.connected }) return row ? row.signal : 0 } 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 } } function submitPassword() { const n = root.networkForSsid(root.passwordSsid) if (n && passwordEntryInput.text.length > 0) n.connectWithPsk(passwordEntryInput.text) root.passwordSsid = "" } function cancelPassword() { root.passwordSsid = "" } // --- 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 font.family: Appearance.iconFontFamilyRounded font.variableAxes: Appearance.iconFontAxes font.pixelSize: 16 color: root.connectedDevice ? Colors.foreground : Colors.muted // Only Wired renders its own distinct glyph; disconnected uses // "signal_wifi_off" — the actual off companion to the // network_wifi/signal_wifi fan family used below (not "wifi_off", // which belongs to a visually different, newer glyph family and // looked inconsistent next to the connected bars) — so it needs the // same vertical-stretch correction as the rest of that family. readonly property bool isWifiFan: root.connectedDevice === null || root.connectedDevice.type !== DeviceType.Wired text: { if (!root.connectedDevice) return "signal_wifi_off" if (root.connectedDevice.type === DeviceType.Wired) return "settings_ethernet" return root.wifiIcon(root.connectedWifiSignal) } // Same correction as the matching glyph inside the popup's // NetworkRow — the wifi-fan family is inherently flat/wide. transform: Scale { origin.x: label.width / 2 origin.y: label.height / 2 yScale: label.isWifiFan ? 1.2 : 1.0 } } 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 } // Hover-only outline, offset outside the switch itself rather than // drawn on its edge — the switch lags behind a click until the // backend confirms it (e.g. Networking.wifiEnabled), so this is just // a "you can click this" cue, not click feedback. Rectangle { anchors.fill: parent anchors.margins: -4 radius: 0 color: "transparent" border.color: Colors.foreground border.width: toggleMouse.containsMouse ? 1 : 0 } 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 { id: toggleMouse anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: toggleRoot.toggled() } } component NetworkRow: Rectangle { id: netRow required property var net signal activate() // Still tracked for the highlight below — the actual input lives // outside this (repeatedly rebuilt) delegate, in the persistent // password entry widget under the network list. readonly property bool passwordOpen: root.passwordSsid === netRow.net.ssid width: parent ? parent.width : 0 radius: 0 implicitHeight: rowContent.implicitHeight + 12 // Hover checked first (same ordering as Volume.qml's DeviceRow): // hovering always shows a solid Colors.primary fill, even for an // already-active row, so the primaryText used below never has to // sit on top of the dimmer translucent tint instead. color: rowMouse.containsMouse ? Colors.primary : ((netRow.net.connected || netRow.passwordOpen) ? Qt.rgba(Colors.primary.r, Colors.primary.g, Colors.primary.b, 0.18) : "transparent") // Hover-only outline, offset outside the row rather than drawn on // its own edge — same treatment as ToggleSwitch's halo and the DNS // pills. networkListColumn insets its rows by 3px on each side // specifically to give this room without getting cropped by the // Flickable's clip. Rectangle { anchors.fill: parent anchors.margins: -3 radius: 0 color: "transparent" border.color: Colors.foreground border.width: rowMouse.containsMouse ? 1 : 0 } 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 Layout.alignment: Qt.AlignVCenter horizontalAlignment: Text.AlignLeft text: root.wifiIcon(netRow.net.signal) font.family: Appearance.iconFontFamilyRounded font.variableAxes: Appearance.iconFontAxes font.pixelSize: 16 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.iconFontFamilyRounded 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.iconFontFamilyRounded font.variableAxes: Appearance.iconFontAxes font.pixelSize: 14 color: rowMouse.containsMouse ? Colors.primaryText : Colors.muted } } } 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 { id: headerIcon text: label.text font.family: Appearance.iconFontFamilyRounded font.variableAxes: Appearance.iconFontAxes font.pixelSize: 30 color: Colors.foreground // Mirrors the bar icon's own correction — see label's // isWifiFan/transform above — since this just // displays the same text at a larger size. transform: Scale { origin.x: headerIcon.width / 2 origin.y: headerIcon.height / 2 yScale: label.isWifiFan ? 1.2 : 1.0 } } Text { Layout.fillWidth: true text: "Network" elide: Text.ElideRight font.family: Appearance.fontFamily font.pixelSize: 18 font.bold: true color: Colors.foreground } } // Row rather than RowLayout — RowLayout is built for // constraint-based stretching (Layout.fillWidth etc.), and // without either Text opting into that, its sizing pass can // still leave more room between them than spacing:0 implies. // Row has no such stretch logic: spacing is exactly the gap // you get, so these two sit flush (plus one small gap) no // matter what width is bound above them. Row { width: parent.width spacing: 4 Text { text: "Connected Interface:" font.family: Appearance.fontFamily font.pixelSize: Appearance.fontSizeSmall color: Colors.muted } Text { text: root.connectedNetworkName font.family: Appearance.fontFamily font.pixelSize: Appearance.fontSizeSmall color: Colors.foreground font.bold: true } } // 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 // Hover-only outline, offset outside the pill // itself rather than drawn on its own border — // same treatment as ToggleSwitch's halo. Rectangle { anchors.fill: parent anchors.margins: -3 radius: 0 color: "transparent" border.color: Colors.foreground border.width: dnsMouse.containsMouse ? 1 : 0 } 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.iconFontFamilyRounded 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.iconFontFamilyRounded 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 } // Spins briefly each time a scan actually lands — // same "still searching" cue as // BluetoothIndicator.qml's spinner, but pulsed via // wifiJustScanned (see above) rather than staying on // for the whole time the panel is open, since // WifiDevice has no per-scan signal of its own to // key off. Text { id: wifiScanSpinner visible: root.wifiDevice !== null && root.wifiDevice.scannerEnabled && root.wifiJustScanned text: "progress_activity" font.family: Appearance.iconFontFamilyRounded font.variableAxes: Appearance.iconFontAxes font.pixelSize: 16 color: Colors.muted RotationAnimation on rotation { running: wifiScanSpinner.visible loops: Animation.Infinite from: 0 to: 360 duration: 900 } } 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 } // Password entry — a single persistent widget rather // than living inside the NetworkRow delegate for // root.passwordSsid, so a wifi scan landing mid-keystroke // (which rebuilds the whole Known/Other Networks list // below) can't destroy the focused TextInput and wipe // out what's been typed. See passwordSsid's own comment. Rectangle { width: parent.width visible: root.passwordSsid !== "" radius: 0 color: Colors.background border.color: Colors.muted border.width: 1 implicitHeight: passwordEntryColumn.implicitHeight + 12 Column { id: passwordEntryColumn anchors.fill: parent anchors.margins: 6 spacing: 6 Text { width: parent.width text: "Password for “" + root.passwordSsid + "”" elide: Text.ElideRight font.family: Appearance.fontFamily font.pixelSize: Appearance.fontSizeSmall color: Colors.muted } RowLayout { width: parent.width spacing: 6 Rectangle { Layout.fillWidth: true implicitHeight: 24 radius: 0 color: Colors.surface border.color: Colors.muted border.width: 1 TextInput { id: passwordEntryInput anchors.fill: parent anchors.margins: 6 echoMode: TextInput.Password color: Colors.foreground font.family: Appearance.fontFamily font.pixelSize: Appearance.fontSizeSmall clip: true focus: root.passwordSsid !== "" onAccepted: root.submitPassword() // Explicit fallback alongside onAccepted, which only // fires when acceptableInput is true — covers both // the main Return key and numpad Enter directly. Keys.onReturnPressed: root.submitPassword() Keys.onEnterPressed: root.submitPassword() Keys.onEscapePressed: root.cancelPassword() } } Text { text: "check" font.family: Appearance.iconFontFamilyRounded font.variableAxes: Appearance.iconFontAxes font.pixelSize: 16 color: Colors.primary MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor onClicked: root.submitPassword() } } Text { text: "close" font.family: Appearance.iconFontFamilyRounded font.variableAxes: Appearance.iconFontAxes font.pixelSize: 16 color: Colors.muted MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor onClicked: root.cancelPassword() } } } } } // 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 // Inset 3px on every side (rather than flush with // the Flickable) so NetworkRow's hover halo below // has room to sit outside the row without being // cropped by the Flickable's own clip. Left/right // needs the manual x/width shift (children bind // width: parent.width, which ignores Column's own // padding); top/bottom just needs padding, since // that's along Column's own layout axis and the // Flickable's contentHeight tracks it. x: 3 width: networkListFlick.width - 6 topPadding: 3 bottomPadding: 3 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) } } } } } } } } } } }