diff --git a/AGENTS.md b/AGENTS.md index 9bb571df..29c5041f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,6 +76,12 @@ Commands installed by Omarchy's default package set are runtime invariants. Invo Exceptions are allowed for migration and package-helper scripts where the helper may not be available yet, where the helper itself is being implemented, or where direct package-manager behavior is required. +# Menu + +- The menu definition lives in `default/omarchy/omarchy-menu.jsonc`. +- Do not add `aliases` to new menu entries. Aliases are reserved for + established alternate names users already type, kept for compatibility. + # Config Structure - `config/` - default configs copied to `~/.config/` diff --git a/bin/omarchy b/bin/omarchy index bd5ac435..a48f0487 100755 --- a/bin/omarchy +++ b/bin/omarchy @@ -43,6 +43,7 @@ GROUP_DESCRIPTIONS[debug]="Diagnostics and support logs" GROUP_DESCRIPTIONS[finalize]="Finalize user setup" GROUP_DESCRIPTIONS[default]="Default application selection" GROUP_DESCRIPTIONS[dev]="Omarchy development tools" +GROUP_DESCRIPTIONS[disk]="Disk performance helpers" GROUP_DESCRIPTIONS[display]="Display and text scaling" GROUP_DESCRIPTIONS[dns]="DNS resolver configuration" GROUP_DESCRIPTIONS[drive]="Drive selection and encryption" diff --git a/bin/omarchy-disk-speedtest b/bin/omarchy-disk-speedtest new file mode 100755 index 00000000..dbdc7794 --- /dev/null +++ b/bin/omarchy-disk-speedtest @@ -0,0 +1,232 @@ +#!/bin/bash + +# omarchy:summary=Measure live disk read and write speed +# omarchy:args=[target-dir] + +set -e + +if [[ -n ${1:-} && ! -d $1 ]]; then + echo "Usage: omarchy-disk-speedtest [target-dir]" >&2 + exit 2 +fi + +target_dir="${1:-${XDG_CACHE_HOME:-$HOME/.cache}/omarchy}" +phase_seconds=8 +parallel=4 +chunk_mb=4 +file_mb=256 + +mkdir -p "$target_dir" + +worker_pids=() +chunk_file="" +test_files=() + +stop_workers() { + local pid + for pid in "${worker_pids[@]}"; do + [[ -n $pid ]] || continue + pkill -TERM -P "$pid" 2>/dev/null || true + kill "$pid" 2>/dev/null || true + done + for pid in "${worker_pids[@]}"; do + [[ -n $pid ]] || continue + wait "$pid" 2>/dev/null || true + done + worker_pids=() +} + +alive_workers() { + local pid count=0 + for pid in "${worker_pids[@]}"; do + kill -0 "$pid" 2>/dev/null && count=$((count + 1)) + done + echo "$count" +} + +cleanup() { + # Unlink before stopping the workers, so even a cleanup cut short by an + # impatient SIGKILL has already taken the names off the filesystem. A live + # write worker's next dd pass recreates its file by name, so sweep again + # once they are gone. + rm -f ${chunk_file:+"$chunk_file"} "${test_files[@]}" + stop_workers + rm -f ${chunk_file:+"$chunk_file"} "${test_files[@]}" +} +# Armed before any scratch file exists, so a failed preflight check below +# cannot leak them. +trap cleanup EXIT +trap 'exit 143' TERM INT + +# Exclusive per-invocation scratch files: predictable names could clobber a +# user's file, follow a planted symlink, or let overlapping runs delete each +# other's active files out from under the measurement. Each worker gets its +# own on-disk file so the phases run at a queue depth the device can actually +# stretch out on, like the network test's parallel curl workers. +# +# The files are marked NOCOW where the filesystem supports it (btrfs), which +# turns off copy-on-write, checksums, and compression for them. That is what +# makes O_DIRECT truly direct on btrfs -- with checksums on it silently falls +# back to the page cache -- and it makes every rewrite land in place instead +# of churning the extent allocator, which run-to-run reproducibility depends +# on. +chunk_file=$(mktemp /dev/shm/omarchy-disk-speedtest-XXXXXX.src) +for (( i = 0; i < parallel; i++ )); do + file=$(mktemp "$target_dir/disk-speedtest-XXXXXX.dat") + chattr +C "$file" 2>/dev/null || true + test_files+=("$file") +done + +format_rate() { + awk -v value="$1" 'BEGIN { + if (value <= 0) print "0.0" + else if (value < 10) printf "%.1f\n", value + else printf "%.0f\n", value + }' +} + +# Resolve the block device backing the target directory, so throughput can be +# sampled from its kernel I/O counters the same way the network speed test +# samples the interface counters. +source_dev=$(findmnt -no SOURCE --target "$target_dir" 2>/dev/null) +source_dev=${source_dev%%\[*} # Strip btrfs subvolume suffix: /dev/sda2[/@home] + +if [[ $source_dev != /dev/* ]]; then + echo "Cannot find a disk behind $target_dir" >&2 + exit 1 +fi + +dev=$(readlink -f "$source_dev") +dev=${dev##*/} + +if [[ ! -r /sys/class/block/$dev/stat ]]; then + echo "No I/O statistics for $dev" >&2 + exit 1 +fi + +available_mb=$(df --output=avail -m "$target_dir" | tail -1 | tr -d ' ') +if (( available_mb < parallel * file_mb * 2 )); then + echo "Need at least $((parallel * file_mb * 2))MB free on $target_dir" >&2 + exit 1 +fi + +# Name the physical disk under test, walking dm-crypt/LVM layers and the +# partition table up to the whole device that carries the hardware model. +disk=$dev +while slave=$(ls "/sys/class/block/$disk/slaves" 2>/dev/null | head -1); [[ -n $slave ]]; do + disk=$slave +done +if [[ -f /sys/class/block/$disk/partition ]]; then + parent=$(readlink -f "/sys/class/block/$disk") + parent=${parent%/*} + disk=${parent##*/} +fi +model=$(lsblk -dno MODEL "/dev/$disk" 2>/dev/null | sed 's/^ *//; s/ *$//') +echo "disk ${model:-$disk}" + +# The stress data must be incompressible so nothing between the write call +# and the flash can shrink it. Staging a urandom chunk in RAM also keeps the +# source out of the measurement -- reading tmpfs is a memcpy. +dd if=/dev/urandom of="$chunk_file" bs=${chunk_mb}M count=$((file_mb / chunk_mb)) status=none + +# Workers loop only while the main script lives: if cleanup ever loses the +# race with a kill, an orphaned worker finishes its current pass and stops +# instead of hammering the disk forever. +write_worker() { + local file=$1 + while kill -0 $$ 2>/dev/null; do + dd if="$chunk_file" of="$file" bs=${chunk_mb}M oflag=direct conv=notrunc status=none 2>/dev/null || return + done +} + +read_worker() { + local file=$1 + while kill -0 $$ 2>/dev/null; do + dd if="$file" of=/dev/null bs=${chunk_mb}M iflag=direct status=none 2>/dev/null || return + done +} + +device_sectors() { + local -a stats + read -r -a stats < "/sys/class/block/$dev/stat" + if [[ $1 == "read" ]]; then + echo "${stats[2]}" + else + echo "${stats[6]}" + fi +} + +run_phase() { + local phase=$1 + local file before after deadline rate alive samples=0 + local baseline_sectors baseline_time end_time + + for file in "${test_files[@]}"; do + "${phase}_worker" "$file" 2>/dev/null & + worker_pids+=("$!") + done + + before=$(device_sectors "$phase") + deadline=$((SECONDS + phase_seconds)) + + while (( SECONDS < deadline )) && (( $(alive_workers) > 0 )); do + sleep 1 + after=$(device_sectors "$phase") + end_time=$EPOCHREALTIME + rate=$(awk -v before="$before" -v after="$after" 'BEGIN { + if (after < before) print 0 + else print (after - before) * 512 / 1000000 + }') + echo "$phase $(format_rate "$rate")" + samples=$((samples + 1)) + # The first second is warm-up -- governor ramp, crypt workers spinning + # up -- so the steady-state average starts after it. + if (( samples == 1 )); then + baseline_sectors=$after + baseline_time=$end_time + fi + before=$after + done + + # The workers only stop on their own when dd fails (quota, I/O error, full + # disk), so any worker gone before the deadline is a failed measurement, + # not a finished one. + alive=$(alive_workers) + stop_workers + if (( alive < parallel )); then + echo "Disk $phase test failed before finishing" >&2 + exit 1 + fi + + # The figure the dial settles on is the steady-state mean over the whole + # phase, not whatever rate the final second happened to catch. + if (( samples > 1 )); then + rate=$(awk -v before="$baseline_sectors" -v after="$after" -v start="$baseline_time" -v end="$end_time" 'BEGIN { + secs = end - start + if (secs <= 0 || after < before) print 0 + else print (after - before) * 512 / 1000000 / secs + }') + echo "$phase $(format_rate "$rate")" + fi +} + +# The read phase runs first, so its data must be staged before any measuring +# starts. Direct I/O leaves nothing in the page cache to serve reads from. +for file in "${test_files[@]}"; do + dd if="$chunk_file" of="$file" bs=${chunk_mb}M oflag=direct conv=notrunc status=none 2>/dev/null & + worker_pids+=("$!") +done + +stage_failed=0 +for pid in "${worker_pids[@]}"; do + wait "$pid" || stage_failed=1 +done +worker_pids=() + +if (( stage_failed )) || [[ ! -s ${test_files[0]} ]]; then + echo "Direct disk I/O is not available on $target_dir" >&2 + exit 1 +fi + +run_phase read +run_phase write diff --git a/default/omarchy/omarchy-menu.jsonc b/default/omarchy/omarchy-menu.jsonc index f0ab83b6..94cf108e 100644 --- a/default/omarchy/omarchy-menu.jsonc +++ b/default/omarchy/omarchy-menu.jsonc @@ -7,7 +7,9 @@ // Dotted IDs define the tree. Use provider:"name" only when the submenu // calls provider_name() or a command named "name" to return JSON rows. // Optional fields: - // aliases alternate `omarchy menu summon ` routes; also searchable + // aliases alternate `omarchy menu summon ` routes; also searchable. + // Reserved for established names users already type; new entries + // should not normally add any. // iconFont font family used for the icon glyph, when it differs from the menu font // title header text shown when the submenu is open; defaults to label // when shell condition; hide row when it fails @@ -59,6 +61,7 @@ "trigger.transcode": {"icon":"󰧸","label":"Transcode","action":"omarchy-transcode"}, "trigger.share": {"icon":"","label":"Share","aliases":["share"]}, "trigger.toggle": {"icon":"󰔎","label":"Toggle","aliases":["toggle","toggles"]}, + "trigger.tests": {"icon":"󰙨","label":"Tests"}, "trigger.hardware": {"icon":"","label":"Hardware","aliases":["hardware","hw"]}, "trigger.hardware.laptop-display": {"icon":"󰛧","label":"Laptop Display","when":"omarchy-hw-laptop","action":"omarchy-hyprland-monitor-internal toggle"}, "trigger.hardware.mirror-display": {"icon":"󰍹","label":"Mirror Display","when":"omarchy-hw-laptop","action":"omarchy-hyprland-monitor-internal-mirror toggle"}, @@ -84,6 +87,8 @@ "trigger.toggle.workspace-layout": {"icon":"󱂬","label":"Workspace Layout","action":"omarchy-hyprland-workspace-layout-toggle"}, "trigger.toggle.window-gaps": {"icon":"","label":"Window Gaps","action":"omarchy-hyprland-window-gaps-toggle"}, "trigger.toggle.one-window-ratio": {"icon":"","label":"1-Window Ratio","action":"omarchy-hyprland-window-single-square-aspect-toggle"}, + "trigger.tests.network-speedtest": {"icon":"󰓅","label":"Network Speed Test","action":"omarchy-shell shell summon omarchy.speedtest"}, + "trigger.tests.disk-speedtest": {"icon":"󰋊","label":"Disk Speed Test","action":"omarchy-shell shell summon omarchy.disk-speedtest"}, // Style "style.theme": {"icon":"󰸌","label":"Theme","aliases":["theme","themes"],"action":"theme=$(omarchy-theme-switcher); [[ -n $theme ]] && omarchy-theme-set \"$theme\""}, @@ -118,7 +123,6 @@ "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 shell summon omarchy.wifiqr"}, - "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/Ui/SpeedTestOverlay.qml b/shell/Ui/SpeedTestOverlay.qml new file mode 100644 index 00000000..12b296bc --- /dev/null +++ b/shell/Ui/SpeedTestOverlay.qml @@ -0,0 +1,396 @@ +import QtQuick +import QtQuick.Layouts +import QtQuick.Shapes +import Quickshell +import Quickshell.Wayland +import qs.Commons +import qs.Ui + +// Centered speed test overlay shared by the network and disk speed tests. No +// card: like the Tucson's floating cluster, the two dials 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. Callers name the dials, the unit, and the scale. +PanelWindow { + id: root + + required property string fontFamily + required property bool running + required property string leftLabel + required property string rightLabel + property string unit: "Mbps" + property string title: "" + property string layerNamespace: "omarchy-speed-test" + property string runAgainTooltip: "Measure again" + property real leftValue: 0 + property real rightValue: 0 + property bool leftLive: false + property bool rightLive: false + property string error: "" + property bool open: false + // Full-scale latch points for the dials, smallest first. The first stop is + // the base scale a fresh run starts from. + property var scaleStops: [100, 250, 500, 1000, 2500, 5000, 10000] + + signal closeRequested() + signal runAgainRequested() + + readonly property bool failed: error !== "" + + // 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. + readonly property color onScrim: "white" + readonly property color onScrimDim: Qt.rgba(1, 1, 1, 0.55) + readonly property color onScrimUrgent: "#ff6b6b" + + 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() + leftDial.ignite() + rightDial.ignite() + }) + } + anchors { top: true; bottom: true; left: true; right: true } + color: "transparent" + exclusionMode: ExclusionMode.Ignore + WlrLayershell.namespace: root.layerNamespace + 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.title !== "" + text: root.title.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 + } + + Row { + spacing: Style.space(48) + Layout.alignment: Qt.AlignHCenter + + SpeedDial { + id: leftDial + label: root.leftLabel + value: root.leftValue + live: root.leftLive + } + + SpeedDial { + id: rightDial + label: root.rightLabel + value: root.rightValue + live: root.rightLive + } + } + + // 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: root.runAgainTooltip + 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.runAgainRequested() + + Behavior on opacity { + NumberAnimation { duration: 240; easing.type: Easing.OutCubic } + } + } + + 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 + } + } + } + } + + // 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(1, 1, 1, 0.14) + readonly property color minorTickColor: Qt.rgba(1, 1, 1, 0.12) + readonly property color majorTickColor: Qt.rgba(1, 1, 1, 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: root.scaleStops[0] + 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 = root.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 < root.scaleStops.length; i++) { + if (value <= root.scaleStops[i] * 0.92) { + if (root.scaleStops[i] > fullScale) fullScale = root.scaleStops[i] + break + } + if (i === root.scaleStops.length - 1) fullScale = root.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).toLocaleString(Qt.locale(), 'f', 0) + color: root.onScrim + font.family: root.fontFamily + font.pixelSize: Style.font.display + font.bold: true + } + + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: root.unit + color: root.onScrimDim + font.family: root.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: root.onScrimDim + font.family: root.fontFamily + font.pixelSize: Style.font.caption + font.bold: true + font.letterSpacing: 1.5 + } + } +} diff --git a/shell/Ui/qmldir b/shell/Ui/qmldir index aaf9892b..b25bf5ca 100644 --- a/shell/Ui/qmldir +++ b/shell/Ui/qmldir @@ -27,6 +27,7 @@ PointerMoveGate 1.0 PointerMoveGate.qml ScreenMoveRemap 1.0 ScreenMoveRemap.qml PopupCard 1.0 PopupCard.qml SearchableDropdown 1.0 SearchableDropdown.qml +SpeedTestOverlay 1.0 SpeedTestOverlay.qml TextField 1.0 TextField.qml Toggle 1.0 Toggle.qml ToggleSwitch 1.0 ToggleSwitch.qml diff --git a/shell/plugins/panels/disk-speedtest/Panel.qml b/shell/plugins/panels/disk-speedtest/Panel.qml new file mode 100644 index 00000000..3b86f5be --- /dev/null +++ b/shell/plugins/panels/disk-speedtest/Panel.qml @@ -0,0 +1,151 @@ +import QtQuick +import Quickshell.Io +import qs.Commons +import qs.Ui + +// The shared gauge-cluster overlay dressed for the disk speed test: read and +// write dials in MB/s, titled with the model of the disk under test. One +// omarchy-disk-speedtest run streams both phases and cleans up after itself, +// so dismissal only has to stop the process. +Item { + id: root + + property var shell: null + property var manifest: null + + property bool opened: false + property bool running: false + property bool expectedStop: false + property bool pendingRun: false + property string phase: "" // "read" | "write" | "" + property string diskName: "" + property string writeMBps: "" + property string readMBps: "" + property string error: "" + property string stderrText: "" + + function open(payloadJson) { + opened = true + runTest() + } + + // Host-initiated close (`shell hide`). The user-initiated paths (Esc, the + // scrim) route through shell.hide so the host's open-panel state stays + // consistent, and land back here. + function close() { + opened = false + pendingRun = false + // Clear the phase before killing the process, so onExited reads the stop + // as a dismissal rather than a failed run. + phase = "" + running = false + if (proc.running) { + expectedStop = true + proc.running = false + } + } + + function dismiss() { + if (shell && typeof shell.hide === "function") + shell.hide((manifest && manifest.id) || "omarchy.disk-speedtest") + else close() + } + + function runTest() { + if (proc.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 = "" + diskName = "" + writeMBps = "" + readMBps = "" + stderrText = "" + phase = "read" + running = true + proc.running = true + } + + function toRate(raw) { + var value = parseFloat(raw) + return isFinite(value) && value > 0 ? value : 0 + } + + // Lines are "disk ", then "read " once a second, then + // "write ". The phase follows whichever figure is streaming, and each + // phase's final line is its steady-state average, which the dial settles on. + function updateLine(line) { + var parts = String(line).trim().split(/\s+/) + if (parts.length < 2) return + if (parts[0] === "disk") { + diskName = parts.slice(1).join(" ") + return + } + var value = parseFloat(parts[1]) + if (!isFinite(value) || value < 0) return + if (parts[0] === "write") { + phase = "write" + writeMBps = String(value) + } else if (parts[0] === "read") { + phase = "read" + readMBps = String(value) + } + } + + Process { + id: proc + command: ["omarchy-disk-speedtest"] + stdout: SplitParser { onRead: function(line) { root.updateLine(line) } } + // Exit and stream-finished have no guaranteed order: when a failed exit + // beat the collector and published the generic message, replace it with + // the specific one once it lands. + stderr: StdioCollector { + waitForEnd: true + onStreamFinished: { + root.stderrText = String(text || "").trim() + if (root.error !== "" && root.stderrText !== "") root.error = root.stderrText + } + } + onExited: function(exitCode) { + if (root.pendingRun) { + root.pendingRun = false + root.expectedStop = false + if (root.opened) Qt.callLater(root.runTest) + return + } + + if (!root.expectedStop && exitCode !== 0) { + root.error = root.stderrText || "Disk speed test failed" + root.phase = "" + root.running = false + return + } + + root.expectedStop = false + root.phase = "" + root.running = false + } + } + + SpeedTestOverlay { + fontFamily: Style.font.family + layerNamespace: "omarchy-disk-speedtest" + title: root.diskName + leftLabel: "READ" + rightLabel: "WRITE" + unit: "MB/s" + runAgainTooltip: "Measure again" + running: root.running + leftValue: root.toRate(root.readMBps) + rightValue: root.toRate(root.writeMBps) + leftLive: root.running && root.phase === "read" + rightLive: root.running && root.phase === "write" + error: root.error + open: root.opened + scaleStops: [500, 1000, 2500, 5000, 10000, 15000] + onCloseRequested: root.dismiss() + onRunAgainRequested: root.runTest() + } +} diff --git a/shell/plugins/panels/disk-speedtest/manifest.json b/shell/plugins/panels/disk-speedtest/manifest.json new file mode 100644 index 00000000..2c7e5bc4 --- /dev/null +++ b/shell/plugins/panels/disk-speedtest/manifest.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": 1, + "id": "omarchy.disk-speedtest", + "name": "Disk speed test", + "version": "1.0.0", + "author": "Omarchy", + "description": "Live disk write and read speed dials. Summon with: omarchy-shell shell summon omarchy.disk-speedtest", + "kinds": [ + "panel" + ], + "entryPoints": { + "panel": "Panel.qml" + } +} diff --git a/shell/plugins/panels/speedtest/Panel.qml b/shell/plugins/panels/speedtest/Panel.qml index ada5d809..6805aa05 100644 --- a/shell/plugins/panels/speedtest/Panel.qml +++ b/shell/plugins/panels/speedtest/Panel.qml @@ -1,18 +1,11 @@ import QtQuick -import QtQuick.Layouts -import QtQuick.Shapes -import Quickshell import Quickshell.Io -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. +// The shared gauge-cluster overlay (SpeedTestOverlay) dressed for the +// internet speed test: download and upload dials in Mbps, titled with the +// connection under test. // // Standalone panel plugin: summoning it starts a fresh run, dismissing it // stops the traffic, so the download workers never keep saturating the link @@ -22,7 +15,6 @@ import qs.Ui Item { id: root - property string omarchyPath: Quickshell.env("OMARCHY_PATH") property var shell: null property var manifest: null @@ -40,14 +32,6 @@ Item { readonly property real downloadValue: toMbps(downloadMbps) readonly property real uploadValue: toMbps(uploadMbps) - readonly property bool failed: error !== "" - - // 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 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) @@ -61,14 +45,6 @@ Item { 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() - }) } function close() { @@ -103,7 +79,6 @@ Item { if (phase === "down") downloadMbps = String(value) else if (phase === "up") uploadMbps = String(value) - error = "" } function runSpeedTest() { @@ -153,9 +128,15 @@ Item { Process { id: speedTestProc stdout: SplitParser { onRead: function(line) { root.updateSpeedTestLine(line) } } + // Exit and stream-finished have no guaranteed order: when a failed exit + // beat the collector and published the generic message, replace it with + // the specific one once it lands. stderr: StdioCollector { waitForEnd: true - onStreamFinished: root.stderrText = String(text || "").trim() + onStreamFinished: { + root.stderrText = String(text || "").trim() + if (root.error !== "" && root.stderrText !== "") root.error = root.stderrText + } } onExited: function(exitCode) { phaseTimer.stop() @@ -201,346 +182,21 @@ Item { } } - 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: keyCatcher - anchors.fill: parent - focus: true - - Keys.onEscapePressed: root.dismiss() - Keys.onReturnPressed: if (!root.running) root.runSpeedTest() - Keys.onEnterPressed: if (!root.running) root.runSpeedTest() - - 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: root.onScrimDim - font.family: root.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.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 } - } - } - - 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 - } - } - } - } - } - - // 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(1, 1, 1, 0.14) - readonly property color minorTickColor: Qt.rgba(1, 1, 1, 0.12) - readonly property color majorTickColor: Qt.rgba(1, 1, 1, 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.onScrim - font.family: root.fontFamily - font.pixelSize: Style.font.display - font.bold: true - } - - Text { - anchors.horizontalCenter: parent.horizontalCenter - text: "Mbps" - color: root.onScrimDim - font.family: root.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: root.onScrimDim - font.family: root.fontFamily - font.pixelSize: Style.font.caption - font.bold: true - font.letterSpacing: 1.5 - } + SpeedTestOverlay { + fontFamily: Style.font.family + layerNamespace: "omarchy-network-speedtest" + title: root.connectionName + leftLabel: "DOWNLOAD" + rightLabel: "UPLOAD" + runAgainTooltip: "Measure again via fast.com" + running: root.running + leftValue: root.downloadValue + rightValue: root.uploadValue + leftLive: root.running && root.phase === "down" + rightLive: root.running && root.phase === "up" + error: root.error + open: root.opened + onCloseRequested: root.dismiss() + onRunAgainRequested: root.runSpeedTest() } }