Files
MeshCore-Solo/src/helpers/ui/DisplayDriver.h
T

522 lines
23 KiB
C++
Raw Normal View History

#pragma once
#include <stdint.h>
#include <string.h>
#include <Arduino.h>
class DisplayDriver {
int _w, _h;
protected:
bool _vw_dirty = true;
bool _vw_result = false;
// ---- Marquee state for the single currently-selected overflowing label ----
// Only one row/label can be "selected" at a time across the whole UI, so one
// slot of state (rather than per-caller) is enough, and keeps every call
// site down to passing a bool.
// Matches temp_str/window's own cap in drawTextEllipsized() below: this is
// only ever compared against that buffer (see is_new) to detect a text/width
// change, and a shorter cap here made a plain strcmp() see a mismatch at the
// truncation point on every single frame for any text past that length --
// "is_new" stuck true forever, so the marquee reset to its start position
// every call and never actually scrolled. Long message-preview rows (not
// just short labels/names) hit this once selected-row scrolling started
// being used to read them without opening fullscreen.
char _marquee_text[256] = {0};
int _marquee_max_w = -1;
uint16_t _marquee_skip_cp = 0;
uint16_t _marquee_max_skip_cp = 0;
uint8_t _marquee_phase = 0; // 0=hold@start 1=scroll fwd 2=hold@end 3=scroll back
unsigned long _marquee_next_at = 0;
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;
2026-06-02 18:36:07 +10:00
virtual bool isEink() { return false; } // default to non-eink, override in eink drivers
virtual void turnOn() = 0;
virtual void turnOff() = 0;
2025-04-01 14:46:48 +13:00
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;
2025-08-08 20:01:31 +10:00
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;
2025-05-04 18:17:18 -07:00
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);
2025-08-08 20:01:31 +10:00
setCursor(mid_x - w/2, y);
print(tmp);
2025-08-08 20:01:31 +10:00
}
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);
}
// Marquee timing — e-ink gets slower, coarser steps (fewer, cheaper partial
// refreshes; a fine pixel/char scroll would be both slow and prone to
// ghosting there). Unchanged frames are skipped by the display's own CRC
// diff before any real panel push happens, so the hold phases are free.
virtual unsigned long marqueeStepMs() { return isEink() ? 900 : 220; }
virtual unsigned long marqueeHoldMs() { return isEink() ? 1200 : 700; }
virtual uint8_t marqueeStepChars() { return isEink() ? 3 : 1; }
// Scales the start/end hold down when only a codepoint or two is actually
// hidden -- with a fixed hold, a label that overflows by just that much
// (e.g. a short "?A1B2C3D4" hex hop tag squeezed by a couple of pixels)
// spends nearly its whole cycle sitting still either side of a single,
// barely-there step, which reads as "stuck" rather than "scrolling" for
// how little extra text it actually reveals. Longer overflows keep the
// full hold, since there's real content worth pausing to read.
unsigned long marqueeHoldForSkip(uint16_t skip_cp) {
unsigned long full = marqueeHoldMs();
if (skip_cp <= 2) return full / 3;
if (skip_cp <= 4) return full / 2;
return full;
}
// draw text with ellipsis if it exceeds max_width. Pass selected=true for
// the row currently highlighted/focused by the user: instead of a static
// ellipsis, an overflowing label then animates a "swing" marquee — holds at
// the start, scrolls to reveal the full tail, holds there, scrolls back to
// the start, and repeats for as long as the caller keeps passing
// selected=true for this same text.
// Returns 0 if nothing is animating (the caller's normal redraw cadence is
// fine), or the number of ms until the next animation step is due — screens
// clamp their render() return value to this so the marquee stays smooth.
virtual int drawTextEllipsized(int x, int y, int max_width, const char* str, bool selected = false) {
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 = ' ';
int full_width = getTextWidth(temp_str);
if (full_width <= max_width) {
setCursor(x, y);
print(temp_str);
return 0;
}
if (!selected) {
// 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);
return 0;
}
// ---- marquee (selected + overflowing) ----
unsigned long now = millis();
bool is_new = (strcmp(temp_str, _marquee_text) != 0) || (max_width != _marquee_max_w);
if (is_new) {
strncpy(_marquee_text, temp_str, sizeof(_marquee_text) - 1);
_marquee_text[sizeof(_marquee_text) - 1] = 0;
_marquee_max_w = max_width;
_marquee_skip_cp = 0;
_marquee_phase = 0; // hold at start
// Find the codepoint skip at which the remaining suffix's own width
// already fits max_width — i.e. the fully-scrolled end position.
const uint8_t* p = (const uint8_t*)temp_str;
int removed_w = 0;
uint16_t cp_count = 0;
while (*p) {
uint32_t cp = decodeCodepoint(p);
removed_w += getCodepointWidth(cp);
cp_count++;
if (full_width - removed_w <= max_width) break;
}
_marquee_max_skip_cp = cp_count;
_marquee_next_at = now + marqueeHoldForSkip(cp_count);
}
// Advance the state machine at most once per elapsed step/hold interval.
if ((int32_t)(now - _marquee_next_at) >= 0) {
uint8_t step = marqueeStepChars();
switch (_marquee_phase) {
case 0: // was holding at start -> begin scrolling forward
case 1: // scrolling forward
_marquee_skip_cp += step;
_marquee_phase = 1;
if (_marquee_skip_cp >= _marquee_max_skip_cp) {
_marquee_skip_cp = _marquee_max_skip_cp;
_marquee_phase = 2;
_marquee_next_at = now + marqueeHoldForSkip(_marquee_max_skip_cp);
} else {
_marquee_next_at = now + marqueeStepMs();
}
break;
case 2: // was holding at end -> begin scrolling back
case 3: // scrolling back
_marquee_phase = 3;
if (_marquee_skip_cp <= step) {
_marquee_skip_cp = 0;
_marquee_phase = 0;
_marquee_next_at = now + marqueeHoldForSkip(_marquee_max_skip_cp);
} else {
_marquee_skip_cp -= step;
_marquee_next_at = now + marqueeStepMs();
}
break;
}
}
// Render the window starting _marquee_skip_cp codepoints in, trimmed from
// the tail (same technique as the static ellipsis path, minus the "..."
// suffix) until it fits max_width.
const uint8_t* p = (const uint8_t*)temp_str;
for (uint16_t i = 0; i < _marquee_skip_cp && *p; i++) decodeCodepoint(p);
char window[256];
strncpy(window, (const char*)p, sizeof(window) - 1);
window[sizeof(window) - 1] = 0;
int wlen = strlen(window);
while (wlen > 0 && getTextWidth(window) > max_width) {
window[--wlen] = 0;
}
while (wlen > 0 && ((uint8_t)window[wlen - 1] & 0xC0) == 0xC0) {
window[--wlen] = 0;
}
setCursor(x, y);
print(window);
return (int)(_marquee_next_at > now ? (_marquee_next_at - now) : 1);
}
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
};