# 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 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/.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 ') 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.