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:
David Heinemeier Hansson
2026-07-30 10:11:23 -04:00
committed by GitHub
co-authored by Claude Opus 5
parent e47d895ffe
commit 14f1bb6c88
5 changed files with 764 additions and 96 deletions
+192
View File
@@ -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
+11 -4
View File
@@ -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
+60 -4
View File
@@ -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,
+469 -88
View File
@@ -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.
+32
View File
@@ -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