-- 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 } -- 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 = , colour = }. `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