diff --git a/docs/tdeck-feature-inventory.md b/docs/tdeck-feature-inventory.md index ad3fb6e..ae2c551 100644 --- a/docs/tdeck-feature-inventory.md +++ b/docs/tdeck-feature-inventory.md @@ -85,7 +85,7 @@ Status labels: | Lock screen | Functional | clock, battery, network icons, notification card | Add per-network badges once MeshCore is active. | | Home launcher | Partial | filtered app grid, Developer Mode hides Terminal by default, Messages unread counter badge, scanned local apps flow across paged 4x2 Home screens | V0.95: add full app launch/runtime integration; run hardware visual regression for badge layout. | | Unified inbox | Functional/Partial | Messages tabs, filters, unread highlighting, per-thread badges, mute indicator, channel tab | MeshCore filter is gated; finish hardware responsiveness pass. | -| Conversation view | Functional/Partial | compose, in-place draft text refresh, scroll-preserving chat rebuilds, bubbles, status colors, resend long-press, persisted sent-DM delivery metadata | Stock-device ACK/retry interop and hardware chat-log latency still need validation. | +| Conversation view | Functional/Partial | compose, in-place draft text refresh, scroll-preserving chat rebuilds, bubbles, status colors, resend long-press, persisted sent-DM delivery metadata, and display-safe chat rendering for malformed UTF-8/common emoji aliases | Stock-device ACK/retry interop, hardware chat-log latency, and broader visual emoji coverage still need validation. | | Meshtastic manager | Functional/Partial | identity card, virtualized node list, channels tab, separate USB and BLE companion toggles | Emergency channel row is disabled; BLE companion has Android connect/send/receive proof but still needs reconnect/disconnect soak. | | MeshCore manager | Prototype/Partial | "Coming soon" unless gate is flipped; deeper screen exists behind gate | Do not enable until MeshCore message path works. | | Contacts/detail | Functional/Partial | virtualized contacts list, add contact, messageable role check, bounded trace diagnostic for contact detail | MeshCore contacts locked; hardware long-list scroll and Trace-on-device need validation. | diff --git a/docs/tdeck-firmware-roadmap.md b/docs/tdeck-firmware-roadmap.md index e04f738..81b7b0f 100644 --- a/docs/tdeck-firmware-roadmap.md +++ b/docs/tdeck-firmware-roadmap.md @@ -48,7 +48,7 @@ These maintainer-provided beta labels are the canonical near-term sequence. The | V0.6 | MeshCore public chat and split airtime config | In progress - public chat send/receive hardware-verified; split-airtime serial dwell/switch smoke passed on COM8; packet-loss, latency, and real dual-network traffic soak still open | | V0.7 | MeshCore DMs and private chats | ✅ Encrypted DMs (X25519 ECDH + AES) send/receive hardware-verified against a real MeshCore peer | | V0.8 | MeshCore USB companion and MeshCore BLE companion | 🚧 Protocol foundation drafted; USB/BLE implementation still planned and not external-app compatible yet | -| V0.9 | Code review, optimization, and emoji polish | ⬜ Not started | +| V0.9 | Code review, optimization, and emoji polish | In progress - display-safe chat text and emoji aliases implemented in UI rendering | | V0.95 | Basic app SDK and infrastructure; Home UI supports adding apps and multiple home screens | 🚧 Local manifest scanner, Home paging, and detail shell started; runtime/catalog still TODO | | V0.96 | Upgraded Wi-Fi password storage | ✅ Implemented on T-Deck hardware: credentials use ESP32 NVS, legacy `wifi.cfg` migrates/removes, and diagnostics do not print passwords | @@ -232,6 +232,9 @@ Deliverables: for Data, POSITION, and TELEMETRY decoders. - Clean up dead demo data and stale comments that no longer match product state. - Add basic emoji rendering/input support appropriate for the T-Deck screen and memory budget. + Initial UI slice implemented: Messages, conversation bubbles, draft text, and + lock-screen notifications now render through a bounded display-safe text path + that aliases common emoji and folds malformed UTF-8 before LVGL measures text. - Re-run hardware dogfood tests on Meshtastic-only, MeshCore-only, and split-airtime modes. Exit criteria: diff --git a/sim/main_sim.c b/sim/main_sim.c index 3073a12..8a33f31 100644 --- a/sim/main_sim.c +++ b/sim/main_sim.c @@ -497,6 +497,14 @@ static int shots(const char *dir) pump(60); snprintf(path, sizeof path, "%s/17-convo-draft.bmp", dir); write_bmp(path); printf("wrote %s\n", path); + { + const char safe_draft[] = "ETA 12 " "\xF0\x9F\x94\xA5" " near waypoint, bring " "\xF0\x9F\x94\x8B" " pack"; + snprintf(S.draft, sizeof S.draft, "%s", safe_draft); + lz_convo_draft_refresh(); + pump(60); + snprintf(path, sizeof path, "%s/17b-convo-safe-draft.bmp", dir); + write_bmp(path); printf("wrote %s\n", path); + } lz_ui_key(LZ_K_ENTER, 0); pump(60); snprintf(path, sizeof path, "%s/18-convo-sent.bmp", dir); @@ -635,6 +643,47 @@ static int codec_selftest(void) /* 1. default LongFast channel hash must be 0x08 */ CHECK(mt_channel_hash() == 0x08, "channel hash(LongFast,defaultPSK) == 0x08"); + /* UI chat labels only receive display-safe text: protocol/storage bytes are + * left alone, but invalid UTF-8 and unsupported glyphs are folded before + * LVGL measures or renders them. */ + { + char out[48]; + lz_text_safe_copy(out, sizeof out, "plain text", LZ_TEXT_SAFE_LINE); + CHECK(strcmp(out, "plain text") == 0, "display text leaves ASCII unchanged"); + lz_text_safe_copy(out, sizeof out, "one\ntwo\tthree", LZ_TEXT_SAFE_LINE); + CHECK(strcmp(out, "one two three") == 0, "display line text folds controls"); + lz_text_safe_copy(out, sizeof out, "one\ntwo", LZ_TEXT_SAFE_BLOCK); + CHECK(strcmp(out, "one\ntwo") == 0, "display block text preserves newlines"); + lz_text_safe_copy(out, sizeof out, + "rx " "\xF0\x9F\x94\xA5" " " "\xE2\x9C\x85", + LZ_TEXT_SAFE_LINE); + CHECK(strcmp(out, "rx fire OK") == 0, "display text aliases common emoji"); + lz_text_safe_copy(out, sizeof out, "copy " "\xE2\x80\x94" " 73", + LZ_TEXT_SAFE_LINE); + CHECK(strcmp(out, "copy - 73") == 0, "display text aliases punctuation"); + { + const char bad[] = { 'b', 'a', 'd', ' ', (char)0xF0, (char)0x28, + (char)0x8C, (char)0x28, 0 }; + bool ok = true; + lz_text_safe_copy(out, sizeof out, bad, LZ_TEXT_SAFE_LINE); + for(size_t i = 0; out[i]; i++) { + unsigned char c = (unsigned char)out[i]; + if(c >= 0x80u || c < 32u || c == 127u) ok = false; + } + CHECK(ok && strstr(out, "?") != NULL, + "display text replaces malformed UTF-8"); + } + { + char small[5]; + size_t n = lz_text_safe_copy(small, sizeof small, "abcdef", + LZ_TEXT_SAFE_LINE); + CHECK(n == 4 && strcmp(small, "abcd") == 0, + "display text stays nul-terminated under cap"); + } + lz_text_safe_copy(out, sizeof out, NULL, LZ_TEXT_SAFE_LINE); + CHECK(out[0] == 0, "display text accepts null input"); + } + /* 2. text frame round-trip: build -> parse header -> decrypt -> decode */ const char *msg = "hello mesh, this is limitlezzOS"; uint32_t from = 0x7c3af1d0, to = 0xa1b2c3d4, id = 0x12345678; diff --git a/src/ui/screens/scr_lock_home.c b/src/ui/screens/scr_lock_home.c index 05dc56d..1710abe 100644 --- a/src/ui/screens/scr_lock_home.c +++ b/src/ui/screens/scr_lock_home.c @@ -105,14 +105,18 @@ void lz_scr_lock(lv_obj_t *root) lv_obj_set_flex_align(hrow, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); lv_obj_set_style_pad_column(hrow, 6, 0); lz_icon(hrow, LZ_I_FORUM, &lz_icons_14, LZ_MINT); - lv_obj_t *nm = lz_text(hrow, g_notif_t->name, LZ_F_BODY, LZ_TEXT); + char safe_name[48]; + char safe_last[96]; + lz_text_safe_copy(safe_name, sizeof safe_name, g_notif_t->name, LZ_TEXT_SAFE_LINE); + lz_text_safe_copy(safe_last, sizeof safe_last, g_notif_t->last_text, LZ_TEXT_SAFE_LINE); + lv_obj_t *nm = lz_text(hrow, safe_name, LZ_F_BODY, LZ_TEXT); lv_obj_set_flex_grow(nm, 1); lv_label_set_long_mode(nm, LV_LABEL_LONG_DOT); if(g_notif_t->unread > 1) { char cnt[8]; snprintf(cnt, sizeof cnt, "%d", g_notif_t->unread); lz_text(hrow, cnt, LZ_F_SMALL, LZ_MINT); } - lv_obj_t *snip = lz_text(card, g_notif_t->last_text, LZ_F_SMALL, lv_color_hex(0xCFD4DA)); + lv_obj_t *snip = lz_text(card, safe_last, LZ_F_SMALL, lv_color_hex(0xCFD4DA)); lv_obj_set_width(snip, lv_pct(100)); lv_label_set_long_mode(snip, LV_LABEL_LONG_DOT); lz_on_click(card, notif_tap); diff --git a/src/ui/screens/scr_messages.c b/src/ui/screens/scr_messages.c index a3f52d5..d6e187b 100644 --- a/src/ui/screens/scr_messages.c +++ b/src/ui/screens/scr_messages.c @@ -16,23 +16,14 @@ static void draft_label_text(const lz_thread_rt *t, char *out, size_t cap, bool bool has = S.draft[0] != 0; if(has_draft) *has_draft = has; if(!has) { - snprintf(out, cap, "Message %s...", t ? t->name : "thread"); + char name[40]; + lz_text_safe_copy(name, sizeof name, t ? t->name : "thread", LZ_TEXT_SAFE_LINE); + snprintf(out, cap, "Message %s...", name); return; } - snprintf(out, cap, "%s", S.draft); int maxw = LZ_W - 80; /* usable px inside the pill */ - lv_point_t sz; - lv_txt_get_size(&sz, S.draft, LZ_F_SMALL, 0, 0, LV_COORD_MAX, 0); - if(sz.x <= maxw) return; - - int start = 0; - while(S.draft[start]) { - lv_txt_get_size(&sz, S.draft + start, LZ_F_SMALL, 0, 0, LV_COORD_MAX, 0); - if(sz.x <= maxw - 12) break; /* leave room for the ellipsis */ - start++; - } - snprintf(out, cap, "...%s", S.draft + start); + lz_text_safe_tail_fit(out, cap, S.draft, LZ_F_SMALL, maxw, LZ_TEXT_SAFE_LINE); } bool lz_convo_draft_refresh(void) @@ -232,6 +223,10 @@ void lz_scr_messages(lv_obj_t *root) for(int i = 0; i < vis_thread_count; i++) { lz_thread_rt *t = vis_threads[i]; char ago[8]; lz_fmt_ago(t->last_ts, ago, sizeof ago); + char tname[48]; + char last_text[96]; + lz_text_safe_copy(tname, sizeof tname, t->name, LZ_TEXT_SAFE_LINE); + lz_text_safe_copy(last_text, sizeof last_text, t->last_text, LZ_TEXT_SAFE_LINE); bool unread = t->unread && !t->muted; lv_obj_t *row = lz_row(body, i == S.focus); lv_obj_set_style_radius(row, 11, 0); @@ -244,7 +239,7 @@ void lz_scr_messages(lv_obj_t *root) lv_obj_t *avwrap = lz_box(row); lv_obj_set_size(avwrap, 34, 34); lv_obj_t *av = lz_dot(avwrap, 33, lz_av_color(t->net)); - char initial[2] = { t->name[0], 0 }; + char initial[2] = { (tname[0] && tname[0] != ' ') ? tname[0] : '?', 0 }; lv_obj_t *ini = lz_text(av, initial, LZ_F_BODY, lv_color_white()); lv_obj_center(ini); lv_obj_t *ring = lz_dot(avwrap, 13, LZ_SCREEN_BG); @@ -264,7 +259,7 @@ void lz_scr_messages(lv_obj_t *root) lv_obj_set_height(top, LV_SIZE_CONTENT); lv_obj_set_flex_flow(top, LV_FLEX_FLOW_ROW); lv_obj_set_flex_align(top, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_t *name = lz_text(top, t->name, LZ_F_BODY, unread ? lv_color_hex(0xF2F4F6) : LZ_TEXT); + lv_obj_t *name = lz_text(top, tname, LZ_F_BODY, unread ? lv_color_hex(0xF2F4F6) : LZ_TEXT); lv_label_set_long_mode(name, LV_LABEL_LONG_DOT); lz_text(top, ago, LZ_F_SMALL, LZ_TEXT_META); @@ -273,7 +268,7 @@ void lz_scr_messages(lv_obj_t *root) lv_obj_set_height(bot, LV_SIZE_CONTENT); lv_obj_set_flex_flow(bot, LV_FLEX_FLOW_ROW); lv_obj_set_flex_align(bot, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_t *snip = lz_text(bot, t->last_text, LZ_F_SMALL, + lv_obj_t *snip = lz_text(bot, last_text, LZ_F_SMALL, unread ? lv_color_hex(0xCFD4DA) : lv_color_hex(0x838A93)); lv_label_set_long_mode(snip, LV_LABEL_LONG_DOT); lv_obj_set_flex_grow(snip, 1); @@ -331,6 +326,10 @@ void lz_scr_messages(lv_obj_t *root) for(int i = 0; i < vis_thread_count; i++) { lz_thread_rt *t = vis_threads[i]; char ago[8]; lz_fmt_ago(t->last_ts, ago, sizeof ago); + char tname[48]; + char last_text[96]; + lz_text_safe_copy(tname, sizeof tname, t->name, LZ_TEXT_SAFE_LINE); + lz_text_safe_copy(last_text, sizeof last_text, t->last_text, LZ_TEXT_SAFE_LINE); lv_obj_t *row = lz_row(body, i == S.focus); lv_obj_set_style_radius(row, 11, 0); lv_obj_add_event_cb(row, mute_longpress_cb, LV_EVENT_LONG_PRESSED, t); /* hold = silence */ @@ -355,10 +354,10 @@ void lz_scr_messages(lv_obj_t *root) lv_obj_set_height(top, LV_SIZE_CONTENT); lv_obj_set_flex_flow(top, LV_FLEX_FLOW_ROW); lv_obj_set_flex_align(top, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lz_text(top, t->name, LZ_F_BODY, LZ_TEXT); + lz_text(top, tname, LZ_F_BODY, LZ_TEXT); lz_text(top, t->last_ts ? ago : "", LZ_F_SMALL, LZ_TEXT_META); lz_text(colm, "Primary - broadcast to everyone", LZ_F_SMALL, lv_color_hex(0x838A93)); - lv_obj_t *last = lz_text(colm, t->last_text[0] ? t->last_text : "Tap to open the channel", + lv_obj_t *last = lz_text(colm, last_text[0] ? last_text : "Tap to open the channel", LZ_F_SMALL, LZ_TEXT_VALUE); lv_label_set_long_mode(last, LV_LABEL_LONG_DOT); lv_obj_set_width(last, lv_pct(100)); @@ -380,6 +379,12 @@ void lz_scr_convo(lv_obj_t *root) if(!t) t = lz_svc_thread_at(0); if(!t) return; lv_color_t net = lz_net_color(t->net); + char tname[48]; + char taddr[24]; + char tpath[20]; + lz_text_safe_copy(tname, sizeof tname, t->name, LZ_TEXT_SAFE_LINE); + lz_text_safe_copy(taddr, sizeof taddr, t->addr, LZ_TEXT_SAFE_LINE); + lz_text_safe_copy(tpath, sizeof tpath, t->path, LZ_TEXT_SAFE_LINE); lv_obj_set_flex_flow(root, LV_FLEX_FLOW_COLUMN); /* nav bar: name + network tag */ @@ -396,7 +401,7 @@ void lz_scr_convo(lv_obj_t *root) lv_obj_set_size(backhit, 64, LZ_NAVBAR_H); lv_obj_set_pos(backhit, 0, 0); lz_on_click(backhit, lz_back); - lv_obj_t *name = lz_text(bar, t->name, LZ_F_BODY, lv_color_hex(0xF2F4F6)); + lv_obj_t *name = lz_text(bar, tname, LZ_F_BODY, lv_color_hex(0xF2F4F6)); lv_obj_align(name, LV_ALIGN_TOP_MID, 0, 3); lv_obj_t *sub = lz_box(bar); lv_obj_set_size(sub, LV_SIZE_CONTENT, 10); @@ -405,7 +410,7 @@ void lz_scr_convo(lv_obj_t *root) lv_obj_set_style_pad_column(sub, 4, 0); lz_dot(sub, 5, net); lz_text(sub, lz_net_name(t->net), LZ_F_SMALL, net); - char pathb[24]; snprintf(pathb, sizeof pathb, "- %s", t->path); + char pathb[24]; snprintf(pathb, sizeof pathb, "- %s", tpath); lz_text(sub, pathb, LZ_F_SMALL, LZ_TEXT_META); lv_obj_align(sub, LV_ALIGN_BOTTOM_MID, 0, -1); @@ -428,7 +433,7 @@ void lz_scr_convo(lv_obj_t *root) lz_nav_set_scroll(body); char cap[64]; - snprintf(cap, sizeof cap, "Encrypted - %s - %s", lz_net_name(t->net), t->addr); + snprintf(cap, sizeof cap, "Encrypted - %s - %s", lz_net_name(t->net), taddr); lv_obj_t *caption = lz_text(body, cap, LZ_F_SMALL, lv_color_hex(0x5E656E)); lv_obj_set_width(caption, lv_pct(100)); lv_obj_set_style_text_align(caption, LV_TEXT_ALIGN_CENTER, 0); @@ -437,7 +442,8 @@ void lz_scr_convo(lv_obj_t *root) int total = lz_svc_tail(&msgs); for(int i = 0; i < total; i++) { bool self = msgs[i].self; - const char *txt = msgs[i].text; + char txt[sizeof msgs[i].text + 32]; + lz_text_safe_copy(txt, sizeof txt, msgs[i].text, LZ_TEXT_SAFE_BLOCK); /* row spanning the width; justify the bubble cluster right for self * (outgoing) and left for incoming — the standard messenger layout */ diff --git a/src/ui/ui.c b/src/ui/ui.c index 310784f..fcdfbb6 100644 --- a/src/ui/ui.c +++ b/src/ui/ui.c @@ -585,6 +585,178 @@ void lz_ui_key(lz_key_t k, char c) /* ================= shared widgets ================= */ +static void safe_append_char(char *out, size_t cap, size_t *len, char c) +{ + if(!out || cap == 0) return; + if(*len + 1 >= cap) return; + out[(*len)++] = c; + out[*len] = 0; +} + +static void safe_append_str(char *out, size_t cap, size_t *len, const char *s) +{ + if(!s) return; + while(*s) safe_append_char(out, cap, len, *s++); +} + +static bool utf8_decode_one(const unsigned char *s, uint32_t *cp, size_t *used) +{ + unsigned char c = s[0]; + *used = 1; + if(c < 0x80u) { *cp = c; return true; } + + if(c >= 0xC2u && c <= 0xDFu) { + if(!s[1] || (s[1] & 0xC0u) != 0x80u) return false; + *cp = ((uint32_t)(c & 0x1Fu) << 6) | (uint32_t)(s[1] & 0x3Fu); + *used = 2; + return true; + } + if(c == 0xE0u) { + if(!s[1] || !s[2] || s[1] < 0xA0u || s[1] > 0xBFu || + (s[2] & 0xC0u) != 0x80u) return false; + } else if(c >= 0xE1u && c <= 0xECu) { + if(!s[1] || !s[2] || (s[1] & 0xC0u) != 0x80u || + (s[2] & 0xC0u) != 0x80u) return false; + } else if(c == 0xEDu) { + if(!s[1] || !s[2] || s[1] < 0x80u || s[1] > 0x9Fu || + (s[2] & 0xC0u) != 0x80u) return false; + } else if(c >= 0xEEu && c <= 0xEFu) { + if(!s[1] || !s[2] || (s[1] & 0xC0u) != 0x80u || + (s[2] & 0xC0u) != 0x80u) return false; + } else if(c == 0xF0u) { + if(!s[1] || !s[2] || !s[3] || s[1] < 0x90u || s[1] > 0xBFu || + (s[2] & 0xC0u) != 0x80u || (s[3] & 0xC0u) != 0x80u) return false; + } else if(c >= 0xF1u && c <= 0xF3u) { + if(!s[1] || !s[2] || !s[3] || (s[1] & 0xC0u) != 0x80u || + (s[2] & 0xC0u) != 0x80u || (s[3] & 0xC0u) != 0x80u) return false; + } else if(c == 0xF4u) { + if(!s[1] || !s[2] || !s[3] || s[1] < 0x80u || s[1] > 0x8Fu || + (s[2] & 0xC0u) != 0x80u || (s[3] & 0xC0u) != 0x80u) return false; + } else { + return false; + } + + if(c < 0xF0u) { + *cp = ((uint32_t)(c & 0x0Fu) << 12) | + ((uint32_t)(s[1] & 0x3Fu) << 6) | + (uint32_t)(s[2] & 0x3Fu); + *used = 3; + return true; + } + + *cp = ((uint32_t)(c & 0x07u) << 18) | + ((uint32_t)(s[1] & 0x3Fu) << 12) | + ((uint32_t)(s[2] & 0x3Fu) << 6) | + (uint32_t)(s[3] & 0x3Fu); + *used = 4; + return *cp <= 0x10FFFFu; +} + +static const char *safe_alias(uint32_t cp) +{ + switch(cp) { + case 0x00A0u: return " "; + case 0x00B0u: return "deg"; + case 0x2013u: + case 0x2014u: return "-"; + case 0x2018u: + case 0x2019u: return "'"; + case 0x201Cu: + case 0x201Du: return "\""; + case 0x2022u: return "*"; + case 0x2026u: return "..."; + case 0x2032u: return "'"; + case 0x2033u: return "\""; + case 0x200Du: + case 0xFE0Eu: + case 0xFE0Fu: return ""; + case 0x26A0u: return "!"; + case 0x2705u: + case 0x2713u: + case 0x2714u: return "OK"; + case 0x1F44Du: return "ok"; + case 0x1F4ACu: return "msg"; + case 0x1F4CDu: return "pin"; + case 0x1F4E1u: return "radio"; + case 0x1F50Bu: return "bat"; + case 0x1F525u: return "fire"; + case 0x1F680u: return "go"; + default: break; + } + if((cp >= 0x0300u && cp <= 0x036Fu) || + (cp >= 0x1F3FBu && cp <= 0x1F3FFu)) + return ""; + return "*"; +} + +size_t lz_text_safe_copy(char *out, size_t cap, const char *raw, + lz_text_safe_mode_t mode) +{ + size_t len = 0; + if(!out || cap == 0) return 0; + out[0] = 0; + if(!raw) return 0; + + const unsigned char *p = (const unsigned char *)raw; + while(*p) { + unsigned char c = *p; + if(c < 0x80u) { + if(c >= 32u && c <= 126u) { + safe_append_char(out, cap, &len, (char)c); + } else if(mode == LZ_TEXT_SAFE_BLOCK && c == '\n') { + safe_append_char(out, cap, &len, '\n'); + } else if(c == '\t' || c == '\r' || c < 32u || c == 127u) { + safe_append_char(out, cap, &len, ' '); + } + p++; + continue; + } + + uint32_t cp = 0; + size_t used = 1; + if(utf8_decode_one(p, &cp, &used)) { + safe_append_str(out, cap, &len, safe_alias(cp)); + p += used; + } else { + safe_append_char(out, cap, &len, '?'); + p++; + } + } + return len; +} + +size_t lz_text_safe_tail_fit(char *out, size_t cap, const char *raw, + const lv_font_t *font, int max_px, + lz_text_safe_mode_t mode) +{ + char safe[192]; + if(!out || cap == 0) return 0; + lz_text_safe_copy(safe, sizeof safe, raw, mode); + if(!font || max_px <= 0) return lz_text_safe_copy(out, cap, safe, LZ_TEXT_SAFE_BLOCK); + + lv_point_t sz; + lv_txt_get_size(&sz, safe, font, 0, 0, LV_COORD_MAX, 0); + if(sz.x <= max_px) return lz_text_safe_copy(out, cap, safe, LZ_TEXT_SAFE_BLOCK); + + lv_point_t ell; + lv_txt_get_size(&ell, "...", font, 0, 0, LV_COORD_MAX, 0); + int fit_px = max_px - ell.x; + if(fit_px < 0) fit_px = 0; + + const char *start = safe; + while(*start) { + lv_txt_get_size(&sz, start, font, 0, 0, LV_COORD_MAX, 0); + if(sz.x <= fit_px) break; + start++; + } + + size_t len = 0; + out[0] = 0; + safe_append_str(out, cap, &len, "..."); + safe_append_str(out, cap, &len, start); + return len; +} + lv_obj_t *lz_box(lv_obj_t *parent) { lv_obj_t *o = lv_obj_create(parent); diff --git a/src/ui/ui.h b/src/ui/ui.h index 84128fc..cb03e5c 100644 --- a/src/ui/ui.h +++ b/src/ui/ui.h @@ -103,6 +103,16 @@ void lz_nav_track(lv_obj_t *obj, int idx); /* scrolled into view when focused void lz_on_click(lv_obj_t *obj, void (*fn)(void)); /* tap handler for chrome elements */ /* --- shared widgets / helpers --- */ +typedef enum { + LZ_TEXT_SAFE_LINE, + LZ_TEXT_SAFE_BLOCK +} lz_text_safe_mode_t; + +size_t lz_text_safe_copy(char *out, size_t cap, const char *raw, + lz_text_safe_mode_t mode); +size_t lz_text_safe_tail_fit(char *out, size_t cap, const char *raw, + const lv_font_t *font, int max_px, + lz_text_safe_mode_t mode); lv_obj_t *lz_box(lv_obj_t *parent); /* plain styleless container */ lv_obj_t *lz_text(lv_obj_t *parent, const char *txt, const lv_font_t *font, lv_color_t color); lv_obj_t *lz_icon(lv_obj_t *parent, const char *glyph, const lv_font_t *font, lv_color_t color);