1353 lines
58 KiB
QML
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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|