mirror of
https://github.com/MarekZegare4/MeshCore-Solo.git
synced 2026-10-09 03:06:39 +00:00
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:
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user