aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--DESIGN.md24
-rw-r--r--README.md138
-rwxr-xr-xbin/disks-sample.sh40
-rwxr-xr-xbin/pubip-sample.sh48
-rw-r--r--conky.conf.in2
-rw-r--r--dashboard.lua8
-rw-r--r--lib/card.lua145
-rw-r--r--lib/data.lua397
-rw-r--r--test/fixtures/df_output7
-rw-r--r--test/fixtures/ip_addr3
-rw-r--r--test/fixtures/proc_cpuinfo12
-rw-r--r--test/fixtures/proc_cpuinfo_intel6
-rw-r--r--test/fixtures/proc_meminfo7
-rw-r--r--test/fixtures/proc_stat5
-rw-r--r--test/fixtures/proc_stat_percore11
-rw-r--r--test/fixtures/temp1_input1
-rw-r--r--test/test_data.lua263
-rw-r--r--widgets/disks.lua150
-rw-r--r--widgets/gpu.lua121
-rw-r--r--widgets/network.lua181
-rw-r--r--widgets/system.lua198
21 files changed, 32 insertions, 1735 deletions
diff --git a/DESIGN.md b/DESIGN.md
index b463c1f..cd7a8a5 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -12,15 +12,13 @@ bottom:
- **Header.** The title, or the glyph that stands in for it, sits top-left in
the `label` colour. The card's **big number** sits at the right edge, on the
same line, in `FONT_HEAVY`. Nothing else shares that line. A card whose data
- lives entirely in its variable band drops the big number: `disks` has no
- headline, the rings are the value.
+ lives entirely in its variable band drops the big number and lets that band be the value.
- **Body.** Rows follow one pattern: **label left, value right**
(`card.text` and `card.text_right`), so the eye runs down the right edge for
numbers. A row's value takes the `threshold` colour when it is a measured
quantity, and stays in `value` when it is not (a fan RPM, a total).
- **Variable band.** One element may absorb the height left over, chosen per
- card: the equaliser in `system`, the daylight arc in `weather`, the ring row
- in `disks`. Or the type itself fills the cell.
+ card: the daylight arc in `weather`. Or the type itself fills the cell.
Failure states keep the chrome and draw a notice in it ("no cache data", `--`
for a missing sensor). An empty cell is indistinguishable from a crashed
@@ -40,7 +38,7 @@ Everything else exists to keep those two readable at any cell size.
of a width fit and a height budget. `card.fit_unit` measures candidate rows
and returns the largest base size that fits the width, and a row that would
collide pulls the size down. Bigger cell, bigger type instead of empty space.
- The `system`, `gpu` and `cache` cards do this; a card that cannot afford the
+ The `cache` card does this; a card that cannot afford the
measuring pass may key off width with a clamped factor.
- **Roles.** `FONT_MONO` for labels and row text, `FONT_HEAVY` for the big
number, `FONT_UI` for prose (a condition description, a notice). Sizes within
@@ -54,14 +52,11 @@ One language for every measured quantity, so state is read rather than decoded:
| State | Role | Example |
|---|---|---|
-| fine | `ok` | a cool sensor, an empty filesystem |
-| busy | `warning` | a core under load, a filesystem past 25% |
-| hot / full | `critical` | a sensor at its ceiling, a nearly full disk |
-
-- Proportions (CPU load, filesystem and cache usage) use **25% / 75%**.
-- Temperatures use a **per-sensor pair**, because 70C is unremarkable on a CPU
- and alarming on an NVMe. The pairs live beside the hwmon bindings in the
- widget.
+| fine | `ok` | a fresh cache, a current changelog |
+| busy | `warning` | a cache past 25% |
+| hot / full | `critical` | a cache past 75%, a stale changelog |
+
+- Proportions (cache usage) use **25% / 75%**.
- `warning` is the palette's middle role. The board should show all three
states in normal use, or the language is not earning its place.
- The big value's colour follows what it is: `body` for a plain number, the
@@ -72,8 +67,7 @@ One language for every measured quantity, so state is read rather than decoded:
## Glyphs
Nerd Font glyphs stand in for labels where an icon reads faster than a word:
-the disks rings put a distro / house / disk / network glyph at each ring's
-centre, weather puts the condition glyph in its header, and every card title
+weather puts the condition glyph in its header, and every card title
carries one in `highlight` before its label (the codepoints live in
`card.ICON`). A wrong-but-present codepoint draws a plausible neighbour rather
than failing, so render a new glyph and look at it before trusting it.
diff --git a/README.md b/README.md
index b137121..ad6f930 100644
--- a/README.md
+++ b/README.md
@@ -63,45 +63,33 @@ in the source, so no real location appears in committed files.
## System widgets
-Seven cards cover the machine itself:
-
-- **System**: CPU load as a per-core equaliser, the CPU and motherboard model
- as two rows under the header, RAM used/total, and every temperature (CPU
- package, CCD die, NVMe and two board sensors).
-- **GPU**: Arc B580 package and VRAM temperatures, VRAM in use as a bar, and
- fan speed.
-- **Disks**: `/`, `/home`, `/data` and one NFS mount per server, as usage
- rings, with free space and total size printed under each ring. Two servers
- are configured, each with the same export mounted twice; the card shows one
- mount per server so no number appears twice.
+Three cards cover the machine itself:
+
- **Cache**: `~/.cache` total and its four largest subdirectories.
-- **Network**: LAN and public address, and download/upload throughput as two
- lines on one chart. See below.
- **Slackware**: distribution version, package count, kernel, install age, and
time since the last `slackpkg` ChangeLog update. See below.
- **Updates**: nvtable's table of tracked packages behind upstream, scrolled
with the mouse wheel. See below.
-The disks and cache cards read a cache file that a sampler writes; they never
-call `du` or `statfs` themselves. `statfs` on an unreachable NFS path blocks,
-and the disks sampler exists so that a dead server costs a stale cache rather
-than a frozen draw. Sampling is scheduled by conky itself: `conky.text` carries
+CPU, RAM, temperatures, the GPU, disks, network and the public address used to
+be cards here. They moved to the performance panel of the quickshell island
+(`desktop/island/` in the quickshell repo), which also warns from the status
+pill. The island keeps writing `~/.cache/udt/pubip.txt`.
+
+The cache card reads a cache file that a sampler writes; it never calls `du`
+itself. Sampling is scheduled by conky itself: `conky.text` carries
a threaded `${texeci}` per sampler, even though that block renders nothing under Cairo.
${texeci 900 ~/.config/conky/bin/weather-fetch.sh}
- ${texeci 60 ~/.config/conky/bin/disks-sample.sh}
${texeci 900 ~/.config/conky/bin/cache-sample.sh}
- ${texeci 1800 ~/.config/conky/bin/pubip-sample.sh}
${texeci 900 ~/.config/conky/bin/slackware-sample.sh}
${texeci 300 ~/.config/conky/bin/calendar-sample.sh}
${texeci 1800 ~/.config/conky/bin/immich-fetch.sh}
${texeci 600 ~/.config/conky/bin/updates-sample.sh}
-The disks sampler runs once a minute; the cache, Slackware and public-IP
-samplers run every 15, 15 and 30 minutes respectively, because `du -sh
+The cache and Slackware samplers run every 15 minutes, because `du -sh
~/.cache` walks the tree and takes about 100ms warm, which is fine on a slow
-cycle and unthinkable on the 2-second draw, and a residential public address
-does not change often enough to warrant asking more frequently. The calendar
+cycle and unthinkable on the 2-second draw. The calendar
sampler runs every five minutes: khal costs about 200ms of Python startup,
which rules out calling it from a draw, and the cache has to notice both a
vdirsyncer run and the day rolling over. Tying them to
@@ -109,9 +97,7 @@ conky means nothing samples while the dashboard is down.
To run a sampler by hand:
- ./bin/disks-sample.sh; echo "exit: $?"
./bin/cache-sample.sh; echo "exit: $?"
- ./bin/pubip-sample.sh; echo "exit: $?"
./bin/slackware-sample.sh; echo "exit: $?"
./bin/calendar-sample.sh; echo "exit: $?"
./bin/updates-sample.sh; echo "exit: $?"
@@ -119,38 +105,11 @@ To run a sampler by hand:
All print nothing and exit 0 on success, and all write a temp file and rename
it over the target, so the widget never reads a half-written file.
-### Network
-
-The chart plots download and upload as two lines on **one shared vertical
-scale**: scaled independently they would lie about their relative size, which
-is the entire reason to draw them together. The window peak is printed at the
-chart's top-left and the live rates at the bottom corners, each in its line's
-colour. History is module state inside `widgets/network.lua`, one sample per
-pixel column of the chart so it is exactly as wide as the card and never
-interpolates; it does not survive a `restart.sh` or a conky restart, so the
-chart starts empty and fills left to right.
-
-The rates come from the graphed interface's own byte counters under
-`/sys/class/net`, read on every draw: two file reads, no subprocess. The LAN
-address comes from `ip -4 addr show br0` and is memoized on first success,
-retried while nil because a bridge may not be up yet when conky starts. The
-public address comes from the cache file `bin/pubip-sample.sh` writes; once
-that cache is older than four hours the card shows `--` instead, because an
-address that may no longer be yours displayed with confidence is worse than a
-dash.
-
-**`br0` is a bridge, not a physical interface**, and the `IFACE` constant at
-the top of `widgets/network.lua` is the one edit that changes which interface
-is graphed. VM-to-host traffic crosses a bridge without reaching the router,
-so a local copy of that traffic appears in the chart. This carries over
-unchanged from the previous text config and is accepted behaviour, not a bug.
-
### Slackware
The big top-right value is the time since `/var/lib/slackpkg/ChangeLog.txt`
last changed, coloured `ok` under 24 hours, `warning` to a week, `critical`
-beyond, the same three-role language the system, GPU, disks and cache cards
-use. Below it: distribution version, package count, kernel, and install age
+beyond, the same three-role language the cache card uses. Below it: distribution version, package count, kernel, and install age
(the root filesystem's birth time, where the filesystem records one).
### Calendar
@@ -236,43 +195,15 @@ at most every 30 minutes, detached so conky never waits on the network; its
results show up on the next sample. `nvtable` is looked up in `~/bin` too,
since conky launched by the compositor lacks the login shell's `PATH`.
-### Host bindings
-
-These hwmon bindings, and the ceilings beside them, are specific to this machine
-and will not port. They are written down because the alternative is
-rediscovering them from a wrong reading.
-
-| chip | file | label | warning / critical |
-|---|---|---|---|
-| `k10temp` | `temp1_input` | CPU package | 75 / 90 |
-| `k10temp` | `temp3_input` | CCD die | 75 / 90 |
-| `nvme` | `temp1_input` | WD_BLACK SN850X | 60 / 70 |
-| `xe` | `temp2_input` | Arc B580 package | 75 / 85 |
-| `xe` | `temp3_input` | Arc B580 memory | 80 / 90 |
-| `xe` | `fan1_input` | Arc B580 fan RPM | none |
-| `gigabyte_wmi` | `temp2_input` | board | 60 / 70 |
-| `gigabyte_wmi` | `temp3_input` | board | 60 / 70 |
-
-Chips are globbed by the contents of their hwmon `name` file, never by index:
-indices drift across kernel and hardware reorders and a stale one silently
-reports a different chip. `Tccd1` is read straight from sysfs rather than by
-shelling out to `sensors`; same number, no subprocess. The `gigabyte_wmi` chip
-exposes five unlabelled temperatures, two of which the old conky config showed;
-they are labelled "board" rather than given a meaning they may not have.
-
### One colour language across every card
Anything with a comfortable range and an uncomfortable one is coloured by the
same three roles, so a card reads without learning a new scheme: `ok` below the
first threshold, `warning` between them, `critical` above the second.
-Filesystems, cache usage and CPU load use 25% and 75%. Temperatures use a
-per-sensor pair (the table above), because 70C is unremarkable on a CPU package
-and alarming on an NVMe. The Slackware card's ChangeLog age uses the same
+Cache usage uses 25% and 75%. The Slackware card's ChangeLog age uses the same
three roles on an hour scale (24 / 168) instead of a percentage. These are
-starting values, host-specific exactly as the
-bindings are, and sit in one table per widget so they are easy to retune once
-real numbers under load are known.
+starting values, and sit in one table per widget so they are easy to retune.
## Changing the layout
@@ -336,9 +267,9 @@ cell is left sparse; size it off the height alone and a short cell shrinks the
text to a whisper. The robust way is a measured fit: `card.fit_unit()` returns
the largest base size at which every row still fits the width, and the widget
takes the smaller of that and its height budget. Larger cell, larger type; a row
-that would collide pulls the size back down. The GPU, cache and system cards do
+that would collide pulls the size back down. The cache card does
this. A card that cannot afford the measuring pass can still key off width with
-a clamp, `math.max(lo, math.min(hi, inner.w * k))`; the clock, weather and disks
+a clamp, `math.max(lo, math.min(hi, inner.w * k))`; the clock and weather
cards do.
**Decide where the slack goes, and let one element absorb it.** The clock pins
@@ -443,9 +374,6 @@ widget passes its own. The clock uses `FONT_CLOCK` (Oswald, OFL-1.1, under
substitutes a default for a name it does not know, which looks exactly like the
font "not applying".
-**Sensors are globbed by hwmon `name`, never by index.** Indices drift across
-kernel and hardware reorders, and a stale one silently reports a different chip.
-
**`${execi}` fires even though `conky.text` renders nothing.** Cairo output
covers the text stream, so the `execi` that refreshes the weather cache is
invisible, but it still runs on schedule. This was checked with a probe config
@@ -475,40 +403,6 @@ half empty. Fill the cell instead: `card.fit_unit()` measures the rows and
returns the largest size that fits the width, the widget takes the smaller of
that and its height budget, and a row that would collide pulls the size down.
-**A bare `df` would silently mis-read every disk figure.** The user's shell
-aliases `df` to `df -h`, so `bin/disks-sample.sh` calls `/usr/bin/df -P -B1` by
-absolute path. `-P` is the POSIX format, one line per filesystem with the
-mountpoint last, and `-B1` is bytes so the parser never interprets a suffix. An
-alias or a function would hand the parser `1.6G` where it expects an integer,
-and every figure would be quietly wrong.
-
-**The disks card shows `df`'s available column, not `size - used`.** On this
-machine's root filesystem the two differ by about 13GB of root-reserved
-blocks, which exist on disk but are not available to any non-root user. A
-card that computed free space as `size - used` would overstate it by exactly
-that reserve.
-
-**The Arc B580 reports no utilisation through any stable interface.** The `xe`
-driver exposes no `gpu_busy_percent` (unlike `amdgpu`) and `intel_gpu_top`
-refuses the device. The GPU card therefore has no load bar; it shows what the
-hardware actually reports rather than inventing a number.
-
-**VRAM in use comes from DRM fdinfo**, the per-client counters `gputop` reads:
-`drm-resident-vram0` in `/proc/<pid>/fdinfo/<fd>`, summed once per
-`drm-client-id`. Only processes readable by conky's user are seen, so a GPU
-client running as another user is not counted. The card's 12 GiB total is
-hardcoded: xe puts no VRAM capacity in sysfs.
-
-**`/data` has no temperature.** It sits on a spinning disk with no hwmon chip;
-reading its SMART temperature would need `smartctl` as root and would spin up a
-sleeping disk on every poll. The NVMe composite temperature is freely readable
-and is shown; `/data` simply has none.
-
-**Per-core counters are module-level in `widgets/system.lua`.** Load is a delta
-between frames, so the counter has to outlive the draw; one created inside
-`draw` would sample against nothing and report nil forever. The aggregate
-counter is the same.
-
## License
GPLv2 only. See `LICENSE`.
diff --git a/bin/disks-sample.sh b/bin/disks-sample.sh
deleted file mode 100755
index 5291bcf..0000000
--- a/bin/disks-sample.sh
+++ /dev/null
@@ -1,40 +0,0 @@
-#!/bin/bash
-# Sample filesystem usage into a cache file, for widgets/disks.lua.
-#
-# This exists so the draw hook never calls statfs on an NFS path. An
-# unreachable server blocks that call, and blocking the Cairo draw freezes the
-# whole dashboard; here it costs a stale cache and nothing else.
-#
-# One NFS mount per server: Library and Slackware are the same export on one
-# server, shared and backup_danix the same on another, so showing all four
-# printed every number twice.
-
-set -u
-
-CACHE_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/udt"
-CACHE="$CACHE_DIR/disks.txt"
-
-MOUNTS=(/ /home /data /mnt/nfs/Library /mnt/nfs/shared)
-
-umask 077
-mkdir -p "$CACHE_DIR"
-
-TMP="$CACHE.tmp.$$"
-trap 'rm -f "$TMP"' EXIT
-
-# /usr/bin/df by absolute path with -P -B1, deliberately:
-# - the user's shell aliases df to `df -h`, and an alias or a function would
-# hand the parser '1.6G' where it expects an integer
-# - -P is the POSIX format, one line per filesystem, mountpoint last
-# - -B1 is bytes, so the widget does the formatting and the parser never
-# has to interpret a suffix
-if ! /usr/bin/df -P -B1 "${MOUNTS[@]}" > "$TMP" 2>/dev/null; then
- # A single unreachable mount must not discard the others: df still reports
- # the ones it could stat, so keep the output if it has any data rows.
- if [ "$(wc -l < "$TMP")" -lt 2 ]; then
- echo "disks-sample: df produced nothing usable" >&2
- exit 1
- fi
-fi
-
-mv -f "$TMP" "$CACHE"
diff --git a/bin/pubip-sample.sh b/bin/pubip-sample.sh
deleted file mode 100755
index c5100f8..0000000
--- a/bin/pubip-sample.sh
+++ /dev/null
@@ -1,48 +0,0 @@
-#!/bin/bash
-# Fetch the public IP address into a cache file, for widgets/network.lua.
-#
-# Run from conky's ${execi} every 30 minutes. The draw hook must never make a
-# network call: a hung request would block the Cairo draw and freeze the whole
-# dashboard, and a residential address is stable for days anyway.
-#
-# Exits non-zero with a message on stderr when it cannot fetch, leaving any
-# existing cache untouched rather than replacing a good address with an error
-# page.
-
-set -u
-
-CACHE_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/udt"
-CACHE="$CACHE_DIR/pubip.txt"
-
-umask 077
-mkdir -p "$CACHE_DIR"
-
-# The temp file sits in the same directory as the target: rename is only
-# atomic within a filesystem, and the widget reads this on its own 2s cadence.
-TMP="$CACHE.tmp.$$"
-trap 'rm -f "$TMP"' EXIT
-
-# --max-time, not just --connect-timeout: a server that accepts the connection
-# and then stalls would otherwise hold a curl process for as long as it likes.
-if ! IP=$(curl -fsS --max-time 10 https://ipinfo.io/ip 2>/dev/null); then
- echo "pubip-sample: fetch failed" >&2
- exit 1
-fi
-
-# Validate before writing. A captive portal, a rate-limit message and an error
-# page all arrive with a 200 and would otherwise be written to the cache and
-# drawn on the dashboard as though they were an address.
-IP=$(echo "$IP" | tr -d '[:space:]')
-if ! echo "$IP" | grep -qE '^([0-9]{1,3}\.){3}[0-9]{1,3}$'; then
- echo "pubip-sample: response is not an IPv4 address" >&2
- exit 1
-fi
-
-# The epoch travels with the address so the widget can refuse to show a stale
-# one.
-{
- echo "ip $IP"
- echo "fetched $(date +%s)"
-} > "$TMP"
-
-mv -f "$TMP" "$CACHE"
diff --git a/conky.conf.in b/conky.conf.in
index d12ffc1..bf7fedc 100644
--- a/conky.conf.in
+++ b/conky.conf.in
@@ -60,4 +60,4 @@ conky.config = {
-- texeci, not execi: execi runs in conky's main loop and blocks the draw until
-- the script exits. At 0.1s while visible, a 0.7s nvtable or an Immich
-- download stalls the scroll; texeci runs each in its own thread.
-conky.text = [[${texeci 900 ~/.config/conky/bin/weather-fetch.sh}${texeci 60 ~/.config/conky/bin/disks-sample.sh}${texeci 900 ~/.config/conky/bin/cache-sample.sh}${texeci 1800 ~/.config/conky/bin/pubip-sample.sh}${texeci 900 ~/.config/conky/bin/slackware-sample.sh}${texeci 300 ~/.config/conky/bin/calendar-sample.sh}${texeci 5 ~/.config/conky/bin/breaktimer-sample.sh}${texeci 1800 ~/.config/conky/bin/immich-fetch.sh}${texeci 600 ~/.config/conky/bin/updates-sample.sh}]]
+conky.text = [[${texeci 900 ~/.config/conky/bin/weather-fetch.sh}${texeci 900 ~/.config/conky/bin/cache-sample.sh}${texeci 900 ~/.config/conky/bin/slackware-sample.sh}${texeci 300 ~/.config/conky/bin/calendar-sample.sh}${texeci 5 ~/.config/conky/bin/breaktimer-sample.sh}${texeci 1800 ~/.config/conky/bin/immich-fetch.sh}${texeci 600 ~/.config/conky/bin/updates-sample.sh}]]
diff --git a/dashboard.lua b/dashboard.lua
index 11ffb24..f0c34e6 100644
--- a/dashboard.lua
+++ b/dashboard.lua
@@ -22,17 +22,11 @@ local layout = {
{ widget = 'clock', col = 1, row = 1.5, w = 1.5, h = 5 },
-- Under the clock, same column.
{ widget = 'weather', col = 1, row = 7, w = 3, h = 5 },
- -- Left of the disks row, in the board's open middle column.
+ -- In the board's open middle column, left of immich.
{ widget = 'calendar', col = 2.5, row = 1, w = 3, h = 6 },
-- Clustered near weather and clock
{ widget = 'breaktimer', col = 4, row = 7, w = 2, h = 3 },
- { widget = 'system', col = 14, row = 4, w = 3, h = 5 },
- { widget = 'gpu', col = 12, row = 4, w = 2, h = 3 },
- { widget = 'disks', col = 12, row = 1, w = 5, h = 3 },
{ widget = 'cache', col = 12, row = 7, w = 2, h = 3 },
- -- Under system, with slackware beside it. Provisional: there is no settled
- -- arrangement for the board yet.
- { widget = 'network', col = 14, row = 9, w = 3, h = 3 },
{ widget = 'slackware', col = 12, row = 10, w = 2, h = 3 },
-- Centred on the board. Twice as many rows as columns because cells are
-- about twice as wide as tall, so this span comes out square.
diff --git a/lib/card.lua b/lib/card.lua
index 7fd40f8..2749a6e 100644
--- a/lib/card.lua
+++ b/lib/card.lua
@@ -215,11 +215,7 @@ end
-- fontconfig alone: a missing glyph draws as an invisible blank and a wrong
-- codepoint as a plausible neighbour, so neither fails visibly.
M.ICON = {
- system = '\u{F0EE0}', -- cpu-64-bit
- gpu = '\u{F083E}', -- video-4k-box (no GPU glyph exists)
- disks = '\u{F02CA}', -- harddisk
cache = '\u{F0ABA}', -- folder-clock
- network = '\u{F0317}', -- lan
slackware = '\u{F318}', -- linux-slackware
breaktimer = '\u{F13AB}', -- timer
calendar = '\u{F0E17}', -- calendar-month
@@ -272,7 +268,7 @@ end
-- Colour for a value against two thresholds.
--
--- One function so all four system cards agree on the rule instead of each
+-- One function so the cards agree on the rule instead of each
-- re-deriving it, and so "what counts as busy" is stated in a single place.
-- `v`, `warn` and `crit` share whatever unit the caller is using: percent for
-- a filesystem, degrees for a sensor.
@@ -283,78 +279,6 @@ function M.threshold(v, warn, crit, colors)
return colors.ok
end
--- A horizontal bar: a dim full-width track with a filled portion over it.
---
--- frac is clamped rather than trusted: a filesystem at 100% and a load that
--- briefly computes above 1.0 must not draw past the track.
-function M.bar(cr, x, y, w, h, frac, colour, colors)
- frac = tonumber(frac) or 0
- if frac < 0 then frac = 0 elseif frac > 1 then frac = 1 end
-
- M.rgba(cr, colors.rule, 0.5)
- M.rounded_path(cr, x, y, w, h, h / 2)
- cairo_fill(cr)
-
- if frac > 0 then
- M.rgba(cr, colour, 1)
- M.rounded_path(cr, x, y, math.max(w * frac, h), h, h / 2)
- cairo_fill(cr)
- end
-end
-
--- A vertical bar, rising from its baseline. The equaliser's element.
-function M.vbar(cr, x, base_y, w, max_h, frac, colour, colors)
- frac = tonumber(frac) or 0
- if frac < 0 then frac = 0 elseif frac > 1 then frac = 1 end
-
- M.rgba(cr, colors.rule, 0.5)
- cairo_rectangle(cr, x, base_y - max_h, w, max_h)
- cairo_fill(cr)
-
- local h = max_h * frac
- if h > 0 then
- M.rgba(cr, colour, 1)
- cairo_rectangle(cr, x, base_y - h, w, h)
- cairo_fill(cr)
- end
-end
-
--- A ring gauge, after idea2.png: a dim full circle with an arc over it
--- covering `frac`, leaving the centre free for a glyph.
---
--- The arc starts at twelve o'clock and sweeps clockwise, which is what reads
--- as a gauge. Cairo's zero angle is at three o'clock and it sweeps clockwise
--- already, so the start is offset by -pi/2 rather than the direction being
--- reversed.
-function M.ring(cr, cx, cy, r, frac, colour, colors, width)
- frac = tonumber(frac) or 0
- if frac < 0 then frac = 0 elseif frac > 1 then frac = 1 end
- width = width or math.max(3, r * 0.18)
-
- -- Start a fresh path. A preceding card.text() leaves a current point behind,
- -- and cairo_arc() joins to it with a straight line, so without this every
- -- ring after a label is drawn with a stray chord to the text baseline.
- cairo_new_path(cr)
- local prev_width = cairo_get_line_width(cr)
- cairo_set_line_width(cr, width)
- cairo_set_line_cap(cr, CAIRO_LINE_CAP_ROUND)
-
- M.rgba(cr, colors.rule, 0.5)
- cairo_arc(cr, cx, cy, r, 0, math.pi * 2)
- cairo_stroke(cr)
-
- if frac > 0 then
- M.rgba(cr, colour, 1)
- cairo_arc(cr, cx, cy, r, -math.pi / 2, -math.pi / 2 + math.pi * 2 * frac)
- cairo_stroke(cr)
- end
- -- Leave the line state as it was found. A later stroke inheriting ROUND gets
- -- visibly rounded ends on the card's hairlines, and one inheriting this
- -- width gets a hairline several pixels thick.
- cairo_set_line_cap(cr, CAIRO_LINE_CAP_BUTT)
- cairo_set_line_width(cr, prev_width)
-end
-
-- The largest base size S at which every row of pieces still fits max_w,
-- measured at a reference size and scaled.
--
@@ -383,19 +307,6 @@ function M.fit_unit(cr, max_w, groups, ref)
return S
end
--- Bytes to a short human string: 1181116006 -> '1.1G'.
--- The card formats its own numbers because the samplers pass raw bytes.
-function M.human(bytes)
- local n = tonumber(bytes)
- if not n then return '--' end
- local units = { 'B', 'K', 'M', 'G', 'T', 'P' }
- local i = 1
- while n >= 1024 and i < #units do n = n / 1024; i = i + 1 end
- if i == 1 then return string.format('%d%s', math.floor(n), units[i]) end
- if n >= 100 then return string.format('%.0f%s', n, units[i]) end
- return string.format('%.1f%s', n, units[i])
-end
-
-- Shorten a string to `limit` characters, appending an ellipsis when it cuts.
--
-- By character, never by byte: Lua's string.sub counts bytes, so slicing a
@@ -417,58 +328,4 @@ function M.truncate(s, limit)
return s:sub(1, cut - 1) .. '\u{2026}'
end
--- A line chart: several series sharing one baseline and one vertical scale.
---
--- `series` is a list of { values = <array>, colour = <rgb> }. `max` is the
--- shared ceiling; passing one rather than computing per series is the whole
--- point, because two series scaled independently lie about their relative
--- size: a 200kB/s upload would draw the same height as a 40MB/s download.
---
--- Values are drawn oldest-left, one per array entry, so a caller sizing its
--- history to the pixel width gets one sample per column and no interpolation.
--- A series shorter than its buffer draws only what it has, which is what a
--- freshly started dashboard shows while the window fills.
-function M.plot(cr, x, y, w, h, series, max, colors)
- if not (w > 0 and h > 0) then return end
- max = tonumber(max) or 0
- if max <= 0 then max = 1 end -- an idle link is a flat line, not a division by zero
-
- local prev_width = cairo_get_line_width(cr)
- local prev_cap = cairo_get_line_cap(cr)
-
- -- The baseline, so an empty chart still reads as a chart rather than a gap.
- M.rgba(cr, colors.rule, 0.5)
- cairo_new_path(cr)
- cairo_set_line_width(cr, 1)
- cairo_move_to(cr, x, y + h)
- cairo_line_to(cr, x + w, y + h)
- cairo_stroke(cr)
-
- cairo_set_line_width(cr, math.max(1.5, h * 0.02))
- cairo_set_line_cap(cr, CAIRO_LINE_CAP_ROUND)
-
- for _, s in ipairs(series) do
- local v = s.values
- local n = #v
- if n >= 2 then
- -- One sample per column when the caller sized its buffer to the width.
- local step = w / math.max(n - 1, 1)
- M.rgba(cr, s.colour, 1)
- cairo_new_path(cr)
- for i = 1, n do
- local frac = v[i] / max
- if frac < 0 then frac = 0 elseif frac > 1 then frac = 1 end
- local px = x + (i - 1) * step
- local py = y + h - frac * h
- if i == 1 then cairo_move_to(cr, px, py) else cairo_line_to(cr, px, py) end
- end
- cairo_stroke(cr)
- end
- end
-
- -- Leave the line state as it was found, for the same reason card.ring does.
- cairo_set_line_width(cr, prev_width)
- cairo_set_line_cap(cr, prev_cap)
-end
-
return M
diff --git a/lib/data.lua b/lib/data.lua
index d02d0f7..804398e 100644
--- a/lib/data.lua
+++ b/lib/data.lua
@@ -1,54 +1,13 @@
--- Readers for /proc and /sys.
+-- Parsers for the sampler caches.
--
-- Every function takes the file *contents* as a string rather than reading the
-- file itself, so the parsers are testable against fixtures without mocking the
--- filesystem. The thin read-and-parse wrappers live at the bottom.
+-- filesystem.
local M = {}
--- Aggregate CPU jiffies from /proc/stat. Returns total, idle.
--- iowait counts as idle: a core waiting on disk is not doing work, and
--- treating it as busy makes a disk-bound system look CPU-bound.
-function M.cpu_times(stat)
- local line = stat:match('^cpu%s+([^\n]+)')
- if not line then return nil, nil end
- local v = {}
- for n in line:gmatch('%d+') do v[#v + 1] = tonumber(n) end
- if #v < 5 then return nil, nil end
- local total = 0
- for _, n in ipairs(v) do total = total + n end
- return total, v[4] + v[5] -- idle + iowait
-end
-
--- /proc/meminfo, values in kB as the file states them.
-function M.mem_info(meminfo)
- local function field(name)
- return tonumber(meminfo:match(name .. ':%s+(%d+)'))
- end
- local total = field('MemTotal')
- local available = field('MemAvailable')
- if not (total and available) then return nil end
- return {
- total = total,
- free = field('MemFree'),
- available = available,
- -- MemAvailable already excludes reclaimable cache, so this is the figure a
- -- user recognises as "used", unlike total-free which counts cache.
- used = total - available,
- }
-end
-
--- hwmon temp*_input is millidegrees C. Returns whole degrees, or nil.
--- nil rather than 0 on failure: 0 C is a legitimate reading.
-function M.millidegrees(s)
- if not s then return nil end
- local n = tonumber(s:match('^%s*(-?%d+)'))
- if not n then return nil end
- return math.floor(n / 1000 + 0.5)
-end
-
--- Read a whole file, returning nil if it cannot be read. Used by the wrappers
--- so a vanished sysfs path degrades to nil instead of raising.
+-- Read a whole file, returning nil if it cannot be read, so a missing cache
+-- degrades to nil instead of raising.
function M.slurp(path)
local f = io.open(path, 'r')
if not f then return nil end
@@ -57,192 +16,6 @@ function M.slurp(path)
return s
end
--- Find a hwmon directory by the exact contents of its `name` file.
---
--- Globbing by name, never by a fixed hwmon index: indices drift across kernel
--- and hardware reorders, and a stale index silently reports a different chip.
--- Carried over from the previous conky config, where this was the hard-won bit.
---
--- The result is memoized per chip name, hits and misses alike. A chip's hwmon
--- directory is stable for the process's life, and the draw hook calls this
--- several times per frame: without the cache every sensor read popens grep,
--- which is exactly the subprocess-per-draw the samplers exist to avoid. A miss
--- is cached too, so an absent chip is not re-globbed on every frame.
-local hwmon_cache = {}
-
-function M.hwmon_dir(name)
- if type(name) ~= 'string' then return nil end
- if hwmon_cache[name] == nil then
- local p = io.popen('grep -lx ' .. ("%q"):format(name)
- .. ' /sys/class/hwmon/hwmon*/name 2>/dev/null')
- local dir = false
- if p then
- local hit = p:read('*l')
- p:close()
- if hit then dir = hit:match('^(.*)/name$') end
- end
- hwmon_cache[name] = dir
- end
- return hwmon_cache[name] or nil
-end
-
--- A stateful CPU-load counter.
---
--- Load is work done between two samples, so a single reading cannot produce a
--- percentage. Each counter keeps its own previous sample, which also means a
--- per-core counter is just another instance.
-function M.new_cpu_counter()
- return {
- prev_total = nil,
- prev_idle = nil,
- -- Returns busy percent since the previous sample, or nil when there is no
- -- usable delta (first call, or the counters did not advance).
- sample = function(self, total, idle)
- if not (total and idle) then return nil end
- local pt, pi = self.prev_total, self.prev_idle
- self.prev_total, self.prev_idle = total, idle
- if not pt then return nil end
- local dt = total - pt
- if dt <= 0 then return nil end
- local busy = (dt - (idle - pi)) / dt * 100
- -- Clamp: a counter reset or a suspend/resume can produce a nonsense
- -- delta, and a bar drawn at -12% or 340% is worse than a clamped one.
- if busy < 0 then busy = 0 elseif busy > 100 then busy = 100 end
- return busy
- end,
- }
-end
-
--- A stateful byte-rate counter, for an interface's rx/tx totals.
---
--- Same shape as new_cpu_counter and for the same reason: the counters are
--- cumulative, so one reading cannot produce a rate, and each direction needs
--- its own previous sample.
---
--- `fallback_dt` is the draw interval, used when two samples land in the same
--- clock second. os.time() resolves to whole seconds and the dashboard draws
--- every two, so equal timestamps are ordinary, not exceptional.
-function M.new_rate_counter(fallback_dt)
- return {
- prev_bytes = nil,
- prev_time = nil,
- -- Returns bytes per second since the previous sample, or nil when there is
- -- no usable delta (first call, or a nil reading from a vanished interface).
- sample = function(self, bytes, now)
- if type(bytes) ~= 'number' then return nil end
- now = now or os.time()
- local pb, pt = self.prev_bytes, self.prev_time
- self.prev_bytes, self.prev_time = bytes, now
- if not pb then return nil end
-
- local dt = now - pt
- -- Same second, or a clock that went backwards over an NTP step.
- if dt <= 0 then dt = fallback_dt or 2 end
-
- local db = bytes - pb
- -- A negative delta is a 32-bit wrap or an interface reset. Zero, never
- -- the huge positive the wrap arithmetic would imply: one bogus sample
- -- sets the shared autoscale and flattens the whole window.
- if db < 0 then db = 0 end
-
- return db / dt
- end,
- }
-end
-
--- Per-core jiffies from /proc/stat, in file order, so entry N is core N.
---
--- Separate from cpu_times() rather than a flag on it: the aggregate is a
--- single pair and this is a list, and a function returning one or the other
--- depending on an argument is worse than two functions.
---
--- The leading 'cpu ' aggregate is excluded by requiring a digit after 'cpu',
--- since counting it would draw an extra bar showing the average alongside the
--- real cores.
-function M.per_cpu_times(stat)
- local out = {}
- if type(stat) ~= 'string' then return out end
- for line in stat:gmatch('[^\n]+') do
- local nums = line:match('^cpu%d+%s+(.+)$')
- if nums then
- local v = {}
- for n in nums:gmatch('%d+') do v[#v + 1] = tonumber(n) end
- -- Needs at least user..iowait to compute a busy fraction; a shorter line
- -- is truncated or from a kernel that reports differently, and a partial
- -- sum would look plausible while being wrong.
- if #v >= 5 then
- local total = 0
- for _, n in ipairs(v) do total = total + n end
- out[#out + 1] = { total = total, idle = v[4] + v[5] }
- end
- end
- end
- return out
-end
-
--- A named hwmon sensor's value in whole degrees, or nil.
---
--- Wraps hwmon_dir + slurp + millidegrees so a widget names the chip and the
--- file rather than building paths. nil, never 0: zero degrees is a plausible
--- reading and must not be indistinguishable from a missing sensor.
-function M.sensor(chip, file)
- if type(chip) ~= 'string' or type(file) ~= 'string' then return nil end
- local dir = M.hwmon_dir(chip)
- if not dir then return nil end
- return M.millidegrees(M.slurp(dir .. '/' .. file))
-end
-
--- A raw hwmon integer (fan RPM, power microwatts), or nil. Same lookup as
--- sensor() without the millidegree conversion.
-function M.sensor_raw(chip, file)
- if type(chip) ~= 'string' or type(file) ~= 'string' then return nil end
- local dir = M.hwmon_dir(chip)
- if not dir then return nil end
- local s = M.slurp(dir .. '/' .. file)
- if not s then return nil end
- return tonumber(s:match('^%s*(-?%d+)'))
-end
-
--- Parse `df -P -B1` output into a list of filesystems.
---
--- The mountpoint is taken as the LAST field, not the sixth. df -P guarantees
--- one line per filesystem with the mountpoint last, and an NFS device reads
--- 'server:/export', so counting fields from the left is fragile in a way
--- counting from the right is not.
---
--- Sizes are bytes because the sampler passes -B1. Never parse `df -h` output
--- here: the user's shell aliases df to df -h, so a sampler calling bare df
--- would hand this function '1.6G' where it expects an integer.
-function M.df_parse(text)
- local out = {}
- if type(text) ~= 'string' then return out end
- for line in text:gmatch('[^\n]+') do
- -- A data row ends in 'NN% /some/path'. The header ends in 'Mounted on',
- -- which fails the percent match, so it is skipped without a special case.
- local size, used, avail, pct, mount =
- line:match('(%d+)%s+(%d+)%s+(%d+)%s+(%d+)%%%s+(%S+)%s*$')
- if size then
- -- The device is kept so a caller can tell an NFS mount from a local one
- -- and recover its server, which is how the disks card labels the two
- -- shares without hardcoding an address in the repo.
- local dev = line:match('^(%S+)')
- out[#out + 1] = {
- mount = mount,
- dev = dev,
- host = dev and dev:match('^([^/:]+):') or nil,
- size = tonumber(size),
- used = tonumber(used),
- -- Read, not derived: size - used overstates free space by the
- -- root-reserved blocks, which is tens of gigabytes on a large
- -- filesystem and is not available to anyone but root.
- avail = tonumber(avail),
- pct = tonumber(pct),
- }
- end
- end
- return out
-end
-
-- Human-readable size to bytes: '1.1G' -> 1181116006.
--
-- Needed because du -sh is what the sampler runs (its output is also what the
@@ -302,107 +75,6 @@ function M.kv_parse(text)
return out
end
--- The CPU's marketing name, stripped to what identifies it.
---
--- '/proc/cpuinfo' repeats the model name once per thread; the first is enough.
--- The stripping is rules, not a table of known CPUs: a leading vendor word,
--- the registered-trademark noise, and a trailing core count or clock, all of
--- which are constant boilerplate that costs a third of the row on a card
--- measured in pixels.
---
--- AMD Ryzen 7 9700X 8-Core Processor -> Ryzen 7 9700X
--- Intel(R) Core(TM) i7-8700K CPU @ 3.7GHz -> Core i7-8700K
-function M.cpu_model(cpuinfo)
- if type(cpuinfo) ~= 'string' then return nil end
- local s = cpuinfo:match('model name%s*:%s*([^\n]+)')
- if not s then return nil end
- s = s:gsub('%(R%)', ''):gsub('%(TM%)', ''):gsub('%(tm%)', '')
- -- The vendor word can sit behind a generation prefix ('11th Gen Intel Core
- -- i7-1165G7'), so the prefix goes first and the vendor anchor is applied
- -- after it rather than only at the very start.
- s = s:gsub('^%s*%d+th Gen%s+', '')
- s = s:gsub('^%s*AMD%s+', ''):gsub('^%s*Intel%s+', '')
- s = s:gsub('%s+%d+%-Core Processor.*$', '')
- -- The clock tail, with or without a literal 'CPU' before the '@'. A Xeon
- -- reads 'E5-2680 v4 @ 2.40GHz' and a Tiger Lake 'i7-1165G7 @ 2.80GHz', so
- -- requiring the CPU token leaves the clock on the card for everything but
- -- the one form the fixture happens to carry.
- -- The clock tail goes first, then the bare 'CPU' token wherever it sits: a
- -- Xeon reads 'Xeon CPU E5-2680 v4 @ 2.40GHz', so the token is mid-string,
- -- not trailing.
- s = s:gsub('%s*@.*$', ''):gsub('%s+CPU%f[%A]', '')
- s = s:gsub('%s+Processor%s*$', '')
- s = s:gsub('%s+', ' '):gsub('^%s+', ''):gsub('%s+$', '')
- if s == '' then return nil end
- return s
-end
-
--- The motherboard, from the two world-readable DMI files.
---
--- board_vendor and board_name are readable without privilege, unlike the
--- serial fields in the same directory. The vendor's corporate suffix is
--- dropped because every vendor has one and none of it identifies the board.
---
--- A board reporting the DMI placeholder is treated as no board at all:
--- 'To Be Filled By O.E.M.' on the dashboard is worse than a blank row.
-local DMI_PLACEHOLDER = {
- ['to be filled by o.e.m.'] = true,
- ['system manufacturer'] = true,
- ['default string'] = true,
- ['unknown'] = true,
- ['n/a'] = true,
-}
-
-local function dmi_clean(s)
- if type(s) ~= 'string' then return nil end
- s = s:gsub('%s+', ' '):gsub('^%s+', ''):gsub('%s+$', '')
- if s == '' or DMI_PLACEHOLDER[s:lower()] then return nil end
- return s
-end
-
-function M.board_name(vendor, name)
- vendor = dmi_clean(vendor)
- name = dmi_clean(name)
- if vendor then
- -- Corporate boilerplate, longest first so 'Co., Ltd.' does not leave 'Co.'
- vendor = vendor:gsub('%s+Technology Co%.,? Ltd%.?$', '')
- vendor = vendor:gsub('%s+COMPUTER INC%.?$', '')
- vendor = vendor:gsub('%s+Corporation$', ''):gsub('%s+Corp%.?$', '')
- vendor = vendor:gsub('%s+Inc%.?$', ''):gsub('%s+INC%.?$', '')
- vendor = vendor:gsub('%s+Co%.,?%s*Ltd%.?$', ''):gsub('%s+CO%.,?%s*LTD%.?$', '')
- vendor = vendor:gsub('%s+GmbH$', ''):gsub('%s+LLC$', '')
- vendor = vendor:gsub('%s+$', '')
- if vendor == '' then vendor = nil end
- end
- if vendor and name then return vendor .. ' ' .. name end
- return name or vendor
-end
-
--- The IPv4 address from `ip -4 addr show <iface>` output.
---
--- Split from the command that produces it so it can be tested against a
--- fixture: every other parser in this file follows the same split, and an
--- address is exactly the kind of value that must not be hardcoded in a test.
-function M.iface_addr_parse(text)
- if type(text) ~= 'string' then return nil end
- return text:match('inet%s+(%d+%.%d+%.%d+%.%d+)')
-end
-
--- The interface's IPv4 address, or nil when it has none.
---
--- This shells out, which the draw hook otherwise never does, so the caller
--- memoizes it: an address does not change without an event this dashboard
--- does not watch. Retried while nil, because a bridge may not be up when
--- conky starts.
-function M.iface_addr(iface)
- if type(iface) ~= 'string' then return nil end
- local p = io.popen('ip -4 addr show ' .. ("%q"):format(iface) .. ' 2>/dev/null')
- if not p then return nil end
- local out = p:read('*a')
- p:close()
- return M.iface_addr_parse(out)
-end
-
-- The calendar cache written by bin/calendar-sample.sh.
--
-- Returns { today = { y, m, d }, colors = { <calendar> = <khal colour name> },
@@ -631,26 +303,6 @@ function M.immich_parse(text)
return out
end
--- A sampling gate for counters that are read per draw. The dashboard draws
--- every 0.1s while it is on screen, and a CPU or byte counter sampled that
--- often gives coarse, flickering deltas, so those cards sample on this gate
--- and redraw their last values in between.
---
--- The returned function answers true at most once per `every` seconds, plus
--- the clock it used: /proc/uptime, because os.time() counts whole seconds and
--- a rate divided by a rounded interval is off by up to half. `now` is for the
--- test. An unreadable clock opens the gate, which is the old per-draw behaviour.
-function M.new_gate(every)
- local last
- return function(now)
- now = now or tonumber((M.slurp('/proc/uptime') or ''):match('^(%S+)'))
- if not now then return true, nil end
- if last and now - last < every then return false, now end
- last = now
- return true, now
- end
-end
-
-- nvtable's plain table (`nvtable -C -E`), as bin/updates-sample.sh caches it:
--
-- [personal] foo-bin 1.14.9 -> 1.14.9 !! ~ 1.14.10
@@ -690,45 +342,4 @@ function M.nvtable_parse(text)
return seen and out or nil
end
--- VRAM in use on an xe device, in bytes, from DRM fdinfo: the same source
--- gputop reads. Input is `grep -H` output, one 'path:key:<tab>value' line per
--- field, so fields are grouped by the fd file they came from.
---
--- One client (drm-client-id) is usually open on several fds, each repeating
--- the client's full totals, so clients are counted once, not fds. Buffers
--- shared between clients are still counted in each, as gputop does.
-local UNIT = { B = 1, KiB = 1024, MiB = 1024 ^ 2, GiB = 1024 ^ 3 }
-
-function M.drm_vram_parse(text)
- if type(text) ~= 'string' then return nil end
- local fds = {}
- for path, key, val in text:gmatch('([^\n]-):(drm%-[%w%-]+):%s*([^\n]*)') do
- local fd = fds[path] or {}
- fds[path] = fd
- fd[key] = val
- end
- local seen, total = {}, nil
- for _, fd in pairs(fds) do
- local id = fd['drm-client-id']
- local n, unit = (fd['drm-resident-vram0'] or ''):match('^(%d+)%s*(%a*)')
- if fd['drm-driver'] == 'xe' and id and n and not seen[id] then
- seen[id] = true
- total = (total or 0) + tonumber(n) * (UNIT[unit ~= '' and unit or 'B'] or 1)
- end
- end
- return total
-end
-
--- ponytail: a popen per call, ~10ms warm; callers gate it. Only fds of
--- processes readable by conky's user are seen, so another user's GPU client
--- (a system service) is not counted.
-function M.drm_vram()
- local p = io.popen("grep -sH -e '^drm-driver' -e '^drm-client-id'"
- .. " -e '^drm-resident-vram0' /proc/[0-9]*/fdinfo/* 2>/dev/null")
- if not p then return nil end
- local out = p:read('*a')
- p:close()
- return M.drm_vram_parse(out)
-end
-
return M
diff --git a/test/fixtures/df_output b/test/fixtures/df_output
deleted file mode 100644
index 9205c83..0000000
--- a/test/fixtures/df_output
+++ /dev/null
@@ -1,7 +0,0 @@
-Filesystem 1-blocks Used Available Capacity Mounted on
-/dev/mapper/myvg-root 263174213632 208111570944 41746907136 84% /
-/dev/mapper/myvg-home 719407267840 219043332096 463804289024 33% /home
-/dev/sda1 983350091776 547869650944 385453473792 59% /data
-server-a:/Volume1/Library 3958241259520 2966853025792 815851831296 79% /mnt/nfs/Library
-server-b:/mnt/HD/HD_a2/share 982889670656 480039763968 503676862464 49% /mnt/nfs/shared
-/dev/full 100000000000 100000000000 0 100% /mnt/full
diff --git a/test/fixtures/ip_addr b/test/fixtures/ip_addr
deleted file mode 100644
index 8d3f212..0000000
--- a/test/fixtures/ip_addr
+++ /dev/null
@@ -1,3 +0,0 @@
-4: br0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default qlen 1000
- inet 192.0.2.15/24 brd 192.0.2.255 scope global br0
- valid_lft forever preferred_lft forever
diff --git a/test/fixtures/proc_cpuinfo b/test/fixtures/proc_cpuinfo
deleted file mode 100644
index e9cd523..0000000
--- a/test/fixtures/proc_cpuinfo
+++ /dev/null
@@ -1,12 +0,0 @@
-processor : 0
-vendor_id : AuthenticAMD
-cpu family : 26
-model : 68
-model name : AMD Ryzen 7 9700X 8-Core Processor
-stepping : 0
-cpu MHz : 4491.436
-cache size : 1024 KB
-
-processor : 1
-vendor_id : AuthenticAMD
-model name : AMD Ryzen 7 9700X 8-Core Processor
diff --git a/test/fixtures/proc_cpuinfo_intel b/test/fixtures/proc_cpuinfo_intel
deleted file mode 100644
index 886b589..0000000
--- a/test/fixtures/proc_cpuinfo_intel
+++ /dev/null
@@ -1,6 +0,0 @@
-processor : 0
-vendor_id : GenuineIntel
-cpu family : 6
-model : 158
-model name : Intel(R) Core(TM) i7-8700K CPU @ 3.70GHz
-stepping : 10
diff --git a/test/fixtures/proc_meminfo b/test/fixtures/proc_meminfo
deleted file mode 100644
index 9c279aa..0000000
--- a/test/fixtures/proc_meminfo
+++ /dev/null
@@ -1,7 +0,0 @@
-MemTotal: 31943076 kB
-MemFree: 4291244 kB
-MemAvailable: 25627088 kB
-Buffers: 123456 kB
-Cached: 6789012 kB
-SwapTotal: 8388604 kB
-SwapFree: 8388604 kB
diff --git a/test/fixtures/proc_stat b/test/fixtures/proc_stat
deleted file mode 100644
index 2914cca..0000000
--- a/test/fixtures/proc_stat
+++ /dev/null
@@ -1,5 +0,0 @@
-cpu 209094 2199 135469 11604898 24408 0 946 0 0 0
-cpu0 5294 11 2928 740049 460 0 592 0 0 0
-cpu1 5210 18 2801 740512 431 0 12 0 0 0
-intr 12345678
-ctxt 987654321
diff --git a/test/fixtures/proc_stat_percore b/test/fixtures/proc_stat_percore
deleted file mode 100644
index cbf62f2..0000000
--- a/test/fixtures/proc_stat_percore
+++ /dev/null
@@ -1,11 +0,0 @@
-cpu 293470 5201 186259 16513195 82679 0 2095 0 0 0
-cpu0 6441 24 3225 1056496 1932 0 760 0 0 0
-cpu1 23587 123 15762 1018523 8299 0 112 0 0 0
-cpu2 9859 24 6098 1049850 2386 0 81 0 0 0
-cpu3 11200 30 7000 1040000 2500 0 90 0 0 0
-intr 123456789 0 0 0
-ctxt 987654321
-btime 1789600000
-processes 54321
-procs_running 2
-procs_blocked 0
diff --git a/test/fixtures/temp1_input b/test/fixtures/temp1_input
deleted file mode 100644
index 46fba94..0000000
--- a/test/fixtures/temp1_input
+++ /dev/null
@@ -1 +0,0 @@
-53125
diff --git a/test/test_data.lua b/test/test_data.lua
index 549ce40..37a302e 100644
--- a/test/test_data.lua
+++ b/test/test_data.lua
@@ -20,141 +20,7 @@ local function read(path)
return s
end
--- cpu_times: total and idle jiffies from the aggregate "cpu " line.
-local total, idle = data.cpu_times(read('test/fixtures/proc_stat'))
--- 209094+2199+135469+11604898+24408+0+946 = 11977014
-assert(total == 11977014, 'cpu total, got ' .. tostring(total))
--- idle field is the 4th value, 11604898; iowait (24408) counts as idle too
-assert(idle == 11629306, 'cpu idle, got ' .. tostring(idle))
-
--- mem_info: values in kB, as the file gives them.
-local mem = data.mem_info(read('test/fixtures/proc_meminfo'))
-assert(mem.total == 31943076, 'mem total, got ' .. tostring(mem.total))
-assert(mem.available == 25627088, 'mem available, got ' .. tostring(mem.available))
--- used is total minus available, which is what a user means by "used"
-assert(mem.used == 6315988, 'mem used, got ' .. tostring(mem.used))
-
--- millidegrees: hwmon temp*_input is millidegrees C, rounded to whole degrees.
-assert(data.millidegrees(read('test/fixtures/temp1_input')) == 53,
- 'temp, got ' .. tostring(data.millidegrees(read('test/fixtures/temp1_input'))))
--- A missing or unreadable sensor must yield nil, not an error and not 0:
--- 0 degrees is a plausible reading and would be indistinguishable from failure.
-assert(data.millidegrees(nil) == nil, 'nil input must give nil')
-assert(data.millidegrees('') == nil, 'empty input must give nil')
-assert(data.millidegrees('garbage') == nil, 'unparseable input must give nil')
-
--- CPU load is a delta between two samples, so the counter holds state.
--- The first call has no previous sample and must report nil, not a number:
--- any number it invented would be wrong, and 100% on startup looks like a
--- real spike.
-local c = data.new_cpu_counter()
-assert(c:sample(1000, 900) == nil, 'first sample must give nil')
-
--- Second sample: 100 more total jiffies, 50 of them idle, so 50% busy.
--- Held in a local first: calling sample() again inside the assert message would
--- advance the counter a third time.
-local busy = c:sample(1100, 950)
-assert(busy == 50.0, 'second sample, got ' .. tostring(busy))
-
--- A counter that did not advance means no elapsed time, not 0% load.
-local c2 = data.new_cpu_counter()
-c2:sample(1000, 900)
-assert(c2:sample(1000, 900) == nil, 'zero delta must give nil, not a division by zero')
-
--- === Per-core CPU =========================================================
--- The aggregate line answers "how busy is the machine"; the equaliser needs
--- one entry per core. Both come from the same file, so they are parsed by the
--- same rules and differ only in which lines they read.
-local percore = read('test/fixtures/proc_stat_percore')
-local cores = data.per_cpu_times(percore)
-assert(#cores == 4, 'one entry per cpuN line, got ' .. tostring(#cores))
-
--- cpu0: 6441+24+3225+1056496+1932+0+760 = 1068878 total, idle+iowait = 1058428
-assert(cores[1].total == 1068878, 'core 0 total, got ' .. tostring(cores[1].total))
-assert(cores[1].idle == 1058428, 'core 0 idle, got ' .. tostring(cores[1].idle))
-
--- Ordering matters: bar N must be core N, so the list follows the file.
-assert(cores[2].total == 1066406, 'core 1 total, got ' .. tostring(cores[2].total))
-
--- The aggregate 'cpu ' line must NOT be counted as a core: it would draw a
--- bar for the aggregate line showing the average, which looks like a real core.
-for i, c in ipairs(cores) do
- assert(c.total < 2000000, 'entry ' .. i .. ' looks like the aggregate line')
-end
-
--- A counter per core is just another instance, which is why new_cpu_counter
--- holds its own previous sample rather than using a module-level one.
-local c0 = data.new_cpu_counter()
-assert(c0:sample(cores[1].total, cores[1].idle) == nil, 'first sample is nil')
-local busy = c0:sample(cores[1].total + 100, cores[1].idle + 25)
-assert(busy == 75.0, 'second sample 75%, got ' .. tostring(busy))
-
--- Malformed input yields an empty list, never an error: a raise here is a
--- blank dashboard.
-assert(#data.per_cpu_times('') == 0, 'empty input gives an empty list')
-assert(#data.per_cpu_times('garbage\nlines\n') == 0, 'garbage gives an empty list')
-assert(#data.per_cpu_times(nil) == 0, 'nil gives an empty list')
--- A truncated line (fewer than the 5 fields the maths needs) is skipped
--- rather than producing a nonsense total.
-assert(#data.per_cpu_times('cpu0 1 2\n') == 0, 'a short line is skipped')
-
-- === Cache files ==========================================================
--- df -P guarantees one line per mount with the mountpoint LAST, which is why
--- the parser takes the last field rather than the sixth: an NFS device is
--- 'server:/export' and a device name can carry surprises, but the mountpoint
--- is always at the end.
-local fs = data.df_parse(read('test/fixtures/df_output'))
-assert(#fs == 6, 'one entry per mount, got ' .. tostring(#fs))
-assert(fs[1].mount == '/', 'first mount, got ' .. tostring(fs[1].mount))
-assert(fs[1].pct == 84, 'root percent, got ' .. tostring(fs[1].pct))
-assert(fs[1].size == 263174213632, 'root size in bytes, got ' .. tostring(fs[1].size))
-assert(fs[1].used == 208111570944, 'root used, got ' .. tostring(fs[1].used))
-
--- Available is READ, never derived as size - used: the two differ by the
--- root-reserved blocks, about 5%, which on this root is some 13GB that exists
--- but cannot be used. A card claiming that space is free would be lying.
-assert(fs[1].avail == 41746907136, 'root available, got ' .. tostring(fs[1].avail))
-assert(fs[1].size - fs[1].used ~= fs[1].avail,
- 'the fixture must exercise the reserved-block gap, or this assertion proves nothing')
-
--- fs[3] is /dev/sda1: a device name ENDING IN A DIGIT, next to a numeric
--- column. A parser counting fields from the left, or matching digits without
--- anchoring, reads the partition number as a size.
-assert(fs[3].mount == '/data', 'sda1 mount, got ' .. tostring(fs[3].mount))
-assert(fs[3].dev == '/dev/sda1', 'sda1 device, got ' .. tostring(fs[3].dev))
-assert(fs[3].size == 983350091776, 'sda1 size, got ' .. tostring(fs[3].size))
-assert(fs[3].used == 547869650944, 'sda1 used, got ' .. tostring(fs[3].used))
-assert(fs[3].avail == 385453473792, 'sda1 available, got ' .. tostring(fs[3].avail))
-assert(fs[3].pct == 59, 'sda1 percent, got ' .. tostring(fs[3].pct))
-assert(fs[3].host == nil, 'a local device has no host')
-
--- A full filesystem reports zero available, which is a real reading.
-assert(fs[6].avail == 0, 'a full mount has 0 available, got ' .. tostring(fs[6].avail))
-
--- The NFS rows are the reason the last field matters: a colon in the device
--- would break a parser splitting on punctuation.
-assert(fs[4].mount == '/mnt/nfs/Library', 'nfs mount, got ' .. tostring(fs[4].mount))
-assert(fs[4].pct == 79, 'nfs percent, got ' .. tostring(fs[4].pct))
-
--- The device field carries the server, which is what lets the disks card
--- label the two shares without an address hardcoded in the repo.
-assert(fs[4].host == 'server-a', 'nfs host, got ' .. tostring(fs[4].host))
-assert(fs[1].host == nil, 'a local device has no host, got ' .. tostring(fs[1].host))
-
--- A full filesystem is a real state, not an error.
-assert(fs[6].pct == 100, 'a full mount reads 100, got ' .. tostring(fs[6].pct))
-
--- The header line must not become a row.
-for _, e in ipairs(fs) do
- assert(e.mount ~= 'on' and e.mount ~= 'Mounted', 'the header leaked in as a row')
-end
-
-assert(#data.df_parse('') == 0, 'empty input gives an empty list')
-assert(#data.df_parse(nil) == 0, 'nil gives an empty list')
--- A header with no rows is what a failed df produces.
-assert(#data.df_parse('Filesystem 1-blocks Used Available Capacity Mounted on\n') == 0,
- 'a header alone gives an empty list')
-
-- du -sh: total first, then children largest-first. The suffixes must be
-- converted, not string-sorted: '1.1G' outranks '245M' numerically and loses
-- to it alphabetically.
@@ -199,46 +65,6 @@ assert(cut == string.rep('\u{00E9}', 9) .. '\u{2026}',
assert(card.truncate(nil, 10) == '', 'nil truncates to empty')
assert(card.truncate('abc', 0) == '', 'a zero limit gives empty')
--- === Network rate =========================================================
--- Interface byte counters are cumulative, so a rate is a delta over elapsed
--- time. The first call has no previous sample and must report nil: any number
--- it invented would be wrong, and a spike at startup looks real.
-local r = data.new_rate_counter()
-assert(r:sample(1000, 100) == nil, 'first sample must give nil')
-
--- 2048 bytes over 2 seconds is 1024 B/s.
-local rate = r:sample(3048, 102)
-assert(rate == 1024, 'second sample, got ' .. tostring(rate))
-
--- A counter that went backwards means a wrap or an interface reset. It must
--- clamp to zero, never produce the huge positive an unsigned wrap implies:
--- one bogus sample poisons the shared autoscale for the whole window.
-local r2 = data.new_rate_counter()
-r2:sample(5000, 100)
-assert(r2:sample(10, 102) == 0, 'a counter reset must give 0')
-
--- Two samples inside the same clock second. os.time() has whole-second
--- resolution against a 2s draw interval, so this happens in normal operation
--- and must not divide by zero.
-local r3 = data.new_rate_counter()
-r3:sample(1000, 500)
-local same_second = r3:sample(2000, 500)
-assert(same_second ~= nil and same_second >= 0,
- 'a zero time delta must fall back to the draw interval, got ' .. tostring(same_second))
-
--- A missing interface reads nil, which must propagate rather than raise.
-local r4 = data.new_rate_counter()
-assert(r4:sample(nil, 100) == nil, 'a nil byte count gives nil')
-
--- The fallback interval is a constructor argument, and the widget passes
--- conky's real update_interval rather than letting it default. That is the
--- path that ships, so it is the path that gets a test: at a 5s interval the
--- same 2048 bytes must read as 409.6 B/s, not 1024.
-local r5 = data.new_rate_counter(5)
-r5:sample(1000, 100)
-assert(r5:sample(3048, 100) == 2048 / 5,
- 'an explicit fallback interval must be used, got ' .. tostring(r5:sample(3048, 100)))
-
-- === kv_parse =============================================================
-- The slackware sampler writes 'key value' lines. The format is deliberately
-- dumber than the data: epochs stay integers rather than becoming formatted
@@ -260,68 +86,6 @@ assert(type(data.kv_parse('')) == 'table', 'empty input gives a table')
assert(type(data.kv_parse(nil)) == 'table', 'nil gives a table')
assert(data.kv_parse('keyonly\n').keyonly == nil, 'a line with no value is skipped')
--- === Hardware identification ==============================================
--- Both strings are constant for the machine's life, so the widget memoizes
--- them. What is tested here is the stripping, which must be a rule rather
--- than a special case for one host: the Intel fixture exists to prove it.
-assert(data.cpu_model(read('test/fixtures/proc_cpuinfo')) == 'Ryzen 7 9700X',
- 'amd cpu, got ' .. tostring(data.cpu_model(read('test/fixtures/proc_cpuinfo'))))
-assert(data.cpu_model(read('test/fixtures/proc_cpuinfo_intel')) == 'Core i7-8700K',
- 'intel cpu, got ' .. tostring(data.cpu_model(read('test/fixtures/proc_cpuinfo_intel'))))
-
--- Unparseable input yields nil, so the card shows nothing rather than a
--- half-stripped string.
-assert(data.cpu_model('') == nil, 'empty cpuinfo gives nil')
-assert(data.cpu_model(nil) == nil, 'nil cpuinfo gives nil')
-assert(data.cpu_model('processor\t: 0\n') == nil, 'cpuinfo with no model name gives nil')
-
--- The board is two sysfs files joined, with the vendor's corporate suffix
--- dropped: it is boilerplate on every board and costs a third of the row.
-assert(data.board_name('Gigabyte Technology Co., Ltd.\n', 'X870 EAGLE WIFI7\n')
- == 'Gigabyte X870 EAGLE WIFI7',
- 'board, got ' .. tostring(data.board_name('Gigabyte Technology Co., Ltd.\n', 'X870 EAGLE WIFI7\n')))
-assert(data.board_name('ASUSTeK COMPUTER INC.\n', 'PRIME B650-PLUS\n')
- == 'ASUSTeK PRIME B650-PLUS',
- 'asus board, got ' .. tostring(data.board_name('ASUSTeK COMPUTER INC.\n', 'PRIME B650-PLUS\n')))
-
--- A machine that reports one and not the other shows what it has.
-assert(data.board_name(nil, 'X870 EAGLE WIFI7\n') == 'X870 EAGLE WIFI7',
- 'name alone, got ' .. tostring(data.board_name(nil, 'X870 EAGLE WIFI7\n')))
-assert(data.board_name('Gigabyte\n', nil) == 'Gigabyte', 'vendor alone')
-assert(data.board_name(nil, nil) == nil, 'neither gives nil')
-
--- A virtual machine reports placeholder DMI strings. Showing 'To be filled by
--- O.E.M.' as the motherboard is worse than showing nothing.
-assert(data.board_name('To Be Filled By O.E.M.\n', 'To Be Filled By O.E.M.\n') == nil,
- 'placeholder DMI gives nil')
-
--- Three Intel forms the single fixture does not cover. The first fixture
--- happens to carry a literal 'CPU' before the clock, so a pattern requiring
--- that token passes the fixture and still leaves '@ 2.40GHz' on the card for
--- every other Intel part. These exist so the stripping stays a rule.
-assert(data.cpu_model('model name\t: Intel(R) Xeon(R) CPU E5-2680 v4 @ 2.40GHz\n')
- == 'Xeon E5-2680 v4',
- 'xeon, got ' .. tostring(data.cpu_model('model name\t: Intel(R) Xeon(R) CPU E5-2680 v4 @ 2.40GHz\n')))
-assert(data.cpu_model('model name\t: 11th Gen Intel(R) Core(TM) i7-1165G7 @ 2.80GHz\n')
- == 'Core i7-1165G7',
- 'tiger lake, got ' .. tostring(data.cpu_model('model name\t: 11th Gen Intel(R) Core(TM) i7-1165G7 @ 2.80GHz\n')))
-assert(data.cpu_model('model name\t: AMD Ryzen 9 7950X3D 16-Core Processor\n')
- == 'Ryzen 9 7950X3D',
- 'x3d, got ' .. tostring(data.cpu_model('model name\t: AMD Ryzen 9 7950X3D 16-Core Processor\n')))
-
--- === Interface address ====================================================
--- The fixture uses TEST-NET-1 (192.0.2.0/24, RFC 5737). This repository is
--- public: no real LAN address belongs in it, and no test may assert one.
-local addr = data.iface_addr_parse(read('test/fixtures/ip_addr'))
-assert(addr == '192.0.2.15', 'lan address, got ' .. tostring(addr))
-
--- A down interface has no inet line. nil, so the card shows '--' rather than
--- a stale or invented address.
-assert(data.iface_addr_parse('5: br0: <BROADCAST,MULTICAST> mtu 1500 state DOWN\n') == nil,
- 'a down interface gives nil')
-assert(data.iface_addr_parse('') == nil, 'empty gives nil')
-assert(data.iface_addr_parse(nil) == nil, 'nil gives nil')
-
-- === Calendar =============================================================
-- The fixture uses generic event titles. This repository is public: a real
-- appointment names a real person or a real appointment, and neither belongs
@@ -479,33 +243,6 @@ assert(up.upgrade == 3 and up.bump == 1 and up.current == 46 and up.missing == 1
'updates summary')
assert(data.nvtable_parse('') == nil, 'updates: empty cache is nil')
--- drm_vram_parse: xe clients counted once across their fds, other drivers
--- skipped, units honoured.
-local fdinfo = table.concat({
- '/proc/1/fdinfo/5:drm-driver:\txe',
- '/proc/1/fdinfo/5:drm-client-id:\t7',
- '/proc/1/fdinfo/5:drm-resident-vram0:\t1024 KiB',
- '/proc/1/fdinfo/6:drm-driver:\txe',
- '/proc/1/fdinfo/6:drm-client-id:\t7',
- '/proc/1/fdinfo/6:drm-resident-vram0:\t1024 KiB',
- '/proc/2/fdinfo/9:drm-driver:\txe',
- '/proc/2/fdinfo/9:drm-client-id:\t8',
- '/proc/2/fdinfo/9:drm-resident-vram0:\t2 MiB',
- '/proc/3/fdinfo/4:drm-driver:\tamdgpu',
- '/proc/3/fdinfo/4:drm-client-id:\t9',
- '/proc/3/fdinfo/4:drm-resident-vram0:\t5 GiB',
-}, '\n')
-assert(data.drm_vram_parse(fdinfo) == 3 * 1024 ^ 2,
- 'drm vram: got ' .. tostring(data.drm_vram_parse(fdinfo)))
-assert(data.drm_vram_parse('') == nil, 'drm vram: no clients is nil')
-
--- new_gate: opens at most once per interval on the clock it is given.
-local gate = data.new_gate(1.9)
-assert(gate(100.0) == true, 'gate: first call opens')
-assert(gate(100.5) == false, 'gate: closed inside the interval')
-assert(gate(101.95) == true, 'gate: opens after it')
-assert(gate(102.0) == false, 'gate: interval restarts from the last open')
-
-- Emoji fall back to card.FONT_EMOJI: the toy API has no per-glyph fallback,
-- so without the run split "x🦷" measured a tofu box in Noto Sans.
do
diff --git a/widgets/disks.lua b/widgets/disks.lua
deleted file mode 100644
index 3922188..0000000
--- a/widgets/disks.lua
+++ /dev/null
@@ -1,150 +0,0 @@
--- Disks: one ring per filesystem, after idea2.png.
---
--- Reads a cache file written by bin/disks-sample.sh and never calls statfs
--- itself: an unreachable NFS server blocks that call, and blocking the draw
--- freezes the whole dashboard.
-
-local card = require 'lib.card'
-local data = require 'lib.data'
-
-local M = {}
-
-local CACHE = (os.getenv('XDG_CACHE_HOME') or (os.getenv('HOME') .. '/.cache'))
- .. '/udt/disks.txt'
-
--- mount -> centre glyph and the small text under it.
---
--- The icon IS the label: no mount name is drawn, because a Slackware S or a
--- house says which filesystem this is faster than the word does. Every
--- codepoint here was taken from the old conky config and re-rendered to
--- confirm it, rather than guessed: an earlier guess at the Slackware mark
--- (F83C) turned out to draw a stomach, and a wrong-but-present codepoint
--- fails plausibly rather than visibly.
---
--- The two NFS shares are told apart by their server's address, which is read
--- from the mount's device field at runtime rather than written here. The
--- addresses belong on the screen, not in a public repo, and deriving them also
--- means the card keeps working if the LAN is renumbered. It fits: at a 50px
--- radius the usable inner width is about 75px and an address measures 58px at
--- 10px type.
---
--- A mount not listed still draws, with a generic disk glyph and the last path
--- segment, so adding one to the sampler needs no change here.
-local GLYPH = {
- ['/'] = '\u{F318}', -- Slackware
- ['/home'] = '\u{F015}', -- house
- ['/data'] = '\u{F02CA}', -- hard disk
-}
-local NFS_GLYPH = '\u{F06F3}' -- network share
-local FALLBACK_GLYPH = '\u{F02CA}'
-
-function M.draw(cr, rect, colors)
- local inner = card.card(cr, rect, colors)
- local function clamp(v, lo, hi) return math.max(lo, math.min(hi, v)) end
- local label_size = clamp(inner.w * 0.030, 10, 16)
-
- local fs = data.df_parse(data.slurp(CACHE) or '')
- if #fs == 0 then
- card.notice(cr, inner, colors, 'DISKS', 'no disk data', 'bin/disks-sample.sh')
- return
- end
-
- card.font(cr, card.FONT_MONO, label_size, false)
- card.title(cr, inner.x, inner.y + label_size, card.ICON.disks, 'DISKS', colors)
-
- local top = inner.y + label_size * 2.2
- local avail_h = (inner.y + inner.h) - top
-
- -- All rings share one row: the cell width shrinks as mounts are added and
- -- the ring radius follows it, so the layout never wraps. Below the radius
- -- where a ring reads, fall back to labelled bars rather than drawing a row
- -- of illegible dots.
- local n = #fs
- local per_row = n
- local cell_w = inner.w / per_row
- -- 0.27 rather than 0.30: two rows sit under each ring now, not one, and the
- -- radius is what gives up the height.
- local r = math.min(cell_w * 0.34, avail_h * 0.27)
-
- if r >= 18 then
- for i, e in ipairs(fs) do
- -- host is non-nil exactly for a network mount, since a local device is
- -- a path and carries no 'server:' prefix.
- local glyph = GLYPH[e.mount] or (e.host and NFS_GLYPH) or FALLBACK_GLYPH
- local sub = e.host
- if not sub and not GLYPH[e.mount] then
- sub = (e.mount:match('([^/]+)/?$') or e.mount):upper()
- end
- local cx = inner.x + cell_w * (i - 0.5)
- local cy = top + r + 6
- local colour = card.threshold(e.pct, 25, 75, colors)
-
- card.ring(cr, cx, cy, r, e.pct / 100, colour, colors)
-
- -- Glyph at the centre. Drawn at 0.55 of the radius rather than 0.7: the
- -- network-share glyph carries a wide horizontal connector that runs past
- -- the ring at larger sizes, which reads as a line joining the rings
- -- together.
- local gsize = r * 0.55
- card.font(cr, card.FONT_MONO, gsize, false)
- card.rgba(cr, colors.label)
- local gw = card.measure(cr, glyph)
- -- Raised when there is text under it, centred when there is not.
- card.text(cr, cx - gw / 2, cy + (sub and gsize * 0.10 or gsize * 0.38), glyph)
-
- -- The address, inside the ring beneath the glyph. Only the NFS shares
- -- have one; the local mounts are identified by their icon alone.
- if sub then
- -- Tied to the card's own label scale rather than a fixed px floor, so
- -- every piece of small mono text on the card tracks the same size.
- card.font(cr, card.FONT_MONO, clamp(r * 0.20, label_size * 0.7, label_size), false)
- card.rgba(cr, colors.label)
- local sw_ = card.measure(cr, sub)
- card.text(cr, cx - sw_ / 2, cy + r * 0.55, sub)
- end
-
- -- Free on top, total beneath, both centred under the ring.
- --
- -- The percentage that used to sit here is gone: the ring already draws
- -- it as an angle, and a number repeating it costs a row in a column
- -- about 75px wide. Free carries the threshold colour because it is the
- -- measured quantity; the total is fixed, so it stays in `value`. One
- -- coloured number per column, as the rest of the board reads.
- local free_size = label_size
- local total_size = label_size * 0.88
- card.font(cr, card.FONT_MONO, free_size, false)
- card.rgba(cr, colour)
- local free_txt = card.human(e.avail) .. ' free'
- local fw = card.measure(cr, free_txt)
- card.text(cr, cx - fw / 2, cy + r + free_size * 1.7, free_txt)
-
- card.font(cr, card.FONT_MONO, total_size, false)
- card.rgba(cr, colors.value)
- local total_txt = card.human(e.size)
- local tw = card.measure(cr, total_txt)
- card.text(cr, cx - tw / 2, cy + r + free_size * 1.7 + total_size * 1.4, total_txt)
- end
- else
- local step = label_size * 2.6
- local bar_h = clamp(label_size * 0.5, 4, 8)
- local ey = top + label_size
- for _, e in ipairs(fs) do
- if ey + step > inner.y + inner.h then break end
- -- The bar fallback has no room for a ring, so it shows the glyph inline
- -- followed by the address or the mount name.
- local glyph = GLYPH[e.mount] or (e.host and NFS_GLYPH) or FALLBACK_GLYPH
- local name = e.host or (e.mount:match('([^/]+)/?$') or e.mount):upper()
- local colour = card.threshold(e.pct, 25, 75, colors)
- card.font(cr, card.FONT_MONO, label_size, false)
- card.rgba(cr, colors.label)
- card.text(cr, inner.x, ey, glyph .. ' ' .. name)
- card.rgba(cr, colour)
- card.text_right(cr, inner.x + inner.w, ey,
- string.format('%s / %s', card.human(e.used), card.human(e.size)))
- card.bar(cr, inner.x, ey + label_size * 0.5, inner.w, bar_h, e.pct / 100, colour, colors)
- ey = ey + step
- end
- end
-end
-
-return M
diff --git a/widgets/gpu.lua b/widgets/gpu.lua
deleted file mode 100644
index abac856..0000000
--- a/widgets/gpu.lua
+++ /dev/null
@@ -1,121 +0,0 @@
--- GPU: Arc B580 temperatures, VRAM in use and fan speed.
---
--- No utilisation figure, deliberately. The xe driver exposes no
--- gpu_busy_percent and intel_gpu_top refuses the device outright ("Detected Xe
--- device which is not supported"). VRAM comes from DRM fdinfo, the per-client
--- counters gputop itself reads (see data.drm_vram).
---
--- The integrated AMD GPU does expose gpu_busy_percent and is deliberately not
--- shown: the discrete card is the one in use.
-
-local card = require 'lib.card'
-local data = require 'lib.data'
-
-local M = {}
-
--- The B580's VRAM. fdinfo reports what clients hold, not the card's size, and
--- xe puts no capacity in sysfs. ponytail: hardcoded like the 'ARC B580' label.
-local VRAM_TOTAL = 12 * 1024 ^ 3
-
--- fdinfo is a popen over every fd on the system, so sample it every ~2s, not
--- every frame.
-local gate = data.new_gate(1.9)
-local vram
-
--- chip, file, label, warn, crit. Package (temp2) is the headline above, so it
--- is not repeated here; these are the rows drawn under it.
-local TEMPS = {
- { 'xe', 'temp3_input', 'VRAM', 80, 90 },
-}
-
-function M.draw(cr, rect, colors)
- local inner = card.card(cr, rect, colors)
- local function clamp(v, lo, hi) return math.max(lo, math.min(hi, v)) end
-
- if gate() then vram = data.drm_vram() end
-
- local pkg = data.sensor('xe', 'temp2_input')
- local big_txt = pkg and string.format('%d\u{00B0}', pkg) or '--'
-
- -- Fluid type. One base size S drives every element. The cell's height sets
- -- how large the content grows; the measured width fit pulls S back down
- -- only where the header or a row would overrun the card. So a tall cell
- -- gets larger type instead of empty space, and nothing ever collides.
- local LABEL_F, ROW_F = 0.32, 0.34
- local row_n = #TEMPS + 2 -- VRAM + MEM + FAN
- local S_h = inner.h * 0.94 / (2.088 + row_n * 0.646 + 0.3) -- + MEM bar
- local groups = {
- { { card.title_str(card.ICON.gpu, 'GPU'), card.FONT_MONO, LABEL_F },
- { big_txt, card.FONT_HEAVY, 1.0 } },
- { { 'ARC B580', card.FONT_MONO, LABEL_F } },
- }
- for _, t in ipairs(TEMPS) do
- groups[#groups + 1] = { { t[3], card.FONT_MONO, ROW_F },
- { '888\u{00B0}', card.FONT_MONO, ROW_F } }
- end
- groups[#groups + 1] = { { 'MEM', card.FONT_MONO, ROW_F },
- { '100%', card.FONT_MONO, ROW_F } }
- groups[#groups + 1] = { { 'FAN', card.FONT_MONO, ROW_F },
- { '8888 RPM', card.FONT_MONO, ROW_F } }
- local S = clamp(math.min(S_h, card.fit_unit(cr, inner.w * 0.96, groups, 100)), 10, 72)
- local label_size = S * LABEL_F
- local row_size = S * ROW_F
-
- local y = inner.y
-
- -- The package temperature is the headline, since there is no load to show.
- -- It shares the header line with the label, set on the same baseline and
- -- right-aligned, so the card's width carries the number rather than leaving
- -- the right half empty under a left-set value.
- card.font(cr, card.FONT_HEAVY, S, false)
- card.rgba(cr, pkg and card.threshold(pkg, 75, 85, colors) or colors.label)
- card.text_right(cr, inner.x + inner.w, y + S, big_txt)
-
- card.font(cr, card.FONT_MONO, label_size, false)
- card.title(cr, inner.x, y + label_size, card.ICON.gpu, 'GPU', colors)
-
- card.rgba(cr, colors.value)
- card.text(cr, inner.x, y + S + label_size * 1.7, 'ARC B580')
-
- local ey = y + S + label_size * 3.4
- local step = row_size * 1.9
-
- card.font(cr, card.FONT_MONO, row_size, false)
- for _, t in ipairs(TEMPS) do
- if ey + step > inner.y + inner.h then break end
- local v = data.sensor(t[1], t[2])
- card.rgba(cr, colors.label)
- card.text(cr, inner.x, ey, t[3])
- card.rgba(cr, v and card.threshold(v, t[4], t[5], colors) or colors.label)
- card.text_right(cr, inner.x + inner.w, ey,
- v and string.format('%d\u{00B0}', v) or '--')
- ey = ey + step
- end
-
- -- VRAM as a fraction of the card's, a bar under the row like RAM in system.
- local bar_h = S * 0.12
- if ey + step + bar_h + S * 0.1 <= inner.y + inner.h then
- local frac = vram and vram / VRAM_TOTAL
- local colour = frac and card.threshold(frac * 100, 25, 75, colors) or colors.label
- card.rgba(cr, colors.label)
- card.text(cr, inner.x, ey, 'MEM')
- card.rgba(cr, colour)
- card.text_right(cr, inner.x + inner.w, ey,
- frac and string.format('%d%%', math.floor(frac * 100 + 0.5)) or '--')
- card.bar(cr, inner.x, ey + row_size * 0.6, inner.w, bar_h, frac, colour, colors)
- ey = ey + step + bar_h + S * 0.1
- end
-
- -- Fan RPM is not a temperature, so it carries no threshold colour: a fast
- -- fan is the cooling working, not a fault.
- if ey + step <= inner.y + inner.h then
- local rpm = data.sensor_raw('xe', 'fan1_input')
- card.rgba(cr, colors.label)
- card.text(cr, inner.x, ey, 'FAN')
- card.rgba(cr, colors.value)
- card.text_right(cr, inner.x + inner.w, ey,
- rpm and string.format('%d RPM', rpm) or '--')
- end
-end
-
-return M
diff --git a/widgets/network.lua b/widgets/network.lua
deleted file mode 100644
index 20b0573..0000000
--- a/widgets/network.lua
+++ /dev/null
@@ -1,181 +0,0 @@
--- Network: throughput as two lines on one chart, with the LAN and public
--- addresses.
---
--- The rates come from the interface's own byte counters, read every draw: two
--- file reads, no subprocess. The public address comes from a cache file,
--- because a network call in the draw hook would freeze the dashboard when it
--- hung.
-
-local card = require 'lib.card'
-local data = require 'lib.data'
-
-local M = {}
-
--- The interface to graph. br0 is this host's bridge, matching the old text
--- config.
---
--- Note what this means: a bridge carries VM-to-host traffic that never reaches
--- the router, so a local copy spikes the graph. That was true of the old
--- config too and is accepted; this constant is the one edit that changes it.
-local IFACE = 'br0'
-
-local CACHE = (os.getenv('XDG_CACHE_HOME') or (os.getenv('HOME') .. '/.cache'))
- .. '/udt/pubip.txt'
-
--- A public address older than this is not shown. Eight sampling intervals: by
--- then the sampler has failed repeatedly, and an address that may no longer be
--- yours displayed with confidence is worse than '--'.
-local STALE_AFTER = 4 * 3600
-
-local STAT = '/sys/class/net/' .. IFACE .. '/statistics/'
-
--- Module state: the history outlives the frame, which is the whole point.
--- It does not outlive a conky restart, so the chart starts empty and fills
--- left to right.
-local rx_counter, tx_counter
-local rx_hist, tx_hist = {}, {}
-local hist_len = 0
-local lan_addr = nil
-
--- Sampled every ~2s whatever the draw rate, so the chart keeps one point per
--- sample rather than per draw. 1.9 not 2: draws jitter by a few ms.
-local gate = data.new_gate(1.9)
-local down, up = nil, nil
-
--- Append to a fixed-length history, dropping the oldest. A plain array with
--- table.remove(1) rather than a circular buffer with an index: the lengths
--- here are a few hundred at most and the draw is every two seconds, so the
--- O(n) shift is free and the array is already in draw order.
-local function push(hist, v, len)
- hist[#hist + 1] = v
- while #hist > len do table.remove(hist, 1) end
-end
-
-local function read_counter(file)
- local s = data.slurp(STAT .. file)
- if not s then return nil end
- return tonumber(s:match('^%s*(%d+)'))
-end
-
--- Bytes per second to a short string. Deliberately not card.human: a rate
--- wants a '/s' and one decimal at most, and reusing card.human would put a
--- suffix meant for capacity onto a speed.
-local function rate_str(bps)
- if not bps then return '--' end
- local units = { 'B', 'K', 'M', 'G' }
- local n, i = bps, 1
- while n >= 1024 and i < #units do n = n / 1024; i = i + 1 end
- if i == 1 then return string.format('%d%s/s', math.floor(n), units[i]) end
- if n >= 100 then return string.format('%.0f%s/s', n, units[i]) end
- return string.format('%.1f%s/s', n, units[i])
-end
-
-function M.draw(cr, rect, colors)
- local inner = card.card(cr, rect, colors)
- local function clamp(v, lo, hi) return math.max(lo, math.min(hi, v)) end
-
- local interval = (conky_info and conky_info.update_interval) or 2
- rx_counter = rx_counter or data.new_rate_counter(interval)
- tx_counter = tx_counter or data.new_rate_counter(interval)
-
- local sampled, now = gate()
- if sampled then
- down = rx_counter:sample(read_counter('rx_bytes'), now)
- up = tx_counter:sample(read_counter('tx_bytes'), now)
- end
-
- -- Fluid type, as every other card does: one base size S drives everything,
- -- taken as the smaller of a height budget and a measured width fit.
- local LABEL_F, BIG_F, ROW_F = 0.30, 1.0, 0.30
- local groups = {
- { { card.title_str(card.ICON.network, 'NET'), card.FONT_MONO, LABEL_F },
- { '888.8M/s', card.FONT_HEAVY, BIG_F } },
- { { '\u{F0318} 192.000.000.000', card.FONT_MONO, ROW_F } },
- { { '\u{F01DA} 888.8M/s', card.FONT_MONO, ROW_F },
- { '\u{F0552} 888.8M/s', card.FONT_MONO, ROW_F } },
- }
- local fixed_units = 1.0 + ROW_F * 2.2 * 3
- local S = clamp(math.min(inner.h * 0.96 / (fixed_units + 1.2),
- card.fit_unit(cr, inner.w * 0.96, groups, 100)), 10, 72)
- local label_size = S * LABEL_F
- local row_size = S * ROW_F
-
- -- Header: the label left, the download rate as the big value at the right.
- -- Download is the headline because it is the number that moves.
- card.font(cr, card.FONT_HEAVY, S * BIG_F, false)
- card.rgba(cr, colors.body)
- card.text_right(cr, inner.x + inner.w, inner.y + S * BIG_F, rate_str(down))
-
- card.font(cr, card.FONT_MONO, label_size, false)
- card.title(cr, inner.x, inner.y + label_size, card.ICON.network, 'NET', colors)
-
- local ey = inner.y + S * BIG_F + row_size * 1.4
- local step = row_size * 2.2
-
- -- The addresses. LAN is memoized on first success and retried while nil: a
- -- bridge may not be up when conky starts.
- lan_addr = lan_addr or data.iface_addr(IFACE)
- card.font(cr, card.FONT_MONO, row_size, false)
- card.rgba(cr, colors.label)
- card.text(cr, inner.x, ey, '\u{F0318}') -- LAN
- card.rgba(cr, colors.value)
- card.text_right(cr, inner.x + inner.w, ey, lan_addr or '--')
- ey = ey + step
-
- local pub = data.kv_parse(data.slurp(CACHE) or '')
- local fetched = tonumber(pub.fetched)
- local pub_txt = '--'
- if pub.ip and fetched and (os.time() - fetched) < STALE_AFTER then
- pub_txt = pub.ip
- end
- card.rgba(cr, colors.label)
- card.text(cr, inner.x, ey, '\u{F059F}') -- globe
- card.rgba(cr, pub_txt == '--' and colors.label or colors.value)
- card.text_right(cr, inner.x + inner.w, ey, pub_txt)
- ey = ey + step * 1.1
-
- -- The chart takes the room left between the addresses and the rate row.
- local rate_row_h = row_size * 2.4
- local chart_top = ey
- local chart_h = (inner.y + inner.h) - chart_top - rate_row_h
- if chart_h > 12 then
- -- One sample per pixel column, so the chart never interpolates. The
- -- buffer is resized when the card is, keeping what it can: a moved card
- -- should not clear the history.
- local want = math.max(8, math.floor(inner.w))
- if want ~= hist_len then
- hist_len = want
- while #rx_hist > hist_len do table.remove(rx_hist, 1) end
- while #tx_hist > hist_len do table.remove(tx_hist, 1) end
- end
- if sampled and down then push(rx_hist, down, hist_len) end
- if sampled and up then push(tx_hist, up, hist_len) end
-
- -- One ceiling for both series. Scaled independently they would lie about
- -- their relative size, which is the entire reason to draw them together.
- local peak = 0
- for _, v in ipairs(rx_hist) do if v > peak then peak = v end end
- for _, v in ipairs(tx_hist) do if v > peak then peak = v end end
-
- card.plot(cr, inner.x, chart_top, inner.w, chart_h, {
- { values = rx_hist, colour = colors.ok },
- { values = tx_hist, colour = colors.highlight },
- }, peak, colors)
-
- -- The peak, so a full-height line means something in absolute terms.
- card.font(cr, card.FONT_MONO, row_size * 0.85, false)
- card.rgba(cr, colors.label)
- card.text(cr, inner.x, chart_top + row_size * 0.85, rate_str(peak))
- end
-
- -- The live rates, each in its series colour so the line and the number are
- -- unmistakably the same thing.
- local ry = inner.y + inner.h - row_size * 0.4
- card.font(cr, card.FONT_MONO, row_size, false)
- card.rgba(cr, colors.ok)
- card.text(cr, inner.x, ry, '\u{F01DA} ' .. rate_str(down))
- card.rgba(cr, colors.highlight)
- card.text_right(cr, inner.x + inner.w, ry, '\u{F0552} ' .. rate_str(up))
-end
-
-return M
diff --git a/widgets/system.lua b/widgets/system.lua
deleted file mode 100644
index c1cdf67..0000000
--- a/widgets/system.lua
+++ /dev/null
@@ -1,198 +0,0 @@
--- System: CPU load as a per-core equaliser, memory, and every temperature.
---
--- The hwmon bindings and temperature ceilings below are host-specific, which
--- is why they live here rather than in lib/data.lua: that file is the part
--- that ports to another machine, this is the part that does not.
-
-local card = require 'lib.card'
-local data = require 'lib.data'
-
-local M = {}
-
--- chip, file, label, warn, crit.
---
--- Ceilings are per sensor because a single pair cannot serve both: 70C is
--- unremarkable on a CPU package and alarming on an NVMe. Starting values,
--- easy to retune once real numbers under load are known.
-local TEMPS = {
- { 'k10temp', 'temp1_input', 'CPU', 75, 90 },
- { 'k10temp', 'temp3_input', 'CCD1', 75, 90 },
- { 'nvme', 'temp1_input', 'NVME', 60, 70 },
- { 'gigabyte_wmi', 'temp2_input', 'BOARD', 60, 70 },
- { 'gigabyte_wmi', 'temp3_input', 'BOARD', 60, 70 },
-}
-
--- One counter per core, created on first draw and kept for the process's life:
--- load is a delta between samples, so the state has to outlive the frame.
-local counters = {}
-local total_counter = data.new_cpu_counter()
-
--- Sampled every ~2s whatever the draw rate; the values between samples are
--- the last ones read. 1.9 not 2: draws jitter by a few ms either way.
-local gate = data.new_gate(1.9)
-local load, pcts = nil, {}
-
--- Read once, not per frame. Neither can change without a reboot, and
--- re-reading /proc/cpuinfo every two seconds for a constant is waste: the file
--- repeats its model-name line once per thread.
---
--- `false` is the cached miss, distinct from nil meaning "not looked up yet",
--- so an absent DMI table is not re-read on every draw.
-local cpu_name, board = nil, nil
-
-local function hardware()
- if cpu_name == nil then
- cpu_name = data.cpu_model(data.slurp('/proc/cpuinfo') or '') or false
- end
- if board == nil then
- local dmi = '/sys/devices/virtual/dmi/id/'
- board = data.board_name(data.slurp(dmi .. 'board_vendor'),
- data.slurp(dmi .. 'board_name')) or false
- end
- return cpu_name or nil, board or nil
-end
-
-function M.draw(cr, rect, colors)
- local inner = card.card(cr, rect, colors)
- local function clamp(v, lo, hi) return math.max(lo, math.min(hi, v)) end
-
- if gate() then
- local stat = data.slurp('/proc/stat') or ''
- load = total_counter:sample(data.cpu_times(stat))
- for i, c in ipairs(data.per_cpu_times(stat)) do
- counters[i] = counters[i] or data.new_cpu_counter()
- pcts[i] = counters[i]:sample(c.total, c.idle) or 0
- end
- end
- local load_txt = load and string.format('%d%%', math.floor(load + 0.5)) or '--'
- local temp_n = #TEMPS
- local cpu_txt, board_txt = hardware()
-
- -- Fluid type. One base size S drives the label, headline, RAM and every
- -- temperature row; the equaliser then absorbs the vertical room left over,
- -- so the card fills its cell and S shrinks only where a row would overrun
- -- the width. card.fit_unit supplies the width side of that.
- local LABEL_F, BIG_F, ROW_F = 0.30, 1.0, 0.32
- local groups = {
- { { card.title_str(card.ICON.system, 'CPU'), card.FONT_MONO, LABEL_F },
- { load_txt, card.FONT_HEAVY, BIG_F } },
- { { cpu_txt or '', card.FONT_MONO, ROW_F } },
- { { board_txt or '', card.FONT_MONO, ROW_F } },
- { { 'RAM', card.FONT_MONO, ROW_F }, { '999.9G / 999.9G', card.FONT_MONO, ROW_F } },
- }
- for _, t in ipairs(TEMPS) do
- groups[#groups + 1] = { { t[3], card.FONT_MONO, ROW_F },
- { '888\u{00B0}', card.FONT_MONO, ROW_F } }
- end
- -- Height the non-equaliser content needs, in units of S, plus a reserve for
- -- the equaliser band. The smaller of that budget and the width fit is S.
- -- The two identification rows come out of the equaliser's budget: it is the
- -- card's designated slack absorber, so it is what gives up the height.
- local id_rows = (cpu_txt and 1 or 0) + (board_txt and 1 or 0)
- local fixed_units = 1.00 + 1.13 + temp_n * ROW_F * 1.8 + 0.10
- + id_rows * ROW_F * 1.5
- local S = clamp(math.min(inner.h * 0.96 / (fixed_units + 1.7),
- card.fit_unit(cr, inner.w * 0.96, groups, 100)), 10, 72)
- local label_size = S * LABEL_F
- local big_size = S * BIG_F
- local row_size = S * ROW_F
-
- local y = inner.y
-
- -- Aggregate load, the headline number. It takes the header line, right
- -- aligned like the big value on every other card, with the label top-left.
- card.font(cr, card.FONT_HEAVY, big_size, false)
- card.rgba(cr, load and card.threshold(load, 25, 75, colors) or colors.label)
- card.text_right(cr, inner.x + inner.w, y + big_size, load_txt)
-
- card.font(cr, card.FONT_MONO, label_size, false)
- card.title(cr, inner.x, y + label_size, card.ICON.system, 'CPU', colors)
-
- -- The hardware this card is about, under the header. Truncated by character,
- -- never by byte: a model string can carry a multi-byte character and half of
- -- one draws as a replacement box.
- -- Two accents rather than one label colour: the rows name two different
- -- pieces of hardware, and giving each its own hue separates them at a glance
- -- without a prefix like 'CPU:' eating the width. `heading` and `highlight`
- -- are the two roles that carry no state meaning, so they can stand for an
- -- identity; ok/warning/critical could not, since those say how something is
- -- doing and these strings never change.
- local id_y = y + big_size + row_size * 0.2
- if cpu_txt or board_txt then
- card.font(cr, card.FONT_MONO, row_size, false)
- local id_limit = math.max(8, math.floor(inner.w / (row_size * 0.55)))
- if cpu_txt then
- card.rgba(cr, colors.heading)
- card.text(cr, inner.x, id_y, card.truncate(cpu_txt, id_limit))
- id_y = id_y + row_size * 1.5
- end
- if board_txt then
- card.rgba(cr, colors.highlight)
- card.text(cr, inner.x, id_y, card.truncate(board_txt, id_limit))
- id_y = id_y + row_size * 1.5
- end
- end
-
- -- The equaliser: one bar per core, rising from a common baseline. It takes
- -- the room left between the header and the RAM/temperature block, so it is
- -- the card's slack absorber.
- -- Below the identification rows when there are any, otherwise where it was.
- local eq_top = (cpu_txt or board_txt) and (id_y + S * 0.10) or (y + big_size + S * 0.30)
- local bar_h = S * 0.18
- local step = row_size * 1.8
- -- The floor was a flat 18px. With the identification rows above it the
- -- equaliser can genuinely run out of room, and a fixed floor means it keeps
- -- height it does not have and the temperature rows draw over it. Zero is a
- -- legitimate outcome: the guard below skips the band when it has no height.
- local eh = clamp((inner.y + inner.h) - eq_top - (S * 1.13 + temp_n * step),
- 0, math.huge)
- local n = #pcts
- if n > 0 and eh >= 8 then
- local gap = math.max(2, inner.w * 0.006)
- local bw = (inner.w - gap * (n - 1)) / n
- -- Below about 3px a row of sixteen bars is an illegible smear; drop to the
- -- aggregate bar instead of drawing one.
- if bw >= 3 then
- for i, pct in ipairs(pcts) do
- card.vbar(cr, inner.x + (i - 1) * (bw + gap), eq_top + eh, bw, eh,
- pct / 100, card.threshold(pct, 25, 75, colors), colors)
- end
- else
- card.bar(cr, inner.x, eq_top + eh - bar_h, inner.w, bar_h, (load or 0) / 100,
- card.threshold(load or 0, 25, 75, colors), colors)
- end
- end
- local ey = eq_top + eh + S * 0.30
-
- -- Memory. MemAvailable, not free: it already excludes reclaimable cache, so
- -- it is the figure a person recognises as "used".
- local mem = data.mem_info(data.slurp('/proc/meminfo') or '')
- if mem then
- local frac = mem.used / mem.total
- card.font(cr, card.FONT_MONO, row_size, false)
- card.rgba(cr, colors.label)
- card.text(cr, inner.x, ey, 'RAM')
- card.rgba(cr, colors.value)
- card.text_right(cr, inner.x + inner.w, ey,
- string.format('%s / %s', card.human(mem.used * 1024), card.human(mem.total * 1024)))
- ey = ey + S * 0.30
- card.bar(cr, inner.x, ey, inner.w, bar_h, frac,
- card.threshold(frac * 100, 25, 75, colors), colors)
- ey = ey + bar_h + S * 0.35
- end
-
- -- Temperatures, each against its own ceiling.
- card.font(cr, card.FONT_MONO, row_size, false)
- for _, t in ipairs(TEMPS) do
- if ey + step > inner.y + inner.h then break end -- out of room: stop
- local v = data.sensor(t[1], t[2])
- card.rgba(cr, colors.label)
- card.text(cr, inner.x, ey, t[3])
- card.rgba(cr, v and card.threshold(v, t[4], t[5], colors) or colors.label)
- card.text_right(cr, inner.x + inner.w, ey,
- v and string.format('%d\u{00B0}', v) or '--')
- ey = ey + step
- end
-end
-
-return M