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:
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 <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
|
||||
#error "ui-lvgl: no LVGL port for this board (see LvglPort.h)"
|
||||
#endif
|
||||
|
||||
@@ -18,6 +18,13 @@
|
||||
|
||||
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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user