diff options
| -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. |
