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
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
|
# Network and Slackware 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:** Add a network card (LAN and public address, dual-line throughput chart) and a Slackware card (version, packages, kernel, time since the last ChangeLog change), plus CPU/board identification on the system card and free/total figures under each disk ring.
**Architecture:** Pure parsers in `lib/data.lua` tested against fixtures, shared Cairo primitives in `lib/card.lua`, one file per card in `widgets/`, and anything slow or blocking pushed into a sampler script under `bin/` that writes a cache file. The draw hook reads files and never spawns a subprocess.
**Tech Stack:** Lua 5.4 (conky's embedded interpreter), Cairo via conky's bindings, bash for samplers. No third-party Lua modules: conky's Lua has no `lfs` and no socket library, which is why timestamps come from samplers rather than from `stat` in-process.
**Spec:** `docs/superpowers/specs/2026-09-17-network-slackware-widgets-design.md`
---
## Before You Start
Read `DESIGN.md`. Every card here must obey it: the big value sits at the card's
top-right, rows run label-left and value-right, type is fluid via
`card.fit_unit`, and measured quantities take the `ok`/`warning`/`critical`
colour from `card.threshold`. A card that ignores it is a defect.
Read `AGENTS.md`. The repository is public: **no LAN addresses, hostnames,
usernames or real locations in committed files.** Fixtures use `192.0.2.x`
(TEST-NET-1) and generic hardware strings. Derive per-host values at runtime.
Two environment facts that will otherwise waste your time:
- **A Lua error in conky is a blank screen with no message.** Parsers and
widgets return nil or a safe default rather than raising.
- **Conky caches widget modules.** After editing anything under `widgets/` or
`lib/`, run `./restart.sh`. Editing `dashboard.lua` alone does not need it.
The test suite is plain `assert`, no framework:
```bash
lua test/test_data.lua && lua test/test_layout.lua && lua test/test_weather.lua
```
Each test file is a script that exits non-zero on the first failed assert. Run
it directly; there is no test runner and no per-test selection.
---
## File Structure
**Create:**
| File | Responsibility |
|---|---|
| `bin/pubip-sample.sh` | Fetch the public address, validate it, write `~/.cache/udt/pubip.txt` |
| `bin/slackware-sample.sh` | Read five Slackware facts, write `~/.cache/udt/slackware.txt` |
| `widgets/network.lua` | Draw the network card; owns the history ring buffers |
| `widgets/slackware.lua` | Draw the Slackware card |
| `test/fixtures/proc_cpuinfo` | CPU model fixture (AMD form) |
| `test/fixtures/proc_cpuinfo_intel` | CPU model fixture (Intel form) |
| `test/fixtures/slackware_cache` | `key value` sampler output |
| `test/fixtures/ip_addr` | `ip -4 addr` output, TEST-NET address |
**Modify:**
| File | Change |
|---|---|
| `lib/data.lua` | Add `new_rate_counter`, `kv_parse`, `cpu_model`, `board_name`, `iface_addr`; add `avail` to `df_parse` |
| `lib/card.lua` | Add `plot` and `truncate`; fix `ring` to restore line width |
| `widgets/system.lua` | Two identification rows under the header |
| `widgets/disks.lua` | Free and total under each ring, replacing the percentage |
| `widgets/cache.lua` | Use `card.truncate` instead of byte slicing |
| `test/test_data.lua` | Cases for every new parser, plus the `fs[3]` gap |
| `conky.conf.in` | Two new `execi` samplers |
| `dashboard.lua` | Two new layout entries |
| `README.md` | Document both cards and both samplers |
| `TODO.md` | Remove the completed item |
**Task order rationale:** primitives and parsers first (Tasks 1-6), because both
cards depend on them; then the samplers (7, 9); then the cards (8, 10); then the
edits to existing cards (11, 12); then wiring and documentation (13, 14). Every
task ends at a commit and leaves the suite green.
---
## Task 1: Fix `card.ring` line-width leak, add `card.truncate`
`card.ring` sets a line width and restores only the cap, so a later stroke
inherits the ring's width. `card.plot` (Task 2) sets both, so fix the pattern
once here. `card.truncate` replaces `cache.lua`'s byte slicing, which can split
a multi-byte UTF-8 name in half and emit an invalid sequence.
**Files:**
- Modify: `lib/card.lua:182-206` (`M.ring`), and append `M.truncate`
- Test: `test/test_data.lua` (append a truncate section)
- [ ] **Step 1: Write the failing test**
Append to `test/test_data.lua`, before any final summary line:
```lua
-- === card.truncate ========================================================
-- Truncation is by CHARACTER, not byte: slicing a UTF-8 string mid-sequence
-- emits an invalid byte that Cairo draws as a replacement box, and the name
-- that needed shortening is exactly the kind that carries accents.
local card = require 'lib.card'
assert(card.truncate('short', 10) == 'short', 'a short string is unchanged')
assert(card.truncate('exactlyten', 10) == 'exactlyten', 'a string at the limit is unchanged')
assert(card.truncate('abcdefghijkl', 10) == 'abcdefghi\u{2026}',
'a long string is cut to limit-1 plus an ellipsis, got ' .. tostring(card.truncate('abcdefghijkl', 10)))
-- The multi-byte case: ten accented characters are 20 bytes, so a byte-based
-- slice would cut one in half and produce invalid UTF-8.
local accented = string.rep('\u{00E9}', 12)
local cut = card.truncate(accented, 10)
assert(cut == string.rep('\u{00E9}', 9) .. '\u{2026}',
'accented input must cut on a character boundary, got ' .. tostring(cut))
assert(card.truncate(nil, 10) == '', 'nil truncates to empty')
assert(card.truncate('abc', 0) == '', 'a zero limit gives empty')
```
- [ ] **Step 2: Run the test to verify it fails**
```bash
lua test/test_data.lua
```
Expected: FAIL with `attempt to call a nil value (field 'truncate')`.
- [ ] **Step 3: Implement**
In `lib/card.lua`, change `M.ring` to save and restore the line width. Replace
the two lines that set width and cap:
```lua
width = width or math.max(3, r * 0.18)
-- Start a fresh path. A preceding card.text() leaves a current point behind,
-- and cairo_arc() joins to it with a straight line, so without this every
-- ring after a label is drawn with a stray chord to the text baseline.
cairo_new_path(cr)
local prev_width = cairo_get_line_width(cr)
cairo_set_line_width(cr, width)
cairo_set_line_cap(cr, CAIRO_LINE_CAP_ROUND)
```
and replace the closing cap restore with both:
```lua
-- Leave the line state as it was found. A later stroke inheriting ROUND gets
-- visibly rounded ends on the card's hairlines, and one inheriting this
-- width gets a hairline several pixels thick.
cairo_set_line_cap(cr, CAIRO_LINE_CAP_BUTT)
cairo_set_line_width(cr, prev_width)
```
Append `M.truncate` to `lib/card.lua`, before the final `return M`:
```lua
-- Shorten a string to `limit` characters, appending an ellipsis when it cuts.
--
-- By character, never by byte: Lua's string.sub counts bytes, so slicing a
-- UTF-8 name mid-sequence emits an invalid byte, which Cairo draws as a
-- replacement box. A name long enough to need shortening is exactly the kind
-- likely to carry an accent.
--
-- This counts codepoints, not rendered width, so it does not account for a
-- double-width CJK glyph. That is the right trade here: the strings it cuts
-- are cache directory names and hardware model strings. Measure with
-- M.measure when true rendered width matters.
function M.truncate(s, limit)
s = tostring(s or '')
limit = tonumber(limit) or 0
if limit <= 0 then return '' end
if utf8.len(s) == nil then return s:sub(1, limit) end -- not valid UTF-8: byte-slice
if utf8.len(s) <= limit then return s end
local cut = utf8.offset(s, limit) -- byte index of the limit'th character
return s:sub(1, cut - 1) .. '\u{2026}'
end
```
- [ ] **Step 4: Run the test to verify it passes**
```bash
lua test/test_data.lua
```
Expected: PASS, no output, exit 0.
- [ ] **Step 5: Commit**
```bash
git add lib/card.lua test/test_data.lua
git commit -m "fix: restore the line width card.ring sets, add card.truncate
A ring left its line width behind, so the next stroke on the card inherited
a hairline several pixels thick. It already restored the cap for the same
reason.
card.truncate replaces byte slicing, which cuts a multi-byte character in
half and emits an invalid sequence Cairo draws as a box.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
```
---
## Task 2: `card.plot`, the two-line chart primitive
**Files:**
- Modify: `lib/card.lua` (append `M.plot`)
- Test: verified visually in Task 8; the arithmetic that matters is tested in Task 3
No unit test here: this draws to a Cairo context and produces pixels, which the
suite has no way to assert against. The project's stated convention is that
parsers get tests and drawing is verified by screenshot. The scaling arithmetic
lives in the widget and is tested there.
- [ ] **Step 1: Implement**
Append to `lib/card.lua`, before `return M`:
```lua
-- A line chart: several series sharing one baseline and one vertical scale.
--
-- `series` is a list of { values = <array>, colour = <rgb> }. `max` is the
-- shared ceiling; passing one rather than computing per series is the whole
-- point, because two series scaled independently lie about their relative
-- size: a 200kB/s upload would draw the same height as a 40MB/s download.
--
-- Values are drawn oldest-left, one per array entry, so a caller sizing its
-- history to the pixel width gets one sample per column and no interpolation.
-- A series shorter than its buffer draws only what it has, which is what a
-- freshly started dashboard shows while the window fills.
function M.plot(cr, x, y, w, h, series, max, colors)
if not (w > 0 and h > 0) then return end
max = tonumber(max) or 0
if max <= 0 then max = 1 end -- an idle link is a flat line, not a division by zero
-- The baseline, so an empty chart still reads as a chart rather than a gap.
M.rgba(cr, colors.rule, 0.5)
cairo_new_path(cr)
cairo_set_line_width(cr, 1)
cairo_move_to(cr, x, y + h)
cairo_line_to(cr, x + w, y + h)
cairo_stroke(cr)
local prev_width = cairo_get_line_width(cr)
local prev_cap = cairo_get_line_cap(cr)
cairo_set_line_width(cr, math.max(1.5, h * 0.02))
cairo_set_line_cap(cr, CAIRO_LINE_CAP_ROUND)
for _, s in ipairs(series) do
local v = s.values
local n = #v
if n >= 2 then
-- One sample per column when the caller sized its buffer to the width.
local step = w / math.max(n - 1, 1)
M.rgba(cr, s.colour, 1)
cairo_new_path(cr)
for i = 1, n do
local frac = v[i] / max
if frac < 0 then frac = 0 elseif frac > 1 then frac = 1 end
local px = x + (i - 1) * step
local py = y + h - frac * h
if i == 1 then cairo_move_to(cr, px, py) else cairo_line_to(cr, px, py) end
end
cairo_stroke(cr)
end
end
-- Leave the line state as it was found, for the same reason card.ring does.
cairo_set_line_width(cr, prev_width)
cairo_set_line_cap(cr, prev_cap)
end
```
- [ ] **Step 2: Verify it loads**
```bash
lua -e "package.path='./?.lua;'..package.path; local c=require 'lib.card'; assert(type(c.plot)=='function'); print('ok')"
```
Expected: `ok`.
- [ ] **Step 3: Run the suite**
```bash
lua test/test_data.lua && lua test/test_layout.lua && lua test/test_weather.lua
```
Expected: no output, exit 0. (`lib/card.lua` is required by the truncate test.)
- [ ] **Step 4: Commit**
```bash
git add lib/card.lua
git commit -m "feat: add card.plot, a shared-scale line chart
Several series on one baseline and one ceiling. The shared ceiling is the
point: scaled independently, a 200kB/s upload draws the same height as a
40MB/s download, which is a lie about a link's shape.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
```
---
## Task 3: `data.new_rate_counter`
A byte counter is cumulative, so a rate needs two samples. This mirrors
`new_cpu_counter`, which is the established pattern for stateful sampling.
**Files:**
- Modify: `lib/data.lua` (append after `new_cpu_counter`)
- Test: `test/test_data.lua`
- [ ] **Step 1: Write the failing test**
Append to `test/test_data.lua`:
```lua
-- === Network rate =========================================================
-- Interface byte counters are cumulative, so a rate is a delta over elapsed
-- time. The first call has no previous sample and must report nil: any number
-- it invented would be wrong, and a spike at startup looks real.
local r = data.new_rate_counter()
assert(r:sample(1000, 100) == nil, 'first sample must give nil')
-- 2048 bytes over 2 seconds is 1024 B/s.
local rate = r:sample(3048, 102)
assert(rate == 1024, 'second sample, got ' .. tostring(rate))
-- A counter that went backwards means a wrap or an interface reset. It must
-- clamp to zero, never produce the huge positive an unsigned wrap implies:
-- one bogus sample poisons the shared autoscale for the whole window.
local r2 = data.new_rate_counter()
r2:sample(5000, 100)
assert(r2:sample(10, 102) == 0, 'a counter reset must give 0, got ' .. tostring(r2:sample(20, 104)))
-- Two samples inside the same clock second. os.time() has whole-second
-- resolution against a 2s draw interval, so this happens in normal operation
-- and must not divide by zero.
local r3 = data.new_rate_counter()
r3:sample(1000, 500)
local same_second = r3:sample(2000, 500)
assert(same_second ~= nil and same_second >= 0,
'a zero time delta must fall back to the draw interval, got ' .. tostring(same_second))
-- A missing interface reads nil, which must propagate rather than raise.
local r4 = data.new_rate_counter()
assert(r4:sample(nil, 100) == nil, 'a nil byte count gives nil')
```
- [ ] **Step 2: Run the test to verify it fails**
```bash
lua test/test_data.lua
```
Expected: FAIL with `attempt to call a nil value (field 'new_rate_counter')`.
- [ ] **Step 3: Implement**
Append to `lib/data.lua`, after `new_cpu_counter`:
```lua
-- A stateful byte-rate counter, for an interface's rx/tx totals.
--
-- Same shape as new_cpu_counter and for the same reason: the counters are
-- cumulative, so one reading cannot produce a rate, and each direction needs
-- its own previous sample.
--
-- `fallback_dt` is the draw interval, used when two samples land in the same
-- clock second. os.time() resolves to whole seconds and the dashboard draws
-- every two, so equal timestamps are ordinary, not exceptional.
function M.new_rate_counter(fallback_dt)
return {
prev_bytes = nil,
prev_time = nil,
-- Returns bytes per second since the previous sample, or nil when there is
-- no usable delta (first call, or a nil reading from a vanished interface).
sample = function(self, bytes, now)
if type(bytes) ~= 'number' then return nil end
now = now or os.time()
local pb, pt = self.prev_bytes, self.prev_time
self.prev_bytes, self.prev_time = bytes, now
if not pb then return nil end
local dt = now - pt
-- Same second, or a clock that went backwards over an NTP step.
if dt <= 0 then dt = fallback_dt or 2 end
local db = bytes - pb
-- A negative delta is a 32-bit wrap or an interface reset. Zero, never
-- the huge positive the wrap arithmetic would imply: one bogus sample
-- sets the shared autoscale and flattens the whole window.
if db < 0 then db = 0 end
return db / dt
end,
}
end
```
- [ ] **Step 4: Run the test to verify it passes**
```bash
lua test/test_data.lua
```
Expected: PASS, exit 0.
- [ ] **Step 5: Commit**
```bash
git add lib/data.lua test/test_data.lua
git commit -m "feat: add a byte-rate counter for interface statistics
Mirrors new_cpu_counter: cumulative counters need two samples to yield a
rate, and each direction carries its own previous reading.
Clamps a negative delta to zero rather than letting a 32-bit wrap or an
interface reset produce a spike. One bogus sample sets the shared autoscale
and flattens the entire window.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
```
---
## Task 4: `data.kv_parse` and the Slackware fixture
**Files:**
- Create: `test/fixtures/slackware_cache`
- Modify: `lib/data.lua`
- Test: `test/test_data.lua`
- [ ] **Step 1: Write the fixture**
Create `test/fixtures/slackware_cache`. Generic values, not this host's:
```
version Slackware 15.0+
packages 1234
changelog 1758000000
birth 1700000000
kernel 6.12.1
```
- [ ] **Step 2: Write the failing test**
Append to `test/test_data.lua`:
```lua
-- === kv_parse =============================================================
-- The slackware sampler writes 'key value' lines. The format is deliberately
-- dumber than the data: epochs stay integers rather than becoming formatted
-- dates, so the widget decides how to render an age and the shell never
-- reconstructs a date string.
local kv = data.kv_parse(read('test/fixtures/slackware_cache'))
assert(kv.version == 'Slackware 15.0+', 'version, got ' .. tostring(kv.version))
-- The value keeps its internal spaces: only the FIRST space is the separator.
assert(kv.packages == '1234', 'packages, got ' .. tostring(kv.packages))
assert(kv.changelog == '1758000000', 'changelog, got ' .. tostring(kv.changelog))
assert(kv.kernel == '6.12.1', 'kernel, got ' .. tostring(kv.kernel))
-- A key the sampler did not write reads nil, which is how the widget tells
-- "the sampler ran but this fact was unavailable" from "no sampler".
assert(kv.nonexistent == nil, 'a missing key is nil')
-- Malformed input must not raise: a Lua error in conky is a blank screen.
assert(type(data.kv_parse('')) == 'table', 'empty input gives a table')
assert(type(data.kv_parse(nil)) == 'table', 'nil gives a table')
assert(data.kv_parse('keyonly\n').keyonly == nil, 'a line with no value is skipped')
```
- [ ] **Step 3: Run the test to verify it fails**
```bash
lua test/test_data.lua
```
Expected: FAIL with `attempt to call a nil value (field 'kv_parse')`.
- [ ] **Step 4: Implement**
Append to `lib/data.lua`:
```lua
-- Parse 'key value' lines into a table of strings.
--
-- Values stay strings and are converted by the caller. The sampler writes
-- epochs as integers and the widget decides whether an age reads as hours or
-- days; a parser that guessed would have to know that.
--
-- Only the first space separates, so a value keeps its own spaces:
-- 'version Slackware 15.0+' yields 'Slackware 15.0+', not 'Slackware'.
function M.kv_parse(text)
local out = {}
if type(text) ~= 'string' then return out end
for line in text:gmatch('[^\n]+') do
local k, v = line:match('^(%S+)%s+(.*)$')
if k and v ~= '' then out[k] = v end
end
return out
end
```
- [ ] **Step 5: Run the test to verify it passes**
```bash
lua test/test_data.lua
```
Expected: PASS, exit 0.
- [ ] **Step 6: Commit**
```bash
git add lib/data.lua test/test_data.lua test/fixtures/slackware_cache
git commit -m "feat: parse the sampler's key/value cache format
Only the first space separates, so a value keeps its own spaces. Values stay
strings: the sampler writes epochs and the widget decides whether an age
reads as hours or days.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
```
---
## Task 5: `data.cpu_model` and `data.board_name`
**Files:**
- Create: `test/fixtures/proc_cpuinfo`, `test/fixtures/proc_cpuinfo_intel`
- Modify: `lib/data.lua`
- Test: `test/test_data.lua`
- [ ] **Step 1: Write the fixtures**
Create `test/fixtures/proc_cpuinfo` (trimmed to the fields the parser reads):
```
processor : 0
vendor_id : AuthenticAMD
cpu family : 26
model : 68
model name : AMD Ryzen 7 9700X 8-Core Processor
stepping : 0
cpu MHz : 4491.436
cache size : 1024 KB
processor : 1
vendor_id : AuthenticAMD
model name : AMD Ryzen 7 9700X 8-Core Processor
```
Create `test/fixtures/proc_cpuinfo_intel`:
```
processor : 0
vendor_id : GenuineIntel
cpu family : 6
model : 158
model name : Intel(R) Core(TM) i7-8700K CPU @ 3.70GHz
stepping : 10
```
- [ ] **Step 2: Write the failing test**
Append to `test/test_data.lua`:
```lua
-- === Hardware identification ==============================================
-- Both strings are constant for the machine's life, so the widget memoizes
-- them. What is tested here is the stripping, which must be a rule rather
-- than a special case for one host: the Intel fixture exists to prove it.
assert(data.cpu_model(read('test/fixtures/proc_cpuinfo')) == 'Ryzen 7 9700X',
'amd cpu, got ' .. tostring(data.cpu_model(read('test/fixtures/proc_cpuinfo'))))
assert(data.cpu_model(read('test/fixtures/proc_cpuinfo_intel')) == 'Core i7-8700K',
'intel cpu, got ' .. tostring(data.cpu_model(read('test/fixtures/proc_cpuinfo_intel'))))
-- Unparseable input yields nil, so the card shows nothing rather than a
-- half-stripped string.
assert(data.cpu_model('') == nil, 'empty cpuinfo gives nil')
assert(data.cpu_model(nil) == nil, 'nil cpuinfo gives nil')
assert(data.cpu_model('processor\t: 0\n') == nil, 'cpuinfo with no model name gives nil')
-- The board is two sysfs files joined, with the vendor's corporate suffix
-- dropped: it is boilerplate on every board and costs a third of the row.
assert(data.board_name('Gigabyte Technology Co., Ltd.\n', 'X870 EAGLE WIFI7\n')
== 'Gigabyte X870 EAGLE WIFI7',
'board, got ' .. tostring(data.board_name('Gigabyte Technology Co., Ltd.\n', 'X870 EAGLE WIFI7\n')))
assert(data.board_name('ASUSTeK COMPUTER INC.\n', 'PRIME B650-PLUS\n')
== 'ASUSTeK PRIME B650-PLUS',
'asus board, got ' .. tostring(data.board_name('ASUSTeK COMPUTER INC.\n', 'PRIME B650-PLUS\n')))
-- A machine that reports one and not the other shows what it has.
assert(data.board_name(nil, 'X870 EAGLE WIFI7\n') == 'X870 EAGLE WIFI7',
'name alone, got ' .. tostring(data.board_name(nil, 'X870 EAGLE WIFI7\n')))
assert(data.board_name('Gigabyte\n', nil) == 'Gigabyte', 'vendor alone')
assert(data.board_name(nil, nil) == nil, 'neither gives nil')
-- A virtual machine reports placeholder DMI strings. Showing 'To be filled by
-- O.E.M.' as the motherboard is worse than showing nothing.
assert(data.board_name('To Be Filled By O.E.M.\n', 'To Be Filled By O.E.M.\n') == nil,
'placeholder DMI gives nil')
```
- [ ] **Step 3: Run the test to verify it fails**
```bash
lua test/test_data.lua
```
Expected: FAIL with `attempt to call a nil value (field 'cpu_model')`.
- [ ] **Step 4: Implement**
Append to `lib/data.lua`:
```lua
-- The CPU's marketing name, stripped to what identifies it.
--
-- '/proc/cpuinfo' repeats the model name once per thread; the first is enough.
-- The stripping is rules, not a table of known CPUs: a leading vendor word,
-- the registered-trademark noise, and a trailing core count or clock, all of
-- which are constant boilerplate that costs a third of the row on a card
-- measured in pixels.
--
-- AMD Ryzen 7 9700X 8-Core Processor -> Ryzen 7 9700X
-- Intel(R) Core(TM) i7-8700K CPU @ 3.7GHz -> Core i7-8700K
function M.cpu_model(cpuinfo)
if type(cpuinfo) ~= 'string' then return nil end
local s = cpuinfo:match('model name%s*:%s*([^\n]+)')
if not s then return nil end
s = s:gsub('%(R%)', ''):gsub('%(TM%)', ''):gsub('%(tm%)', '')
s = s:gsub('^%s*AMD%s+', ''):gsub('^%s*Intel%s+', '')
s = s:gsub('%s+%d+%-Core Processor.*$', '')
s = s:gsub('%s+CPU%s*@.*$', '')
s = s:gsub('%s+Processor%s*$', '')
s = s:gsub('%s+', ' '):gsub('^%s+', ''):gsub('%s+$', '')
if s == '' then return nil end
return s
end
-- The motherboard, from the two world-readable DMI files.
--
-- board_vendor and board_name are readable without privilege, unlike the
-- serial fields in the same directory. The vendor's corporate suffix is
-- dropped because every vendor has one and none of it identifies the board.
--
-- A board reporting the DMI placeholder is treated as no board at all:
-- 'To Be Filled By O.E.M.' on the dashboard is worse than a blank row.
local DMI_PLACEHOLDER = {
['to be filled by o.e.m.'] = true,
['system manufacturer'] = true,
['default string'] = true,
['unknown'] = true,
['n/a'] = true,
}
local function dmi_clean(s)
if type(s) ~= 'string' then return nil end
s = s:gsub('%s+', ' '):gsub('^%s+', ''):gsub('%s+$', '')
if s == '' or DMI_PLACEHOLDER[s:lower()] then return nil end
return s
end
function M.board_name(vendor, name)
vendor = dmi_clean(vendor)
name = dmi_clean(name)
if vendor then
-- Corporate boilerplate, longest first so 'Co., Ltd.' does not leave 'Co.'
vendor = vendor:gsub('%s+Technology Co%.,? Ltd%.?$', '')
vendor = vendor:gsub('%s+COMPUTER INC%.?$', '')
vendor = vendor:gsub('%s+Corporation$', ''):gsub('%s+Corp%.?$', '')
vendor = vendor:gsub('%s+Inc%.?$', ''):gsub('%s+INC%.?$', '')
vendor = vendor:gsub('%s+Co%.,?%s*Ltd%.?$', ''):gsub('%s+CO%.,?%s*LTD%.?$', '')
vendor = vendor:gsub('%s+GmbH$', ''):gsub('%s+LLC$', '')
vendor = vendor:gsub('%s+$', '')
if vendor == '' then vendor = nil end
end
if vendor and name then return vendor .. ' ' .. name end
return name or vendor
end
```
- [ ] **Step 5: Run the test to verify it passes**
```bash
lua test/test_data.lua
```
Expected: PASS, exit 0.
- [ ] **Step 6: Commit**
```bash
git add lib/data.lua test/test_data.lua test/fixtures/proc_cpuinfo test/fixtures/proc_cpuinfo_intel
git commit -m "feat: parse the CPU and motherboard model strings
Stripping by rule rather than by a table of known parts: a leading vendor
word, trademark noise, a trailing core count or clock. The Intel fixture
exists to keep it a rule.
DMI placeholders read as no board at all. 'To Be Filled By O.E.M.' on the
dashboard is worse than a blank row.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
```
---
## Task 6: `avail` in `df_parse`, and the `fs[3]` gap
The disks card needs free space, and `df -P -B1` already prints it. Deriving it
as `size - used` would be wrong by the root-reserved blocks, typically 5%, which
on a 263GB root is about 13GB of space you cannot actually use.
This task also closes a gap recorded in the previous session's review: the
suite never asserts `fs[3]`, the row whose device name ends in a digit
(`/dev/sda1`), which is the row most likely to break a parser counting fields.
**Files:**
- Modify: `lib/data.lua` (`M.df_parse`)
- Test: `test/test_data.lua`
- [ ] **Step 1: Write the failing test**
Insert into `test/test_data.lua`, in the `df_parse` section after the `fs[1]`
assertions (around line 104):
```lua
-- Available is READ, never derived as size - used: the two differ by the
-- root-reserved blocks, about 5%, which on this root is some 13GB that exists
-- but cannot be used. A card claiming that space is free would be lying.
assert(fs[1].avail == 41746907136, 'root available, got ' .. tostring(fs[1].avail))
assert(fs[1].size - fs[1].used ~= fs[1].avail,
'the fixture must exercise the reserved-block gap, or this assertion proves nothing')
-- fs[3] is /dev/sda1: a device name ENDING IN A DIGIT, next to a numeric
-- column. A parser counting fields from the left, or matching digits without
-- anchoring, reads the partition number as a size.
assert(fs[3].mount == '/data', 'sda1 mount, got ' .. tostring(fs[3].mount))
assert(fs[3].dev == '/dev/sda1', 'sda1 device, got ' .. tostring(fs[3].dev))
assert(fs[3].size == 983350091776, 'sda1 size, got ' .. tostring(fs[3].size))
assert(fs[3].used == 547869650944, 'sda1 used, got ' .. tostring(fs[3].used))
assert(fs[3].avail == 385453473792, 'sda1 available, got ' .. tostring(fs[3].avail))
assert(fs[3].pct == 59, 'sda1 percent, got ' .. tostring(fs[3].pct))
assert(fs[3].host == nil, 'a local device has no host')
-- A full filesystem reports zero available, which is a real reading.
assert(fs[6].avail == 0, 'a full mount has 0 available, got ' .. tostring(fs[6].avail))
```
- [ ] **Step 2: Run the test to verify it fails**
```bash
lua test/test_data.lua
```
Expected: FAIL at `root available, got nil`.
- [ ] **Step 3: Implement**
In `lib/data.lua`, change the pattern in `M.df_parse` to capture the available
column, and add it to the returned entry:
```lua
-- A data row ends in 'NN% /some/path'. The header ends in 'Mounted on',
-- which fails the percent match, so it is skipped without a special case.
local size, used, avail, pct, mount =
line:match('(%d+)%s+(%d+)%s+(%d+)%s+(%d+)%%%s+(%S+)%s*$')
```
and in the table it builds:
```lua
out[#out + 1] = {
mount = mount,
dev = dev,
host = dev and dev:match('^([^/:]+):') or nil,
size = tonumber(size),
used = tonumber(used),
-- Read, not derived: size - used overstates free space by the
-- root-reserved blocks, which is tens of gigabytes on a large
-- filesystem and is not available to anyone but root.
avail = tonumber(avail),
pct = tonumber(pct),
}
```
- [ ] **Step 4: Run the test to verify it passes**
```bash
lua test/test_data.lua
```
Expected: PASS, exit 0.
- [ ] **Step 5: Commit**
```bash
git add lib/data.lua test/test_data.lua
git commit -m "feat: read the available column from df
Free space is read rather than derived: size - used overstates it by the
root-reserved blocks, tens of gigabytes on a large filesystem, none of it
available to anyone but root.
Also asserts fs[3], the row whose device name ends in a digit. It sits next
to a numeric column and is the row most likely to break a parser counting
fields, and nothing covered it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
```
---
## Task 7: The public IP sampler
**Files:**
- Create: `bin/pubip-sample.sh`
- [ ] **Step 1: Write the sampler**
Create `bin/pubip-sample.sh`:
```bash
#!/bin/bash
# Fetch the public IP address into a cache file, for widgets/network.lua.
#
# Run from conky's ${execi} every 30 minutes. The draw hook must never make a
# network call: a hung request would block the Cairo draw and freeze the whole
# dashboard, and a residential address is stable for days anyway.
#
# Exits non-zero with a message on stderr when it cannot fetch, leaving any
# existing cache untouched rather than replacing a good address with an error
# page.
set -u
CACHE_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/udt"
CACHE="$CACHE_DIR/pubip.txt"
umask 077
mkdir -p "$CACHE_DIR"
# The temp file sits in the same directory as the target: rename is only
# atomic within a filesystem, and the widget reads this on its own 2s cadence.
TMP="$CACHE.tmp.$$"
trap 'rm -f "$TMP"' EXIT
# --max-time, not just --connect-timeout: a server that accepts the connection
# and then stalls would otherwise hold a curl process for as long as it likes.
if ! IP=$(curl -fsS --max-time 10 https://ipinfo.io/ip 2>/dev/null); then
echo "pubip-sample: fetch failed" >&2
exit 1
fi
# Validate before writing. A captive portal, a rate-limit message and an error
# page all arrive with a 200 and would otherwise be written to the cache and
# drawn on the dashboard as though they were an address.
IP=$(echo "$IP" | tr -d '[:space:]')
if ! echo "$IP" | grep -qE '^([0-9]{1,3}\.){3}[0-9]{1,3}$'; then
echo "pubip-sample: response is not an IPv4 address" >&2
exit 1
fi
# The epoch travels with the address so the widget can refuse to show a stale
# one. Same 'key value' format the slackware sampler uses.
{
echo "ip $IP"
echo "fetched $(date +%s)"
} > "$TMP"
mv -f "$TMP" "$CACHE"
```
- [ ] **Step 2: Make it executable and run it**
```bash
chmod +x bin/pubip-sample.sh
./bin/pubip-sample.sh && cat ~/.cache/udt/pubip.txt
```
Expected: two lines, `ip <your address>` and `fetched <epoch>`. **Do not paste
the address into a commit message, a comment or a fixture.**
- [ ] **Step 3: Verify the cache file's permissions**
```bash
stat -c '%a %n' ~/.cache/udt/pubip.txt
```
Expected: `600`.
- [ ] **Step 4: Verify it rejects a bad response**
```bash
bash -c 'curl() { echo "<html>captive portal</html>"; }; export -f curl; ./bin/pubip-sample.sh'; echo "exit=$?"
```
Expected: `pubip-sample: response is not an IPv4 address` on stderr and a
non-zero exit. (If the function export does not take effect in your shell, skip
this step: the validation is exercised by inspection.)
- [ ] **Step 5: Commit**
```bash
git add bin/pubip-sample.sh
git commit -m "feat: sample the public IP into a cache file
The draw hook must never make a network call: a hung request blocks the
Cairo draw and freezes the dashboard. Thirty minutes, not the old config's
five, because a residential address is stable for days.
Validates the response before writing. A captive portal and a rate-limit
message both arrive with a 200 and would otherwise be drawn as an address.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
```
---
## Task 8: The network card
**Files:**
- Create: `widgets/network.lua`
- Modify: `lib/data.lua` (add `iface_addr`)
- Create: `test/fixtures/ip_addr`
- Test: `test/test_data.lua`
- [ ] **Step 1: Write the fixture**
Create `test/fixtures/ip_addr`. **TEST-NET-1 (`192.0.2.0/24`), never a real
address:**
```
4: br0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default qlen 1000
inet 192.0.2.15/24 brd 192.0.2.255 scope global br0
valid_lft forever preferred_lft forever
```
- [ ] **Step 2: Write the failing test**
Append to `test/test_data.lua`:
```lua
-- === Interface address ====================================================
-- The fixture uses TEST-NET-1 (192.0.2.0/24, RFC 5737). This repository is
-- public: no real LAN address belongs in it, and no test may assert one.
local addr = data.iface_addr_parse(read('test/fixtures/ip_addr'))
assert(addr == '192.0.2.15', 'lan address, got ' .. tostring(addr))
-- A down interface has no inet line. nil, so the card shows '--' rather than
-- a stale or invented address.
assert(data.iface_addr_parse('5: br0: <BROADCAST,MULTICAST> mtu 1500 state DOWN\n') == nil,
'a down interface gives nil')
assert(data.iface_addr_parse('') == nil, 'empty gives nil')
assert(data.iface_addr_parse(nil) == nil, 'nil gives nil')
```
- [ ] **Step 3: Run the test to verify it fails**
```bash
lua test/test_data.lua
```
Expected: FAIL with `attempt to call a nil value (field 'iface_addr_parse')`.
- [ ] **Step 4: Implement the parser**
Append to `lib/data.lua`:
```lua
-- The IPv4 address from `ip -4 addr show <iface>` output.
--
-- Split from the command that produces it so it can be tested against a
-- fixture: every other parser in this file follows the same split, and an
-- address is exactly the kind of value that must not be hardcoded in a test.
function M.iface_addr_parse(text)
if type(text) ~= 'string' then return nil end
return text:match('inet%s+(%d+%.%d+%.%d+%.%d+)')
end
-- The interface's IPv4 address, or nil when it has none.
--
-- This shells out, which the draw hook otherwise never does, so the caller
-- memoizes it: an address does not change without an event this dashboard
-- does not watch. Retried while nil, because a bridge may not be up when
-- conky starts.
function M.iface_addr(iface)
if type(iface) ~= 'string' then return nil end
local p = io.popen('ip -4 addr show ' .. ("%q"):format(iface) .. ' 2>/dev/null')
if not p then return nil end
local out = p:read('*a')
p:close()
return M.iface_addr_parse(out)
end
```
- [ ] **Step 5: Run the test to verify it passes**
```bash
lua test/test_data.lua
```
Expected: PASS, exit 0.
- [ ] **Step 6: Write the widget**
Create `widgets/network.lua`:
```lua
-- Network: throughput as two lines on one chart, with the LAN and public
-- addresses.
--
-- The rates come from the interface's own byte counters, read every draw: two
-- file reads, no subprocess. The public address comes from a cache file,
-- because a network call in the draw hook would freeze the dashboard when it
-- hung.
local card = require 'lib.card'
local data = require 'lib.data'
local M = {}
-- The interface to graph. br0 is this host's bridge, matching the old text
-- config.
--
-- Note what this means: a bridge carries VM-to-host traffic that never reaches
-- the router, so a local copy spikes the graph. That was true of the old
-- config too and is accepted; this constant is the one edit that changes it.
local IFACE = 'br0'
local CACHE = (os.getenv('XDG_CACHE_HOME') or (os.getenv('HOME') .. '/.cache'))
.. '/udt/pubip.txt'
-- A public address older than this is not shown. Eight sampling intervals: by
-- then the sampler has failed repeatedly, and an address that may no longer be
-- yours displayed with confidence is worse than '--'.
local STALE_AFTER = 4 * 3600
local STAT = '/sys/class/net/' .. IFACE .. '/statistics/'
-- Module state: the history outlives the frame, which is the whole point.
-- It does not outlive a conky restart, so the chart starts empty and fills
-- left to right.
local rx_counter, tx_counter
local rx_hist, tx_hist = {}, {}
local hist_len = 0
local lan_addr = nil
-- Append to a fixed-length history, dropping the oldest. A plain array with
-- table.remove(1) rather than a circular buffer with an index: the lengths
-- here are a few hundred at most and the draw is every two seconds, so the
-- O(n) shift is free and the array is already in draw order.
local function push(hist, v, len)
hist[#hist + 1] = v
while #hist > len do table.remove(hist, 1) end
end
local function read_counter(file)
local s = data.slurp(STAT .. file)
if not s then return nil end
return tonumber(s:match('^%s*(%d+)'))
end
-- Bytes per second to a short string. Deliberately not card.human: a rate
-- wants a '/s' and one decimal at most, and reusing card.human would put a
-- suffix meant for capacity onto a speed.
local function rate_str(bps)
if not bps then return '--' end
local units = { 'B', 'K', 'M', 'G' }
local n, i = bps, 1
while n >= 1024 and i < #units do n = n / 1024; i = i + 1 end
if i == 1 then return string.format('%d%s/s', math.floor(n), units[i]) end
if n >= 100 then return string.format('%.0f%s/s', n, units[i]) end
return string.format('%.1f%s/s', n, units[i])
end
function M.draw(cr, rect, colors)
local inner = card.card(cr, rect, colors)
local function clamp(v, lo, hi) return math.max(lo, math.min(hi, v)) end
local interval = (conky_info and conky_info.update_interval) or 2
rx_counter = rx_counter or data.new_rate_counter(interval)
tx_counter = tx_counter or data.new_rate_counter(interval)
local down = rx_counter:sample(read_counter('rx_bytes'))
local up = tx_counter:sample(read_counter('tx_bytes'))
-- Fluid type, as every other card does: one base size S drives everything,
-- taken as the smaller of a height budget and a measured width fit.
local LABEL_F, BIG_F, ROW_F = 0.30, 0.62, 0.30
local groups = {
{ { 'NET', card.FONT_MONO, LABEL_F }, { '888.8M/s', card.FONT_HEAVY, BIG_F } },
{ { '\u{F0318} 192.000.000.000', card.FONT_MONO, ROW_F } },
{ { '\u{F0319} 888.8M/s', card.FONT_MONO, ROW_F },
{ '\u{F01DA} 888.8M/s', card.FONT_MONO, ROW_F } },
}
local fixed_units = 1.0 + ROW_F * 2.2 * 3
local S = clamp(math.min(inner.h * 0.96 / (fixed_units + 1.2),
card.fit_unit(cr, inner.w * 0.96, groups, 100)), 10, 72)
local label_size = S * LABEL_F
local row_size = S * ROW_F
-- Header: the label left, the download rate as the big value at the right.
-- Download is the headline because it is the number that moves.
card.font(cr, card.FONT_HEAVY, S * BIG_F, false)
card.rgba(cr, colors.body)
card.text_right(cr, inner.x + inner.w, inner.y + S * BIG_F, rate_str(down))
card.font(cr, card.FONT_MONO, label_size, false)
card.rgba(cr, colors.label)
card.text(cr, inner.x, inner.y + label_size, 'NET')
local ey = inner.y + S * BIG_F + row_size * 1.4
local step = row_size * 2.2
-- The addresses. LAN is memoized on first success and retried while nil: a
-- bridge may not be up when conky starts.
lan_addr = lan_addr or data.iface_addr(IFACE)
card.font(cr, card.FONT_MONO, row_size, false)
card.rgba(cr, colors.label)
card.text(cr, inner.x, ey, '\u{F0318}') -- LAN
card.rgba(cr, colors.value)
card.text_right(cr, inner.x + inner.w, ey, lan_addr or '--')
ey = ey + step
local pub = data.kv_parse(data.slurp(CACHE) or '')
local fetched = tonumber(pub.fetched)
local pub_txt = '--'
if pub.ip and fetched and (os.time() - fetched) < STALE_AFTER then
pub_txt = pub.ip
end
card.rgba(cr, colors.label)
card.text(cr, inner.x, ey, '\u{F059F}') -- globe
card.rgba(cr, pub_txt == '--' and colors.label or colors.value)
card.text_right(cr, inner.x + inner.w, ey, pub_txt)
ey = ey + step * 1.1
-- The chart takes the room left between the addresses and the rate row.
local rate_row_h = row_size * 2.4
local chart_top = ey
local chart_h = (inner.y + inner.h) - chart_top - rate_row_h
if chart_h > 12 then
-- One sample per pixel column, so the chart never interpolates. The
-- buffer is resized when the card is, keeping what it can: a moved card
-- should not clear the history.
local want = math.max(8, math.floor(inner.w))
if want ~= hist_len then
hist_len = want
while #rx_hist > hist_len do table.remove(rx_hist, 1) end
while #tx_hist > hist_len do table.remove(tx_hist, 1) end
end
if down then push(rx_hist, down, hist_len) end
if up then push(tx_hist, up, hist_len) end
-- One ceiling for both series. Scaled independently they would lie about
-- their relative size, which is the entire reason to draw them together.
local peak = 0
for _, v in ipairs(rx_hist) do if v > peak then peak = v end end
for _, v in ipairs(tx_hist) do if v > peak then peak = v end end
card.plot(cr, inner.x, chart_top, inner.w, chart_h, {
{ values = rx_hist, colour = colors.ok },
{ values = tx_hist, colour = colors.highlight },
}, peak, colors)
-- The peak, so a full-height line means something in absolute terms.
card.font(cr, card.FONT_MONO, row_size * 0.85, false)
card.rgba(cr, colors.label)
card.text(cr, inner.x, chart_top + row_size * 0.85, rate_str(peak))
end
-- The live rates, each in its series colour so the line and the number are
-- unmistakably the same thing.
local ry = inner.y + inner.h - row_size * 0.4
card.font(cr, card.FONT_MONO, row_size, false)
card.rgba(cr, colors.ok)
card.text(cr, inner.x, ry, '\u{F0319} ' .. rate_str(down))
card.rgba(cr, colors.highlight)
card.text_right(cr, inner.x + inner.w, ry, '\u{F01DA} ' .. rate_str(up))
end
return M
```
- [ ] **Step 7: Render it and look at it**
```bash
lua test/render.lua network 16 12 4 4 /tmp/net.png
```
Expected: a PNG at `/tmp/net.png`. **Open it and check every glyph.** A
wrong-but-present codepoint draws a plausible neighbour rather than failing, so
confirm: `\u{F0318}` is a LAN/ethernet mark, `\u{F059F}` a globe, `\u{F0319}` a
download arrow, `\u{F01DA}` an upload arrow. If any is wrong, find the right
codepoint and re-render before continuing.
The chart will be empty on a single render: the history needs two samples and
this harness draws one frame. That is expected here and is what the live board
is for.
- [ ] **Step 8: Run the suite**
```bash
lua test/test_data.lua && lua test/test_layout.lua && lua test/test_weather.lua
```
Expected: exit 0.
- [ ] **Step 9: Commit**
```bash
git add widgets/network.lua lib/data.lua test/test_data.lua test/fixtures/ip_addr
git commit -m "feat: add the network card
Two lines on one chart against a shared ceiling, one sample per pixel column
so nothing is interpolated. The history is module state sized from the card's
width, so moving the card reframes the window rather than clearing it.
The public address is refused once stale: an address that may no longer be
yours, displayed with confidence, is worse than a dash.
The fixture uses TEST-NET-1. This repository is public.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
```
---
## Task 9: The Slackware sampler
**Files:**
- Create: `bin/slackware-sample.sh`
- [ ] **Step 1: Write the sampler**
Create `bin/slackware-sample.sh`:
```bash
#!/bin/bash
# Sample Slackware facts into a cache file, for widgets/slackware.lua.
#
# Conky's Lua has no lfs, so it cannot stat a file for an mtime, and counting
# 2800 package files every two seconds would be a directory walk per draw.
# Both belong here.
set -u
CACHE_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/udt"
CACHE="$CACHE_DIR/slackware.txt"
umask 077
mkdir -p "$CACHE_DIR"
TMP="$CACHE.tmp.$$"
trap 'rm -f "$TMP"' EXIT
VERSION=$(cat /etc/slackware-version 2>/dev/null || echo unknown)
# The TRAILING SLASH is load-bearing. /var/log/packages is a symlink to
# /var/lib/pkgtools/packages, and `ls -1 /var/log/packages` lists the link
# itself: one entry. With the slash it lists the target's contents, which on
# this machine is some 2800 packages. Do not remove it as cosmetic.
PACKAGES=$(ls -1 /var/log/packages/ 2>/dev/null | wc -l)
# Epochs, never formatted dates. The shell function this replaces rebuilds a
# date string from `ls -l` output with the year hardcoded, which breaks every
# January and on any file older than six months, when ls prints a year instead
# of a time and the field offsets shift.
CHANGELOG=$(stat -c %Y /var/lib/slackpkg/ChangeLog.txt 2>/dev/null || echo 0)
# Filesystem birth time: the install date. Not every filesystem records it,
# and those that do not report 0, which the widget shows as '--'.
BIRTH=$(stat -c %W / 2>/dev/null || echo 0)
KERNEL=$(uname -r)
{
echo "version $VERSION"
echo "packages $PACKAGES"
echo "changelog $CHANGELOG"
echo "birth $BIRTH"
echo "kernel $KERNEL"
} > "$TMP"
mv -f "$TMP" "$CACHE"
```
- [ ] **Step 2: Make it executable and run it**
```bash
chmod +x bin/slackware-sample.sh
./bin/slackware-sample.sh && cat ~/.cache/udt/slackware.txt
```
Expected: five lines.
- [ ] **Step 3: Verify the package count against a known-good value**
```bash
ls -1 /var/log/packages/ | wc -l
grep '^packages' ~/.cache/udt/slackware.txt
```
Expected: the same number, in the thousands. **If it reads 1, the trailing
slash was dropped.** That is the specific failure this step exists to catch.
- [ ] **Step 4: Commit**
```bash
git add bin/slackware-sample.sh
git commit -m "feat: sample the Slackware facts into a cache file
Conky's Lua has no lfs, so it cannot stat a file, and counting 2800 package
files per draw would be a directory walk every two seconds.
Writes epochs rather than formatted dates. The shell function this replaces
rebuilds a date from ls -l output with the year hardcoded, which breaks every
January and on any file older than six months.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
```
---
## Task 10: The Slackware card
**Files:**
- Create: `widgets/slackware.lua`
- [ ] **Step 1: Write the widget**
Create `widgets/slackware.lua`:
```lua
-- Slackware: the distribution version, package count, kernel, install age, and
-- how long since slackpkg's ChangeLog last changed.
--
-- Everything comes from a cache file written by bin/slackware-sample.sh:
-- conky's Lua cannot stat a file for an mtime, and counting the package
-- directory every two seconds would be a walk per draw.
local card = require 'lib.card'
local data = require 'lib.data'
local CACHE = (os.getenv('XDG_CACHE_HOME') or (os.getenv('HOME') .. '/.cache'))
.. '/udt/slackware.txt'
-- Hours. A ChangeLog under a day old is current, under a week is ordinary, and
-- past that is worth noticing. This extends the shell prompt's binary green/red
-- into the board's three-state language rather than adding a fourth convention.
local WARN_H, CRIT_H = 24, 168
local M = {}
-- An age in seconds as a short string: hours below two days, then days. Short
-- because it is the card's big value and shares its line with the label.
local function age_str(seconds)
if not seconds then return '--' end
-- A future timestamp yields a negative age. Clock skew and a mirror's dated
-- file both produce it, and '-8000h' on the dashboard reads as a bug.
if seconds < 0 then seconds = 0 end
local hours = seconds / 3600
if hours < 48 then return string.format('%dh', math.floor(hours)) end
return string.format('%dd', math.floor(hours / 24))
end
function M.draw(cr, rect, colors)
local inner = card.card(cr, rect, colors)
local function clamp(v, lo, hi) return math.max(lo, math.min(hi, v)) end
local LABEL_F, BIG_F, ROW_F = 0.30, 0.85, 0.32
local kv = data.kv_parse(data.slurp(CACHE) or '')
-- No cache at all: the sampler has not run. Name it, as the cache card does,
-- rather than leaving a blank cell that is indistinguishable from a crash.
if not kv.version then
local S = clamp(inner.h * 0.94 / (1.78 + 2 * 0.72), 10, 72)
S = math.min(S, card.fit_unit(cr, inner.w * 0.96, {
{ { 'SLACKWARE', card.FONT_MONO, LABEL_F } },
{ { 'no slackware data', card.FONT_UI, 0.36 } },
{ { 'bin/slackware-sample.sh', card.FONT_MONO, ROW_F } },
}, 100))
card.font(cr, card.FONT_MONO, S * LABEL_F, false)
card.rgba(cr, colors.label)
card.text(cr, inner.x, inner.y + S * LABEL_F, 'SLACKWARE')
card.font(cr, card.FONT_UI, S * 0.36, true)
card.text(cr, inner.x, inner.y + S + S * LABEL_F * 2.6, 'no slackware data')
card.font(cr, card.FONT_MONO, S * ROW_F, false)
card.text(cr, inner.x, inner.y + S + S * LABEL_F * 2.6 + S * 0.72,
'bin/slackware-sample.sh')
return
end
-- The ChangeLog age, the card's headline. A cache that exists without this
-- key is a different failure from no cache: the sampler ran and the
-- ChangeLog is what was missing, so the rows still draw and only this reads
-- '--'.
local changelog = tonumber(kv.changelog)
local age_s = (changelog and changelog > 0) and (os.time() - changelog) or nil
local age_txt = age_str(age_s)
local age_colour = colors.label
if age_s then
age_colour = card.threshold(math.max(age_s, 0) / 3600, WARN_H, CRIT_H, colors)
end
-- 'Slackware 15.0+' -> '15.0+': the header already says which distribution.
local version = (kv.version or ''):gsub('^Slackware%s+', '')
local birth = tonumber(kv.birth)
local install_age = (birth and birth > 0)
and string.format('%dd', math.floor((os.time() - birth) / 86400)) or '--'
local rows = {
{ 'VERSION', version ~= '' and version or '--' },
{ 'PACKAGES', kv.packages or '--' },
{ 'KERNEL', kv.kernel or '--' },
{ 'AGE', install_age },
}
-- Fluid type: the height budget grows the content to fill the cell, the
-- measured width fit pulls it back where a row would overrun.
local groups = {
{ { 'SLACKWARE', card.FONT_MONO, LABEL_F }, { age_txt, card.FONT_HEAVY, BIG_F } },
}
for _, r in ipairs(rows) do
groups[#groups + 1] = { { r[1], card.FONT_MONO, ROW_F },
{ tostring(r[2]), card.FONT_MONO, ROW_F } }
end
local S = clamp(math.min(inner.h * 0.94 / (1.5 + #rows * 0.66),
card.fit_unit(cr, inner.w * 0.96, groups, 100)), 10, 72)
local label_size = S * LABEL_F
local row_size = S * ROW_F
card.font(cr, card.FONT_HEAVY, S * BIG_F, false)
card.rgba(cr, age_colour)
card.text_right(cr, inner.x + inner.w, inner.y + S * BIG_F, age_txt)
card.font(cr, card.FONT_MONO, label_size, false)
card.rgba(cr, colors.label)
card.text(cr, inner.x, inner.y + label_size, 'SLACKWARE')
local ey = inner.y + S * BIG_F + row_size * 1.6
local step = row_size * 2.0
card.font(cr, card.FONT_MONO, row_size, false)
for _, r in ipairs(rows) do
if ey + step > inner.y + inner.h then break end
card.rgba(cr, colors.label)
card.text(cr, inner.x, ey, r[1])
card.rgba(cr, colors.value)
card.text_right(cr, inner.x + inner.w, ey, tostring(r[2]))
ey = ey + step
end
end
return M
```
- [ ] **Step 2: Render it and look at it**
```bash
lua test/render.lua slackware 16 12 3 4 /tmp/slack.png
```
Expected: a PNG showing the age top-right in a threshold colour, `SLACKWARE`
top-left, and four rows.
- [ ] **Step 3: Verify the no-cache state draws a notice**
```bash
mv ~/.cache/udt/slackware.txt /tmp/slackware.bak
lua test/render.lua slackware 16 12 3 4 /tmp/slack_empty.png
mv /tmp/slackware.bak ~/.cache/udt/slackware.txt
```
Expected: `/tmp/slack_empty.png` shows `no slackware data` and the sampler's
name, not a blank card.
- [ ] **Step 4: Run the suite**
```bash
lua test/test_data.lua && lua test/test_layout.lua && lua test/test_weather.lua
```
Expected: exit 0.
- [ ] **Step 5: Commit**
```bash
git add widgets/slackware.lua
git commit -m "feat: add the slackware card
The big value is the time since slackpkg's ChangeLog changed, coloured green
under a day, amber to a week, red past it.
A cache with no changelog key is a different failure from no cache at all:
the sampler ran and the ChangeLog is what was missing, so the rows still draw
and only the age reads '--'. Collapsing the two would send the reader to the
wrong problem.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
```
---
## Task 11: CPU and board rows on the system card
**Files:**
- Modify: `widgets/system.lua`
- [ ] **Step 1: Add the memoized lookups**
In `widgets/system.lua`, after the `counters` declaration near the top:
```lua
-- Read once, not per frame. Neither can change without a reboot, and
-- re-reading /proc/cpuinfo every two seconds for a constant is waste: the file
-- repeats its model-name line once per thread.
--
-- `false` is the cached miss, distinct from nil meaning "not looked up yet",
-- so an absent DMI table is not re-read on every draw.
local cpu_name, board = nil, nil
local function hardware()
if cpu_name == nil then
cpu_name = data.cpu_model(data.slurp('/proc/cpuinfo') or '') or false
end
if board == nil then
local dmi = '/sys/devices/virtual/dmi/id/'
board = data.board_name(data.slurp(dmi .. 'board_vendor'),
data.slurp(dmi .. 'board_name')) or false
end
return cpu_name or nil, board or nil
end
```
- [ ] **Step 2: Add the rows to the fluid-type group list**
In `M.draw`, after `local cores = ...` and before the `groups` table, add:
```lua
local cpu_txt, board_txt = hardware()
```
Then in the `groups` table, insert two rows after the header row so the width
fit accounts for them:
```lua
local groups = {
{ { 'CPU', card.FONT_MONO, LABEL_F }, { load_txt, card.FONT_HEAVY, BIG_F } },
{ { cpu_txt or '', card.FONT_MONO, ROW_F } },
{ { board_txt or '', card.FONT_MONO, ROW_F } },
{ { 'RAM', card.FONT_MONO, ROW_F }, { '999.9G / 999.9G', card.FONT_MONO, ROW_F } },
}
```
- [ ] **Step 3: Give the equaliser's budget the two rows**
Change the `fixed_units` line to account for them. It currently reads:
```lua
local fixed_units = 1.00 + 1.13 + temp_n * ROW_F * 1.8 + 0.10
```
Replace with:
```lua
-- The two identification rows come out of the equaliser's budget: it is the
-- card's designated slack absorber, so it is what gives up the height.
local id_rows = (cpu_txt and 1 or 0) + (board_txt and 1 or 0)
local fixed_units = 1.00 + 1.13 + temp_n * ROW_F * 1.8 + 0.10
+ id_rows * ROW_F * 1.5
```
- [ ] **Step 4: Draw the rows**
Immediately after the `card.text(cr, inner.x, y + label_size, 'CPU')` call that
draws the header label, insert:
```lua
-- The hardware this card is about, under the header. Truncated by character,
-- never by byte: a model string can carry a multi-byte character and half of
-- one draws as a replacement box.
local id_y = y + big_size + row_size * 0.2
if cpu_txt or board_txt then
card.font(cr, card.FONT_MONO, row_size, false)
card.rgba(cr, colors.label)
local id_limit = math.max(8, math.floor(inner.w / (row_size * 0.55)))
if cpu_txt then
card.text(cr, inner.x, id_y, card.truncate(cpu_txt, id_limit))
id_y = id_y + row_size * 1.5
end
if board_txt then
card.text(cr, inner.x, id_y, card.truncate(board_txt, id_limit))
id_y = id_y + row_size * 1.5
end
end
```
Then change the equaliser's top to start below them. The line currently reads:
```lua
local eq_top = y + big_size + S * 0.30
```
Replace with:
```lua
-- Below the identification rows when there are any, otherwise where it was.
local eq_top = (cpu_txt or board_txt) and (id_y + S * 0.10) or (y + big_size + S * 0.30)
```
And change the equaliser height calculation to subtract the same rows. It reads:
```lua
local eh = clamp((inner.y + inner.h) - eq_top - (S * 1.13 + temp_n * step),
18, math.huge)
```
This already measures from `eq_top`, which now sits lower, so the subtraction
is correct. But the `18` floor means that on a short card the equaliser keeps
18px it no longer has, and the temperature rows draw over it. Change the floor
to a proportion of what is actually left:
```lua
-- The floor was a flat 18px. With the identification rows above it the
-- equaliser can genuinely run out of room, and a fixed floor means it keeps
-- height it does not have and the temperature rows draw over it. Zero is a
-- legitimate outcome: the loop below skips the band when it has no height.
local eh = clamp((inner.y + inner.h) - eq_top - (S * 1.13 + temp_n * step),
0, math.huge)
```
and guard the equaliser block so it does not draw a zero-height band. The line
that reads `if n > 0 then` becomes:
```lua
if n > 0 and eh >= 8 then
```
- [ ] **Step 5: Render and check it fits**
```bash
lua test/render.lua system 16 12 3 5 /tmp/sys.png
```
Expected: header with the load percentage top-right, two identification rows,
the equaliser, RAM, and five temperature rows, nothing overlapping.
**This is the crowding risk the spec names.** If the rows collide or the
equaliser is squeezed to nothing, stop and report it rather than tuning
constants indefinitely: the fallback is one row (CPU only) or moving the pair
to their own card, and that is the user's call.
- [ ] **Step 6: Check it on the live board**
```bash
./restart.sh
```
The offscreen renderer crops to one card and says nothing about how the card
looks beside its neighbours, which is a real failure mode here.
- [ ] **Step 7: Commit**
```bash
git add widgets/system.lua
git commit -m "feat: name the CPU and motherboard on the system card
Both read once: neither changes without a reboot, and /proc/cpuinfo repeats
its model-name line once per thread.
The two rows come out of the equaliser's height budget, since it is the
card's designated slack absorber.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
```
---
## Task 12: Free and total under each disk ring
**Files:**
- Modify: `widgets/disks.lua`
- Modify: `widgets/cache.lua` (adopt `card.truncate`)
- Modify: `TODO.md`
- [ ] **Step 1: Replace the percentage with two rows**
In `widgets/disks.lua`, find the block that draws the percentage under the
ring:
```lua
-- 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)
```
Replace it with:
```lua
-- Free on top, total beneath, both centred under the ring.
--
-- The percentage that used to sit here is gone: the ring already draws
-- it as an angle, and a number repeating it costs a row in a column
-- about 75px wide. Free carries the threshold colour because it is the
-- measured quantity; the total is fixed, so it stays in `value`. One
-- coloured number per column, as the rest of the board reads.
local free_size = label_size
local total_size = label_size * 0.88
card.font(cr, card.FONT_MONO, free_size, false)
card.rgba(cr, colour)
local free_txt = card.human(e.avail) .. ' free'
local fw = card.measure(cr, free_txt)
card.text(cr, cx - fw / 2, cy + r + free_size * 1.7, free_txt)
card.font(cr, card.FONT_MONO, total_size, false)
card.rgba(cr, colors.value)
local total_txt = card.human(e.size)
local tw = card.measure(cr, total_txt)
card.text(cr, cx - tw / 2, cy + r + free_size * 1.7 + total_size * 1.4, total_txt)
```
- [ ] **Step 2: Give the second row vertical room**
The ring radius is derived from the available height and must now leave room
for two rows rather than one. Find:
```lua
local r = math.min(cell_w * 0.34, avail_h * 0.30)
```
Replace with:
```lua
-- 0.27 rather than 0.30: two rows sit under each ring now, not one, and the
-- radius is what gives up the height.
local r = math.min(cell_w * 0.34, avail_h * 0.27)
```
- [ ] **Step 3: Adopt `card.truncate` in the cache card**
In `widgets/cache.lua`, find:
```lua
local name = e.name
if #name > 18 then name = name:sub(1, 17) .. '\u{2026}' end
names[i] = name
```
Replace with:
```lua
-- By character, not byte: a cache directory can carry an accented name and
-- a byte slice cuts one in half, which Cairo draws as a replacement box.
names[i] = card.truncate(e.name, 18)
```
- [ ] **Step 4: Render and check**
```bash
lua test/render.lua disks 16 12 5 3 /tmp/disks.png
lua test/render.lua cache 16 12 2 3 /tmp/cache.png
```
Expected: each ring carries `NNG free` above `NNNG`, with no overlap between
neighbouring columns and no text running outside the card.
- [ ] **Step 5: Run the suite**
```bash
lua test/test_data.lua && lua test/test_layout.lua && lua test/test_weather.lua
```
Expected: exit 0.
- [ ] **Step 6: Clear the completed TODO item**
Edit `TODO.md` and remove the line:
```
- [ ] Add a free space lable under every disk indicator in the disks widget
```
**`TODO.md` is untracked.** Do not `git add` it. Leave it in the working tree.
- [ ] **Step 7: Commit**
```bash
git add widgets/disks.lua widgets/cache.lua
git commit -m "feat: show free and total under each disk ring
Replaces the percentage, which the ring already draws as an angle. A number
repeating it costs a row in a column about 75px wide.
Free carries the threshold colour because it is the measured quantity; the
total is fixed and stays in the plain value colour, so each column has one
coloured number.
The cache card adopts card.truncate in the same pass: it was slicing names by
byte, which halves a multi-byte character.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
```
---
## Task 13: Wire both cards into the dashboard
**Files:**
- Modify: `conky.conf.in`
- Modify: `dashboard.lua`
- [ ] **Step 1: Add the samplers to the conky config**
In `conky.conf.in`, the `conky.text` line currently reads:
```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}]]
```
Replace with:
```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}${execi 1800 ~/.config/conky/bin/pubip-sample.sh}${execi 900 ~/.config/conky/bin/slackware-sample.sh}]]
```
- [ ] **Step 2: Add the layout entries**
In `dashboard.lua`, add two entries to the `layout` table. Network under
system, Slackware beside it:
```lua
local layout = {
{ widget = 'clock', col = 1, row = 1, w = 1.5, h = 5 },
-- Under the clock, same column.
{ widget = 'weather', col = 1, row = 6, w = 3, h = 5 },
{ widget = 'system', col = 14, row = 4, w = 3, h = 5 },
{ widget = 'gpu', col = 12, row = 4, w = 2, h = 3 },
{ widget = 'disks', col = 12, row = 1, w = 5, h = 3 },
{ widget = 'cache', col = 12, row = 7, w = 2, h = 3 },
-- Under system, with slackware beside it. Provisional: there is no settled
-- arrangement for the board yet.
{ widget = 'network', col = 14, row = 9, w = 3, h = 3 },
{ widget = 'slackware', col = 12, row = 10, w = 2, h = 3 },
}
```
- [ ] **Step 3: Re-render the conky config**
`conky.conf` is generated from `conky.conf.in` by the other repository's
installer, which substitutes the palette:
```bash
../unified-desktop-theme/install.sh
```
Expected: `conky.conf` regenerated. Confirm the two new `execi` entries landed:
```bash
grep -c 'sample.sh' conky.conf
```
Expected: `1` (they are all on one line) and the line contains `pubip-sample`
and `slackware-sample`:
```bash
grep -o 'pubip-sample.sh\|slackware-sample.sh' conky.conf
```
Expected: both names printed.
- [ ] **Step 4: Restart and look at the board**
```bash
./restart.sh
```
Toggle the dashboard workspace:
```bash
hyprctl dispatch 'hl.dsp.workspace.toggle_special("dash")'
```
Expected: eight cards, no blank cells, no error overlay. Let it run two minutes
so the network chart has history, then look again: two lines should be moving.
- [ ] **Step 5: Run the suite**
```bash
lua test/test_data.lua && lua test/test_layout.lua && lua test/test_weather.lua
```
Expected: exit 0. `test_layout.lua` walks the layout table, so a malformed
entry fails here.
- [ ] **Step 6: Commit**
```bash
git add conky.conf.in dashboard.lua conky.conf
git commit -m "feat: put the network and slackware cards on the board
Placement is provisional: there is no settled arrangement yet.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
```
---
## Task 14: Documentation
**Files:**
- Modify: `README.md`
- [ ] **Step 1: Document both cards**
In `README.md`, find the section listing the widgets and add entries matching
the existing format for `network` and `slackware`. Each entry states what the
card shows, where the data comes from, and which sampler feeds it.
Include, because they are the things a reader will otherwise get wrong:
- `widgets/network.lua` graphs `br0`, **a bridge**, so VM-to-host traffic
appears in the chart without crossing the router. The `IFACE` constant at the
top of the file changes it.
- The public address is refused once it is more than four hours old.
- `bin/slackware-sample.sh` counts `/var/log/packages/` **with the trailing
slash**, because the path is a symlink and counting it without the slash
reports one package.
- [ ] **Step 2: Document both samplers**
In the section listing `bin/` scripts, add `pubip-sample.sh` (30 minutes) and
`slackware-sample.sh` (15 minutes), in the format the existing three use.
- [ ] **Step 3: Verify no personal data reached the docs**
```bash
grep -rniE '192\.168\.|10\.[0-9]+\.|danix@|/home/danix' README.md | grep -v 'danix@danix.xyz'
```
Expected: no output. A real LAN address, hostname or username in a committed
file violates `AGENTS.md`, and the commit hooks will reject it anyway.
- [ ] **Step 4: Commit**
```bash
git add README.md
git commit -m "docs: document the network and slackware cards
Notes the two things a reader would otherwise get wrong: br0 is a bridge, so
local VM traffic shows up in the chart, and the package count needs the
trailing slash because /var/log/packages is a symlink.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
```
---
## Task 15: Whole-branch verification
- [ ] **Step 1: Run the full suite**
```bash
lua test/test_data.lua && lua test/test_layout.lua && lua test/test_weather.lua && echo "SUITE PASS"
```
Expected: `SUITE PASS`.
- [ ] **Step 2: Render every card**
```bash
for w in clock weather system gpu disks cache network slackware; do
lua test/render.lua "$w" 16 12 3 4 "/tmp/card_$w.png" || echo "FAILED: $w"
done
```
Expected: eight PNGs, no failures. Look at each one.
- [ ] **Step 3: Confirm every commit is signed**
```bash
git log --format='%h %G? %s' -16
```
Expected: every line's second field is `G`. A `N` means an unsigned commit;
recover with the procedure in the global preferences file rather than leaving
it.
- [ ] **Step 4: Confirm no personal data is staged anywhere in the branch**
```bash
git diff master@{u}..HEAD | grep -niE '192\.168\.|10\.[0-9]{1,3}\.[0-9]|inet 1[^9]|[a-z0-9]+@[a-z0-9]+\.[a-z]+' | grep -v 'danix@danix.xyz' | grep -v '192\.0\.2\.'
```
Expected: no output. `192.0.2.x` is TEST-NET and is allowed; a real address is
not.
- [ ] **Step 5: Check the live board one more time**
```bash
./restart.sh
```
Leave it running for five minutes, then confirm: the network chart has two
moving lines with a sensible peak, the Slackware age is plausible against
`stat -c %Y /var/lib/slackpkg/ChangeLog.txt`, and the disk rings show free and
total without collision.
- [ ] **Step 6: Push**
```bash
git push
```
Note that `origin` may carry multiple push URLs, in which case one push fans
out to every configured destination.
---
## Notes for the implementer
**If the system card crowds** (Task 11, Step 5): stop and report it. The
fallback is one identification row or a separate card, and that is the user's
decision, not a constant to tune indefinitely.
**If a glyph draws wrong** (Task 8, Step 7): a wrong-but-present codepoint
draws a plausible neighbour rather than failing, so it must be looked at, not
assumed. Find the right codepoint in a Nerd Font cheat sheet and re-render.
**Do not run the samplers from a cron job or a systemd timer.** They are tied
to conky's `execi` deliberately: nothing should be fetching while the dashboard
is not running.
**Deferred, not forgotten.** These were recorded in a previous review and stay
out of scope here: the RAM row has no vertical-fit guard, a full-circle ring at
`frac == 1` leaves a small knob where the round caps coincide, `card.vbar` has
no minimum fill height, and the disks bar-fallback can crowd its right-aligned
figures on a very narrow card.
|