feat(ui-lvgl): 320x240 browser simulator, compose field fixes

Simulator: SIM_UI=lvgl variants/sim/build_wasm.sh builds ui-lvgl for the
browser. SimLcdDisplay blits LVGL's RGB565 flush to a 320x240 canvas and
LvglPort.h's sim branch feeds the mouse in as touch; LVGL comes from the
L2 env's libdeps, its objects cached in web/build/obj_lvgl/. web/lvgl.html
runs it beside a ui-new peer on a direct link, with a window.sim API for
headless checks. New hooks: sim_lcd_touch, sim_lcd_button,
sim_test_send_channel_msg.

Compose: the theme's padding left less than one line in the field, so it
scrolled vertically and text jumped; pad it to fit exactly one line. The
cursor is drawn only while FOCUSED, now set while the keyboard is up.
With the keyboard up the screen header hides so the conversation keeps a
message in view; the accent popup stays inside the keyboard, clear of the
field; channel bubbles put the age next to the sender (one line shorter);
the thread scrollbar shows only while scrolling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-09-25 00:36:26 +02:00
co-authored by Claude Opus 5.5
parent bf1e6fcaa0
commit 87fad71af3
9 changed files with 509 additions and 28 deletions
+84
View File
@@ -360,4 +360,88 @@ public:
// (see the class comment) -- nothing left to flush.
void endFrame() override { }
};
// 320x240 colour touch LCD for the ui-lvgl frontend (Wio Tracker L2 shape).
// LVGL renders everything itself; this driver only owns the canvas and power
// state. blit() takes an RGB565 area straight from LVGL's flush callback
// (ui-lvgl/LvglPort.h) and putImageData()s it. The DisplayDriver text/shape
// API is a no-op -- only main.cpp's "Loading..." splash calls it.
//
// Touch comes from the host page: sim_lcd_touch(x, y, down) on mouse/pointer
// events, read back by LvglPort.h through touchState().
class SimLcdDisplay : public DisplayDriver {
bool _on = false;
public:
static const int W = 320, H = 240;
SimLcdDisplay() : DisplayDriver(W, H) { }
struct Touch { int x, y; bool down; };
static Touch& touchState() { static Touch t = {0, 0, false}; return t; }
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.fillStyle = '#000';
Module.__simCtx.fillRect(0, 0, $0, $1);
}, W, H);
return true;
}
// RGB565 (little-endian, as LVGL renders it) -> RGBA -> canvas.
void blit(int x, int y, int w, int h, const uint16_t* px) {
if (!_on) return;
EM_ASM({
if (!Module.__simCtx) return;
// (no bare commas: EM_ASM is a macro)
var src = $4 >> 1;
var n = $2 * $3;
var img = Module.__simCtx.createImageData($2, $3);
var d = img.data;
for (var i = 0; i < n; i++) {
var c = HEAPU16[src + i];
var r = (c >> 11) & 0x1F;
var g = (c >> 5) & 0x3F;
var b = c & 0x1F;
var o = i * 4;
d[o] = (r << 3) | (r >> 2);
d[o + 1] = (g << 2) | (g >> 4);
d[o + 2] = (b << 3) | (b >> 2);
d[o + 3] = 255;
}
Module.__simCtx.putImageData(img, $0, $1);
}, x, y, w, h, px);
}
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, $0, $1);
}, W, H);
}
void clear() override { }
void startFrame(Color bkg = DARK) override { (void)bkg; }
void setTextSize(int sz) override { (void)sz; }
void setColor(Color c) override { (void)c; }
void setCursor(int x, int y) override { (void)x; (void)y; }
void print(const char* str) override { (void)str; }
void fillRect(int x, int y, int w, int h) override { (void)x; (void)y; (void)w; (void)h; }
void drawRect(int x, int y, int w, int h) override { (void)x; (void)y; (void)w; (void)h; }
void drawXbm(int x, int y, const uint8_t* bits, int w, int h) override { (void)x; (void)y; (void)bits; (void)w; (void)h; }
uint16_t getTextWidth(const char* str) override { return str ? strlen(str) * 6 : 0; }
void endFrame() override { }
};
extern "C" EMSCRIPTEN_KEEPALIVE inline void sim_lcd_touch(int x, int y, int down) {
SimLcdDisplay::Touch& t = SimLcdDisplay::touchState();
t.x = x; t.y = y; t.down = down != 0;
}
#endif // __EMSCRIPTEN__
+59 -5
View File
@@ -52,6 +52,18 @@ else
OPT_FLAGS=(-O2)
fi
# Which frontend: SIM_UI=new (default, 128x64 OLED-style ui-new) or
# SIM_UI=lvgl (320x240 touch ui-lvgl, Wio Tracker L2 shape -> web/lvgl.html).
# The lvgl build takes the LVGL 9 sources from PlatformIO's libdeps of the L2
# env, so run `pio run -e Wio_Tracker_L2_companion_solo_lvgl` once first.
SIM_UI="${SIM_UI:-new}"
LVGL_DIR="$REPO_ROOT/.pio/libdeps/Wio_Tracker_L2_companion_solo_lvgl/lvgl"
if [ "$SIM_UI" = "lvgl" ] && [ ! -f "$LVGL_DIR/lvgl.h" ]; then
echo "error: LVGL sources not found at $LVGL_DIR" >&2
echo "Fetch them first: pio run -e Wio_Tracker_L2_companion_solo_lvgl" >&2
exit 1
fi
mkdir -p "$OUT_DIR"
cd "$REPO_ROOT"
@@ -105,7 +117,7 @@ SRCS=(
examples/companion_radio/main.cpp
examples/companion_radio/MyMesh.cpp
examples/companion_radio/DataStore.cpp
examples/companion_radio/ui-new/UITask.cpp
"examples/companion_radio/ui-$SIM_UI/UITask.cpp"
)
INCLUDES=(
@@ -118,7 +130,7 @@ INCLUDES=(
-Ilib/ed25519
-Isrc
-Iexamples/companion_radio
-Iexamples/companion_radio/ui-new
"-Iexamples/companion_radio/ui-$SIM_UI"
)
DEFINES=(
@@ -158,6 +170,21 @@ if [ -n "${FIRMWARE_VERSION:-}" ]; then
DEFINES+=("-DFIRMWARE_VERSION=\"${FIRMWARE_VERSION}\"")
fi
OUT_NAME=meshcore_sim
EXPORT_NAME=MeshCoreSim
if [ "$SIM_UI" = "lvgl" ]; then
OUT_NAME=meshcore_sim_lvgl
EXPORT_NAME=MeshCoreSimLvgl # distinct factory: web/lvgl.html loads both
# The 320x240 LCD driver (SimDisplayDriver.h's SimLcdDisplay) instead of
# the OLED canvas one, and the L2's channel count.
kept=()
for d in "${DEFINES[@]}"; do
case "$d" in -DDISPLAY_CLASS=*|-DMAX_GROUP_CHANNELS=*) ;; *) kept+=("$d") ;; esac
done
DEFINES=("${kept[@]}" -DDISPLAY_CLASS=SimLcdDisplay -DMAX_GROUP_CHANNELS=40 -DUI_ZOOM=1 -DLV_CONF_INCLUDE_SIMPLE)
INCLUDES+=("-I$LVGL_DIR")
fi
# -funsigned-char: carried over from Phase 1 verbatim -- real ARM cores
# default `char` to unsigned; em++'s target (wasm32) defaults it to signed,
# same mismatch Phase 1 hit on a native x86/ARM64 host, for the same reason
@@ -205,6 +232,33 @@ for src in "${SRCS[@]}"; do
OBJS+=("$obj")
done
# ui-lvgl: LVGL itself and the generated fonts, compiled as C. Several hundred
# files that rarely change, so their objects are cached in obj_lvgl/ and only
# rebuilt when the source or lv_conf.h is newer.
if [ "$SIM_UI" = "lvgl" ]; then
EMCC="$EMSDK_DIR/upstream/emscripten/emcc"
LV_CONF="examples/companion_radio/ui-lvgl/lv_conf.h"
# Only LVGL + lv_conf.h on the path: examples/companion_radio/features.h
# would shadow the libc <features.h> the C headers include.
C_FLAGS=(-std=gnu99 "${OPT_FLAGS[@]}" "${DEFINES[@]}" "-I$LVGL_DIR" -Iexamples/companion_radio/ui-lvgl)
LV_OBJ_DIR="$OUT_DIR/obj_lvgl"
C_SRCS=()
while IFS= read -r f; do C_SRCS+=("$f"); done < <(
find "$LVGL_DIR/src" -name '*.c' | sed "s|^$REPO_ROOT/||" | sort
find examples/companion_radio/ui-lvgl/fonts -name '*.c' | sort)
built=0
for src in "${C_SRCS[@]}"; do
obj="$LV_OBJ_DIR/${src%.*}.o"
if [ ! -f "$obj" ] || [ "$src" -nt "$obj" ] || [ "$LV_CONF" -nt "$obj" ]; then
mkdir -p "$(dirname "$obj")"
"$EMCC" -c "${C_FLAGS[@]}" "$src" -o "$obj"
built=$((built + 1))
fi
OBJS+=("$obj")
done
echo "LVGL/fonts: $built of ${#C_SRCS[@]} C files rebuilt"
fi
# FS is exported so a host page (or a manual verification script) can
# directly inspect what DataStore/IdentityStore actually wrote -- e.g.
# Module.FS.readFile('/sim_data/identity/_main.id') -- to prove IDBFS
@@ -233,14 +287,14 @@ done
-sALLOW_MEMORY_GROWTH=1 \
-sFORCE_FILESYSTEM=1 \
-sMODULARIZE=1 \
-sEXPORT_NAME=MeshCoreSim \
-sEXPORT_NAME=$EXPORT_NAME \
-sENVIRONMENT=web \
-sEXIT_RUNTIME=0 \
-sEXPORTED_RUNTIME_METHODS=FS,ccall,cwrap,HEAPU8,HEAPF32,HEAP32 \
-sEXPORTED_FUNCTIONS=_main,_malloc,_free \
-o "$OUT_DIR/meshcore_sim.js"
-o "$OUT_DIR/$OUT_NAME.js"
echo ""
echo "Built: $OUT_DIR/meshcore_sim.js (+ .wasm alongside it)"
echo "Built: $OUT_DIR/$OUT_NAME.js (+ .wasm alongside it)"
echo "Serve variants/sim/web/ locally and open index.html, e.g.:"
echo " cd $SCRIPT_DIR/web && python3 -m http.server 8080"
+219
View File
@@ -0,0 +1,219 @@
<!DOCTYPE html>
<!--
ui-lvgl simulator: the Wio Tracker L2 frontend (320x240 touch LCD) running
the real companion firmware in the browser, plus one ui-new peer device on a
direct simulated link so there is someone to talk to.
Build both, then serve this directory (http://, not file://):
variants/sim/build_wasm.sh # peer -> build/meshcore_sim.js
SIM_UI=lvgl variants/sim/build_wasm.sh # L2 -> build/meshcore_sim_lvgl.js
cd variants/sim/web && python3 -m http.server 8080
and open http://localhost:8080/lvgl.html .
The mouse is the finger: press / drag / release on the LCD. Holding a key
on the on-screen keyboard opens its accent popup, as on the device.
window.sim is the automation surface (headless checks): sim.ready (promise),
sim.touch(x, y, down), sim.tap(x, y), sim.hold(x, y, ms), sim.L / sim.P.
-->
<html lang="en">
<head>
<meta charset="utf-8">
<title>ui-lvgl sim</title>
<style>
:root { color-scheme: dark; }
body {
margin: 0; padding: 24px 16px;
background: #111114; color: #ddd;
font: 13px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
display: flex; flex-wrap: wrap; gap: 24px; justify-content: center; align-items: flex-start;
}
.dev { display: flex; flex-direction: column; gap: 8px; align-items: center; }
.dev h2 { margin: 0; font-size: 13px; font-weight: 600; color: #aaa; }
#sim-canvas-L {
width: 640px; height: 480px; max-width: calc(100vw - 32px); aspect-ratio: 4 / 3; height: auto;
image-rendering: pixelated; background: #000;
border: 10px solid #26262b; border-radius: 14px; touch-action: none; cursor: crosshair;
}
#sim-canvas-P {
width: 384px; height: 192px; image-rendering: pixelated; background: #000;
border: 6px solid #26262b; border-radius: 8px;
}
.panel { width: 384px; max-width: calc(100vw - 32px); display: flex; flex-direction: column; gap: 6px; }
.row { display: flex; gap: 6px; flex-wrap: wrap; }
button, input {
font: inherit; color: #eee; background: #2a2a30; border: 1px solid #45454d; border-radius: 6px; padding: 6px 10px;
}
button { cursor: pointer; }
button:active { background: #3a3a42; }
input { flex: 1; min-width: 0; }
#log {
height: 180px; overflow-y: auto; background: #18181c; border: 1px solid #2c2c33; border-radius: 6px;
padding: 6px; font: 11px ui-monospace, Menlo, monospace; color: #9a9a9a; white-space: pre-wrap;
}
.status { font-size: 11px; color: #888; }
</style>
</head>
<body>
<div class="dev">
<h2>Wio Tracker L2 &middot; ui-lvgl <span class="status" id="status-L">booting…</span></h2>
<canvas id="sim-canvas-L" width="320" height="240"></canvas>
<div class="row">
<button id="btn-L">Button (back / sleep)</button>
<button id="reset-L">Reset L2</button>
</div>
</div>
<div class="dev">
<h2>Peer &middot; ui-new <span class="status" id="status-P">booting…</span></h2>
<canvas id="sim-canvas-P" width="128" height="64"></canvas>
<div class="panel">
<div class="row">
<input id="peer-text" value="Cześć! Привет! Γειά σου!" aria-label="Peer message text">
</div>
<div class="row">
<button id="peer-dm">Peer → DM to L2</button>
<button id="peer-ch">Peer → Public</button>
<button id="advert">Advert both</button>
</div>
<div id="log"></div>
</div>
</div>
<script src="build/meshcore_sim.js"></script>
<script src="build/meshcore_sim_lvgl.js"></script>
<script>
const logEl = document.getElementById('log');
function log(msg) {
const line = document.createElement('div');
line.textContent = new Date().toISOString().substr(11, 8) + ' ' + msg;
logEl.appendChild(line);
logEl.scrollTop = logEl.scrollHeight;
}
const MAX_PKT = 256;
function wrap(mod, label) {
const txBuf = mod._malloc(MAX_PKT);
return {
mod, label,
isReady() { return mod.ccall('sim_is_ready', 'number', [], []) === 1; },
pollTx() {
const out = [];
for (;;) {
const n = mod.ccall('sim_radio_poll_tx', 'number', ['number', 'number'], [txBuf, MAX_PKT]);
if (n <= 0) break;
out.push(mod.HEAPU8.slice(txBuf, txBuf + n));
}
return out;
},
injectRx(bytes) {
const p = mod._malloc(bytes.length);
mod.HEAPU8.set(bytes, p);
mod.ccall('sim_radio_inject_rx', null, ['number', 'number'], [p, bytes.length]);
mod._free(p);
},
call(name, ret, types, args) { return mod.ccall(name, ret, types || [], args || []); },
};
}
let L = null, P = null;
// Direct link L <-> P, 50 ms air time.
setInterval(() => {
if (!L || !P) return;
for (const [from, to] of [[L, P], [P, L]]) {
for (const pkt of from.pollTx()) setTimeout(() => to.injectRx(pkt), 50);
}
}, 40);
async function waitReady(inst) {
for (let i = 0; i < 400; i++) {
if (inst.isReady()) return true;
await new Promise(r => setTimeout(r, 25));
}
return false;
}
function prepare(inst) {
inst.call('sim_test_sync_time', 'number', ['number'], [Date.now() / 1000]);
inst.call('sim_test_set_timezone_hours', 'number', ['number'], [-Math.round(new Date().getTimezoneOffset() / 60)]);
inst.call('sim_test_disable_screen_timeout', 'number');
}
async function bootL() {
document.getElementById('status-L').textContent = 'booting…';
if (L) L.call('sim_stop_main_loop', null);
L = wrap(await MeshCoreSimLvgl({ simInstanceTag: 'L' }), 'L');
const ok = await waitReady(L);
if (ok) prepare(L);
document.getElementById('status-L').textContent = ok ? 'ready' : 'boot timed out';
return ok;
}
async function bootP() {
P = wrap(await MeshCoreSim({ simInstanceTag: 'P' }), 'P');
const ok = await waitReady(P);
if (ok) { prepare(P); P.call('sim_test_show_all_home_pages', 'number'); }
document.getElementById('status-P').textContent = ok ? 'ready' : 'boot timed out';
return ok;
}
function advertBoth() {
L.call('sim_test_advert_flood', 'number');
setTimeout(() => P.call('sim_test_advert_flood', 'number'), 300);
log('advert: both');
}
// ── Touch: mouse / pen / finger on the LCD canvas ─────────────────────
const canvas = document.getElementById('sim-canvas-L');
function touch(x, y, down) {
if (L) L.call('sim_lcd_touch', null, ['number', 'number', 'number'], [x | 0, y | 0, down ? 1 : 0]);
}
function toDevice(e) {
const r = canvas.getBoundingClientRect();
const bx = (r.width - canvas.clientWidth) / 2, by = (r.height - canvas.clientHeight) / 2; // border
const x = (e.clientX - r.left - bx) * 320 / canvas.clientWidth;
const y = (e.clientY - r.top - by) * 240 / canvas.clientHeight;
return [Math.max(0, Math.min(319, x)), Math.max(0, Math.min(239, y))];
}
let pressed = false;
canvas.addEventListener('pointerdown', (e) => { pressed = true; canvas.setPointerCapture(e.pointerId); touch(...toDevice(e), true); });
canvas.addEventListener('pointermove', (e) => { if (pressed) touch(...toDevice(e), true); });
const lift = (e) => { if (!pressed) return; pressed = false; touch(...toDevice(e), false); };
canvas.addEventListener('pointerup', lift);
canvas.addEventListener('pointercancel', lift);
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
async function tap(x, y) { touch(x, y, true); await sleep(80); touch(x, y, false); await sleep(120); }
async function hold(x, y, ms) { touch(x, y, true); await sleep(ms || 700); } // leaves the finger down
document.getElementById('btn-L').addEventListener('click', () => L && L.call('sim_lcd_button', null));
document.getElementById('reset-L').addEventListener('click', () => bootL());
document.getElementById('advert').addEventListener('click', advertBoth);
document.getElementById('peer-dm').addEventListener('click', () => {
const t = document.getElementById('peer-text').value;
const r = P.call('sim_test_send_msg_to_first_contact', 'number', ['string'], [t]);
log(r < 0 ? 'peer DM: no contact yet (Advert both first)' : 'peer DM sent: ' + t);
});
document.getElementById('peer-ch').addEventListener('click', () => {
const t = document.getElementById('peer-text').value;
const r = P.call('sim_test_send_channel_msg', 'number', ['number', 'string'], [0, t]);
log(r ? 'peer → Public: ' + t : 'peer → Public failed');
});
const ready = (async () => {
const [okP, okL] = [await bootP(), await bootL()];
if (okP && okL) { advertBoth(); log('both devices up'); }
return okP && okL;
})();
window.sim = {
ready, touch, tap, hold, advertBoth,
get L() { return L; }, get P() { return P; },
peerDM(t) { return P.call('sim_test_send_msg_to_first_contact', 'number', ['string'], [t]); },
peerChannel(t) { return P.call('sim_test_send_channel_msg', 'number', ['number', 'string'], [0, t]); },
contacts(inst) { return (inst || L).call('sim_test_get_num_contacts', 'number'); },
};
</script>
</body>
</html>