diff --git a/docs/development/ui-core.md b/docs/development/ui-core.md index 0114bb83..93ffeb5e 100644 --- a/docs/development/ui-core.md +++ b/docs/development/ui-core.md @@ -146,7 +146,7 @@ consumes keys only. - LVGL 9 (pinned), memory in a PSRAM pool; partial-render buffers in internal RAM, flush over QSPI via LovyanGFX. - Theme tokens (colours, spacing, typography) in one place — the first chance to realise the "Amber Trace" direction in colour. - Fonts ✅ Noto Sans generated by `ui-lvgl/fonts/generate.sh` (lv_font_conv): European Latin (Latin-1, Extended-A/B), Greek, Cyrillic + supplement, typographic punctuation, currency, LVGL symbols; 12/14/16/20 px, clock 40 px digits only. On-screen keyboard (`ui-lvgl/Keyboard.h`), phone-style: one layout per script (Latin QWERTY / Cyrillic ЙЦУКЕН / Greek) chosen by the same `keyboard_main_alphabet` / `keyboard_alt_alphabet` prefs as `ui-new` (globe key switches), hold a key for its variants; compose limit counted in UTF-8 bytes. Long-press variants and UTF-8 case mapping are shared with `ui-new` in `ui-core/KeyboardData.h`. -- Runs in the WASM sim at 320×240 with mouse as touch, so most UI work needs no flashing. +- Simulator ✅: `SIM_UI=lvgl variants/sim/build_wasm.sh` builds the same `ui-lvgl` code for the browser (`SimLcdDisplay` in `variants/sim/SimDisplayDriver.h` blits LVGL's RGB565 flush to a 320×240 canvas; the `SIM_PLATFORM` branch of `ui-lvgl/LvglPort.h` feeds the mouse in as touch). LVGL comes from the L2 env's PlatformIO libdeps; its objects are cached in `web/build/obj_lvgl/`. `web/lvgl.html` runs it next to a `ui-new` peer on a direct link (buttons: advert, peer DM, peer → Public, the board button) and exposes `window.sim` (`tap`, `hold`, `touch`, `peerDM`, `peerChannel`) for headless checks. - PR #3381's LVGL UI is a reference for platform glue (flush, GT911, PSRAM pool) only; its screens are not adopted. ## Migration plan @@ -159,7 +159,7 @@ checked in the sim plus on L1 hardware before the next one. 2. **Engines, one per commit.** Clock tools ✅ (also introduced `ui-core/UiEvents.h`, the Core → frontend event queue; `UITask::tickCore()` runs `UiCore::loop()` and drains it) → ping ✅ → course-over-ground ✅ → live share ✅ → locator ✅ → trail ✅ → notifications ✅ (with step 3). `UITask` shrinks to screen management + drawing. 3. **Flip the interface** ✅. `UiCore` is `MyMesh::Listener`; `ui-new`'s `UITask` derives `UITaskBase` + `UiCoreHost` and is fed by events, drained at the start (mesh-originated) and end (engines) of its `loop()`. Mesh callbacks no longer touch the display or buzzer directly. 4. **Settings schema.** Convert `SettingsScreen` section by section. -5. **`ui-lvgl` skeleton** ✅ on L2 hardware (env `Wio_Tracker_L2_companion_solo_lvgl`, LVGL 9.2.2): status bar, home, conversation list, contact picker, conversation with keyboard, toasts, display sleep/wake. The Core gained the first actions (`sendDirectText`, `sendChannelText`) and runs DM resends itself. Still to do: sim target at 320×240, a font with Polish/Cyrillic coverage, then screens by priority. +5. **`ui-lvgl` skeleton** ✅ on L2 hardware (env `Wio_Tracker_L2_companion_solo_lvgl`, LVGL 9.2.2): status bar, home, conversation list, contact picker, conversation with keyboard, toasts, display sleep/wake. The Core gained the first actions (`sendDirectText`, `sendChannelText`) and runs DM resends itself. Then: European fonts + phone-style keyboard ✅, 320×240 sim target ✅. Next: screens by priority. 6. Contacts/Nearby, Admin, Bot logic extraction as the LVGL screens for them are built. ## Decisions diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 764e50b4..2ddbe28b 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -456,6 +456,18 @@ extern "C" EMSCRIPTEN_KEEPALIVE int sim_test_send_msg_to_first_contact(const cha return the_mesh.sendMessage(ci, ts, 0, text, expected_ack, est_timeout); } +// Sends `text` on group channel `channel_idx` (0 = Public) via +// BaseChatMesh::sendGroupMessage() -- what CMD_SEND_CHANNEL_TXT_MSG calls -- +// so a peer instance can post into a channel another instance is viewing +// (web/lvgl.html). Returns 1 if sent, 0 if not (no such channel / not ready). +extern "C" EMSCRIPTEN_KEEPALIVE int sim_test_send_channel_msg(int channel_idx, const char* text) { + if (!g_sim_ready || !text) return 0; + ChannelDetails ch; + if (!the_mesh.getChannel(channel_idx, ch)) return 0; + return the_mesh.sendGroupMessage(rtc_clock.getCurrentTime(), ch.channel, + the_mesh.getNodeName(), text, strlen(text)) ? 1 : 0; +} + // How many contacts this instance has discovered so far (any type) -- lets // the JS ether-tick loop poll "has advert propagation finished yet" without // guessing a fixed timeout. diff --git a/examples/companion_radio/ui-lvgl/Keyboard.h b/examples/companion_radio/ui-lvgl/Keyboard.h index 2c6bd0d6..93604045 100644 --- a/examples/companion_radio/ui-lvgl/Keyboard.h +++ b/examples/companion_radio/ui-lvgl/Keyboard.h @@ -198,16 +198,19 @@ static void openPopup(lv_obj_t* kbd, const char* variants, bool upper) { lv_obj_set_style_radius(m, theme::RADIUS, 0); lv_obj_set_style_text_font(m, THEME_FONT_LARGE, LV_PART_ITEMS); - // Above the pressed key, clamped to the screen. + // Above the pressed key, but never above the keyboard's own top edge: the + // compose field sits right there and must stay readable while picking. lv_point_t pt = { 0, 0 }; lv_indev_t* indev = lv_indev_active(); if (indev) lv_indev_get_point(indev, &pt); + lv_area_t kb_area; + lv_obj_get_coords(kbd, &kb_area); int scr_w = lv_display_get_horizontal_resolution(NULL); int x = pt.x - w / 2; if (x < 2) x = 2; if (x + w > scr_w - 2) x = scr_w - 2 - w; - int y = pt.y - 70; - if (y < theme::STATUS_H) y = theme::STATUS_H; + int y = pt.y - 60; + if (y < kb_area.y1) y = kb_area.y1; lv_obj_set_pos(m, x, y); lv_obj_add_event_cb(m, onPopupPick, LV_EVENT_VALUE_CHANGED, NULL); } diff --git a/examples/companion_radio/ui-lvgl/LvglPort.h b/examples/companion_radio/ui-lvgl/LvglPort.h index cc399218..50a2cf11 100644 --- a/examples/companion_radio/ui-lvgl/LvglPort.h +++ b/examples/companion_radio/ui-lvgl/LvglPort.h @@ -69,6 +69,46 @@ static bool touched() { static void swallowTouch() { s_swallow = true; } +#elif defined(SIM_PLATFORM) && defined(__EMSCRIPTEN__) +// Browser simulator (variants/sim/build_wasm_lvgl.sh): SimLcdDisplay blits to +// a , the host page feeds the mouse in as touch. + +static bool s_swallow = false; + +static void flushCb(lv_display_t* disp, const lv_area_t* area, uint8_t* px_map) { + display.blit(area->x1, area->y1, area->x2 - area->x1 + 1, area->y2 - area->y1 + 1, + (const uint16_t*)px_map); + lv_display_flush_ready(disp); +} + +static void touchCb(lv_indev_t* indev, lv_indev_data_t* data) { + (void)indev; + const SimLcdDisplay::Touch& t = SimLcdDisplay::touchState(); + if (s_swallow) { + if (!t.down) s_swallow = false; + data->state = LV_INDEV_STATE_RELEASED; + return; + } + data->state = t.down ? LV_INDEV_STATE_PRESSED : LV_INDEV_STATE_RELEASED; + data->point.x = t.x; + data->point.y = t.y; +} + +static bool begin() { + lv_display_t* disp = lv_display_create(SimLcdDisplay::W, SimLcdDisplay::H); + lv_display_set_flush_cb(disp, flushCb); + static uint8_t buf[SimLcdDisplay::W * 40 * 2]; // same 40-line partial buffer as the board + lv_display_set_buffers(disp, buf, nullptr, sizeof(buf), LV_DISPLAY_RENDER_MODE_PARTIAL); + + lv_indev_t* indev = lv_indev_create(); + lv_indev_set_type(indev, LV_INDEV_TYPE_POINTER); + lv_indev_set_read_cb(indev, touchCb); + return true; +} + +static bool touched() { return SimLcdDisplay::touchState().down; } +static void swallowTouch() { s_swallow = true; } + #else #error "ui-lvgl: no LVGL port for this board (see LvglPort.h)" #endif diff --git a/examples/companion_radio/ui-lvgl/UITask.cpp b/examples/companion_radio/ui-lvgl/UITask.cpp index 981e00a3..46fe3607 100644 --- a/examples/companion_radio/ui-lvgl/UITask.cpp +++ b/examples/companion_radio/ui-lvgl/UITask.cpp @@ -18,6 +18,13 @@ static UITask* s_ui = nullptr; // for LVGL's C callbacks +#if defined(SIM_PLATFORM) && defined(__EMSCRIPTEN__) +#include +// The board's one button, pressed from the simulator page (web/lvgl.html). +static bool s_sim_btn_click = false; +extern "C" EMSCRIPTEN_KEEPALIVE void sim_lcd_button() { s_sim_btn_click = true; } +#endif + // ── Small helpers ───────────────────────────────────────────────────────────── static void styleSurface(lv_obj_t* o, uint32_t bg) { @@ -105,14 +112,18 @@ void UITask::loop() { pollConnection(); drainCoreEvents(); + bool btn_click = false; #ifdef PIN_USER_BTN - int ev = user_btn.check(); - if (ev == BUTTON_EVENT_CLICK) { + btn_click = user_btn.check() == BUTTON_EVENT_CLICK; +#elif defined(SIM_PLATFORM) && defined(__EMSCRIPTEN__) + btn_click = s_sim_btn_click; + s_sim_btn_click = false; +#endif + if (btn_click) { if (_asleep) wake(); else if (_screen == SCR_HOME) sleep(); else back(); } -#endif if (_asleep) { if (lvport::touched()) { lvport::swallowTouch(); wake(); } @@ -311,6 +322,7 @@ static void onBack(lv_event_t* e) { (void)e; s_ui->back(); } lv_obj_t* UITask::newScreen(const char* title, bool with_back) { _home_clock = _home_date = _home_unread = nullptr; _thread_list = _compose_ta = _keyboard = nullptr; + _header = _body = nullptr; lv_obj_t* prev = lv_screen_active(); lv_obj_t* scr = lv_obj_create(NULL); styleSurface(scr, theme::BG); @@ -323,6 +335,7 @@ lv_obj_t* UITask::newScreen(const char* title, bool with_back) { lv_obj_remove_flag(hdr, LV_OBJ_FLAG_SCROLLABLE); lv_obj_set_size(hdr, LV_PCT(100), 32); lv_obj_set_pos(hdr, 0, top); + _header = hdr; if (with_back) { lv_obj_t* b = lv_button_create(hdr); lv_obj_set_size(b, 40, 28); @@ -346,6 +359,7 @@ lv_obj_t* UITask::newScreen(const char* title, bool with_back) { lv_obj_set_flex_flow(body, LV_FLEX_FLOW_COLUMN); lv_obj_set_style_pad_all(body, theme::PAD, 0); lv_obj_set_style_pad_row(body, theme::GAP, 0); + _body = body; lv_screen_load(scr); if (prev && prev != scr) lv_obj_delete_async(prev); return body; @@ -556,7 +570,7 @@ void UITask::buildContacts() { static void onKeyboard(lv_event_t* e) { lv_event_code_t code = lv_event_get_code(e); if (code == LV_EVENT_READY) s_ui->sendFromCompose(); - else if (code == LV_EVENT_CANCEL) lv_obj_add_flag((lv_obj_t*)lv_event_get_user_data(e), LV_OBJ_FLAG_HIDDEN); + else if (code == LV_EVENT_CANCEL) s_ui->setKeyboardVisible(false); } // Compose limit is in UTF-8 bytes (the over-the-air limit), not characters: @@ -571,9 +585,33 @@ static void onComposeInsert(lv_event_t* e) { lv_textarea_set_insert_replace(ta, ""); // reject: would exceed the byte limit } -static void onComposeClicked(lv_event_t* e) { - lv_obj_t* kb = (lv_obj_t*)lv_event_get_user_data(e); - lv_obj_remove_flag(kb, LV_OBJ_FLAG_HIDDEN); +static void onComposeClicked(lv_event_t* e) { (void)e; s_ui->setKeyboardVisible(true); } + +// Keyboard up: the screen header goes away and the body takes its 32 px, so +// a line or two of the conversation stays visible above the compose field +// (240 px can't fit header + list + field + keys). The field is FOCUSED +// while the keyboard is up -- that is what makes LVGL draw its cursor. +void UITask::setKeyboardVisible(bool show) { + if (!_keyboard || !_compose_ta) return; + if (show == !lv_obj_has_flag(_keyboard, LV_OBJ_FLAG_HIDDEN)) return; + if (show) { + lv_obj_remove_flag(_keyboard, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_state(_compose_ta, LV_STATE_FOCUSED); + } else { + lv_obj_add_flag(_keyboard, LV_OBJ_FLAG_HIDDEN); + lv_obj_remove_state(_compose_ta, LV_STATE_FOCUSED); + } + if (_header && _body) { + int top = theme::STATUS_H + (show ? 0 : lv_obj_get_height(_header)); + if (show) lv_obj_add_flag(_header, LV_OBJ_FLAG_HIDDEN); + else lv_obj_remove_flag(_header, LV_OBJ_FLAG_HIDDEN); + lv_obj_set_pos(_body, 0, top); + lv_obj_set_height(_body, lv_display_get_vertical_resolution(NULL) - top); + } + if (_thread_list) { // keep the newest message in view + lv_obj_update_layout(_thread_list); + lv_obj_scroll_to_y(_thread_list, LV_COORD_MAX, LV_ANIM_OFF); + } } void UITask::openChannel(uint8_t channel_idx) { @@ -618,6 +656,7 @@ void UITask::buildThread() { lv_obj_set_flex_flow(_thread_list, LV_FLEX_FLOW_COLUMN); lv_obj_set_style_pad_all(_thread_list, theme::PAD, 0); lv_obj_set_style_pad_row(_thread_list, theme::GAP, 0); + lv_obj_set_scrollbar_mode(_thread_list, LV_SCROLLBAR_MODE_ACTIVE); // only while scrolling _compose_ta = nullptr; _keyboard = nullptr; @@ -625,19 +664,29 @@ void UITask::buildThread() { lv_obj_t* bar = lv_obj_create(body); styleSurface(bar, theme::BG); lv_obj_remove_flag(bar, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_size(bar, LV_PCT(100), 44); + lv_obj_set_size(bar, LV_PCT(100), 42); lv_obj_set_style_pad_all(bar, 4, 0); _compose_ta = lv_textarea_create(bar); lv_textarea_set_one_line(_compose_ta, true); lv_textarea_set_placeholder_text(_compose_ta, "Message"); lv_obj_add_event_cb(_compose_ta, onComposeInsert, LV_EVENT_INSERT, NULL); - lv_obj_set_size(_compose_ta, LV_PCT(100), 36); + lv_obj_set_size(_compose_ta, LV_PCT(100), 34); lv_obj_align(_compose_ta, LV_ALIGN_LEFT_MID, 0, 0); + // The theme's padding leaves less than one line inside 36 px, which makes + // the field scroll vertically (text jumps as it's typed). Pad so exactly one + // body line (20 px) fits: 34 = 2*1 border + 2*6 pad + 20. + lv_obj_set_style_border_width(_compose_ta, 1, 0); + lv_obj_set_style_pad_ver(_compose_ta, 6, 0); + lv_obj_set_style_pad_hor(_compose_ta, 10, 0); + lv_obj_set_scrollbar_mode(_compose_ta, LV_SCROLLBAR_MODE_OFF); + // Cursor: the theme draws it only while FOCUSED (set while the keyboard is up). + lv_obj_set_style_border_color(_compose_ta, lv_color_hex(theme::ACCENT), LV_PART_CURSOR | LV_STATE_FOCUSED); + lv_obj_set_style_border_width(_compose_ta, 2, LV_PART_CURSOR | LV_STATE_FOCUSED); // In the body's flex column below the compose bar: showing it shrinks the // message list, so the text field stays visible just above the keys. _keyboard = kb::create(body, _prefs); // phone-style, scripts from prefs, hold for accents (Keyboard.h) - lv_obj_set_size(_keyboard, LV_PCT(100), 130); + lv_obj_set_size(_keyboard, LV_PCT(100), 124); lv_keyboard_set_textarea(_keyboard, _compose_ta); lv_obj_add_event_cb(_keyboard, onKeyboard, LV_EVENT_READY, _keyboard); lv_obj_add_event_cb(_keyboard, onKeyboard, LV_EVENT_CANCEL, _keyboard); @@ -695,18 +744,35 @@ static void bubble(lv_obj_t* list, const char* from, const char* text, bool own, lv_obj_set_style_pad_row(b, 2, 0); lv_obj_set_flex_flow(b, LV_FLEX_FLOW_COLUMN); - if (from && from[0]) label(b, from, THEME_FONT_SMALL, theme::ACCENT); + char meta[32]; + formatAge(meta, sizeof(meta), ts); + + // Channel messages: "Sender 5m" on one line above the text, which keeps + // the bubble two lines tall -- matters with the keyboard up. + bool meta_in_header = from && from[0] && !status; + if (from && from[0]) { + lv_obj_t* hdr = lv_obj_create(b); + styleSurface(hdr, theme::BG); + lv_obj_set_style_bg_opa(hdr, LV_OPA_TRANSP, 0); + lv_obj_remove_flag(hdr, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(hdr, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(hdr, LV_SIZE_CONTENT, LV_SIZE_CONTENT); + lv_obj_set_flex_flow(hdr, LV_FLEX_FLOW_ROW); + lv_obj_set_style_pad_column(hdr, 8, 0); + label(hdr, from, THEME_FONT_SMALL, theme::ACCENT); // names are <= 31 chars: fits the bubble + if (meta_in_header) label(hdr, meta, THEME_FONT_SMALL, theme::TEXT_MUTED); + } lv_obj_t* t = label(b, text, THEME_FONT_BODY, theme::TEXT); lv_label_set_long_mode(t, LV_LABEL_LONG_WRAP); lv_obj_set_style_max_width(t, 238, 0); lv_obj_set_width(t, LV_SIZE_CONTENT); - char meta[32]; - formatAge(meta, sizeof(meta), ts); - lv_obj_t* m = label(b, meta, THEME_FONT_SMALL, theme::TEXT_MUTED); - if (status) { - lv_label_set_text_fmt(m, "%s %s", meta, status); - lv_obj_set_style_text_color(m, lv_color_hex(status_col), 0); + if (!meta_in_header) { + lv_obj_t* m = label(b, meta, THEME_FONT_SMALL, theme::TEXT_MUTED); + if (status) { + lv_label_set_text_fmt(m, "%s %s", meta, status); + lv_obj_set_style_text_color(m, lv_color_hex(status_col), 0); + } } } @@ -774,7 +840,7 @@ void UITask::sendFromCompose() { } if (ok) { lv_textarea_set_text(_compose_ta, ""); - if (_keyboard) lv_obj_add_flag(_keyboard, LV_OBJ_FLAG_HIDDEN); + setKeyboardVisible(false); refreshThread(); } else { showToast("Send failed"); diff --git a/examples/companion_radio/ui-lvgl/UITask.h b/examples/companion_radio/ui-lvgl/UITask.h index 9b680ffe..e48a7d5f 100644 --- a/examples/companion_radio/ui-lvgl/UITask.h +++ b/examples/companion_radio/ui-lvgl/UITask.h @@ -42,6 +42,7 @@ public: void openDM(const uint8_t* pub_key); void back(); void sendFromCompose(); + void setKeyboardVisible(bool show); void showToast(const char* text, uint32_t ms = 2500); // Kept for main.cpp's sim hooks (ui-new API). @@ -93,6 +94,8 @@ private: lv_obj_t* _home_clock = nullptr; lv_obj_t* _home_date = nullptr; lv_obj_t* _home_unread = nullptr; + lv_obj_t* _header = nullptr; // current screen's title bar (nullptr on Home) + lv_obj_t* _body = nullptr; lv_obj_t* _thread_list = nullptr; lv_obj_t* _compose_ta = nullptr; lv_obj_t* _keyboard = nullptr; diff --git a/variants/sim/SimDisplayDriver.h b/variants/sim/SimDisplayDriver.h index 8c79eede..f9e4d156 100644 --- a/variants/sim/SimDisplayDriver.h +++ b/variants/sim/SimDisplayDriver.h @@ -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__ diff --git a/variants/sim/build_wasm.sh b/variants/sim/build_wasm.sh index 8dc436ff..c864628d 100755 --- a/variants/sim/build_wasm.sh +++ b/variants/sim/build_wasm.sh @@ -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 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" diff --git a/variants/sim/web/lvgl.html b/variants/sim/web/lvgl.html new file mode 100644 index 00000000..c2317440 --- /dev/null +++ b/variants/sim/web/lvgl.html @@ -0,0 +1,219 @@ + + + + + +ui-lvgl sim + + + +
+

Wio Tracker L2 · ui-lvgl booting…

+ +
+ + +
+
+ +
+

Peer · ui-new booting…

+ +
+
+ +
+
+ + + +
+
+
+
+ + + + + +