Add a Wi-Fi band toggle to the network panel (#6437)
Pins the active connection to 2.4/5/6GHz, or leaves the choice automatic.
The section only appears on Wi-Fi when the network answers on more than one
band, or while a pin is in force so it stays clearable.
bin/omarchy-network-band reports and sets the band. It sets NetworkManager's
802-11-wireless.band rather than pinning a BSSID: NM 1.44+ accepts a third
band value so 5 and 6GHz can be pinned apart, and a band survives an AP
rotating its BSSIDs while leaving roaming between APs intact. It refuses a
band the network does not answer on, and restores the previous setting if
reassociation fails rather than leaving the machine offline.
Under Automatic the pills collapse and the header reads the live band
("WI-FI BAND: 2.4GHZ"); pinning reveals them and the header goes plain. The
height is animated, and the section stays mounted through the reconnect a
band change causes -- otherwise `kind` briefly stops being "wifi" and the
whole segment would tear down and rebuild.
Two fixes to the same panel fall out of this:
- Every stat row stays mounted and reads "--" until it has data, instead of
appearing a beat after the panel opens and shoving the rows below down.
- The speed test's Run button is scaled to match the band header's controls
and joins the keyboard cursor chain.
ToggleSwitch gains a settable trackHeight so a compact placement can render a
genuinely small switch instead of scaling one down onto fractional pixels.
Defaults are unchanged.
Claude-Session: https://claude.ai/code/session_01XcKqYe1n5bnRBuAZwikwsr
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
e47d895ffe
commit
14f1bb6c88
Executable
+192
@@ -0,0 +1,192 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Show or pin the Wi-Fi band for the active connection
|
||||
# omarchy:group=network
|
||||
# omarchy:args=[auto|2.4|5|6]
|
||||
# omarchy:examples=omarchy network band | omarchy network band 5 | omarchy network band auto
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# NetworkManager 1.44+ accepts a third band value, so 5GHz and 6GHz can be
|
||||
# pinned apart. Pinning the band rather than a BSSID keeps working when the AP
|
||||
# rotates BSSIDs, and leaves roaming between APs intact.
|
||||
nm_band_for() {
|
||||
case "$1" in
|
||||
2.4) echo "bg" ;;
|
||||
5) echo "a" ;;
|
||||
6) echo "6GHz" ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
band_from_nm() {
|
||||
case "$1" in
|
||||
bg) echo "2.4" ;;
|
||||
a) echo "5" ;;
|
||||
6GHz) echo "6" ;;
|
||||
*) echo "auto" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Accepts either nmcli's "2412 MHz" or iw's "5745.0": keep the leading digits
|
||||
# and ignore whatever unit or fraction follows. The boundaries mirror Model.js
|
||||
# formatHeaderFreq so the panel's label and this command cannot disagree.
|
||||
band_for_freq() {
|
||||
local mhz=${1%%[!0-9]*}
|
||||
[[ -n $mhz ]] || return 1
|
||||
|
||||
if ((mhz >= 2400 && mhz < 2500)); then
|
||||
echo "2.4"
|
||||
elif ((mhz >= 4900 && mhz < 5925)); then
|
||||
echo "5"
|
||||
elif ((mhz >= 5925 && mhz < 7125)); then
|
||||
echo "6"
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Every value read back from nmcli goes through here. LC_ALL=C because nmcli
|
||||
# translates state words such as "connected", which would silently stop matching
|
||||
# under a non-English session; `-e no` because `-g` otherwise escapes ':' and
|
||||
# '\' in values, and an escaped SSID would never match iw's raw one.
|
||||
nm_get() {
|
||||
LC_ALL=C nmcli -e no -g "$@" 2>/dev/null
|
||||
}
|
||||
|
||||
wifi_device() {
|
||||
nm_get DEVICE,TYPE,STATE device status |
|
||||
awk -F: '$2 == "wifi" && $3 == "connected" { print $1; exit }'
|
||||
}
|
||||
|
||||
wifi_profile() {
|
||||
nm_get GENERAL.CONNECTION device show "$1"
|
||||
}
|
||||
|
||||
selected_band() {
|
||||
band_from_nm "$(nm_get 802-11-wireless.band connection show "$1")"
|
||||
}
|
||||
|
||||
# Sets $ssid and $freq from one `iw dev <device> link`. iw prints the SSID as
|
||||
# the rest of its line, so an SSID containing ':' needs no special handling.
|
||||
read_link() {
|
||||
local link
|
||||
link=$(iw dev "$1" link 2>/dev/null)
|
||||
ssid=$(awk '/SSID:/ { sub(/.*SSID: /, ""); print; exit }' <<<"$link")
|
||||
freq=$(awk '/freq:/ { print $2; exit }' <<<"$link")
|
||||
}
|
||||
|
||||
# Every band the SSID is reachable on, low to high, always including the one
|
||||
# already in use -- a weak radio gets missed by plenty of scans, and the band we
|
||||
# are sitting on must never be absent from its own list of options. A band the
|
||||
# AP does not answer on is never offered: pinning to it would drop the
|
||||
# connection with nothing to reassociate to.
|
||||
#
|
||||
# --rescan no reads NetworkManager's cache, which the panel's own scanner keeps
|
||||
# warm; forcing a scan here would stall every poll. The kernel's own cache (iw
|
||||
# scan dump) is pruned far harder and would make bands flicker.
|
||||
available_bands() {
|
||||
local device=$1 ssid=$2 current=$3
|
||||
|
||||
{
|
||||
if [[ -n $current ]]; then echo "$current"; fi
|
||||
|
||||
# SSID is queried last so one containing ':' can be reassembled verbatim.
|
||||
# It reaches awk through the environment, not -v, which would expand
|
||||
# backslash escapes in an SSID that contains one.
|
||||
nm_get FREQ,SSID dev wifi list ifname "$device" --rescan no |
|
||||
want="$ssid" awk -F: '
|
||||
BEGIN { want = ENVIRON["want"] }
|
||||
{
|
||||
name = $2
|
||||
for (i = 3; i <= NF; i++) name = name ":" $i
|
||||
if (name == want) print $1
|
||||
}' |
|
||||
while read -r freq; do band_for_freq "$freq" || true; done
|
||||
} | sort -u -g | tr '\n' ' ' | sed 's/ $//'
|
||||
}
|
||||
|
||||
print_status() {
|
||||
local device profile band available
|
||||
|
||||
device=$(wifi_device)
|
||||
[[ -n $device ]] || return 0
|
||||
|
||||
read_link "$device"
|
||||
[[ -n $ssid ]] || return 0
|
||||
|
||||
band=$(band_for_freq "$freq" || true)
|
||||
available=$(available_bands "$device" "$ssid" "$band")
|
||||
profile=$(wifi_profile "$device")
|
||||
|
||||
printf 'band\t%s\n' "$band"
|
||||
printf 'available\t%s\n' "$available"
|
||||
if [[ -n $profile ]]; then printf 'selected\t%s\n' "$(selected_band "$profile")"; fi
|
||||
}
|
||||
|
||||
set_band() {
|
||||
local target=$1
|
||||
local device profile previous desired
|
||||
|
||||
device=$(wifi_device)
|
||||
if [[ -z $device ]]; then
|
||||
echo "Error: no connected Wi-Fi device." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
profile=$(wifi_profile "$device")
|
||||
if [[ -z $profile ]]; then
|
||||
echo "Error: no active Wi-Fi connection profile." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ $target == "auto" ]]; then
|
||||
desired=""
|
||||
else
|
||||
read_link "$device"
|
||||
if [[ " $(available_bands "$device" "$ssid" "$(band_for_freq "$freq" || true)") " != *" $target "* ]]; then
|
||||
echo "Error: ${target}GHz is not available on this network." >&2
|
||||
exit 1
|
||||
fi
|
||||
desired=$(nm_band_for "$target")
|
||||
fi
|
||||
|
||||
previous=$(nm_get 802-11-wireless.band connection show "$profile")
|
||||
[[ $previous == "$desired" ]] && exit 0
|
||||
|
||||
nmcli connection modify "$profile" 802-11-wireless.band "$desired" >/dev/null
|
||||
|
||||
# A band change only takes effect on reassociation. If the radio cannot come
|
||||
# back up on the requested band, put the previous setting back and reconnect
|
||||
# rather than leaving the machine stranded offline.
|
||||
if ! nmcli connection up "$profile" >/dev/null 2>&1; then
|
||||
nmcli connection modify "$profile" 802-11-wireless.band "$previous" >/dev/null
|
||||
nmcli connection up "$profile" >/dev/null 2>&1 || true
|
||||
echo "Error: could not connect on ${target}; reverted to previous band." >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
usage() {
|
||||
echo "Usage: omarchy-network-band [auto|2.4|5|6]" >&2
|
||||
}
|
||||
|
||||
if (($# == 0)); then
|
||||
print_status
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if (($# > 1)); then
|
||||
usage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "$1" in
|
||||
auto | 2.4 | 5 | 6)
|
||||
set_band "$1"
|
||||
;;
|
||||
*)
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -48,10 +48,17 @@ Item {
|
||||
|
||||
readonly property alias containsMouse: mouse.containsMouse
|
||||
readonly property bool hot: hasCursor || mouse.containsMouse
|
||||
readonly property int trackHeight: Math.max(22, Math.round(Style.spacing.controlHeight * 0.55))
|
||||
readonly property int trackWidth: Math.max(42, Math.round(trackHeight * 1.9))
|
||||
readonly property int knobSize: Math.max(16, Math.round(trackHeight * 0.72))
|
||||
readonly property int knobInset: Math.max(2, Math.round((trackHeight - knobSize) / 2))
|
||||
|
||||
// `trackHeight` is settable so a compact placement — a switch riding a panel
|
||||
// section header, say — can ask for a genuinely smaller control instead of
|
||||
// scaling a big one down, which lands the track and knob on fractional pixels
|
||||
// and blurs their edges. The derived sizes only carry floors low enough to
|
||||
// stay out of an override's way; at the default track height each one is
|
||||
// already above its floor, so nothing about the normal switch changes.
|
||||
property int trackHeight: Math.max(22, Math.round(Style.spacing.controlHeight * 0.55))
|
||||
property int trackWidth: Math.round(trackHeight * 1.9)
|
||||
property int knobSize: Math.max(6, Math.round(trackHeight * 0.72))
|
||||
property int knobInset: Math.max(1, Math.round((trackHeight - knobSize) / 2))
|
||||
|
||||
readonly property int _pad: cursorRing ? cursorPad : 0
|
||||
|
||||
|
||||
@@ -40,13 +40,58 @@ function formatHeaderFreq(mhz) {
|
||||
return ghz.toFixed(ghz % 1 === 0 ? 0 : 1) + "ghz"
|
||||
}
|
||||
|
||||
function headerDetail(info) {
|
||||
// `hideWifiBand` is set when the band toggle is on screen: the band is already
|
||||
// spelled out there, so repeating it in "York (5ghz)" is noise. A single-band
|
||||
// network hides the toggle, and then the header stays the only place it shows.
|
||||
function headerDetail(info, hideWifiBand) {
|
||||
var value = info || {}
|
||||
if (value.type === "ethernet") return formatHeaderSpeed(value.speed || "")
|
||||
if (value.type === "wifi") return formatHeaderFreq(value.freq || "")
|
||||
if (value.type === "wifi") return hideWifiBand ? "" : formatHeaderFreq(value.freq || "")
|
||||
return ""
|
||||
}
|
||||
|
||||
function bandLabel(band) {
|
||||
if (band === "auto") return "Auto"
|
||||
if (!band) return ""
|
||||
return band + "ghz"
|
||||
}
|
||||
|
||||
// Under Automatic the pills are hidden, so the header carries the live band
|
||||
// instead -- "WI-FI BAND: 2.4GHZ". Once a band is pinned the pills are on
|
||||
// screen and say it themselves, so the header drops back to a plain label.
|
||||
function bandSectionTitle(selected, current) {
|
||||
if (selected !== "auto") return "WI-FI BAND"
|
||||
|
||||
var label = bandLabel(current)
|
||||
if (label === "") return "WI-FI BAND"
|
||||
|
||||
return "WI-FI BAND: " + label.toUpperCase()
|
||||
}
|
||||
|
||||
function bandTooltip(band) {
|
||||
if (band === "auto") return "Let Wi-Fi pick the band"
|
||||
if (!band) return ""
|
||||
return "Stay on " + bandLabel(band)
|
||||
}
|
||||
|
||||
function parseBandStatus(raw) {
|
||||
var next = parseKeyValue(raw)
|
||||
var tokens = String(next.available || "").split(" ")
|
||||
var available = []
|
||||
|
||||
for (var i = 0; i < tokens.length; i++) {
|
||||
if (tokens[i] !== "") available.push(tokens[i])
|
||||
}
|
||||
|
||||
return {
|
||||
band: next.band || "",
|
||||
selected: next.selected || "auto",
|
||||
available: available
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
function parseKeyValue(raw) {
|
||||
var next = {}
|
||||
var lines = String(raw || "").split("\n")
|
||||
@@ -140,7 +185,9 @@ function pingPacketLossPercent(samples) {
|
||||
return Math.round((lost / values.length) * 100)
|
||||
}
|
||||
|
||||
function formatPacketLoss(percent) {
|
||||
function formatPacketLoss(percent, hasSamples) {
|
||||
if (hasSamples === false) return "--"
|
||||
|
||||
var value = parseInt(percent, 10)
|
||||
if (!value || value < 0) return "0%"
|
||||
return value + "%"
|
||||
@@ -188,7 +235,12 @@ function formatSpeedMbps(mbps) {
|
||||
return value.toFixed(value > 0 && value < 10 ? 1 : 0) + " Mbps"
|
||||
}
|
||||
|
||||
function formatPingLatency(ms) {
|
||||
// `hasSamples` false means no probe has come back yet, which is different from
|
||||
// a probe that timed out. The rows stay mounted through that gap and read "--"
|
||||
// so the grid doesn't reflow a second after the panel opens.
|
||||
function formatPingLatency(ms, hasSamples) {
|
||||
if (hasSamples === false) return "--"
|
||||
|
||||
var value = parseFloat(ms)
|
||||
if (!isFinite(value) || value < 0) return "Timeout"
|
||||
return value.toFixed(value > 0 && value < 10 ? 1 : 0) + " ms"
|
||||
@@ -263,6 +315,10 @@ if (typeof module !== "undefined") {
|
||||
formatHeaderSpeed: formatHeaderSpeed,
|
||||
formatHeaderFreq: formatHeaderFreq,
|
||||
headerDetail: headerDetail,
|
||||
bandLabel: bandLabel,
|
||||
bandSectionTitle: bandSectionTitle,
|
||||
bandTooltip: bandTooltip,
|
||||
parseBandStatus: parseBandStatus,
|
||||
parseKeyValue: parseKeyValue,
|
||||
throughputState: throughputState,
|
||||
pingLatencyState: pingLatencyState,
|
||||
|
||||
@@ -47,6 +47,10 @@ Panel {
|
||||
readonly property int pingHistoryWindow: 24
|
||||
readonly property int pingAverageWindow: 5
|
||||
readonly property bool hasInternetPing: internetPingSamples.length > 0
|
||||
// Every stat row stays mounted whether or not there is data behind it, so a
|
||||
// sample arriving late never reflows the grid. This says whether the numbers
|
||||
// are real yet or the row should read "--".
|
||||
readonly property bool hasTransferStats: info.rx_bytes !== undefined
|
||||
property int connectionPhraseIndex: 0
|
||||
readonly property var connectionPhrases: [
|
||||
"Wiring bits",
|
||||
@@ -68,6 +72,13 @@ Panel {
|
||||
property bool wifiStationAvailable: false
|
||||
property string dnsProvider: ""
|
||||
property string pendingDnsProvider: ""
|
||||
// Wi-Fi band state from `omarchy-network-band`. `bandCurrent` is the band
|
||||
// the radio is actually on; `bandSelected` is the pinned choice ("auto" when
|
||||
// nothing is pinned), and the two differ whenever Auto is in effect.
|
||||
property string bandCurrent: ""
|
||||
property string bandSelected: "auto"
|
||||
property var bandAvailable: []
|
||||
property string pendingBand: ""
|
||||
property bool speedTestRunning: false
|
||||
property bool speedTestHasRun: false
|
||||
property bool speedTestExpectedStop: false
|
||||
@@ -103,9 +114,9 @@ Panel {
|
||||
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"
|
||||
// header actions ⇄ band ⇄ DNS row ⇄ speed test ⇄ Wi-Fi networks. h/l move
|
||||
// within header actions, band pills, or DNS providers.
|
||||
property string focusSection: "dns" // "header" | "band" | "dns" | "speed" | "wifi"
|
||||
property int headerIndex: 0
|
||||
readonly property bool canDisconnect: !!connectedWifiNetwork
|
||||
readonly property bool headerHasDisconnect: false
|
||||
@@ -121,9 +132,61 @@ Panel {
|
||||
readonly property string toggleHint: Networking.wifiEnabled ? "Turn Wi-Fi off" : "Turn Wi-Fi on"
|
||||
readonly property var dnsProviders: ["DHCP", "Cloudflare", "Google", "Custom"]
|
||||
property int dnsIndex: 0
|
||||
// ["2.4", "5", ...], or empty when there is nothing to choose between.
|
||||
// Wi-Fi only: on Ethernet the band of a secondary radio is not what the
|
||||
// panel is describing.
|
||||
// `bandBusy` keeps the section mounted across the reconnect a band change
|
||||
// causes: `kind` stops being "wifi" for a second or two in the middle of it,
|
||||
// and without this the whole segment would vanish and rebuild itself.
|
||||
// Worth showing when there is a real choice, or when a pin is in force even
|
||||
// though only one band answers right now -- otherwise the Automatic switch
|
||||
// vanishes and the pin becomes unclearable from the panel.
|
||||
readonly property bool canSelectBand: (kind === "wifi" || bandBusy)
|
||||
&& (bandAvailable.length > 1 || bandPinned)
|
||||
// While a change is in flight, show the state that was asked for rather than
|
||||
// the one still in force, so the row answers the click immediately instead of
|
||||
// after the reconnect. actionProc puts it back if the change failed.
|
||||
readonly property string bandEffective: pendingBand !== "" ? pendingBand : bandSelected
|
||||
readonly property bool bandPinned: bandEffective !== "auto"
|
||||
// Under Automatic there is nothing to pick, so the pills collapse away and
|
||||
// the header states the live band instead.
|
||||
readonly property bool bandPillsVisible: canSelectBand && bandPinned
|
||||
readonly property string bandSectionTitle: Model.bandSectionTitle(bandEffective, bandCurrent)
|
||||
readonly property bool bandBusy: pendingBand !== ""
|
||||
// The speed test section only exists once there's an interface to test, so
|
||||
// the Run button only joins the cursor chain then.
|
||||
readonly property bool canRunSpeedTest: !!info.iface
|
||||
property int bandIndex: 0
|
||||
// The band section has up to two cursor rows: the Automatic switch on the
|
||||
// header line, then the pills. Same shape as wifiActionFocused.
|
||||
property bool bandAutoFocused: true
|
||||
|
||||
onHeaderActionCountChanged: clampHeaderIndex()
|
||||
|
||||
// Availability shifts as scans land, so the option list can shrink out from
|
||||
// under the cursor. Clamp the index and evacuate the section before it
|
||||
// disappears, or the panel is left highlighting nothing.
|
||||
onBandAvailableChanged: {
|
||||
if (bandIndex > bandAvailable.length - 1) bandIndex = Math.max(0, bandAvailable.length - 1)
|
||||
}
|
||||
|
||||
onCanSelectBandChanged: {
|
||||
if (!canSelectBand && focusSection === "band") {
|
||||
focusSection = "dns"
|
||||
bandAutoFocused = true
|
||||
}
|
||||
}
|
||||
|
||||
onCanRunSpeedTestChanged: {
|
||||
if (!canRunSpeedTest && focusSection === "speed") focusSection = "dns"
|
||||
}
|
||||
|
||||
// Collapsing the pills out from under the cursor would leave it pointing at
|
||||
// nothing, so send it up to the switch that is still on screen.
|
||||
onBandPillsVisibleChanged: {
|
||||
if (!bandPillsVisible) bandAutoFocused = true
|
||||
}
|
||||
|
||||
function clampHeaderIndex() {
|
||||
var max = Math.max(0, headerActionCount - 1)
|
||||
if (headerIndex > max) headerIndex = max
|
||||
@@ -159,6 +222,47 @@ Panel {
|
||||
setDns(dnsProviders[dnsIndex])
|
||||
}
|
||||
|
||||
function selectBandByDelta(delta) {
|
||||
bandIndex = Math.max(0, Math.min(bandAvailable.length - 1, bandIndex + delta))
|
||||
}
|
||||
|
||||
function activateBand() {
|
||||
if (bandAutoFocused) {
|
||||
toggleBandAuto()
|
||||
return
|
||||
}
|
||||
if (bandIndex < 0 || bandIndex >= bandAvailable.length) return
|
||||
setBand(bandAvailable[bandIndex])
|
||||
}
|
||||
|
||||
// Switching Automatic off has to commit to something, so it pins whatever
|
||||
// band the radio already landed on -- the reading the pills are showing.
|
||||
function toggleBandAuto() {
|
||||
if (bandSelected !== "auto") {
|
||||
setBand("auto")
|
||||
return
|
||||
}
|
||||
if (bandCurrent === "") return
|
||||
setBand(bandCurrent)
|
||||
}
|
||||
|
||||
// Park the cursor on the pinned band, so opening the panel highlights the
|
||||
// pill the user would expect. Under Automatic there are no pills, so the
|
||||
// cursor belongs on the switch.
|
||||
function syncBandIndex() {
|
||||
var idx = bandAvailable.indexOf(bandSelected)
|
||||
bandIndex = idx >= 0 ? idx : 0
|
||||
bandAutoFocused = !bandPillsVisible
|
||||
}
|
||||
|
||||
function bandLabel(band) {
|
||||
return Model.bandLabel(band)
|
||||
}
|
||||
|
||||
function bandTooltip(band) {
|
||||
return Model.bandTooltip(band)
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -179,6 +283,7 @@ Panel {
|
||||
focusSection = wifiNetworks.length > 0 ? "wifi" : "dns"
|
||||
var idx = dnsProviders.indexOf(dnsProvider)
|
||||
dnsIndex = idx >= 0 ? idx : 0
|
||||
syncBandIndex()
|
||||
cursorActive = false
|
||||
} else {
|
||||
// Reset throughput tracking so the next open doesn't compute a fake
|
||||
@@ -300,6 +405,10 @@ Panel {
|
||||
dnsProc.command = ["bash", "-c", root.dnsCommand("")]
|
||||
dnsProc.running = true
|
||||
}
|
||||
if (!bandProc.running) {
|
||||
bandProc.command = ["omarchy-network-band"]
|
||||
bandProc.running = true
|
||||
}
|
||||
if (wifiDevice) {
|
||||
if (scanWifi) {
|
||||
scanning = true
|
||||
@@ -321,11 +430,19 @@ Panel {
|
||||
}
|
||||
|
||||
function headerDetail() {
|
||||
return Model.headerDetail(info)
|
||||
return Model.headerDetail(info, canSelectBand)
|
||||
}
|
||||
|
||||
function updateDetails(raw) {
|
||||
var next = Model.parseKeyValue(raw)
|
||||
|
||||
// A band change tears the link down and brings it back, and the status
|
||||
// command reports nothing at all while there is no route. Publishing that
|
||||
// would blank every stat and unmount the whole section mid-toggle, so the
|
||||
// last good sample stands until the reconnect settles. A real disconnect is
|
||||
// still reported, because nothing is in flight then.
|
||||
if (bandBusy && !next.iface) return
|
||||
|
||||
info = next
|
||||
updateThroughput(next)
|
||||
updatePingLatency(next)
|
||||
@@ -377,11 +494,11 @@ Panel {
|
||||
}
|
||||
|
||||
function formatPingLatency(ms) {
|
||||
return Model.formatPingLatency(ms)
|
||||
return Model.formatPingLatency(ms, hasInternetPing)
|
||||
}
|
||||
|
||||
function formatPacketLoss(percent) {
|
||||
return Model.formatPacketLoss(percent)
|
||||
return Model.formatPacketLoss(percent, hasInternetPing)
|
||||
}
|
||||
|
||||
// Prefer a connected device: a machine can expose several NICs of the
|
||||
@@ -436,6 +553,30 @@ Panel {
|
||||
dnsProvider = value || "DHCP"
|
||||
}
|
||||
|
||||
function updateBand(raw) {
|
||||
var status = Model.parseBandStatus(raw)
|
||||
|
||||
// Mid-reconnect there is no connected station, so the command reports
|
||||
// nothing. Publishing that would empty the option list and unmount the
|
||||
// section on every toggle -- same guard as updateDetails.
|
||||
if (bandBusy && status.available.length === 0) return
|
||||
|
||||
bandCurrent = status.band
|
||||
bandSelected = status.selected
|
||||
bandAvailable = status.available
|
||||
}
|
||||
|
||||
// Pinning a band reassociates, but the panel deliberately stays open: the
|
||||
// reconnect is the thing you want to watch, and the details rows above
|
||||
// report it as it happens.
|
||||
function setBand(band) {
|
||||
if (!band || actionProc.running) return
|
||||
|
||||
root.pendingBand = band
|
||||
actionProc.command = ["omarchy-network-band", band]
|
||||
actionProc.running = true
|
||||
}
|
||||
|
||||
function updateSpeedTestLine(line) {
|
||||
var value = parseFloat(line)
|
||||
if (!isFinite(value) || value < 0) return
|
||||
@@ -659,6 +800,28 @@ Panel {
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: bandProc
|
||||
stdout: StdioCollector {
|
||||
waitForEnd: true
|
||||
onStreamFinished: root.updateBand(text)
|
||||
}
|
||||
}
|
||||
|
||||
// Slower than detailsPoll on purpose: this shells out to nmcli several times,
|
||||
// and band availability only moves when a scan turns up a new BSSID.
|
||||
Timer {
|
||||
id: bandPoll
|
||||
interval: 4000
|
||||
repeat: true
|
||||
running: root.opened
|
||||
onTriggered: {
|
||||
if (bandProc.running) return
|
||||
bandProc.command = ["omarchy-network-band"]
|
||||
bandProc.running = true
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: speedTestProc
|
||||
stdout: SplitParser { onRead: function(line) { root.updateSpeedTestLine(line) } }
|
||||
@@ -699,6 +862,15 @@ Panel {
|
||||
if (exitCode === 0) root.dnsProvider = root.pendingDnsProvider
|
||||
root.pendingDnsProvider = ""
|
||||
}
|
||||
if (root.pendingBand !== "") {
|
||||
// A refused or reverted pin leaves bandSelected alone, so the pills
|
||||
// keep showing what is actually in force rather than what was asked.
|
||||
if (exitCode === 0) root.bandSelected = root.pendingBand
|
||||
root.pendingBand = ""
|
||||
// The panel stayed open through the reconnect, so pull fresh state now
|
||||
// instead of leaving stale readings until the next poll tick.
|
||||
root.refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -807,26 +979,63 @@ Panel {
|
||||
if (dy >= 0) return
|
||||
}
|
||||
if (dy !== 0) {
|
||||
// Vertical order is header ⇄ band ⇄ DNS ⇄ wifi, with the band section
|
||||
// dropping out of the chain entirely when it isn't on screen.
|
||||
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.canSelectBand) {
|
||||
root.focusSection = "band"
|
||||
root.bandAutoFocused = true
|
||||
} else {
|
||||
root.focusSection = "dns"
|
||||
}
|
||||
}
|
||||
} else if (root.focusSection === "band") {
|
||||
// Automatic on the header line, then the pills -- which collapse
|
||||
// away under Automatic, leaving a single row to walk.
|
||||
if (dy < 0) {
|
||||
if (root.headerActionCount > 0) {
|
||||
if (!root.bandAutoFocused) {
|
||||
root.bandAutoFocused = true
|
||||
} else if (root.headerActionCount > 0) {
|
||||
root.focusSection = "header"
|
||||
root.headerIndex = 0
|
||||
}
|
||||
} else if (root.bandAutoFocused && root.bandPillsVisible) {
|
||||
root.bandAutoFocused = false
|
||||
} else {
|
||||
root.focusSection = "dns"
|
||||
}
|
||||
} else if (root.focusSection === "dns") {
|
||||
// k from DNS moves up into the band section when it's on screen,
|
||||
// then the disconnect button; otherwise stays put. j drops into the
|
||||
// wifi list if there's anywhere to land.
|
||||
if (dy < 0) {
|
||||
if (root.canSelectBand) {
|
||||
root.focusSection = "band"
|
||||
root.bandAutoFocused = !root.bandPillsVisible
|
||||
} else if (root.headerActionCount > 0) {
|
||||
root.focusSection = "header"
|
||||
root.headerIndex = 0
|
||||
}
|
||||
} else if (root.canRunSpeedTest) {
|
||||
root.focusSection = "speed"
|
||||
} else if (root.wifiNetworks.length > 0) {
|
||||
root.focusSection = "wifi"
|
||||
if (root.selectedIndex < 0) root.selectedIndex = 0
|
||||
}
|
||||
} else if (root.focusSection === "speed") {
|
||||
if (dy < 0) {
|
||||
root.focusSection = "dns"
|
||||
} 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.
|
||||
// k from the top row escapes back up into the speed test's Run
|
||||
// button, or DNS when there is no speed test, rather than wrapping
|
||||
// around to the bottom of the list.
|
||||
if (dy < 0 && root.selectedIndex <= 0) {
|
||||
root.focusSection = "dns"
|
||||
root.focusSection = root.canRunSpeedTest ? "speed" : "dns"
|
||||
root.wifiActionFocused = false
|
||||
}
|
||||
else root.selectByDelta(dy)
|
||||
@@ -834,6 +1043,7 @@ Panel {
|
||||
}
|
||||
if (dx !== 0) {
|
||||
if (root.focusSection === "header") root.selectHeaderByDelta(dx)
|
||||
else if (root.focusSection === "band") { if (!root.bandAutoFocused) root.selectBandByDelta(dx) }
|
||||
else if (root.focusSection === "dns") root.selectDnsByDelta(dx)
|
||||
else if (root.focusSection === "wifi") root.selectWifiActionByDelta(dx)
|
||||
}
|
||||
@@ -841,7 +1051,9 @@ Panel {
|
||||
onActivateRequested: {
|
||||
if (root.cursorActive) {
|
||||
if (root.focusSection === "header") root.activateHeader()
|
||||
else if (root.focusSection === "band") root.activateBand()
|
||||
else if (root.focusSection === "dns") root.activateDns()
|
||||
else if (root.focusSection === "speed") root.runSpeedTest()
|
||||
else root.activateSelected()
|
||||
}
|
||||
}
|
||||
@@ -963,118 +1175,175 @@ Panel {
|
||||
columnSpacing: Style.space(20)
|
||||
rowSpacing: Style.spacing.labelGap
|
||||
|
||||
InfoLabel { visible: root.hasInternetPing; text: "Ping" }
|
||||
// Always mounted: these two used to appear a beat after the panel
|
||||
// opened, once the first probe returned, shoving everything below
|
||||
// them down. They now hold their place and read "--" until there is
|
||||
// a sample.
|
||||
InfoLabel { 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" }
|
||||
InfoLabel { 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 { text: "Receiving" }
|
||||
DetailValue { text: root.hasTransferStats ? root.formatRate(root.downloadRate) : "--" }
|
||||
InfoLabel { text: "Sending" }
|
||||
DetailValue { text: root.hasTransferStats ? 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 { text: "Downloaded" }
|
||||
DetailValue { text: root.hasTransferStats ? root.formatBytes(parseFloat(root.info.rx_bytes || "0")) : "--" }
|
||||
InfoLabel { text: "Uploaded" }
|
||||
DetailValue { text: root.hasTransferStats ? root.formatBytes(parseFloat(root.info.tx_bytes || "0")) : "--" }
|
||||
|
||||
InfoLabel {
|
||||
visible: !!root.info.ip || !!root.info.gateway
|
||||
text: root.info.ip ? "IP Address" : ""
|
||||
}
|
||||
InfoLabel { text: "IP Address" }
|
||||
DetailValue {
|
||||
visible: !!root.info.ip || !!root.info.gateway
|
||||
text: root.info.ip || ""
|
||||
text: root.info.ip || "--"
|
||||
copyable: !!root.info.ip
|
||||
tooltipText: "Copy IP"
|
||||
}
|
||||
InfoLabel {
|
||||
visible: !!root.info.ip || !!root.info.gateway
|
||||
text: root.info.gateway ? "Gateway" : ""
|
||||
}
|
||||
InfoLabel { text: "Gateway" }
|
||||
DetailValue {
|
||||
visible: !!root.info.ip || !!root.info.gateway
|
||||
text: root.info.gateway || ""
|
||||
text: root.info.gateway || "--"
|
||||
copyable: !!root.info.gateway
|
||||
tooltipText: "Copy gateway"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Wi-Fi band selection. Only on Wi-Fi, and only when the network answers
|
||||
// on more than one band -- a single-band AP has nothing to toggle.
|
||||
PanelSeparator {
|
||||
visible: !!root.info.iface
|
||||
visible: root.canSelectBand
|
||||
foreground: root.bar.foreground
|
||||
}
|
||||
|
||||
Column {
|
||||
visible: !!root.info.iface
|
||||
visible: root.canSelectBand
|
||||
width: parent.width
|
||||
spacing: Style.space(12)
|
||||
spacing: Style.space(10)
|
||||
|
||||
Column {
|
||||
// "Automatic" rides on the header line rather than under the pills: it
|
||||
// qualifies the whole row, and at header scale it reads as a modifier
|
||||
// instead of competing with the band choices for attention.
|
||||
Item {
|
||||
width: parent.width
|
||||
spacing: Style.space(8)
|
||||
implicitHeight: Math.max(bandHeader.implicitHeight, bandAutoRow.implicitHeight)
|
||||
|
||||
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"
|
||||
tooltipText: "Run using fast.com"
|
||||
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()
|
||||
}
|
||||
PanelSectionHeader {
|
||||
id: bandHeader
|
||||
text: root.bandSectionTitle
|
||||
foreground: root.bar.foreground
|
||||
fontFamily: root.bar.fontFamily
|
||||
anchors.left: parent.left
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
Row {
|
||||
id: speedTestValues
|
||||
visible: root.speedTestHasRun
|
||||
width: parent.width
|
||||
spacing: Style.space(20)
|
||||
id: bandAutoRow
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: Style.space(6)
|
||||
|
||||
readonly property real cellWidth: Math.max(0, (width - spacing * 3) / 4)
|
||||
PanelSectionHeader {
|
||||
id: bandAutoLabel
|
||||
text: "AUTOMATIC"
|
||||
foreground: root.bar.foreground
|
||||
fontFamily: root.bar.fontFamily
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
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) }
|
||||
}
|
||||
// Sized off the label rather than the theme's control height so it
|
||||
// reads as part of the header, and centred on the label's *glyphs*:
|
||||
// PanelSectionHeader carries topPadding to protect Nerd Font
|
||||
// overshoot, which pushes its text below its own box centre, so a
|
||||
// plain verticalCenter would sit the switch visibly high.
|
||||
ToggleSwitch {
|
||||
id: bandAutoSwitch
|
||||
trackHeight: Math.round(bandAutoLabel.font.pixelSize * 1.2)
|
||||
cursorPad: Style.space(3)
|
||||
anchors.verticalCenter: bandAutoLabel.verticalCenter
|
||||
anchors.verticalCenterOffset: Math.round(bandAutoLabel.topPadding / 2)
|
||||
checked: !root.bandPinned
|
||||
busy: root.bandBusy
|
||||
hasCursor: root.cursorActive && root.focusSection === "band" && root.bandAutoFocused
|
||||
foreground: root.bar.foreground
|
||||
onToggled: root.toggleBandAuto()
|
||||
|
||||
InfoValue {
|
||||
visible: root.speedTestError !== ""
|
||||
text: root.speedTestError
|
||||
color: root.bar.urgent
|
||||
width: parent.width
|
||||
elide: Text.ElideRight
|
||||
onHovered: function(isHovered) {
|
||||
if (!isHovered) return
|
||||
root.cursorActive = true
|
||||
root.focusSection = "band"
|
||||
root.bandAutoFocused = true
|
||||
}
|
||||
|
||||
PanelToolTip {
|
||||
visible: bandAutoSwitch.containsMouse
|
||||
text: root.bandPinned
|
||||
? "Let Wi-Fi pick the band"
|
||||
: "Stay on " + root.bandLabel(root.bandCurrent)
|
||||
fontFamily: root.bar.fontFamily
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collapsing container: the pills animate their height so toggling
|
||||
// Automatic slides the sections below into place instead of snapping.
|
||||
// `visible` only drops at a real zero, which keeps the row rendered for
|
||||
// the whole animation and takes it out of the Column's spacing once
|
||||
// it's actually gone.
|
||||
Item {
|
||||
id: bandPillsClip
|
||||
width: parent.width
|
||||
clip: true
|
||||
visible: height > 0
|
||||
height: root.bandPillsVisible ? bandRow.implicitHeight : 0
|
||||
opacity: root.bandPillsVisible ? 1 : 0
|
||||
|
||||
Behavior on height {
|
||||
NumberAnimation { duration: 140; easing.type: Easing.OutCubic }
|
||||
}
|
||||
Behavior on opacity {
|
||||
NumberAnimation { duration: 140; easing.type: Easing.OutCubic }
|
||||
}
|
||||
|
||||
Row {
|
||||
id: bandRow
|
||||
width: parent.width
|
||||
spacing: Style.space(6)
|
||||
|
||||
readonly property int count: Math.max(1, root.bandAvailable.length)
|
||||
readonly property real cellWidth: (width - spacing * (count - 1)) / count
|
||||
|
||||
// Wrapper takes modelData/index from the Repeater's delegate
|
||||
// context, which doesn't bind into nested `component` declarations,
|
||||
// and passes them down explicitly -- same shape as the network
|
||||
// list delegate.
|
||||
Repeater {
|
||||
model: root.bandAvailable
|
||||
|
||||
delegate: Item {
|
||||
required property var modelData
|
||||
required property int index
|
||||
width: bandRow.cellWidth
|
||||
height: bandPill.implicitHeight
|
||||
|
||||
BandPill {
|
||||
id: bandPill
|
||||
band: modelData
|
||||
slot: index
|
||||
width: parent.width
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// DNS provider selection.
|
||||
@@ -1134,6 +1403,85 @@ Panel {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Scaled to the band section's AUTOMATIC row -- caption type and
|
||||
// trimmed padding -- so both header lines carry a control of the
|
||||
// same visual weight instead of this one dominating.
|
||||
Button {
|
||||
id: speedRunButton
|
||||
text: root.speedTestRunning ? "Running..." : "Run"
|
||||
tooltipText: "Run using fast.com"
|
||||
enabled: !root.speedTestRunning
|
||||
foreground: root.bar.foreground
|
||||
fontFamily: root.bar.fontFamily
|
||||
fontSize: Style.font.caption
|
||||
horizontalPadding: Style.space(8)
|
||||
verticalPadding: Style.space(2)
|
||||
bordered: true
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
hasCursor: root.cursorActive && root.focusSection === "speed"
|
||||
onClicked: root.runSpeedTest()
|
||||
|
||||
onHovered: function(isHovered) {
|
||||
if (!isHovered) return
|
||||
root.cursorActive = true
|
||||
root.focusSection = "speed"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Wi-Fi networks (only if a Wi-Fi station is available).
|
||||
PanelSeparator {
|
||||
visible: root.wifiStationAvailable
|
||||
@@ -1204,6 +1552,39 @@ Panel {
|
||||
}
|
||||
}
|
||||
|
||||
// One Wi-Fi band pill. `active` (fill) is the band actually in use and
|
||||
// `selected` (bold) is the pinned choice; with Automatic on nothing is
|
||||
// pinned, so only the live band lights up and the two can no longer read as
|
||||
// a contradiction. They land on the same pill once a band is pinned.
|
||||
component BandPill: Button {
|
||||
id: pill
|
||||
required property string band
|
||||
required property int slot
|
||||
|
||||
text: root.bandLabel(band)
|
||||
tooltipText: root.bandTooltip(band)
|
||||
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
|
||||
|
||||
active: root.bandCurrent === band
|
||||
selected: root.bandEffective === band
|
||||
hasCursor: root.cursorActive && root.focusSection === "band"
|
||||
&& !root.bandAutoFocused && root.bandIndex === slot
|
||||
|
||||
onClicked: root.setBand(band)
|
||||
|
||||
onHovered: function(isHovered) {
|
||||
if (!isHovered) return
|
||||
root.cursorActive = true
|
||||
root.focusSection = "band"
|
||||
root.bandIndex = pill.slot
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -73,8 +73,13 @@ assertEqual(network.formatSpeedMbps('250.4'), '250 Mbps', 'network formats speed
|
||||
assertEqual(network.formatPingLatency('2.54'), '2.5 ms', 'network formats low ping with precision')
|
||||
assertEqual(network.formatPingLatency('25.4'), '25 ms', 'network formats ping')
|
||||
assertEqual(network.formatPingLatency(''), 'Timeout', 'network formats missing ping as timeout')
|
||||
assertEqual(network.formatPingLatency(-1, false), '--', 'network holds the ping row before the first sample')
|
||||
assertEqual(network.formatPingLatency('25.4', true), '25 ms', 'network formats ping once samples exist')
|
||||
assertEqual(network.formatPingLatency('', true), 'Timeout', 'network still reports a timeout among real samples')
|
||||
assertEqual(network.formatPacketLoss(2), '2%', 'network formats packet loss')
|
||||
assertEqual(network.formatPacketLoss(0), '0%', 'network formats zero packet loss')
|
||||
assertEqual(network.formatPacketLoss(0, false), '--', 'network holds the packet loss row before the first sample')
|
||||
assertEqual(network.formatPacketLoss(0, true), '0%', 'network reports zero loss once samples exist')
|
||||
|
||||
const rows = network.sortWifiRows([
|
||||
{ ssid: 'Open', connected: false, known: false, signal: 95 },
|
||||
@@ -89,4 +94,31 @@ const reasons = { NoSecrets: 1, WifiAuthTimeout: 2, WifiNetworkLost: 3, WifiClie
|
||||
assertEqual(network.networkFailureReason(1, reasons), 'Passphrase required', 'network maps missing passphrase failures')
|
||||
assertEqual(network.networkFailureReason(2, reasons), 'Wrong password', 'network maps auth timeout failures')
|
||||
assertEqual(network.networkFailureReason(99, reasons), 'Failed to connect', 'network maps unknown failures')
|
||||
|
||||
|
||||
assertEqual(network.bandLabel('2.4'), '2.4ghz', 'network labels the 2.4GHz band')
|
||||
assertEqual(network.bandLabel('6'), '6ghz', 'network labels the 6GHz band')
|
||||
assertEqual(network.bandLabel('auto'), 'Auto', 'network labels the automatic band choice')
|
||||
|
||||
assertEqual(network.bandSectionTitle('auto', '2.4'), 'WI-FI BAND: 2.4GHZ', 'network names the live band in the header under automatic')
|
||||
assertEqual(network.bandSectionTitle('auto', ''), 'WI-FI BAND', 'network omits an unknown band from the header')
|
||||
assertEqual(network.bandSectionTitle('5', '5'), 'WI-FI BAND', 'network drops the header band once the pills are showing')
|
||||
assertEqual(network.bandSectionTitle('5', '2.4'), 'WI-FI BAND', 'network keeps a plain header while a pin is settling')
|
||||
|
||||
assertDeepEqual(
|
||||
network.parseBandStatus('band\t5\navailable\t2.4 5 6\nselected\tauto\n'),
|
||||
{ band: '5', selected: 'auto', available: ['2.4', '5', '6'] },
|
||||
'network parses band status'
|
||||
)
|
||||
assertDeepEqual(
|
||||
network.parseBandStatus(''),
|
||||
{ band: '', selected: 'auto', available: [] },
|
||||
'network parses empty band status without a wifi connection'
|
||||
)
|
||||
|
||||
|
||||
|
||||
assertEqual(network.headerDetail({ type: 'wifi', freq: '5745' }), '5ghz', 'network header shows the wifi band when the toggle is hidden')
|
||||
assertEqual(network.headerDetail({ type: 'wifi', freq: '5745' }, true), '', 'network header drops the wifi band when the toggle shows it')
|
||||
assertEqual(network.headerDetail({ type: 'ethernet', speed: '100' }, true), '100mbit', 'network header keeps ethernet speed regardless of the band toggle')
|
||||
JS
|
||||
|
||||
Reference in New Issue
Block a user