diff options
| author | Danilo M. <danix@danix.xyz> | 2026-09-17 11:10:13 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-09-17 11:10:13 +0200 |
| commit | 64482a7f4c11c430c4159954e05692b28f03f4f1 (patch) | |
| tree | 6a2a50e765311eb73bb363508935769adbee60a0 | |
| parent | b3348082df104b67b33242f337947dadfc66f53b (diff) | |
| download | conky-theme-udt-64482a7f4c11c430c4159954e05692b28f03f4f1.tar.gz conky-theme-udt-64482a7f4c11c430c4159954e05692b28f03f4f1.zip | |
docs: add the system widgets implementation plan
Eleven tasks. The palette's warning role first, since everything else
colours against it; then an offscreen render harness, so each later task
can look at its work without an install-restart-toggle cycle; then
parsers, samplers, primitives, the four cards, and the live pass.
Glyphs are taken from the old conky config and re-rendered rather than
guessed. That matters: a guess at the Slackware mark drew a stomach, and
the config had the real codepoint (U+F318) all along, along with the
disk and network-share icons. The icon is the label now, so no mount
name is drawn.
The two NFS shares are told apart by their server's address, read from
the mount's device field at runtime. The repo is public, so the address
belongs on screen and not in a committed file; deriving it also means
the card survives a renumbered LAN. df_parse keeps the device field for
this, and `host` is non-nil exactly for a network mount.
Two traps recorded because both would fail silently: df is aliased to
df -h in the user's shell, so the sampler calls /usr/bin/df -P -B1 by
absolute path or the parser reads '1.6G' where it expects bytes; and the
per-core counters must outlive a frame, since load is a delta and a
counter rebuilt each draw reports nil forever.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| -rw-r--r-- | docs/superpowers/plans/2026-09-17-system-widgets.md | 1607 |
1 files changed, 1607 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-09-17-system-widgets.md b/docs/superpowers/plans/2026-09-17-system-widgets.md new file mode 100644 index 0000000..2ba13f8 --- /dev/null +++ b/docs/superpowers/plans/2026-09-17-system-widgets.md @@ -0,0 +1,1607 @@ +# System Widgets Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Four dashboard cards covering the machine: CPU load with a per-core equaliser, memory and every temperature; the GPU; filesystem capacity as rings; and the user cache. + +**Architecture:** Parsers extend `lib/data.lua` in its existing string-in/table-out shape. Two sampler scripts write cache files on conky's `${execi}` schedule so the draw hook never runs a subprocess. Shared drawing primitives (`bar`, `ring`, `threshold`) go in `lib/card.lua` so all four cards apply one colour language. A new `warning` palette role supplies the middle threshold colour. + +**Tech Stack:** Lua 5.4 + Cairo via conky's bindings; bash + coreutils for the samplers. No new dependency. + +**Spec:** `docs/superpowers/specs/2026-09-17-system-widgets-design.md` + +--- + +## Critical platform facts + +Read these before touching anything. Each cost time to learn here. + +- **A Lua error is a blank screen.** Conky reports a Lua fault on no stream. The `pcall` overlay in `dashboard.lua` is the only reason failures are visible. Every parser returns nil or an empty table rather than raising. +- **`lib/weather.lua` is raise-free and verified so.** Match that: guard every argument you do arithmetic on, including the ones "no caller can get wrong". Three such bugs were found and fixed there under review. +- **`card.measure()` is ink width, `card.advance()` is cursor movement.** Not interchangeable. `measure` to centre or right-align, `advance` to step along a run of text. +- **Never call `cairo_text_extents_t:create()`.** It leaks ~182KB per 5000 calls, unreclaimable. `lib/card.lua` owns one reused struct. +- **`${execi}` fires even though `conky.text` renders nothing**, which is what schedules the samplers. Verified with a probe config. +- **Widget modules are cached after first `require`.** Editing a widget needs `./restart.sh`; editing only `dashboard.lua` does not. +- **`df` is aliased to `df -h` in the user's shell.** A sampler calling bare `df` gets human-readable sizes and a parser expecting bytes silently mis-reads every figure. Call `/usr/bin/df -P -B1` by absolute path. +- **hwmon is globbed by its `name` file, never by index.** `lib/data.lua` has `hwmon_dir()` for this. +- **Conky never rereads its config.** A `conky.conf.in` change needs UDT's `install.sh` to re-render. + +## Offscreen rendering + +Conky's Cairo bindings load in plain Lua, so a widget can be drawn to a PNG without conky. This turns a four-step verify loop into one command and is how the weather card's proportions were fixed. A harness exists at `test/render.lua` after Task 2. + + lua test/render.lua <widget> <cols> <rows> <wspan> <hspan> <out.png> + +It crops to the card, so check the live dashboard before concluding anything about size relative to neighbouring cards. + +--- + +## File structure + +| Path | Responsibility | +|---|---| +| `lib/data.lua` | + `per_cpu_times`, `sensor`, `df_parse`, `du_parse`. Parsing only; no drawing, no host bindings. | +| `lib/card.lua` | + `bar`, `ring`, `threshold`. Drawing primitives; no data, no thresholds of its own. | +| `test/render.lua` | Offscreen widget renderer. Test tooling, not shipped code. | +| `bin/disks-sample.sh` | `df` to a cache file. | +| `bin/cache-sample.sh` | `du` to a cache file. | +| `widgets/system.lua` | CPU equaliser, RAM, all temperatures. Owns its hwmon bindings and ceilings. | +| `widgets/gpu.lua` | Arc B580 temps and fan. Owns its bindings. | +| `widgets/disks.lua` | Filesystem rings. | +| `widgets/cache.lua` | Cache total and top four. | + +Modified: `conky.conf.in` (two `${execi}`, one `@WARNING@`), `dashboard.lua` (palette role, layout rows), `README.md`, and UDT's `bin/udt-palette` (one word). + +--- + +## Task 1: The warning palette role + +The middle threshold colour, needed by every later task. Touches the UDT repo. + +**Files:** +- Modify: `../unified-desktop-theme/bin/udt-palette` +- Modify: `conky.conf.in` +- Modify: `dashboard.lua` + +- [ ] **Step 1: Add the role to gen_conky** + +In `../unified-desktop-theme/bin/udt-palette`, find `def gen_conky` (around line 548). Its loop lists the roles it substitutes. Add `"warning"`: + +```python + for role in ("heading", "label", "rule", "value", "highlight", "ok", + "body", "body_outline", "body_shade", "critical", "warning"): +``` + +`res['warning']` already resolves in every scheme (udt-palette maps `yellow`, and Nord's `aurora_yellow`, to it globally) and other generators already read it, so no scheme file changes. + +- [ ] **Step 2: Add the placeholder to the template** + +In `conky.conf.in`, after the `color7` line: + +```lua + color8 = '@WARNING@', +``` + +- [ ] **Step 3: Expose it in the palette** + +In `dashboard.lua`'s `palette()` function, after the `critical` line: + +```lua + warning = hex(CFG.color8), +``` + +- [ ] **Step 4: Render and verify the colour arrives** + +```bash +cd ../unified-desktop-theme && ./install.sh >/dev/null 2>&1 +grep color8 ~/.config/conky/conky.conf +``` + +Expected: `color8 = '#e0af68'` (tokyo-night's yellow) or the current scheme's equivalent. If it is `@WARNING@` unsubstituted, Step 1 did not take. + +- [ ] **Step 5: Verify it survives a scheme switch** + +```bash +cd ../unified-desktop-theme +for s in dracula nord mocha; do + python3 bin/udt-palette --scheme $s --print-role warning 2>/dev/null \ + || echo "$s: check manually" +done +``` + +If `--print-role` does not exist, instead confirm by eye that `palette/<scheme>.conf` defines a yellow (Nord calls it `aurora_yellow`). The point is that no scheme is missing the colour. + +- [ ] **Step 6: Commit** + +Two repos, two commits. + +```bash +cd ../unified-desktop-theme +git add bin/udt-palette +git commit -m "feat: substitute the warning role into the conky template" +cd - +git add conky.conf.in dashboard.lua +git commit -m "feat: expose the warning palette role" +``` + +--- + +## Task 2: The offscreen render harness + +Test tooling, built before the widgets so every later task can check its work cheaply. + +**Files:** +- Create: `test/render.lua` + +- [ ] **Step 1: Write the harness** + +Create `test/render.lua`: + +```lua +-- Render a widget to a PNG without conky. +-- +-- Conky ships its Cairo bindings as a loadable module, so a widget's draw() +-- can be called against an image surface from plain Lua. That turns +-- edit-install-restart-toggle-screenshot into one command, which is how the +-- weather card's proportions were fixed. +-- +-- lua test/render.lua weather 16 12 3 5 /tmp/w.png +-- +-- Caveat: this crops to the single card. It says nothing about how the card +-- looks NEXT to its neighbours, which is a real failure mode (the weather +-- card's type once looked fine here and tiny beside the clock). Check the +-- live dashboard before believing anything about relative size. + +package.cpath = '/usr/lib64/conky/lib?.so;' .. package.cpath +package.path = './?.lua;' .. package.path +require 'cairo' + +local widget_name = arg[1] or error('usage: render.lua <widget> <cols> <rows> <w> <h> <out.png>') +local COLS = tonumber(arg[2]) or 8 +local ROWS = tonumber(arg[3]) or 6 +local wspan = tonumber(arg[4]) or 2 +local hspan = tonumber(arg[5]) or 3 +local out = arg[6] or '/tmp/widget.png' + +local GAP, MARGIN = 16, 28 +local sw, sh = 2560, 1080 + +-- The palette, read from the rendered conky.conf exactly as dashboard.lua +-- does, so the PNG uses the real scheme rather than invented colours. +local function config_colors() + local path = os.getenv('HOME') .. '/.config/conky/conky.conf' + local f = io.open(path, 'r') + if not f then return {} end + local src = f:read('*a') + f:close() + local c = {} + for k, v in src:gmatch("([%w_]+)%s*=%s*'(#%x%x%x%x%x%x)'") do c[k] = v end + return c +end + +local CFG = config_colors() + +local function hex(s, fallback) + local r, g, b = tostring(s or ''):match('^#?(%x%x)(%x%x)(%x%x)$') + if not r then return fallback or { 1, 0, 1 } end + return { tonumber(r, 16) / 255, tonumber(g, 16) / 255, tonumber(b, 16) / 255 } +end + +local colors = { + heading = hex(CFG.color1), label = hex(CFG.color2), + border = hex(CFG.color3), rule = hex(CFG.color3), + value = hex(CFG.color4), highlight = hex(CFG.color5), + ok = hex(CFG.color6), critical = hex(CFG.color7), + warning = hex(CFG.color8, { 0.88, 0.69, 0.41 }), + body = hex(CFG.default_color), + surface = hex(CFG.default_shade_color), +} + +local cw = (sw - MARGIN * 2 - GAP * (COLS - 1)) / COLS +local ch = (sh - MARGIN * 2 - GAP * (ROWS - 1)) / ROWS +local rect = { + x = MARGIN, y = MARGIN, + w = cw * wspan + GAP * (wspan - 1), + h = ch * hspan + GAP * (hspan - 1), +} + +local surf = cairo_image_surface_create(CAIRO_FORMAT_ARGB32, rect.w + 40, rect.h + 40) +local cr = cairo_create(surf) +-- A backdrop close to the dashboard's own, so contrast reads honestly. +local bg = colors.surface +cairo_set_source_rgb(cr, bg[1] * 0.6, bg[2] * 0.6, bg[3] * 0.7) +cairo_paint(cr) +cairo_translate(cr, 20 - rect.x, 20 - rect.y) + +local ok, mod = pcall(require, 'widgets.' .. widget_name) +if not ok then + print('cannot load widget ' .. widget_name .. ': ' .. tostring(mod)) + os.exit(1) +end +local drew, err = pcall(mod.draw, cr, rect, colors) +if not drew then print('DRAW ERROR: ' .. tostring(err)) end + +cairo_destroy(cr) +cairo_surface_write_to_png(surf, out) +cairo_surface_destroy(surf) +print(string.format('%s at %dx%d on a %dx%d grid -> %s', widget_name, wspan, hspan, COLS, ROWS, out)) +``` + +- [ ] **Step 2: Verify against an existing widget** + +```bash +lua test/render.lua weather 16 12 3 5 /tmp/harness-check.png +``` + +Expected: the line `weather at 3x5 on a 16x12 grid -> /tmp/harness-check.png`, and no `DRAW ERROR`. + +- [ ] **Step 3: Look at the PNG** + +Open `/tmp/harness-check.png`. Expected: the weather card as it appears on the dashboard, in the current scheme's colours. If the colours are magenta, `config_colors` did not find the rendered config, which means Task 1 Step 4 was skipped. + +- [ ] **Step 4: Commit** + +```bash +git add test/render.lua +git commit -m "test: add an offscreen widget render harness" +``` + +--- + +## Task 3: Parsers for per-core CPU and sensors + +**Files:** +- Modify: `lib/data.lua` +- Modify: `test/test_data.lua` +- Create: `test/fixtures/proc_stat_percore` + +- [ ] **Step 1: Create the fixture** + +Create `test/fixtures/proc_stat_percore`, a real `/proc/stat` shape trimmed to four cores: + +``` +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 +``` + +- [ ] **Step 2: Write the failing test** + +Insert into `test/test_data.lua`, immediately before the final `print` line: + +```lua +-- === 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 +-- seventeenth bar 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') +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `lua test/test_data.lua` +Expected: FAIL with `attempt to call a nil value (field 'per_cpu_times')` + +- [ ] **Step 4: Write the implementation** + +Add to `lib/data.lua`, before the final `return M`: + +```lua +-- 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 +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `lua test/test_data.lua` +Expected: PASS + +- [ ] **Step 6: Verify against the live machine** + +The fixture proves the parse; this proves the binding. + +```bash +lua -e ' +package.path="./?.lua;"..package.path +local d = require "lib.data" +local cores = d.per_cpu_times(d.slurp("/proc/stat")) +print("cores found:", #cores, "(expect 16 on this host)") +print("Tctl:", d.sensor("k10temp","temp1_input")) +print("Tccd1:", d.sensor("k10temp","temp3_input")) +print("NVMe:", d.sensor("nvme","temp1_input")) +print("Arc pkg:", d.sensor("xe","temp2_input")) +print("Arc vram:", d.sensor("xe","temp3_input")) +print("Arc fan:", d.sensor_raw("xe","fan1_input"), "RPM") +print("board2:", d.sensor("gigabyte_wmi","temp2_input")) +print("board3:", d.sensor("gigabyte_wmi","temp3_input")) +print("absent chip:", tostring(d.sensor("nosuchchip","temp1_input")))' +``` + +Expected: 16 cores, plausible temperatures (roughly 30-60C at idle), a fan RPM, and `nil` for the absent chip. A `nil` for a sensor that should exist means the chip name or file is wrong; fix the call, not the parser. + +- [ ] **Step 7: Commit** + +```bash +git add lib/data.lua test/test_data.lua test/fixtures/proc_stat_percore +git commit -m "feat: add per-core CPU times and named sensor reads" +``` + +--- + +## Task 4: Parsers for the two cache files + +**Files:** +- Modify: `lib/data.lua` +- Modify: `test/test_data.lua` +- Create: `test/fixtures/df_output` +- Create: `test/fixtures/du_output` + +- [ ] **Step 1: Create the fixtures** + +Create `test/fixtures/df_output`, a real `df -P -B1` capture with placeholder servers: + +``` +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 +``` + +Create `test/fixtures/du_output`, a `du -sh` capture, total first: + +``` +1.6G /home/you/.cache +1.1G /home/you/.cache/mozilla +245M /home/you/.cache/pip +131M /home/you/.cache/go-build +85M /home/you/.cache/opencode +``` + +- [ ] **Step 2: Write the failing test** + +Insert into `test/test_data.lua`, immediately before the final `print` line: + +```lua +-- === 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)) + +-- 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. +local cache = data.du_parse(read('test/fixtures/du_output')) +assert(cache.total, 'a total is parsed') +assert(cache.total.label == '1.6G', 'total label, got ' .. tostring(cache.total.label)) +assert(#cache.items == 4, 'four children, got ' .. tostring(#cache.items)) +assert(cache.items[1].name == 'mozilla', 'largest child, got ' .. tostring(cache.items[1].name)) +assert(cache.items[1].label == '1.1G', 'its label, got ' .. tostring(cache.items[1].label)) +assert(cache.items[2].name == 'pip', 'second child, got ' .. tostring(cache.items[2].name)) + +-- Sizes are compared as bytes, which is what makes 'biggest' meaningful. +assert(cache.items[1].bytes > cache.items[2].bytes, '1.1G must outrank 245M') +assert(cache.items[1].bytes > 1e9, 'a G suffix is about a billion bytes') +assert(cache.items[4].bytes < 1e8, 'an M suffix is far smaller') + +-- Share of the total drives the highlight colour. +assert(cache.items[1].bytes / cache.total.bytes > 0.5, + 'mozilla dominates this fixture, which is what the highlight keys on') + +assert(data.du_parse('') == nil, 'empty input gives nil') +assert(data.du_parse(nil) == nil, 'nil gives nil') +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `lua test/test_data.lua` +Expected: FAIL with `attempt to call a nil value (field 'df_parse')` + +- [ ] **Step 4: Write the implementation** + +Add to `lib/data.lua`, before the final `return M`: + +```lua +-- 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, 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), + 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 +-- card displays), and 'biggest' has to be decided numerically: sorted as +-- strings, '245M' beats '1.1G'. +local SUFFIX = { K = 1024, M = 1024^2, G = 1024^3, T = 1024^4, P = 1024^5 } + +function M.human_bytes(s) + if type(s) ~= 'string' then return nil end + local n, suf = s:match('^%s*([%d%.]+)%s*([KMGTP]?)') + n = tonumber(n) + if not n then return nil end + return n * (SUFFIX[suf] or 1) +end + +-- Parse `du -sh` output: the first line is the total, the rest are children +-- already sorted largest-first by the sampler. Returns nil when there is +-- nothing usable, so the widget can draw its "no data" state. +function M.du_parse(text) + if type(text) ~= 'string' or text == '' then return nil end + local lines = {} + for line in text:gmatch('[^\n]+') do lines[#lines + 1] = line end + if #lines == 0 then return nil end + + local function entry(line) + local label, path = line:match('^(%S+)%s+(.+)$') + if not label then return nil end + return { label = label, bytes = M.human_bytes(label) or 0, + name = path:match('([^/]+)/?$') or path, path = path } + end + + local total = entry(lines[1]) + if not total then return nil end + local items = {} + for i = 2, #lines do + local e = entry(lines[i]) + if e then items[#items + 1] = e end + end + return { total = total, items = items } +end +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `lua test/test_data.lua` +Expected: PASS + +- [ ] **Step 6: Run the whole suite** + +Run: `lua test/test_data.lua && lua test/test_layout.lua && lua test/test_weather.lua` +Expected: three "all assertions passed" lines + +- [ ] **Step 7: Commit** + +```bash +git add lib/data.lua test/test_data.lua test/fixtures/df_output test/fixtures/du_output +git commit -m "feat: parse df and du output for the disk and cache cards" +``` + +--- + +## Task 5: The sampler scripts + +**Files:** +- Create: `bin/disks-sample.sh` +- Create: `bin/cache-sample.sh` +- Modify: `conky.conf.in` + +- [ ] **Step 1: Write the disks sampler** + +Create `bin/disks-sample.sh`: + +```bash +#!/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) + +mkdir -p "$CACHE_DIR" + +TMP="$CACHE.tmp.$$" +trap 'rm -f "$TMP"' EXIT +umask 077 + +# /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" +``` + +- [ ] **Step 2: Write the cache sampler** + +Create `bin/cache-sample.sh`: + +```bash +#!/bin/bash +# Sample ~/.cache sizes into a cache file, for widgets/cache.lua. +# +# du walks the tree and costs about 100ms warm, which is fine every 15 minutes +# and unthinkable on a 2-second draw. The old conky config used the same +# interval for the same reason. + +set -u + +CACHE_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/udt" +CACHE="$CACHE_DIR/cachesize.txt" +TARGET="${XDG_CACHE_HOME:-$HOME/.cache}" + +mkdir -p "$CACHE_DIR" + +TMP="$CACHE.tmp.$$" +trap 'rm -f "$TMP"' EXIT +umask 077 + +# Total first, then the four largest children. `sort -h` compares human sizes +# numerically; the Lua side converts them again because it needs the ratio to +# the total, not just the order. +{ + du -sh "$TARGET" 2>/dev/null + du -sh "$TARGET"/* 2>/dev/null | sort -hr | head -4 +} > "$TMP" + +if [ ! -s "$TMP" ]; then + echo "cache-sample: du produced nothing" >&2 + exit 1 +fi + +mv -f "$TMP" "$CACHE" +``` + +- [ ] **Step 3: Make them executable and lint** + +```bash +chmod +x bin/disks-sample.sh bin/cache-sample.sh +shellcheck bin/disks-sample.sh bin/cache-sample.sh +``` + +Expected: no output. + +- [ ] **Step 4: Run both and check the output parses** + +```bash +./bin/disks-sample.sh; echo "disks exit: $?" +./bin/cache-sample.sh; echo "cache exit: $?" +ls -l ~/.cache/udt/disks.txt ~/.cache/udt/cachesize.txt | awk '{print $1, $NF}' +``` + +Expected: both exit 0, both files mode `-rw-------`. + +```bash +lua -e ' +package.path="./?.lua;"..package.path +local d = require "lib.data" +local fs = d.df_parse(d.slurp(os.getenv("HOME").."/.cache/udt/disks.txt")) +print("filesystems:", #fs) +for _, e in ipairs(fs) do print(string.format(" %-20s %3d%%", e.mount, e.pct)) end +local c = d.du_parse(d.slurp(os.getenv("HOME").."/.cache/udt/cachesize.txt")) +print("cache total:", c and c.total.label, "children:", c and #c.items) +for _, e in ipairs(c and c.items or {}) do print(" " .. e.name, e.label) end' +``` + +Expected: five filesystems with plausible percentages, a cache total and four named children. **This is the step that catches the `df -h` alias trap**: if percentages are nil or sizes look like `1`, the sampler is not producing bytes. + +- [ ] **Step 5: Verify a failed sample keeps the old cache** + +```bash +cp ~/.cache/udt/disks.txt /tmp/disks.good +md5sum ~/.cache/udt/disks.txt +# a sampler pointed at a nonexistent mount +sed 's|/mnt/nfs/Library|/mnt/nfs/nonexistent|' bin/disks-sample.sh > /tmp/ds.sh +chmod +x /tmp/ds.sh && /tmp/ds.sh; echo "exit: $?" +md5sum ~/.cache/udt/disks.txt +rm -f /tmp/ds.sh /tmp/disks.good +``` + +Expected: `df` still reports the reachable mounts, so the cache updates with those. The point is that it does not end up empty or truncated. + +- [ ] **Step 6: Schedule both** + +In `conky.conf.in`, replace the `conky.text` line: + +```lua +conky.text = [[${execi 900 ~/.config/conky/bin/weather-fetch.sh}${execi 60 ~/.config/conky/bin/disks-sample.sh}${execi 900 ~/.config/conky/bin/cache-sample.sh}]] +``` + +All three render nothing and are covered by Cairo; the execi still fires, which was verified with a probe config before the weather widget relied on it. + +- [ ] **Step 7: Commit** + +```bash +git add bin/disks-sample.sh bin/cache-sample.sh conky.conf.in +git commit -m "feat: add the disks and cache samplers" +``` + +--- + +## Task 6: Drawing primitives + +**Files:** +- Modify: `lib/card.lua` + +- [ ] **Step 1: Add the three primitives** + +Add to `lib/card.lua`, before the final `return M`: + +```lua +-- Colour for a value against two thresholds. +-- +-- One function so all four system 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. +function M.threshold(v, warn, crit, colors) + if type(v) ~= 'number' then return colors.label end + if v >= crit then return colors.critical end + if v >= warn then return colors.warning end + 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) + + 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 cap as it was found: a later stroke inheriting ROUND would get + -- visibly rounded ends on the card's hairlines. + cairo_set_line_cap(cr, CAIRO_LINE_CAP_BUTT) +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', 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 +``` + +- [ ] **Step 2: Check it parses** + +Run: `luac -p lib/card.lua && echo "syntax ok"` +Expected: `syntax ok` + +- [ ] **Step 3: Check the formatter and threshold by hand** + +These are pure functions, so they check without Cairo: + +```bash +lua -e ' +package.path="./?.lua;"..package.path +-- card.lua needs the cairo globals only inside the drawing functions, so a +-- stub is enough to require it for the pure ones. +cairo_text_extents_t = { create = function() return {} end } +local card = require "lib.card" +for _, b in ipairs{0, 999, 1024, 1181116006, 1024^3, 3958241259520} do + print(string.format("%16s -> %s", tostring(b), card.human(b))) +end +local colors = { ok="OK", warning="WARN", critical="CRIT", label="LBL" } +for _, v in ipairs{0, 24, 25, 74, 75, 100} do + print(string.format(" %3d%% -> %s", v, card.threshold(v, 25, 75, colors))) +end +print(" nil ->", card.threshold(nil, 25, 75, colors))' +``` + +Expected: `1181116006 -> 1.1G`, `3958241259520 -> 3.6T`; thresholds giving OK below 25, WARN from 25 to 74, CRIT at 75 and above, and LBL for nil. + +- [ ] **Step 4: Commit** + +```bash +git add lib/card.lua +git commit -m "feat: add bar, vbar, ring and threshold primitives" +``` + +--- + +## Task 7: The system card + +**Files:** +- Create: `widgets/system.lua` +- Modify: `dashboard.lua` + +- [ ] **Step 1: Write the widget** + +Create `widgets/system.lua`: + +```lua +-- 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() + +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, 11, 18) + local big_size = clamp(inner.w * 0.085, 26, 56) + local row_size = clamp(inner.w * 0.030, 11, 17) + + local stat = data.slurp('/proc/stat') + local agg_total, agg_idle = data.cpu_times(stat or '') + local load = total_counter:sample(agg_total, agg_idle) + + local y = inner.y + + -- Aggregate load, the headline number. + card.font(cr, card.FONT_MONO, label_size, false) + card.rgba(cr, colors.label) + card.text(cr, inner.x, y + label_size, 'CPU') + + 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(cr, inner.x, y + label_size + big_size, + load and string.format('%d%%', math.floor(load + 0.5)) or '--') + + local ey = y + label_size + big_size + 14 + + -- The equaliser: one bar per core, rising from a common baseline. + local cores = data.per_cpu_times(stat or '') + local n = #cores + if n > 0 then + local gap = math.max(2, inner.w * 0.006) + local bw = (inner.w - gap * (n - 1)) / n + local eh = clamp(inner.h * 0.16, 24, 80) + -- 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, c in ipairs(cores) do + counters[i] = counters[i] or data.new_cpu_counter() + local pct = counters[i]:sample(c.total, c.idle) or 0 + card.vbar(cr, inner.x + (i - 1) * (bw + gap), ey + eh, bw, eh, + pct / 100, card.threshold(pct, 25, 75, colors), colors) + end + else + card.bar(cr, inner.x, ey + eh - 8, inner.w, 8, (load or 0) / 100, + card.threshold(load or 0, 25, 75, colors), colors) + end + ey = ey + eh + 16 + end + + -- 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 + 8 + card.bar(cr, inner.x, ey, inner.w, 8, frac, + card.threshold(frac * 100, 25, 75, colors), colors) + ey = ey + 22 + end + + -- Temperatures, each against its own ceiling. + card.font(cr, card.FONT_MONO, row_size, false) + local step = row_size * 1.8 + 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 +``` + +- [ ] **Step 2: Check it parses and renders** + +```bash +luac -p widgets/system.lua && echo "syntax ok" +lua test/render.lua system 16 12 3 6 /tmp/system.png +``` + +Expected: `syntax ok`, then the render line with no `DRAW ERROR`. + +- [ ] **Step 3: Look at the PNG, twice** + +Open `/tmp/system.png`. Expected: a CPU percentage, a row of sixteen bars at varying heights, a RAM bar, and five temperature rows. + +**The equaliser needs two renders to judge**, because the first sample of every counter returns nil and draws every bar at zero. Run it again immediately: + +```bash +lua test/render.lua system 16 12 3 6 /tmp/system2.png +``` + +`/tmp/system2.png` is a fresh process, so its bars will also be flat. That is expected and is not a bug: in conky the counters persist across frames and fill in from the second frame onward. Judge the bar layout here and the bar *heights* in Task 10 on the live dashboard. + +- [ ] **Step 4: Add it to the layout** + +In `dashboard.lua`, add to the `layout` table: + +```lua + { widget = 'system', col = 4, row = 1, w = 3, h = 6 }, +``` + +- [ ] **Step 5: Run the suite** + +Run: `lua test/test_data.lua && lua test/test_layout.lua && lua test/test_weather.lua` +Expected: three "all assertions passed" lines. + +- [ ] **Step 6: Commit** + +```bash +git add widgets/system.lua dashboard.lua +git commit -m "feat: add the system card with a per-core equaliser" +``` + +--- + +## Task 8: The GPU card + +**Files:** +- Create: `widgets/gpu.lua` +- Modify: `dashboard.lua` + +- [ ] **Step 1: Write the widget** + +Create `widgets/gpu.lua`: + +```lua +-- GPU: Arc B580 temperatures and fan speed. +-- +-- No utilisation figure, deliberately. The xe driver exposes no +-- gpu_busy_percent, intel_gpu_top refuses the device outright ("Detected Xe +-- device which is not supported"), and gputop prints per-process rows with +-- ANSI escapes, which is not an interface to build a widget on. The card shows +-- what the hardware actually reports rather than inventing a number. +-- +-- 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 = {} + +-- chip, file, label, warn, crit +local TEMPS = { + { 'xe', 'temp2_input', 'PKG', 75, 85 }, + { '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 + + local label_size = clamp(inner.w * 0.030, 11, 18) + local big_size = clamp(inner.w * 0.085, 26, 56) + local row_size = clamp(inner.w * 0.030, 11, 17) + + local y = inner.y + + card.font(cr, card.FONT_MONO, label_size, false) + card.rgba(cr, colors.label) + card.text(cr, inner.x, y + label_size, 'GPU') + card.rgba(cr, colors.value) + card.text_right(cr, inner.x + inner.w, y + label_size, 'ARC B580') + + -- The package temperature is the headline, since there is no load to show. + local pkg = data.sensor('xe', 'temp2_input') + card.font(cr, card.FONT_HEAVY, big_size, false) + card.rgba(cr, pkg and card.threshold(pkg, 75, 85, colors) or colors.label) + card.text(cr, inner.x, y + label_size + big_size, + pkg and string.format('%d\u{00B0}', pkg) or '--') + + local ey = y + label_size + big_size + 18 + 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 + + -- 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 +``` + +- [ ] **Step 2: Check and render** + +```bash +luac -p widgets/gpu.lua && echo "syntax ok" +lua test/render.lua gpu 16 12 3 3 /tmp/gpu.png +``` + +- [ ] **Step 3: Look at the PNG** + +Open `/tmp/gpu.png`. Expected: "GPU" and "ARC B580", a large package temperature, then PKG, VRAM and FAN rows. Compare the numbers against: + +```bash +for f in temp2_input temp3_input fan1_input; do + d=$(grep -lx xe /sys/class/hwmon/hwmon*/name | head -1); d=${d%/name} + printf '%-12s %s\n' "$f" "$(cat $d/$f)" +done +``` + +Temperatures are millidegrees there, so `51000` means the card should show `51`. + +- [ ] **Step 4: Add to the layout** + +In `dashboard.lua`: + +```lua + { widget = 'gpu', col = 7, row = 1, w = 3, h = 3 }, +``` + +- [ ] **Step 5: Commit** + +```bash +git add widgets/gpu.lua dashboard.lua +git commit -m "feat: add the GPU card" +``` + +--- + +## Task 9: The disks and cache cards + +**Files:** +- Create: `widgets/disks.lua` +- Create: `widgets/cache.lua` +- Modify: `dashboard.lua` + +- [ ] **Step 1: Write the disks widget** + +Create `widgets/disks.lua`: + +```lua +-- 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) + + card.font(cr, card.FONT_MONO, label_size, false) + card.rgba(cr, colors.label) + card.text(cr, inner.x, inner.y + label_size, 'DISKS') + + local fs = data.df_parse(data.slurp(CACHE) or '') + if #fs == 0 then + card.rgba(cr, colors.label) + card.font(cr, card.FONT_UI, label_size * 1.2, true) + card.text(cr, inner.x, inner.y + label_size * 3.4, 'no disk data') + card.font(cr, card.FONT_MONO, label_size, false) + card.text(cr, inner.x, inner.y + label_size * 5, 'bin/disks-sample.sh') + return + end + + local top = inner.y + label_size * 2.2 + local avail_h = (inner.y + inner.h) - top + + -- Lay the rings out in a row, wrapping if the card is narrow. 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 + local r = math.min(cell_w * 0.34, avail_h * 0.30) + + 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 + card.font(cr, card.FONT_MONO, clamp(r * 0.20, 9, 12), false) + card.rgba(cr, colors.label) + local sw_ = card.measure(cr, sub) + card.text(cr, cx - sw_ / 2, cy + r * 0.55, sub) + end + + -- The percentage sits under the ring, in the threshold colour. + card.font(cr, card.FONT_MONO, label_size, false) + card.rgba(cr, colour) + local pct = string.format('%d%%', e.pct) + local pw = card.measure(cr, pct) + card.text(cr, cx - pw / 2, cy + r + label_size * 1.7, pct) + end + else + local step = label_size * 2.6 + 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 + 6, inner.w, 6, e.pct / 100, colour, colors) + ey = ey + step + end + end +end + +return M +``` + +- [ ] **Step 2: Write the cache widget** + +Create `widgets/cache.lua`: + +```lua +-- Cache: ~/.cache total and its four largest subdirectories. +-- +-- Reads a cache file written by bin/cache-sample.sh. du walks the tree and +-- costs about 100ms, which cannot happen on a 2-second draw. + +local card = require 'lib.card' +local data = require 'lib.data' + +local CACHE = (os.getenv('XDG_CACHE_HOME') or (os.getenv('HOME') .. '/.cache')) + .. '/udt/cachesize.txt' + +local M = {} + +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 big_size = clamp(inner.w * 0.075, 22, 46) + + card.font(cr, card.FONT_MONO, label_size, false) + card.rgba(cr, colors.label) + card.text(cr, inner.x, inner.y + label_size, 'CACHE') + + local c = data.du_parse(data.slurp(CACHE) or '') + if not c then + card.rgba(cr, colors.label) + card.font(cr, card.FONT_UI, label_size * 1.2, true) + card.text(cr, inner.x, inner.y + label_size * 3.4, 'no cache data') + card.font(cr, card.FONT_MONO, label_size, false) + card.text(cr, inner.x, inner.y + label_size * 5, 'bin/cache-sample.sh') + return + end + + card.font(cr, card.FONT_HEAVY, big_size, false) + card.rgba(cr, colors.body) + card.text(cr, inner.x, inner.y + label_size + big_size, c.total.label) + + local ey = inner.y + label_size + big_size + label_size * 2 + local step = label_size * 2.2 + + card.font(cr, card.FONT_MONO, label_size, false) + for i, e in ipairs(c.items) do + if ey + step > inner.y + inner.h then break end + -- The biggest offender is the point of the card, so it carries colour: + -- critical when it is over half the total, warning otherwise. The rest + -- stay in the ordinary value colour so the eye lands on the one to delete. + local colour = colors.value + if i == 1 then + local share = c.total.bytes > 0 and (e.bytes / c.total.bytes) or 0 + colour = share > 0.5 and colors.critical or colors.warning + end + card.rgba(cr, colors.label) + -- Long names are truncated rather than allowed to collide with the size. + local name = e.name + if #name > 18 then name = name:sub(1, 17) .. '\u{2026}' end + card.text(cr, inner.x, ey, name) + card.rgba(cr, colour) + card.text_right(cr, inner.x + inner.w, ey, e.label) + ey = ey + step + end +end + +return M +``` + +- [ ] **Step 3: Check and render both** + +```bash +luac -p widgets/disks.lua widgets/cache.lua && echo "syntax ok" +lua test/render.lua disks 16 12 5 4 /tmp/disks.png +lua test/render.lua cache 16 12 3 4 /tmp/cache.png +lua test/render.lua disks 16 12 2 3 /tmp/disks-small.png +``` + +- [ ] **Step 4: Look at all three PNGs** + +`/tmp/disks.png`: five rings with glyphs at their centres, names and percentages beneath, each coloured by usage. At the real figures, `/` at 84% should be `critical`, `/data` at 59% and Library at 79% should differ visibly. + +`/tmp/disks-small.png`: the same data as labelled bars, because the rings would be under 18px. This is the fallback working, not a bug. + +`/tmp/cache.png`: the total, then four rows with the largest coloured. + +**Check the glyphs rendered.** All five were taken from the old conky config +and re-rendered while this plan was written, so they are known good, but a +wrong-but-present codepoint draws a plausible neighbour rather than failing, so +look: + +```bash +f=$(fc-match -f '%{file}' 'Inconsolata Nerd Font') +magick -background '#16161e' -fill '#c0caf5' -font "$f" -pointsize 80 \ + label:$'\uF318 \uF015 \U000F02CA \U000F06F3' /tmp/diskglyphs.png +``` + +Open it: expected, in order, the Slackware S, a house, a hard disk platter, +and a network-share icon. A blank means the codepoint is absent entirely. + +**Check the two NFS rings show their addresses** and that the network glyph's +connector does not run outside the ring. If it does, reduce `gsize` below +`r * 0.55`. + +- [ ] **Step 5: Add both to the layout** + +In `dashboard.lua`: + +```lua + { widget = 'disks', col = 7, row = 4, w = 5, h = 4 }, + { widget = 'cache', col = 12, row = 4, w = 3, h = 4 }, +``` + +- [ ] **Step 6: Run the suite and commit** + +```bash +lua test/test_data.lua && lua test/test_layout.lua && lua test/test_weather.lua +git add widgets/disks.lua widgets/cache.lua dashboard.lua +git commit -m "feat: add the disks and cache cards" +``` + +--- + +## Task 10: Install, look at it, iterate + +Nothing so far has been seen in the real dashboard. + +- [ ] **Step 1: Render and restart** + +```bash +cd ../unified-desktop-theme && ./install.sh && cd - +./restart.sh +``` + +`install.sh` is needed here, not just `restart.sh`, because `conky.conf.in` changed in Tasks 1 and 5. + +- [ ] **Step 2: Confirm the samplers ran** + +```bash +sleep 70 # the disks sampler is on a 60s execi +ls -l ~/.cache/udt/*.txt +``` + +Expected: both files, recently modified. If `disks.txt` is missing after 70s, the `${execi}` line in `conky.conf.in` did not take; check the rendered `~/.config/conky/conky.conf`. + +- [ ] **Step 3: Screenshot the dashboard** + +Poll for the workspace switch rather than sleeping through it, and capture the output rather than the window geometry (a window on a hidden special workspace reports a negative Y): + +```bash +hyprctl dispatch 'hl.dsp.workspace.toggle_special("dash")' >/dev/null +for i in $(seq 40); do + [ "$(hyprctl monitors -j | jq -r '.[0].specialWorkspace.name')" = "special:dash" ] && break + sleep 0.15 +done +sleep 3 +grim -o DP-1 /tmp/dash.png +hyprctl dispatch 'hl.dsp.workspace.toggle_special("dash")' >/dev/null +``` + +- [ ] **Step 4: Look at the PNG** + +Open `/tmp/dash.png` and read it. `grim` exits 0 whether the cards drew correctly, drew nothing, or drew an error. + +Check specifically: + +- **The equaliser has varying bar heights.** Flat bars mean the per-core counters are not persisting; they are module-level in `widgets/system.lua` precisely so they survive between frames. +- **Colours differ between cards by state**, not everything one colour. A filesystem at 84% and one at 33% must look different. +- **The `warning` colour appears at all.** If nothing is ever amber, `color8` did not render; check `grep color8 ~/.config/conky/conky.conf`. +- **Nothing overlaps or runs past a card edge.** + +- [ ] **Step 5: Load the machine and look again** + +Thresholds are invisible at idle. Make something happen: + +```bash +stress-ng --cpu 16 --timeout 30s 2>/dev/null & +sleep 12 +# screenshot again as in Step 3, into /tmp/dash-load.png +``` + +If `stress-ng` is not installed, `for i in $(seq 16); do yes >/dev/null & done; sleep 12; kill %1 %2 %3 %4` and so on, or simply build something. + +Expected: the equaliser rises and turns amber then red, the CPU percentage follows, temperatures climb. This is the only way to see the colour language working. + +- [ ] **Step 6: Fix what the screenshots show** + +Likely first-pass problems, each fixed in the widget and re-rendered offscreen before restarting: + +- Text colliding with a ring or running past the card edge: reduce the font size or the ring radius. +- Rings cramped: raise the fallback threshold above 18px so bars take over sooner. +- Equaliser too short or too tall: adjust the `clamp(inner.h * 0.16, 24, 80)` band. +- A temperature row missing: the card ran out of vertical room and stopped, which is by design; give the card more rows or drop a sensor. + +- [ ] **Step 7: Commit any adjustments** + +```bash +git add widgets/ dashboard.lua +git commit -m "fix: adjust the system cards against the screenshots" +``` + +--- + +## Task 11: Documentation + +**Files:** +- Modify: `README.md` + +- [ ] **Step 1: Document the cards and samplers** + +Add to `README.md`: + +- A **System widgets** section: what the four cards show, that the samplers are scheduled by `${execi}` in `conky.text`, and how to run them by hand (`./bin/disks-sample.sh`, `./bin/cache-sample.sh`). +- The **host bindings** table from the spec (chip, file, label, ceilings), stating plainly that these are specific to this machine. +- The **colour language**: `ok`/`warning`/`critical` at 25% and 75% for proportions, per-sensor ceilings for temperatures. + +Add to **Gotchas worth knowing**: + +- `df` is aliased to `df -h` in the user's shell, so the sampler calls `/usr/bin/df -P -B1` by absolute path. A sampler using bare `df` hands the parser human-readable sizes and every figure silently mis-reads. +- The Arc B580 exposes no utilisation figure through any stable interface, which is why the GPU card has no load bar. +- `/data` is on a spinning disk with no hwmon chip, so it has no temperature. +- Per-core counters are module-level in `widgets/system.lua` because load is a delta between frames; a counter recreated each draw reports nil forever. + +- [ ] **Step 2: Verify every documented command** + +Run each command the README now claims works and confirm the output matches. A README documenting a command nobody ran is how the last one drifted. + +- [ ] **Step 3: Commit** + +```bash +git add README.md +git commit -m "docs: document the system widgets" +``` + +--- + +## Self-review notes + +Checked against the spec: every section has a task. The spec's out-of-scope items (disk I/O rates, GPU utilisation, network) stay out. + +Two things this plan cannot settle in advance: + +- **The equaliser's bar heights cannot be judged offscreen.** Every counter's first sample is nil, so a one-shot render always draws flat bars. Task 7 says so explicitly and Task 10 Step 5 is where they are actually verified, under load. +- **The exact band sizes in every widget are a first pass.** Task 10 Step 6 corrects them against screenshots, which is the only way to judge them. + +One risk worth naming: `widgets/disks.lua` and `widgets/system.lua` are the two largest widgets so far, and `system.lua` carries CPU, memory and temperatures. If it grows further it should split, but at four sections it is still one coherent card. |
