From 1f8819318d3bbe5afa30a6ee46e22f49eaeac3b1 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sat, 1 Aug 2026 19:32:30 -0700 Subject: [PATCH 1/9] Switch to Omacalc --- default/hypr/apps/system.lua | 2 +- default/hypr/bindings/utilities.lua | 2 +- install/omarchy-base.packages | 2 +- migrations/1785637426.sh | 4 ++++ 4 files changed, 7 insertions(+), 3 deletions(-) create mode 100644 migrations/1785637426.sh diff --git a/default/hypr/apps/system.lua b/default/hypr/apps/system.lua index a8c9b250..98d2d13b 100644 --- a/default/hypr/apps/system.lua +++ b/default/hypr/apps/system.lua @@ -19,7 +19,7 @@ o.window({ }, { tag = "+floating-window" }) o.window("dev.tensaku.Tensaku", { float = true }) o.window("dev.tensaku.Tensaku", { center = true }) -o.window("org.gnome.Calculator", { float = true }) +o.window("omacalc", { float = true }) -- Fullscreen screensaver. o.window("org.omarchy.screensaver", { fullscreen = true }) diff --git a/default/hypr/bindings/utilities.lua b/default/hypr/bindings/utilities.lua index 1bf194dc..a993a8b3 100644 --- a/default/hypr/bindings/utilities.lua +++ b/default/hypr/bindings/utilities.lua @@ -8,7 +8,7 @@ o.bind("SUPER + ESCAPE", "System menu", "omarchy-menu toggle system") o.bind("XF86PowerOff", "Power menu", "omarchy-menu toggle system", { locked = true }) o.bind("SUPER + K", "Show key bindings", "omarchy-menu-keybindings") o.bind("SUPER + ALT + K", "Show Tmux key bindings", "omarchy-menu-tmux-keybindings") -o.bind("XF86Calculator", "Calculator", "gnome-calculator") +o.bind("XF86Calculator", "Calculator", "omacalc") o.bind_toggle("SUPER + SHIFT + SPACE", "Toggle top bar", "bar") o.bind("SUPER + CTRL + SPACE", "Background switcher", "omarchy-menu toggle background") diff --git a/install/omarchy-base.packages b/install/omarchy-base.packages index 96d44d7b..fccce139 100644 --- a/install/omarchy-base.packages +++ b/install/omarchy-base.packages @@ -42,7 +42,6 @@ fontconfig foot fzf git -gnome-calculator gnome-keyring gnome-themes-extra grim @@ -91,6 +90,7 @@ nss-mdns nvim obs-studio obsidian +omacalc omacut omawrite omarchy-nvim diff --git a/migrations/1785637426.sh b/migrations/1785637426.sh new file mode 100644 index 00000000..e4e05e73 --- /dev/null +++ b/migrations/1785637426.sh @@ -0,0 +1,4 @@ +echo "Replace GNOME Calculator with Omacalc" + +omarchy-pkg-add omacalc +omarchy-pkg-drop gnome-calculator From 9ca5f63f86bd9b7238e29607e99811e1feb5a60f Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sat, 1 Aug 2026 21:53:01 -0500 Subject: [PATCH 2/9] Speed up mouse scrolling in foot Co-Authored-By: Claude Fable 5 --- config/foot/foot.ini | 1 + migrations/1785633225.sh | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+) create mode 100644 migrations/1785633225.sh diff --git a/config/foot/foot.ini b/config/foot/foot.ini index f21f4ecd..543e4a0b 100644 --- a/config/foot/foot.ini +++ b/config/foot/foot.ini @@ -8,6 +8,7 @@ workers=0 [scrollback] lines=10000 +multiplier=7.0 [cursor] style=block diff --git a/migrations/1785633225.sh b/migrations/1785633225.sh new file mode 100644 index 00000000..22eae3af --- /dev/null +++ b/migrations/1785633225.sh @@ -0,0 +1,20 @@ +echo "Speed up mouse scrolling in foot" + +foot_config="$HOME/.config/foot/foot.ini" + +if [[ -f $foot_config ]] && ! grep -q '^multiplier=' "$foot_config"; then + if grep -qxF '[scrollback]' "$foot_config"; then + tmp=$(mktemp) + awk ' + { print } + !inserted && $0 == "[scrollback]" { + print "multiplier=7.0" + inserted = 1 + } + ' "$foot_config" >"$tmp" + cat "$tmp" >"$foot_config" + rm -f "$tmp" + else + printf '\n[scrollback]\nmultiplier=7.0\n' >>"$foot_config" + fi +fi From abe5b1ff9f65f99a053e04a3db3f81d50f6cd000 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sat, 1 Aug 2026 22:12:01 -0500 Subject: [PATCH 3/9] Silence jq noise when the reload guard probes dead Hyprland instances hyprctl prints "Couldn't connect ..." on stdout for stale instance dirs left in /run/user/*/hypr/, so jq's parse error leaked into pacman's pre-transaction hook output. The dead instances were already skipped correctly; only the stderr noise escaped. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Ls3ump7hcv4oNnjWW5AXmn --- bin/omarchy-hyprland-reload-guard | 4 +++- test/shell.d/hyprland-reload-guard-test.sh | 14 ++++++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/bin/omarchy-hyprland-reload-guard b/bin/omarchy-hyprland-reload-guard index 218da79e..c0b5868c 100755 --- a/bin/omarchy-hyprland-reload-guard +++ b/bin/omarchy-hyprland-reload-guard @@ -31,7 +31,9 @@ option_bool() { local signature="$2" local option="$3" - hyprctl_instance "$runtime_dir" "$signature" -j getoption "$option" 2>/dev/null | jq -r '.bool' + # A dead instance makes hyprctl print "Couldn't connect ..." on stdout, so + # silence jq too and let the failed pipeline skip the instance. + hyprctl_instance "$runtime_dir" "$signature" -j getoption "$option" 2>/dev/null | jq -r '.bool' 2>/dev/null } instances() { diff --git a/test/shell.d/hyprland-reload-guard-test.sh b/test/shell.d/hyprland-reload-guard-test.sh index 6d8ccb12..05ab23ee 100755 --- a/test/shell.d/hyprland-reload-guard-test.sh +++ b/test/shell.d/hyprland-reload-guard-test.sh @@ -14,7 +14,9 @@ fake_hyprctl="$test_tmp/hyprctl" signature="test-signature" runtime_dir="$run_root/1000" -mkdir -p "$runtime_dir/hypr/$signature" +dead_signature="dead-signature" + +mkdir -p "$runtime_dir/hypr/$signature" "$runtime_dir/hypr/$dead_signature" cat >"$fake_hyprctl" <<'BASH' #!/bin/bash @@ -22,6 +24,10 @@ cat >"$fake_hyprctl" <<'BASH' printf '%s\t%s\n' "$XDG_RUNTIME_DIR" "$*" >>"$FAKE_HYPRCTL_LOG" case "$*" in + *'--instance dead-signature '*) + printf "Couldn't connect to %s/hypr/dead-signature/.socket.sock. (4)\n" "$XDG_RUNTIME_DIR" + exit 4 + ;; *'getoption misc.disable_autoreload'*) printf '{"option":"misc.disable_autoreload","bool":%s,"set":true}\n' "${FAKE_DISABLE_AUTORELOAD:-false}" ;; @@ -39,7 +45,7 @@ FAKE_HYPRCTL_LOG="$hyprctl_log" \ HYPRCTL="$fake_hyprctl" \ OMARCHY_HYPRLAND_RELOAD_GUARD_RUN_ROOT="$run_root" \ OMARCHY_HYPRLAND_RELOAD_GUARD_STATE_DIR="$state_dir" \ - "$ROOT/bin/omarchy-hyprland-reload-guard" pause + "$ROOT/bin/omarchy-hyprland-reload-guard" pause 2>"$test_tmp/pause-stderr" state_file="$state_dir/$signature" [[ -f $state_file ]] || fail "reload guard stores Hyprland state on pause" @@ -48,6 +54,10 @@ grep -Fx "$expected_state" "$state_file" >/dev/null || fail "reload guard record grep -F 'hl.config({ misc = { disable_autoreload = true }, debug = { suppress_errors = true } })' "$hyprctl_log" >/dev/null || fail "reload guard pauses autoreload with hyprctl eval" pass "reload guard pauses live Hyprland reloads" +[[ ! -e $state_dir/$dead_signature ]] || fail "reload guard skips instances hyprctl cannot reach" +[[ ! -s $test_tmp/pause-stderr ]] || fail "reload guard pauses dead Hyprland instances quietly" "$(cat "$test_tmp/pause-stderr")" +pass "reload guard skips dead Hyprland instances quietly" + : >"$hyprctl_log" FAKE_HYPRCTL_LOG="$hyprctl_log" \ HYPRCTL="$fake_hyprctl" \ From c2889be493d1eee71dd7fb4d50901cd451de39c8 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sat, 1 Aug 2026 22:15:46 -0500 Subject: [PATCH 4/9] Add Setup > Network menu with DNS, QR Code, and Speed Test (#6499) * Make omarchy-network-qr detect the connected Wi-Fi interface The interface argument is now optional so IPC and menu callers can summon the QR card without knowing the device name. Co-Authored-By: Claude Fable 5 * Move the speed test into a modal card with cluster dials The network panel's Run button and the new omarchy.network speedTest IPC route open a centered card where download and upload dials sweep on open and track the live readings, Tucson style. Dismissing the card stops the traffic workers. The QR card gains a showQr IPC route. Co-Authored-By: Claude Fable 5 * Add Setup > Network menu with DNS, QR Code, and Speed Test DNS switches providers through omarchy-dns with the current choice checked. QR Code only shows while connected over Wi-Fi. Co-Authored-By: Claude Fable 5 * Hold the speed test card steady and add a corner dismiss The Run Again button now fades instead of unmounting so the card keeps its size across runs, and a small X in the corner closes the card alongside Esc and the scrim. Co-Authored-By: Claude Fable 5 * Move the speed test action into the network panel hero A speedometer icon beside the QR share replaces the dedicated inline section, and the keyboard chain loses its speed stop accordingly. Co-Authored-By: Claude Fable 5 * Float the speed test cluster on the scrim Drop the bordered card and the pulsing halo: like the Tucson's floating cluster, the dials now sit directly on a near-black scrim with a soft under-glow along the value arc, fainter ticks, and hubless needles that fade toward the pivot. Co-Authored-By: Claude Fable 5 * Center the retry button between the dials The measuring status lines and the corner dismiss go away; the retry button moves into the gap between the two dials like a cluster's center display, anchored out of the column flow so nothing ever shifts. The fast.com attribution lives on as its tooltip, and only errors still print below the cluster. Co-Authored-By: Claude Fable 5 * Put the retry button back beneath the dial pair The dials close ranks again and the retry button returns below them, centered on the pair and still fading in place so nothing shifts. Co-Authored-By: Claude Fable 5 * Harden the network IPC routes against stale panel state The QR menu route forces interface self-detection instead of trusting details that stop refreshing while the panel is closed, and the widget's canonical close now tears down the centered cards and their traffic instead of only hiding the compact panel. Co-Authored-By: Claude Fable 5 * Re-range the speed dials for every run The scale latched upward forever, so one unusually fast run would compress every later one for the lifetime of the shell process. Each dial now returns to the base scale when its measurement starts. Co-Authored-By: Claude Fable 5 * Float the Wi-Fi QR share like the speed test Same presentation as the dials: no bordered card, just the code on a heavy scrim. Only the dark modules paint now, so the white canvas can round its corners while the spec quiet zone keeps the code clear. Co-Authored-By: Claude Fable 5 * Pick the default-route device and pin the locale in QR detection nmcli localizes state names, so the detection fallback pins LC_ALL=C and accepts states like "connected (externally)". Detection now prefers the default-route device, matching the connection the panel and the menu's visibility gate describe when several Wi-Fi adapters are up. Co-Authored-By: Claude Fable 5 * Make every network summon path overlay-aware Opening the widget while a centered card is up now dismisses the card instead of raising the compact panel behind an exclusive overlay -- the shadowed open() covers the keybind toggle, the bar icon, and IPC. Re-summoning a card while its process is still tearing down queues the fresh request for onExited instead of dropping it, and the speed test route refreshes connection details like the QR route does. Co-Authored-By: Claude Fable 5 * Shrink the overlays to fit narrow outputs The dial cluster and the QR card scale down instead of clipping when a portrait or heavily scaled display is narrower than their natural size. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- bin/omarchy-network-qr | 18 +- default/omarchy/omarchy-menu.jsonc | 8 + shell/plugins/panels/network/Model.js | 7 - shell/plugins/panels/network/Panel.qml | 254 +++++++----- .../plugins/panels/network/SpeedTestPanel.qml | 392 ++++++++++++++++++ shell/plugins/panels/network/WifiQrPanel.qml | 66 ++- shell/plugins/panels/network/manifest.json | 4 +- test/shell.d/network-qr-test.sh | 25 +- test/shell.d/network-test.sh | 1 - 9 files changed, 622 insertions(+), 153 deletions(-) create mode 100644 shell/plugins/panels/network/SpeedTestPanel.qml 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') From 12af188304793b65551b5c43d20f02961dc938a9 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sat, 1 Aug 2026 22:31:12 -0500 Subject: [PATCH 5/9] We don't need rust any longer Was only needed for before ruby precompiles via mise --- install/omarchy-base.packages | 1 - 1 file changed, 1 deletion(-) diff --git a/install/omarchy-base.packages b/install/omarchy-base.packages index fccce139..9a1863ec 100644 --- a/install/omarchy-base.packages +++ b/install/omarchy-base.packages @@ -109,7 +109,6 @@ qrencode quickshell-git ripgrep ruby -rust tensaku sddm slurp From 7e9cc153dbf9afa866122ec81b583f385c2b09c6 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 3 Aug 2026 15:40:22 -0500 Subject: [PATCH 6/9] Retry failed weather fetches so the bar icon can't stay stuck The Open-Meteo fetch is the only thing that updates the bar icon when a location is configured, but a failed response was dropped silently with no retry, leaving a stale icon until the next refresh tick. Give it the same short retry loop the wttr fetch already has, and reset both retry budgets on each full refresh cycle so an exhausted round (e.g. waking before the network is back) doesn't starve retries for the session. Co-Authored-By: Claude Fable 5 --- shell/plugins/panels/weather/Panel.qml | 32 ++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/shell/plugins/panels/weather/Panel.qml b/shell/plugins/panels/weather/Panel.qml index 3757d942..2053d88b 100644 --- a/shell/plugins/panels/weather/Panel.qml +++ b/shell/plugins/panels/weather/Panel.qml @@ -87,6 +87,7 @@ Panel { onLocationQueryChanged: { if (savingLocation) savingLocationQueryStarted = true forecastRetries = 0 + dailyForecastRetries = 0 forecastProc.running = false dailyForecastProc.running = false Qt.callLater(refresh) @@ -112,6 +113,7 @@ Panel { } property int forecastRetries: 0 + property int dailyForecastRetries: 0 // Click-to-edit state for the location label. property bool editingLocation: false @@ -148,6 +150,11 @@ Panel { readonly property string reportHumidity: current ? (current.humidity + "%") : "" function refresh() { + // Each full refresh cycle gets a fresh retry budget, so an earlier + // exhausted round (e.g. waking with the network still down) doesn't + // starve retries for the rest of the session. + forecastRetries = 0 + dailyForecastRetries = 0 if (!forecastProc.running) forecastProc.running = true if (root.locationQuery === "" && !locationProc.running) locationProc.running = true // With stored coordinates this fetches open-meteo right away — no need @@ -368,22 +375,42 @@ Panel { onTriggered: if (!forecastProc.running) forecastProc.running = true } + // With configured coordinates this fetch is the only thing that updates the + // bar icon, so a dropped response (e.g. waking before the network is back) + // must retry rather than wait out the refresh timer with a stale icon. + function scheduleDailyForecastRetry() { + if (dailyForecastRetries >= 3) return + dailyForecastRetries++ + dailyForecastRetryTimer.restart() + } + + Timer { + id: dailyForecastRetryTimer + interval: 2500 + onTriggered: root.refreshDailyForecast(null) + } + Process { id: dailyForecastProc stdout: StdioCollector { waitForEnd: true onStreamFinished: { var raw = String(text || "").trim() - if (!raw) return + if (!raw) { + root.scheduleDailyForecastRetry() + return + } try { var parsed = JSON.parse(raw) var parsedCurrent = Model.openMeteoCurrentCondition(parsed) root.dailyForecastReport = parsed root.label = Model.currentIcon(parsedCurrent, root.label) + root.dailyForecastRetries = 0 if (Model.weatherResponseCompletesSave(root.hasConfiguredCoordinates, "open-meteo")) root.finishSavingLocation() } catch (e) { - // Keep last-good daily forecast on parse failure. + // Keep last-good daily forecast visible, but try again shortly. + root.scheduleDailyForecastRetry() } } } @@ -418,6 +445,7 @@ Panel { if (!root.savingLocationQueryStarted) { root.savingLocationQueryStarted = true root.forecastRetries = 0 + root.dailyForecastRetries = 0 forecastProc.running = false dailyForecastProc.running = false Qt.callLater(root.refresh) From 929eca317da40e3fa54ddde79280849570c7085d Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 3 Aug 2026 16:24:14 -0500 Subject: [PATCH 7/9] Add a Super + Shift + = hotkey for calculator --- default/hypr/bindings/utilities.lua | 1 + 1 file changed, 1 insertion(+) diff --git a/default/hypr/bindings/utilities.lua b/default/hypr/bindings/utilities.lua index a993a8b3..6be42a7a 100644 --- a/default/hypr/bindings/utilities.lua +++ b/default/hypr/bindings/utilities.lua @@ -9,6 +9,7 @@ o.bind("XF86PowerOff", "Power menu", "omarchy-menu toggle system", { locked = tr o.bind("SUPER + K", "Show key bindings", "omarchy-menu-keybindings") o.bind("SUPER + ALT + K", "Show Tmux key bindings", "omarchy-menu-tmux-keybindings") o.bind("XF86Calculator", "Calculator", "omacalc") +o.bind("SUPER + SHIFT + EQUAL", "Calculator", "omacalc") o.bind_toggle("SUPER + SHIFT + SPACE", "Toggle top bar", "bar") o.bind("SUPER + CTRL + SPACE", "Background switcher", "omarchy-menu toggle background") From 72ffd58316265bb770dddfc77983117bf9b91f0a Mon Sep 17 00:00:00 2001 From: Scott Jones Date: Mon, 3 Aug 2026 17:29:14 -0400 Subject: [PATCH 8/9] Offer AM/PM clock formats when right-clicking the bar clock (#6536) Every preset in the right-click ring was 24-hour, so a 12-hour label was something you had to hand-write into shell.json. Pair each locale-shaped time preset with its AM/PM twin, and give vertical bars one stacked variant. The ISO preset keeps its 24-hour clock, since ISO 8601 writes time that way. Co-authored-by: Claude Opus 5 (1M context) --- shell/plugins/panels/clock/Model.js | 13 ++++++++++++- test/shell.d/clock-test.sh | 27 ++++++++++++++++++++++++++- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/shell/plugins/panels/clock/Model.js b/shell/plugins/panels/clock/Model.js index 8d42f7fc..9607a4a6 100644 --- a/shell/plugins/panels/clock/Model.js +++ b/shell/plugins/panels/clock/Model.js @@ -12,18 +12,29 @@ var WEEKDAY_NAMES = ["sunday", "monday", "tuesday", "wednesday", "thursday", "fr // ---- Bar label formats. Right-clicking the clock walks these in order and // writes the result back to shell.json, so the label the bar shows and // the format the config stores are always the same thing. +// +// The locale-shaped time presets are each followed by their 12-hour twin, so +// the walk from a 24-hour label to the same label in AM/PM is a single right +// click rather than a lap of the ring. The ISO preset is deliberately left +// without one: ISO 8601 writes time on a 24-hour clock, so an AM/PM variant +// would contradict the only thing that format is for. var CLOCK_FORMATS = [ "dddd HH:mm", + "dddd h:mm AP", "HH:mm", + "h:mm AP", "ddd d MMM HH:mm", + "ddd d MMM h:mm AP", "d MMMM 'W'ww yyyy", "yyyy-MM-dd HH:mm" ] // Vertical bars have room for a few stacked lines and nothing else, so the -// ring stays short. +// ring stays short. AM/PM costs a fourth line, which is why only the plain +// time carries it here. var VERTICAL_CLOCK_FORMATS = [ "HH\n—\nmm", + "h\n—\nmm\nAP", "dd\nMMM\n'W'ww\n''yy", "HH\nmm" ] diff --git a/test/shell.d/clock-test.sh b/test/shell.d/clock-test.sh index 6ed9cd6f..582f7736 100755 --- a/test/shell.d/clock-test.sh +++ b/test/shell.d/clock-test.sh @@ -138,7 +138,32 @@ assertDeepEqual(calendar.clockFormatRing('', '', []), ['HH:mm'], 'clock keeps a assertEqual(calendar.nextClockFormat(ring, ring[0]), ring[1], 'clock steps to the next format') assertEqual(calendar.nextClockFormat(ring, ring[ring.length - 1]), ring[0], 'clock wraps the format ring') assertEqual(calendar.nextClockFormat(ring, 'HH:mm:ss'), ring[0], 'clock starts at the top from a format outside the ring') -assertEqual(calendar.clockFormats(true)[0], 'HH\n\u2014\nmm', 'clock keeps stacked formats for vertical bars') +// Both rings, contents and order: a right click walks this list and writes +// the result back to shell.json, so an inserted preset should have to be +// acknowledged here. +assertDeepEqual( + calendar.clockFormats(false), + [ + 'dddd HH:mm', 'dddd h:mm AP', + 'HH:mm', 'h:mm AP', + 'ddd d MMM HH:mm', 'ddd d MMM h:mm AP', + "d MMMM 'W'ww yyyy", + // No twin: ISO 8601 writes time on a 24-hour clock, so an AM/PM variant + // would contradict the one thing that format is for. + 'yyyy-MM-dd HH:mm' + ], + 'clock offers the horizontal presets in this order' +) +assertDeepEqual( + calendar.clockFormats(true), + ['HH\n\u2014\nmm', 'h\n\u2014\nmm\nAP', "dd\nMMM\n'W'ww\n''yy", 'HH\nmm'], + 'clock offers the stacked presets in this order' +) +assertEqual( + calendar.nextClockFormat(ring, 'dddd HH:mm'), + 'dddd h:mm AP', + 'clock reaches an AM/PM twin in one right click' +) assertEqual(calendar.isoWeekLiteral(2026, 0, 5), '02', 'clock zero-pads the ISO week token') // ---- widget wiring From c992cdff100e765ad05dc6bd435eaf137d73fa16 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 3 Aug 2026 16:52:02 -0500 Subject: [PATCH 9/9] Restart the shell unconditionally after every update Updates routinely replace the shell's QML, and a stale process can lazy-load new files into old code. Restarting at the end of every omarchy update removes the need for migrations to restart the shell or defer one with the restart-shell-required marker: the login-time migration path already runs a fresh shell that hot-reloads shell.json. Co-Authored-By: Claude Fable 5 --- bin/omarchy-update-restart | 6 ++++++ docs/migrations.md | 3 +++ docs/update-process.md | 4 ++-- migrations/1784672586.sh | 1 - migrations/1784989000.sh | 2 -- migrations/1785189600.sh | 4 ---- migrations/1785344985.sh | 2 -- test/shell.d/tmux-alert-removal-migration-test.sh | 15 +++++---------- 8 files changed, 16 insertions(+), 21 deletions(-) diff --git a/bin/omarchy-update-restart b/bin/omarchy-update-restart index 763d6b65..a2263b16 100755 --- a/bin/omarchy-update-restart +++ b/bin/omarchy-update-restart @@ -42,3 +42,9 @@ for file in "$HOME"/.local/state/omarchy/restart-*-required; do omarchy-restart-"$service" fi done + +# Updates routinely replace the shell's QML, and a stale process can lazy-load +# new files into old code. A restart failure (locked session, ssh, TTY) only +# prints its reason: the next update or login gets a fresh shell anyway. +echo "Restarting shell" +omarchy-restart-shell || true diff --git a/docs/migrations.md b/docs/migrations.md index f7f00229..4a89ca13 100644 --- a/docs/migrations.md +++ b/docs/migrations.md @@ -127,6 +127,9 @@ New migration format: - Use helper commands such as `omarchy-cmd-present`, `omarchy-cmd-missing`, `omarchy-pkg-add`, `omarchy-pkg-drop`, `omarchy-pkg-present`, and `omarchy-pkg-missing` when appropriate. +- Never restart the Omarchy shell. `omarchy update` restarts it unconditionally + after migrations run, and the login-time shell already runs current code and + hot-reloads `shell.json` edits. Example: diff --git a/docs/update-process.md b/docs/update-process.md index 80520acc..bcc0c86e 100644 --- a/docs/update-process.md +++ b/docs/update-process.md @@ -26,7 +26,7 @@ The design goal is: | `~/.local/state/omarchy/current/` | user | Generated active theme, selected theme name, and current background symlink. | | `~/.local/state/omarchy/migrations/` | user | Per-user migration markers. | | `~/.local/state/omarchy/reboot-required` | user | Optional reboot marker checked by `omarchy-update-restart`. | -| `~/.local/state/omarchy/restart-*-required` | user | Optional service/app restart markers checked by `omarchy-update-restart`. | +| `~/.local/state/omarchy/restart-*-required` | user | Optional service/app restart markers checked by `omarchy-update-restart`. The shell needs no marker: it is restarted unconditionally after every update. | ## Migration layout @@ -261,7 +261,7 @@ scripts. | `omarchy-update-mise` | Runs `mise up` for mise-managed tools. | **Keep.** Mise-managed tools are intentionally part of the blessed update path. | | `omarchy-update-orphan-pkgs` | Lists orphans and prompts before removal; noninteractive mode never removes. | **Keep for now.** Safe because it is prompt-only. | | `omarchy-update-analyze-logs` | Scans `/tmp/omarchy-update.log` for known failure patterns, currently initramfs generation. | **Keep/expand.** Useful safety net; should grow only for high-signal checks. | -| `omarchy-update-restart` | Prompts for reboot after kernel/Hyprland updates and restarts components with `restart-*-required` markers. | **Keep.** Important final step; may eventually include service-restart checks. | +| `omarchy-update-restart` | Prompts for reboot after kernel/Hyprland updates, restarts components with `restart-*-required` markers, and always restarts the shell. | **Keep.** Important final step; may eventually include service-restart checks. | | `omarchy-update-firmware` | Manual firmware update command using fwupd. Not part of the normal update pipeline. | **Keep separate.** Firmware is not a routine system update step. | | `omarchy-update-time` | Restarts `systemd-timesyncd`. | **Question.** Not really an update command. Consider renaming/moving under system/time maintenance. | diff --git a/migrations/1784672586.sh b/migrations/1784672586.sh index dd392889..87d05094 100644 --- a/migrations/1784672586.sh +++ b/migrations/1784672586.sh @@ -5,5 +5,4 @@ if ! omarchy-pkg-present quickshell-git; then # quickshell package in place; packages depending on quickshell stay # satisfied through the provides. sudo pacman -S --noconfirm --ask 4 quickshell-git - omarchy-state set restart-shell-required fi diff --git a/migrations/1784989000.sh b/migrations/1784989000.sh index 3f806b6f..2d2c008e 100644 --- a/migrations/1784989000.sh +++ b/migrations/1784989000.sh @@ -37,5 +37,3 @@ if [[ -s $config_file ]]; then .bar.layout.center |= place_indicators_before_clock ' "$config_file" >"$tmp" && mv "$tmp" "$config_file" || rm -f "$tmp" fi - -omarchy-restart-shell diff --git a/migrations/1785189600.sh b/migrations/1785189600.sh index 4916f7d9..5b6fe807 100644 --- a/migrations/1785189600.sh +++ b/migrations/1785189600.sh @@ -118,7 +118,3 @@ if [[ -s $config_file ]] && grep -q 'TmuxAlert' "$config_file"; then rm -f "$tmp" fi - -# Nothing to restart from a TTY or over ssh, and that is no reason to stop the -# rest of the queue: hand it to the post-update restart instead. -omarchy-restart-shell >/dev/null 2>&1 || omarchy-state set restart-shell-required diff --git a/migrations/1785344985.sh b/migrations/1785344985.sh index 96deab5d..8668e799 100644 --- a/migrations/1785344985.sh +++ b/migrations/1785344985.sh @@ -45,5 +45,3 @@ if [[ -s $config_file ]] && omarchy-cmd-present jq; then end ' "$config_file" >"$tmp" && mv "$tmp" "$config_file" || rm -f "$tmp" fi - -omarchy-restart-shell diff --git a/test/shell.d/tmux-alert-removal-migration-test.sh b/test/shell.d/tmux-alert-removal-migration-test.sh index 53a8e7f5..2b6d73ec 100644 --- a/test/shell.d/tmux-alert-removal-migration-test.sh +++ b/test/shell.d/tmux-alert-removal-migration-test.sh @@ -234,13 +234,8 @@ pass "alert removal handles the older indicators key" fail "alert removal leaves an already-empty list alone" "$(jq -c '.bar.layout.right[1]' "$shell_config")" pass "alert removal leaves an already-empty list alone" -(($(wc -l <"$SHELL_RESTARTS") == 1)) || fail "alert removal restarts the shell" -[[ ! -s $STATE_CALLS ]] || fail "a restarted shell needs no deferred restart" "$(cat "$STATE_CALLS")" -pass "alert removal restarts the shell" - -# Migrations run from a TTY or over ssh have no shell to restart, and stopping -# there would strand every migration queued behind this one. -reset_home -SHELL_RESTART_STATUS=1 run_migration -grep -Fxq 'set restart-shell-required' "$STATE_CALLS" || fail "an unavailable shell defers its restart" "$(cat "$STATE_CALLS")" -pass "an unavailable shell defers its restart" +# The running shell hot-reloads shell.json and the post-update restart is +# unconditional, so the migration itself never touches the shell. +[[ ! -s $SHELL_RESTARTS ]] || fail "alert removal leaves shell restarts to the update" "$(cat "$SHELL_RESTARTS")" +[[ ! -s $STATE_CALLS ]] || fail "alert removal defers no shell restart" "$(cat "$STATE_CALLS")" +pass "alert removal leaves shell restarts to the update"