Add a disk speed test under a new Trigger > Tests menu (#6607)

* Extract the speed test gauge cluster into a shared SpeedTestOverlay

The dial cluster -- scrim, ignition sweep, self-ranging dials, run-again
button -- moves from the network speed test panel into qs.Ui with the
labels, unit, title, scale stops, and layer namespace as parameters, so
other measurements can wear the same cluster. The network panel keeps
its process handling and becomes a thin dressing of the overlay.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add a disk speed test and move speed tests under Trigger > Tests

omarchy-disk-speedtest streams live write and read MB/s once a second by
sampling the backing block device's kernel I/O counters while dd workers
generate the traffic, the same way the network test samples the
interface counters. The stress data is an incompressible urandom chunk
staged in RAM, written with fdatasync per pass and fadvise drop-behind:
O_DIRECT silently falls back to the page cache on btrfs, and zeros never
reach a compressed filesystem at all. Scratch files are created
exclusively per invocation and removed even when a dismissal interrupts
the run mid-phase.

The omarchy.disk-speedtest panel dresses the shared SpeedTestOverlay
with write and read dials in MB/s, titled with the hardware model of
the disk under test.

The menu grows a Trigger > Tests submenu holding the new Disk Speed
Test and the Network Speed Test, which moves there from Setup > Network.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Make the disk speed test reproducible, direct, and read-first

Successive runs could swing 40% because the settled figure was just the
last one-second sample of a single buffered dd stream, taken while btrfs
copy-on-write churned the extent allocator on every rewrite pass and the
fadvise cache-eviction dance stayed advisory.

The test files are now marked NOCOW, which is what makes O_DIRECT truly
direct on btrfs -- with checksums on it silently falls back to the page
cache -- and lets every rewrite land in place. Four parallel workers per
phase give the device a queue depth it can stretch out on, and the
figure the dial settles on is the steady-state average over the whole
phase with the first warm-up second excluded, not whatever rate the
final second happened to catch. Together this tightens successive runs
from +/-40% to a few percent of each other, at the device's actual
spec throughput.

The read phase now runs first, staged against freshly written files,
with the read dial on the left. Workers also only loop while the main
script lives, so a dismissal that loses the kill race can no longer
leave an orphan hammering the disk forever, and any worker dying before
the deadline fails the run instead of passing off partial figures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Drop the menu aliases from the speed test entries

Aliases are reserved for established alternate names users already
type, kept for compatibility -- not something new entries pick up by
default. Note that in the menu definition header and AGENTS.md so the
next entry doesn't repeat it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Group dial readouts with thousands separators

A gen5 disk reads five digits; 11,450 scans, 11450 doesn't. Uses the
locale's grouping separator.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Trim redundant overlay props and unused imports from the speed test panels

The network panel restated the overlay's default unit and scale stops,
and both panels carried imports and an omarchyPath property nothing
uses.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Publish the specific speed test error whichever handler fires last

Process exit and stderr stream-finished have no guaranteed order, so a
failure that beat the collector showed the generic message forever even
when the command emitted an actionable one; the collector now replaces
it once the text lands. Also stop clearing the error on every stdout
line: only a new run should do that, or buffered output delivered after
a failed exit erases the failure message.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Arm the disk speed test cleanup before any scratch file exists

A preflight failure -- tmpfs target, missing device statistics, not
enough free space -- exited between mktemp and the trap, leaking the
scratch files. Cleanup also now unlinks before stopping the workers and
sweeps once more after, so even a cleanup cut short by an impatient
SIGKILL leaves no names behind and a final worker pass cannot recreate
one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
David Heinemeier Hansson
2026-08-07 23:04:42 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent b16ac48693
commit 2521b11fdd
9 changed files with 833 additions and 372 deletions
+6
View File
@@ -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/`
+1
View File
@@ -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"
+232
View File
@@ -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
+6 -2
View File
@@ -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 <name>` routes; also searchable
// aliases alternate `omarchy menu summon <name>` 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"},
+396
View File
@@ -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
}
}
}
+1
View File
@@ -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
@@ -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 <model>", then "read <MB/s>" once a second, then
// "write <MB/s>". 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()
}
}
@@ -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"
}
}
+26 -370
View File
@@ -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()
}
}