mirror of
https://github.com/MarekZegare4/MeshCore-Solo.git
synced 2026-10-09 03:06:39 +00:00
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:
@@ -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.
|
- 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.
|
- 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`.
|
- 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.
|
- PR #3381's LVGL UI is a reference for platform glue (flush, GT911, PSRAM pool) only; its screens are not adopted.
|
||||||
|
|
||||||
## Migration plan
|
## 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.
|
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.
|
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.
|
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.
|
6. Contacts/Nearby, Admin, Bot logic extraction as the LVGL screens for them are built.
|
||||||
|
|
||||||
## Decisions
|
## Decisions
|
||||||
|
|||||||
@@ -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);
|
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
|
// How many contacts this instance has discovered so far (any type) -- lets
|
||||||
// the JS ether-tick loop poll "has advert propagation finished yet" without
|
// the JS ether-tick loop poll "has advert propagation finished yet" without
|
||||||
// guessing a fixed timeout.
|
// guessing a fixed timeout.
|
||||||
|
|||||||
@@ -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_radius(m, theme::RADIUS, 0);
|
||||||
lv_obj_set_style_text_font(m, THEME_FONT_LARGE, LV_PART_ITEMS);
|
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_point_t pt = { 0, 0 };
|
||||||
lv_indev_t* indev = lv_indev_active();
|
lv_indev_t* indev = lv_indev_active();
|
||||||
if (indev) lv_indev_get_point(indev, &pt);
|
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 scr_w = lv_display_get_horizontal_resolution(NULL);
|
||||||
int x = pt.x - w / 2;
|
int x = pt.x - w / 2;
|
||||||
if (x < 2) x = 2;
|
if (x < 2) x = 2;
|
||||||
if (x + w > scr_w - 2) x = scr_w - 2 - w;
|
if (x + w > scr_w - 2) x = scr_w - 2 - w;
|
||||||
int y = pt.y - 70;
|
int y = pt.y - 60;
|
||||||
if (y < theme::STATUS_H) y = theme::STATUS_H;
|
if (y < kb_area.y1) y = kb_area.y1;
|
||||||
lv_obj_set_pos(m, x, y);
|
lv_obj_set_pos(m, x, y);
|
||||||
lv_obj_add_event_cb(m, onPopupPick, LV_EVENT_VALUE_CHANGED, NULL);
|
lv_obj_add_event_cb(m, onPopupPick, LV_EVENT_VALUE_CHANGED, NULL);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,6 +69,46 @@ static bool touched() {
|
|||||||
|
|
||||||
static void swallowTouch() { s_swallow = true; }
|
static void swallowTouch() { s_swallow = true; }
|
||||||
|
|
||||||
|
#elif defined(SIM_PLATFORM) && defined(__EMSCRIPTEN__)
|
||||||
|
// Browser simulator (variants/sim/build_wasm_lvgl.sh): SimLcdDisplay blits to
|
||||||
|
// a <canvas>, 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
|
#else
|
||||||
#error "ui-lvgl: no LVGL port for this board (see LvglPort.h)"
|
#error "ui-lvgl: no LVGL port for this board (see LvglPort.h)"
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -18,6 +18,13 @@
|
|||||||
|
|
||||||
static UITask* s_ui = nullptr; // for LVGL's C callbacks
|
static UITask* s_ui = nullptr; // for LVGL's C callbacks
|
||||||
|
|
||||||
|
#if defined(SIM_PLATFORM) && defined(__EMSCRIPTEN__)
|
||||||
|
#include <emscripten.h>
|
||||||
|
// 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 ─────────────────────────────────────────────────────────────
|
// ── Small helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
static void styleSurface(lv_obj_t* o, uint32_t bg) {
|
static void styleSurface(lv_obj_t* o, uint32_t bg) {
|
||||||
@@ -105,14 +112,18 @@ void UITask::loop() {
|
|||||||
pollConnection();
|
pollConnection();
|
||||||
drainCoreEvents();
|
drainCoreEvents();
|
||||||
|
|
||||||
|
bool btn_click = false;
|
||||||
#ifdef PIN_USER_BTN
|
#ifdef PIN_USER_BTN
|
||||||
int ev = user_btn.check();
|
btn_click = user_btn.check() == BUTTON_EVENT_CLICK;
|
||||||
if (ev == 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();
|
if (_asleep) wake();
|
||||||
else if (_screen == SCR_HOME) sleep();
|
else if (_screen == SCR_HOME) sleep();
|
||||||
else back();
|
else back();
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
|
|
||||||
if (_asleep) {
|
if (_asleep) {
|
||||||
if (lvport::touched()) { lvport::swallowTouch(); wake(); }
|
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) {
|
lv_obj_t* UITask::newScreen(const char* title, bool with_back) {
|
||||||
_home_clock = _home_date = _home_unread = nullptr;
|
_home_clock = _home_date = _home_unread = nullptr;
|
||||||
_thread_list = _compose_ta = _keyboard = nullptr;
|
_thread_list = _compose_ta = _keyboard = nullptr;
|
||||||
|
_header = _body = nullptr;
|
||||||
lv_obj_t* prev = lv_screen_active();
|
lv_obj_t* prev = lv_screen_active();
|
||||||
lv_obj_t* scr = lv_obj_create(NULL);
|
lv_obj_t* scr = lv_obj_create(NULL);
|
||||||
styleSurface(scr, theme::BG);
|
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_remove_flag(hdr, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
lv_obj_set_size(hdr, LV_PCT(100), 32);
|
lv_obj_set_size(hdr, LV_PCT(100), 32);
|
||||||
lv_obj_set_pos(hdr, 0, top);
|
lv_obj_set_pos(hdr, 0, top);
|
||||||
|
_header = hdr;
|
||||||
if (with_back) {
|
if (with_back) {
|
||||||
lv_obj_t* b = lv_button_create(hdr);
|
lv_obj_t* b = lv_button_create(hdr);
|
||||||
lv_obj_set_size(b, 40, 28);
|
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_flex_flow(body, LV_FLEX_FLOW_COLUMN);
|
||||||
lv_obj_set_style_pad_all(body, theme::PAD, 0);
|
lv_obj_set_style_pad_all(body, theme::PAD, 0);
|
||||||
lv_obj_set_style_pad_row(body, theme::GAP, 0);
|
lv_obj_set_style_pad_row(body, theme::GAP, 0);
|
||||||
|
_body = body;
|
||||||
lv_screen_load(scr);
|
lv_screen_load(scr);
|
||||||
if (prev && prev != scr) lv_obj_delete_async(prev);
|
if (prev && prev != scr) lv_obj_delete_async(prev);
|
||||||
return body;
|
return body;
|
||||||
@@ -556,7 +570,7 @@ void UITask::buildContacts() {
|
|||||||
static void onKeyboard(lv_event_t* e) {
|
static void onKeyboard(lv_event_t* e) {
|
||||||
lv_event_code_t code = lv_event_get_code(e);
|
lv_event_code_t code = lv_event_get_code(e);
|
||||||
if (code == LV_EVENT_READY) s_ui->sendFromCompose();
|
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:
|
// 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
|
lv_textarea_set_insert_replace(ta, ""); // reject: would exceed the byte limit
|
||||||
}
|
}
|
||||||
|
|
||||||
static void onComposeClicked(lv_event_t* e) {
|
static void onComposeClicked(lv_event_t* e) { (void)e; s_ui->setKeyboardVisible(true); }
|
||||||
lv_obj_t* kb = (lv_obj_t*)lv_event_get_user_data(e);
|
|
||||||
lv_obj_remove_flag(kb, LV_OBJ_FLAG_HIDDEN);
|
// 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) {
|
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_flex_flow(_thread_list, LV_FLEX_FLOW_COLUMN);
|
||||||
lv_obj_set_style_pad_all(_thread_list, theme::PAD, 0);
|
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_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;
|
_compose_ta = nullptr;
|
||||||
_keyboard = nullptr;
|
_keyboard = nullptr;
|
||||||
@@ -625,19 +664,29 @@ void UITask::buildThread() {
|
|||||||
lv_obj_t* bar = lv_obj_create(body);
|
lv_obj_t* bar = lv_obj_create(body);
|
||||||
styleSurface(bar, theme::BG);
|
styleSurface(bar, theme::BG);
|
||||||
lv_obj_remove_flag(bar, LV_OBJ_FLAG_SCROLLABLE);
|
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);
|
lv_obj_set_style_pad_all(bar, 4, 0);
|
||||||
_compose_ta = lv_textarea_create(bar);
|
_compose_ta = lv_textarea_create(bar);
|
||||||
lv_textarea_set_one_line(_compose_ta, true);
|
lv_textarea_set_one_line(_compose_ta, true);
|
||||||
lv_textarea_set_placeholder_text(_compose_ta, "Message");
|
lv_textarea_set_placeholder_text(_compose_ta, "Message");
|
||||||
lv_obj_add_event_cb(_compose_ta, onComposeInsert, LV_EVENT_INSERT, NULL);
|
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);
|
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
|
// 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.
|
// 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)
|
_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_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_READY, _keyboard);
|
||||||
lv_obj_add_event_cb(_keyboard, onKeyboard, LV_EVENT_CANCEL, _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_style_pad_row(b, 2, 0);
|
||||||
lv_obj_set_flex_flow(b, LV_FLEX_FLOW_COLUMN);
|
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_obj_t* t = label(b, text, THEME_FONT_BODY, theme::TEXT);
|
||||||
lv_label_set_long_mode(t, LV_LABEL_LONG_WRAP);
|
lv_label_set_long_mode(t, LV_LABEL_LONG_WRAP);
|
||||||
lv_obj_set_style_max_width(t, 238, 0);
|
lv_obj_set_style_max_width(t, 238, 0);
|
||||||
lv_obj_set_width(t, LV_SIZE_CONTENT);
|
lv_obj_set_width(t, LV_SIZE_CONTENT);
|
||||||
|
|
||||||
char meta[32];
|
if (!meta_in_header) {
|
||||||
formatAge(meta, sizeof(meta), ts);
|
lv_obj_t* m = label(b, meta, THEME_FONT_SMALL, theme::TEXT_MUTED);
|
||||||
lv_obj_t* m = label(b, meta, THEME_FONT_SMALL, theme::TEXT_MUTED);
|
if (status) {
|
||||||
if (status) {
|
lv_label_set_text_fmt(m, "%s %s", meta, status);
|
||||||
lv_label_set_text_fmt(m, "%s %s", meta, status);
|
lv_obj_set_style_text_color(m, lv_color_hex(status_col), 0);
|
||||||
lv_obj_set_style_text_color(m, lv_color_hex(status_col), 0);
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -774,7 +840,7 @@ void UITask::sendFromCompose() {
|
|||||||
}
|
}
|
||||||
if (ok) {
|
if (ok) {
|
||||||
lv_textarea_set_text(_compose_ta, "");
|
lv_textarea_set_text(_compose_ta, "");
|
||||||
if (_keyboard) lv_obj_add_flag(_keyboard, LV_OBJ_FLAG_HIDDEN);
|
setKeyboardVisible(false);
|
||||||
refreshThread();
|
refreshThread();
|
||||||
} else {
|
} else {
|
||||||
showToast("Send failed");
|
showToast("Send failed");
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ public:
|
|||||||
void openDM(const uint8_t* pub_key);
|
void openDM(const uint8_t* pub_key);
|
||||||
void back();
|
void back();
|
||||||
void sendFromCompose();
|
void sendFromCompose();
|
||||||
|
void setKeyboardVisible(bool show);
|
||||||
void showToast(const char* text, uint32_t ms = 2500);
|
void showToast(const char* text, uint32_t ms = 2500);
|
||||||
|
|
||||||
// Kept for main.cpp's sim hooks (ui-new API).
|
// 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_clock = nullptr;
|
||||||
lv_obj_t* _home_date = nullptr;
|
lv_obj_t* _home_date = nullptr;
|
||||||
lv_obj_t* _home_unread = 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* _thread_list = nullptr;
|
||||||
lv_obj_t* _compose_ta = nullptr;
|
lv_obj_t* _compose_ta = nullptr;
|
||||||
lv_obj_t* _keyboard = nullptr;
|
lv_obj_t* _keyboard = nullptr;
|
||||||
|
|||||||
@@ -360,4 +360,88 @@ public:
|
|||||||
// (see the class comment) -- nothing left to flush.
|
// (see the class comment) -- nothing left to flush.
|
||||||
void endFrame() override { }
|
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__
|
#endif // __EMSCRIPTEN__
|
||||||
|
|||||||
@@ -52,6 +52,18 @@ else
|
|||||||
OPT_FLAGS=(-O2)
|
OPT_FLAGS=(-O2)
|
||||||
fi
|
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"
|
mkdir -p "$OUT_DIR"
|
||||||
cd "$REPO_ROOT"
|
cd "$REPO_ROOT"
|
||||||
|
|
||||||
@@ -105,7 +117,7 @@ SRCS=(
|
|||||||
examples/companion_radio/main.cpp
|
examples/companion_radio/main.cpp
|
||||||
examples/companion_radio/MyMesh.cpp
|
examples/companion_radio/MyMesh.cpp
|
||||||
examples/companion_radio/DataStore.cpp
|
examples/companion_radio/DataStore.cpp
|
||||||
examples/companion_radio/ui-new/UITask.cpp
|
"examples/companion_radio/ui-$SIM_UI/UITask.cpp"
|
||||||
)
|
)
|
||||||
|
|
||||||
INCLUDES=(
|
INCLUDES=(
|
||||||
@@ -118,7 +130,7 @@ INCLUDES=(
|
|||||||
-Ilib/ed25519
|
-Ilib/ed25519
|
||||||
-Isrc
|
-Isrc
|
||||||
-Iexamples/companion_radio
|
-Iexamples/companion_radio
|
||||||
-Iexamples/companion_radio/ui-new
|
"-Iexamples/companion_radio/ui-$SIM_UI"
|
||||||
)
|
)
|
||||||
|
|
||||||
DEFINES=(
|
DEFINES=(
|
||||||
@@ -158,6 +170,21 @@ if [ -n "${FIRMWARE_VERSION:-}" ]; then
|
|||||||
DEFINES+=("-DFIRMWARE_VERSION=\"${FIRMWARE_VERSION}\"")
|
DEFINES+=("-DFIRMWARE_VERSION=\"${FIRMWARE_VERSION}\"")
|
||||||
fi
|
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
|
# -funsigned-char: carried over from Phase 1 verbatim -- real ARM cores
|
||||||
# default `char` to unsigned; em++'s target (wasm32) defaults it to signed,
|
# 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
|
# 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")
|
OBJS+=("$obj")
|
||||||
done
|
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
|
# FS is exported so a host page (or a manual verification script) can
|
||||||
# directly inspect what DataStore/IdentityStore actually wrote -- e.g.
|
# directly inspect what DataStore/IdentityStore actually wrote -- e.g.
|
||||||
# Module.FS.readFile('/sim_data/identity/_main.id') -- to prove IDBFS
|
# Module.FS.readFile('/sim_data/identity/_main.id') -- to prove IDBFS
|
||||||
@@ -233,14 +287,14 @@ done
|
|||||||
-sALLOW_MEMORY_GROWTH=1 \
|
-sALLOW_MEMORY_GROWTH=1 \
|
||||||
-sFORCE_FILESYSTEM=1 \
|
-sFORCE_FILESYSTEM=1 \
|
||||||
-sMODULARIZE=1 \
|
-sMODULARIZE=1 \
|
||||||
-sEXPORT_NAME=MeshCoreSim \
|
-sEXPORT_NAME=$EXPORT_NAME \
|
||||||
-sENVIRONMENT=web \
|
-sENVIRONMENT=web \
|
||||||
-sEXIT_RUNTIME=0 \
|
-sEXIT_RUNTIME=0 \
|
||||||
-sEXPORTED_RUNTIME_METHODS=FS,ccall,cwrap,HEAPU8,HEAPF32,HEAP32 \
|
-sEXPORTED_RUNTIME_METHODS=FS,ccall,cwrap,HEAPU8,HEAPF32,HEAP32 \
|
||||||
-sEXPORTED_FUNCTIONS=_main,_malloc,_free \
|
-sEXPORTED_FUNCTIONS=_main,_malloc,_free \
|
||||||
-o "$OUT_DIR/meshcore_sim.js"
|
-o "$OUT_DIR/$OUT_NAME.js"
|
||||||
|
|
||||||
echo ""
|
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 "Serve variants/sim/web/ locally and open index.html, e.g.:"
|
||||||
echo " cd $SCRIPT_DIR/web && python3 -m http.server 8080"
|
echo " cd $SCRIPT_DIR/web && python3 -m http.server 8080"
|
||||||
|
|||||||
@@ -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 · 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 · 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>
|
||||||
Reference in New Issue
Block a user