diff options
| author | Danilo M. <danix@danix.xyz> | 2026-10-07 11:01:27 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-10-07 11:01:27 +0200 |
| commit | b2fe3f493efc12dd300f0e2b2ede5660ee70c193 (patch) | |
| tree | dde01586d60f2b4a7d3ff21a3e9d90b6bb97c86e | |
| parent | 1b1a697102176c955d497a02143a5d9ecd45b718 (diff) | |
| download | conky-theme-udt-master.tar.gz conky-theme-udt-master.zip | |
Cairo's toy text API binds one face with no per-glyph fallback, so an
emoji in a calendar event title rendered as a box in Noto Sans.
text/measure/advance now split strings into runs and draw emoji runs
(U+1F000-1FAFF, U+2600-27BF) in monochrome Noto Emoji, restoring the
caller's face afterwards. Nerd Font PUA icons stay in FONT_MONO.
Plain strings and invalid UTF-8 take the old single-call path.
No shaping: ZWJ sequences and flags draw as their parts. Pango via
lgi (built for Lua 5.4) is the upgrade path.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| -rw-r--r-- | lib/card.lua | 105 | ||||
| -rw-r--r-- | test/test_data.lua | 16 |
2 files changed, 114 insertions, 7 deletions
diff --git a/lib/card.lua b/lib/card.lua index dde4121..7fd40f8 100644 --- a/lib/card.lua +++ b/lib/card.lua @@ -27,12 +27,72 @@ M.FONT_HEAVY = 'Noto Sans Black' -- 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) - cairo_select_font_face(cr, family, CAIRO_FONT_SLANT_NORMAL, - bold and CAIRO_FONT_WEIGHT_BOLD or CAIRO_FONT_WEIGHT_NORMAL) + 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 @@ -40,7 +100,10 @@ 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) + 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. @@ -61,8 +124,27 @@ 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 + 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 @@ -74,8 +156,17 @@ end -- 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 + 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. diff --git a/test/test_data.lua b/test/test_data.lua index ec2b6ed..549ce40 100644 --- a/test/test_data.lua +++ b/test/test_data.lua @@ -505,3 +505,19 @@ assert(gate(100.0) == true, 'gate: first call opens') assert(gate(100.5) == false, 'gate: closed inside the interval') assert(gate(101.95) == true, 'gate: opens after it') assert(gate(102.0) == false, 'gate: interval restarts from the last open') + +-- Emoji fall back to card.FONT_EMOJI: the toy API has no per-glyph fallback, +-- so without the run split "x🦷" measured a tofu box in Noto Sans. +do + local s = cairo_image_surface_create(CAIRO_FORMAT_ARGB32, 10, 10) + local cr = cairo_create(s) + card.font(cr, 'Noto Emoji', 20, false) + local tooth = card.advance(cr, '🦷') + card.font(cr, card.FONT_UI, 20, false) + local x = card.advance(cr, 'x') + assert(math.abs(card.advance(cr, 'x🦷') - (x + tooth)) < 0.01, 'emoji run uses FONT_EMOJI') + assert(card.advance(cr, 'x') == x, 'caller face restored after an emoji run') + assert(pcall(card.advance, cr, '\240\159'), 'invalid UTF-8 must not raise') + cairo_destroy(cr) + cairo_surface_destroy(s) +end |
