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
|
-- Cairo drawing primitives shared by every widget.
--
-- A widget is handed a rect and these helpers; it never computes its own
-- position and never touches the palette directly.
local M = {}
local function clamp(v, lo, hi) return math.max(lo, math.min(hi, v)) end
-- Cairo's font API tops out at CAIRO_FONT_WEIGHT_BOLD, so a Black face cannot
-- be requested by weight. Passing the family name that fontconfig registers for
-- it ("Noto Sans Black") with NORMAL weight does select it: measured at size
-- 90, "13" is 100.0px wide as Black against 94.0px as Noto Sans Bold.
M.FONT_MONO = 'Inconsolata Nerd Font'
M.FONT_UI = 'Noto Sans'
M.FONT_HEAVY = 'Noto Sans Black'
-- Shared defaults, not a restriction: card.font() takes any family string, so
-- a widget wanting its own face just passes one. The clock does, for squared
-- numerals (Oswald, OFL-1.1, installed under ~/.fonts/o/Oswald).
--
-- Cairo can only ask for Regular or Bold by weight, so a heavier cut is
-- selected by the family name fontconfig registers for it, exactly as
-- FONT_HEAVY does above: 'Oswald SemiBold' at NORMAL weight, not 'Oswald' at
-- bold. Fontconfig silently substitutes a default for a family it does not
-- know, which looks identical to the font "not applying", so check a new name
-- with `fc-match` before trusting it.
M.FONT_CLOCK = 'Oswald'
-- Cairo's toy text API binds ONE face and has no per-glyph fallback, so an
-- emoji in a calendar title drew as tofu in Noto Sans. text/measure/advance
-- therefore split a string into runs and draw the emoji runs in this face.
-- Monochrome on purpose: it takes the source colour like any other glyph, so
-- emoji follow the palette instead of pasting fixed colours onto the card.
M.FONT_EMOJI = 'Noto Emoji'
-- The face last chosen by M.font(), restored after each emoji run.
local cur_family, cur_weight
function M.font(cr, family, size, bold)
cur_family = family
cur_weight = bold and CAIRO_FONT_WEIGHT_BOLD or CAIRO_FONT_WEIGHT_NORMAL
cairo_select_font_face(cr, family, CAIRO_FONT_SLANT_NORMAL, cur_weight)
cairo_set_font_size(cr, size)
end
-- ponytail: codepoint ranges, not real glyph coverage, and the toy API has no
-- shaping, so a ZWJ family or a flag draws as its parts. Pango (via lgi) is
-- the upgrade path if that ever matters. 0x1F000-0x1FAFF only: the Nerd Font
-- icons the card titles use live in PUA (0xE000+, 0xF0000+) and must stay in
-- FONT_MONO.
local function is_emoji(cp)
return (cp >= 0x1F000 and cp <= 0x1FAFF) or (cp >= 0x2600 and cp <= 0x27BF)
end
-- { {text, is_emoji}, ... }, dropping VS16 and ZWJ, which only carry meaning
-- to a shaper. Plain ASCII and Latin text, and anything that is not valid
-- UTF-8, come back as a single non-emoji run: no allocation per frame for
-- the common case, and no error for a bad string (an error is a blank screen).
local function runs(s)
if not s:find('[\226\240-\244]') then return nil end
local ok, out = pcall(function()
local out, cur, ce = {}, {}, nil
for _, cp in utf8.codes(s) do
if cp ~= 0xFE0F and cp ~= 0x200D then
local e = is_emoji(cp)
if e ~= ce and #cur > 0 then
out[#out + 1] = { table.concat(cur), ce }
cur = {}
end
cur[#cur + 1], ce = utf8.char(cp), e
end
end
if #cur > 0 then out[#out + 1] = { table.concat(cur), ce } end
return out
end)
return ok and out or nil
end
-- Calls fn(text) for each run with its face selected, then restores the
-- caller's face so the next draw call is unaffected.
local function each_run(cr, list, fn)
for _, r in ipairs(list) do
local swap = r[2] and cur_family
if swap then
cairo_select_font_face(cr, M.FONT_EMOJI, CAIRO_FONT_SLANT_NORMAL,
CAIRO_FONT_WEIGHT_NORMAL)
end
fn(r[1])
if swap then
cairo_select_font_face(cr, cur_family, CAIRO_FONT_SLANT_NORMAL, cur_weight)
end
end
end
function M.rgba(cr, c, alpha)
cairo_set_source_rgba(cr, c[1], c[2], c[3], alpha or c[4] or 1)
end
-- Text at (x, y), where y is the BASELINE, not the top of the glyphs.
function M.text(cr, x, y, s)
cairo_move_to(cr, x, y)
local list = runs(s)
if not list then return cairo_show_text(cr, s) end
-- show_text leaves the current point after the run, so runs chain.
each_run(cr, list, function(t) cairo_show_text(cr, t) end)
end
-- Measured width and height of a string under the current font.
-- One reused extents struct for every measurement, allocated at load.
--
-- Not per call: cairo_text_extents_t:create() leaks. 5000 allocations grow
-- Lua's heap by ~182KB that collectgarbage() never reclaims, and calling
-- :destroy() on each one does NOT help (measured: same 182KB either way).
-- Reusing a single struct costs 0KB. In a draw hook running every 2s with
-- several measured strings per frame, the per-call version bleeds memory for as
-- long as conky is up, which is exactly the kind of fault a screenshot cannot
-- show.
--
-- Safe because the draw hook is single-threaded and each measure() consumes the
-- values before the next call overwrites them.
local extents = cairo_text_extents_t:create()
-- Ink size of a string: how much space the glyphs actually cover.
-- Use this to CENTRE or RIGHT-ALIGN text, never to advance a cursor.
function M.measure(cr, s)
local list = runs(s)
if not list then
cairo_text_extents(cr, s, extents)
return extents.width, extents.height
end
-- Union of each run's ink box, offset by the advances before it. A run
-- with no ink (spaces) moves the pen but adds nothing to the box.
local pen, x0, x1, y0, y1 = 0, nil, nil, nil, nil
each_run(cr, list, function(t)
cairo_text_extents(cr, t, extents)
if extents.width > 0 then
local l = pen + extents.x_bearing
x0, x1 = math.min(x0 or l, l), math.max(x1 or l, l + extents.width)
y0 = math.min(y0 or extents.y_bearing, extents.y_bearing)
local b = extents.y_bearing + extents.height
y1 = math.max(y1 or b, b)
end
pen = pen + extents.x_advance
end)
if not x0 then return 0, 0 end
return x1 - x0, y1 - y0
end
-- How far the cursor moves after drawing a string. Use this to lay out runs of
-- text left to right.
--
-- Not measure(): that returns the INK width, which ignores leading and
-- trailing spaces because a space carries no ink. Stepping a cursor by the ink
-- width collapses the gaps, and " / " measures 6px of ink against a 14px
-- advance, so a date drawn in segments came out as "16 /SEP /2026" with each
-- slash jammed into the next glyph.
function M.advance(cr, s)
local list = runs(s)
if not list then
cairo_text_extents(cr, s, extents)
return extents.x_advance
end
local pen = 0
each_run(cr, list, function(t)
cairo_text_extents(cr, t, extents)
pen = pen + extents.x_advance
end)
return pen
end
-- Right-aligned text: x is the RIGHT edge.
function M.text_right(cr, x, y, s)
local w = M.measure(cr, s)
M.text(cr, x - w, y, s)
end
-- A rounded-rectangle path. Does not paint; the caller fills or strokes, so one
-- path can serve both a fill and its border.
function M.rounded_path(cr, x, y, w, h, r)
-- Clamp the radius: a radius over half the shorter side makes the arcs
-- overlap and Cairo draws a pinched, bowtie-looking shape.
local m = math.min(w, h) / 2
if r > m then r = m end
cairo_new_path(cr)
cairo_arc(cr, x + w - r, y + r, r, -math.pi / 2, 0)
cairo_arc(cr, x + w - r, y + h - r, r, 0, math.pi / 2)
cairo_arc(cr, x + r, y + h - r, r, math.pi / 2, math.pi)
cairo_arc(cr, x + r, y + r, r, math.pi, math.pi * 1.5)
cairo_close_path(cr)
end
-- A card: filled rounded rect with a hairline border, matching the mockups.
-- Returns the inner rect so a widget can lay out against padded bounds.
function M.card(cr, rect, colors, pad)
pad = pad or 18
M.rounded_path(cr, rect.x, rect.y, rect.w, rect.h, 14)
M.rgba(cr, colors.surface, 0.55)
cairo_fill_preserve(cr)
-- 1px hairline. Cairo strokes astride the path, so a width of 1 on an integer
-- coordinate straddles two pixel rows and renders as a soft 2px line; the
-- cards read as thin outlines in the mockup, so keep it sub-pixel-crisp by
-- stroking at 1 and accepting the AA rather than offsetting by 0.5, which
-- would misalign the fill.
M.rgba(cr, colors.border, 0.9)
cairo_set_line_width(cr, 1)
cairo_stroke(cr)
return { x = rect.x + pad, y = rect.y + pad,
w = rect.w - pad * 2, h = rect.h - pad * 2 }
end
-- Card title glyphs, all present in FONT_MONO (Inconsolata Nerd Font).
--
-- Verified by rendering a contact sheet and looking at each one, not by
-- fontconfig alone: a missing glyph draws as an invisible blank and a wrong
-- codepoint as a plausible neighbour, so neither fails visibly.
M.ICON = {
system = '\u{F0EE0}', -- cpu-64-bit
gpu = '\u{F083E}', -- video-4k-box (no GPU glyph exists)
disks = '\u{F02CA}', -- harddisk
cache = '\u{F0ABA}', -- folder-clock
network = '\u{F0317}', -- lan
slackware = '\u{F318}', -- linux-slackware
breaktimer = '\u{F13AB}', -- timer
calendar = '\u{F0E17}', -- calendar-month
immich = '\u{F02F9}', -- image-multiple
updates = '\u{F03D5}', -- package-up
}
-- The gap between a title's icon and its text. One constant so the measured
-- width and the drawn width cannot drift apart.
local TITLE_GAP = ' '
-- The full run a title occupies, for fit_unit to measure. Colour does not
-- affect width, so measuring icon and text as one string is exact.
function M.title_str(icon, text) return icon .. TITLE_GAP .. text end
-- Draw a card title: the icon in `highlight`, the text in `label`, both at the
-- caller's current font. x is the left edge, y the baseline.
function M.title(cr, x, y, icon, text, colors)
M.rgba(cr, colors.highlight)
M.text(cr, x, y, icon)
x = x + M.advance(cr, icon .. TITLE_GAP)
M.rgba(cr, colors.label)
M.text(cr, x, y, text)
end
-- The no-data face: the card chrome with a named message in it.
--
-- Every cached card draws this when its sampler has not run, so the board keeps
-- its shape instead of showing an empty cell, which is indistinguishable from a
-- crashed widget. One helper rather than a copy per card: the same three lines
-- at the same ratios in the same colours is what makes the no-data face read the
-- same everywhere, and the fit_unit pass sizes all three off the rect exactly as
-- a populated card would.
function M.notice(cr, inner, colors, title, note, hint)
local LABEL_F, NOTE_F, HINT_F = 0.30, 0.36, 0.32
local S = clamp(inner.h * 0.94 / (1.78 + 2 * 0.72), 10, 72)
S = math.min(S, M.fit_unit(cr, inner.w * 0.96, {
{ { title, M.FONT_MONO, LABEL_F } },
{ { note, M.FONT_UI, NOTE_F } },
{ { hint, M.FONT_MONO, HINT_F } },
}, 100))
M.font(cr, M.FONT_MONO, S * LABEL_F, false)
M.rgba(cr, colors.label)
M.text(cr, inner.x, inner.y + S * LABEL_F, title)
M.font(cr, M.FONT_UI, S * NOTE_F, true)
M.text(cr, inner.x, inner.y + S + S * LABEL_F * 2.6, note)
M.font(cr, M.FONT_MONO, S * HINT_F, false)
M.text(cr, inner.x, inner.y + S + S * LABEL_F * 2.6 + S * 0.72, hint)
end
-- 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)
-- 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)
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 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)
end
-- The largest base size S at which every row of pieces still fits max_w,
-- measured at a reference size and scaled.
--
-- `groups` is a list of rows; each row is a list of { text, font, factor }
-- pieces drawn at factor*S on that row. A widget uses this to derive one
-- fluid size for the whole card: the cell's height decides how large the
-- content grows, and this pulls S back down only where a row would overrun
-- the width, so type fills the card instead of leaving empty space and never
-- collides. Returns math.huge when there is nothing to measure, leaving the
-- caller's height budget in charge.
function M.fit_unit(cr, max_w, groups, ref)
if not (max_w and max_w > 0) then return math.huge end
ref = ref or 100
local S = math.huge
for _, row in ipairs(groups) do
local w = 0
for _, piece in ipairs(row) do
M.font(cr, piece[2], ref * piece[3], false)
w = w + (M.measure(cr, piece[1]))
end
if w > 0 then
local fit = ref * max_w / w
if fit < S then S = fit end
end
end
return S
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', math.floor(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
-- 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
-- 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
local prev_width = cairo_get_line_width(cr)
local prev_cap = cairo_get_line_cap(cr)
-- 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)
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
return M
|