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:
co-authored by
Claude Fable 5
parent
b16ac48693
commit
2521b11fdd
@@ -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/`
|
||||
|
||||
@@ -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"
|
||||
|
||||
Executable
+232
@@ -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
|
||||
@@ -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"},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user