Files
MeshCore-Solo/variants/sim/SimDisplayDriver.h
T
JakubandClaude Sonnet 5 b067e95b24 fix(sim): accent-picker confirm was silently dropping the diacritic
Selecting a variant from the Hold-Enter accent popup (e.g. 'o' -> 'o with
acute') correctly stored the real UTF-8 character in the text buffer, but
the live-typing preview line rendered it as the plain base letter -- the
accent was invisible even though the data was right.

Root cause: DisplayDriver::translateUTF8ToBlocks() transliterates extended
Latin characters down to ASCII for display drivers whose font can't render
them; real boards with the pixel-perfect MiscFixedFont override it to a
plain passthrough once _single_font is set (SH1106Display.cpp etc).
SimDisplayDriverCanvas reuses that same MiscFixedFont/MiscFixedRenderer
(confirmed the popup's own preview renders diacritics correctly, since it
prints its own variants directly rather than through this path) but never
added the matching override, so every string routed through
translateUTF8ToBlocks() -- not just the keyboard preview -- silently lost
its diacritics.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 12:10:57 +02:00

344 lines
14 KiB
C++

#pragma once
#include <helpers/ui/DisplayDriver.h>
#include <cstdio>
#include <cstring>
#ifdef __EMSCRIPTEN__
#include <emscripten.h>
#endif
// DisplayDriver implementation for the native sim build. Shaped like
// src/helpers/ui/NullDisplayDriver.h (same pure-virtual overrides -- start
// from that file, per the Phase-1 plan) but instead of no-ops, maintains an
// in-memory framebuffer and prints it to stdout as ASCII/block-art on
// endFrame(), so the real UITask/menu system's actual draw calls are
// visible in a terminal.
//
// Logical canvas is 128x64 (matches NullDisplayDriver / a typical SSD1306
// OLED, so layout math in the real screens behaves exactly as on that
// hardware). For terminal rendering it's downsampled onto a coarser
// CELL_W x CELL_H-pixel grid:
// - fillRect()/drawRect()/drawXbm() mark the cells they cover as "filled"
// (drawXbm -- icons -- has no real bitmap to rasterize in ASCII, so it's
// approximated as a solid block, same as a fillRect over that area).
// - print() does NOT rasterize a bitmap font -- it places the real
// characters of the real string into the grid at the (approximate)
// cursor cell, which is what actually makes the output legible. Real
// text always wins over a "filled" block in the same cell.
class SimDisplayDriver : public DisplayDriver {
static const int CELL_W = 2; // pixels per terminal column
static const int CELL_H = 2; // pixels per terminal row
static const int COLS = 128 / CELL_W; // 64
static const int ROWS = 64 / CELL_H; // 32
bool _on = false;
int _cursor_x = 0, _cursor_y = 0;
Color _color = LIGHT;
bool _filled[ROWS][COLS];
char _text[ROWS][COLS]; // 0 = no character placed
bool _dirty = false;
int _frame_no = 0;
void cellOf(int px, int py, int& cx, int& cy) const {
cx = px / CELL_W; cy = py / CELL_H;
}
public:
SimDisplayDriver() : DisplayDriver(128, 64) { clearBuffers(); }
bool begin() { _on = true; return true; }
void clearBuffers() {
memset(_filled, 0, sizeof(_filled));
memset(_text, 0, sizeof(_text));
}
bool isOn() override { return _on; }
void turnOn() override { _on = true; }
void turnOff() override { _on = false; }
void clear() override { clearBuffers(); }
void startFrame(Color bkg = DARK) override {
clearBuffers();
_color = LIGHT;
}
void setTextSize(int sz) override { /* one fixed size in ASCII output */ }
void setColor(Color c) override { _color = c; }
void setCursor(int x, int y) override { _cursor_x = x; _cursor_y = y; }
void print(const char* str) override {
if (!str) return;
int cx, cy;
cellOf(_cursor_x, _cursor_y, cx, cy);
int col = cx;
for (const char* p = str; *p; p++) {
if (*p == '\n') { cy++; col = cx; continue; }
if (col >= 0 && col < COLS && cy >= 0 && cy < ROWS) {
_text[cy][col] = (*p >= 32 && *p < 127) ? *p : '?';
}
col++;
}
// advance cursor horizontally by the printed width, like a real display
_cursor_x += getTextWidth(str);
}
void fillRect(int x, int y, int w, int h) override { markRect(x, y, w, h); }
void drawRect(int x, int y, int w, int h) override {
markRect(x, y, w, 1);
markRect(x, y + h - 1, w, 1);
markRect(x, y, 1, h);
markRect(x + w - 1, y, 1, h);
}
void drawXbm(int x, int y, const uint8_t* bits, int w, int h) override {
markRect(x, y, w, h); // icon placeholder: solid block (see class comment)
}
uint16_t getTextWidth(const char* str) override {
return str ? (uint16_t)(strlen(str) * getCharWidth()) : 0;
}
void endFrame() {
printf("\n===== SimDisplayDriver frame #%d =====\n", _frame_no++);
printf("+");
for (int c = 0; c < COLS; c++) printf("-");
printf("+\n");
for (int r = 0; r < ROWS; r++) {
printf("|");
for (int c = 0; c < COLS; c++) {
char ch = _text[r][c];
if (ch) putchar(ch);
else if (_filled[r][c]) putchar('#');
else putchar(' ');
}
printf("|\n");
}
printf("+");
for (int c = 0; c < COLS; c++) printf("-");
printf("+\n");
fflush(stdout);
}
private:
void markRect(int x, int y, int w, int h) {
bool lit = (_color != DARK);
int cx0, cy0, cx1, cy1;
cellOf(x, y, cx0, cy0);
cellOf(x + w - 1, y + h - 1, cx1, cy1);
for (int r = cy0; r <= cy1; r++) {
if (r < 0 || r >= ROWS) continue;
for (int c = cx0; c <= cx1; c++) {
if (c < 0 || c >= COLS) continue;
_filled[r][c] = lit;
}
}
}
};
#ifdef __EMSCRIPTEN__
// ---------------------------------------------------------------------
// Phase 2 (Emscripten): DisplayDriver backend that draws to a real
// HTML5 <canvas> instead of dumping ASCII art to stdout. SimDisplayDriver
// above is left completely untouched -- the native build still links that
// class (see variants/sim/platformio.ini's DISPLAY_CLASS=SimDisplayDriver);
// this class is only selected when DISPLAY_CLASS=SimDisplayDriverCanvas is
// set by the Emscripten build (variants/sim/build_wasm.sh).
//
// Design choice: draw straight through the browser's canvas 2D API on every
// draw call (fillRect/strokeRect/fillText), rather than building an offscreen
// RGBA framebuffer in linear memory and blitting it with putImageData(). The
// canvas 2D approach was simpler and more robust for this app's actual draw
// call shape:
// - print() needs real text rendering (variable glyphs, marquee/ellipsis
// logic in DisplayDriver.h measures via getTextWidth()) -- letting the
// browser's own font rasterizer draw it is both less code and crisper
// than hand-rolling a bitmap font + blit.
// - Every draw happens synchronously within one call to startFrame()..
// endFrame() inside a single JS "tick" (called from the Emscripten main
// loop -- see sim_main.cpp) -- the browser never paints a partial canvas
// mid-tick, so there's no tearing/flicker risk from not double-buffering
/// in C++ first.
// - putImageData() would still need *something* to rasterize text and
// icons into an RGBA buffer first -- it doesn't remove that work, it
/// only relocates it into C++ for no real benefit here.
//
// Each call reaches into the DOM via EM_ASM (synchronous, main-thread JS --
// fine since this build has no pthreads/proxying). The canvas is looked up
// by id once in begin() and cached on a per-instance JS property (see below)
// so every later call is one property read, not a fresh getElementById().
//
// Phase 3 addendum: cached on Module.__simCtx, NOT window.__simCtx as this
// class originally did in Phase 2. Phase 2 only ever ran one instance on a
// page, so a plain `window` global was invisible/harmless as a design smell;
// Phase 3 loads multiple MeshCoreSim()/MeshCoreSimRepeater() instances on
// ONE page, and `window` is the single real browser global shared by every
// one of them (MODULARIZE isolates each instance's own Module/wasm linear
// memory, but NOT the DOM/window) -- two instances' begin() calls would
// stomp the same window.__simCtx in turn, and both would end up drawing
// through whichever one won. `Module` itself, by contrast, IS a distinct
// object per instance (that's the whole point of MODULARIZE) and is already
// reachable from inside EM_ASM here as the current instance's own Module
// (same access pattern SimFS.h's sim_fs_mount_idbfs()/SimInstance.h's
// sim_instance_salt() already rely on for Module['simInstanceTag']), so
// storing it there instead scopes it correctly per instance for free.
//
// The canvas element id is ALSO made per-instance the same way: an untagged
// instance (no Module['simInstanceTag'], e.g. Phase 2's original
// single-instance web/index.html harness) still looks for plain
// "sim-canvas", byte-for-byte the pre-Phase-3 behavior; a tagged instance
// (Module['simInstanceTag'] = 'A', from a Phase 3 multi-instance host page
// like web/mesh.html) looks for "sim-canvas-A" instead, so two instances on
// one page never fight over the same <canvas> element either.
class SimDisplayDriverCanvas : public DisplayDriver {
bool _on = false;
int _cursor_x = 0, _cursor_y = 0;
Color _color = LIGHT;
int _text_sz = 1;
public:
SimDisplayDriverCanvas() : DisplayDriver(128, 64) { }
bool begin() {
_on = true;
EM_ASM({
var tag = (typeof Module !== 'undefined' && Module['simInstanceTag']) ? Module['simInstanceTag'] : '';
var id = tag ? ('sim-canvas-' + tag) : 'sim-canvas';
var c = document.getElementById(id);
if (!c) { console.error('[sim] #' + id + ' not found in the host page'); return; }
Module.__simCtx = c.getContext('2d');
Module.__simCtx.imageSmoothingEnabled = false;
});
return true;
}
bool isOn() override { return _on; }
void turnOn() override { _on = true; }
void turnOff() override {
_on = false;
EM_ASM({
if (!Module.__simCtx) return;
Module.__simCtx.fillStyle = '#000';
Module.__simCtx.fillRect(0, 0, 128, 64);
});
}
void clear() override { turnOff(); _on = true; }
void startFrame(Color bkg = DARK) override {
_color = LIGHT;
EM_ASM({
if (!Module.__simCtx) return;
Module.__simCtx.fillStyle = '#000';
Module.__simCtx.fillRect(0, 0, 128, 64);
});
}
void setTextSize(int sz) override { _text_sz = sz; }
void setColor(Color c) override { _color = c; }
void setCursor(int x, int y) override { _cursor_x = x; _cursor_y = y; }
// MiscFixed's real metrics (6px advance, 9px row height -- see
// src/helpers/ui/MiscFixedFont.h), scaled by the current text size, same
// as a real board's SSD1306Display::getCharWidth()/getLineHeight() do.
// DisplayDriver.h's own defaults (6/8, unscaled) would make the Clock
// screen's setTextSize(2)/(4) big-digit layout math (drawBig()'s width
// centring, line spacing) come out wrong -- half-size digits crowded on
// top of each other -- even though print() itself renders them at the
// right size once _text_sz reaches it (see target.cpp).
int getCharWidth() const override { return 6 * _text_sz; }
int getLineHeight() const override { return 9 * _text_sz; }
// Amber-on-black palette (a common OLED look) for LIGHT/DARK; the other
// Color enumerators (RED/GREEN/BLUE/YELLOW/ORANGE) aren't used on the real
// monochrome OLED boards this sim mirrors either (DisplayDriver.h's own
// comment: "on b/w screen, colors will be !=0 synonym of light").
static const char* jsColor(Color c) { return c == DARK ? "#000" : "#ffb000"; }
// Real bitmap-font rendering (byte-identical glyphs to a real MeshCore-Solo
// board with OLED_MISC_FIXED_FONT=1 -- see solo/heltec_v3/platformio.ini),
// not the browser's own system font -- defined out-of-line in target.cpp,
// the one Emscripten-only translation unit allowed to pull in
// MiscFixedRenderer.h (its own header comment: including it from more than
// one .cpp would duplicate the font's static const tables in each).
void print(const char* str) override;
// Real MiscFixedFont covers the full glyph set print() draws above, so
// unlike DisplayDriver's base assumption ("no extended glyphs -- fall back
// to transliterateCodepoint()'s ASCII substitution"), this driver never
// needs that. Matches a real board's SH1106Display/SSD1306Display, whose
// own translateUTF8ToBlocks() override (gated on _single_font) does the
// same plain passthrough once OLED_MISC_FIXED_FONT is enabled. Without
// this override here, anything routed through translateUTF8ToBlocks()
// (KeyboardWidget's live-typing preview line, screen titles, ...) would
// inherit the base class's ASCII transliteration and silently strip every
// accented character down to its plain-Latin base -- e.g. typing "ó" via
// the accent-picker popup would show "o" in the text-entry preview even
// though the popup itself (which prints its own variants directly, not
// through this path) and the underlying buffer both had it right.
void translateUTF8ToBlocks(char* dest, const char* src, size_t dest_size) override {
size_t n = strlen(src);
if (n >= dest_size) n = dest_size - 1;
memcpy(dest, src, n);
dest[n] = '\0';
}
void fillRect(int x, int y, int w, int h) override {
EM_ASM({
if (!Module.__simCtx) return;
Module.__simCtx.fillStyle = UTF8ToString($4) === 'L' ? '#ffb000' : '#000';
Module.__simCtx.fillRect($0, $1, $2, $3);
}, x, y, w, h, (_color != DARK) ? "L" : "D");
}
void drawRect(int x, int y, int w, int h) override {
EM_ASM({
if (!Module.__simCtx) return;
var ctx = Module.__simCtx;
ctx.strokeStyle = UTF8ToString($4) === 'L' ? '#ffb000' : '#000';
ctx.lineWidth = 1;
ctx.strokeRect($0 + 0.5, $1 + 0.5, $2 - 1, $3 - 1);
}, x, y, w, h, (_color != DARK) ? "L" : "D");
}
// Real XBM bit-unpacking (row-major, MSB-first, rows padded to whole
// bytes) -- same convention every real DisplayDriver's drawXbm() already
// assumes (see e.g. src/helpers/ui/ST7789Display.cpp's own drawXbm(),
// `0x80 >> (bx & 7)` against `widthInBytes = (w+7)/8`). A canvas can
// afford to rasterize the real icon pixels cheaply, unlike the native
// ASCII backend's solid-block placeholder (no ASCII resolution for that).
// `bits` is a pointer into wasm linear memory; EM_ASM passes it through as
// a plain integer and the JS side indexes HEAPU8 with it directly.
void drawXbm(int x, int y, const uint8_t* bits, int w, int h) override {
EM_ASM({
if (!Module.__simCtx) return;
var ctx = Module.__simCtx;
var x0 = $0;
var y0 = $1;
var w = $2;
var h = $3;
var bits = $4;
var lit = UTF8ToString($5) === 'L';
var widthInBytes = (w + 7) >> 3;
ctx.fillStyle = lit ? '#ffb000' : '#000';
for (var ry = 0; ry < h; ry++) {
for (var rx = 0; rx < w; rx++) {
var byteOff = bits + ry * widthInBytes + (rx >> 3);
var mask = 0x80 >> (rx & 7);
if (HEAPU8[byteOff] & mask) ctx.fillRect(x0 + rx, y0 + ry, 1, 1);
}
}
}, x, y, w, h, bits, (_color != DARK) ? "L" : "D");
}
uint16_t getTextWidth(const char* str) override {
return str ? (uint16_t)(strlen(str) * getCharWidth()) : 0;
}
// Every draw call above already lands directly on the visible canvas
// (see the class comment) -- nothing left to flush.
void endFrame() override { }
};
#endif // __EMSCRIPTEN__