aboutsummaryrefslogtreecommitdiffstats
path: root/lib/card.lua
blob: 23131412546213c7c814c4e70f1c51f1f4870933 (plain)
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
-- 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'

function M.font(cr, family, size, bold)
  cairo_select_font_face(cr, family, CAIRO_FONT_SLANT_NORMAL,
    bold and CAIRO_FONT_WEIGHT_BOLD or CAIRO_FONT_WEIGHT_NORMAL)
  cairo_set_font_size(cr, size)
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)
  cairo_show_text(cr, s)
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)
  cairo_text_extents(cr, s, extents)
  return extents.width, extents.height
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)
  cairo_text_extents(cr, s, extents)
  return extents.x_advance
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
}

-- 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