Files
MeshCore-Solo/examples/companion_radio/ui-new/FullscreenMsgView.h
T
JakubandClaude Opus 5 b36cc7730b fix(ui): honour newlines in message text instead of overdrawing lines
A message containing a line break drew two words on top of each other in
the fullscreen reader. wrapLines() treated '\n' as an ordinary character:
it measured it via getCodepointWidth() -- which reports a full 6px cell
for it, since 0x0A sits below the font's first glyph -- and copied it into
the wrapped line. Both display drivers' print() then acts on '\n' by
resetting the cursor to x=0 and stepping down one row, so the tail of that
line was drawn straight over the following one.

wrapLines() now ends the line at '\n'/'\r' (CRLF counts as one break) and
consumes the byte rather than emitting it, preserving blank lines the
sender typed while still skipping degenerate empty wrap segments so the
loop can't stall. This covers the fullscreen view and the history list's
portrait bubbles, which share the function.

drawTextEllipsized() folds newlines into spaces for the same reason: it
draws one line clipped to max_width, and the compact one-line message
previews in the landscape list feed it raw message bodies. A space keeps
the words apart and measures the same, so the ellipsis maths is unchanged;
for names and labels it's a no-op.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 13:54:34 +02:00

182 lines
7.6 KiB
C

#pragma once
#include <helpers/ui/DisplayDriver.h>
#include <Arduino.h>
#include "icons.h"
static const int FS_CHARS_MAX = 80; // max bytes per wrapped line
// Shared, non-reentrant wrap scratch. The UI renders on a single thread, and the
// fullscreen message view and the history list are never laid out in the same
// frame (opening the fullscreen view early-returns before the list loop runs),
// so one static buffer serves both. This keeps ~1.5 KB of line buffers off the
// render-call stack at the cost of a fixed RAM allocation. Only used inside
// render()/wrap helpers — never across a yield, so the single instance is safe.
static char s_wrap_trans[512];
static char s_wrap_lines[12][FS_CHARS_MAX];
// Parse a leading "@[nick] " reply prefix. Returns the message body that
// follows it (and any leading whitespace); when nick/nick_n are supplied,
// fills nick with the addressee, or "" when there's no prefix. One parser for
// both the history list (body only — see MessagesScreen::skipReplyPrefix) and
// the fullscreen view (which also shows the "To:" nick).
static inline const char* msgReplyBody(const char* text, char* nick = nullptr, int nick_n = 0) {
if (nick && nick_n > 0) nick[0] = '\0';
const char* body = text;
if (text[0] == '@' && text[1] == '[') {
const char* close = strchr(text + 2, ']');
if (close && close[1] == ' ' && close[2]) {
if (nick && nick_n > 0) {
int len = (int)(close - text) - 2;
if (len > nick_n - 1) len = nick_n - 1;
memcpy(nick, text + 2, len);
nick[len] = '\0';
}
body = close + 2;
}
}
while (*body == '\n' || *body == '\r' || *body == ' ') body++;
return body;
}
struct FullscreenMsgView {
int scroll;
bool active;
int _max_scroll; // last value computed in render(); bounds KEY_DOWN
FullscreenMsgView() : scroll(0), active(false), _max_scroll(0) {}
enum Result { NONE, PREV, NEXT, CLOSE, REPLY };
void begin() { scroll = 0; active = true; }
// Pixel-accurate word-wrap: breaks at last space that fits within max_px,
// hard-breaks long words, and honours newlines the sender put in the text.
// Walks the string codepoint-by-codepoint, summing per-codepoint widths via
// DisplayDriver::getCodepointWidth — O(n) instead of O(n²) substring
// re-measurement. Works for both fixed- and variable-width fonts.
static int wrapLines(DisplayDriver& display, const char* text, int max_px,
char out[][FS_CHARS_MAX], int max_lines) {
int count = 0;
const uint8_t* seg = (const uint8_t*)text;
while (*seg && count < max_lines) {
const uint8_t* p = seg;
const uint8_t* last_sp = nullptr; // pointer to space byte (cut here, drop the space)
const uint8_t* last_fit = seg; // farthest point that still fits
const uint8_t* brk = nullptr; // explicit newline that ends this line
int width = 0;
while (*p && (p - seg) < FS_CHARS_MAX - 1) {
const uint8_t* cp_start = p;
uint32_t cp = DisplayDriver::decodeCodepoint(p);
if ((p - seg) >= FS_CHARS_MAX) { p = cp_start; break; }
// A newline in the message ends the line right here, and is consumed
// rather than copied out. Letting one through was drawing two words on
// top of each other: both display drivers' print() acts on '\n' by
// resetting the cursor to x=0 and stepping down one row, so the rest of
// this wrapped line landed on the following one. Measuring it is just
// as wrong — it has no ink, but glyphXAdvance() reports a full 6px cell
// for it (it's below the font's first codepoint), so it also ate width.
if (cp == '\n' || cp == '\r') { brk = cp_start; break; }
uint16_t cw = display.getCodepointWidth(cp);
if (width + cw > max_px && cp_start > seg) { p = cp_start; break; }
if (cp == ' ') last_sp = cp_start;
width += cw;
last_fit = p;
}
const uint8_t* end;
const uint8_t* next_seg;
if (brk) {
end = brk;
next_seg = brk + 1;
if (*brk == '\r' && *next_seg == '\n') next_seg++; // CRLF is one break, not two
} else if (!*p) {
end = p; next_seg = p;
} else if (last_sp && last_sp > seg) {
end = last_sp; next_seg = last_sp + 1;
} else {
end = last_fit; next_seg = last_fit;
}
int len = (int)(end - seg);
// An empty line is only emitted for a real newline (a blank line the
// sender typed); an empty wrap segment would loop forever, so skip it.
if (len <= 0 && !brk) { seg = *seg ? seg + 1 : seg; continue; }
if (len < 0) len = 0;
if (len > FS_CHARS_MAX - 1) len = FS_CHARS_MAX - 1;
memcpy(out[count], seg, len);
out[count][len] = '\0';
count++;
seg = next_seg;
}
return count;
}
int render(DisplayDriver& display, const char* sender, const char* text,
bool has_prev, bool has_next) {
display.setTextSize(1);
const int lineH = display.getLineHeight();
const int max_px = display.width() - 6;
// "@[nick] " reply prefix → "To:" header + body (shared parser).
char to_nick[32];
const char* body = msgReplyBody(text, to_nick, sizeof(to_nick));
const int cw = display.getCharWidth();
const int header_h = to_nick[0] ? (lineH * 2 + 4) : (lineH + 2);
const int startY = header_h + 2;
const int visible = (display.height() - startY - lineH) / lineH;
display.setColor(DisplayDriver::LIGHT);
display.fillRect(0, 0, display.width(), header_h);
display.setColor(DisplayDriver::DARK);
display.drawTextEllipsized(2, 1, display.width() - 4, sender);
if (to_nick[0]) {
char trans_nick[32], to_label[36];
display.translateUTF8ToBlocks(trans_nick, to_nick, sizeof(trans_nick));
snprintf(to_label, sizeof(to_label), "To: %s", trans_nick);
display.drawTextEllipsized(2, lineH + 3, display.width() - 4, to_label);
}
display.setColor(DisplayDriver::LIGHT);
display.translateUTF8ToBlocks(s_wrap_trans, body, sizeof(s_wrap_trans));
int lcount = wrapLines(display, s_wrap_trans, max_px, s_wrap_lines, 12);
// Reserve the right-edge column for the scroll indicator and re-wrap so text
// can't run under it. Reserve appears only once the list overflows, and a
// narrower second pass only ever yields more lines — so it stays overflowing.
int reserve = scrollIndicatorReserve(display, lcount, visible);
if (reserve > 0)
lcount = wrapLines(display, s_wrap_trans, max_px - reserve, s_wrap_lines, 12);
int max_scroll = lcount > visible ? lcount - visible : 0;
_max_scroll = max_scroll;
if (scroll > max_scroll) scroll = max_scroll;
for (int i = 0; i < visible && (scroll + i) < lcount; i++) {
display.setCursor(0, startY + i * lineH);
display.print(s_wrap_lines[scroll + i]);
}
drawScrollIndicator(display, startY, visible * lineH, lcount, visible, scroll);
const int nav_y = display.height() - lineH;
if (has_next) {
display.setCursor(0, nav_y);
display.print("<");
}
if (has_prev) {
display.setCursor(display.width() - cw, nav_y);
display.print(">");
}
return 2000;
}
Result handleInput(char c) {
if (c == KEY_UP) { if (scroll > 0) scroll--; return NONE; }
if (c == KEY_DOWN) { if (scroll < _max_scroll) scroll++; return NONE; }
if (keyIsPrev(c)) return NEXT; // page between messages (encoder too)
if (keyIsNext(c)) return PREV;
if (c == KEY_CONTEXT_MENU) return REPLY;
if (c == KEY_ENTER || c == KEY_CANCEL) return CLOSE;
return NONE;
}
};