From 6c9186dc54c43f9b7d0729128889cec0a7ca95a2 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Wed, 7 Oct 2026 22:58:01 +0200 Subject: [PATCH] feat: a message's bytes left, counted as it will be sent, on every screen The limit is what the send leaves of MAX_TEXT_LEN: a channel post carries ": ", a direct message resent more than three times keeps two bytes spare, and placeholders count as they expand (msgtext::limit/sentLen). Bytes, not characters. The path and packet header sit outside the payload; static_asserts keep a full message within the radio frame on the longest path. From 40 bytes left the count shows: on the L2 as a tag on the field's top edge, on OLED/e-ink at the right of the preview's last line with the text wrapped short of it. Input stops at the limit; a send still over (a placeholder grew) is held. Sim hooks to open a compose with text. Co-Authored-By: Claude Opus 5.5 --- .../companion_radio/ui-core/MessageText.h | 39 ++++++++++++ examples/companion_radio/ui-lvgl/UITask.cpp | 53 +++++++++++++++-- examples/companion_radio/ui-lvgl/UITask.h | 3 + .../companion_radio/ui-new/KeyboardWidget.h | 59 ++++++++++++++++++- .../companion_radio/ui-new/MessagesScreen.h | 19 +++++- examples/companion_radio/ui-new/UITask.cpp | 12 ++++ examples/companion_radio/ui-new/UITask.h | 3 + 7 files changed, 179 insertions(+), 9 deletions(-) diff --git a/examples/companion_radio/ui-core/MessageText.h b/examples/companion_radio/ui-core/MessageText.h index e33ffa77..c7dd2364 100644 --- a/examples/companion_radio/ui-core/MessageText.h +++ b/examples/companion_radio/ui-core/MessageText.h @@ -105,4 +105,43 @@ static void expandOutgoing(const char* text, char* out, int n, const NodePrefs* expand(text + pre, out + pre, n - pre, p); } +// ── What fits ───────────────────────────────────────────────────────────────── + +// The bytes a typed message takes over the air once its placeholders are +// replaced (a {loc} is longer than it looks). +static int sentLen(const char* typed, const NodePrefs* p) { + if (!strchr(typed, '{')) return (int)strlen(typed); + char out[2 * MAX_TEXT_LEN + 1]; + expandOutgoing(typed, out, sizeof(out), p); + return (int)strlen(out); +} + +// What a message to a channel / a contact may take, in bytes (not characters: +// a Cyrillic or accented letter is two). MAX_TEXT_LEN less what the send adds: +// a channel post goes out as ": "; a direct message +// that may be resent more than three times keeps two bytes spare (the resends' +// own limit, BaseChatMesh::composeMsgPacket). +// +// The route and the packet's own bytes do not come out of it: the path, the +// header and the transport codes sit outside the payload, and MAX_TEXT_LEN is +// sized so the worst packet (a 64-byte path, scoped, text padded to the cipher +// block) still fits the radio's MAX_TRANS_UNIT. Checked below, so a change to +// any of those constants cannot silently make a full message unsendable. +#define MSGTEXT_BLOCKS(n) (((n) + CIPHER_BLOCK_SIZE - 1) / CIPHER_BLOCK_SIZE * CIPHER_BLOCK_SIZE) +static_assert(1 + 4 + 1 + MAX_PATH_SIZE // header, transport codes, path_len, path + + 2 * PATH_HASH_SIZE + CIPHER_MAC_SIZE // direct: dest + src hash, MAC + + MSGTEXT_BLOCKS(5 + MAX_TEXT_LEN + 2) <= MAX_TRANS_UNIT, + "a full direct message must fit the radio frame with the longest path"); +static_assert(1 + 4 + 1 + MAX_PATH_SIZE + PATH_HASH_SIZE + CIPHER_MAC_SIZE + + MSGTEXT_BLOCKS(5 + MAX_TEXT_LEN) <= MAX_TRANS_UNIT, + "a full channel post must fit the radio frame with the longest path"); +#undef MSGTEXT_BLOCKS + +static int limit(bool channel, const NodePrefs* p) { + int n = MAX_TEXT_LEN; + if (channel) n -= (int)strlen(the_mesh.getNodeName()) + 2; + else if (p && p->dm_resend_count > 3) n -= 2; + return n; +} + } // namespace msgtext diff --git a/examples/companion_radio/ui-lvgl/UITask.cpp b/examples/companion_radio/ui-lvgl/UITask.cpp index 7c75c001..e37117cb 100644 --- a/examples/companion_radio/ui-lvgl/UITask.cpp +++ b/examples/companion_radio/ui-lvgl/UITask.cpp @@ -3551,18 +3551,42 @@ static void onKeyboard(lv_event_t* e) { else if (code == LV_EVENT_CANCEL) s_ui->setKeyboardVisible(false); } -// Compose limit is in UTF-8 bytes (the over-the-air limit), not characters: -// Cyrillic / Greek / accented letters take two bytes each. Leaves room for the -// "Name: " prefix a channel send adds. -static const size_t COMPOSE_MAX_BYTES = MAX_TEXT_LEN - 40; +// What a message may take is counted in UTF-8 bytes (the over-the-air limit), +// not characters: Cyrillic / Greek / accented letters take two bytes each. The +// limit is what the send leaves of it -- a channel post carries this node's +// name -- and a placeholder counts as what it expands to (MessageText.h). +int UITask::composeLimit() const { return msgtext::limit(_thread_is_channel, _prefs); } +int UITask::composeLen(const char* typed) const { return msgtext::sentLen(typed, _prefs); } + +static lv_obj_t* s_compose_left = nullptr; // the bytes left, a tag over the field's right end + +// Only once the end is near (40 bytes, as on the other screens): a tag sitting +// on the field's top edge, so the field keeps its width and its text. +static void composeLeftRefresh(lv_obj_t* ta) { + if (!s_compose_left || !ta) return; + const int left = s_ui->composeLimit() - s_ui->composeLen(lv_textarea_get_text(ta)); + if (left > 40) { lv_obj_add_flag(s_compose_left, LV_OBJ_FLAG_HIDDEN); return; } + char t[8]; + snprintf(t, sizeof(t), "%d", left); + setText(s_compose_left, t); + setTextColor(s_compose_left, left < 0 ? theme::FAIL : left <= 20 ? theme::ACCENT : theme::TEXT_MUTED); + lv_obj_remove_flag(s_compose_left, LV_OBJ_FLAG_HIDDEN); + lv_obj_update_layout(s_compose_left); + lv_obj_align_to(s_compose_left, ta, LV_ALIGN_OUT_TOP_RIGHT, -8, lv_obj_get_height(s_compose_left) / 2); +} static void onComposeInsert(lv_event_t* e) { lv_obj_t* ta = (lv_obj_t*)lv_event_get_target(e); const char* ins = (const char*)lv_event_get_param(e); - if (ins && strlen(lv_textarea_get_text(ta)) + strlen(ins) > COMPOSE_MAX_BYTES) + if (!ins) return; + char joined[2 * MAX_TEXT_LEN + 1]; // the text with the insert (at the end: the length is the same) + snprintf(joined, sizeof(joined), "%s%s", lv_textarea_get_text(ta), ins); + if (s_ui->composeLen(joined) > s_ui->composeLimit()) lv_textarea_set_insert_replace(ta, ""); // reject: would exceed the byte limit } +static void onComposeChanged(lv_event_t* e) { composeLeftRefresh((lv_obj_t*)lv_event_get_target(e)); } + static void onComposeClicked(lv_event_t* e) { (void)e; s_ui->setKeyboardVisible(true); } static void onComposeMore(lv_event_t* e) { (void)e; s_ui->quickPopup(); } @@ -3640,6 +3664,7 @@ void UITask::buildThread() { _compose_ta = nullptr; _keyboard = nullptr; + s_compose_left = nullptr; if (can_send) { lv_obj_t* bar = lv_obj_create(body); styleSurface(bar, theme::BG); @@ -3657,8 +3682,17 @@ void UITask::buildThread() { lv_obj_center(label(more, LV_SYMBOL_PLUS, THEME_FONT_BODY, theme::TEXT)); _compose_ta = textField(bar, "Message"); // FOCUSED while the keyboard is up lv_obj_add_event_cb(_compose_ta, onComposeInsert, LV_EVENT_INSERT, NULL); + lv_obj_add_event_cb(_compose_ta, onComposeChanged, LV_EVENT_VALUE_CHANGED, NULL); lv_obj_set_width(_compose_ta, lv_display_get_horizontal_resolution(NULL) - 8 - 34 - 4); // beside "+" lv_obj_align(_compose_ta, LV_ALIGN_RIGHT_MID, 0, 0); + lv_obj_add_flag(bar, LV_OBJ_FLAG_OVERFLOW_VISIBLE); // the count's tag rises above the bar + s_compose_left = label(bar, "", THEME_FONT_SMALL, theme::TEXT_MUTED); // bytes left + lv_obj_set_style_bg_color(s_compose_left, lv_color_hex(theme::BG), 0); + lv_obj_set_style_bg_opa(s_compose_left, LV_OPA_COVER, 0); + lv_obj_set_style_pad_hor(s_compose_left, 4, 0); + lv_obj_set_style_radius(s_compose_left, theme::RADIUS, 0); + lv_obj_add_flag(s_compose_left, LV_OBJ_FLAG_HIDDEN); + composeLeftRefresh(_compose_ta); // In the body's flex column below the compose bar: showing it shrinks the // message list, so the text field stays visible just above the keys. @@ -4157,6 +4191,7 @@ void UITask::sendFromCompose() { if (!typed || !typed[0]) return; char text[MSG_TEXT_BUF]; msgtext::expandOutgoing(typed, text, sizeof(text), _prefs); // {loc}, {time}, ... (MessageText.h) + if (composeLen(typed) > composeLimit()) { showToast("Too long"); return; } // the count beside the field says by how much if (!sendThreadText(text)) return; lv_textarea_set_text(_compose_ta, ""); setKeyboardVisible(false); @@ -4615,6 +4650,12 @@ bool UITask::cpuNeeded() { } #if defined(SIM_PLATFORM) && defined(__EMSCRIPTEN__) +void UITask::simCompose(const char* text) { + if (!_compose_ta) return; + setKeyboardVisible(true); + lv_textarea_set_text(_compose_ta, text); +} + // Sim tests: straight to a screen by name. extern "C" EMSCRIPTEN_KEEPALIVE void sim_open(const char* name) { if (!s_ui) return; @@ -4635,6 +4676,8 @@ extern "C" EMSCRIPTEN_KEEPALIVE void sim_open(const char* name) { if (!strcmp(name, "homeedit")) { s_ui->showHome(); s_ui->homeEdit(true); return; } // arranging the apps if (!strncmp(name, "accent@", 7)) { theme::setAccent(atoi(name + 7)); return; } // "accent@4": Cyan if (!strncmp(name, "map@", 4)) { s_ui->simMapAt(name + 4); return; } // "map@lat,lon,z" + if (!strncmp(name, "channel@", 8)) { s_ui->openChannel(atoi(name + 8)); return; } + if (!strncmp(name, "compose@", 8)) { s_ui->simCompose(name + 8); return; } if (!strcmp(name, "vector")) { s_ui->setVectorMap(true); return; } if (!strcmp(name, "maptools")) { s_ui->navToolsPopup(); return; } if (!strcmp(name, "areasel")) { s_ui->areaSelectBegin(); return; } diff --git a/examples/companion_radio/ui-lvgl/UITask.h b/examples/companion_radio/ui-lvgl/UITask.h index 0dc98558..5c85adfa 100644 --- a/examples/companion_radio/ui-lvgl/UITask.h +++ b/examples/companion_radio/ui-lvgl/UITask.h @@ -128,6 +128,7 @@ public: void setTrails(bool on); void setVectorMap(bool on); void simMapAt(const char* spec); // Map tools > Live tiles + void simCompose(const char* text); // the open thread's field: this text, keyboard up // Navigation map (NavMap.h) void navTargetsPopup(); void navClosePopup(); @@ -169,6 +170,8 @@ public: void bannerOpen(); // the message banner tapped: its conversation void back(); void sendFromCompose(); + int composeLimit() const; // bytes the open thread's message may take + int composeLen(const char* typed) const; // what `typed` takes once sent void setKeyboardVisible(bool show); void showToast(const char* text, uint32_t ms = 2500); // Clock tools (ClockScreen.h) diff --git a/examples/companion_radio/ui-new/KeyboardWidget.h b/examples/companion_radio/ui-new/KeyboardWidget.h index 72be5af5..8b80e1a6 100644 --- a/examples/companion_radio/ui-new/KeyboardWidget.h +++ b/examples/companion_radio/ui-new/KeyboardWidget.h @@ -112,6 +112,7 @@ static const char* const KB_T9_GROUPS_GREEK[9] = { // (10*CIPHER_BLOCK_SIZE = 160) so a full-length message can be composed; each // field passes its own smaller max to begin() where its store is smaller. static const int KB_MAX_LEN = 160; +static const int KB_COUNT_FROM = 40; // a message's bytes left show from here on (the L2's too) // Longest preview line we render per row, in CHARACTERS. Caps the per-line // stack buffers so a very wide display (small font → many chars per line) @@ -275,6 +276,13 @@ struct KeyboardWidget { // the right of the preview; set it after begin(), which clears it. Kept past // the PIN pad's ABC switch. const char* prompt = nullptr; + // What the message may take once sent (a channel's "Name: " comes out of it, + // a {loc} grows): sent_limit 0 = max_len as typed. sent_len measures `buf` + // as it will go out; both are set by the owner after begin(). Input stops + // there, and the bytes left show near it (see render()). + int sent_limit = 0; + int (*sent_len)(const char* buf, void* ctx) = nullptr; + void* sent_ctx = nullptr; static const int PIN_MAX_LEN = 16; int row, col; int page; // see totalPages()/scriptAt()/pageIsSymbols() below @@ -420,6 +428,9 @@ struct KeyboardWidget { pin_kb_mask_enabled = false; pin_mode = false; prompt = nullptr; + sent_limit = 0; + sent_len = nullptr; + sent_ctx = nullptr; t9_cell = -1; t9_cycle = 0; _ph_menu.active = false; @@ -599,8 +610,19 @@ struct KeyboardWidget { // works in codepoints via the kbUtf8*() helpers; this was the last // byte-based holdout. int cpl = display.width() / cw; // chars per preview line - if (cpl < 1) cpl = 1; if (cpl > KB_PREVIEW_CAP) cpl = KB_PREVIEW_CAP; // never overrun linebuf below + // A message near its end: the bytes left at the right of the preview's last + // line, the text wrapped short of it so the count is always there. + char count[8]; + count[0] = '\0'; + if (!isPin() && sent_limit > 0) { + const int n = sent_limit - sentNow(); + if (n <= KB_COUNT_FROM) { + snprintf(count, sizeof(count), "%d", n); + cpl -= (int)strlen(count) + 2; // its digits, the cursor, a space + } + } + if (cpl < 1) cpl = 1; // Which preview line the cursor sits on = how many whole codepoints precede it. int cursor_chars = 0; for (int p = 0; p < cursor_pos; ) { p += kbUtf8CharBytesAt(buf, p, len); cursor_chars++; } @@ -658,12 +680,21 @@ struct KeyboardWidget { display.print(linebuf_t); ps = pe; } - if (!isPin()) { + if (count[0]) { + const int tw = display.getTextWidth(count), y = (prev_lines - 1) * lh; + if (count[0] == '-') { // over (a placeholder grew): inverted + display.fillRect(display.width() - tw - 2, y, tw + 2, lh); + display.setColor(DisplayDriver::DARK); + } + display.setCursor(display.width() - tw - (count[0] == '-' ? 1 : 0), y); + display.print(count); + display.setColor(DisplayDriver::LIGHT); + } else if (!isPin()) { // Bottom-right of the preview: what the field is, or near the limit how // much room is left. Only when it clears the text on that line. char left[12]; const char* tag = prompt; - if (max_len > 0 && len * 4 >= max_len * 3) { snprintf(left, sizeof(left), "%d left", max_len - len); tag = left; } + if (sent_limit <= 0 && max_len > 0 && len * 4 >= max_len * 3) { snprintf(left, sizeof(left), "%d left", max_len - len); tag = left; } if (tag) { int tw = display.getTextWidth(tag); // Codepoints on the last preview line, plus the cursor if it's there. @@ -901,7 +932,29 @@ struct KeyboardWidget { return true; } + // What buf takes once sent (sent_len set), or as typed. + int sentNow() { return sent_len ? sent_len(buf, sent_ctx) : len; } + + // A message never grows past what it may send (sent_limit, its placeholders + // as they expand): a key that would take it there is undone. Shortening is + // always let through, even while over (a {loc} that grew since). Result handleInput(char c) { + if (sent_limit <= 0 || isPin()) return handleKey(c); + char was[KB_MAX_LEN + 1]; + memcpy(was, buf, len + 1); + const int was_len = len, was_cur = cursor_pos, before = sentNow(); + Result r = handleKey(c); + const int after = sentNow(); + if (after > sent_limit && after > before) { + memcpy(buf, was, was_len + 1); + len = was_len; + cursor_pos = was_cur; + t9_cell = -1; // a T9 cycle would otherwise go on replacing the char before it + } + return r; + } + + Result handleKey(char c) { // placeholder overlay consumes all input if (_ph_menu.active) { auto res = _ph_menu.handleInput(c); diff --git a/examples/companion_radio/ui-new/MessagesScreen.h b/examples/companion_radio/ui-new/MessagesScreen.h index 198d7614..f4dc70a4 100644 --- a/examples/companion_radio/ui-new/MessagesScreen.h +++ b/examples/companion_radio/ui-new/MessagesScreen.h @@ -324,6 +324,11 @@ class MessagesScreen : public UIScreen { if (_sending_to_channel && the_mesh.getChannel(_sel_channel_idx, ch)) to = ch.name; snprintf(_kb_prompt, sizeof(_kb_prompt), "%.12s", to); _kb->prompt = _kb_prompt; + // The room it really has: this node's name rides a channel post, and the + // placeholders grow when sent. + _kb->sent_limit = msgtext::limit(_sending_to_channel, _task->getNodePrefs()); + _kb->sent_len = [](const char* b, void* t) { return msgtext::sentLen(b, ((UITask*)t)->getNodePrefs()); }; + _kb->sent_ctx = _task; } // Recipient chosen while sharing — open the keyboard with the prepared text. @@ -948,6 +953,15 @@ class MessagesScreen : public UIScreen { bool _ch_view_pending_rebuild = false; // tracks redraw after channel create/delete public: +#ifdef SIM_PLATFORM + // Sim tests: channel 0's keyboard holding `text`. + void simCompose(const char* text) { + _sel_channel_idx = 0; + snprintf(_share_text, sizeof(_share_text), "%s", text); + beginShareCompose(true); + } +#endif + MessagesScreen(UITask* task, KeyboardWidget* kb) : _task(task), _kb(kb), _phase(MODE_SELECT), _mode_sel(0), _contact_sel(0), _contact_scroll(0), _num_contacts(0), _room_mode(false), _login_mode(false), @@ -2448,13 +2462,16 @@ public: if (_kb->len > prefix_len) { // Expand only the body — prefix "@[nick] " is preserved verbatim, so a nick // that happens to contain a placeholder token isn't substituted. - char expanded[KB_MAX_LEN + 1]; + char expanded[2 * KB_MAX_LEN + 1]; if (prefix_len > 0) { memcpy(expanded, _kb->buf, prefix_len); expandMsg(_kb->buf + prefix_len, expanded + prefix_len, sizeof(expanded) - prefix_len); } else { expandMsg(_kb->buf, expanded, sizeof(expanded)); } + // Too long for what it adds (the keyboard's "N over" says by how much): it + // would be cut short, or refused, so it stays on the keyboard. + if ((int)strlen(expanded) > msgtext::limit(_sending_to_channel, _task->getNodePrefs())) return true; bool ok = sendText(expanded); afterSend(ok); } diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 50c82480..1f3eab21 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -2190,6 +2190,13 @@ void UITask::gotoMessagesScreen() { setCurrScreen(messages_screen); } +#ifdef SIM_PLATFORM +void UITask::simCompose(const char* text) { + gotoMessagesScreen(); + ((MessagesScreen*)messages_screen)->simCompose(text); +} +#endif + void UITask::openContactDM(const ContactInfo& ci) { ((MessagesScreen*)messages_screen)->reset(); ((MessagesScreen*)messages_screen)->enterDM(ci); @@ -2661,6 +2668,11 @@ extern "C" EMSCRIPTEN_KEEPALIVE void sim_enqueue_key(char c) { if (g_sim_ui_task_for_js) g_sim_ui_task_for_js->injectSimKey(c); } +// Sim tests: a channel post's keyboard, holding `text`. +extern "C" EMSCRIPTEN_KEEPALIVE void sim_compose(const char* text) { + if (g_sim_ui_task_for_js) g_sim_ui_task_for_js->simCompose(text); +} + // Long-press counterpart -- the host page's own press-and-hold timer (see // web/index.html/mesh.html) calls this instead of sim_enqueue_key() once a // button/key has been held past the same ~1000ms threshold every real diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index d959ade8..8d2ef1d5 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -274,6 +274,9 @@ public: void gotoHomeScreen() { setCurrScreen(home); } void gotoSettingsScreen(); void gotoMessagesScreen(); +#ifdef SIM_PLATFORM + void simCompose(const char* text); // sim tests: a channel post's keyboard holding `text` +#endif void openContactDM(const ContactInfo& ci); void openChannelHistory(uint8_t channel_idx); // Favourites dial: open a pinned channel void openRoomServer(const ContactInfo& ci); // Favourites dial: open a pinned room (logs in first)