diff --git a/bin/omarchy-network-band b/bin/omarchy-network-band new file mode 100755 index 00000000..59fa04b4 --- /dev/null +++ b/bin/omarchy-network-band @@ -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 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 diff --git a/shell/Ui/ToggleSwitch.qml b/shell/Ui/ToggleSwitch.qml index 4da6c445..22e6461a 100644 --- a/shell/Ui/ToggleSwitch.qml +++ b/shell/Ui/ToggleSwitch.qml @@ -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 diff --git a/shell/plugins/panels/network/Model.js b/shell/plugins/panels/network/Model.js index c16b24ed..8b46db39 100644 --- a/shell/plugins/panels/network/Model.js +++ b/shell/plugins/panels/network/Model.js @@ -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, diff --git a/shell/plugins/panels/network/Panel.qml b/shell/plugins/panels/network/Panel.qml index ffe54434..ac48950c 100644 --- a/shell/plugins/panels/network/Panel.qml +++ b/shell/plugins/panels/network/Panel.qml @@ -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. diff --git a/test/shell.d/network-test.sh b/test/shell.d/network-test.sh index 89cd8c76..a6838097 100644 --- a/test/shell.d/network-test.sh +++ b/test/shell.d/network-test.sh @@ -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