mirror of
https://github.com/MarekZegare4/MeshCore-Solo.git
synced 2026-09-17 16:46:37 +00:00
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>
392 lines
17 KiB
C++
392 lines
17 KiB
C++
#pragma once
|
|
|
|
#include <stdint.h>
|
|
#include <string.h>
|
|
|
|
class DisplayDriver {
|
|
int _w, _h;
|
|
protected:
|
|
bool _vw_dirty = true;
|
|
bool _vw_result = false;
|
|
DisplayDriver(int w, int h) { _w = w; _h = h; }
|
|
void setDimensions(int w, int h) { _w = w; _h = h; }
|
|
public:
|
|
enum Color { DARK=0, LIGHT, RED, GREEN, BLUE, YELLOW, ORANGE }; // on b/w screen, colors will be !=0 synonym of light
|
|
|
|
int width() const { return _w; }
|
|
int height() const { return _h; }
|
|
|
|
virtual bool isOn() = 0;
|
|
virtual bool isEink() { return false; } // default to non-eink, override in eink drivers
|
|
virtual void turnOn() = 0;
|
|
virtual void turnOff() = 0;
|
|
virtual void clear() = 0;
|
|
virtual void startFrame(Color bkg = DARK) = 0;
|
|
virtual void setTextSize(int sz) = 0;
|
|
virtual void setColor(Color c) = 0;
|
|
virtual void setCursor(int x, int y) = 0;
|
|
virtual void print(const char* str) = 0;
|
|
virtual void printWordWrap(const char* str, int max_width) { print(str); } // fallback to basic print() if no override
|
|
virtual void fillRect(int x, int y, int w, int h) = 0;
|
|
virtual void drawRect(int x, int y, int w, int h) = 0;
|
|
virtual void drawXbm(int x, int y, const uint8_t* bits, int w, int h) = 0;
|
|
virtual uint16_t getTextWidth(const char* str) = 0;
|
|
virtual int getCharWidth() const { return 6; } // typical character advance width (px)
|
|
virtual int getLineHeight() const { return 8; } // pixel rows per text line
|
|
virtual void setSingleFont(bool) { } // no-op; both concrete drivers are permanently pinned to their one font
|
|
virtual bool isSingleFont() const { return false; }
|
|
// Layout helpers — derived from font metrics and screen size.
|
|
// Use these instead of hardcoded pixel values so layouts adapt to any display.
|
|
int lineStep() const { return getLineHeight() + 2; } // row pitch: text + gap
|
|
int headerH() const { return getLineHeight() + 3; } // title bar height
|
|
// y where list items begin: a 2px breathing gap below the header separator so
|
|
// the first row doesn't touch the line (matches the hand-rolled hdr+2 used by
|
|
// the graphical screens).
|
|
int listStart() const { return headerH() + 2; }
|
|
int listVisible(int itemH) const { return (height() - listStart()) / itemH; }
|
|
int listVisible() const { return listVisible(lineStep()); }
|
|
// x where a right-side value column starts (leaves ~8 chars for the value)
|
|
int valCol() const { return width() - getCharWidth() * 8; }
|
|
// true only on landscape e-ink; use instead of comparing pixel counts or getLineHeight()
|
|
#ifdef EINK_DISPLAY_MODEL
|
|
bool isLandscape() const { return width() >= height(); }
|
|
#else
|
|
bool isLandscape() const { return false; }
|
|
#endif
|
|
// separator line thickness: 2px on landscape e-ink, 1px everywhere else
|
|
int sepH() const { return isLandscape() ? 2 : 1; }
|
|
virtual void drawTextCentered(int mid_x, int y, const char* str) {
|
|
char tmp[256]; translateUTF8ToBlocks(tmp, str, sizeof(tmp));
|
|
int w = getTextWidth(tmp);
|
|
setCursor(mid_x - w/2, y);
|
|
print(tmp);
|
|
}
|
|
virtual void drawTextRightAlign(int x_anch, int y, const char* str) {
|
|
char tmp[256]; translateUTF8ToBlocks(tmp, str, sizeof(tmp));
|
|
int w = getTextWidth(tmp);
|
|
setCursor(x_anch - w, y);
|
|
print(tmp);
|
|
}
|
|
virtual void drawTextLeftAlign(int x_anch, int y, const char* str) {
|
|
char tmp[256]; translateUTF8ToBlocks(tmp, str, sizeof(tmp));
|
|
setCursor(x_anch, y);
|
|
print(tmp);
|
|
}
|
|
|
|
// Sorted-codepoint → ASCII transliteration table.
|
|
// Binary search beats a ~110-case switch on flash (parallel arrays = 3 bytes/entry,
|
|
// no padding) and gives O(log n) lookup. Covers Latin Extended A/B + common
|
|
// Latin-1 accented diacritics needed by EU languages and Turkish.
|
|
static char transliterateCodepoint(uint32_t cp) {
|
|
// Codepoints must stay sorted ascending — binary search assumes it.
|
|
static const uint16_t CPS[] = {
|
|
0x00C0, 0x00C1, 0x00C2, 0x00C3, 0x00C4, 0x00C5, 0x00C6, 0x00C7,
|
|
0x00C8, 0x00C9, 0x00CA, 0x00CB, 0x00CC, 0x00CD, 0x00CE, 0x00CF,
|
|
0x00D0, 0x00D1, 0x00D2, 0x00D3, 0x00D4, 0x00D5, 0x00D6, 0x00D8,
|
|
0x00D9, 0x00DA, 0x00DB, 0x00DC, 0x00DD, 0x00DE, 0x00DF,
|
|
0x00E0, 0x00E1, 0x00E2, 0x00E3, 0x00E4, 0x00E5, 0x00E6, 0x00E7,
|
|
0x00E8, 0x00E9, 0x00EA, 0x00EB, 0x00EC, 0x00ED, 0x00EE, 0x00EF,
|
|
0x00F0, 0x00F1, 0x00F2, 0x00F3, 0x00F4, 0x00F5, 0x00F6, 0x00F8,
|
|
0x00F9, 0x00FA, 0x00FB, 0x00FC, 0x00FD, 0x00FE,
|
|
0x0100, 0x0101, 0x0102, 0x0103, 0x0104, 0x0105, 0x0106, 0x0107,
|
|
0x010C, 0x010D, 0x010E, 0x010F, 0x0110, 0x0111, 0x0112, 0x0113,
|
|
0x0116, 0x0117, 0x0118, 0x0119, 0x011A, 0x011B, 0x011E, 0x011F,
|
|
0x0122, 0x0123, 0x012A, 0x012B, 0x012E, 0x012F, 0x0131,
|
|
0x0136, 0x0137, 0x0139, 0x013A, 0x013B, 0x013C, 0x013D, 0x013E,
|
|
0x0141, 0x0142, 0x0143, 0x0144, 0x0145, 0x0146, 0x0147, 0x0148,
|
|
0x0150, 0x0151, 0x0154, 0x0155, 0x0156, 0x0157, 0x0158, 0x0159,
|
|
0x015A, 0x015B, 0x015E, 0x015F, 0x0160, 0x0161, 0x0162, 0x0163,
|
|
0x0164, 0x0165, 0x016A, 0x016B, 0x016E, 0x016F, 0x0170, 0x0171,
|
|
0x0172, 0x0173, 0x0179, 0x017A, 0x017B, 0x017C, 0x017D, 0x017E,
|
|
0x0218, 0x0219, 0x021A, 0x021B
|
|
};
|
|
static const char ASCII[] = {
|
|
'A','A','A','A','A','A','A','C',
|
|
'E','E','E','E','I','I','I','I',
|
|
'D','N','O','O','O','O','O','O',
|
|
'U','U','U','U','Y','T','s',
|
|
'a','a','a','a','a','a','a','c',
|
|
'e','e','e','e','i','i','i','i',
|
|
'd','n','o','o','o','o','o','o',
|
|
'u','u','u','u','y','t',
|
|
'A','a','A','a','A','a','C','c',
|
|
'C','c','D','d','D','d','E','e',
|
|
'E','e','E','e','E','e','G','g',
|
|
'G','g','I','i','I','i','i',
|
|
'K','k','L','l','L','l','L','l',
|
|
'L','l','N','n','N','n','N','n',
|
|
'O','o','R','r','R','r','R','r',
|
|
'S','s','S','s','S','s','T','t',
|
|
'T','t','U','u','U','u','U','u',
|
|
'U','u','Z','z','Z','z','Z','z',
|
|
'S','s','T','t'
|
|
};
|
|
static_assert(sizeof(CPS) / sizeof(CPS[0]) == sizeof(ASCII), "transliteration tables out of sync");
|
|
int lo = 0, hi = (int)(sizeof(ASCII)) - 1;
|
|
while (lo <= hi) {
|
|
int mid = (lo + hi) >> 1;
|
|
uint16_t v = CPS[mid];
|
|
if (v == cp) return ASCII[mid];
|
|
if (v < cp) lo = mid + 1; else hi = mid - 1;
|
|
}
|
|
return '\xDB';
|
|
}
|
|
|
|
// convert UTF-8 to ASCII, transliterating accented/diacritic characters (callable without a display instance)
|
|
static void translateUTF8Static(char* dest, const char* src, size_t dest_size) {
|
|
size_t j = 0;
|
|
const uint8_t* p = (const uint8_t*)src;
|
|
while (*p && j < dest_size - 1) {
|
|
if (*p < 0x80) {
|
|
uint8_t c = *p++;
|
|
if (c >= 32) dest[j++] = c;
|
|
} else {
|
|
uint32_t cp = decodeCodepoint(p);
|
|
// transliterateCodepoint already returns '\xDB' for unmapped values (incl. 0xFFFD).
|
|
dest[j++] = transliterateCodepoint(cp);
|
|
}
|
|
}
|
|
dest[j] = 0;
|
|
}
|
|
|
|
virtual void translateUTF8ToBlocks(char* dest, const char* src, size_t dest_size) {
|
|
translateUTF8Static(dest, src, dest_size);
|
|
}
|
|
|
|
// Common selection-row pattern: when sel, fills (x,y,w,h) with ink and sets
|
|
// colour to DARK so the next text render appears inverted; when not sel,
|
|
// just sets ink colour to LIGHT. Replaces the 5-line if/else copy-paste
|
|
// present in every list-style screen.
|
|
void drawSelectionRow(int x, int y, int w, int h, bool sel) {
|
|
setColor(LIGHT);
|
|
if (sel) {
|
|
fillRect(x, y, w, h);
|
|
setColor(DARK);
|
|
}
|
|
}
|
|
|
|
// Format a small unread count into buf: "1".."99", then "99+". count >= 1.
|
|
// No stdio — DisplayDriver.h only pulls stdint/string.
|
|
static void fmtBadgeCount(char* buf, int count) {
|
|
if (count > 99) { buf[0]='9'; buf[1]='9'; buf[2]='+'; buf[3]=0; }
|
|
else if (count >= 10) { buf[0]=(char)('0'+count/10); buf[1]=(char)('0'+count%10); buf[2]=0; }
|
|
else { buf[0]=(char)('0'+count); buf[1]=0; }
|
|
}
|
|
// Trailing blank column baked into a backend's own advance-based
|
|
// getTextWidth() (0 if it's already ink-tight). Adafruit_GFX's classic
|
|
// built-in font measures every character as a fixed 6px advance cell (5px
|
|
// glyph + 1px inter-character gap) regardless of the glyph actually drawn —
|
|
// so getTextWidth() always reports 1px more than the real ink, all of it
|
|
// trailing the last character. Centring on the raw width then leaves 1px
|
|
// more slack on the right than the left. Overridden by backends that use
|
|
// that font.
|
|
virtual int textWidthTrailingGap() const { return 0; }
|
|
// Pixel width the pill from drawUnreadBadge(count) occupies — for reserving
|
|
// the name column before it. Mirrors the pill's horizontal padding.
|
|
int unreadBadgeWidth(int count) {
|
|
char buf[5]; fmtBadgeCount(buf, count);
|
|
int pad = sepH() + 1;
|
|
return getTextWidth(buf) - textWidthTrailingGap() + pad * 2;
|
|
}
|
|
// Right-aligned unread-count "pill": a filled capsule ending at right_x,
|
|
// aligned to a text row of height getLineHeight() at y, with the count
|
|
// knocked out. On a selected/inverted row pass sel=true so the pill inverts
|
|
// too (paper capsule + ink digits) and stays visible. The four corners are
|
|
// knocked back to the surrounding colour for a rounded-capsule look.
|
|
// Restores ink to LIGHT. Returns the pill width.
|
|
int drawUnreadBadge(int right_x, int y, int count, bool sel) {
|
|
char buf[5]; fmtBadgeCount(buf, count);
|
|
int pad = sepH() + 1;
|
|
int pw = getTextWidth(buf) - textWidthTrailingGap() + pad * 2;
|
|
int ph = getLineHeight();
|
|
int px = right_x - pw;
|
|
Color body = sel ? DARK : LIGHT;
|
|
Color ink = sel ? LIGHT : DARK;
|
|
setColor(body);
|
|
fillRect(px, y, pw, ph);
|
|
setColor(ink);
|
|
fillRect(px, y, 1, 1);
|
|
fillRect(px + pw - 1, y, 1, 1);
|
|
fillRect(px, y + ph - 1, 1, 1);
|
|
fillRect(px + pw - 1, y + ph - 1, 1, 1);
|
|
setCursor(px + pad, y);
|
|
print(buf);
|
|
setColor(LIGHT);
|
|
return pw;
|
|
}
|
|
|
|
// Inverted title bar: light background, dark ellipsized label, then the
|
|
// standard separator line. The label is UTF-8 translated by
|
|
// drawTextEllipsized. Leaves ink colour LIGHT for following content.
|
|
// Pixel width the ≡ context-menu hint reserves at the header's right edge.
|
|
int menuHintWidth() const { return getCharWidth() + 3; }
|
|
|
|
// Small ≡ glyph at the header's top-right, signalling the screen has a
|
|
// Hold-Enter context menu. Three stacked bars, drawn in colour c (LIGHT on a
|
|
// plain header, DARK on an inverted bar). When active (the menu is open) the
|
|
// corner cell is highlighted and the bars knocked out, tying the glyph to the
|
|
// popup it spawned. Call after the header body.
|
|
void drawContextMenuHint(Color c = LIGHT, bool active = false) {
|
|
int gw = getCharWidth() + 1;
|
|
int gx = width() - gw - 1;
|
|
int th = sepH();
|
|
int gy = (getLineHeight() - (th * 3 + 4)) / 2;
|
|
if (gy < 0) gy = 0;
|
|
Color bars = c;
|
|
if (active) {
|
|
setColor(c);
|
|
fillRect(gx - 1, 0, gw + 2, headerH() - sepH());
|
|
bars = (c == LIGHT) ? DARK : LIGHT;
|
|
}
|
|
setColor(bars);
|
|
for (int i = 0; i < 3; i++) fillRect(gx, gy + i * (th + 2), gw, th);
|
|
setColor(LIGHT);
|
|
}
|
|
|
|
void drawInvertedHeader(const char* label, bool menu_hint = false, bool menu_open = false) {
|
|
int hdr = headerH();
|
|
setColor(LIGHT);
|
|
fillRect(0, 0, width(), hdr - 1);
|
|
int reserve = menu_hint ? menuHintWidth() : 0;
|
|
setColor(DARK);
|
|
drawTextEllipsized(2, 1, width() - 4 - reserve, (label && label[0]) ? label : "");
|
|
if (menu_hint) drawContextMenuHint(DARK, menu_open);
|
|
setColor(LIGHT);
|
|
fillRect(0, hdr - 1, width(), sepH());
|
|
}
|
|
|
|
// Centred screen title + bottom separator line — the standard top bar for
|
|
// full-screen list/detail views. Leaves ink LIGHT; callers can draw extra
|
|
// header content (counters, etc.) afterwards. menu_hint adds the ≡ glyph;
|
|
// menu_open highlights it while the context menu is on screen.
|
|
void drawCenteredHeader(const char* title, bool menu_hint = false, bool menu_open = false) {
|
|
setColor(LIGHT);
|
|
int reserve = menu_hint ? menuHintWidth() : 0;
|
|
char buf[96];
|
|
translateUTF8ToBlocks(buf, (title && title[0]) ? title : "", sizeof(buf));
|
|
int avail = width() - reserve - 4;
|
|
if (avail < 0) avail = 0;
|
|
if (getTextWidth(buf) <= avail) {
|
|
int w = getTextWidth(buf);
|
|
setCursor((width() - reserve) / 2 - w / 2, 0); // centred, clear of the ≡ hint
|
|
print(buf);
|
|
} else {
|
|
// Too wide to centre without print() wrapping onto a second line → left-align
|
|
// and ellipsize, like a list row. (Long DM / room-server / channel names.)
|
|
drawTextEllipsized(2, 0, avail, buf);
|
|
}
|
|
fillRect(0, headerH() - sepH(), width(), sepH());
|
|
if (menu_hint) drawContextMenuHint(LIGHT, menu_open);
|
|
}
|
|
|
|
// Advance a UTF-8 pointer by one codepoint, returning the decoded value.
|
|
// Invalid sequences return 0xFFFD and consume trailing continuation bytes.
|
|
static uint32_t decodeCodepoint(const uint8_t*& p) {
|
|
uint8_t c = *p++;
|
|
if (c < 0x80) return c;
|
|
if ((c & 0xE0) == 0xC0) {
|
|
uint32_t cp = c & 0x1F;
|
|
if (*p) cp = (cp << 6) | (*p++ & 0x3F);
|
|
return cp;
|
|
}
|
|
if ((c & 0xF0) == 0xE0) {
|
|
uint32_t cp = c & 0x0F;
|
|
if (*p) cp = (cp << 6) | (*p++ & 0x3F);
|
|
if (*p) cp = (cp << 6) | (*p++ & 0x3F);
|
|
return cp;
|
|
}
|
|
if ((c & 0xF8) == 0xF0) {
|
|
uint32_t cp = c & 0x07;
|
|
if (*p) cp = (cp << 6) | (*p++ & 0x3F);
|
|
if (*p) cp = (cp << 6) | (*p++ & 0x3F);
|
|
if (*p) cp = (cp << 6) | (*p++ & 0x3F);
|
|
return cp;
|
|
}
|
|
while (*p && (*p & 0xC0) == 0x80) p++;
|
|
return 0xFFFD;
|
|
}
|
|
|
|
// Width of a single codepoint in pixels. Default: fall back to getTextWidth
|
|
// on a one-codepoint UTF-8 string. Drivers can override for O(1) lookup.
|
|
virtual uint16_t getCodepointWidth(uint32_t cp) {
|
|
char buf[5];
|
|
int n = 0;
|
|
if (cp < 0x80) { buf[n++] = (char)cp; }
|
|
else if (cp < 0x800) { buf[n++] = 0xC0 | (cp >> 6); buf[n++] = 0x80 | (cp & 0x3F); }
|
|
else if (cp < 0x10000) { buf[n++] = 0xE0 | (cp >> 12); buf[n++] = 0x80 | ((cp >> 6) & 0x3F); buf[n++] = 0x80 | (cp & 0x3F); }
|
|
else { buf[n++] = 0xF0 | (cp >> 18); buf[n++] = 0x80 | ((cp >> 12) & 0x3F); buf[n++] = 0x80 | ((cp >> 6) & 0x3F); buf[n++] = 0x80 | (cp & 0x3F); }
|
|
buf[n] = '\0';
|
|
return getTextWidth(buf);
|
|
}
|
|
|
|
|
|
// draw text with ellipsis if it exceeds max_width
|
|
virtual void drawTextEllipsized(int x, int y, int max_width, const char* str) {
|
|
char temp_str[256]; // reasonable buffer size
|
|
translateUTF8ToBlocks(temp_str, str, sizeof(temp_str));
|
|
|
|
// Fold newlines into spaces: this draws ONE line clipped to max_width, but
|
|
// print() acts on '\n' by returning to x=0 one row down, which would spill
|
|
// the tail onto whatever is drawn below. Message bodies (the compact
|
|
// one-line previews in the history list) are the texts that carry them;
|
|
// for labels and names this is a no-op. A space keeps the words apart and
|
|
// measures the same, so the width/ellipsis maths below is unaffected.
|
|
for (char* q = temp_str; *q; q++) if (*q == '\n' || *q == '\r') *q = ' ';
|
|
|
|
if (getTextWidth(temp_str) <= max_width) {
|
|
setCursor(x, y);
|
|
print(temp_str);
|
|
return;
|
|
}
|
|
|
|
// for variable-width fonts (GxEPD), add space after ellipsis
|
|
// for fixed-width fonts (OLED), keep tight spacing to save precious characters
|
|
const char* ellipsis;
|
|
// use a simple heuristic: if 'i' and 'l' have different widths, it's variable-width
|
|
if (_vw_dirty) {
|
|
_vw_result = (getTextWidth("i") != getTextWidth("l"));
|
|
_vw_dirty = false;
|
|
}
|
|
if (_vw_result) {
|
|
ellipsis = "... "; // variable-width fonts: add space
|
|
} else {
|
|
ellipsis = "..."; // fixed-width fonts: no space
|
|
}
|
|
|
|
int ellipsis_width = getTextWidth(ellipsis);
|
|
int str_len = strlen(temp_str);
|
|
|
|
while (str_len > 0 && getTextWidth(temp_str) > max_width - ellipsis_width) {
|
|
temp_str[--str_len] = 0;
|
|
}
|
|
// Strip orphaned UTF-8 leading byte left by byte-at-a-time trimming above.
|
|
while (str_len > 0 && ((uint8_t)temp_str[str_len - 1] & 0xC0) == 0xC0) {
|
|
temp_str[--str_len] = 0;
|
|
}
|
|
strcat(temp_str, ellipsis);
|
|
|
|
setCursor(x, y);
|
|
print(temp_str);
|
|
}
|
|
|
|
virtual void setBrightness(uint8_t level) { } // level 0-4 (min to max), no-op default
|
|
virtual void setDisplayRotation(uint8_t rot) { } // 0-3, no-op for fixed-orientation displays
|
|
virtual void setFullRefreshInterval(uint8_t n) { } // e-ink: do full refresh every n partial refreshes (0=never)
|
|
virtual void endFrame() = 0;
|
|
|
|
#ifdef ENABLE_SCREENSHOT
|
|
// Screenshot support — return raw framebuffer and its size in bytes.
|
|
// 0=OLED (page-based, column-major), 1=e-ink (row-major, MSB-first, 1=white/0=black).
|
|
virtual const uint8_t* getBuffer() { return nullptr; }
|
|
virtual uint16_t getBufferSize() { return 0; }
|
|
virtual uint8_t getDisplayType() { return 0; }
|
|
// Visible pixel dimensions to embed in the screenshot header.
|
|
// Override in e-ink drivers to return the GxEPD2-reported dimensions (which use
|
|
// WIDTH_VISIBLE instead of the full physical WIDTH used by DisplayDriver).
|
|
// OLED drivers: width()/height() already reflect the visible canvas, so no override needed.
|
|
virtual int screenshotWidth() { return width(); }
|
|
virtual int screenshotHeight() { return height(); }
|
|
virtual uint8_t screenshotRotation() { return 0; } // 0-3, GxEPD2/GFX rotation value
|
|
#endif
|
|
};
|