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
|
-- 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 = {}
-- 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
-- A small-caps section label, as used across both mockups.
function M.label(cr, x, y, s, colors)
M.font(cr, M.FONT_MONO, 13, false)
M.rgba(cr, colors.label)
M.text(cr, x, y, s:upper())
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)
cairo_set_line_width(cr, width)
cairo_set_line_cap(cr, CAIRO_LINE_CAP_ROUND)
M.rgba(cr, colors.rule, 0.5)
cairo_arc(cr, cx, cy, r, 0, math.pi * 2)
cairo_stroke(cr)
if frac > 0 then
M.rgba(cr, colour, 1)
cairo_arc(cr, cx, cy, r, -math.pi / 2, -math.pi / 2 + math.pi * 2 * frac)
cairo_stroke(cr)
end
-- Leave the cap as it was found: a later stroke inheriting ROUND would get
-- visibly rounded ends on the card's hairlines.
cairo_set_line_cap(cr, CAIRO_LINE_CAP_BUTT)
end
-- 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
return M
|