mirror of
https://github.com/arthur-pbty/arthur-os.git
synced 2026-08-01 20:28:16 +02:00
1362 lines
46 KiB
QML
1362 lines
46 KiB
QML
import QtQuick
|
||
import QtQuick.Controls
|
||
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 }
|
||
|
||
// 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 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: ""
|
||
|
||
// 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
|
||
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)
|
||
}
|
||
|
||
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 formatBytes(bytes) {
|
||
return Model.formatBytes(bytes)
|
||
}
|
||
|
||
function formatRate(bytesPerSec) {
|
||
return Model.formatRate(bytesPerSec)
|
||
}
|
||
|
||
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 dnsCommand(provider) {
|
||
var command = root.bar ? Util.shellQuote(root.bar.omarchyPath + "/bin/omarchy-dns") : "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 = Util.shellQuote(root.bar.omarchyPath + "/bin/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: [root.bar ? root.bar.omarchyPath + "/bin/omarchy-network-status" : "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)
|
||
}
|
||
}
|
||
|
||
// 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()
|
||
}
|
||
}
|
||
|
||
WidgetButton {
|
||
id: button
|
||
anchors.fill: parent
|
||
bar: root.bar
|
||
text: root.icon
|
||
fixedWidth: root.bar && root.bar.vertical ? -1 : Style.space(27)
|
||
fixedHeight: root.bar && root.bar.vertical ? Style.space(26) : -1
|
||
rightExtraMargin: 5.5
|
||
|
||
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
|
||
|
||
Row {
|
||
visible: root.info.rx_bytes !== undefined
|
||
width: parent.width
|
||
spacing: Style.space(20)
|
||
|
||
Column {
|
||
width: (parent.width - parent.spacing) / 2
|
||
spacing: Style.spacing.labelGap
|
||
InfoPair { label: "Receiving"; value: root.formatRate(root.downloadRate) }
|
||
InfoPair { label: "Downloaded"; value: root.formatBytes(parseFloat(root.info.rx_bytes || "0")) }
|
||
}
|
||
|
||
Column {
|
||
width: (parent.width - parent.spacing) / 2
|
||
spacing: Style.spacing.labelGap
|
||
InfoPair { label: "Sending"; value: root.formatRate(root.uploadRate) }
|
||
InfoPair { label: "Uploaded"; value: root.formatBytes(parseFloat(root.info.tx_bytes || "0")) }
|
||
}
|
||
}
|
||
|
||
Row {
|
||
width: parent.width
|
||
spacing: Style.space(20)
|
||
Column {
|
||
width: (parent.width - parent.spacing) / 2
|
||
InfoPair {
|
||
visible: !!root.info.ip
|
||
label: "IP Address"
|
||
value: root.info.ip || ""
|
||
copyable: true
|
||
tooltipText: "Copy IP"
|
||
}
|
||
}
|
||
Column {
|
||
width: (parent.width - parent.spacing) / 2
|
||
InfoPair {
|
||
visible: !!root.info.gateway
|
||
label: "Gateway"
|
||
value: root.info.gateway || ""
|
||
copyable: true
|
||
tooltipText: "Copy gateway"
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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: [root.bar ? root.bar.omarchyPath + "/bin/omarchy-network-status" : "omarchy-network-status"]
|
||
stdout: StdioCollector {
|
||
waitForEnd: true
|
||
onStreamFinished: root.updateNetwork(text)
|
||
}
|
||
}
|
||
|
||
Timer {
|
||
interval: 3000
|
||
running: true
|
||
repeat: true
|
||
triggeredOnStart: true
|
||
onTriggered: if (!networkProc.running) networkProc.running = true
|
||
}
|
||
|
||
component InfoPair: Row {
|
||
property string label: ""
|
||
property string value: ""
|
||
property bool copyable: false
|
||
property string tooltipText: "Copy to clipboard"
|
||
|
||
width: parent.width
|
||
spacing: Style.space(8)
|
||
|
||
InfoLabel { text: label }
|
||
Item { width: Math.max(0, parent.width - parent.children[0].implicitWidth - valueText.implicitWidth - parent.spacing * 2); height: 1 }
|
||
InfoValue {
|
||
id: valueText
|
||
text: value
|
||
|
||
MouseArea {
|
||
id: valueMouse
|
||
anchors.fill: parent
|
||
enabled: copyable && valueText.text !== ""
|
||
hoverEnabled: enabled
|
||
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||
onClicked: root.copyToClipboard(valueText.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
|
||
}
|
||
}
|