diff --git a/default/omarchy/omarchy-menu.jsonc b/default/omarchy/omarchy-menu.jsonc index 75f34ecc..af79fb48 100644 --- a/default/omarchy/omarchy-menu.jsonc +++ b/default/omarchy/omarchy-menu.jsonc @@ -118,7 +118,7 @@ "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.network.speedtest": {"icon":"󰓅","label":"Speed Test","aliases":["speedtest","speed-test"],"action":"omarchy-shell shell summon omarchy.speedtest"}, "setup.default": {"icon":"","label":"Defaults","aliases":["default","defaults"]}, "setup.default.agent": {"icon":"󰚩","label":"Agent"}, "setup.default.agent.claude": {"icon":"󰛄","label":"Claude","checked":"[[ \"$(omarchy-default-agent)\" == \"claude\" ]]","action":"omarchy-default-agent claude"}, diff --git a/shell/plugins/panels/network/Panel.qml b/shell/plugins/panels/network/Panel.qml index bf72afbb..4058eddb 100644 --- a/shell/plugins/panels/network/Panel.qml +++ b/shell/plugins/panels/network/Panel.qml @@ -17,7 +17,7 @@ Panel { manageIpc: false // Centralized close so callers can't forget to drop the passphrase prompt. - readonly property bool overlayVisible: qrVisible || speedTestModalOpen + readonly property bool overlayVisible: qrVisible // 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 @@ -26,7 +26,6 @@ Panel { function open() { if (overlayVisible) { hideWifiQr() - hideSpeedTest() return } root.controller.show() @@ -35,10 +34,9 @@ Panel { 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. + // The centered card outlives the compact panel, but the widget's + // canonical close must not leave an overlay behind. hideWifiQr() - hideSpeedTest() } function cancelPasswordPrompt() { @@ -101,15 +99,6 @@ Panel { property string bandSelected: "auto" property var bandAvailable: [] property string pendingBand: "" - property bool speedTestRunning: false - property bool speedTestModalOpen: false - property bool speedTestExpectedStop: false - property bool pendingSpeedRun: false - property string speedTestPhase: "" - property string speedTestStderr: "" - property string speedTestDownloadMbps: "" - property string speedTestUploadMbps: "" - property string speedTestError: "" // Per-row in-flight state. `actionSsid` flips on for the row whose action // is currently running so it can render "Connecting…" / "Disconnecting…" / @@ -251,15 +240,12 @@ Panel { root.refresh() root.showWifiQr(true) } - function speedTest() { - root.refresh() - root.showSpeedTest() - } + function speedTest() { root.summonSpeedTest() } } function activateHeader() { if (headerIndex === qrHeaderIndex) showWifiQr() - else if (headerIndex === speedHeaderIndex) showSpeedTest() + else if (headerIndex === speedHeaderIndex) summonSpeedTest() else if (headerIndex === toggleHeaderIndex) toggleNetwork() } @@ -691,83 +677,17 @@ Panel { actionProc.running = true } - function updateSpeedTestLine(line) { - var value = parseFloat(line) - if (!isFinite(value) || value < 0) return - - if (speedTestPhase === "down") speedTestDownloadMbps = String(value) - else if (speedTestPhase === "up") speedTestUploadMbps = String(value) - 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) { - // 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 = "" - speedTestDownloadMbps = "" - speedTestUploadMbps = "" - speedTestRunning = true - startSpeedTestPhase("down") - } - - function startSpeedTestPhase(phase) { - speedTestExpectedStop = false - speedTestPhase = phase - speedTestStderr = "" - speedTestProc.command = ["omarchy-network-speedtest", phase] - speedTestProc.running = true - speedTestPhaseTimer.restart() - } - - function stopSpeedTestPhase() { - speedTestPhaseTimer.stop() - if (speedTestProc.running) { - speedTestExpectedStop = true - speedTestProc.running = false - return - } - finishSpeedTestPhase() - } - - function finishSpeedTestPhase() { - if (speedTestPhase === "down") { - startSpeedTestPhase("up") - return - } - - speedTestPhase = "" - speedTestRunning = false - speedTestExpectedStop = false + // The speed test is its own panel plugin (omarchy.speedtest) so a + // replacement design can take it over; summon() routes to whichever + // implementation is enabled. The payload names the connection when this + // panel knows it; the plugin looks it up itself otherwise. + function summonSpeedTest() { + controller.hide() + cancelPasswordPrompt() + var connection = "" + if (info.type === "wifi") connection = info.ssid || "Wi-Fi" + else if (info.type === "ethernet") connection = "Ethernet" + bar.shell.summon("omarchy.speedtest", connection ? JSON.stringify({ connection: connection }) : "{}") } function dnsCommand(provider) { @@ -1017,42 +937,6 @@ Panel { } } - Process { - id: speedTestProc - stdout: SplitParser { onRead: function(line) { root.updateSpeedTestLine(line) } } - stderr: StdioCollector { - waitForEnd: true - onStreamFinished: root.speedTestStderr = String(text || "").trim() - } - 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 = "" - root.speedTestRunning = false - return - } - - root.speedTestExpectedStop = false - root.finishSpeedTestPhase() - } - } - - Timer { - id: speedTestPhaseTimer - interval: 5000 - repeat: false - onTriggered: root.stopSpeedTestPhase() - } - // Action runner for DNS provider changes. Wi-Fi actions use the // Quickshell.Networking NetworkManager backend directly. Process { @@ -1316,7 +1200,7 @@ Panel { hasCursor: root.speedHeaderHasCursor Layout.alignment: Qt.AlignVCenter onHovered: function(on) { if (on) root.setHeaderCursor(root.speedHeaderIndex) } - onClicked: root.showSpeedTest() + onClicked: root.summonSpeedTest() } ToggleSwitch { @@ -1720,24 +1604,6 @@ 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/manifest.json b/shell/plugins/panels/network/manifest.json index 56d84662..4ea64ef6 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, QR sharing, and speed test", + "description": "Wi-Fi list, connection state, and QR sharing", "kinds": [ "bar-widget" ], @@ -13,7 +13,7 @@ }, "barWidget": { "displayName": "Network", - "description": "Wi-Fi list, connection state, QR sharing, and speed test", + "description": "Wi-Fi list, connection state, and QR sharing", "category": "Network", "allowMultiple": false } diff --git a/shell/plugins/panels/network/SpeedTestPanel.qml b/shell/plugins/panels/speedtest/Panel.qml similarity index 51% rename from shell/plugins/panels/network/SpeedTestPanel.qml rename to shell/plugins/panels/speedtest/Panel.qml index 76307ead..ada5d809 100644 --- a/shell/plugins/panels/network/SpeedTestPanel.qml +++ b/shell/plugins/panels/speedtest/Panel.qml @@ -2,6 +2,7 @@ import QtQuick import QtQuick.Layouts import QtQuick.Shapes import Quickshell +import Quickshell.Io import Quickshell.Wayland import qs.Commons import qs.Ui @@ -12,160 +13,307 @@ import qs.Ui // 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 { +// +// Standalone panel plugin: summoning it starts a fresh run, dismissing it +// stops the traffic, so the download workers never keep saturating the link +// behind a closed overlay. The payload may carry the connection's display +// name -- {"connection": "MyWifi"} -- and the panel looks it up itself via +// omarchy-network-status when the caller doesn't know it. +Item { 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 + property string omarchyPath: Quickshell.env("OMARCHY_PATH") + property var shell: null + property var manifest: null - signal closeRequested() - signal runAgainRequested() + property bool opened: false + property string connectionName: "" + + property bool running: false + property bool expectedStop: false + property bool pendingRun: false + property string phase: "" // "down" | "up" | "" + property string stderrText: "" + property string downloadMbps: "" + property string uploadMbps: "" + property string error: "" 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) // The scrim below is a fixed near-black regardless of theme, so text and - // ticks on it need a fixed light palette, not the themed bar.foreground. + // ticks on it need a fixed light palette, not the themed foreground. readonly property color onScrim: "white" readonly property color onScrimDim: Qt.rgba(1, 1, 1, 0.55) readonly property color onScrimUrgent: "#ff6b6b" + readonly property string fontFamily: Style.font.family 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 + function open(payloadJson) { + var payload = {} + try { payload = JSON.parse(payloadJson || "{}") || {} } catch (e) {} + if (payload.connection !== undefined) root.connectionName = String(payload.connection) + else refreshConnectionName() + root.opened = true + runSpeedTest() + // The window is instantiated hidden, so re-acquire focus after mapping + // and fire the ignition sweep once the surface is actually on screen. + Qt.callLater(function() { + if (!root.opened) 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() + function close() { + root.opened = false + root.pendingRun = false + phaseTimer.stop() + // Clear the phase before killing the process: onExited advances to the + // upload phase when it still reads "down". + root.phase = "" + root.running = false + if (speedTestProc.running) { + root.expectedStop = true + speedTestProc.running = false } } - Item { - id: keyCatcher - anchors.fill: parent - focus: true + function dismiss() { + if (root.shell && typeof root.shell.hide === "function") + root.shell.hide((root.manifest && root.manifest.id) || "omarchy.speedtest") + else close() + } - Keys.onEscapePressed: root.closeRequested() - Keys.onReturnPressed: if (!root.running) root.runAgainRequested() - Keys.onEnterPressed: if (!root.running) root.runAgainRequested() + function refreshConnectionName() { + root.connectionName = "" + statusProc.running = false + statusProc.running = true + } + + function updateSpeedTestLine(line) { + var value = parseFloat(line) + if (!isFinite(value) || value < 0) return + + if (phase === "down") downloadMbps = String(value) + else if (phase === "up") uploadMbps = String(value) + error = "" + } + + function runSpeedTest() { + 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 (expectedStop) pendingRun = true + return + } + error = "" + downloadMbps = "" + uploadMbps = "" + running = true + startPhase("down") + } + + function startPhase(nextPhase) { + expectedStop = false + phase = nextPhase + stderrText = "" + speedTestProc.command = ["omarchy-network-speedtest", nextPhase] + speedTestProc.running = true + phaseTimer.restart() + } + + function stopPhase() { + phaseTimer.stop() + if (speedTestProc.running) { + expectedStop = true + speedTestProc.running = false + return + } + finishPhase() + } + + function finishPhase() { + if (phase === "down") { + startPhase("up") + return + } + + phase = "" + running = false + expectedStop = false + } + + Process { + id: speedTestProc + stdout: SplitParser { onRead: function(line) { root.updateSpeedTestLine(line) } } + stderr: StdioCollector { + waitForEnd: true + onStreamFinished: root.stderrText = String(text || "").trim() + } + onExited: function(exitCode) { + phaseTimer.stop() + + if (root.pendingRun) { + root.pendingRun = false + root.expectedStop = false + if (root.opened) Qt.callLater(root.runSpeedTest) + return + } + + if (!root.expectedStop && exitCode !== 0) { + root.error = root.stderrText || "Speed test failed" + root.phase = "" + root.running = false + return + } + + root.expectedStop = false + root.finishPhase() + } + } + + Timer { + id: phaseTimer + interval: 5000 + repeat: false + onTriggered: root.stopPhase() + } + + // Names the connection under test when the summoner didn't. First tab + // field is the kind, second the SSID (wifi) or device (ethernet). + Process { + id: statusProc + command: ["omarchy-network-status"] + stdout: StdioCollector { + waitForEnd: true + onStreamFinished: { + var fields = String(text || "").trim().split("\t") + if (fields[0] === "wifi") root.connectionName = fields[1] || "Wi-Fi" + else if (fields[0] === "ethernet") root.connectionName = "Ethernet" + } + } + } + + PanelWindow { + visible: root.opened + 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.dismiss() + } + } 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)) + id: keyCatcher + anchors.fill: parent + focus: true - // Swallow clicks so only the scrim outside the cluster dismisses. - MouseArea { anchors.fill: parent; onClicked: {} } + Keys.onEscapePressed: root.dismiss() + Keys.onReturnPressed: if (!root.running) root.runSpeedTest() + Keys.onEnterPressed: if (!root.running) root.runSpeedTest() - ColumnLayout { - id: content - anchors.fill: parent - spacing: Style.space(16) + 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)) - Text { - visible: root.connectionName !== "" - text: root.connectionName.toUpperCase() - color: root.onScrimDim - font.family: root.bar.fontFamily - font.pixelSize: Style.font.caption - font.bold: true - font.letterSpacing: 2 - Layout.fillWidth: true - horizontalAlignment: Text.AlignHCenter - } + // Swallow clicks so only the scrim outside the cluster dismisses. + MouseArea { anchors.fill: parent; onClicked: {} } - Row { - spacing: Style.space(48) - Layout.alignment: Qt.AlignHCenter + ColumnLayout { + id: content + anchors.fill: parent + spacing: Style.space(16) - SpeedDial { - id: downDial - label: "DOWNLOAD" - value: root.downloadValue - live: root.running && root.phase === "down" + Text { + visible: root.connectionName !== "" + text: root.connectionName.toUpperCase() + color: root.onScrimDim + font.family: root.fontFamily + font.pixelSize: Style.font.caption + font.bold: true + font.letterSpacing: 2 + Layout.fillWidth: true + horizontalAlignment: Text.AlignHCenter } - SpeedDial { - id: upDial - label: "UPLOAD" - value: root.uploadValue - live: root.running && root.phase === "up" + 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.onScrim - fontFamily: root.bar.fontFamily - fontSize: Style.font.bodySmall - horizontalPadding: Style.space(14) - verticalPadding: Style.space(4) - Layout.alignment: Qt.AlignHCenter - onClicked: root.runAgainRequested() + // 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.onScrim + fontFamily: root.fontFamily + fontSize: Style.font.bodySmall + horizontalPadding: Style.space(14) + verticalPadding: Style.space(4) + Layout.alignment: Qt.AlignHCenter + onClicked: root.runSpeedTest() - Behavior on opacity { - NumberAnimation { duration: 240; easing.type: Easing.OutCubic } + Behavior on opacity { + NumberAnimation { duration: 240; easing.type: Easing.OutCubic } + } } - } - Text { - visible: root.failed - text: root.error - color: root.onScrimUrgent - font.family: root.bar.fontFamily - font.pixelSize: Style.font.bodySmall - wrapMode: Text.Wrap - Layout.fillWidth: true - Layout.maximumWidth: Style.space(440) - horizontalAlignment: Text.AlignHCenter + Text { + visible: root.failed + text: root.error + color: root.onScrimUrgent + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + wrapMode: Text.Wrap + Layout.fillWidth: true + Layout.maximumWidth: Style.space(440) + horizontalAlignment: Text.AlignHCenter + } } } } @@ -368,7 +516,7 @@ PanelWindow { anchors.horizontalCenter: parent.horizontalCenter text: dial.reading < 10 ? dial.reading.toFixed(1) : Math.round(dial.reading).toString() color: root.onScrim - font.family: root.bar.fontFamily + font.family: root.fontFamily font.pixelSize: Style.font.display font.bold: true } @@ -377,7 +525,7 @@ PanelWindow { anchors.horizontalCenter: parent.horizontalCenter text: "Mbps" color: root.onScrimDim - font.family: root.bar.fontFamily + font.family: root.fontFamily font.pixelSize: Style.font.caption } } @@ -389,7 +537,7 @@ PanelWindow { anchors.bottom: parent.bottom text: dial.label color: root.onScrimDim - font.family: root.bar.fontFamily + font.family: root.fontFamily font.pixelSize: Style.font.caption font.bold: true font.letterSpacing: 1.5 diff --git a/shell/plugins/panels/speedtest/manifest.json b/shell/plugins/panels/speedtest/manifest.json new file mode 100644 index 00000000..05898d45 --- /dev/null +++ b/shell/plugins/panels/speedtest/manifest.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": 1, + "id": "omarchy.speedtest", + "name": "Speed Test", + "version": "1.0.0", + "author": "Omarchy", + "description": "Internet speed test with download and upload dials", + "kinds": [ + "panel" + ], + "entryPoints": { + "panel": "Panel.qml" + } +}