diff --git a/bin/omarchy-network-qr b/bin/omarchy-network-qr index 92af3dd9..0ecac863 100755 --- a/bin/omarchy-network-qr +++ b/bin/omarchy-network-qr @@ -2,11 +2,25 @@ # omarchy:summary=Generate a Wi-Fi QR matrix for the shell # omarchy:group=network -# omarchy:args= +# omarchy:args=[interface] set -euo pipefail -interface=${1:?Usage: omarchy-network-qr } +interface=${1:-} +if [[ -z $interface ]]; then + # Prefer the default-route device: it is the connection the panel and the + # menu's visibility gate describe. Fall back to the first connected Wi-Fi + # device. nmcli localizes state names, so pin the locale, and the prefix + # match accepts states like "connected (externally)". + route_device=$(ip route get 1.1.1.1 2>/dev/null | awk '{ for (i = 1; i <= NF; i++) if ($i == "dev") { print $(i + 1); exit } }') + if [[ -n $route_device && -d /sys/class/net/$route_device/wireless ]]; then + interface=$route_device + else + interface=$(LC_ALL=C nmcli -t -f DEVICE,TYPE,STATE device status 2>/dev/null | + awk -F: '$2 == "wifi" && $3 ~ /^connected/ { print $1; exit }') + fi +fi +[[ -n $interface ]] || { echo "No active Wi-Fi connection" >&2; exit 1; } uuid=$(nmcli --get-values GENERAL.CON-UUID device show "$interface" | head -n 1) [[ -n $uuid && $uuid != "--" ]] || { echo "No active Wi-Fi connection" >&2; exit 1; } diff --git a/default/omarchy/omarchy-menu.jsonc b/default/omarchy/omarchy-menu.jsonc index c3382e8b..ddc30ef0 100644 --- a/default/omarchy/omarchy-menu.jsonc +++ b/default/omarchy/omarchy-menu.jsonc @@ -122,6 +122,14 @@ "setup.keybindings": {"icon":"","label":"Keybindings","when":"[[ -f ~/.config/hypr/bindings.lua ]]","action":"omarchy-launch-config-editor \"$HOME/.config/hypr/bindings.lua\""}, "setup.input": {"icon":"","label":"Input","when":"[[ -f ~/.config/hypr/input.lua ]]","action":"omarchy-launch-config-editor \"$HOME/.config/hypr/input.lua\""}, "setup.direct-boot": {"icon":"","label":"Direct Boot","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-direct-boot"}, + "setup.network": {"icon":"󰛳","label":"Network","aliases":["network"]}, + "setup.network.dns": {"icon":"󰇖","label":"DNS","aliases":["dns"]}, + "setup.network.dns.dhcp": {"icon":"󰩟","label":"DHCP","checked":"[[ \"$(omarchy-dns)\" == \"DHCP\" ]]","action":"omarchy-dns DHCP"}, + "setup.network.dns.cloudflare": {"icon":"󰅟","label":"Cloudflare","checked":"[[ \"$(omarchy-dns)\" == \"Cloudflare\" ]]","action":"omarchy-dns Cloudflare"}, + "setup.network.dns.google": {"icon":"󰊭","label":"Google","checked":"[[ \"$(omarchy-dns)\" == \"Google\" ]]","action":"omarchy-dns Google"}, + "setup.network.dns.custom": {"icon":"","label":"Custom","checked":"[[ \"$(omarchy-dns)\" == \"Custom\" ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-dns Custom'"}, + "setup.network.qr": {"icon":"󰐲","label":"QR Code","aliases":["wifi-qr"],"when":"[[ $(omarchy-network-status) == wifi* ]]","action":"omarchy-shell omarchy.network showQr"}, + "setup.network.speedtest": {"icon":"󰓅","label":"Speed Test","aliases":["speedtest","speed-test"],"action":"omarchy-shell omarchy.network speedTest"}, "setup.default": {"icon":"","label":"Defaults","aliases":["default","defaults"]}, "setup.default.browser": {"icon":"","label":"Browser"}, "setup.default.browser.chromium": {"icon":"","label":"Chromium","when":"omarchy-cmd-present chromium","checked":"[[ \"$(omarchy-default-browser)\" == \"chromium\" ]]","action":"omarchy-default-browser chromium"}, diff --git a/shell/plugins/panels/network/Model.js b/shell/plugins/panels/network/Model.js index 2c9e3ebb..c083aeaf 100644 --- a/shell/plugins/panels/network/Model.js +++ b/shell/plugins/panels/network/Model.js @@ -250,12 +250,6 @@ function formatRate(bytesPerSec) { return formatBytes(bytesPerSec) + "/s" } -function formatSpeedMbps(mbps) { - var value = parseFloat(mbps) - if (!isFinite(value) || value <= 0) return "--" - return value.toFixed(value > 0 && value < 10 ? 1 : 0) + " Mbps" -} - // `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. @@ -362,7 +356,6 @@ if (typeof module !== "undefined") { formatPacketLoss: formatPacketLoss, formatBytes: formatBytes, formatRate: formatRate, - formatSpeedMbps: formatSpeedMbps, formatPingLatency: formatPingLatency, wifiRow: wifiRow, sortWifiRows: sortWifiRows, diff --git a/shell/plugins/panels/network/Panel.qml b/shell/plugins/panels/network/Panel.qml index eb8a5f1f..bf72afbb 100644 --- a/shell/plugins/panels/network/Panel.qml +++ b/shell/plugins/panels/network/Panel.qml @@ -17,9 +17,28 @@ Panel { manageIpc: false // Centralized close so callers can't forget to drop the passphrase prompt. + readonly property bool overlayVisible: qrVisible || speedTestModalOpen + + // Shadows the base open(): a summon or toggle while a centered card is up + // dismisses the card instead of opening the compact panel behind an + // exclusive overlay. The base toggle() dispatches here, so the keybind, + // the bar icon, and every IPC route all get this behavior. + function open() { + if (overlayVisible) { + hideWifiQr() + hideSpeedTest() + return + } + root.controller.show() + } + function close() { root.controller.hide() cancelPasswordPrompt() + // The centered cards outlive the compact panel, but the widget's + // canonical close must not leave an overlay (or its traffic) behind. + hideWifiQr() + hideSpeedTest() } function cancelPasswordPrompt() { @@ -83,8 +102,9 @@ Panel { property var bandAvailable: [] property string pendingBand: "" property bool speedTestRunning: false - property bool speedTestHasRun: false + property bool speedTestModalOpen: false property bool speedTestExpectedStop: false + property bool pendingSpeedRun: false property string speedTestPhase: "" property string speedTestStderr: "" property string speedTestDownloadMbps: "" @@ -111,6 +131,8 @@ Panel { property string qrError: "" property bool qrLoading: false property bool qrExpectedStop: false + property bool pendingQrShow: false + property bool pendingQrDetect: false property string qrPassword: "" property bool qrPasswordVisible: false property string qrPasswordError: "" @@ -127,9 +149,9 @@ Panel { property bool cursorActive: false // Keyboard focus zone for the panel. j/k crosses row boundaries: - // header actions ⇄ band ⇄ DNS row ⇄ speed test ⇄ Wi-Fi networks. h/l move + // header actions ⇄ band ⇄ DNS row ⇄ Wi-Fi networks. h/l move // within header actions, band pills, or DNS providers. - property string focusSection: "dns" // "header" | "band" | "dns" | "speed" | "wifi" + property string focusSection: "dns" // "header" | "band" | "dns" | "wifi" property int headerIndex: 0 readonly property bool canDisconnect: !!connectedWifiNetwork readonly property bool headerHasDisconnect: false @@ -139,9 +161,11 @@ Panel { // "off" beside a perfectly live Ethernet connection. readonly property bool canToggleWifi: networkManagerAvailable && wifiStationAvailable readonly property int qrHeaderIndex: canShareWifi ? 0 : -1 - readonly property int toggleHeaderIndex: canToggleWifi ? (canShareWifi ? 1 : 0) : -1 - readonly property int headerActionCount: (canShareWifi ? 1 : 0) + (canToggleWifi ? 1 : 0) + readonly property int speedHeaderIndex: canRunSpeedTest ? (canShareWifi ? 1 : 0) : -1 + readonly property int toggleHeaderIndex: canToggleWifi ? (canShareWifi ? 1 : 0) + (canRunSpeedTest ? 1 : 0) : -1 + readonly property int headerActionCount: (canShareWifi ? 1 : 0) + (canRunSpeedTest ? 1 : 0) + (canToggleWifi ? 1 : 0) readonly property bool qrHeaderHasCursor: cursorActive && focusSection === "header" && headerIndex === qrHeaderIndex + readonly property bool speedHeaderHasCursor: cursorActive && focusSection === "header" && headerIndex === speedHeaderIndex readonly property bool toggleHeaderHasCursor: cursorActive && focusSection === "header" && headerIndex === toggleHeaderIndex readonly property string toggleHint: Networking.wifiEnabled ? "Turn Wi-Fi off" : "Turn Wi-Fi on" readonly property var dnsProviders: ["DHCP", "Cloudflare", "Google", "Custom"] @@ -167,8 +191,8 @@ Panel { 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. + // The speed test needs an interface to test, so its hero action only + // appears once there is one. readonly property bool canRunSpeedTest: !!info.iface property int bandIndex: 0 // The band section has up to two cursor rows: the Automatic switch on the @@ -191,10 +215,6 @@ Panel { } } - 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: { @@ -226,10 +246,20 @@ Panel { function hide() { root.close() } function toggle() { root.toggle() } function toggleNetwork() { root.toggleNetwork() } + // Menu routes: summon the centered cards directly, panel open or not. + function showQr() { + root.refresh() + root.showWifiQr(true) + } + function speedTest() { + root.refresh() + root.showSpeedTest() + } } function activateHeader() { if (headerIndex === qrHeaderIndex) showWifiQr() + else if (headerIndex === speedHeaderIndex) showSpeedTest() else if (headerIndex === toggleHeaderIndex) toggleNetwork() } @@ -427,14 +457,27 @@ Panel { readonly property string icon: Model.connectionIcon(kind, signalStrength) - function showWifiQr() { - if (qrProc.running || !info.iface || info.type !== "wifi") return + function showWifiQr(forceDetect) { + if (qrProc.running) { + // A dismissal's SIGTERM is still in flight; Process.running stays true + // until the child exits, so queue the reopen for onExited. + if (qrExpectedStop) { + pendingQrShow = true + pendingQrDetect = !!forceDetect + } + return + } qrSize = 0 qrRows = [] qrError = "" qrLoading = true qrExpectedStop = false - qrProc.command = ["omarchy-network-qr", info.iface] + // The panel's own button shares the interface it is showing. The IPC + // route forces self-detection instead: details polling stops while the + // panel is closed, so its cached interface can be stale. + qrProc.command = !forceDetect && info.type === "wifi" && info.iface + ? ["omarchy-network-qr", info.iface] + : ["omarchy-network-qr"] qrProc.running = true // Leave the compact network panel behind while the centered share card is open. @@ -443,6 +486,7 @@ Panel { } function hideWifiQr() { + pendingQrShow = false if (qrProc.running) { qrExpectedStop = true qrProc.running = false @@ -563,10 +607,6 @@ Panel { return Model.formatRate(bytesPerSec) } - function formatSpeedMbps(mbps) { - return Model.formatSpeedMbps(mbps) - } - function formatPingLatency(ms) { return Model.formatPingLatency(ms, hasInternetPing) } @@ -660,10 +700,42 @@ Panel { speedTestError = "" } + // The speed test lives in a centered modal card like the QR share. + // Opening it starts a fresh run; dismissing it stops the traffic, so the + // download workers never keep saturating the link behind a closed card. + function showSpeedTest() { + if (!speedTestModalOpen) { + speedTestModalOpen = true + controller.hide() + cancelPasswordPrompt() + } + runSpeedTest() + } + + function hideSpeedTest() { + speedTestModalOpen = false + pendingSpeedRun = false + speedTestPhaseTimer.stop() + // Clear the phase before killing the process: onExited advances to the + // upload phase when it still reads "down". + speedTestPhase = "" + speedTestRunning = false + if (speedTestProc.running) { + speedTestExpectedStop = true + speedTestProc.running = false + } + } + function runSpeedTest() { - if (speedTestProc.running) return + if (speedTestProc.running) { + // A dismissal's SIGTERM is still in flight; Process.running stays true + // until the child exits, so queue the fresh run for onExited. + if (speedTestExpectedStop) pendingSpeedRun = true + return + } speedTestError = "" - speedTestHasRun = true + speedTestDownloadMbps = "" + speedTestUploadMbps = "" speedTestRunning = true startSpeedTestPhase("down") } @@ -883,6 +955,12 @@ Panel { } onExited: function(exitCode) { root.qrLoading = false + if (root.pendingQrShow) { + root.pendingQrShow = false + root.qrExpectedStop = false + Qt.callLater(function() { root.showWifiQr(root.pendingQrDetect) }) + return + } if (root.qrExpectedStop) return if (exitCode !== 0 || root.qrSize === 0) { root.qrSize = 0 @@ -949,6 +1027,13 @@ Panel { onExited: function(exitCode) { speedTestPhaseTimer.stop() + if (root.pendingSpeedRun) { + root.pendingSpeedRun = false + root.speedTestExpectedStop = false + if (root.speedTestModalOpen) Qt.callLater(root.runSpeedTest) + return + } + if (!root.speedTestExpectedStop && exitCode !== 0) { root.speedTestError = root.speedTestStderr || "Speed test failed" root.speedTestPhase = "" @@ -1134,25 +1219,15 @@ Panel { 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 speed test's Run - // button, or DNS when there is no speed test, rather than wrapping - // around to the bottom of the list. + // k from the top row escapes back up to the DNS row rather than + // wrapping around to the bottom of the list. if (dy < 0 && root.selectedIndex <= 0) { - root.focusSection = root.canRunSpeedTest ? "speed" : "dns" + root.focusSection = "dns" root.wifiActionFocused = false } else root.selectByDelta(dy) @@ -1170,7 +1245,6 @@ Panel { 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() } } @@ -1229,6 +1303,22 @@ Panel { onClicked: root.showWifiQr() } + Button { + id: speedAction + visible: root.canRunSpeedTest + iconText: "󰓅" + tooltipText: "Run a speed test" + foreground: root.bar.foreground + fontFamily: root.bar.fontFamily + iconSize: Style.font.subtitle * 1.5 + horizontalPadding: Style.space(5) + verticalPadding: Style.space(2) + hasCursor: root.speedHeaderHasCursor + Layout.alignment: Qt.AlignVCenter + onHovered: function(on) { if (on) root.setHeaderCursor(root.speedHeaderIndex) } + onClicked: root.showSpeedTest() + } + ToggleSwitch { id: powerSwitch visible: root.canToggleWifi @@ -1543,84 +1633,6 @@ 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 @@ -1708,6 +1720,24 @@ Panel { onPasswordToggleRequested: root.toggleQrPassword() } + SpeedTestPanel { + anchorItem: button + bar: root.bar + running: root.speedTestRunning + phase: root.speedTestPhase + downloadMbps: root.speedTestDownloadMbps + uploadMbps: root.speedTestUploadMbps + error: root.speedTestError + connectionName: { + if (root.info.type === "wifi") return root.info.ssid || "Wi-Fi" + if (root.info.type === "ethernet") return "Ethernet" + return "" + } + open: root.speedTestModalOpen + onCloseRequested: root.hideSpeedTest() + onRunAgainRequested: root.runSpeedTest() + } + // 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 diff --git a/shell/plugins/panels/network/SpeedTestPanel.qml b/shell/plugins/panels/network/SpeedTestPanel.qml new file mode 100644 index 00000000..2e690209 --- /dev/null +++ b/shell/plugins/panels/network/SpeedTestPanel.qml @@ -0,0 +1,392 @@ +import QtQuick +import QtQuick.Layouts +import QtQuick.Shapes +import Quickshell +import Quickshell.Wayland +import qs.Commons +import qs.Ui + +// Centered speed test overlay. No card: like the Tucson's floating cluster, +// the two dials (download left, upload right) sit directly on a darkened +// scrim -- open 270° arcs, faint tick rings, hubless gradient needles, and a +// digital readout in the middle. Esc, the scrim, or the corner dismiss close +// it; the needles sweep to full scale and back on open, then track the live +// readings. +PanelWindow { + id: root + + required property Item anchorItem + required property QtObject bar + required property bool running + required property string phase // "down" | "up" | "" + required property string downloadMbps + required property string uploadMbps + required property string error + required property string connectionName + property bool open: false + + signal closeRequested() + signal runAgainRequested() + + readonly property real downloadValue: toMbps(downloadMbps) + readonly property real uploadValue: toMbps(uploadMbps) + readonly property bool failed: error !== "" + readonly property bool finished: !running && !failed && (downloadValue > 0 || uploadValue > 0) + + function toMbps(raw) { + var value = parseFloat(raw) + return isFinite(value) && value > 0 ? value : 0 + } + + visible: open + // The window is instantiated hidden, so re-acquire focus after mapping and + // fire the ignition sweep once the surface is actually on screen. + onOpenChanged: { + if (open) Qt.callLater(function() { + if (!root.open) return + keyCatcher.forceActiveFocus() + downDial.ignite() + upDial.ignite() + }) + } + screen: anchorItem.QsWindow.window ? anchorItem.QsWindow.window.screen : null + anchors { top: true; bottom: true; left: true; right: true } + color: "transparent" + exclusionMode: ExclusionMode.Ignore + WlrLayershell.namespace: "omarchy-network-speedtest" + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive + + // Deep scrim: with no card behind them, the floating dials need the + // backdrop to carry the contrast on any wallpaper, like the near-black + // panel behind a real cluster. + Rectangle { + anchors.fill: parent + color: Qt.rgba(0, 0, 0, 0.78) + + MouseArea { + anchors.fill: parent + onClicked: root.closeRequested() + } + } + + Item { + id: keyCatcher + anchors.fill: parent + focus: true + + Keys.onEscapePressed: root.closeRequested() + Keys.onReturnPressed: if (!root.running) root.runAgainRequested() + Keys.onEnterPressed: if (!root.running) root.runAgainRequested() + + Item { + id: cluster + anchors.centerIn: parent + width: content.implicitWidth + height: content.implicitHeight + // Narrow or heavily scaled outputs: shrink the whole cluster rather + // than clipping it at the screen edge. + scale: Math.min(1, + (keyCatcher.width - Style.space(32)) / Math.max(1, width), + (keyCatcher.height - Style.space(32)) / Math.max(1, height)) + + // Swallow clicks so only the scrim outside the cluster dismisses. + MouseArea { anchors.fill: parent; onClicked: {} } + + ColumnLayout { + id: content + anchors.fill: parent + spacing: Style.space(16) + + Text { + visible: root.connectionName !== "" + text: root.connectionName.toUpperCase() + color: Qt.darker(root.bar.foreground, 1.4) + font.family: root.bar.fontFamily + font.pixelSize: Style.font.caption + font.bold: true + font.letterSpacing: 2 + Layout.fillWidth: true + horizontalAlignment: Text.AlignHCenter + } + + Row { + spacing: Style.space(48) + Layout.alignment: Qt.AlignHCenter + + SpeedDial { + id: downDial + label: "DOWNLOAD" + value: root.downloadValue + live: root.running && root.phase === "down" + } + + SpeedDial { + id: upDial + label: "UPLOAD" + value: root.uploadValue + live: root.running && root.phase === "up" + } + } + + // Centered on the dial pair. Fades rather than unmounts while a run + // is in flight, so the cluster never shifts. + Button { + text: "Run Again" + tooltipText: "Measure again via fast.com" + bordered: true + enabled: !root.running + opacity: root.running ? 0 : 1 + foreground: root.bar.foreground + fontFamily: root.bar.fontFamily + fontSize: Style.font.bodySmall + horizontalPadding: Style.space(14) + verticalPadding: Style.space(4) + Layout.alignment: Qt.AlignHCenter + onClicked: root.runAgainRequested() + + Behavior on opacity { + NumberAnimation { duration: 240; easing.type: Easing.OutCubic } + } + } + + Text { + visible: root.failed + text: root.error + color: root.bar.urgent + font.family: root.bar.fontFamily + font.pixelSize: Style.font.bodySmall + wrapMode: Text.Wrap + Layout.fillWidth: true + Layout.maximumWidth: Style.space(440) + horizontalAlignment: Text.AlignHCenter + } + } + } + } + + // One floating cluster dial: an open 270° scale with the gap at the + // bottom, a faint tick ring, a glowing accent value arc, a hubless needle + // that fades toward the pivot, and a digital readout in the middle. All + // writes to the needle funnel through `shown` so the ignition sweep and + // live readings share one animation. + component SpeedDial: Item { + id: dial + + required property string label + required property real value + required property bool live + + readonly property real diameter: Style.space(210) + // 0° = 3 o'clock, increasing clockwise (PathAngleArc's convention). + readonly property real dialStart: 135 + readonly property real dialSweep: 270 + readonly property int tickCount: 46 + readonly property real arcWidth: Style.space(4) + readonly property real arcRadius: diameter / 2 - arcWidth + readonly property color trackColor: Qt.rgba(root.bar.foreground.r, root.bar.foreground.g, root.bar.foreground.b, 0.14) + readonly property color minorTickColor: Qt.rgba(root.bar.foreground.r, root.bar.foreground.g, root.bar.foreground.b, 0.12) + readonly property color majorTickColor: Qt.rgba(root.bar.foreground.r, root.bar.foreground.g, root.bar.foreground.b, 0.3) + // The dial that isn't measuring yet sits dimmed until it gets a figure. + readonly property bool engaged: live || value > 0 + + property real shown: 0 + // The digital readout stays on the real figure while the ignition sweep + // drives the needle -- a cluster sweeps its gauges, not its numerals. + readonly property real reading: ignition.running ? value : shown + property real fullScale: 100 + readonly property var scaleStops: [100, 250, 500, 1000, 2500, 5000, 10000] + readonly property real fraction: fullScale > 0 ? Math.max(0, Math.min(1, shown / fullScale)) : 0 + readonly property bool arcVisible: fraction > 0.004 + + width: diameter + height: diameter + opacity: engaged ? 1 : 0.5 + + Behavior on opacity { + NumberAnimation { duration: 240; easing.type: Easing.OutCubic } + } + + // Live readings land once a second; glide between them rather than snap. + Behavior on shown { + enabled: !ignition.running + NumberAnimation { duration: 600; easing.type: Easing.OutCubic } + } + + Behavior on fullScale { + enabled: !ignition.running + NumberAnimation { duration: 400; easing.type: Easing.OutCubic } + } + + // A fresh measurement re-ranges from the base scale. Without this, one + // unusually fast run would compress every later one for the lifetime of + // the shell process. + onLiveChanged: { + if (live) fullScale = scaleStops[0] + } + + onValueChanged: { + // Latch the scale upward to the next stop when a reading approaches the + // rim. Never shrink mid-run; a fresh run just re-sweeps from zero. + for (var i = 0; i < scaleStops.length; i++) { + if (value <= scaleStops[i] * 0.92) { + if (scaleStops[i] > fullScale) fullScale = scaleStops[i] + break + } + if (i === scaleStops.length - 1) fullScale = scaleStops[i] + } + if (!ignition.running) shown = value + } + + function ignite() { + ignition.restart() + } + + // Car-cluster power-on: needle sweeps to full scale and falls back before + // the live figures take over. + SequentialAnimation { + id: ignition + NumberAnimation { target: dial; property: "shown"; to: dial.fullScale; duration: 550; easing.type: Easing.InOutCubic } + NumberAnimation { target: dial; property: "shown"; to: 0; duration: 650; easing.type: Easing.OutCubic } + onFinished: dial.shown = dial.value + } + + Shape { + anchors.fill: parent + preferredRendererType: Shape.CurveRenderer + + // Track: the full scale, always visible, dim. + ShapePath { + strokeWidth: dial.arcWidth + strokeColor: dial.trackColor + fillColor: "transparent" + capStyle: ShapePath.RoundCap + + PathAngleArc { + centerX: dial.width / 2 + centerY: dial.height / 2 + radiusX: dial.arcRadius + radiusY: dial.arcRadius + startAngle: dial.dialStart + sweepAngle: dial.dialSweep + } + } + + // Soft under-glow beneath the value arc, standing in for the backlit + // ring of a real cluster. Both arcs go transparent at rest, or their + // round caps would leave a stray dot at the foot of the scale. + ShapePath { + strokeWidth: dial.arcWidth * 3 + strokeColor: dial.arcVisible ? Qt.rgba(Color.accent.r, Color.accent.g, Color.accent.b, 0.18) : "transparent" + fillColor: "transparent" + capStyle: ShapePath.RoundCap + + PathAngleArc { + centerX: dial.width / 2 + centerY: dial.height / 2 + radiusX: dial.arcRadius + radiusY: dial.arcRadius + startAngle: dial.dialStart + sweepAngle: dial.dialSweep * dial.fraction + } + } + + // Value: fills behind the needle. + ShapePath { + strokeWidth: dial.arcWidth + strokeColor: dial.arcVisible ? Color.accent : "transparent" + fillColor: "transparent" + capStyle: ShapePath.RoundCap + + PathAngleArc { + centerX: dial.width / 2 + centerY: dial.height / 2 + radiusX: dial.arcRadius + radiusY: dial.arcRadius + startAngle: dial.dialStart + sweepAngle: dial.dialSweep * dial.fraction + } + } + } + + // Faint tick ring just inside the arc; every fifth tick is a major. + Repeater { + model: dial.tickCount + + Item { + required property int index + readonly property bool major: index % 5 === 0 + + anchors.fill: parent + rotation: dial.dialStart + (index / (dial.tickCount - 1)) * dial.dialSweep - 270 + + Rectangle { + anchors.horizontalCenter: parent.horizontalCenter + y: dial.arcWidth * 2 + (parent.major ? 0 : Style.space(2)) + width: parent.major ? Math.max(2, Style.space(2)) : 1 + height: parent.major ? Style.space(10) : Style.space(6) + radius: width / 2 + color: parent.major ? dial.majorTickColor : dial.minorTickColor + } + } + } + + // Hubless needle: a slender sliver that fades out toward the pivot, so + // it reads as floating like the rest of the cluster. + Item { + anchors.fill: parent + rotation: dial.dialStart + dial.fraction * dial.dialSweep - 270 + + Rectangle { + anchors.horizontalCenter: parent.horizontalCenter + y: dial.arcWidth * 2 + Style.space(10) + width: Math.max(2, Style.space(3)) + height: dial.diameter * 0.32 + radius: width / 2 + + gradient: Gradient { + GradientStop { position: 0.0; color: Color.accent } + GradientStop { position: 0.55; color: Color.accent } + GradientStop { position: 1.0; color: "transparent" } + } + } + } + + Column { + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.verticalCenter + anchors.topMargin: Style.space(14) + spacing: 0 + + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: dial.reading < 10 ? dial.reading.toFixed(1) : Math.round(dial.reading).toString() + color: root.bar.foreground + font.family: root.bar.fontFamily + font.pixelSize: Style.font.display + font.bold: true + } + + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: "Mbps" + color: Qt.darker(root.bar.foreground, 1.4) + font.family: root.bar.fontFamily + font.pixelSize: Style.font.caption + } + } + + // The 90° gap at the bottom of the scale is where a cluster prints its + // unit; here it names the direction. + Text { + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.bottom + text: dial.label + color: Qt.darker(root.bar.foreground, 1.3) + font.family: root.bar.fontFamily + font.pixelSize: Style.font.caption + font.bold: true + font.letterSpacing: 1.5 + } + } +} diff --git a/shell/plugins/panels/network/WifiQrPanel.qml b/shell/plugins/panels/network/WifiQrPanel.qml index 119b9225..1d4a3193 100644 --- a/shell/plugins/panels/network/WifiQrPanel.qml +++ b/shell/plugins/panels/network/WifiQrPanel.qml @@ -5,6 +5,8 @@ import Quickshell.Wayland import qs.Commons import qs.Ui +// Centered Wi-Fi share overlay, presented like the speed test: no card, +// just the QR code floating on a heavy scrim. Esc or the scrim dismiss it. PanelWindow { id: root @@ -43,9 +45,11 @@ PanelWindow { WlrLayershell.layer: WlrLayer.Overlay WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive + // Deep scrim: the floating code needs the backdrop to carry the contrast + // on any wallpaper. Rectangle { anchors.fill: parent - color: Qt.rgba(0, 0, 0, 0.45) + color: Qt.rgba(0, 0, 0, 0.78) MouseArea { anchors.fill: parent @@ -53,42 +57,49 @@ PanelWindow { } } - BorderSurface { - width: Style.space(320) - height: content.implicitHeight + Style.space(48) - anchors.centerIn: parent - radius: Style.cornerRadius - color: Color.menu.background - borderSpec: Border.surfaceSpec("menu", "border", Color.popups.border, Math.max(1, Style.space(2))) + Item { + id: keyCatcher + anchors.fill: parent + focus: true - MouseArea { anchors.fill: parent; onClicked: {} } + Keys.onEscapePressed: root.closeRequested() Item { - id: keyCatcher - anchors.fill: parent - anchors.margins: Style.space(24) - focus: true + anchors.centerIn: parent + width: content.implicitWidth + height: content.implicitHeight + // Narrow or heavily scaled outputs: shrink the whole card rather than + // clipping it at the screen edge. + scale: Math.min(1, + (keyCatcher.width - Style.space(32)) / Math.max(1, width), + (keyCatcher.height - Style.space(32)) / Math.max(1, height)) - Keys.onEscapePressed: root.closeRequested() + // Swallow clicks so only the scrim outside the content dismisses. + MouseArea { anchors.fill: parent; onClicked: {} } ColumnLayout { id: content anchors.fill: parent - spacing: Style.space(12) + spacing: Style.space(16) Text { - text: "Share " + (root.ssid || "Wi-Fi") - color: root.bar.foreground + text: (root.ssid || "Wi-Fi").toUpperCase() + color: Qt.darker(root.bar.foreground, 1.4) font.family: root.bar.fontFamily - font.pixelSize: Style.font.title + font.pixelSize: Style.font.caption font.bold: true + font.letterSpacing: 2 elide: Text.ElideRight - Layout.fillWidth: true + Layout.maximumWidth: Style.space(320) + Layout.alignment: Qt.AlignHCenter horizontalAlignment: Text.AlignHCenter } // Render every QR module as an integer-sized native rectangle. This - // stays crisp and avoids temporary images and file-cache races. + // stays crisp and avoids temporary images and file-cache races. Only + // the dark modules paint, so the white canvas can keep its rounded + // corners; the spec quiet zone baked into the matrix keeps the code + // itself clear of them. Rectangle { id: qrCanvas readonly property int moduleSize: root.qrSize > 0 @@ -99,6 +110,7 @@ PanelWindow { width: root.qrSize * moduleSize height: width color: "white" + radius: Style.cornerRadius Layout.alignment: Qt.AlignHCenter Grid { @@ -115,7 +127,7 @@ PanelWindow { width: qrCanvas.moduleSize height: qrCanvas.moduleSize - color: root.qrRows[matrixRow].charAt(matrixColumn) === "1" ? "#111111" : "white" + color: root.qrRows[matrixRow].charAt(matrixColumn) === "1" ? "#111111" : "transparent" } } } @@ -124,7 +136,9 @@ PanelWindow { Text { visible: root.loading text: "Generating QR code…" - color: root.bar.foreground + color: Qt.darker(root.bar.foreground, 1.3) + font.family: root.bar.fontFamily + font.pixelSize: Style.font.bodySmall Layout.fillWidth: true horizontalAlignment: Text.AlignHCenter } @@ -133,15 +147,20 @@ PanelWindow { visible: root.error !== "" text: root.error color: root.bar.urgent + font.family: root.bar.fontFamily + font.pixelSize: Style.font.bodySmall wrapMode: Text.Wrap Layout.fillWidth: true + Layout.maximumWidth: Style.space(320) horizontalAlignment: Text.AlignHCenter } Text { visible: root.showingQr text: "Scan to join this network" - color: root.bar.foreground + color: Qt.darker(root.bar.foreground, 1.3) + font.family: root.bar.fontFamily + font.pixelSize: Style.font.bodySmall Layout.fillWidth: true horizontalAlignment: Text.AlignHCenter } @@ -157,6 +176,7 @@ PanelWindow { font.pixelSize: Style.font.bodySmall wrapMode: Text.WrapAnywhere Layout.fillWidth: true + Layout.maximumWidth: Style.space(320) horizontalAlignment: Text.AlignHCenter MouseArea { diff --git a/shell/plugins/panels/network/manifest.json b/shell/plugins/panels/network/manifest.json index 4ea64ef6..56d84662 100644 --- a/shell/plugins/panels/network/manifest.json +++ b/shell/plugins/panels/network/manifest.json @@ -4,7 +4,7 @@ "name": "Network", "version": "1.0.0", "author": "Omarchy", - "description": "Wi-Fi list, connection state, and QR sharing", + "description": "Wi-Fi list, connection state, QR sharing, and speed test", "kinds": [ "bar-widget" ], @@ -13,7 +13,7 @@ }, "barWidget": { "displayName": "Network", - "description": "Wi-Fi list, connection state, and QR sharing", + "description": "Wi-Fi list, connection state, QR sharing, and speed test", "category": "Network", "allowMultiple": false } diff --git a/test/shell.d/network-qr-test.sh b/test/shell.d/network-qr-test.sh index a203ef27..65e49a92 100644 --- a/test/shell.d/network-qr-test.sh +++ b/test/shell.d/network-qr-test.sh @@ -10,7 +10,9 @@ mkdir -p "$tmp/bin" cat >"$tmp/bin/nmcli" <<'EOF' #!/bin/bash -if [[ $* == *GENERAL.CON-UUID* ]]; then +if [[ $* == *"DEVICE,TYPE,STATE"* ]]; then + printf 'eth0:ethernet:connected\nwlan0:wifi:connected\n' +elif [[ $* == *GENERAL.CON-UUID* ]]; then echo test-uuid else printf '%s' "$QR_NMCLI_FIELDS" @@ -30,11 +32,12 @@ chmod +x "$tmp/bin/nmcli" "$tmp/bin/qrencode" run_success_case() { local description=$1 fields=$2 expected_payload=$3 + shift 3 local expected output payload export QR_NMCLI_FIELDS=$fields export QR_PAYLOAD_FILE="$tmp/payload" - output=$(PATH="$tmp/bin:$PATH" "$ROOT/bin/omarchy-network-qr" wlan0) + output=$(PATH="$tmp/bin:$PATH" "$ROOT/bin/omarchy-network-qr" "$@") expected=$'100\n010\n001' [[ $output == "$expected" ]] || fail "$description emits a compact module matrix" "expected: $expected\nactual: $output" @@ -46,24 +49,34 @@ run_success_case() { run_success_case \ "network QR helper escapes WPA credentials through stdin" \ $'Cafe;Guest\\5G\nwpa-psk\np,a:ss;word\\42\nno\n' \ - 'WIFI:T:WPA;S:Cafe\;Guest\\5G;P:p\,a\:ss\;word\\42;;' + 'WIFI:T:WPA;S:Cafe\;Guest\\5G;P:p\,a\:ss\;word\\42;;' \ + wlan0 + +# With no interface argument the helper finds the connected Wi-Fi device. +run_success_case \ + "network QR helper detects the Wi-Fi interface" \ + $'Cafe Detected\nwpa-psk\nsecret\nno\n' \ + 'WIFI:T:WPA;S:Cafe Detected;P:secret;;' run_success_case \ "network QR helper supports open networks" \ $'Cafe Open\nnone\n\nno\n' \ - 'WIFI:T:nopass;S:Cafe Open;P:;;' + 'WIFI:T:nopass;S:Cafe Open;P:;;' \ + wlan0 run_success_case \ "network QR helper marks hidden networks" \ $'Hidden Network\nwpa-psk\nsecret\nyes\n' \ - 'WIFI:T:WPA;S:Hidden Network;P:secret;H:true;;' + 'WIFI:T:WPA;S:Hidden Network;P:secret;H:true;;' \ + wlan0 # NetworkManager models WEP as key-mgmt "none" plus a wep-key, which must not # be mistaken for an open network. run_success_case \ "network QR helper encodes WEP networks" \ $'Old Router\nnone\n\nno\nwep-secret\n' \ - 'WIFI:T:WEP;S:Old Router;P:wep-secret;;' + 'WIFI:T:WEP;S:Old Router;P:wep-secret;;' \ + wlan0 export QR_NMCLI_FIELDS=$'Enterprise\nwpa-eap\nsecret\nno\n' export QR_PAYLOAD_FILE="$tmp/enterprise-payload" diff --git a/test/shell.d/network-test.sh b/test/shell.d/network-test.sh index c9fdbfd4..a24e4e5e 100644 --- a/test/shell.d/network-test.sh +++ b/test/shell.d/network-test.sh @@ -85,7 +85,6 @@ assertDeepEqual( assertEqual(network.formatBytes(1536), '1.5 KB', 'network formats bytes') assertEqual(network.formatRate(1536), '1.5 KB/s', 'network formats rates') -assertEqual(network.formatSpeedMbps('250.4'), '250 Mbps', 'network formats speed test results') 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')