The monitor panel polled `omarchy-monitor-state` every 5s (26 forks per call) and the network panel polled `omarchy-network-status` every 3s, both with `running: true` rather than gating like the sibling panels. Monitor: gate the poll on `opened` (brightness/scale are only shown in the open panel, which already refreshes on open) and drive the bar glyph from Quickshell.screens instead of the polled display list, so monitor count stays correct without polling. Network: the bar pill needs live-ish state, so keep polling but at 10s instead of 3s. (Deriving the pill from the native Networking service would remove the poll entirely; left as a follow-up since it needs Wi-Fi state testing.) Idle fork rate drops ~20/s to ~14/s. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1551 lines
52 KiB
QML
1551 lines
52 KiB
QML
import QtQuick
|
||
import QtQuick.Controls
|
||
import QtQuick.Layouts
|
||
import Quickshell
|
||
import Quickshell.Io
|
||
import Quickshell.Networking
|
||
import qs.Ui
|
||
import qs.Commons
|
||
import "Model.js" as Model
|
||
|
||
Panel {
|
||
id: root
|
||
moduleName: "omarchy.network"
|
||
ipcTarget: "omarchy.network"
|
||
|
||
// Centralized close so callers can't forget to drop the passphrase prompt.
|
||
function close() {
|
||
root.controller.hide()
|
||
passwordSsid = ""
|
||
}
|
||
|
||
// Live connection details from `ip` / /sys / iw.
|
||
property var info: ({}) // { iface, type, ip, prefix, gateway, speed, duplex, ssid, signal, freq, bitrate, rx_bytes, tx_bytes, router_ping_ms, internet_ping_ms }
|
||
|
||
// Throughput tracking. Rates are computed as deltas between successive
|
||
// `omarchy-network-status --verbose` samples (~1.5s apart via detailsPoll).
|
||
// We hold "prev" alongside a timestamp so the first sample after open or
|
||
// after an interface switch doesn't manufacture a spike.
|
||
property real prevRxBytes: 0
|
||
property real prevTxBytes: 0
|
||
property real prevSampleTime: 0
|
||
property string prevIface: ""
|
||
property real downloadRate: 0 // bytes/sec
|
||
property real uploadRate: 0 // bytes/sec
|
||
property string pingIface: ""
|
||
property var routerPingSamples: []
|
||
property var internetPingSamples: []
|
||
property real routerPingLatency: -1
|
||
property real internetPingLatency: -1
|
||
property int internetPingPacketLoss: 0
|
||
readonly property int pingHistoryWindow: 24
|
||
readonly property int pingAverageWindow: 5
|
||
readonly property bool hasInternetPing: internetPingSamples.length > 0
|
||
property int connectionPhraseIndex: 0
|
||
readonly property var connectionPhrases: [
|
||
"Wiring bits",
|
||
"Handling packets",
|
||
"Sorting frames",
|
||
"Hauling bytes",
|
||
"Routing crumbs",
|
||
"Counting collisions",
|
||
"Bending light",
|
||
]
|
||
readonly property string connectionPhrase: connectionPhrases[connectionPhraseIndex % connectionPhrases.length]
|
||
readonly property bool networkManagerAvailable: Networking.backend === NetworkBackendType.NetworkManager
|
||
readonly property var networkDevices: Networking.devices ? Networking.devices.values : []
|
||
readonly property var wifiDevice: findDevice(DeviceType.Wifi)
|
||
readonly property var wifiNetworkObjects: wifiDevice && wifiDevice.networks ? wifiDevice.networks.values : []
|
||
readonly property var connectedWifiNetwork: findConnectedWifiNetwork()
|
||
property var wifiNetworks: []
|
||
property bool scanning: false
|
||
property bool wifiStationAvailable: false
|
||
property string dnsProvider: ""
|
||
property string pendingDnsProvider: ""
|
||
property bool speedTestRunning: false
|
||
property bool speedTestHasRun: false
|
||
property bool speedTestExpectedStop: false
|
||
property string speedTestPhase: ""
|
||
property string speedTestStderr: ""
|
||
property string speedTestDownloadMbps: ""
|
||
property string speedTestUploadMbps: ""
|
||
property string speedTestError: ""
|
||
|
||
// Per-row in-flight state. `actionSsid` flips on for the row whose action
|
||
// is currently running so it can render "Connecting…" / "Disconnecting…" /
|
||
// "Forgetting…". `passwordSsid` is the row currently expanded into
|
||
// password-entry mode; we keep it open across refresh cycles so a slow scan
|
||
// doesn't collapse the input the user is typing into. Rows must gate
|
||
// comparisons on the matching `*Kind`/`*Reason` being non-empty so a
|
||
// hidden-SSID row (ssid == "") doesn't collide with the "" defaults.
|
||
property string actionSsid: ""
|
||
property string actionKind: "" // "connect" | "disconnect" | "forget"
|
||
property string failureSsid: ""
|
||
property string failureReason: ""
|
||
property string passwordSsid: ""
|
||
property string passwordText: ""
|
||
|
||
// True while any wifi action is mid-flight. Rows
|
||
// disable themselves on this so clicks on the other rows don't silently
|
||
// no-op against runNetworkAction's serialized guard.
|
||
readonly property bool busy: actionKind !== ""
|
||
|
||
// Index into `wifiNetworks` for keyboard navigation. -1 = no selection.
|
||
property int selectedIndex: -1
|
||
property bool wifiActionFocused: false
|
||
property bool cursorActive: false
|
||
|
||
// Keyboard focus zone for the panel. j/k crosses row boundaries:
|
||
// header actions ⇄ DNS row ⇄ Wi-Fi networks. h/l move within header
|
||
// actions or DNS providers.
|
||
property string focusSection: "dns" // "header" | "dns" | "wifi"
|
||
property int headerIndex: 0
|
||
readonly property bool canDisconnect: !!connectedWifiNetwork
|
||
readonly property bool headerHasDisconnect: false
|
||
readonly property int headerActionCount: 0
|
||
readonly property var dnsProviders: ["DHCP", "Cloudflare", "Google", "Custom"]
|
||
property int dnsIndex: 0
|
||
|
||
onHeaderActionCountChanged: clampHeaderIndex()
|
||
|
||
function clampHeaderIndex() {
|
||
var max = Math.max(0, headerActionCount - 1)
|
||
if (headerIndex > max) headerIndex = max
|
||
if (headerIndex < 0) headerIndex = 0
|
||
}
|
||
|
||
function selectHeaderByDelta(delta) {
|
||
headerIndex = Math.max(0, Math.min(headerActionCount - 1, headerIndex + delta))
|
||
}
|
||
|
||
function activateHeader() {
|
||
if (headerHasDisconnect && headerIndex === 0 && !busy) disconnect(connectedWifiNetwork)
|
||
}
|
||
|
||
function selectDnsByDelta(delta) {
|
||
dnsIndex = Math.max(0, Math.min(dnsProviders.length - 1, dnsIndex + delta))
|
||
}
|
||
|
||
function activateDns() {
|
||
if (dnsIndex < 0 || dnsIndex >= dnsProviders.length) return
|
||
setDns(dnsProviders[dnsIndex])
|
||
}
|
||
|
||
// Single cursor model: exactly one highlighted spot across the whole
|
||
// panel, located via `focusSection` + (`headerIndex` | `dnsIndex` |
|
||
// `selectedIndex`). Mouse hover and keyboard nav both mutate this state
|
||
// at the root; items never read containsMouse for visuals. See
|
||
// CursorSurface for the shared chrome shared by rows and pills.
|
||
readonly property color hoverFill: bar ? Style.hoverFillFor(bar.foreground, Color.accent) : "transparent"
|
||
readonly property color selectedFill: bar ? Style.selectedFillFor(bar.foreground, Color.accent) : "transparent"
|
||
|
||
// The panel below is its own layer-shell with Exclusive keyboard focus,
|
||
// so Hyprland grants focus when the surface is mapped (opened flips
|
||
// to true). That's what makes the SUPER+CTRL+W keybind actually work
|
||
// — OnDemand only grants focus on click/hover.
|
||
onOpenedChanged: {
|
||
if (opened) {
|
||
refresh(true)
|
||
selectedIndex = wifiNetworks.length > 0 ? 0 : -1
|
||
wifiActionFocused = false
|
||
focusSection = wifiNetworks.length > 0 ? "wifi" : "dns"
|
||
var idx = dnsProviders.indexOf(dnsProvider)
|
||
dnsIndex = idx >= 0 ? idx : 0
|
||
cursorActive = false
|
||
} else {
|
||
// Reset throughput tracking so the next open doesn't compute a fake
|
||
// rate from a sample taken minutes ago.
|
||
prevSampleTime = 0
|
||
downloadRate = 0
|
||
uploadRate = 0
|
||
pingIface = ""
|
||
routerPingSamples = []
|
||
internetPingSamples = []
|
||
routerPingLatency = -1
|
||
internetPingLatency = -1
|
||
internetPingPacketLoss = 0
|
||
if (wifiDevice) wifiDevice.scannerEnabled = false
|
||
}
|
||
}
|
||
|
||
// When the passphrase prompt closes (Esc / Cancel / success) restore
|
||
// focus to the keyCatcher so j/k/Enter resume working without a click.
|
||
// The KeyboardPanel's focusTarget covers initial popup-open; this handles
|
||
// the inline-editor case where focus was handed off to a child.
|
||
onPasswordSsidChanged: {
|
||
if (passwordSsid === "" && opened) {
|
||
passwordText = ""
|
||
Qt.callLater(function() { if (keyCatcher) keyCatcher.forceActiveFocus() })
|
||
}
|
||
}
|
||
|
||
// Keep selectedIndex valid as scans refresh the network list.
|
||
// If the list empties (station gone, e.g. wifi off), bounce the cursor
|
||
// back to the DNS row so the panel doesn't end up with no cursor at all.
|
||
onWifiNetworksChanged: {
|
||
if (wifiNetworks.length === 0) {
|
||
selectedIndex = -1
|
||
wifiActionFocused = false
|
||
if (focusSection === "wifi") focusSection = "dns"
|
||
} else if (passwordSsid !== "") {
|
||
var passwordIndex = wifiIndexForSsid(passwordSsid)
|
||
if (passwordIndex >= 0) {
|
||
selectedIndex = passwordIndex
|
||
focusSection = "wifi"
|
||
}
|
||
} else if (selectedIndex >= wifiNetworks.length) {
|
||
selectedIndex = wifiNetworks.length - 1
|
||
} else if (selectedIndex < 0 && opened) {
|
||
selectedIndex = 0
|
||
}
|
||
|
||
if (selectedIndex < 0 || selectedIndex >= wifiNetworks.length || !canForgetNetwork(wifiNetworks[selectedIndex])) {
|
||
wifiActionFocused = false
|
||
}
|
||
}
|
||
|
||
onWifiDeviceChanged: {
|
||
if (wifiDevice) wifiDevice.scannerEnabled = opened
|
||
syncWifiNetworks()
|
||
}
|
||
|
||
onWifiNetworkObjectsChanged: syncWifiNetworks()
|
||
|
||
function selectByDelta(delta) {
|
||
if (wifiNetworks.length === 0) { selectedIndex = -1; return }
|
||
if (selectedIndex < 0) selectedIndex = delta > 0 ? 0 : wifiNetworks.length - 1
|
||
else selectedIndex = Math.max(0, Math.min(wifiNetworks.length - 1, selectedIndex + delta))
|
||
wifiActionFocused = false
|
||
}
|
||
|
||
function canForgetNetwork(net) {
|
||
return !!(net && net.known && isProtected(net.security) && !net.connected)
|
||
}
|
||
|
||
function selectWifiActionByDelta(delta) {
|
||
if (selectedIndex < 0 || selectedIndex >= wifiNetworks.length) return
|
||
if (!canForgetNetwork(wifiNetworks[selectedIndex])) {
|
||
wifiActionFocused = false
|
||
return
|
||
}
|
||
if (delta > 0) wifiActionFocused = true
|
||
else if (delta < 0) wifiActionFocused = false
|
||
}
|
||
|
||
// Enter/Space on the highlighted row. Mirrors row-click semantics:
|
||
// connected → disconnect, protected-unknown → password prompt,
|
||
// open/known → connect.
|
||
function activateSelected() {
|
||
if (busy || selectedIndex < 0 || selectedIndex >= wifiNetworks.length) return
|
||
var net = wifiNetworks[selectedIndex]
|
||
if (!net) return
|
||
if (wifiActionFocused && canForgetNetwork(net)) { forget(net); return }
|
||
if (net.connected) { disconnect(net.network); return }
|
||
if (isProtected(net.security) && !net.known) { openPasswordPrompt(net.ssid); return }
|
||
connectKnown(net.ssid)
|
||
}
|
||
|
||
// Bar pill state. Polled locally so this panel is self-contained;
|
||
// populated by networkProc + networkTimer below.
|
||
property string kind: "disconnected"
|
||
property string label: ""
|
||
property int signalStrength: -1
|
||
property string frequency: ""
|
||
|
||
function updateNetwork(raw) {
|
||
var parsed = Model.parseNetworkStatus(raw)
|
||
kind = parsed.kind
|
||
label = parsed.label
|
||
signalStrength = parsed.signalStrength
|
||
frequency = parsed.frequency
|
||
}
|
||
|
||
function copyToClipboard(value) {
|
||
if (!value || !root.bar) return
|
||
Quickshell.execDetached(["bash", "-lc", "printf %s " + Util.shellQuote(value) + " | wl-copy"])
|
||
}
|
||
|
||
readonly property string icon: Model.connectionIcon(kind, signalStrength)
|
||
|
||
function refresh(scanWifi) {
|
||
if (scanWifi === undefined) scanWifi = false
|
||
if (!detailsProc.running) detailsProc.running = true
|
||
if (!dnsProc.running) {
|
||
dnsProc.command = ["bash", "-lc", root.dnsCommand("")]
|
||
dnsProc.running = true
|
||
}
|
||
if (wifiDevice) {
|
||
if (scanWifi) {
|
||
scanning = true
|
||
wifiDevice.scannerEnabled = false
|
||
scanRestart.start()
|
||
} else {
|
||
wifiDevice.scannerEnabled = true
|
||
}
|
||
}
|
||
syncWifiNetworks()
|
||
}
|
||
|
||
function formatHeaderSpeed(mbps) {
|
||
return Model.formatHeaderSpeed(mbps)
|
||
}
|
||
|
||
function formatHeaderFreq(mhz) {
|
||
return Model.formatHeaderFreq(mhz)
|
||
}
|
||
|
||
function headerDetail() {
|
||
return Model.headerDetail(info)
|
||
}
|
||
|
||
function updateDetails(raw) {
|
||
var next = Model.parseKeyValue(raw)
|
||
info = next
|
||
updateThroughput(next)
|
||
updatePingLatency(next)
|
||
}
|
||
|
||
function updateThroughput(next) {
|
||
var state = Model.throughputState({
|
||
prevIface: prevIface,
|
||
prevRxBytes: prevRxBytes,
|
||
prevTxBytes: prevTxBytes,
|
||
prevSampleTime: prevSampleTime,
|
||
downloadRate: downloadRate,
|
||
uploadRate: uploadRate
|
||
}, next, Date.now() / 1000)
|
||
|
||
prevIface = state.prevIface
|
||
prevRxBytes = state.prevRxBytes
|
||
prevTxBytes = state.prevTxBytes
|
||
prevSampleTime = state.prevSampleTime
|
||
downloadRate = state.downloadRate
|
||
uploadRate = state.uploadRate
|
||
}
|
||
|
||
function updatePingLatency(next) {
|
||
var state = Model.pingLatencyState({
|
||
pingIface: pingIface,
|
||
routerPingSamples: routerPingSamples,
|
||
internetPingSamples: internetPingSamples
|
||
}, next, pingHistoryWindow, pingAverageWindow)
|
||
|
||
pingIface = state.pingIface
|
||
routerPingSamples = state.routerPingSamples
|
||
internetPingSamples = state.internetPingSamples
|
||
routerPingLatency = state.routerPingLatency
|
||
internetPingLatency = state.internetPingLatency
|
||
internetPingPacketLoss = state.internetPingPacketLoss
|
||
}
|
||
|
||
function formatBytes(bytes) {
|
||
return Model.formatBytes(bytes)
|
||
}
|
||
|
||
function formatRate(bytesPerSec) {
|
||
return Model.formatRate(bytesPerSec)
|
||
}
|
||
|
||
function formatSpeedMbps(mbps) {
|
||
return Model.formatSpeedMbps(mbps)
|
||
}
|
||
|
||
function formatPingLatency(ms) {
|
||
return Model.formatPingLatency(ms)
|
||
}
|
||
|
||
function formatPacketLoss(percent) {
|
||
return Model.formatPacketLoss(percent)
|
||
}
|
||
|
||
function findDevice(type) {
|
||
var devices = networkDevices || []
|
||
for (var i = 0; i < devices.length; i++) {
|
||
if (devices[i] && devices[i].type === type) return devices[i]
|
||
}
|
||
return null
|
||
}
|
||
|
||
function findConnectedWifiNetwork() {
|
||
var networks = wifiNetworkObjects || []
|
||
for (var i = 0; i < networks.length; i++) {
|
||
if (networks[i] && networks[i].connected) return networks[i]
|
||
}
|
||
return null
|
||
}
|
||
|
||
function syncWifiNetworks() {
|
||
var nets = []
|
||
var networks = wifiNetworkObjects || []
|
||
|
||
for (var i = 0; i < networks.length; i++) {
|
||
var network = networks[i]
|
||
if (!network) continue
|
||
checkActionCompletion(network)
|
||
var row = Model.wifiRow(network)
|
||
if (row) nets.push(row)
|
||
}
|
||
wifiNetworks = Model.sortWifiRows(nets)
|
||
wifiStationAvailable = !!wifiDevice
|
||
scanning = false
|
||
}
|
||
|
||
function wifiSectionTitle(index) {
|
||
return Model.wifiSectionTitle(wifiNetworks, index)
|
||
}
|
||
|
||
function wifiIconFor(strength) {
|
||
return Model.wifiIconFor(strength)
|
||
}
|
||
|
||
function updateDns(raw) {
|
||
var value = String(raw || "").trim()
|
||
dnsProvider = value || "DHCP"
|
||
}
|
||
|
||
function updateSpeedTestLine(line) {
|
||
var value = parseFloat(line)
|
||
if (!isFinite(value) || value < 0) return
|
||
|
||
if (speedTestPhase === "down") speedTestDownloadMbps = String(value)
|
||
else if (speedTestPhase === "up") speedTestUploadMbps = String(value)
|
||
speedTestError = ""
|
||
}
|
||
|
||
function runSpeedTest() {
|
||
if (speedTestProc.running) return
|
||
speedTestError = ""
|
||
speedTestHasRun = true
|
||
speedTestRunning = true
|
||
startSpeedTestPhase("down")
|
||
}
|
||
|
||
function startSpeedTestPhase(phase) {
|
||
speedTestExpectedStop = false
|
||
speedTestPhase = phase
|
||
speedTestStderr = ""
|
||
speedTestProc.command = ["omarchy-network-speedtest", phase]
|
||
speedTestProc.running = true
|
||
speedTestPhaseTimer.restart()
|
||
}
|
||
|
||
function stopSpeedTestPhase() {
|
||
speedTestPhaseTimer.stop()
|
||
if (speedTestProc.running) {
|
||
speedTestExpectedStop = true
|
||
speedTestProc.running = false
|
||
return
|
||
}
|
||
finishSpeedTestPhase()
|
||
}
|
||
|
||
function finishSpeedTestPhase() {
|
||
if (speedTestPhase === "down") {
|
||
startSpeedTestPhase("up")
|
||
return
|
||
}
|
||
|
||
speedTestPhase = ""
|
||
speedTestRunning = false
|
||
speedTestExpectedStop = false
|
||
}
|
||
|
||
function dnsCommand(provider) {
|
||
var command = "omarchy-dns"
|
||
if (provider) command += " " + Util.shellQuote(provider)
|
||
return command
|
||
}
|
||
|
||
function setDns(provider) {
|
||
if (!root.bar || !provider || actionProc.running) return
|
||
|
||
if (provider === "Custom") {
|
||
var launcher = "omarchy-launch-floating-terminal-with-presentation"
|
||
root.bar.run(launcher + " " + Util.shellQuote(root.dnsCommand(provider)))
|
||
root.close()
|
||
return
|
||
}
|
||
|
||
root.pendingDnsProvider = provider
|
||
actionProc.command = ["bash", "-lc", root.dnsCommand(provider)]
|
||
actionProc.running = true
|
||
root.close()
|
||
}
|
||
|
||
function isProtected(security) {
|
||
return Model.isProtected(security, WifiSecurityType.Open)
|
||
}
|
||
|
||
function openPasswordPrompt(ssid) {
|
||
if (passwordSsid !== ssid) passwordText = ""
|
||
passwordSsid = ssid
|
||
}
|
||
|
||
function networkForSsid(ssid) {
|
||
var networks = wifiNetworkObjects || []
|
||
for (var i = 0; i < networks.length; i++) {
|
||
if (networks[i] && networks[i].name === ssid) return networks[i]
|
||
}
|
||
return null
|
||
}
|
||
|
||
function wifiIndexForSsid(ssid) {
|
||
for (var i = 0; i < wifiNetworks.length; i++) {
|
||
if (wifiNetworks[i] && wifiNetworks[i].ssid === ssid) return i
|
||
}
|
||
return -1
|
||
}
|
||
|
||
function runNetworkAction(kind, network, callback) {
|
||
if (actionKind !== "" || !network) return
|
||
var ssid = network.name || ""
|
||
actionSsid = ssid
|
||
actionKind = kind
|
||
failureSsid = ""
|
||
failureReason = ""
|
||
callback(network)
|
||
// Safety net: if onExited never fires (process death, signal handler
|
||
// throws, etc.), clear the busy state so the row doesn't get stuck on
|
||
// "Connecting…" / "Disconnecting…" forever.
|
||
actionTimeout.restart()
|
||
}
|
||
|
||
function clearNetworkAction() {
|
||
actionTimeout.stop()
|
||
if (actionKind === "connect") passwordSsid = ""
|
||
failureSsid = ""
|
||
failureReason = ""
|
||
actionSsid = ""
|
||
actionKind = ""
|
||
refresh()
|
||
}
|
||
|
||
function failNetworkAction(network, reason) {
|
||
if (!network || actionKind === "" || actionSsid !== (network.name || "")) return
|
||
actionTimeout.stop()
|
||
failureSsid = actionSsid
|
||
failureReason = networkFailureReason(reason)
|
||
actionSsid = ""
|
||
actionKind = ""
|
||
refresh()
|
||
}
|
||
|
||
function networkFailureReason(reason) {
|
||
return Model.networkFailureReason(reason, {
|
||
NoSecrets: ConnectionFailReason.NoSecrets,
|
||
WifiAuthTimeout: ConnectionFailReason.WifiAuthTimeout,
|
||
WifiNetworkLost: ConnectionFailReason.WifiNetworkLost,
|
||
WifiClientDisconnected: ConnectionFailReason.WifiClientDisconnected,
|
||
WifiClientFailed: ConnectionFailReason.WifiClientFailed
|
||
})
|
||
}
|
||
|
||
function checkActionCompletion(network) {
|
||
if (!network || actionKind === "" || actionSsid !== (network.name || "")) return
|
||
if (actionKind === "connect" && network.connected) clearNetworkAction()
|
||
else if (actionKind === "disconnect" && !network.connected && !network.stateChanging) clearNetworkAction()
|
||
else if (actionKind === "forget" && !network.known && !network.stateChanging) clearNetworkAction()
|
||
}
|
||
|
||
function connectKnown(ssid) {
|
||
runNetworkAction("connect", networkForSsid(ssid), function(network) { network.connect() })
|
||
}
|
||
|
||
function connectWithPassphrase(ssid, passphrase) {
|
||
runNetworkAction("connect", networkForSsid(ssid), function(network) { network.connectWithPsk(passphrase) })
|
||
}
|
||
|
||
function disconnect(network) {
|
||
runNetworkAction("disconnect", network || connectedWifiNetwork, function(net) { net.disconnect() })
|
||
}
|
||
|
||
function forget(net) {
|
||
runNetworkAction("forget", net ? net.network : null, function(network) { network.forget() })
|
||
}
|
||
|
||
implicitWidth: button.implicitWidth
|
||
implicitHeight: button.implicitHeight
|
||
|
||
Component.onCompleted: refresh()
|
||
|
||
// Pulls everything we want about the active route's interface in one shot.
|
||
Process {
|
||
id: detailsProc
|
||
command: ["omarchy-network-status", "--verbose"]
|
||
stdout: StdioCollector {
|
||
waitForEnd: true
|
||
onStreamFinished: root.updateDetails(text)
|
||
}
|
||
}
|
||
|
||
Timer {
|
||
id: scanRestart
|
||
interval: 100
|
||
repeat: false
|
||
onTriggered: {
|
||
if (root.wifiDevice) root.wifiDevice.scannerEnabled = true
|
||
scanDone.start()
|
||
}
|
||
}
|
||
|
||
Timer {
|
||
id: scanDone
|
||
interval: 1500
|
||
repeat: false
|
||
onTriggered: root.syncWifiNetworks()
|
||
}
|
||
|
||
Process {
|
||
id: dnsProc
|
||
stdout: StdioCollector {
|
||
waitForEnd: true
|
||
onStreamFinished: root.updateDns(text)
|
||
}
|
||
}
|
||
|
||
Process {
|
||
id: speedTestProc
|
||
stdout: SplitParser { onRead: function(line) { root.updateSpeedTestLine(line) } }
|
||
stderr: StdioCollector {
|
||
waitForEnd: true
|
||
onStreamFinished: root.speedTestStderr = String(text || "").trim()
|
||
}
|
||
onExited: function(exitCode) {
|
||
speedTestPhaseTimer.stop()
|
||
|
||
if (!root.speedTestExpectedStop && exitCode !== 0) {
|
||
root.speedTestError = root.speedTestStderr || "Speed test failed"
|
||
root.speedTestPhase = ""
|
||
root.speedTestRunning = false
|
||
return
|
||
}
|
||
|
||
root.speedTestExpectedStop = false
|
||
root.finishSpeedTestPhase()
|
||
}
|
||
}
|
||
|
||
Timer {
|
||
id: speedTestPhaseTimer
|
||
interval: 5000
|
||
repeat: false
|
||
onTriggered: root.stopSpeedTestPhase()
|
||
}
|
||
|
||
// Action runner for DNS provider changes. Wi-Fi actions use the
|
||
// Quickshell.Networking NetworkManager backend directly.
|
||
Process {
|
||
id: actionProc
|
||
stdout: StdioCollector { id: actionStdout; waitForEnd: true }
|
||
stderr: StdioCollector { id: actionStderr; waitForEnd: true }
|
||
onExited: function(exitCode) {
|
||
if (root.pendingDnsProvider !== "") {
|
||
if (exitCode === 0) root.dnsProvider = root.pendingDnsProvider
|
||
root.pendingDnsProvider = ""
|
||
}
|
||
}
|
||
}
|
||
|
||
// Poll details while the panel is open so the IP/route header catches up
|
||
// as soon as NetworkManager finishes activating a connection.
|
||
Timer {
|
||
id: detailsPoll
|
||
interval: 1500
|
||
repeat: true
|
||
running: root.opened
|
||
onTriggered: if (!detailsProc.running) detailsProc.running = true
|
||
}
|
||
|
||
Timer {
|
||
id: connectionPhraseTimer
|
||
interval: 2800
|
||
running: root.opened && (root.info.type === "ethernet" || (root.info.type === "wifi" && root.canDisconnect))
|
||
repeat: true
|
||
onTriggered: connectionPhraseSwap.restart()
|
||
}
|
||
|
||
SequentialAnimation {
|
||
id: connectionPhraseSwap
|
||
PropertyAnimation {
|
||
target: heroMeta; property: "opacity"
|
||
to: 0.0; duration: 180; easing.type: Easing.OutQuad
|
||
}
|
||
ScriptAction {
|
||
script: root.connectionPhraseIndex = (root.connectionPhraseIndex + 1) % root.connectionPhrases.length
|
||
}
|
||
PropertyAnimation {
|
||
target: heroMeta; property: "opacity"
|
||
to: 1.0; duration: 260; easing.type: Easing.InQuad
|
||
}
|
||
}
|
||
|
||
Connections {
|
||
target: root
|
||
function onInfoChanged() {
|
||
if (!(root.info.type === "ethernet" || (root.info.type === "wifi" && root.canDisconnect))) {
|
||
connectionPhraseSwap.stop()
|
||
heroMeta.opacity = 1.0
|
||
}
|
||
}
|
||
}
|
||
|
||
Timer {
|
||
id: actionTimeout
|
||
interval: 15000
|
||
repeat: false
|
||
onTriggered: {
|
||
if (!root.actionKind) return
|
||
var reason
|
||
if (root.actionKind === "connect") reason = "Timed out connecting"
|
||
else if (root.actionKind === "disconnect") reason = "Timed out disconnecting"
|
||
else reason = "Timed out forgetting"
|
||
root.failureSsid = root.actionSsid
|
||
root.failureReason = reason
|
||
root.actionSsid = ""
|
||
root.actionKind = ""
|
||
root.refresh()
|
||
}
|
||
}
|
||
|
||
BarIconButton {
|
||
id: button
|
||
anchors.fill: parent
|
||
bar: root.bar
|
||
text: root.icon
|
||
|
||
onPressed: function(b) {
|
||
if (root.opened) root.close()
|
||
else { root.open(); root.refresh() }
|
||
}
|
||
}
|
||
|
||
// Keyboard-driven popup anchored to the bar widget icon. The shared
|
||
// KeyboardPanel handles the layer-shell PanelWindow scaffolding
|
||
// (Exclusive focus on map, screen binding, anchored-to-icon positioning,
|
||
// outside-click via an overlay MouseArea + Region mask that lets the bar
|
||
// remain clickable, fade animation, popout coordination). What stays
|
||
// here is the wifi-specific UI inside.
|
||
KeyboardPanel {
|
||
id: panel
|
||
anchorItem: button
|
||
owner: root
|
||
bar: root.bar
|
||
open: root.opened
|
||
focusTarget: keyCatcher
|
||
contentWidth: panel.fittedContentWidth(Style.space(380))
|
||
contentHeight: panel.fittedContentHeight(column.implicitHeight)
|
||
|
||
// Catches all unhandled keys for keyboard navigation. AfterItem priority
|
||
// lets the passphrase TextField (a child via focus chain) get its keys
|
||
// first; only events the focused subtree ignores bubble back here.
|
||
PanelKeyCatcher {
|
||
id: keyCatcher
|
||
anchors.fill: parent
|
||
// Freeze the cursor model while the inline password prompt is open;
|
||
// the TextField inside owns input until Esc/Enter/Cancel.
|
||
blocked: root.passwordSsid !== ""
|
||
|
||
onMoveRequested: function(dx, dy) {
|
||
if (!root.cursorActive) {
|
||
root.cursorActive = true
|
||
if (dy >= 0) return
|
||
}
|
||
if (dy !== 0) {
|
||
if (root.focusSection === "header") {
|
||
if (dy > 0) root.focusSection = "dns"
|
||
} else if (root.focusSection === "dns") {
|
||
// k from DNS moves up into the disconnect button when there is
|
||
// one; otherwise stays put. j drops into the wifi list if there's
|
||
// anywhere to land.
|
||
if (dy < 0) {
|
||
if (root.headerHasDisconnect) {
|
||
root.focusSection = "header"
|
||
root.headerIndex = 0
|
||
}
|
||
} else if (root.wifiNetworks.length > 0) {
|
||
root.focusSection = "wifi"
|
||
if (root.selectedIndex < 0) root.selectedIndex = 0
|
||
}
|
||
} else { // wifi
|
||
// k from the top row escapes back up into the DNS row rather
|
||
// than wrapping around to the bottom of the list.
|
||
if (dy < 0 && root.selectedIndex <= 0) {
|
||
root.focusSection = "dns"
|
||
root.wifiActionFocused = false
|
||
}
|
||
else root.selectByDelta(dy)
|
||
}
|
||
}
|
||
if (dx !== 0) {
|
||
if (root.focusSection === "header") root.selectHeaderByDelta(dx)
|
||
else if (root.focusSection === "dns") root.selectDnsByDelta(dx)
|
||
else if (root.focusSection === "wifi") root.selectWifiActionByDelta(dx)
|
||
}
|
||
}
|
||
onActivateRequested: {
|
||
if (root.cursorActive) {
|
||
if (root.focusSection === "header") root.activateHeader()
|
||
else if (root.focusSection === "dns") root.activateDns()
|
||
else root.activateSelected()
|
||
}
|
||
}
|
||
onCloseRequested: root.close()
|
||
onTabRequested: function(direction) { root.switchPanel(direction) }
|
||
onTextKey: function(t) {
|
||
if (t === "r" || t === "R") root.refresh()
|
||
}
|
||
|
||
Column {
|
||
id: column
|
||
anchors.left: parent.left
|
||
anchors.right: parent.right
|
||
anchors.top: parent.top
|
||
spacing: Style.space(12)
|
||
|
||
// ---------- Hero: network icon · SSID + state · actions ----------
|
||
Item {
|
||
width: parent.width
|
||
implicitHeight: Math.max(heroIcon.implicitHeight, heroLabels.implicitHeight)
|
||
|
||
Text {
|
||
id: heroIcon
|
||
text: root.icon
|
||
color: root.bar.foreground
|
||
font.family: root.bar.fontFamily
|
||
font.pixelSize: Style.font.display
|
||
opacity: root.networkManagerAvailable ? 1.0 : 0.5
|
||
anchors.left: parent.left
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
|
||
MouseArea {
|
||
id: heroIconMouse
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
enabled: root.networkManagerAvailable
|
||
onClicked: {
|
||
Networking.wifiEnabled = !Networking.wifiEnabled
|
||
Qt.callLater(function() { root.refresh(true) })
|
||
}
|
||
}
|
||
|
||
PanelToolTip {
|
||
visible: heroIconMouse.containsMouse
|
||
text: root.info.type === "ethernet" ? "Toggle network" : "Toggle Wi-Fi"
|
||
fontFamily: root.bar.fontFamily
|
||
}
|
||
}
|
||
|
||
Column {
|
||
id: heroLabels
|
||
anchors.left: heroIcon.right
|
||
anchors.leftMargin: Style.space(14)
|
||
anchors.right: parent.right
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
spacing: Style.space(2)
|
||
|
||
Row {
|
||
id: heroSsid
|
||
width: parent.width
|
||
|
||
readonly property string title: {
|
||
if (root.info.type === "wifi") return root.info.ssid || "Wi-Fi"
|
||
if (root.info.type === "ethernet") return "Ethernet"
|
||
return root.info.iface || (root.kind === "disconnected" ? "Disconnected" : "No connection")
|
||
}
|
||
readonly property string detail: root.headerDetail()
|
||
|
||
Text {
|
||
text: heroSsid.title
|
||
width: Math.min(implicitWidth, Math.max(0, parent.width - (heroDetailPill.visible ? heroDetailPill.implicitWidth + Style.space(8) : 0)))
|
||
color: root.bar.foreground
|
||
font.family: root.bar.fontFamily
|
||
font.pixelSize: Style.font.title
|
||
font.bold: true
|
||
elide: Text.ElideRight
|
||
}
|
||
|
||
Item { width: Math.max(0, parent.width - parent.children[0].width - heroDetailPill.implicitWidth); height: 1 }
|
||
|
||
BorderSurface {
|
||
id: heroDetailPill
|
||
visible: heroSsid.detail !== ""
|
||
implicitWidth: heroDetail.implicitWidth + Style.space(10)
|
||
implicitHeight: heroDetail.implicitHeight + Style.space(4)
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
color: "transparent"
|
||
borderSpec: Border.controlSpec("normal", root.bar.foreground, Color.accent)
|
||
radius: Style.cornerRadius
|
||
|
||
Text {
|
||
id: heroDetail
|
||
anchors.centerIn: parent
|
||
text: heroSsid.detail
|
||
color: Qt.darker(root.bar.foreground, 1.4)
|
||
font.family: root.bar.fontFamily
|
||
font.pixelSize: Style.font.body
|
||
font.bold: true
|
||
}
|
||
}
|
||
}
|
||
|
||
Text {
|
||
id: heroMeta
|
||
width: parent.width
|
||
text: {
|
||
if (root.info.type === "wifi") {
|
||
if (root.canDisconnect) return root.connectionPhrase.toUpperCase()
|
||
if (root.kind === "disconnected") return "NOT CONNECTED"
|
||
return ""
|
||
}
|
||
if (root.info.type === "ethernet") return root.connectionPhrase.toUpperCase()
|
||
if (root.kind === "disconnected") return "NOT CONNECTED"
|
||
return ""
|
||
}
|
||
visible: text !== ""
|
||
color: Qt.darker(root.bar.foreground, 1.4)
|
||
font.family: root.bar.fontFamily
|
||
font.pixelSize: Style.font.caption
|
||
font.bold: true
|
||
font.letterSpacing: 1.2
|
||
elide: Text.ElideRight
|
||
}
|
||
}
|
||
|
||
}
|
||
|
||
// Connection details: transfer metrics first, then IP/Gateway.
|
||
Column {
|
||
visible: !!root.info.iface
|
||
width: parent.width
|
||
spacing: Style.spacing.labelGap
|
||
|
||
GridLayout {
|
||
width: parent.width
|
||
columns: 4
|
||
columnSpacing: Style.space(20)
|
||
rowSpacing: Style.spacing.labelGap
|
||
|
||
InfoLabel { visible: root.hasInternetPing; text: "Ping" }
|
||
DetailValue {
|
||
visible: root.hasInternetPing
|
||
text: root.formatPingLatency(root.internetPingLatency)
|
||
color: root.internetPingPacketLoss > 0 ? root.bar.urgent : root.bar.foreground
|
||
}
|
||
InfoLabel { visible: root.hasInternetPing; text: "Packet Loss" }
|
||
DetailValue {
|
||
visible: root.hasInternetPing
|
||
text: root.formatPacketLoss(root.internetPingPacketLoss)
|
||
color: root.internetPingPacketLoss > 0 ? root.bar.urgent : root.bar.foreground
|
||
}
|
||
|
||
InfoLabel { visible: root.info.rx_bytes !== undefined; text: "Receiving" }
|
||
DetailValue { visible: root.info.rx_bytes !== undefined; text: root.formatRate(root.downloadRate) }
|
||
InfoLabel { visible: root.info.rx_bytes !== undefined; text: "Sending" }
|
||
DetailValue { visible: root.info.rx_bytes !== undefined; text: root.formatRate(root.uploadRate) }
|
||
|
||
InfoLabel { visible: root.info.rx_bytes !== undefined; text: "Downloaded" }
|
||
DetailValue { visible: root.info.rx_bytes !== undefined; text: root.formatBytes(parseFloat(root.info.rx_bytes || "0")) }
|
||
InfoLabel { visible: root.info.rx_bytes !== undefined; text: "Uploaded" }
|
||
DetailValue { visible: root.info.rx_bytes !== undefined; text: root.formatBytes(parseFloat(root.info.tx_bytes || "0")) }
|
||
|
||
InfoLabel {
|
||
visible: !!root.info.ip || !!root.info.gateway
|
||
text: root.info.ip ? "IP Address" : ""
|
||
}
|
||
DetailValue {
|
||
visible: !!root.info.ip || !!root.info.gateway
|
||
text: root.info.ip || ""
|
||
copyable: !!root.info.ip
|
||
tooltipText: "Copy IP"
|
||
}
|
||
InfoLabel {
|
||
visible: !!root.info.ip || !!root.info.gateway
|
||
text: root.info.gateway ? "Gateway" : ""
|
||
}
|
||
DetailValue {
|
||
visible: !!root.info.ip || !!root.info.gateway
|
||
text: root.info.gateway || ""
|
||
copyable: !!root.info.gateway
|
||
tooltipText: "Copy gateway"
|
||
}
|
||
}
|
||
}
|
||
|
||
PanelSeparator {
|
||
visible: !!root.info.iface
|
||
foreground: root.bar.foreground
|
||
}
|
||
|
||
Column {
|
||
visible: !!root.info.iface
|
||
width: parent.width
|
||
spacing: Style.space(12)
|
||
|
||
Column {
|
||
width: parent.width
|
||
spacing: Style.space(8)
|
||
|
||
Item {
|
||
width: parent.width
|
||
implicitHeight: Math.max(speedTestHeader.implicitHeight, speedRunButton.implicitHeight)
|
||
|
||
PanelSectionHeader {
|
||
id: speedTestHeader
|
||
text: "SPEED TEST"
|
||
foreground: root.bar.foreground
|
||
fontFamily: root.bar.fontFamily
|
||
anchors.left: parent.left
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
}
|
||
|
||
Button {
|
||
id: speedRunButton
|
||
text: root.speedTestRunning ? "Running..." : "Run"
|
||
enabled: !root.speedTestRunning
|
||
foreground: root.bar.foreground
|
||
fontFamily: root.bar.fontFamily
|
||
fontSize: Style.font.bodySmall
|
||
horizontalPadding: Style.spacing.controlPaddingX
|
||
verticalPadding: Style.spacing.controlPaddingY
|
||
bordered: true
|
||
anchors.right: parent.right
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
onClicked: root.runSpeedTest()
|
||
}
|
||
}
|
||
|
||
Row {
|
||
id: speedTestValues
|
||
visible: root.speedTestHasRun
|
||
width: parent.width
|
||
spacing: Style.space(20)
|
||
|
||
readonly property real cellWidth: Math.max(0, (width - spacing * 3) / 4)
|
||
|
||
InfoLabel { width: speedTestValues.cellWidth; text: "Download" }
|
||
DetailValue { width: speedTestValues.cellWidth; text: root.formatSpeedMbps(root.speedTestDownloadMbps) }
|
||
InfoLabel { width: speedTestValues.cellWidth; text: "Upload" }
|
||
DetailValue { width: speedTestValues.cellWidth; text: root.formatSpeedMbps(root.speedTestUploadMbps) }
|
||
}
|
||
|
||
InfoValue {
|
||
visible: root.speedTestError !== ""
|
||
text: root.speedTestError
|
||
color: root.bar.urgent
|
||
width: parent.width
|
||
elide: Text.ElideRight
|
||
}
|
||
}
|
||
}
|
||
|
||
// DNS provider selection.
|
||
PanelSeparator {
|
||
foreground: root.bar.foreground
|
||
}
|
||
|
||
Column {
|
||
width: parent.width
|
||
spacing: Style.space(10)
|
||
|
||
PanelSectionHeader {
|
||
text: "DNS PROVIDER"
|
||
foreground: root.bar.foreground
|
||
fontFamily: root.bar.fontFamily
|
||
}
|
||
|
||
Row {
|
||
id: dnsRow
|
||
width: parent.width
|
||
spacing: Style.space(6)
|
||
|
||
readonly property int count: 4
|
||
readonly property real cellWidth: (width - spacing * (count - 1)) / count
|
||
|
||
DnsProviderPill {
|
||
provider: "DHCP"
|
||
index: 0
|
||
tooltipText: "Use DNS from DHCP"
|
||
width: dnsRow.cellWidth
|
||
onClicked: root.setDns(provider)
|
||
}
|
||
|
||
DnsProviderPill {
|
||
provider: "Cloudflare"
|
||
index: 1
|
||
tooltipText: "Set DNS to Cloudflare"
|
||
width: dnsRow.cellWidth
|
||
onClicked: root.setDns(provider)
|
||
}
|
||
|
||
DnsProviderPill {
|
||
provider: "Google"
|
||
index: 2
|
||
tooltipText: "Set DNS to Google"
|
||
width: dnsRow.cellWidth
|
||
onClicked: root.setDns(provider)
|
||
}
|
||
|
||
DnsProviderPill {
|
||
provider: "Custom"
|
||
index: 3
|
||
tooltipText: "Set custom DNS servers"
|
||
width: dnsRow.cellWidth
|
||
onClicked: root.setDns(provider)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Wi-Fi networks (only if a Wi-Fi station is available).
|
||
PanelSeparator {
|
||
visible: root.wifiStationAvailable
|
||
foreground: root.bar.foreground
|
||
}
|
||
|
||
PanelSectionHeader {
|
||
visible: root.wifiStationAvailable && root.scanning
|
||
text: "SCANNING WI-FI…"
|
||
foreground: root.bar.foreground
|
||
fontFamily: root.bar.fontFamily
|
||
}
|
||
|
||
// Scrollable network list — cap the height so a busy neighbourhood
|
||
// doesn't push the popup off-screen. ListView (vs Repeater+Column)
|
||
// gives us positionViewAtIndex for free, which is what keeps the
|
||
// keyboard-selected row scrolled into view as j/k walk past the
|
||
// visible window.
|
||
ListView {
|
||
id: networkList
|
||
visible: root.wifiStationAvailable
|
||
width: parent.width
|
||
height: Math.min(contentHeight, Style.space(240))
|
||
spacing: Style.space(4)
|
||
clip: true
|
||
boundsBehavior: Flickable.StopAtBounds
|
||
interactive: contentHeight > height
|
||
|
||
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
|
||
|
||
model: root.wifiStationAvailable ? root.wifiNetworks : []
|
||
currentIndex: root.selectedIndex
|
||
onCurrentIndexChanged: if (currentIndex >= 0) positionViewAtIndex(currentIndex, ListView.Contain)
|
||
|
||
// Wrapper takes the required props from ListView's delegate context
|
||
// (which doesn't bind into nested `component` declarations like
|
||
// NetworkRow) and passes them down explicitly.
|
||
delegate: Item {
|
||
required property var modelData
|
||
required property int index
|
||
readonly property string sectionTitle: root.wifiSectionTitle(index)
|
||
width: ListView.view.width
|
||
height: delegateColumn.implicitHeight
|
||
|
||
Column {
|
||
id: delegateColumn
|
||
width: parent.width
|
||
spacing: Style.space(4)
|
||
|
||
PanelSectionHeader {
|
||
visible: sectionTitle !== ""
|
||
text: sectionTitle
|
||
foreground: root.bar.foreground
|
||
fontFamily: root.bar.fontFamily
|
||
height: visible ? implicitHeight : 0
|
||
}
|
||
|
||
NetworkRow {
|
||
id: row
|
||
width: parent.width
|
||
net: modelData
|
||
index: parent.parent.index
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// One DNS provider pill. The cursor + current visuals come entirely from
|
||
// CursorSurface; this component just binds them to the panel's cursor
|
||
// state and renders the label/tooltip/click target.
|
||
component DnsProviderPill: Button {
|
||
id: pill
|
||
required property string provider
|
||
required property int index
|
||
|
||
text: provider
|
||
fontSize: Style.font.bodySmall
|
||
foreground: root.bar.foreground
|
||
fontFamily: root.bar.fontFamily
|
||
horizontalPadding: Style.spacing.controlPaddingX
|
||
verticalPadding: Style.spacing.controlPaddingY + Style.space(2)
|
||
bordered: true
|
||
|
||
// Map the panel's domain semantics onto Button's structural props:
|
||
// `current DNS` is the pill's `active` fill; the keyboard cursor lights
|
||
// up `hasCursor`.
|
||
active: root.dnsProvider === provider
|
||
hasCursor: root.cursorActive && root.focusSection === "dns" && root.dnsIndex === index
|
||
|
||
onHovered: function(isHovered) {
|
||
if (!isHovered) return
|
||
root.cursorActive = true
|
||
root.focusSection = "dns"
|
||
root.dnsIndex = pill.index
|
||
}
|
||
}
|
||
|
||
// A single Wi-Fi network entry. Collapses to a one-line pill normally;
|
||
// expands inline to a passphrase prompt when the user picks a protected
|
||
// network we don't have credentials for. Clicking a connected row
|
||
// disconnects.
|
||
component NetworkRow: CursorSurface {
|
||
id: row
|
||
required property var net
|
||
required property int index
|
||
|
||
readonly property bool isConnected: net && net.connected
|
||
readonly property bool isKnown: !!(net && net.known)
|
||
readonly property bool isProtected: net ? root.isProtected(net.security) : false
|
||
readonly property bool canForgetFromLock: isKnown && isProtected && !isConnected
|
||
readonly property bool isSelected: root.focusSection === "wifi" && root.selectedIndex === index
|
||
readonly property bool forgetFocused: isSelected && root.wifiActionFocused && canForgetFromLock
|
||
readonly property bool forgetVisible: canForgetFromLock && (forgetFocused || rightMouse.containsMouse)
|
||
|
||
hasCursor: root.cursorActive && isSelected && !root.wifiActionFocused
|
||
current: isConnected
|
||
foreground: root.bar.foreground
|
||
fill: root.hoverFill
|
||
currentFill: root.selectedFill
|
||
// Gate on the matching *Kind/*Reason being non-empty so a hidden-SSID
|
||
// row (ssid == "") doesn't match the "" defaults of actionSsid etc.
|
||
readonly property bool isBusy: root.actionKind !== "" && root.actionSsid === (net ? net.ssid : "")
|
||
readonly property bool isFailed: root.failureReason !== "" && root.failureSsid === (net ? net.ssid : "")
|
||
readonly property bool isPasswordOpen: root.passwordSsid !== "" && root.passwordSsid === (net ? net.ssid : "")
|
||
|
||
Connections {
|
||
target: row.net ? row.net.network : null
|
||
function onConnectionFailed(reason) {
|
||
root.failNetworkAction(row.net.network, reason)
|
||
if (reason === ConnectionFailReason.NoSecrets) root.openPasswordPrompt(row.net.ssid)
|
||
}
|
||
function onConnectedChanged() {
|
||
if (row.net) root.checkActionCompletion(row.net.network)
|
||
}
|
||
function onKnownChanged() {
|
||
if (row.net) root.checkActionCompletion(row.net.network)
|
||
}
|
||
function onStateChangingChanged() {
|
||
if (row.net) root.checkActionCompletion(row.net.network)
|
||
}
|
||
}
|
||
|
||
readonly property string statusText: {
|
||
if (!net) return ""
|
||
if (isPasswordOpen) return ""
|
||
if (isBusy && root.actionKind === "connect") return "Connecting…"
|
||
if (isBusy && root.actionKind === "disconnect") return "Disconnecting…"
|
||
if (isBusy && root.actionKind === "forget") return "Forgetting…"
|
||
if (isFailed) return root.failureReason || "Failed"
|
||
if (isConnected) return "Connected"
|
||
return ""
|
||
}
|
||
|
||
readonly property color statusColor: {
|
||
if (isFailed) return root.bar.urgent
|
||
if (isBusy) return root.bar.foreground
|
||
if (isConnected) return root.bar.foreground
|
||
return Qt.darker(root.bar.foreground, 1.5)
|
||
}
|
||
|
||
implicitHeight: rowBody.implicitHeight + (isPasswordOpen ? passwordPanel.implicitHeight + Style.spacing.md : 0)
|
||
|
||
MouseArea {
|
||
id: rowMouse
|
||
anchors.left: parent.left
|
||
anchors.right: parent.right
|
||
anchors.top: parent.top
|
||
height: rowBody.implicitHeight
|
||
hoverEnabled: true
|
||
acceptedButtons: Qt.LeftButton
|
||
cursorShape: Qt.PointingHandCursor
|
||
enabled: !root.busy
|
||
|
||
// Move the cursor here when the mouse enters; mouse leaving doesn't
|
||
// clear it (so the cursor stays where the mouse last was and
|
||
// subsequent j/k pick up from this row).
|
||
onContainsMouseChanged: if (containsMouse) { root.cursorActive = true; root.focusSection = "wifi"; root.selectedIndex = row.index; root.wifiActionFocused = false }
|
||
|
||
onClicked: {
|
||
if (!row.net) return
|
||
// Resync cursor in case keyboard nav moved it away while the mouse
|
||
// stayed parked on this row — the click target is unambiguously here.
|
||
root.cursorActive = true
|
||
root.focusSection = "wifi"
|
||
root.selectedIndex = row.index
|
||
root.wifiActionFocused = false
|
||
if (row.isConnected) {
|
||
root.disconnect(row.net.network)
|
||
return
|
||
}
|
||
if (row.isProtected && !row.isKnown) {
|
||
root.openPasswordPrompt(row.net.ssid)
|
||
return
|
||
}
|
||
root.connectKnown(row.net.ssid)
|
||
}
|
||
}
|
||
|
||
Item {
|
||
id: rowBody
|
||
anchors.left: parent.left
|
||
anchors.right: parent.right
|
||
anchors.top: parent.top
|
||
anchors.leftMargin: Style.space(10)
|
||
anchors.rightMargin: Style.space(10)
|
||
implicitHeight: Math.max(networkIcon.implicitHeight, networkInfo.implicitHeight, rightAction.implicitHeight) + Style.spacing.rowPaddingX
|
||
|
||
Text {
|
||
id: networkIcon
|
||
text: row.net ? root.wifiIconFor(row.net.signal) : ""
|
||
color: row.statusColor
|
||
font.family: root.bar.fontFamily
|
||
font.pixelSize: Style.font.title
|
||
anchors.left: parent.left
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
}
|
||
|
||
// Shows a lock glyph for protected networks. Known disconnected
|
||
// networks reveal the forget action when hovering that right edge.
|
||
Item {
|
||
id: rightAction
|
||
visible: row.isProtected
|
||
width: Style.space(22)
|
||
implicitHeight: lockIndicator.implicitHeight
|
||
anchors.right: parent.right
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
|
||
Text {
|
||
id: lockIndicator
|
||
width: parent.width
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
horizontalAlignment: Text.AlignHCenter
|
||
text: row.forgetVisible ? "" : ""
|
||
color: row.forgetVisible ? root.bar.urgent : Qt.darker(root.bar.foreground, 1.4)
|
||
font.family: root.bar.fontFamily
|
||
font.pixelSize: Style.font.subtitle
|
||
}
|
||
|
||
BorderSurface {
|
||
anchors.fill: parent
|
||
visible: row.forgetFocused
|
||
color: Style.hoverFillFor(root.bar.urgent, root.bar.urgent)
|
||
borderSpec: Border.controlSpec("hover-cursor", root.bar.urgent, root.bar.urgent)
|
||
radius: Style.cornerRadius
|
||
z: -1
|
||
}
|
||
|
||
MouseArea {
|
||
id: rightMouse
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
acceptedButtons: Qt.LeftButton
|
||
enabled: row.canForgetFromLock && !root.busy
|
||
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||
onContainsMouseChanged: if (containsMouse) { root.cursorActive = true; root.focusSection = "wifi"; root.selectedIndex = row.index; root.wifiActionFocused = true }
|
||
onClicked: if (row.net) root.forget(row.net)
|
||
}
|
||
|
||
PanelToolTip {
|
||
visible: rightMouse.containsMouse || row.forgetFocused
|
||
text: "Forget network"
|
||
fontFamily: root.bar.fontFamily
|
||
}
|
||
}
|
||
|
||
Column {
|
||
id: networkInfo
|
||
spacing: Style.space(1)
|
||
anchors.left: networkIcon.right
|
||
anchors.leftMargin: Style.space(10)
|
||
anchors.right: rightAction.visible ? rightAction.left
|
||
: parent.right
|
||
anchors.rightMargin: rightAction.visible ? Style.space(8) : 0
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
|
||
Text {
|
||
text: row.net ? (row.net.ssid || "Hidden") : ""
|
||
color: root.bar.foreground
|
||
font.family: root.bar.fontFamily
|
||
font.pixelSize: Style.font.body
|
||
elide: Text.ElideRight
|
||
width: parent.width
|
||
}
|
||
Text {
|
||
// Signal strength is conveyed by the wifi-bars icon and the
|
||
// right-edge glyph/buttons carry protection or forget affordances,
|
||
// so the second line only carries action status (Connecting…,
|
||
// Connected, Failed, etc.). Collapses to zero height when empty
|
||
// so rows without status keep a tight one-line look.
|
||
text: row.statusText
|
||
visible: row.statusText !== ""
|
||
height: visible ? implicitHeight : 0
|
||
color: row.statusColor
|
||
font.family: root.bar.fontFamily
|
||
font.pixelSize: Style.font.caption
|
||
elide: Text.ElideRight
|
||
width: parent.width
|
||
}
|
||
}
|
||
}
|
||
|
||
Timer {
|
||
id: failureTimer
|
||
interval: 2000
|
||
running: row.isFailed && row.isPasswordOpen
|
||
onTriggered: {
|
||
root.failureSsid = ""
|
||
root.failureReason = ""
|
||
pwField.forceActiveFocus()
|
||
}
|
||
}
|
||
|
||
// Inline passphrase prompt — only shown when we hit a protected network
|
||
// we don't have saved credentials for. Submitting (Enter or the check
|
||
// button) fires connect; Esc cancels back to the row.
|
||
Item {
|
||
id: passwordPanel
|
||
visible: row.isPasswordOpen
|
||
anchors.left: parent.left
|
||
anchors.right: parent.right
|
||
anchors.top: rowMouse.bottom
|
||
anchors.leftMargin: Style.space(10)
|
||
anchors.rightMargin: Style.space(10)
|
||
anchors.topMargin: Style.space(4)
|
||
implicitHeight: pwField.implicitHeight + Style.spacing.rowGap
|
||
height: implicitHeight
|
||
|
||
TextField {
|
||
id: pwField
|
||
visible: !row.isBusy && !row.isFailed
|
||
anchors.left: parent.left
|
||
anchors.right: connectPwBtn.left
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
anchors.rightMargin: Style.space(6)
|
||
password: true
|
||
placeholderText: "Passphrase"
|
||
font.family: Style.font.family
|
||
font.pixelSize: Style.font.body
|
||
foreground: root.bar.foreground
|
||
horizontalPadding: Style.spacing.controlGap
|
||
verticalPadding: Style.spacing.controlPaddingY
|
||
enabled: !row.isBusy
|
||
text: row.isPasswordOpen ? root.passwordText : ""
|
||
|
||
onAccepted: {
|
||
if (!root.busy && row.net && text.length > 0) root.connectWithPassphrase(row.net.ssid, text)
|
||
}
|
||
onTextChanged: if (row.isPasswordOpen && text !== root.passwordText) root.passwordText = text
|
||
Keys.onEscapePressed: { root.passwordSsid = ""; root.passwordText = "" }
|
||
|
||
onVisibleChanged: if (visible) Qt.callLater(forceActiveFocus)
|
||
Component.onCompleted: if (visible) Qt.callLater(forceActiveFocus)
|
||
}
|
||
|
||
BorderSurface {
|
||
id: statusMsgWrapper
|
||
visible: row.isBusy || row.isFailed
|
||
anchors.left: parent.left
|
||
anchors.right: parent.right
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
height: Style.spacing.controlHeight
|
||
color: Style.normalFillFor(root.bar.foreground)
|
||
borderSpec: Border.controlSpec("normal", root.bar.foreground, Color.accent)
|
||
radius: Style.cornerRadius
|
||
|
||
Text {
|
||
anchors.fill: parent
|
||
horizontalAlignment: Text.AlignHCenter
|
||
verticalAlignment: Text.AlignVCenter
|
||
text: row.isFailed ? "Wrong password" : "Connecting..."
|
||
color: row.isFailed ? root.bar.urgent : root.bar.foreground
|
||
font.family: root.bar.fontFamily
|
||
font.pixelSize: Style.font.bodySmall
|
||
}
|
||
}
|
||
|
||
// 22×22 right-anchored to line up with lockIndicator above. Esc closes
|
||
// the prompt (handled by pwField.Keys.onEscapePressed)
|
||
// so there's no separate cancel button.
|
||
PanelActionButton {
|
||
id: connectPwBtn
|
||
visible: !row.isBusy && !row.isFailed
|
||
anchors.right: parent.right
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
enabled: row.net && pwField.text.length > 0
|
||
iconText: ""
|
||
tooltipText: "Connect"
|
||
foreground: root.bar.foreground
|
||
fontFamily: root.bar.fontFamily
|
||
onClicked: if (row.net) root.connectWithPassphrase(row.net.ssid, root.passwordText)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Poll the wifi/ethernet pill state every 3s. Local to this panel so
|
||
// Bar.qml does not need to mirror network state.
|
||
Process {
|
||
id: networkProc
|
||
command: ["omarchy-network-status"]
|
||
stdout: StdioCollector {
|
||
waitForEnd: true
|
||
onStreamFinished: root.updateNetwork(text)
|
||
}
|
||
}
|
||
|
||
Timer {
|
||
interval: 10000
|
||
running: true
|
||
repeat: true
|
||
triggeredOnStart: true
|
||
onTriggered: if (!networkProc.running) networkProc.running = true
|
||
}
|
||
|
||
component DetailValue: InfoValue {
|
||
property bool copyable: false
|
||
property string tooltipText: "Copy to clipboard"
|
||
|
||
Layout.fillWidth: true
|
||
horizontalAlignment: Text.AlignRight
|
||
|
||
MouseArea {
|
||
id: valueMouse
|
||
anchors.fill: parent
|
||
enabled: copyable && parent.text !== ""
|
||
hoverEnabled: enabled
|
||
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||
onClicked: root.copyToClipboard(parent.text)
|
||
}
|
||
|
||
PanelToolTip {
|
||
visible: valueMouse.enabled && valueMouse.containsMouse
|
||
text: tooltipText
|
||
fontFamily: root.bar.fontFamily
|
||
}
|
||
}
|
||
|
||
component InfoLabel: Text {
|
||
color: root.bar.foreground
|
||
opacity: 0.6
|
||
font.family: root.bar.fontFamily
|
||
font.pixelSize: Style.font.bodySmall
|
||
}
|
||
|
||
component InfoValue: Text {
|
||
color: root.bar.foreground
|
||
font.family: root.bar.fontFamily
|
||
font.pixelSize: Style.font.bodySmall
|
||
}
|
||
}
|