feat(ui-lvgl): @nick mentions highlighted, colour emoji and flags

Mentions: "@[nick]" in message text becomes "@nick" in the accent colour
(a span group); a mention of us is underlined and its bubble outlined.
Chat previews and the message popup's quote show "@nick" as well.

Emoji: every single-codepoint Twemoji and every flag, 16 px RGB565A8 in
flash, as the fallback font of each text size (fonts/ui_emoji.c, data
generated by fonts/emoji.py). Joiners, selectors and skin tones take no
space; a regional-indicator pair is swapped for the flag's private
codepoint before text reaches a label. Twemoji credit in Settings > About.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-09-26 23:41:12 +02:00
co-authored by Claude Opus 5.5
parent 708fde7225
commit f5206f10b4
13 changed files with 54002 additions and 18 deletions
+9 -3
View File
@@ -247,12 +247,18 @@ User list, second batch (2026-09-26):
- [x] Map download popup runs off the screen; take the WiFi settings out - [x] Map download popup runs off the screen; take the WiFi settings out
of it (one place: Settings > WiFi). Compact: a progress bar, one of it (one place: Settings > WiFi). Compact: a progress bar, one
line of counts, errors on one line, only the network's name. line of counts, errors on one line, only the network's name.
- [ ] Mentions: highlight @[nick] in message text (a coloured chip in - [x] Mentions: "@[nick]" shows as "@nick" in the accent colour (a span);
place of the raw "@[...]" markup), our own name stronger. our own name underlined and its bubble outlined. Previews and the
message popup's quote show "@nick" too.
- [x] Splash: "MeshCore Solo" with the upstream version and ours, all in - [x] Splash: "MeshCore Solo" with the upstream version and ours, all in
the MeshCore title font (the wordmark's letters plus l, v, d, digits, the MeshCore title font (the wordmark's letters plus l, v, d, digits,
'.', '-' drawn to match; Noto for a line with anything else). '.', '-' drawn to match; Noto for a line with anything else).
- [ ] Emoji, ideally the full range (glyphs could live on the SD card). - [x] Emoji: every single-codepoint emoji plus all flags, colour Twemoji
at 16 px baked into flash (~1.2 MB; fonts/emoji.py -> ui_emoji_data.c),
the text fonts' fallback. Flags are swapped for one private codepoint
before text reaches a label. Other sequences (skin tones, ZWJ) draw as
their parts. Credit in Settings > About. Later: an emoji picker on the
keyboard.
- [x] Tap-to-wake toggle (Settings > Display & power > WAKE, NVS, on by - [x] Tap-to-wake toggle (Settings > Display & power > WAKE, NVS, on by
default; off: only the top button wakes it). default; off: only the top button wakes it).
- [x] Favourites as their own screen in the menu rather than a tile: a - [x] Favourites as their own screen in the menu rather than a tile: a
@@ -303,7 +303,10 @@ void UITask::messageMenu(int idx) {
} }
// The message itself, quoted short, and when. // The message itself, quoted short, and when.
lv_obj_t* q = label(panel, body, THEME_FONT_SMALL, theme::TEXT); char quote[MAX_TEXT_LEN + 1];
snprintf(quote, sizeof(quote), "%s", body);
plainMentions(quote);
lv_obj_t* q = label(panel, quote, THEME_FONT_SMALL, theme::TEXT);
lv_label_set_long_mode(q, LV_LABEL_LONG_DOT); lv_label_set_long_mode(q, LV_LABEL_LONG_DOT);
lv_obj_set_width(q, LV_PCT(100)); lv_obj_set_width(q, LV_PCT(100));
lv_obj_set_style_max_height(q, 34, 0); // two lines lv_obj_set_style_max_height(q, 34, 0); // two lines
@@ -883,4 +883,6 @@ void UITask::mapDownloadTick() {
#if defined(SIM_PLATFORM) && defined(__EMSCRIPTEN__) #if defined(SIM_PLATFORM) && defined(__EMSCRIPTEN__)
// Sim page / tests: a saved WiFi network without going through Settings > WiFi. // Sim page / tests: a saved WiFi network without going through Settings > WiFi.
extern "C" EMSCRIPTEN_KEEPALIVE void sim_wifi_save(const char* ssid, const char* pass) { lvport::saveWifi(ssid, pass); } extern "C" EMSCRIPTEN_KEEPALIVE void sim_wifi_save(const char* ssid, const char* pass) { lvport::saveWifi(ssid, pass); }
// ...and this device's name (a test mentions it).
extern "C" EMSCRIPTEN_KEEPALIVE const char* sim_node_name() { return the_mesh.getNodeName(); }
#endif #endif
+118 -6
View File
@@ -6,6 +6,43 @@
#include <esp_heap_caps.h> #include <esp_heap_caps.h>
#endif #endif
#include <new> #include <new>
#include <stdarg.h>
// Flags: LVGL draws text one codepoint at a time, so a flag (a pair of regional
// indicator letters) would show as two letter tiles. Every label and span of
// this UI (all of it is this one file and the headers it includes) goes through
// these, which swap each pair for the codepoint fonts/ui_emoji.c keeps the
// flag's image under. Display only: what's stored and sent stays as it was.
extern "C" char* ui_emoji_flags(const char* s);
static void ui_label_set_text(lv_obj_t* o, const char* t) {
char* f = t ? ui_emoji_flags(t) : nullptr; // t == NULL: LVGL's "redraw the current text"
lv_label_set_text(o, f ? f : t);
lv_free(f);
}
static void ui_label_set_text_fmt(lv_obj_t* o, const char* fmt, ...) __attribute__((format(printf, 2, 3)));
static void ui_label_set_text_fmt(lv_obj_t* o, const char* fmt, ...) {
va_list ap;
va_start(ap, fmt);
char buf[160];
int n = vsnprintf(buf, sizeof(buf), fmt, ap);
va_end(ap);
if (n < (int)sizeof(buf)) { ui_label_set_text(o, buf); return; }
char* big = (char*)lv_malloc(n + 1);
if (!big) return;
va_start(ap, fmt);
vsnprintf(big, n + 1, fmt, ap);
va_end(ap);
ui_label_set_text(o, big);
lv_free(big);
}
static void ui_span_set_text(lv_span_t* s, const char* t) {
char* f = ui_emoji_flags(t);
lv_span_set_text(s, f ? f : t);
lv_free(f);
}
#define lv_label_set_text ui_label_set_text
#define lv_label_set_text_fmt ui_label_set_text_fmt
#define lv_span_set_text ui_span_set_text
// A zeroed buffer of `n` T in PSRAM when the board has it: internal RAM is what // A zeroed buffer of `n` T in PSRAM when the board has it: internal RAM is what
// BLE, WiFi and TLS need. Allocated once (at startup or first use), never freed. // BLE, WiFi and TLS need. Allocated once (at startup or first use), never freed.
@@ -135,6 +172,23 @@ static void stylePrimary(lv_obj_t* b) {
} }
// Unread badge: amber pill with a count, on the right edge of `parent`. // Unread badge: amber pill with a count, on the right edge of `parent`.
// "@[nick]" -> "@nick" in place, for one-line text (previews, quotes) where
// the bubble's highlight doesn't reach.
static void plainMentions(char* t) {
char* w = t;
for (const char* r = t; *r;) {
const char* close = r[0] == '@' && r[1] == '[' ? strchr(r + 2, ']') : nullptr;
if (close && close - r <= 34) {
*w++ = '@';
for (const char* q = r + 2; q < close;) *w++ = *q++;
r = close + 1;
} else {
*w++ = *r++;
}
}
*w = '\0';
}
static void badge(lv_obj_t* parent, int n, bool overflow) { static void badge(lv_obj_t* parent, int n, bool overflow) {
if (n <= 0) return; if (n <= 0) return;
lv_obj_t* b = lv_obj_create(parent); lv_obj_t* b = lv_obj_create(parent);
@@ -1179,6 +1233,7 @@ void UITask::buildChats() {
if (n > 0) { if (n > 0) {
const ChHistEntry& e = _core->history.chAtPos(_core->history.histEntryForChannel(i, 0)); const ChHistEntry& e = _core->history.chAtPos(_core->history.histEntryForChannel(i, 0));
snprintf(sub, sizeof(sub), "%s", e.text); snprintf(sub, sizeof(sub), "%s", e.text);
plainMentions(sub);
} }
lv_obj_t* row = listRow(body, title, sub[0] ? sub : NULL, onOpenChannel, (void*)(uintptr_t)i); lv_obj_t* row = listRow(body, title, sub[0] ? sub : NULL, onOpenChannel, (void*)(uintptr_t)i);
lv_obj_add_event_cb(row, onChanRowHold, LV_EVENT_LONG_PRESSED, (void*)(uintptr_t)i); lv_obj_add_event_cb(row, onChanRowHold, LV_EVENT_LONG_PRESSED, (void*)(uintptr_t)i);
@@ -1214,6 +1269,7 @@ void UITask::buildChats() {
if (known && contactctl::notif(_prefs, c.id.pub_key) == contactctl::NOTIF_MUTED) strncat(name, " " UI_SYMBOL_MUTE, sizeof(name) - strlen(name) - 1); if (known && contactctl::notif(_prefs, c.id.pub_key) == contactctl::NOTIF_MUTED) strncat(name, " " UI_SYMBOL_MUTE, sizeof(name) - strlen(name) - 1);
char sub[64]; char sub[64];
snprintf(sub, sizeof(sub), "%s%s", e.outgoing ? "Me: " : "", e.text); snprintf(sub, sizeof(sub), "%s%s", e.outgoing ? "Me: " : "", e.text);
plainMentions(sub);
lv_obj_t* row = listRow(body, name, sub, onOpenDMRow, (void*)(uintptr_t)rows); lv_obj_t* row = listRow(body, name, sub, onOpenDMRow, (void*)(uintptr_t)rows);
if (known) lv_obj_add_event_cb(row, onDMRowHold, LV_EVENT_LONG_PRESSED, (void*)(uintptr_t)rows); if (known) lv_obj_add_event_cb(row, onDMRowHold, LV_EVENT_LONG_PRESSED, (void*)(uintptr_t)rows);
badge(row, _core->dmUnread(e.prefix), _core->dmUnreadOverflow(e.prefix)); badge(row, _core->dmUnread(e.prefix), _core->dmUnreadOverflow(e.prefix));
@@ -1990,6 +2046,59 @@ static void msgLocButton(lv_obj_t* parent, const char* text, int idx, bool save,
lv_obj_center(label(b, text, THEME_FONT_SMALL, accent ? theme::BG : theme::TEXT)); lv_obj_center(label(b, text, THEME_FONT_SMALL, accent ? theme::BG : theme::TEXT));
} }
// A message's text, wrapped at `max_w` and shrunk to fit. "@[nick]" mentions
// (how a reply names who it answers) show as "@nick" in the accent -- in the
// text colour on our own amber bubbles -- and underlined when the nick is
// ours (*mentions_me set). Plain text stays a label.
static lv_obj_t* msgText(lv_obj_t* parent, const char* text, bool own, int max_w, bool* mentions_me) {
*mentions_me = false;
if (!strstr(text, "@[")) {
lv_obj_t* t = label(parent, text, THEME_FONT_BODY, theme::TEXT);
lv_label_set_long_mode(t, LV_LABEL_LONG_WRAP);
lv_obj_set_style_max_width(t, max_w, 0);
lv_obj_set_width(t, LV_SIZE_CONTENT);
return t;
}
lv_obj_t* sg = lv_spangroup_create(parent);
lv_obj_remove_flag(sg, LV_OBJ_FLAG_CLICKABLE); // a hold still reaches the bubble (its menu)
lv_obj_set_style_text_font(sg, THEME_FONT_BODY, 0);
lv_obj_set_style_text_color(sg, lv_color_hex(theme::TEXT), 0);
lv_spangroup_set_mode(sg, LV_SPAN_MODE_BREAK);
const char* me = the_mesh.getNodeName();
char part[MAX_TEXT_LEN + 1];
const char* p = text;
while (*p) {
const char* at = strstr(p, "@[");
const char* close = at ? strchr(at + 2, ']') : nullptr;
if (at && (!close || close - at > 34)) close = nullptr; // not a mention: a nick is <= 31 chars
const char* end = at && close ? at : p + strlen(p);
if (at && !close) end = at + 2; // "@[" alone: plain, carry on after it
if (end > p) {
size_t n = end - p < (ptrdiff_t)sizeof(part) ? end - p : sizeof(part) - 1;
memcpy(part, p, n);
part[n] = '\0';
lv_span_set_text(lv_spangroup_new_span(sg), part);
}
if (!at || !close) { p = end; continue; }
size_t n = close - (at + 2);
part[0] = '@';
memcpy(part + 1, at + 2, n);
part[n + 1] = '\0';
bool mine = strlen(me) == n && strncasecmp(me, at + 2, n) == 0;
lv_span_t* sp = lv_spangroup_new_span(sg);
lv_span_set_text(sp, part);
lv_style_t* st = lv_span_get_style(sp);
lv_style_set_text_color(st, lv_color_hex(own ? theme::TEXT : theme::ACCENT));
if (mine) { lv_style_set_text_decor(st, LV_TEXT_DECOR_UNDERLINE); *mentions_me = true; }
p = close + 1;
}
lv_spangroup_refr_mode(sg);
uint32_t w = lv_spangroup_get_expand_width(sg, 0);
lv_obj_set_width(sg, w > (uint32_t)max_w ? max_w : (int32_t)w + 1);
lv_obj_set_height(sg, LV_SIZE_CONTENT);
return sg;
}
// One message bubble. Own messages right-aligned in amber, others left. // One message bubble. Own messages right-aligned in amber, others left.
// loc_idx >= 0: the text carries a position (s_msg_locs[loc_idx]). // loc_idx >= 0: the text carries a position (s_msg_locs[loc_idx]).
static void bubble(lv_obj_t* list, const char* from, const char* text, bool own, static void bubble(lv_obj_t* list, const char* from, const char* text, bool own,
@@ -2042,10 +2151,12 @@ static void bubble(lv_obj_t* list, const char* from, const char* text, bool own,
label(hdr, from, THEME_FONT_SMALL, theme::ACCENT); // names are <= 31 chars: fits the bubble label(hdr, from, THEME_FONT_SMALL, theme::ACCENT); // names are <= 31 chars: fits the bubble
if (meta_in_header) label(hdr, meta, THEME_FONT_SMALL, theme::TEXT_MUTED); if (meta_in_header) label(hdr, meta, THEME_FONT_SMALL, theme::TEXT_MUTED);
} }
lv_obj_t* t = label(b, text, THEME_FONT_BODY, theme::TEXT); bool mentions_me;
lv_label_set_long_mode(t, LV_LABEL_LONG_WRAP); msgText(b, text, own, 238, &mentions_me);
lv_obj_set_style_max_width(t, 238, 0); if (mentions_me && !own) { // someone answering us: the bubble outlined
lv_obj_set_width(t, LV_SIZE_CONTENT); lv_obj_set_style_border_width(b, 1, 0);
lv_obj_set_style_border_color(b, lv_color_hex(theme::ACCENT), 0);
}
if (loc_idx >= 0) { // position: navigate there / keep it as a waypoint if (loc_idx >= 0) { // position: navigate there / keep it as a waypoint
lv_obj_t* acts = lv_obj_create(b); lv_obj_t* acts = lv_obj_create(b);
@@ -2605,9 +2716,10 @@ void UITask::buildSettings() {
} }
sectionTitle(body, "ABOUT"); sectionTitle(body, "ABOUT");
char about[200], built[24] = ""; char about[300], built[24] = "";
if (!strstr(FIRMWARE_VERSION, FIRMWARE_BUILD_DATE)) snprintf(built, sizeof(built), " (%s)", FIRMWARE_BUILD_DATE); if (!strstr(FIRMWARE_VERSION, FIRMWARE_BUILD_DATE)) snprintf(built, sizeof(built), " (%s)", FIRMWARE_BUILD_DATE);
snprintf(about, sizeof(about), "%s\nFirmware %s%s\n\nMap data: %s", the_mesh.getNodeName(), FIRMWARE_VERSION, built, snprintf(about, sizeof(about), "%s\nFirmware %s%s\n\nMap data: %s\nEmoji: Twemoji \xC2\xA9 Twitter, Inc. and contributors (CC\xE2\x80\x91" "BY 4.0)",
the_mesh.getNodeName(), FIRMWARE_VERSION, built,
(lvport::mountStorage() && mapview::s_provider->available()) ? mapview::s_provider->attribution() (lvport::mountStorage() && mapview::s_provider->available()) ? mapview::s_provider->attribution()
: "\xC2\xA9 OpenStreetMap contributors"); : "\xC2\xA9 OpenStreetMap contributors");
lv_obj_t* a = label(body, about, THEME_FONT_SMALL, theme::TEXT); lv_obj_t* a = label(body, about, THEME_FONT_SMALL, theme::TEXT);
@@ -0,0 +1,90 @@
#!/usr/bin/env python3
"""Builds ui_emoji_data.c: every single-codepoint Twemoji and every flag, scaled to PX x PX,
as LVGL RGB565A8 images (an RGB565 plane, then an alpha plane).
ui_emoji.c turns the table into fonts the text fonts fall back to, so an emoji
in a message, a name or a preview draws in colour. Multi-codepoint sequences
(ZWJ families, skin tones) have no shaping in LVGL: they draw as their parts,
a toned hand untoned. Flags are the exception: each is stored under a private
codepoint, FLAG_BASE + letter1 * 26 + letter2, and UITask.cpp swaps a pair of
regional indicator letters for it before the text reaches a label.
Twemoji graphics: (c) Twitter / X and other contributors, CC-BY 4.0
(https://github.com/jdecked/twemoji).
Usage: emoji.py <twemoji assets/72x72 dir> <out.c> (needs Pillow)
Called by generate.sh.
"""
import os
import sys
from PIL import Image
PX = 16
FLAG_BASE = 0xF0000 # Supplementary Private Use Area-A
RI_A = 0x1F1E6 # regional indicator symbol letter A
def codepoints(src):
"""(codepoint, file) of every glyph, sorted."""
cps = []
for f in os.listdir(src):
name, ext = os.path.splitext(f)
if ext != ".png":
continue
parts = [int(x, 16) for x in name.split("-")]
if len(parts) == 2 and all(RI_A <= p < RI_A + 26 for p in parts): # a flag
cps.append((FLAG_BASE + (parts[0] - RI_A) * 26 + parts[1] - RI_A, f))
continue
if len(parts) > 1:
continue
cp = parts[0]
# below U+2000 the text fonts already have it (digits, (c), (r)); the
# skin tone swatches only ever follow another emoji: ui_emoji.c drops them
if cp < 0x2000 or 0x1F3FB <= cp <= 0x1F3FF:
continue
cps.append((cp, f))
return sorted(cps)
def main(src, out):
glyphs = codepoints(src)
cps = [cp for cp, _ in glyphs]
data = bytearray()
for _, f in glyphs:
im = Image.open(os.path.join(src, f)).convert("RGBA")
im = im.resize((PX, PX), Image.LANCZOS) # Pillow premultiplies RGBA while resampling
rgb, alpha = bytearray(), bytearray()
raw = im.tobytes()
for i in range(0, len(raw), 4):
r, g, b, a = raw[i:i + 4]
v = ((r >> 3) << 11) | ((g >> 2) << 5) | (b >> 3)
rgb += bytes((v & 0xFF, v >> 8))
alpha.append(a)
data += rgb + alpha
n = len(cps)
size = PX * PX * 3
with open(out, "w") as o:
o.write("/* Generated by emoji.py from Twemoji 15.1 (CC-BY 4.0) -- do not edit. */\n")
o.write('#include "lvgl.h"\n\n')
o.write("const uint16_t ui_emoji_count = %d;\n\n" % n)
o.write("const uint32_t ui_emoji_cps[%d] = {\n" % n)
for i in range(0, n, 8):
o.write(" " + ", ".join("0x%X" % c for c in cps[i:i + 8]) + ",\n")
o.write("};\n\n")
o.write("static const uint8_t px[%d] = {\n" % len(data))
for i in range(0, len(data), 24):
o.write(" " + ",".join("0x%02X" % b for b in data[i:i + 24]) + ",\n")
o.write("};\n\n")
o.write("#define E(i) { .header = { .magic = LV_IMAGE_HEADER_MAGIC, .cf = LV_COLOR_FORMAT_RGB565A8, "
".w = %d, .h = %d, .stride = %d }, .data_size = %d, .data = px + (i) * %d }\n"
% (PX, PX, PX * 2, size, size))
o.write("const lv_image_dsc_t ui_emoji_img[%d] = {\n" % n)
for i in range(0, n, 10):
o.write(" " + ", ".join("E(%d)" % j for j in range(i, min(n, i + 10))) + ",\n")
o.write("};\n")
print("emoji: %d glyphs, %d KB" % (n, len(data) // 1024))
if __name__ == "__main__":
main(sys.argv[1], sys.argv[2])
@@ -5,7 +5,8 @@
# Czech, Slovak, Hungarian, Romanian ș/ț, Baltic, Turkish, Nordic, Icelandic, # Czech, Slovak, Hungarian, Romanian ș/ț, Baltic, Turkish, Nordic, Icelandic,
# Maltese, Welsh ŵ/ŷ/ẁ…, German ẞ), Greek, Cyrillic (+ supplement: Ukrainian, # Maltese, Welsh ŵ/ŷ/ẁ…, German ẞ), Greek, Cyrillic (+ supplement: Ukrainian,
# Belarusian, Serbian, Macedonian, Bulgarian…), typographic punctuation, # Belarusian, Serbian, Macedonian, Bulgarian…), typographic punctuation,
# currency (€ ₴ ₽ …) and №, plus LVGL's LV_SYMBOL_* icons. # currency (€ ₴ ₽ …) and №, plus LVGL's LV_SYMBOL_* icons; emoji fall
# back to colour Twemoji images (emoji.py -> ui_emoji_data.c, ui_emoji.c).
# #
# Needs node (npx fetches lv_font_conv) and curl. Run from anywhere: # Needs node (npx fetches lv_font_conv) and curl. Run from anywhere:
# examples/companion_radio/ui-lvgl/fonts/generate.sh # examples/companion_radio/ui-lvgl/fonts/generate.sh
@@ -41,7 +42,10 @@ gen() { # size weight ranges name
--font "$CACHE/fa.woff" -r "$SYMS" \ --font "$CACHE/fa.woff" -r "$SYMS" \
--format lvgl --lv-include lvgl.h --lv-font-name "$4" -o "$HERE/$4.c" --format lvgl --lv-include lvgl.h --lv-font-name "$4" -o "$HERE/$4.c"
} }
for s in 12 14 16 20; do gen "$s" Medium "$TEXT" "ui_font_$s"; done for s in 12 14 16 20; do
gen "$s" Medium "$TEXT" "ui_font_$s"
sed -i.bak "s/\.fallback = NULL,/.fallback = \&ui_emoji_$s,/" "$HERE/ui_font_$s.c" && rm "$HERE/ui_font_$s.c.bak"
done
# Clock: digits, colon, dash, dot, space only. # Clock: digits, colon, dash, dot, space only.
$CONV --bpp 4 --size 40 --no-compress --font "$CACHE/NotoSans-SemiBold.ttf" -r "0x20,0x2D-0x2E,0x30-0x3A" \ $CONV --bpp 4 --size 40 --no-compress --font "$CACHE/NotoSans-SemiBold.ttf" -r "0x20,0x2D-0x2E,0x30-0x3A" \
--format lvgl --lv-include lvgl.h --lv-font-name ui_font_40 -o "$HERE/ui_font_40.c" --format lvgl --lv-include lvgl.h --lv-font-name ui_font_40 -o "$HERE/ui_font_40.c"
@@ -54,4 +58,9 @@ $CONV --bpp 4 --size 14 --no-compress --font "$CACHE/ui-icons.ttf" -r "$ICONS" \
--format lvgl --lv-include lvgl.h --lv-font-name ui_icons_14 -o "$HERE/ui_icons_14.c" --format lvgl --lv-include lvgl.h --lv-font-name ui_icons_14 -o "$HERE/ui_icons_14.c"
sed -i.bak -e 's/\.line_height = [0-9]*,/.line_height = 18,/' -e 's/\.base_line = [0-9]*,/.base_line = 4,/' \ sed -i.bak -e 's/\.line_height = [0-9]*,/.line_height = 18,/' -e 's/\.base_line = [0-9]*,/.base_line = 4,/' \
-e 's/\.fallback = NULL,/.fallback = \&ui_font_12,/' "$HERE/ui_icons_14.c" && rm "$HERE/ui_icons_14.c.bak" -e 's/\.fallback = NULL,/.fallback = \&ui_font_12,/' "$HERE/ui_icons_14.c" && rm "$HERE/ui_icons_14.c.bak"
# Colour emoji the text fonts fall back to (ui_emoji.c): Twemoji 15.1, CC-BY 4.0.
# Needs python3 with Pillow.
TW="$CACHE/twemoji-15.1.0"
[ -d "$TW" ] || curl -sSL https://codeload.github.com/jdecked/twemoji/tar.gz/refs/tags/v15.1.0 | tar xz -C "$CACHE" twemoji-15.1.0/assets/72x72
python3 "$HERE/emoji.py" "$TW/assets/72x72" "$HERE/ui_emoji_data.c"
echo "done:$(ls "$HERE"/ui_font_*.c | wc -l) fonts" echo "done:$(ls "$HERE"/ui_font_*.c | wc -l) fonts"
@@ -0,0 +1,118 @@
/* Colour emoji for the text fonts: ui_font_<size> falls back to ui_emoji_<size>
* (generate.sh sets the fallback), which draws the Twemoji images of
* ui_emoji_data.c (emoji.py). One 16 px image set; each font only centres it on
* its own line, which is why there is one lv_font_t per text size.
*
* LVGL doesn't shape text, so a sequence draws as its parts: the joiners and
* selectors between them take no space, a skin tone is dropped (the plain
* emoji stays). Flags are swapped in before drawing (ui_emoji_flags). */
#include "lvgl.h"
#include <string.h>
extern const uint16_t ui_emoji_count;
extern const uint32_t ui_emoji_cps[];
extern const lv_image_dsc_t ui_emoji_img[];
#define PX 16
#define FLAG_BASE 0xF0000 /* flag images: FLAG_BASE + letter1 * 26 + letter2 (emoji.py) */
static bool zero_width(uint32_t u) {
return u == 0x200D /* zero width joiner */
|| u == 0xFE0E || u == 0xFE0F /* text / emoji presentation */
|| u == 0x20E3 /* keycap */
|| (u >= 0x1F3FB && u <= 0x1F3FF) /* skin tones */
|| (u >= 0xE0020 && u <= 0xE007F); /* tags (subdivision flags) */
}
static int find(uint32_t u) {
if (u < ui_emoji_cps[0] || u > ui_emoji_cps[ui_emoji_count - 1]) return -1;
int lo = 0, hi = ui_emoji_count - 1;
while (lo <= hi) {
int mid = (lo + hi) / 2;
if (ui_emoji_cps[mid] < u) lo = mid + 1;
else if (ui_emoji_cps[mid] > u) hi = mid - 1;
else return mid;
}
return -1;
}
/* 0..25 when p starts with a regional indicator letter (U+1F1E6 + 0..25,
* UTF-8 F0 9F 87 A6..BF), else -1. */
static int ri_letter(const char* p) {
return ((uint8_t)p[0] == 0xF0 && (uint8_t)p[1] == 0x9F && (uint8_t)p[2] == 0x87 && (uint8_t)p[3] >= 0xA6 &&
(uint8_t)p[3] <= 0xBF) ? (uint8_t)p[3] - 0xA6 : -1;
}
/* A flag is a pair of regional indicator letters. Returns s with every pair
* that has an image swapped for that image's codepoint -- a copy from lv_malloc,
* for the caller to lv_free -- or NULL when s has no flag. The copy is never
* longer: 8 bytes become 4. */
char* ui_emoji_flags(const char* s) {
if (!strstr(s, "\xF0\x9F\x87")) return NULL;
size_t n = strlen(s);
char* out = lv_malloc(n + 1);
if (!out) return NULL;
char* o = out;
while (*s) {
int a = ri_letter(s), b = a >= 0 ? ri_letter(s + 4) : -1;
if (b >= 0) {
uint32_t cp = FLAG_BASE + a * 26 + b;
if (find(cp) >= 0) {
*o++ = (char)(0xF0 | (cp >> 18));
*o++ = (char)(0x80 | ((cp >> 12) & 0x3F));
*o++ = (char)(0x80 | ((cp >> 6) & 0x3F));
*o++ = (char)(0x80 | (cp & 0x3F));
} else { /* no such flag: keep both letters, so the next pair stays paired */
memcpy(o, s, 8);
o += 8;
}
s += 8;
} else {
*o++ = *s++;
}
}
*o = 0;
return out;
}
static bool get_glyph_dsc(const lv_font_t* font, lv_font_glyph_dsc_t* g, uint32_t u, uint32_t next) {
LV_UNUSED(next);
g->is_placeholder = 0;
g->ofs_x = 0;
if (zero_width(u)) {
g->adv_w = g->box_w = g->box_h = 0;
g->ofs_y = 0;
g->format = LV_FONT_GLYPH_FORMAT_NONE;
return true;
}
int i = find(u);
if (i < 0) return false;
g->adv_w = PX + 2; /* 1 px clear each side */
g->box_w = g->box_h = PX;
g->ofs_x = 1;
g->ofs_y = (int16_t)(intptr_t)font->user_data;
g->format = LV_FONT_GLYPH_FORMAT_IMAGE;
g->gid.src = &ui_emoji_img[i];
return true;
}
static const void* get_glyph_bitmap(lv_font_glyph_dsc_t* g, lv_draw_buf_t* buf) {
LV_UNUSED(buf);
return g->gid.src;
}
/* LVGL puts a glyph's top at line top + (line_height - base_line) - box_h - ofs_y
* with the *text* font's metrics; this ofs_y centres the image on that line. */
#define EMOJI_FONT(name, lh, bl) \
const lv_font_t name = { \
.get_glyph_dsc = get_glyph_dsc, \
.get_glyph_bitmap = get_glyph_bitmap, \
.line_height = (lh), \
.base_line = (bl), \
.user_data = (void*)(intptr_t)(((lh) - (bl)) - PX - ((lh) - PX) / 2), \
};
EMOJI_FONT(ui_emoji_12, 18, 4)
EMOJI_FONT(ui_emoji_14, 20, 4)
EMOJI_FONT(ui_emoji_16, 23, 5)
EMOJI_FONT(ui_emoji_20, 28, 6)
File diff suppressed because it is too large Load Diff
@@ -10780,7 +10780,7 @@ lv_font_t ui_font_12 = {
#endif #endif
.dsc = &font_dsc, /*The custom font data. Will be accessed by `get_glyph_bitmap/dsc` */ .dsc = &font_dsc, /*The custom font data. Will be accessed by `get_glyph_bitmap/dsc` */
#if LV_VERSION_CHECK(8, 2, 0) || LVGL_VERSION_MAJOR >= 9 #if LV_VERSION_CHECK(8, 2, 0) || LVGL_VERSION_MAJOR >= 9
.fallback = NULL, .fallback = &ui_emoji_12,
#endif #endif
.user_data = NULL, .user_data = NULL,
}; };
@@ -12260,7 +12260,7 @@ lv_font_t ui_font_14 = {
#endif #endif
.dsc = &font_dsc, /*The custom font data. Will be accessed by `get_glyph_bitmap/dsc` */ .dsc = &font_dsc, /*The custom font data. Will be accessed by `get_glyph_bitmap/dsc` */
#if LV_VERSION_CHECK(8, 2, 0) || LVGL_VERSION_MAJOR >= 9 #if LV_VERSION_CHECK(8, 2, 0) || LVGL_VERSION_MAJOR >= 9
.fallback = NULL, .fallback = &ui_emoji_14,
#endif #endif
.user_data = NULL, .user_data = NULL,
}; };
@@ -14191,7 +14191,7 @@ lv_font_t ui_font_16 = {
#endif #endif
.dsc = &font_dsc, /*The custom font data. Will be accessed by `get_glyph_bitmap/dsc` */ .dsc = &font_dsc, /*The custom font data. Will be accessed by `get_glyph_bitmap/dsc` */
#if LV_VERSION_CHECK(8, 2, 0) || LVGL_VERSION_MAJOR >= 9 #if LV_VERSION_CHECK(8, 2, 0) || LVGL_VERSION_MAJOR >= 9
.fallback = NULL, .fallback = &ui_emoji_16,
#endif #endif
.user_data = NULL, .user_data = NULL,
}; };
@@ -18520,7 +18520,7 @@ lv_font_t ui_font_20 = {
#endif #endif
.dsc = &font_dsc, /*The custom font data. Will be accessed by `get_glyph_bitmap/dsc` */ .dsc = &font_dsc, /*The custom font data. Will be accessed by `get_glyph_bitmap/dsc` */
#if LV_VERSION_CHECK(8, 2, 0) || LVGL_VERSION_MAJOR >= 9 #if LV_VERSION_CHECK(8, 2, 0) || LVGL_VERSION_MAJOR >= 9
.fallback = NULL, .fallback = &ui_emoji_20,
#endif #endif
.user_data = NULL, .user_data = NULL,
}; };
+9 -2
View File
@@ -28,24 +28,31 @@
// Fonts: generated Noto Sans (European Latin, Greek, Cyrillic + LV_SYMBOL_*), // Fonts: generated Noto Sans (European Latin, Greek, Cyrillic + LV_SYMBOL_*),
// see fonts/generate.sh. LVGL's built-in Montserrat (ASCII only) is off. // see fonts/generate.sh. LVGL's built-in Montserrat (ASCII only) is off.
// Each text font falls back to colour emoji (fonts/ui_emoji.c, Twemoji).
#define LV_FONT_MONTSERRAT_14 0 #define LV_FONT_MONTSERRAT_14 0
#define LV_FONT_CUSTOM_DECLARE LV_FONT_DECLARE(ui_font_12) LV_FONT_DECLARE(ui_font_14) \ #define LV_FONT_CUSTOM_DECLARE LV_FONT_DECLARE(ui_font_12) LV_FONT_DECLARE(ui_font_14) \
LV_FONT_DECLARE(ui_font_16) LV_FONT_DECLARE(ui_font_20) \ LV_FONT_DECLARE(ui_font_16) LV_FONT_DECLARE(ui_font_20) \
LV_FONT_DECLARE(ui_font_40) \ LV_FONT_DECLARE(ui_font_40) \
LV_FONT_DECLARE(ui_icons_14) LV_FONT_DECLARE(ui_icons_14) \
LV_FONT_DECLARE(ui_emoji_12) LV_FONT_DECLARE(ui_emoji_14) \
LV_FONT_DECLARE(ui_emoji_16) LV_FONT_DECLARE(ui_emoji_20)
// Image glyphs (the emoji) are drawn only with this on; lv_imgfont itself is unused.
#define LV_USE_IMGFONT 1
#define LV_FONT_DEFAULT &ui_font_14 #define LV_FONT_DEFAULT &ui_font_14
#define LV_USE_FONT_COMPRESSED 0 // fonts are generated uncompressed: no per-glyph decompression on every draw #define LV_USE_FONT_COMPRESSED 0 // fonts are generated uncompressed: no per-glyph decompression on every draw
// PNG decoder for raster map tiles (map/TileProvider.h calls lodepng directly). // PNG decoder for raster map tiles (map/TileProvider.h calls lodepng directly).
#define LV_USE_LODEPNG 1 #define LV_USE_LODEPNG 1
// Spans: message text with @[nick] mentions picked out (UITask.cpp msgText).
#define LV_USE_SPAN 1
#define LV_USE_THEME_DEFAULT 1 #define LV_USE_THEME_DEFAULT 1
#define LV_THEME_DEFAULT_DARK 1 #define LV_THEME_DEFAULT_DARK 1
// Not needed by the frontend (smaller build). // Not needed by the frontend (smaller build).
#define LV_USE_CHART 0 #define LV_USE_CHART 0
#define LV_USE_CALENDAR 0 #define LV_USE_CALENDAR 0
#define LV_USE_SPAN 0
#define LV_USE_TABLE 0 #define LV_USE_TABLE 0
#define LV_USE_SCALE 0 #define LV_USE_SCALE 0
#define LV_USE_LOTTIE 0 #define LV_USE_LOTTIE 0