1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
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.
|