Files
dotfiles/dotfiles/.config/quickshell/modules/bar/NetworkIndicator.qml
2026-08-27 13:02:34 +01:00

1353 lines
58 KiB
QML

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)
}
}
}
}
}
}
}
}
}
}
}