aboutsummaryrefslogtreecommitdiffstats
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/card.lua105
1 files changed, 98 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.