diff --git a/examples/companion_radio/ui-lvgl/ChatList.h b/examples/companion_radio/ui-lvgl/ChatList.h new file mode 100644 index 00000000..92b2c594 --- /dev/null +++ b/examples/companion_radio/ui-lvgl/ChatList.h @@ -0,0 +1,393 @@ +#pragma once +// Included into UITask.cpp only, where it stands (it uses what is defined above it). +// Chats: the conversation list and the contact picker (start a DM). + +// ── Conversation list ───────────────────────────────────────────────────────── + +static void onOpenChannel(lv_event_t* e) { + intptr_t i = vlist::arg(e); + if (i >= 0) s_ui->openChannel((uint8_t)i); +} + +// DM rows carry a 4-byte prefix; kept in a static table the rows point into. +static uint8_t s_dm_rows[MessageHistory::DM_HIST_MAX][4]; +static const int CONTACT_ROWS_MAX = 256; // "All" with a full contact table stays usable +static uint8_t (*s_contact_rows)[PUB_KEY_SIZE] = psramBuf(CONTACT_ROWS_MAX); +static uint16_t* s_contact_raw = psramBuf(CONTACT_ROWS_MAX); // their raw table index, for the name + +static uint16_t s_dm_pos[MessageHistory::DM_HIST_MAX]; // its newest message in the DM ring +static bool s_dm_known[MessageHistory::DM_HIST_MAX]; // a contact (has options on hold) +static void onOpenDMRow(lv_event_t* e) { + intptr_t r = vlist::arg(e); + if (r >= 0) s_ui->openDM(s_dm_rows[r]); +} +static void onOpenContactRow(lv_event_t* e) { + intptr_t i = vlist::arg(e); + if (i >= 0) s_ui->openDM(s_contact_rows[i]); +} +static void onNewChat(lv_event_t* e) { (void)e; s_ui->showContacts(); } +static void onChanRowHold(lv_event_t* e); // ChannelScreen.h +static void onChanAdd(lv_event_t* e); +static void onChanThreadMenu(lv_event_t* e); +static const int ROOM_ROWS_MAX = 32; +static uint8_t (*s_room_rows)[PUB_KEY_SIZE] = psramBuf(ROOM_ROWS_MAX); +static uint16_t s_room_raw[ROOM_ROWS_MAX]; // their raw table index, for the row +static bool s_chats_read_all = false; // Messages built with "Read all" in its header +static void onDMRowHold(lv_event_t* e); // ConversationScreen.h +static void onRoomRow(lv_event_t* e); +static void onRoomRowHold(lv_event_t* e); +static void onConvThreadMenu(lv_event_t* e); +static void onMsgHold(lv_event_t* e); +static void onChatFilter(lv_event_t* e); +enum : uint8_t { CF_CHANNELS, CF_ROOMS, CF_CONTACTS }; // favourites-only filters + +// One tappable list row: title, optional muted subtitle, optional badge. +// In a group(): a flat row, the subtitle wrapping, a chevron on the right. +static lv_obj_t* listRow(lv_obj_t* parent, const char* title, const char* sub, + lv_event_cb_t cb, void* user) { + if (isGroup(parent)) { + lv_obj_t* row = groupLine(parent, true); + groupText(row, title, sub); + label(row, LV_SYMBOL_RIGHT, THEME_FONT_SMALL, theme::TEXT_MUTED); + lv_obj_add_event_cb(row, cb, LV_EVENT_CLICKED, user); + return row; + } + lv_obj_t* row = lv_button_create(parent); + lv_obj_set_size(row, LV_PCT(100), theme::ROW_H); + lv_obj_set_style_bg_color(row, lv_color_hex(theme::SURFACE), 0); + lv_obj_set_style_bg_color(row, lv_color_hex(theme::SURFACE_2), LV_STATE_PRESSED); + lv_obj_set_style_radius(row, theme::RADIUS, 0); + lv_obj_set_style_shadow_width(row, 0, 0); + lv_obj_set_style_pad_all(row, 0, 0); + lv_obj_add_event_cb(row, cb, LV_EVENT_CLICKED, user); + lv_obj_t* t = label(row, title, THEME_FONT_BODY, theme::TEXT); + lv_label_set_long_mode(t, LV_LABEL_LONG_DOT); + lv_obj_set_width(t, 230); + lv_obj_align(t, LV_ALIGN_TOP_LEFT, theme::PAD, sub ? 5 : 13); + lv_obj_set_user_data(row, t); + if (sub) { + lv_obj_t* s = label(row, sub, THEME_FONT_SMALL, theme::TEXT_MUTED); + lv_label_set_long_mode(s, LV_LABEL_LONG_DOT); + lv_obj_set_size(s, 230, 15); // fixed height: LONG_DOT cuts instead of wrapping over the title + lv_obj_align(s, LV_ALIGN_BOTTOM_LEFT, theme::PAD, -5); + } + return row; +} + +// A listRow() for a vlist: the subtitle always there (shown or not), the +// right side free for a value or a badge; texts set by rowSet(). +static lv_obj_t* vRow(lv_obj_t* list, lv_event_cb_t cb, lv_event_cb_t hold) { + lv_obj_t* row = listRow(list, "", "", cb, NULL); + if (hold) lv_obj_add_event_cb(row, hold, LV_EVENT_LONG_PRESSED, NULL); + return row; +} +static void rowSet(lv_obj_t* row, const char* title, const char* sub, uint32_t title_col = theme::TEXT) { + lv_obj_t* t = rowTitle(row); + lv_obj_t* s = lv_obj_get_child(row, 1); + setText(t, title); + setTextColor(t, title_col); + int32_t y = sub ? 5 : 13; // as listRow() puts it + if (lv_obj_get_style_y(t, LV_PART_MAIN) != y) lv_obj_set_y(t, y); + if (sub) setText(s, sub); + if (!sub != lv_obj_has_flag(s, LV_OBJ_FLAG_HIDDEN)) lv_obj_set_flag(s, LV_OBJ_FLAG_HIDDEN, !sub); +} + +// The same with a value on the right (a distance, a signal), set by rowValue(). +static lv_obj_t* vRowValue(lv_obj_t* list, lv_event_cb_t cb) { + lv_obj_t* row = vRow(list, cb, NULL); + lv_obj_align(label(row, "", THEME_FONT_SMALL, theme::TEXT_MUTED), LV_ALIGN_RIGHT_MID, -theme::PAD, 0); + return row; +} +static void rowValue(lv_obj_t* row, const char* text, uint32_t col) { + lv_obj_t* r = lv_obj_get_child(row, 2); + setText(r, text); + setTextColor(r, col); +} + +static lv_obj_t* sectionTitle(lv_obj_t* parent, const char* text) { + lv_obj_t* l = label(parent, text, THEME_FONT_SMALL, theme::TEXT_MUTED); + lv_obj_set_style_pad_top(l, 4, 0); + return l; +} + +// Messages section title: a chevron and the title fold the section (tap), +// an "All" / "★ Fav" pill on the right (filter >= 0) flips its +// favourites-only filter. Folded, the title carries the unread count. +enum : uint8_t { FOLD_CHANNELS, FOLD_DIRECT, FOLD_ROOMS }; +static int s_chat_fold = -1; // bit per FOLD_*; -1: not read from NVS yet +static void onChatFold(lv_event_t* e) { s_ui->chatFold((int)(uintptr_t)lv_event_get_user_data(e)); } +static bool chatFolded(int which) { + if (s_chat_fold < 0) s_chat_fold = lvport::loadChatFold(); + return s_chat_fold & (1 << which); +} + +static void chatSection(lv_obj_t* parent, const char* text, int fold, int unread, int filter, bool fav_only) { + bool folded = chatFolded(fold); + lv_obj_t* row = lv_obj_create(parent); + styleSurface(row, theme::BG); + lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(row, LV_PCT(100), 30); + lv_obj_add_flag(row, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_style_opa(row, LV_OPA_60, LV_STATE_PRESSED); + lv_obj_add_event_cb(row, onChatFold, LV_EVENT_CLICKED, (void*)(uintptr_t)fold); + lv_obj_t* ch = label(row, folded ? LV_SYMBOL_RIGHT : LV_SYMBOL_DOWN, THEME_FONT_SMALL, theme::TEXT_MUTED); + lv_obj_align(ch, LV_ALIGN_LEFT_MID, 2, 0); + lv_obj_t* t = label(row, text, THEME_FONT_SMALL, theme::TEXT_MUTED); + lv_obj_align(t, LV_ALIGN_LEFT_MID, 22, 0); + if (folded && unread > 0) { // what's waiting inside + lv_obj_t* n = label(row, "", THEME_FONT_SMALL, theme::ACCENT); + lv_label_set_text_fmt(n, "%d new", unread); + lv_obj_align_to(n, t, LV_ALIGN_OUT_RIGHT_MID, 8, 0); + } + if (filter < 0 || folded) return; + styleSelected(pillButton(row, fav_only ? UI_SYMBOL_STAR " Fav" : "All", theme::SURFACE, onChatFilter, filter), fav_only); +} + +// Section title with an "All" / "★ Fav" pill on the right that flips the +// section's favourites-only filter. +static void sectionWithFilter(lv_obj_t* parent, const char* text, bool fav_only, uint8_t which) { + lv_obj_t* row = lv_obj_create(parent); + styleSurface(row, theme::BG); + lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(row, LV_PCT(100), 26); + lv_obj_align(label(row, text, THEME_FONT_SMALL, theme::TEXT_MUTED), LV_ALIGN_BOTTOM_LEFT, 0, -2); + styleSelected(pillButton(row, fav_only ? UI_SYMBOL_STAR " Fav" : "All", theme::SURFACE, onChatFilter, which), fav_only); +} + +void UITask::showChats() { + _screen = SCR_CHATS; + buildChats(); +} + +static void onMarkAllRead(lv_event_t* e) { (void)e; s_ui->markAllRead(); } +static lv_obj_t* headerButton(lv_obj_t* hdr, const char* text, lv_event_cb_t cb, int right, lv_obj_t** label_out); + +void UITask::buildChats() { + lv_obj_t* body = newScreen("Messages", true); + s_chats_read_all = unreadTotal() > 0; + if (_header) { // a new message first, whatever the lists below hold + lv_obj_t* nb = headerButton(_header, LV_SYMBOL_EDIT " New", onNewChat, 4, NULL); + stylePrimary(nb); + if (s_chats_read_all) { + lv_obj_t* rb = headerButton(_header, LV_SYMBOL_OK " Read all", onMarkAllRead, 0, NULL); + layoutNow(_header); + lv_obj_align_to(rb, nb, LV_ALIGN_OUT_LEFT_MID, -6, 0); + } + } + vlist::attach(body); + fillChats(); +} + +// Messages again where it is (a new message, a section folded, back from +// the lock): the rows in view rewritten, the rest as they scroll in. Built +// anew only when "Read all" comes or goes. +void UITask::refreshChats() { + if (_screen != SCR_CHATS) { s_chats_stale = false; return; } + if (!_body || !vlist::of(_body) || (unreadTotal() > 0) != s_chats_read_all) { + int y = _body ? lv_obj_get_scroll_y(_body) : 0; + buildChats(); + layoutNow(_body); + lv_obj_scroll_to_y(_body, y, LV_ANIM_OFF); + return; + } + s_chats_stale = vlist::pressed(_body); // held: once it's let go + if (!s_chats_stale) fillChats(); +} + +void UITask::fillChats() { + lv_obj_t* body = _body; + vlist::begin(body); + auto own = [body]() { vlist::own(body, lv_obj_get_child(body, -1)); }; + + // Channels: favourites first (unless turned off), hold a row for its options + static const vlist::Kind CHANNEL = { + theme::ROW_H, + [](lv_obj_t* list) { return vRow(list, onOpenChannel, onChanRowHold); }, + [](lv_obj_t* row, intptr_t i) { + UITask* ui = s_ui; + ChannelDetails ch; + if (!the_mesh.getChannel((int)i, ch)) ch.name[0] = '\0'; + char title[48], sub[64] = ""; + snprintf(title, sizeof(title), "%s%s%s", chanctl::favourite(ui->_prefs, (int)i) ? UI_SYMBOL_STAR " " : "", ch.name, + chanctl::notif(ui->_prefs, (int)i) == chanctl::NOTIF_MUTED ? " " UI_SYMBOL_MUTE : ""); + if (ui->_core->history.histCountForChannel((int)i) > 0) { + const ChHistEntry& e = ui->_core->history.chAtPos(ui->_core->history.histEntryForChannel((int)i, 0)); + snprintf(sub, sizeof(sub), "%s", e.text); + plainMentions(sub); + } + rowSet(row, title, sub[0] ? sub : NULL); + rowBadge(row, ui->_core->history.chUnread((int)i), ui->_core->history.chUnreadOverflow((int)i)); + }, + }; + bool ch_fav_only = _prefs && _prefs->ch_fav_only; + chatSection(body, "CHANNELS", FOLD_CHANNELS, _core->history.getTotalChannelUnread(), CF_CHANNELS, ch_fav_only); + own(); + bool fav_first = !_prefs || _prefs->fav_sort; + int ch_rows = 0; + for (int pass = fav_first ? 0 : 1; pass < 2 && !chatFolded(FOLD_CHANNELS); pass++) { + for (int i = 0; i < MAX_GROUP_CHANNELS; i++) { + ChannelDetails ch; + if (!the_mesh.getChannel(i, ch) || ch.name[0] == '\0') continue; + bool fav = chanctl::favourite(_prefs, i); + if (ch_fav_only && !fav) continue; + if (fav_first && fav != (pass == 0)) continue; + ch_rows++; + vlist::add(body, &CHANNEL, i); + } + } + if (!chatFolded(FOLD_CHANNELS)) { + if (ch_rows == 0 && ch_fav_only) { label(body, "No favourite channels", THEME_FONT_SMALL, theme::TEXT_MUTED); own(); } + lv_obj_t* add_ch = lv_button_create(body); + lv_obj_set_size(add_ch, LV_PCT(100), 32); + lv_obj_set_style_bg_color(add_ch, lv_color_hex(theme::BG), 0); + lv_obj_set_style_border_color(add_ch, lv_color_hex(theme::SURFACE_2), 0); + lv_obj_set_style_border_width(add_ch, 1, 0); + lv_obj_set_style_radius(add_ch, theme::RADIUS, 0); + lv_obj_set_style_shadow_width(add_ch, 0, 0); + lv_obj_add_event_cb(add_ch, onChanAdd, LV_EVENT_CLICKED, NULL); + lv_obj_center(label(add_ch, LV_SYMBOL_PLUS " Add channel", THEME_FONT_SMALL, theme::TEXT_MUTED)); + vlist::own(body, add_ch); + } + + // Recent direct conversations (DM ring, newest first, one row per contact) + static const vlist::Kind DM = { + theme::ROW_H, + [](lv_obj_t* list) { return vRow(list, onOpenDMRow, onDMRowHold); }, + [](lv_obj_t* row, intptr_t r) { + UITask* ui = s_ui; + const DmHistEntry& e = ui->_core->history.dmAtPos(s_dm_pos[r]); + ContactInfo c; + bool known = s_dm_known[r] && MessageHistory::contactByPrefix(s_dm_rows[r], c); + char name[48]; + contactName(s_dm_rows[r], name, sizeof(name)); + if (known && contactctl::favourite(c)) { char t[48]; snprintf(t, sizeof(t), UI_SYMBOL_STAR " %s", name); strcpy(name, t); } + if (known && contactctl::notif(ui->_prefs, c.id.pub_key) == contactctl::NOTIF_MUTED) strncat(name, " " UI_SYMBOL_MUTE, sizeof(name) - strlen(name) - 1); + char sub[64]; + snprintf(sub, sizeof(sub), "%s%s", e.outgoing ? "Me: " : "", e.text); + plainMentions(sub); + rowSet(row, name, sub); + rowBadge(row, ui->_core->dmUnread(s_dm_rows[r]), ui->_core->dmUnreadOverflow(s_dm_rows[r])); + }, + }; + chatSection(body, "DIRECT", FOLD_DIRECT, _core->dmUnreadTotal(), -1, false); + own(); + int rows = 0; + for (int j = 0; j < _core->history.dmHistCount() && rows < MessageHistory::DM_HIST_MAX && !chatFolded(FOLD_DIRECT); j++) { + int pos = _core->history.dmHistPosNewest(j); + const DmHistEntry& e = _core->history.dmAtPos(pos); + bool seen = false; + for (int r = 0; r < rows; r++) if (memcmp(s_dm_rows[r], e.prefix, 4) == 0) { seen = true; break; } + if (seen) continue; + ContactInfo c; + bool known = MessageHistory::contactByPrefix(e.prefix, c); + if (known && c.type == ADV_TYPE_ROOM) continue; // rooms have their own section + memcpy(s_dm_rows[rows], e.prefix, 4); + s_dm_pos[rows] = (uint16_t)pos; + s_dm_known[rows] = known; + vlist::add(body, &DM, rows); + rows++; + } + if (rows == 0 && !chatFolded(FOLD_DIRECT)) { label(body, "No conversations yet - New, top right", THEME_FONT_SMALL, theme::TEXT_MUTED); own(); } + + // Room servers: tap logs in (saved password or ask) and opens; hold: options + static const vlist::Kind ROOM = { + theme::ROW_H, + [](lv_obj_t* list) { return vRow(list, onRoomRow, onRoomRowHold); }, + [](lv_obj_t* row, intptr_t r) { + UITask* ui = s_ui; + ContactInfo c; + if (!the_mesh.getContactByIdx(s_room_raw[r], c) || memcmp(c.id.pub_key, s_room_rows[r], PUB_KEY_SIZE) != 0) { + rowSet(row, "?", NULL); // deleted meanwhile + return; + } + char title[48], sub[64]; + snprintf(title, sizeof(title), "%s%s", contactctl::favourite(c) ? UI_SYMBOL_STAR " " : "", c.name); + if (ui->_core->history.dmHistCountForContact(c.id.pub_key) > 0) { + const DmHistEntry& e = ui->_core->history.dmAtPos(ui->_core->history.dmHistEntryForContact(c.id.pub_key, 0)); + snprintf(sub, sizeof(sub), "%s%s", e.outgoing ? "Me: " : "", e.text); + plainMentions(sub); + } else { + snprintf(sub, sizeof(sub), "%s", ui->_core->rooms.isLoggedIn(c.id.pub_key) ? "Logged in" : "Tap to log in"); + } + rowSet(row, title, sub); + }, + }; + bool room_fav_only = _prefs && _prefs->room_fav_only; + chatSection(body, "ROOMS", FOLD_ROOMS, _core->roomUnread(), CF_ROOMS, room_fav_only); + own(); + int nrooms = 0, total = the_mesh.getNumContacts(); + for (int pass = fav_first ? 0 : 1; pass < 2 && !chatFolded(FOLD_ROOMS); pass++) { + for (int i = 0; i < total && nrooms < ROOM_ROWS_MAX; i++) { + ContactInfo c; + if (!the_mesh.getContactByIdx(MAX_ANON_CONTACTS + i, c) || c.type != ADV_TYPE_ROOM) continue; + bool fav = contactctl::favourite(c); + if (room_fav_only && !fav) continue; + if (fav_first && fav != (pass == 0)) continue; + memcpy(s_room_rows[nrooms], c.id.pub_key, PUB_KEY_SIZE); + s_room_raw[nrooms] = (uint16_t)(MAX_ANON_CONTACTS + i); + vlist::add(body, &ROOM, nrooms); + nrooms++; + } + } + if (nrooms == 0 && !chatFolded(FOLD_ROOMS)) { + label(body, room_fav_only ? "No favourite rooms" : "No room servers known", THEME_FONT_SMALL, theme::TEXT_MUTED); + own(); + } + vlist::end(body); +} + +// A section title tapped: folded / unfolded (kept in NVS), the list redrawn +// where it was. +void UITask::chatFold(int which) { + chatFolded(which); // loaded + s_chat_fold ^= 1 << which; + lvport::saveChatFold(s_chat_fold); + refreshChats(); +} + +// ── Contact picker (start a DM) ─────────────────────────────────────────────── + +void UITask::showContacts() { + _screen = SCR_CONTACTS; + buildContacts(); +} + +void UITask::buildContacts() { + lv_obj_t* body = newScreen("New message", true); + bool fav_only = _prefs && !_prefs->dm_show_all; // ui-new's default: favourites only + vlist::attach(body); + vlist::begin(body); + sectionWithFilter(body, "CONTACTS", fav_only, CF_CONTACTS); + vlist::own(body, lv_obj_get_child(body, -1)); + int total = the_mesh.getNumContacts(); + int rows = 0; + // +MAX_ANON_CONTACTS: getContactByIdx() takes the raw table index (see + // MessageHistory::contactByPrefix()). + for (int i = 0; i < total && rows < CONTACT_ROWS_MAX; i++) { + ContactInfo c; + if (!the_mesh.getContactByIdx(MAX_ANON_CONTACTS + i, c)) continue; + if (c.type != ADV_TYPE_CHAT) continue; + if (fav_only && !contactctl::favourite(c)) continue; + memcpy(s_contact_rows[rows], c.id.pub_key, PUB_KEY_SIZE); + s_contact_raw[rows] = (uint16_t)(MAX_ANON_CONTACTS + i); + rows++; + } +#ifdef PERF_LIST_MUL + for (int k = 0, real = rows; real && rows < CONTACT_ROWS_MAX; k++, rows++) { // a long list from the few there are + memcpy(s_contact_rows[rows], s_contact_rows[k % real], PUB_KEY_SIZE); + s_contact_raw[rows] = s_contact_raw[k % real]; + } +#endif + static const vlist::Kind ROW = { + theme::ROW_H, + [](lv_obj_t* list) { return vRow(list, onOpenContactRow, NULL); }, + [](lv_obj_t* row, intptr_t i) { + ContactInfo c; + bool same = the_mesh.getContactByIdx(s_contact_raw[i], c) && memcmp(c.id.pub_key, s_contact_rows[i], PUB_KEY_SIZE) == 0; + rowSet(row, same ? c.name : "?", NULL); // "?": deleted meanwhile + }, + }; + for (int i = 0; i < rows; i++) vlist::add(body, &ROW, i); + if (rows == 0) vlist::own(body, label(body, fav_only ? "No favourites - tap All" : "No contacts yet", THEME_FONT_BODY, theme::TEXT_MUTED)); + vlist::end(body); +} diff --git a/examples/companion_radio/ui-lvgl/NodesView.h b/examples/companion_radio/ui-lvgl/NodesView.h new file mode 100644 index 00000000..1160e43b --- /dev/null +++ b/examples/companion_radio/ui-lvgl/NodesView.h @@ -0,0 +1,539 @@ +#pragma once +// Included into UITask.cpp only, where it stands (it uses what is defined above it). +// Nodes: the nearby list and a node's detail card. + +// ── Nearby ──────────────────────────────────────────────────────────────────── +// Contacts / live shares / heard adverts from NearbyModel, filtered by type +// chips, sorted by distance or recency. Tap a row for detail. Scan (nodes that +// answer a discover request right now) is a popup over the list with its own +// model, since it is a different set: who is in range, not who is known. + +static NearbyModel::Entry s_node; // the node open in SCR_NODE (a copy: the list re-sorts) +// Its info card's values (UITask::_node_info is the card). +static lv_obj_t *s_nd_type, *s_nd_status, *s_nd_dist, *s_nd_pos, *s_nd_heard, *s_nd_signal, *s_nd_id; +enum : uint8_t { NODE_MSG, NODE_PING, NODE_FAV, NODE_ADD, NODE_DELETE, NODE_NAV, NODE_ADMIN, NODE_WAYPOINT, NODE_PIN, + NODE_RESET_PATH, NODE_SHARE }; + +static void onNearbyChip(lv_event_t* e) { s_ui->setNearbyFilter((uint8_t)(uintptr_t)lv_event_get_user_data(e)); } +static void onNearbySort(lv_event_t* e) { (void)e; s_ui->toggleNearbySort(); } +static void onNearbyScan(lv_event_t* e) { (void)e; s_ui->startNearbyScan(); } +static void onNearbyRow(lv_event_t* e) { s_ui->openNode((int)vlist::arg(e)); } +static void onScanRow(lv_event_t* e) { s_ui->openScanNode((int)vlist::arg(e)); } +static void onScanClose(lv_event_t* e) { (void)e; s_ui->closeScanPopup(); } +static void onNodeAction(lv_event_t* e) { s_ui->nodeAction((uint8_t)(uintptr_t)lv_event_get_user_data(e)); } + +static lv_obj_t* headerButton(lv_obj_t* hdr, const char* text, lv_event_cb_t cb, int right, lv_obj_t** label_out) { + lv_obj_t* b = lv_button_create(hdr); + lv_obj_set_size(b, LV_SIZE_CONTENT, 28); + lv_obj_set_style_pad_hor(b, 10, 0); + lv_obj_set_style_pad_ver(b, 0, 0); + lv_obj_set_style_bg_color(b, lv_color_hex(theme::SURFACE), 0); + lv_obj_set_style_bg_color(b, lv_color_hex(theme::SURFACE_2), LV_STATE_PRESSED); + lv_obj_set_style_shadow_width(b, 0, 0); + lv_obj_align(b, LV_ALIGN_RIGHT_MID, -right, 0); + lv_obj_add_event_cb(b, cb, LV_EVENT_CLICKED, NULL); + lv_obj_t* l = label(b, text, THEME_FONT_SMALL, theme::TEXT); + lv_obj_center(l); + if (label_out) *label_out = l; + return b; +} + +void UITask::showNearby() { + _screen = SCR_NEARBY; + _scanning = false; + buildNearby(); // filter / sort persist +} + +void UITask::buildNearby() { + lv_obj_t* body = newScreen("Nodes", true); + lv_obj_set_style_pad_row(body, 4, 0); + if (_header) { + headerButton(_header, LV_SYMBOL_REFRESH, onNearbyScan, 4, NULL); // scan + lv_obj_set_width(headerButton(_header, "", onNearbySort, 46, &_nearby_sort_lbl), 62); // fits "Recent" / "Dist" + headerButton(_header, UI_SYMBOL_MAP, onOpenNodesMap, 114, NULL); // the Nodes map (the advert: the quick panel) + } + + // Type filter chips + _nearby_chips = lv_obj_create(body); + styleSurface(_nearby_chips, theme::BG); + lv_obj_remove_flag(_nearby_chips, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(_nearby_chips, LV_PCT(100), 26); + lv_obj_set_flex_flow(_nearby_chips, LV_FLEX_FLOW_ROW); + lv_obj_set_style_pad_column(_nearby_chips, 4, 0); + for (uint8_t f = 0; f < NearbyModel::F_COUNT; f++) { + lv_obj_t* c = lv_button_create(_nearby_chips); + lv_obj_set_height(c, 26); + lv_obj_set_flex_grow(c, 1); + lv_obj_set_style_pad_all(c, 0, 0); + lv_obj_set_style_radius(c, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_shadow_width(c, 0, 0); + bool on = f == _nearby->filter(); + lv_obj_add_event_cb(c, onNearbyChip, LV_EVENT_CLICKED, (void*)(uintptr_t)f); + lv_obj_center(label(c, NearbyModel::filterLabel(f), THEME_FONT_SMALL, theme::TEXT)); + styleSelected(c, on); // (Theme.h) + } + + _nearby_status = label(body, "", THEME_FONT_SMALL, theme::TEXT_MUTED); + + _nearby_list = scrollList(body); + vlist::attach(_nearby_list); + + _nearby_sig = 0; + refreshNearbyList(); +} + +void UITask::setNearbyFilter(uint8_t f) { + _nearby->setFilter(f); + buildNearby(); +} + +void UITask::toggleNearbySort() { + _nearby->setSortMode(_nearby->sortMode() == NearbyModel::SORT_DIST ? NearbyModel::SORT_TIME + : NearbyModel::SORT_DIST); + _nearby_sig = 0; + refreshNearbyList(); +} + +void UITask::startNearbyScan() { + _scanning = true; + _scan_until_ms = millis() + 8000; + _next_nearby_ms = millis() + 250; + the_mesh.sendNodeDiscoverReq(); + if (!_scan_overlay) showScanPopup(); + else { _scan_sig = 0; refreshScanPopup(); } +} + +// Dimmed full-screen overlay (swallows taps) holding a panel with the results. +// A child of the current screen, so it goes away with it. +void UITask::showScanPopup() { + lv_obj_t* panel = popupOpen(screen(), POP_FULL, _scan_overlay); + + lv_obj_t* hdr = lv_obj_create(panel); + styleSurface(hdr, theme::BG); + lv_obj_remove_flag(hdr, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(hdr, LV_PCT(100), 28); + lv_obj_align(label(hdr, "In range now", THEME_FONT_TITLE, theme::TEXT), LV_ALIGN_LEFT_MID, 0, 0); + headerButton(hdr, LV_SYMBOL_CLOSE, onScanClose, 0, NULL); + headerButton(hdr, LV_SYMBOL_REFRESH " Again", onNearbyScan, 44, NULL); + + _scan_status = label(panel, "", THEME_FONT_SMALL, theme::TEXT_MUTED); + _scan_list = scrollList(panel); + vlist::attach(_scan_list); + + _scan_sig = 0; + refreshScanPopup(); +} + +void UITask::closeScanPopup() { + if (_scan_overlay) lv_obj_delete_async(_scan_overlay); // may be closing from its own button + _scan_overlay = _scan_list = _scan_status = nullptr; + _scanning = false; +} + +void UITask::refreshScanPopup() { + if (!_scan_list) return; + _scan->refreshScan(); + int n = _scan->count(); + if (_scanning) setTextFmt(_scan_status, LV_SYMBOL_REFRESH " Listening for replies... %d", n); + else setTextFmt(_scan_status, "%d node%s answered the discover request", n, n == 1 ? "" : "s"); + + uint32_t sig = (uint32_t)n + (_scanning ? 0x10000u : 0); + for (int i = 0; i < n; i++) { + const NearbyModel::Entry& e = _scan->at(i); + sig = sig * 31 + e.rssi * 7 + e.snr_x4 + e.remote_snr_x4 * 3 + e.type * 5 + e.is_known; + for (int k = 0; k < 4; k++) sig = sig * 31 + e.pub_key[k]; + for (const char* p = e.name; *p; p++) sig = sig * 31 + (uint8_t)*p; + } + if (sig == _scan_sig || vlist::pressed(_scan_list)) return; // held: once it's let go + _scan_sig = sig; + + static const vlist::Kind ROW = { + theme::ROW_H, + [](lv_obj_t* list) { return vRowValue(list, onScanRow); }, + [](lv_obj_t* row, intptr_t i) { + const NearbyModel::Entry& e = s_ui->_scan->at((int)i); + char title[40], sub[48], right[12]; + if (e.name[0]) snprintf(title, sizeof(title), "%s", e.name); + else snprintf(title, sizeof(title), "%s %02X%02X%02X%02X", NearbyModel::typeName(e.type), + e.pub_key[0], e.pub_key[1], e.pub_key[2], e.pub_key[3]); + snprintf(sub, sizeof(sub), "%s - SNR %.1f / %.1f%s", NearbyModel::typeName(e.type), + e.snr_x4 / 4.0f, e.remote_snr_x4 / 4.0f, e.is_known ? "" : " - new"); + snprintf(right, sizeof(right), "%d dBm", e.rssi); + rowSet(row, title, sub); + rowValue(row, right, theme::TEXT_MUTED); + }, + }; + vlist::begin(_scan_list); + for (int i = 0; i < n; i++) vlist::add(_scan_list, &ROW, i); + if (n == 0) { + lv_obj_t* l = noteLabel(_scan_list, _scanning ? "Repeaters and rooms in range will answer." + : "Nobody answered. Try again later or move.", + THEME_FONT_BODY, theme::TEXT_MUTED); + lv_obj_set_style_pad_top(l, 8, 0); + vlist::own(_scan_list, l); + } + vlist::end(_scan_list); +} + +void UITask::openScanNode(int row) { + if (row < 0 || row >= _scan->count()) return; + s_node = _scan->at(row); + _node_from_scan = true; + _node_from_map = false; + _scanning = false; + _pinging = false; + _screen = SCR_NODE; + buildNode(); +} + +// Changes whenever a rebuild would show something different. +uint32_t UITask::nearbySignature() const { + uint32_t sig = (uint32_t)_nearby->count() * 2654435761u + _nearby->sortMode() + (_scanning ? 7 : 0) + + (_prefs && _prefs->units_imperial ? 13 : 0); + for (int i = 0; i < _nearby->count(); i++) { + const NearbyModel::Entry& e = _nearby->at(i); + sig = sig * 31 + e.contact_idx + (uint32_t)e.lastmod + (uint32_t)(e.dist_km * 100) + e.rssi + e.fav + + e.type * 5 + (e.is_live ? 3 : 0); + for (const char* p = e.name; *p; p++) sig = sig * 31 + (uint8_t)*p; + } + // Ages are shown in minutes, so let the list re-render once a minute anyway. + return sig + rtc_clock.getCurrentTime() / 60; +} + +// A Nearby row's texts: the name (a star on a favourite), what it is and how +// long ago it was heard, and on the right its distance (or the age). +struct NearbyText { char title[48], sub[48], right[16]; uint32_t right_col; }; +static void nearbyText(const NearbyModel::Entry& e, uint32_t now, bool imperial, NearbyText& t) { + const char* name = e.name[0] ? e.name : "(unknown)"; + snprintf(t.title, sizeof(t.title), "%s%s", e.fav ? UI_SYMBOL_STAR " " : "", name); + char age[8]; + geo::fmtAgeShort(age, sizeof(age), now, e.lastmod); + t.right[0] = 0; + if (e.dist_km >= 0.0f) geo::fmtDist(t.right, sizeof(t.right), e.dist_km, imperial); + else if (age[0]) snprintf(t.right, sizeof(t.right), "%s", age); + const char* kind = e.contact_idx >= 0 ? NearbyModel::typeName(e.type) : "not a contact"; + snprintf(t.sub, sizeof(t.sub), "%s%s%s%s", kind, e.is_live ? " - live" : "", + (e.dist_km >= 0.0f && age[0]) ? " - " : "", (e.dist_km >= 0.0f && age[0]) ? age : ""); + t.right_col = e.is_live ? theme::OK : theme::TEXT_MUTED; +} + +// A small arrow towards the node, drawn in the value label's left padding: +// its user data is the bearing + 1 (0: none). +static const int ROW_ARROW_W = 14; +static void onRowArrowDraw(lv_event_t* e) { + lv_obj_t* o = (lv_obj_t*)lv_event_get_target(e); + const int b = (int)(intptr_t)lv_obj_get_user_data(o) - 1; + if (b < 0) return; + lv_area_t a; + lv_obj_get_coords(o, &a); + const float cx = a.x1 + ROW_ARROW_W / 2 - 1, cy = (a.y1 + a.y2) / 2.0f; + const float ang = b * (float)M_PI / 180.0f, sx = sinf(ang), sy = -cosf(ang); + lv_draw_triangle_dsc_t tri; + lv_draw_triangle_dsc_init(&tri); + tri.color = lv_obj_get_style_text_color(o, LV_PART_MAIN); + tri.opa = LV_OPA_COVER; + tri.p[0].x = cx + sx * 6; tri.p[0].y = cy + sy * 6; // the tip + tri.p[1].x = cx - sx * 5 + sy * 4; tri.p[1].y = cy - sy * 5 - sx * 4; + tri.p[2].x = cx - sx * 5 - sy * 4; tri.p[2].y = cy - sy * 5 + sx * 4; + lv_draw_triangle(lv_event_get_layer(e), &tri); +} + +void UITask::refreshNearbyList() { + if (!_nearby_list) return; + _nearby->refreshModel(); + uint32_t sig = nearbySignature(); + int n = _nearby->count(); + + if (_nearby_sort_lbl) + setText(_nearby_sort_lbl, _nearby->sortMode() == NearbyModel::SORT_TIME ? "Recent" : "Dist"); + int32_t lat, lon; + bool gps = _nearby->ownPosition(lat, lon); + setTextFmt(_nearby_status, "%d node%s%s", n, n == 1 ? "" : "s", + gps ? "" : " - no GPS fix, distances unknown"); + if (sig == _nearby_sig || vlist::pressed(_nearby_list)) return; // held: once it's let go + _nearby_sig = sig; + + // The rows in view rewritten from the model (ages, distances, a new + // order); the list stays where it was scrolled. + static const vlist::Kind ROW = { + theme::ROW_H, + [](lv_obj_t* list) { + lv_obj_t* row = vRowValue(list, onNearbyRow); + lv_obj_add_event_cb(lv_obj_get_child(row, 2), onRowArrowDraw, LV_EVENT_DRAW_MAIN_END, NULL); + return row; + }, + [](lv_obj_t* row, intptr_t i) { + const NearbyModel::Entry& e = s_ui->_nearby->at((int)i); + NearbyText t; + nearbyText(e, rtc_clock.getCurrentTime(), s_ui->_prefs && s_ui->_prefs->units_imperial, t); + rowSet(row, t.title, t.sub, e.fav ? theme::ACCENT : theme::TEXT); + rowValue(row, t.right, t.right_col); + // By the distance, an arrow towards it + int b = -1; + int32_t lat, lon; + if (e.dist_km >= 0.0f && s_ui->_nearby->ownPosition(lat, lon)) b = geo::bearingDeg(lat, lon, e.lat_e6, e.lon_e6); + lv_obj_t* v = lv_obj_get_child(row, 2); + if ((intptr_t)lv_obj_get_user_data(v) != b + 1) { + lv_obj_set_user_data(v, (void*)(intptr_t)(b + 1)); + lv_obj_set_style_pad_left(v, b >= 0 ? ROW_ARROW_W : 0, 0); + lv_obj_invalidate(v); + } + }, + }; + vlist::begin(_nearby_list); + for (int i = 0; i < n; i++) vlist::add(_nearby_list, &ROW, i); + if (n == 0) { + lv_obj_t* l = label(_nearby_list, "Nobody here yet. Tap Scan to look around.", + THEME_FONT_BODY, theme::TEXT_MUTED); + lv_obj_set_style_pad_top(l, 12, 0); + vlist::own(_nearby_list, l); + } + vlist::end(_nearby_list); +} + +// ── Node detail ─────────────────────────────────────────────────────────────── + +void UITask::openNode(int row) { + if (row < 0 || row >= _nearby->count()) return; + s_node = _nearby->at(row); + _node_from_scan = false; + _node_from_map = false; + _pinging = false; + _screen = SCR_NODE; + buildNode(); +} + +static lv_obj_t* s_nd_fav = nullptr; +static void nodeFavText() { + if (s_nd_fav) lv_label_set_text(s_nd_fav, s_node.fav ? UI_SYMBOL_STAR " Remove from favourites" : UI_SYMBOL_STAR " Add to favourites"); +} + +static lv_obj_t* switchRow(lv_obj_t* parent, const char* text, const char* sub, uint8_t* pref); // below + +// One of the node's "Can ask for" switches; user data = its TELEM_PERM_* bit. +static void onNodeTelemSwitch(lv_event_t* e) { + ContactInfo* ci = the_mesh.lookupContactByPubKey(s_node.pub_key, PUB_KEY_SIZE); + if (!ci) return; + uint8_t bit = (uint8_t)(uintptr_t)lv_event_get_user_data(e); + uint8_t perms = contactctl::telemetry(*ci); + if (lv_obj_has_state((lv_obj_t*)lv_event_get_target(e), LV_STATE_CHECKED)) perms |= bit; else perms &= ~bit; + contactctl::setTelemetry(s_node.pub_key, perms); +} + +void UITask::buildNode() { + const NearbyModel::Entry& e = s_node; + lv_obj_t* body = newScreen(e.name[0] ? e.name : "(unknown)", true); + + lv_obj_t* info = scrollList(body); // the info, scrolling above the actions at the bottom + s_nd_fav = nullptr; + _node_path_lbl = nullptr; + nodeVizBuild(info); + nodeviz::s_bearing = nodeviz::s_hops = -2; // nothing drawn yet: the first set fills both + _node_info = infoCard(info); + s_nd_type = infoRow(_node_info, "Type", ""); + s_nd_status = infoRow(_node_info, "Status", ""); + s_nd_dist = infoRow(_node_info, "Distance", ""); + s_nd_pos = infoRow(_node_info, "Position", ""); + s_nd_heard = infoRow(_node_info, "Heard", ""); + s_nd_signal = infoRow(_node_info, "Signal", ""); + s_nd_id = infoRow(_node_info, "ID", ""); + _node_ping = label(info, "", THEME_FONT_BODY, theme::ACCENT); + + // The bar: what the node is for -- a message, its admin, adding it -- a + // ping and the way there. The rest, less often, in a group under the info. + lv_obj_t* acts = buttonBar(body); + bool contact = e.contact_idx >= 0; + bool admin = contact && (e.type == ADV_TYPE_REPEATER || e.type == ADV_TYPE_ROOM); + bool pos = e.lat_e6 != 0 || e.lon_e6 != 0; + if (contact && e.type == ADV_TYPE_CHAT) barButton(acts, LV_SYMBOL_ENVELOPE " Message", onNodeAction, NODE_MSG, true); + if (admin) barButton(acts, LV_SYMBOL_SETTINGS " Admin", onNodeAction, NODE_ADMIN, true); + if (!contact && e.has_key && !e.is_known) barButton(acts, LV_SYMBOL_PLUS " Add", onNodeAction, NODE_ADD, true); + if (e.has_key) barButton(acts, LV_SYMBOL_LOOP " Ping", onNodeAction, NODE_PING); + if (pos) barButton(acts, UI_SYMBOL_COMPASS " Navigate", onNodeAction, NODE_NAV); + + if (pos || contact) { + lv_obj_t* g = group(info, "More"); + if (pos) actionRow(g, UI_SYMBOL_FLAG " Save as waypoint", onNodeAction, (void*)(uintptr_t)NODE_WAYPOINT); + if (contact) s_nd_fav = actionRow(g, "", onNodeAction, (void*)(uintptr_t)NODE_FAV); + nodeFavText(); + if (contact && e.has_key) actionRow(g, UI_SYMBOL_TACK " Pin to Home", onNodeAction, (void*)(uintptr_t)NODE_PIN); + ContactInfo* ci = (contact && e.has_key) ? the_mesh.lookupContactByPubKey(e.pub_key, PUB_KEY_SIZE) : nullptr; + if (ci && contactctl::hasPath(*ci)) + _node_path_lbl = actionRow(g, LV_SYMBOL_REFRESH " Reset path", onNodeAction, (void*)(uintptr_t)NODE_RESET_PATH); + if (ci) actionRow(g, LV_SYMBOL_UPLOAD " Share with nodes in range", onNodeAction, (void*)(uintptr_t)NODE_SHARE); + if (contact) _node_delete_lbl = actionRow(g, LV_SYMBOL_TRASH " Delete contact", onNodeAction, + (void*)(uintptr_t)NODE_DELETE, theme::FAIL); + } + + // Where Settings > Privacy says "Allowed", what this contact may ask for. + ContactInfo* ci = (contact && e.has_key) ? the_mesh.lookupContactByPubKey(e.pub_key, PUB_KEY_SIZE) : nullptr; + if (ci && contactctl::telemetryPerContact(_prefs)) { + lv_obj_t* g = group(info, "Can ask for"); + static const struct { const char* text; const char* sub; uint8_t bit; } T[] = { + { "Status", "Battery; the two below need it", TELEM_PERM_BASE }, + { "Location", nullptr, TELEM_PERM_LOCATION }, + { "Sensors", nullptr, TELEM_PERM_ENVIRONMENT }, + }; + uint8_t perms = contactctl::telemetry(*ci); + for (const auto& t : T) { + lv_obj_t* sw = switchRow(g, t.text, t.sub, nullptr); + if (perms & t.bit) lv_obj_add_state(sw, LV_STATE_CHECKED); + lv_obj_add_event_cb(sw, onNodeTelemSwitch, LV_EVENT_VALUE_CHANGED, (void*)(uintptr_t)t.bit); + } + } + + refreshNode(); +} + +void UITask::refreshNode() { + if (!_node_info) return; + const NearbyModel::Entry& e = s_node; + // Refresh position / age / signal from its model while the node is still listed. + NearbyModel* model = _node_from_scan ? _scan : _nearby; + model->refreshModel(); + for (int i = 0; i < model->count(); i++) { + const NearbyModel::Entry& m = model->at(i); + bool same = e.has_key ? (m.has_key && memcmp(m.pub_key, e.pub_key, PUB_KEY_SIZE) == 0) + : (e.contact_idx >= 0) ? m.contact_idx == e.contact_idx + : strncmp(m.name, e.name, sizeof(m.name)) == 0; + if (same) { s_node = m; break; } + } + + // A scan row carries no contact index; is_known says whether it's in the contacts. + bool known = e.contact_idx >= 0 || (_node_from_scan && e.is_known); + char status[64] = "", dist[40] = "", pos[32] = "", heard[16] = "", sig[40] = "", id[12] = ""; + int o = 0; + auto tag = [&](const char* t) { o += snprintf(status + o, sizeof(status) - o, "%s%s", o ? ", " : "", t); }; + if (!known) tag("not a contact"); + if (e.is_live) tag(e.live_verified ? "live position" : "live position (channel)"); + if (e.fav) tag("favourite"); + if (status[0] >= 'a' && status[0] <= 'z') status[0] -= 'a' - 'A'; + int32_t lat, lon; + int bearing = -1; + if (e.lat_e6 != 0 || e.lon_e6 != 0) { + if (_nearby->ownPosition(lat, lon) && e.dist_km >= 0.0f) { + char d[16]; + geo::fmtDist(d, sizeof(d), e.dist_km, _prefs && _prefs->units_imperial); + int az = geo::bearingDeg(lat, lon, e.lat_e6, e.lon_e6); + bearing = az; + snprintf(dist, sizeof(dist), "%s %d\xC2\xB0 %s", d, az, geo::bearingCardinal(az)); + } + snprintf(pos, sizeof(pos), "%.5f, %.5f", e.lat_e6 / 1e6, e.lon_e6 / 1e6); + } else if (!_node_from_scan) { + snprintf(pos, sizeof(pos), "Not shared"); + } + char age[8]; + geo::fmtAgeShort(age, sizeof(age), rtc_clock.getCurrentTime(), e.lastmod); + if (age[0]) snprintf(heard, sizeof(heard), "%s ago", age); + if (_node_from_scan) + snprintf(sig, sizeof(sig), "%d dBm, SNR %.1f / %.1f", e.rssi, e.snr_x4 / 4.0f, e.remote_snr_x4 / 4.0f); + if (e.has_prefix) snprintf(id, sizeof(id), "%02X%02X%02X%02X", e.pub_key[0], e.pub_key[1], e.pub_key[2], e.pub_key[3]); + infoSet(s_nd_type, NearbyModel::typeName(e.type)); + infoSet(s_nd_status, status); + infoSet(s_nd_dist, dist); + infoSet(s_nd_pos, pos); + infoSet(s_nd_heard, heard); + infoSet(s_nd_signal, sig); + infoSet(s_nd_id, id); + ContactInfo ci; + int hops = -1; + if (!_node_from_scan && e.contact_idx >= 0 && the_mesh.getContactByIdx(e.contact_idx, ci)) + hops = ci.out_path_len == 0xFF ? 0xFF : (ci.out_path_len & 63); + nodeVizSet(bearing, hops); + + // Ping result (PingEngine releases its slot on reply; the view owns the timeout) + if (_pinging) { + int16_t out = 0, back = 0; uint32_t rtt = 0; + _core->ping.getResult(out, back, rtt); + if (!_core->ping.isActive() && (out || back || rtt)) { + setTextFmt(_node_ping, "Ping %lu ms - SNR out %.1f back %.1f", (unsigned long)rtt, + out / 4.0f, back / 4.0f); + _pinging = false; + } else if (millis() - _ping_started_ms > 3000) { + _core->ping.clear(); + setText(_node_ping, "Ping: no reply"); + _pinging = false; + } + } +} + +void UITask::nodeAction(uint8_t action) { + NearbyModel::Entry& e = s_node; + switch (action) { + case NODE_MSG: + openDM(e.pub_key); + break; + case NODE_PING: { + if (_pinging) break; + PingEngine::StartResult r = _core->ping.start(e.pub_key); + if (r == PingEngine::STARTED) { + _pinging = true; + _ping_started_ms = millis(); + _next_nearby_ms = millis() + 250; + lv_label_set_text(_node_ping, "Pinging..."); + } else { + lv_label_set_text(_node_ping, r == PingEngine::UNSUPPORTED ? "Ping needs 1-2 byte path hashes" + : "Ping failed"); + } + break; + } + case NODE_FAV: + if (contactctl::setFavourite(e.pub_key, !e.fav)) { // in place: the list stays where it was scrolled + e.fav = !e.fav; + nodeFavText(); + refreshNode(); + } + break; + case NODE_ADD: + if (the_mesh.addDiscoveredContact(e.pub_key, e.name, e.type)) { + _screen = SCR_NEARBY; + buildNearby(); + } else { + showToast("Contacts full"); + } + break; + case NODE_NAV: + // Followed by key when there is one (the Locator resolves live share, + // then advert position); otherwise a fixed point where it was seen. + if (e.has_prefix) navToNode(e.pub_key, e.lat_e6, e.lon_e6, e.name[0] ? e.name : "Node"); + else { + _core->locator.setTarget(0, nullptr, e.lat_e6, e.lon_e6, e.name[0] ? e.name : "Node"); + prefsSave(); + openMap(true); + navFrameTarget(); + } + break; + case NODE_ADMIN: + openAdmin(e.pub_key); + break; + case NODE_WAYPOINT: { + if (_core->waypoints.full()) { showToast(waypointsFull()); break; } + char t[48]; + if (_core->waypoints.add(e.lat_e6, e.lon_e6, rtc_clock.getCurrentTime(), e.name[0] ? e.name : "Node")) { + snprintf(t, sizeof(t), "Saved %s", _core->waypoints.at(_core->waypoints.count() - 1).label); + showToast(t); + } + break; + } + case NODE_PIN: // to the favourites dial (Home), as from a chat's options + pinPopup(false, 0, e.pub_key); + break; + case NODE_RESET_PATH: // the row goes, the chain above turns to flood + if (contactctl::resetPath(e.pub_key) && _node_path_lbl) { + lv_obj_add_flag(lv_obj_get_parent(lv_obj_get_parent(_node_path_lbl)), LV_OBJ_FLAG_HIDDEN); // label < text box < row + _node_path_lbl = nullptr; + refreshNode(); + } + break; + case NODE_SHARE: + showToast(contactctl::share(e.pub_key) ? "Sent to nodes in range" : "No advert saved to send"); + break; + case NODE_DELETE: + if (!tapConfirmed(_node_delete_lbl, LV_SYMBOL_TRASH " Tap again to delete")) break; + if (the_mesh.deleteContactByKey(e.pub_key)) { + _screen = SCR_NEARBY; + buildNearby(); + } + break; + } +} diff --git a/examples/companion_radio/ui-lvgl/SettingsPages.h b/examples/companion_radio/ui-lvgl/SettingsPages.h new file mode 100644 index 00000000..36f5f3bd --- /dev/null +++ b/examples/companion_radio/ui-lvgl/SettingsPages.h @@ -0,0 +1,646 @@ +#pragma once +// Included into UITask.cpp only, where it stands (it uses what is defined above it). +// Settings: the schema-driven pages and the hand-built ones (Privacy ...). + +// ── Settings (first rows; the declarative schema replaces this later) ──────── + +static lv_obj_t* s_kb_main_dd = nullptr; +static lv_obj_t* s_kb_alt_dd = nullptr; + +static void onKeyboardAlphabet(lv_event_t* e) { + (void)e; + s_ui->setKeyboardAlphabets(choiceSelected(s_kb_main_dd), choiceSelected(s_kb_alt_dd)); +} + +// A preference toggle (0/1 byte in NodePrefs), saved on change. +static void onPrefSwitch(lv_event_t* e) { + uint8_t* pref = (uint8_t*)lv_event_get_user_data(e); + *pref = lv_obj_has_state((lv_obj_t*)lv_event_get_target(e), LV_STATE_CHECKED) ? 1 : 0; + prefsSave(); +} + +static void onGpsSwitch(lv_event_t* e) { + s_ui->setGps(lv_obj_has_state((lv_obj_t*)lv_event_get_target(e), LV_STATE_CHECKED)); +} + +// pref == nullptr: the caller wires the switch itself. +static lv_obj_t* switchRow(lv_obj_t* parent, const char* text, const char* sub, uint8_t* pref) { + lv_obj_t* row = settingRow(parent, text, sub, 236); // the hint clear of the switch + lv_obj_t* sw = lv_switch_create(row); + lv_obj_set_size(sw, 46, 24); + lv_obj_align(sw, LV_ALIGN_RIGHT_MID, -theme::PAD, 0); + if (pref) { + if (*pref) lv_obj_add_state(sw, LV_STATE_CHECKED); + lv_obj_add_event_cb(sw, onPrefSwitch, LV_EVENT_VALUE_CHANGED, pref); + } + return sw; +} + +// ── Schema-driven settings (ui-core/SettingsSchema.h) ───────────────────────── + +// Settings > Display: ui-new's Message alert as a switch -- the banner, or +// none (Normal / Off; the L2 has no corner for Compact's envelope). +static void onMsgBannerSwitch(lv_event_t* e) { + s_ui->setMsgBanner(lv_obj_has_state((lv_obj_t*)lv_event_get_target(e), LV_STATE_CHECKED)); +} +void UITask::msgBannerRow(lv_obj_t* card) { + lv_obj_t* sw = switchRow(card, "Message banner", "A new message drops in from the top", nullptr); + if (!_prefs || _prefs->msg_alert != NodePrefs::MSG_ALERT_OFF) lv_obj_add_state(sw, LV_STATE_CHECKED); + lv_obj_add_event_cb(sw, onMsgBannerSwitch, LV_EVENT_VALUE_CHANGED, NULL); +} +void UITask::setMsgBanner(bool on) { + if (!_prefs) return; + _prefs->msg_alert = on ? NodePrefs::MSG_ALERT_NORMAL : NodePrefs::MSG_ALERT_OFF; + prefsSave(); +} + +static void onSchemaSwitch(lv_event_t* e) { + s_ui->setSchemaValue((int)(uintptr_t)lv_event_get_user_data(e), + lv_obj_has_state((lv_obj_t*)lv_event_get_target(e), LV_STATE_CHECKED) ? 1 : 0); +} +static void onSchemaDropdown(lv_event_t* e) { + s_ui->setSchemaValue((int)(uintptr_t)lv_event_get_user_data(e), + choiceSelected((lv_obj_t*)lv_event_get_target(e))); +} +static void onOpenSchemaPage(lv_event_t* e) { + s_nav_section = -1; // from Settings: all of a page + s_ui->showSchemaSettings((int)(uintptr_t)lv_event_get_user_data(e)); +} +static void onHomeApps(lv_event_t* e) { (void)e; s_ui->homeEdit(true); } +static void onPruneContacts(lv_event_t* e) { (void)e; s_ui->pruneContacts(); } +static void onOpenQuickMsgs(lv_event_t* e); +static void onPinSetup(lv_event_t* e); // DeviceScreen.h +static void onBattCurve(lv_event_t* e) { (void)e; s_ui->showBattCurve(); } +static void onOpenOta(lv_event_t* e); // OtaScreen.h +static void bluetoothRow(lv_obj_t* body); +static void onVolumeSlider(lv_event_t* e) { + s_ui->setSoundVolume((int)lv_slider_get_value((lv_obj_t*)lv_event_get_target(e))); +} + + +// Map tools > Trail / Share / Alert options: that section of the Map options. +void UITask::showMapOptions(uint8_t section) { + s_nav_section = section; + showSchemaSettings(settings::PG_NAV); +} + +void UITask::showSchemaSettings(int page) { + if (page == settings::PG_NAV && !_nav_back) s_opts_from_map = _screen == SCR_MAP; + _screen = SCR_SETTINGS_NAV; + _settings_page = (uint8_t)(page < PG_ALL ? page : 0); + buildSchemaSettings(); +} + +// Contacts > prune now: first tap shows how many, the second (within 3 s) removes them. +void UITask::pruneContacts() { + int n = the_mesh.countStaleContacts(); + if (n == 0) { + showToast(_prefs && _prefs->contact_expiry_idx == 0 ? "Contact expiry is off" : "No inactive contacts"); + return; + } + char ask[40]; + snprintf(ask, sizeof(ask), LV_SYMBOL_TRASH " Remove %d contact%s?", n, n == 1 ? "" : "s"); + if (!tapConfirmed(_prune_lbl, ask)) return; + int removed = the_mesh.pruneStaleContacts(); + char t[40]; + snprintf(t, sizeof(t), "Removed %d contact%s", removed, removed == 1 ? "" : "s"); + showToast(t); +} + +void UITask::applyDisplayPrefs() { + if (!_prefs) return; + if (_prefs->display_brightness_pct) lvport::setBacklightPct(_prefs->display_brightness_pct); + else if (_display) _display->setBrightness(_prefs->display_brightness); +} + +// Brightness slider: live while dragging, saved on release. +static void onBrightnessSlider(lv_event_t* e) { + lv_obj_t* sl = (lv_obj_t*)lv_event_get_target(e); + s_ui->setBrightnessPct((uint8_t)lv_slider_get_value(sl), lv_event_get_code(e) == LV_EVENT_RELEASED); +} + +void UITask::setBrightnessPct(uint8_t pct, bool save) { + if (!_prefs) return; + _prefs->display_brightness_pct = pct; + _prefs->display_brightness = (uint8_t)((pct + 12) / 25 > 4 ? 4 : (pct + 12) / 25); // nearest level, for anything reading it + applyDisplayPrefs(); + if (save) prefsSave(); +} + +static void onTapWake(lv_event_t* e) { + s_ui->setTapWake(lv_obj_has_state((lv_obj_t*)lv_event_get_target(e), LV_STATE_CHECKED)); +} + +void UITask::setTapWake(bool on) { + _tap_wake = on; + lvport::saveTapWake(on); +} + +void UITask::buildSchemaSettings() { + static const char* const OWN[] = { "Keyboard", "About", "Display", "Power", "Time" }; + const char* title = _settings_page >= PG_KEYBOARD ? OWN[_settings_page - PG_KEYBOARD] : settings::pageTitle(_settings_page); + if (_settings_page == settings::PG_NAV && s_nav_section >= 0) + title = s_nav_section == settings::SEC_TRAIL ? "Trail options" + : s_nav_section == settings::SEC_LIVE_SHARE ? "Share options" : "Alert options"; + lv_obj_t* body = newScreen(title, true); + _prune_lbl = nullptr; + switch (_settings_page) { + case PG_KEYBOARD: buildKeyboardPage(body); return; + case PG_ABOUT: buildAboutPage(body); return; + case PG_DISPLAY: { + lv_obj_t* g = group(body, "SCREEN"); + schemaRow(g, SETTING(display_brightness)); + schemaRow(g, SETTING(auto_off_secs)); + schemaRow(g, SETTING(msg_wake)); + lv_obj_t* tw = switchRow(g, "Tap to wake", "Off: only the top button wakes it", nullptr); // in NVS + if (_tap_wake) lv_obj_add_state(tw, LV_STATE_CHECKED); + lv_obj_add_event_cb(tw, onTapWake, LV_EVENT_VALUE_CHANGED, NULL); + g = group(body, "LOCK"); + schemaRow(g, SETTING(auto_lock)); + listRow(g, "Screen PIN", pinSet() ? "On - asked when the screen wakes" : "Off", onPinSetup, NULL); + accentRow(group(body, "LOOK")); // DeviceScreen.h + return; + } + case PG_POWER: { + lv_obj_t* g = group(body, "BATTERY"); + schemaRow(g, SETTING(batt_display_mode)); + schemaRow(g, SETTING(low_batt_mv)); + listRow(g, "Battery curve", _prefs && battery::validCurve(_prefs->batt_curve_mv) ? "Your own" : "LiPo (built in)", + onBattCurve, NULL); + g = group(body, "GPS"); + schemaRow(g, SETTING(gps_interval)); + return; + } + case PG_TIME: { + lv_obj_t* g = group(body, nullptr); + for (int i = 0; i < settings::COUNT; i++) if (settings::ALL[i].section == settings::SEC_TIME) schemaRow(g, i); + return; + } + } + schemaRows(body, _settings_page); + if (_settings_page == settings::PG_SOUND) then(&UITask::schemaExtras, settings::PG_SOUND); // after its groups + if (_settings_page == settings::PG_MESSAGES) then(&UITask::schemaExtras, settings::PG_MESSAGES); + if (_settings_page == settings::PG_PRIVACY) then(&UITask::schemaExtras, settings::PG_PRIVACY); +} + +// ── Settings > Privacy: position, Bluetooth PIN; factory reset ───────────────── + +static void onOwnPos(lv_event_t* e) { (void)e; s_ui->ownPositionPopup(); } +static void onOwnPosPick(lv_event_t* e) { s_ui->ownPositionPick((int)(uintptr_t)lv_event_get_user_data(e)); } +static void onOwnPosKb(lv_event_t* e) { s_ui->ownPositionDone(lv_event_get_code(e) == LV_EVENT_READY); } +static void onBlePin(lv_event_t* e) { (void)e; s_ui->blePinPopup(); } +static void onBlePinKb(lv_event_t* e) { s_ui->blePinDone(lv_event_get_code(e) == LV_EVENT_READY); } + +enum : int { OWNPOS_TYPE, OWNPOS_WAYPOINTS, OWNPOS_CLEAR, OWNPOS_WP0 = 100 }; + +void UITask::ownPositionPopup() { + if (_core->gpsEnabled()) { showToast("The GPS sets your position while it's on", 2500); return; } + int32_t lat, lon; + bool have = _core->fixedPosition(lat, lon); + lv_obj_t* g = group(navPopupPanel("Fixed position", false), nullptr); + listRow(g, "Type coordinates", "Latitude, longitude in degrees", onOwnPosPick, (void*)(uintptr_t)OWNPOS_TYPE); + if (_core->waypoints.count() > 0) { + char sub[24]; + snprintf(sub, sizeof(sub), "%d saved", _core->waypoints.count()); + listRow(g, "From a waypoint", sub, onOwnPosPick, (void*)(uintptr_t)OWNPOS_WAYPOINTS); + } + if (have) actionRow(g, "Clear", onOwnPosPick, (void*)(uintptr_t)OWNPOS_CLEAR); +} + +void UITask::ownPositionPick(int code) { + const WaypointModel& wp = _core->waypoints; + if (code == OWNPOS_TYPE) { + int32_t lat, lon; + char cur[32] = ""; + if (_core->fixedPosition(lat, lon)) snprintf(cur, sizeof(cur), "%.5f, %.5f", lat / 1e6, lon / 1e6); + TextEntry t = { "Fixed position", onOwnPosKb }; + t.text = cur; + t.hint = "50.06143, 19.93658"; + t.max_bytes = 31; + t.accepted = "0123456789.,- "; + t.symbols = true; + navTextEntry(t); + return; + } + if (code == OWNPOS_WAYPOINTS) { + lv_obj_t* list = scrollList(navPopupPanel("From a waypoint", true)); + for (int i = 0; i < wp.count(); i++) { + const Waypoint& w = wp.at(i); + char title[WAYPOINT_LABEL_LEN + 8], sub[40]; + snprintf(title, sizeof(title), UI_SYMBOL_FLAG " %s", w.label[0] ? w.label : "(unnamed)"); + snprintf(sub, sizeof(sub), "%.5f, %.5f", w.lat_1e6 / 1e6, w.lon_1e6 / 1e6); + listRow(list, title, sub, onOwnPosPick, (void*)(uintptr_t)(OWNPOS_WP0 + i)); + } + return; + } + if (code == OWNPOS_CLEAR) _core->setFixedPosition(0, 0); + else if (code >= OWNPOS_WP0 && code - OWNPOS_WP0 < wp.count()) + _core->setFixedPosition(wp.at(code - OWNPOS_WP0).lat_1e6, wp.at(code - OWNPOS_WP0).lon_1e6); + navClosePopup(); + if (_screen == SCR_SETTINGS_NAV) rebuildSchemaSettings(); +} + +void UITask::ownPositionDone(bool ok) { + if (ok && _nav_ta) { + int32_t lat, lon; + if (!geo::parseLatLon(lv_textarea_get_text(_nav_ta), lat, lon) || (!lat && !lon)) { + showToast("Type it as latitude, longitude"); + return; // the field stays, to fix it + } + _core->setFixedPosition(lat, lon); + } + navClosePopup(); + if (ok && _screen == SCR_SETTINGS_NAV) rebuildSchemaSettings(); +} + +void UITask::blePinPopup() { + if (!_prefs) return; + char cur[8] = ""; + if (_prefs->ble_pin) snprintf(cur, sizeof(cur), "%06lu", (unsigned long)_prefs->ble_pin); + TextEntry t = { "Bluetooth PIN", onBlePinKb }; + t.text = cur; + t.hint = "6 digits, empty for a random one"; + t.max_bytes = 6; + t.accepted = "0123456789"; + t.symbols = true; + navTextEntry(t); +} + +void UITask::blePinDone(bool ok) { + if (ok && _nav_ta) { + const char* t = lv_textarea_get_text(_nav_ta); + if ((t[0] && strlen(t) != 6) || !_core->setBlePin((uint32_t)strtoul(t, nullptr, 10))) { + showToast("Six digits, not starting with 0"); + return; + } + showToast("Used from the next start", 2000); + } + navClosePopup(); + if (ok && _screen == SCR_SETTINGS_NAV) rebuildSchemaSettings(); +} + +void UITask::factoryResetStep(int stage) { + if (stage == 1) + confirmBody(navPopupPanel("Factory reset?", false), + "Erases contacts, channels, messages, settings and this node's keys. The SD card is left as it is.", + "Continue", onFactoryReset, 2); + else if (stage == 2) + confirmBody(navPopupPanel("Erase everything?", false), + "This can't be undone. The device restarts as a new node.", + LV_SYMBOL_TRASH " Erase", onFactoryReset, 3); + else { + navClosePopup(); + _core->factoryReset(); // restarts; back here only if the erase failed + showToast("Erase failed"); + } +} + +// The companion CLI's settings keys (ui-core/SettingsCli.h); a Settings page +// open on the screen is built again with the new value. +bool UITask::cliCommand(const char* command, char* reply, int n) { + if (!settingscli::handle(*_core, command, reply, n)) return false; + if (_screen == SCR_SETTINGS_NAV) rebuildSchemaSettings(); + return true; +} + +// What a page adds to its schema groups, once they are there. +void UITask::schemaExtras(int page) { + if (page == settings::PG_PRIVACY) { + if (lv_obj_t* c = s_sec_card[settings::SEC_PRIVACY]) { // with "Position in adverts" + char sub[40]; + int32_t lat, lon; + if (_core->gpsEnabled()) snprintf(sub, sizeof(sub), "From the GPS while it's on"); + else if (_core->fixedPosition(lat, lon)) snprintf(sub, sizeof(sub), "%.5f, %.5f", lat / 1e6, lon / 1e6); + else snprintf(sub, sizeof(sub), "Not set"); + listRow(c, "Fixed position", sub, onOwnPos, NULL); + } +#ifdef BLE_PIN_CODE + char pin[40]; + if (_prefs && _prefs->ble_pin) snprintf(pin, sizeof(pin), "%06lu", (unsigned long)_prefs->ble_pin); + else snprintf(pin, sizeof(pin), "Random each start, shown on screen"); + listRow(group(_body, "BLUETOOTH"), "Pairing PIN", pin, onBlePin, NULL); +#endif + } + if (page == settings::PG_SOUND) buildSoundRows(_body, false); // the melodies + if (page == settings::PG_MESSAGES) { + lv_obj_t* c = s_sec_card[settings::SEC_CONTACTS]; // the action that goes with "Contact expiry" + if (c) _prune_lbl = actionRow(c, LV_SYMBOL_TRASH " Remove inactive contacts now", onPruneContacts, NULL, theme::FAIL); + char sub[48]; + snprintf(sub, sizeof(sub), "%d of %d set - sent with one tap", msgtext::quickUsed(_prefs), msgtext::QUICK_COUNT); + if (lv_obj_t* m = s_sec_card[settings::SEC_MESSAGES]) listRow(m, "Quick messages", sub, onOpenQuickMsgs, NULL); + } +} + +// Settings > Keyboard: the two scripts the keyboard switches between. +void UITask::buildKeyboardPage(lv_obj_t* body) { + uint8_t main_a = _prefs ? _prefs->keyboard_main_alphabet : 0; + uint8_t alt_a = _prefs ? _prefs->keyboard_alt_alphabet : 0; + if (main_a >= NodePrefs::KB_ALPHABET_COUNT) main_a = 0; + if (alt_a >= NodePrefs::KB_ALPHABET_COUNT) alt_a = main_a; + lv_obj_t* g = group(body, "SCRIPTS"); + // Order matches NodePrefs::KB_ALPHABET_* (Latin, Cyrillic, Greek). + s_kb_main_dd = choiceRow(g, "Main", nullptr, "Latin\nCyrillic\nGreek", main_a, onKeyboardAlphabet); + // "None" = no second script (stored as alt == main, as ui-new does). + s_kb_alt_dd = choiceRow(g, "Additional", "The globe key switches to it", "None\nLatin\nCyrillic\nGreek", + alt_a == main_a ? 0 : alt_a + 1, onKeyboardAlphabet); + groupNote(body, "Hold a letter for accents and other variants."); +} + +// A QR code as a 1-bit image into dsc / data (the palette, then the rows): +// black on white with its quiet zone, s px a module. The image cache forgets +// an older picture at the same place. +template +static void qrImage(lv_image_dsc_t& dsc, uint8_t* data, size_t size, int n, int s, Dark dark) { + const int side = n + 2 * siteqr::QUIET, px = side * s, stride = (px + 7) / 8; + memset(data, 0, size); + const lv_color32_t pal[2] = { lv_color32_make(0xFF, 0xFF, 0xFF, 0xFF), lv_color32_make(0, 0, 0, 0xFF) }; + memcpy(data, pal, sizeof(pal)); + uint8_t* rows = data + 8; + for (int y = 0; y < px; y++) + for (int x = 0; x < px; x++) { + int mx = x / s - siteqr::QUIET, my = y / s - siteqr::QUIET; + if (mx >= 0 && my >= 0 && mx < n && my < n && dark(mx, my)) rows[y * stride + x / 8] |= 0x80 >> (x % 8); + } + lv_image_cache_drop(&dsc); + dsc.header.magic = LV_IMAGE_HEADER_MAGIC; + dsc.header.cf = LV_COLOR_FORMAT_I1; + dsc.header.w = px; + dsc.header.h = px; + dsc.header.stride = stride; + dsc.data_size = 8 + stride * px; + dsc.data = data; +} + +// The Solo site's QR code (ui-core/SiteQr.h), built once at 5 px a module. +static lv_obj_t* siteQr(lv_obj_t* parent) { + static const int S = 5, PX = (siteqr::N + 2 * siteqr::QUIET) * S; + static uint8_t data[8 + (PX + 7) / 8 * PX]; + static lv_image_dsc_t dsc; + if (!dsc.data) qrImage(dsc, data, sizeof(data), siteqr::N, S, siteqr::dark); + lv_obj_t* img = lv_image_create(parent); + lv_image_set_src(img, &dsc); + return img; +} + +// Your own contact's QR code (ui-core/ContactQr.h), made each time it is +// shown (the name can change), 3 px a module; null when it doesn't fit. +static lv_obj_t* contactQr(lv_obj_t* parent) { + static const int S = 3, PX = (contactqr::MAX_VERSION * 4 + 17 + 2 * siteqr::QUIET) * S; + static uint8_t data[8 + (PX + 7) / 8 * PX]; + static lv_image_dsc_t dsc; + static contactqr::Code code; + if (!contactqr::make(code)) return nullptr; + qrImage(dsc, data, sizeof(data), code.n, S, [](int x, int y) { return code.dark(x, y); }); + lv_obj_t* img = lv_image_create(parent); + lv_image_set_src(img, &dsc); + return img; +} + +// Settings > About: this node, the firmware, the manual's QR code, credits. +void UITask::buildAboutPage(lv_obj_t* body) { + lv_obj_t* about = infoCard(body); + infoRow(about, "Node", the_mesh.getNodeName()); + infoRow(about, "Firmware", FIRMWARE_VERSION); + if (!strstr(FIRMWARE_VERSION, FIRMWARE_BUILD_DATE)) infoRow(about, "Built", FIRMWARE_BUILD_DATE); + sectionTitle(body, "MY CONTACT"); // the phone app scans it to add you + lv_obj_t* me = infoCard(body); + lv_obj_set_flex_align(me, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_ver(me, theme::PAD, 0); + lv_obj_set_style_pad_row(me, 6, 0); + contactQr(me); + label(me, "Scan with the MeshCore app to add this node", THEME_FONT_SMALL, theme::TEXT_MUTED); + sectionTitle(body, "MANUAL"); + lv_obj_t* man = infoCard(body); + lv_obj_set_flex_align(man, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_ver(man, theme::PAD, 0); + lv_obj_set_style_pad_row(man, 6, 0); + siteQr(man); + label(man, siteqr::URL, THEME_FONT_SMALL, theme::TEXT_MUTED); + sectionTitle(body, "CREDITS"); + lv_obj_t* cr = infoCard(body); + infoNote(cr, "Map data", creditText((lvport::mountStorage() && mapview::s_provider->available()) + ? mapview::s_provider->attribution() : "\xC2\xA9 OpenStreetMap contributors")); + infoNote(cr, "Emoji", creditText("Twemoji \xC2\xA9 Twitter, Inc. and contributors (CC-BY\xC2\xA0" "4.0)")); +} + +// A page's schema rows, one group per section (Settings pages and the tools' +// options). Each section's card is kept in s_sec_card for rows added after. + +// On a screen's body the first group shows at once and the others follow +// (fill); a popup's list gets them all now. +static lv_obj_t* s_schema_body = nullptr; +static bool s_schema_one = false; // one section: its own page, no heading +static bool s_schema_bare = false; // no section headings + +void UITask::schemaRows(lv_obj_t* body, uint8_t page) { + for (lv_obj_t*& c : s_sec_card) c = nullptr; + s_schema_body = body; + s_schema_one = page == settings::PG_NAV && s_nav_section >= 0; + s_schema_bare = s_schema_one || page == settings::PG_RADIO; // one section: the page title says it + uint8_t sec = 0xFF; + bool first = true; + for (int i = 0; i < settings::COUNT; i++) { + const settings::Setting& st = settings::ALL[i]; + if (settings::sectionPage(st.section) != page || st.section == sec) continue; + if (s_schema_one && st.section != s_nav_section) continue; + sec = st.section; + if (first || body != _body) schemaSection(i); + else fill(&UITask::schemaSection, i, i + 1); + first = false; + } +} + +// The group of the section setting i starts, with its rows. +void UITask::schemaSection(int i) { + uint8_t sec = settings::ALL[i].section; + lv_obj_t* card = s_sec_card[sec] = group(s_schema_body, s_schema_bare ? nullptr : settings::sectionTitle(sec)); + if (sec == settings::SEC_SOUND) buildSoundRows(card, true); // On / Off / Auto + for (int k = i; k < settings::COUNT && settings::ALL[k].section == sec; k++) { + if (settings::ALL[k].offset == offsetof(NodePrefs, lock_compact)) continue; // ui-new's lock look; the slide card has one + if (settings::ALL[k].offset == offsetof(NodePrefs, msg_alert)) { msgBannerRow(card); continue; } // no corner envelope here + schemaRow(card, k); + } +} + +// One schema setting as a row of `card`: a switch, a choice, or (brightness, +// volume) a slider. +void UITask::schemaRow(lv_obj_t* card, int i) { + if (i < 0 || i >= settings::COUNT) return; + const settings::Setting& st = settings::ALL[i]; + uint8_t v = settings::get(*_prefs, st); + if (st.offset == offsetof(NodePrefs, buzzer_volume)) { // a five-step slider, heard on release + lv_obj_t* sl = rowSlider(settingRow(card, st.label, NULL), 0, 4, v); + lv_obj_add_event_cb(sl, onVolumeSlider, LV_EVENT_RELEASED, NULL); + return; + } + if (st.offset == offsetof(NodePrefs, display_brightness)) { // a slider here instead of five steps + uint8_t pct = _prefs->display_brightness_pct ? _prefs->display_brightness_pct + : (uint8_t)(_prefs->display_brightness * 25 > 5 ? _prefs->display_brightness * 25 : 5); + lv_obj_t* sl = rowSlider(settingRow(card, st.label, NULL), 5, 100, pct); + lv_obj_add_event_cb(sl, onBrightnessSlider, LV_EVENT_VALUE_CHANGED, NULL); + lv_obj_add_event_cb(sl, onBrightnessSlider, LV_EVENT_RELEASED, NULL); + return; + } + if (!st.option) { + lv_obj_t* sw = switchRow(card, st.label, st.hint, nullptr); + if (v) lv_obj_add_state(sw, LV_STATE_CHECKED); + lv_obj_add_event_cb(sw, onSchemaSwitch, LV_EVENT_VALUE_CHANGED, (void*)(uintptr_t)i); + return; + } + char opts[160]; + int o = 0; + for (uint8_t k = 0; k < st.count && o < (int)sizeof(opts) - 16; k++) { + if (k) opts[o++] = '\n'; + st.option(k, opts + o, sizeof(opts) - o, *_prefs); + o += strlen(opts + o); + } + opts[o] = '\0'; + choiceRow(card, st.label, st.hint, opts, v, onSchemaDropdown, (void*)(uintptr_t)i); +} + +void UITask::setSchemaValue(int idx, int v) { + if (!_prefs || idx < 0 || idx >= settings::COUNT) return; + const settings::Setting& st = settings::ALL[idx]; + settings::set(*_prefs, st, (uint8_t)v); + if (st.changed) st.changed(*_core); + prefsSave(); + if (st.offset == offsetof(NodePrefs, units_imperial) && _screen == SCR_SETTINGS_NAV) // other labels depend on it + rebuildSoon(&UITask::rebuildSchemaSettings); +} + +void UITask::rebuildSchemaSettings() { + int32_t y = _body ? lv_obj_get_scroll_y(_body) : 0; + buildSchemaSettings(); + fillFlush(); + if (_body) { layoutNow(_body); lv_obj_scroll_to_y(_body, y, LV_ANIM_OFF); } +} + +// A screen rebuilt for what a switch or segmented row changed (hints, rows +// that come and go), once its knob has moved: rebuilt at once, the 80-130 ms +// cut the animation short and the knob jumped. Not if the screen's gone by then. +static lv_timer_t* s_rebuild_t = nullptr; +void UITask::rebuildSoon(void (UITask::*fn)()) { + static void (UITask::*s_fn)() = nullptr; + static uint8_t s_scr = 0; + s_fn = fn; + s_scr = (uint8_t)_screen; + if (s_rebuild_t) lv_timer_delete(s_rebuild_t); + s_rebuild_t = lv_timer_create([](lv_timer_t*) { + s_rebuild_t = nullptr; + if ((uint8_t)s_ui->_screen == s_scr && !lv_display_get_screen_prev(NULL)) (s_ui->*s_fn)(); + }, 150, nullptr); + lv_timer_set_repeat_count(s_rebuild_t, 1); +} + +void UITask::showSettings() { + _screen = SCR_SETTINGS; + buildSettings(); +} + +static void onOpenRadio(lv_event_t* e); // RadioScreen.h +static void onOpenStorage(lv_event_t* e); // StorageScreen.h + +// Settings: groups of rows, each opening a page (chevron) or switching +// something on the spot. What the app is for first, the system last. +void UITask::buildSettings() { + bool restore = _nav_back; // back from one of its pages: where it was + lv_obj_t* body = newScreen("Settings", true); + // A group at a time (fillStart): the first shows at once. Coming back to + // where it was scrolled needs them all first. + fillStart(SETTINGS_GROUPS, 1, &UITask::settingsGroup); + if (restore) { fillFlush(); layoutNow(body); lv_obj_scroll_to_y(body, s_settings_y, LV_ANIM_OFF); } +} + +void UITask::settingsGroup(int i) { + lv_obj_t* body = _body; + char sub[48]; + lv_obj_t* g; + char v1[24], v2[24]; + switch (i) { + case 0: + if (!_prefs) break; + g = group(body, "DEVICE"); + listRow(g, LV_SYMBOL_EYE_OPEN " Display", "Brightness, screen off, lock, colour", + onOpenSchemaPage, (void*)(uintptr_t)PG_DISPLAY); + snprintf(sub, sizeof(sub), "Order, hide - %d of %d shown", home::shownCount(), home::APP_COUNT); + listRow(g, LV_SYMBOL_HOME " Home apps", sub, onHomeApps, NULL); + snprintf(sub, sizeof(sub), "Shutdown %s - GPS: %s", settingText(*_prefs, SETTING(low_batt_mv), v1, sizeof(v1)), + settingText(*_prefs, SETTING(gps_interval), v2, sizeof(v2))); + listRow(g, LV_SYMBOL_BATTERY_FULL " Power", sub, onOpenSchemaPage, (void*)(uintptr_t)PG_POWER); + { + char vol[12]; + settings::optVolume(_prefs->buzzer_volume, vol, sizeof(vol), *_prefs); + snprintf(sub, sizeof(sub), "%s, %s", soundctl::modeLabel(soundctl::mode(_prefs)), vol); + } + listRow(g, LV_SYMBOL_VOLUME_MAX " Sound", sub, onOpenSchemaPage, (void*)(uintptr_t)settings::PG_SOUND); + { + static const char* const SCRIPT[] = { "Latin", "Cyrillic", "Greek" }; + uint8_t ma = _prefs->keyboard_main_alphabet % NodePrefs::KB_ALPHABET_COUNT; + uint8_t aa = _prefs->keyboard_alt_alphabet % NodePrefs::KB_ALPHABET_COUNT; + snprintf(sub, sizeof(sub), aa == ma ? "%s" : "%s + %s", SCRIPT[ma], SCRIPT[aa]); + } + listRow(g, LV_SYMBOL_KEYBOARD " Keyboard", sub, onOpenSchemaPage, (void*)(uintptr_t)PG_KEYBOARD); + listRow(g, LV_SYMBOL_ENVELOPE " Messages & contacts", "Resend, adding, expiry, quick messages", + onOpenSchemaPage, (void*)(uintptr_t)settings::PG_MESSAGES); + break; + + case 1: + g = group(body, "CONNECTIONS"); + if (_prefs) { + int pi = radioctl::currentPreset(_prefs); + const char* pn = "Custom"; float f, b; uint8_t sf, cr; + if (pi >= 0) radioctl::presetAt(_prefs, pi, pn, f, b, sf, cr); + snprintf(sub, sizeof(sub), "%s - %.3f MHz, %d dBm", pn, _prefs->freq, _prefs->tx_power_dbm); + listRow(g, UI_SYMBOL_RADIO " Radio", sub, onOpenRadio, NULL); + listRow(g, LV_SYMBOL_EYE_CLOSE " Privacy", "Position, who can ask for telemetry, Bluetooth PIN", + onOpenSchemaPage, (void*)(uintptr_t)settings::PG_PRIVACY); + } + bluetoothRow(g); + wifiRow(g); + if (_core->gpsAvailable()) { // as WiFi: the switch turns it on / off, the row opens its details + lv_obj_t* sw = switchRow(g, LV_SYMBOL_GPS " GPS", "Tap for satellites and signal", nullptr); + if (_core->gpsEnabled()) lv_obj_add_state(sw, LV_STATE_CHECKED); + lv_obj_add_event_cb(sw, onGpsSwitch, LV_EVENT_VALUE_CHANGED, NULL); + lv_obj_t* row = lv_obj_get_parent(sw); + rowPressable(row); + lv_obj_add_event_cb(row, [](lv_event_t* e) { + if (lv_event_get_target(e) == lv_event_get_current_target(e)) onOpenGpsFromSettings(e); // not the switch + }, LV_EVENT_CLICKED, NULL); + } + break; + + case 2: + g = group(body, "MAP & DATA"); + if (_prefs) listRow(g, UI_SYMBOL_MAP " Map", "Trail, live sharing, arrival alert", + onOpenSchemaPage, (void*)(uintptr_t)settings::PG_NAV); + listRow(g, LV_SYMBOL_SD_CARD " Storage", "SD card, message history", onOpenStorage, NULL); + break; + + case 3: + g = group(body, "SYSTEM"); + listRow(g, LV_SYMBOL_EDIT " Name", the_mesh.getNodeName(), onNodeName, NULL); + if (_prefs) { + snprintf(sub, sizeof(sub), "%s, %s", settingText(*_prefs, SETTING(tz_offset_hours), v1, sizeof(v1)), + _prefs->clock_12h ? "12 h" : "24 h"); + listRow(g, UI_SYMBOL_CLOCK " Time", sub, onOpenSchemaPage, (void*)(uintptr_t)PG_TIME); + schemaRow(g, SETTING(units_imperial)); + } + listRow(g, LV_SYMBOL_DOWNLOAD " Firmware update", FIRMWARE_VERSION, onOpenOta, NULL); + listRow(g, LV_SYMBOL_LIST " About", "Node, your contact QR, manual, credits", onOpenSchemaPage, (void*)(uintptr_t)PG_ABOUT); + break; + + case 4: + g = group(body, nullptr); + actionRow(g, LV_SYMBOL_REFRESH " Reboot", onPowerRow, (void*)(uintptr_t)1); + actionRow(g, LV_SYMBOL_POWER " Power off", onPowerRow, (void*)(uintptr_t)0, theme::FAIL); + actionRow(g, LV_SYMBOL_TRASH " Factory reset", onFactoryReset, (void*)(uintptr_t)1, theme::FAIL); + break; + } +} + +void UITask::setKeyboardAlphabets(int main_idx, int alt_sel) { + if (!_prefs) return; + _prefs->keyboard_main_alphabet = (uint8_t)main_idx; + _prefs->keyboard_alt_alphabet = (uint8_t)(alt_sel == 0 ? main_idx : alt_sel - 1); + prefsSave(); +} diff --git a/examples/companion_radio/ui-lvgl/ThreadView.h b/examples/companion_radio/ui-lvgl/ThreadView.h new file mode 100644 index 00000000..4de304c6 --- /dev/null +++ b/examples/companion_radio/ui-lvgl/ThreadView.h @@ -0,0 +1,722 @@ +#pragma once +// Included into UITask.cpp only, where it stands (it uses what is defined above it). +// A conversation's thread: bubbles, the compose row, message metadata. + +// ── Conversation ────────────────────────────────────────────────────────────── + +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) s_ui->setKeyboardVisible(false); +} + +// What a message may take is counted in UTF-8 bytes (the over-the-air limit), +// not characters: Cyrillic / Greek / accented letters take two bytes each. The +// limit is what the send leaves of it -- a channel post carries this node's +// name -- and a placeholder counts as what it expands to (MessageText.h). +int UITask::composeLimit() const { return msgtext::limit(_thread_is_channel, _prefs); } +int UITask::composeLen(const char* typed) const { return msgtext::sentLen(typed, _prefs); } + +static lv_obj_t* s_compose_left = nullptr; // the bytes left, a tag over the field's right end + +// Only once the end is near (40 bytes, as on the other screens): a tag sitting +// on the field's top edge, so the field keeps its width and its text. +static void composeLeftRefresh(lv_obj_t* ta) { + if (!s_compose_left || !ta) return; + const int left = s_ui->composeLimit() - s_ui->composeLen(lv_textarea_get_text(ta)); + if (left > 40) { lv_obj_add_flag(s_compose_left, LV_OBJ_FLAG_HIDDEN); return; } + char t[8]; + snprintf(t, sizeof(t), "%d", left); + setText(s_compose_left, t); + setTextColor(s_compose_left, left < 0 ? theme::FAIL : left <= 20 ? theme::ACCENT : theme::TEXT_MUTED); + lv_obj_remove_flag(s_compose_left, LV_OBJ_FLAG_HIDDEN); + lv_obj_update_layout(s_compose_left); + lv_obj_align_to(s_compose_left, ta, LV_ALIGN_OUT_TOP_RIGHT, -8, lv_obj_get_height(s_compose_left) / 2); +} + +static void onComposeInsert(lv_event_t* e) { + lv_obj_t* ta = (lv_obj_t*)lv_event_get_target(e); + const char* ins = (const char*)lv_event_get_param(e); + if (!ins) return; + char joined[2 * MAX_TEXT_LEN + 1]; // the text with the insert (at the end: the length is the same) + snprintf(joined, sizeof(joined), "%s%s", lv_textarea_get_text(ta), ins); + if (s_ui->composeLen(joined) > s_ui->composeLimit()) + lv_textarea_set_insert_replace(ta, ""); // reject: would exceed the byte limit +} + +static void onComposeChanged(lv_event_t* e) { composeLeftRefresh((lv_obj_t*)lv_event_get_target(e)); } + +static void onComposeClicked(lv_event_t* e) { (void)e; s_ui->setKeyboardVisible(true); } +static void onComposeMore(lv_event_t* e) { (void)e; s_ui->quickPopup(); } + +// 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); + fieldFocus(_compose_ta, true); + } else { + lv_obj_add_flag(_keyboard, LV_OBJ_FLAG_HIDDEN); + fieldFocus(_compose_ta, false); + } + 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 + layoutNow(_thread_list); + lv_obj_scroll_to_y(_thread_list, LV_COORD_MAX, LV_ANIM_OFF); + } +} + +static const int THREAD_MAX_SHOWN = 50; // newest bubbles built per conversation + +// Opens on the first unread (newest-first index `first`, -1: none), "New" +// above it: the page that holds it, a few read ones above. +void UITask::threadOpenAt(int first) { + _thread_new = first; + _thread_new_on = false; + _thread_skip = first < THREAD_MAX_SHOWN ? 0 : first - (THREAD_MAX_SHOWN - 4); +} + +// Back on over a conversation: what came in while asleep opens it again on +// its first unread (the typed text kept, the keyboard down); else just the +// fresh content. +void UITask::reopenThreadIfUnread() { + bool unread = _thread_is_channel ? _core->history.chUnread(_thread_channel) > 0 : _core->dmUnread(_thread_key) > 0; + if (!unread || _nav_overlay) { refreshThread(); return; } + char typed[MSG_TEXT_BUF] = ""; + if (_compose_ta) snprintf(typed, sizeof(typed), "%s", lv_textarea_get_text(_compose_ta)); + uint8_t key[PUB_KEY_SIZE]; // openDM() clears _thread_key before reading its argument + memcpy(key, _thread_key, sizeof(key)); + if (_thread_is_channel) openChannel(_thread_channel); + else openDM(key); + if (_compose_ta && typed[0]) { lv_textarea_set_text(_compose_ta, typed); composeLeftRefresh(_compose_ta); } +} + +void UITask::openChannel(uint8_t channel_idx) { + _thread_is_channel = true; + _thread_channel = channel_idx; + MessageHistory& h = _core->history; + threadOpenAt(h.firstUnreadChannel(channel_idx, h.chUnread(channel_idx))); + h.setChUnread(channel_idx, 0); + _screen = SCR_THREAD; + buildThread(); +} + +void UITask::openDM(const uint8_t* pub_key) { + _thread_is_channel = false; + memset(_thread_key, 0, sizeof(_thread_key)); + ContactInfo c; + if (MessageHistory::contactByPrefix(pub_key, c)) memcpy(_thread_key, c.id.pub_key, PUB_KEY_SIZE); + else memcpy(_thread_key, pub_key, 4); + threadOpenAt(_core->history.firstUnreadDm(_thread_key, _core->dmUnread(_thread_key))); + _core->clearDMUnread(_thread_key); + _screen = SCR_THREAD; + buildThread(); +} + +void UITask::buildThread() { + char title[40]; + bool can_send = true; + if (_thread_is_channel) { + ChannelDetails ch; + if (the_mesh.getChannel(_thread_channel, ch)) snprintf(title, sizeof(title), "%s", ch.name); // as named: "#" marks a hashtag channel + else snprintf(title, sizeof(title), "Channel %d", _thread_channel); + } else { + contactName(_thread_key, title, sizeof(title)); + ContactInfo c; + can_send = MessageHistory::contactByPrefix(_thread_key, c) && (c.type == ADV_TYPE_CHAT || c.type == ADV_TYPE_ROOM); + } + lv_obj_t* body = newScreen(title, true); + lv_obj_set_style_pad_all(body, 0, 0); + lv_obj_set_style_pad_row(body, 0, 0); + if (_header && (_thread_is_channel || can_send)) { // channel / conversation options + lv_obj_set_width(lv_obj_get_child(_header, 1), 200); // title, clear of the button + headerButton(_header, LV_SYMBOL_SETTINGS, _thread_is_channel ? onChanThreadMenu : onConvThreadMenu, 4, NULL); + } + + _thread_list = scrollList(body); + s_th_rows_n = 0; + lv_obj_set_style_pad_all(_thread_list, theme::PAD, 0); + + _compose_ta = nullptr; + _keyboard = nullptr; + s_compose_left = nullptr; + if (can_send) { + 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), 42); + lv_obj_set_style_pad_all(bar, 4, 0); + lv_obj_t* more = lv_button_create(bar); // quick messages, placeholders + lv_obj_set_size(more, 34, 34); + lv_obj_align(more, LV_ALIGN_LEFT_MID, 0, 0); + lv_obj_set_style_pad_all(more, 0, 0); + lv_obj_set_style_radius(more, theme::RADIUS, 0); + lv_obj_set_style_shadow_width(more, 0, 0); + lv_obj_set_style_bg_color(more, lv_color_hex(theme::SURFACE), 0); + lv_obj_add_event_cb(more, onComposeMore, LV_EVENT_CLICKED, NULL); + lv_obj_center(label(more, LV_SYMBOL_PLUS, THEME_FONT_BODY, theme::TEXT)); + _compose_ta = textField(bar, "Message"); // FOCUSED while the keyboard is up + lv_obj_add_event_cb(_compose_ta, onComposeInsert, LV_EVENT_INSERT, NULL); + lv_obj_add_event_cb(_compose_ta, onComposeChanged, LV_EVENT_VALUE_CHANGED, NULL); + lv_obj_set_width(_compose_ta, lv_display_get_horizontal_resolution(NULL) - 8 - 34 - 4); // beside "+" + lv_obj_align(_compose_ta, LV_ALIGN_RIGHT_MID, 0, 0); + lv_obj_add_flag(bar, LV_OBJ_FLAG_OVERFLOW_VISIBLE); // the count's tag rises above the bar + s_compose_left = label(bar, "", THEME_FONT_SMALL, theme::TEXT_MUTED); // bytes left + lv_obj_set_style_bg_color(s_compose_left, lv_color_hex(theme::BG), 0); + lv_obj_set_style_bg_opa(s_compose_left, LV_OPA_COVER, 0); + lv_obj_set_style_pad_hor(s_compose_left, 4, 0); + lv_obj_set_style_radius(s_compose_left, theme::RADIUS, 0); + lv_obj_add_flag(s_compose_left, LV_OBJ_FLAG_HIDDEN); + composeLeftRefresh(_compose_ta); + + // 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), 124); + lv_keyboard_set_textarea(_keyboard, _compose_ta); + fieldFocus(_compose_ta, false); // until the keyboard is up + 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_flag(_keyboard, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_event_cb(_compose_ta, onComposeClicked, LV_EVENT_CLICKED, _keyboard); + } + refreshThread(); + if (_compose_ta && _share_text[0]) { // shareToMessage(): the text waits in the field + lv_textarea_set_text(_compose_ta, _share_text); + _share_text[0] = '\0'; + layoutNow(screen()); + setKeyboardVisible(true); + } +} + +// Changes whenever the open conversation's content or delivery markers change. +uint32_t UITask::threadSignature() const { + const MessageHistory& h = _core->history; + uint32_t sig = 0; + if (_thread_is_channel) { + int n = h.histCountForChannel(_thread_channel); + sig = n; + for (int j = 0; j < n && j < 8; j++) { + const ChHistEntry& e = h.chAtPos(h.histEntryForChannel(_thread_channel, j)); + sig = sig * 31 + e.timestamp + e.relay_status + e.path_len; // path_len: repeaters heard + } + } else { + int n = h.dmHistCountForContact(_thread_key); + sig = n; + for (int j = 0; j < n && j < 8; j++) { + const DmHistEntry& e = h.dmAtPos(h.dmHistEntryForContact(_thread_key, j)); + sig = sig * 31 + e.timestamp + h.dmEffectiveStatus(e) + e.attempt * 7; // attempt: a dot per resend + } + } + return sig; +} + +// Positions found in the shown messages ([WAY] / [LOC] / plain "lat,lon"), +// for the Go / Save buttons under such a bubble. +struct MsgLoc { int32_t lat, lon; char label[WAYPOINT_LABEL_LEN * 2]; }; +static MsgLoc* s_msg_locs = psramBuf(THREAD_MAX_SHOWN); +static int s_msg_loc_n = 0; + +// The messages shown, oldest first: copies from the SD card's history (which +// holds far more than the RAM ring) or from the ring, one page at a time. +static ChHistEntry* s_th_ch = psramBuf(THREAD_MAX_SHOWN); +static DmHistEntry* s_th_dm = psramBuf(THREAD_MAX_SHOWN); + +// What a held bubble is about: its entry (index into s_th_ch / s_th_dm), +// sender, position slot. One per shown message, at its index. +struct MsgMeta { int pos; bool channel; bool own; int loc; char from[32]; }; +static MsgMeta* s_msg_meta = psramBuf(THREAD_MAX_SHOWN); +static int s_msg_meta_n = 0; + +// The bubbles built, one per shown message: which message (key), its +// delivery state, its row and age label. A refresh that only adds messages +// or changes a delivery mark touches just those bubbles -- rebuilding all 50 +// on every repeater echo made the conversation stutter. +struct ThreadRow { uint32_t key, state; lv_obj_t* row; lv_obj_t* age; }; +static ThreadRow* s_th_rows = psramBuf(THREAD_MAX_SHOWN); +static lv_obj_t* s_older_lbl = nullptr; // "Older messages (n)" on the page's top +static bool s_th_rows_newest = false; // built for the newest page (the one patched) +static lv_obj_t* s_new_div = nullptr; // "New" above the first unread + +// The line with "New" over the first message unread when the thread opened. +static lv_obj_t* newDivider(lv_obj_t* list) { + lv_obj_t* d = lv_obj_create(list); + lv_obj_remove_style_all(d); + lv_obj_set_size(d, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_flex_flow(d, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(d, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_column(d, 6, 0); + lv_obj_remove_flag(d, LV_OBJ_FLAG_CLICKABLE); + for (int i = 0; i < 3; i++) { + if (i == 1) { label(d, "New", THEME_FONT_SMALL, theme::ACCENT); continue; } + lv_obj_t* l = lv_obj_create(d); + lv_obj_remove_style_all(l); + lv_obj_set_height(l, 1); + lv_obj_set_flex_grow(l, 1); + lv_obj_set_style_bg_color(l, lv_color_hex(theme::ACCENT), 0); + lv_obj_set_style_bg_opa(l, LV_OPA_COVER, 0); + } + return d; +} + +// The shown message a bubble (or a button in it) belongs to, or -1. +static int threadRowOf(lv_obj_t* o) { + for (; o; o = lv_obj_get_parent(o)) + for (int r = 0; r < s_th_rows_n; r++) if (s_th_rows[r].row == o) return r; + return -1; +} + +static void onMsgLoc(lv_event_t* e) { + int r = threadRowOf((lv_obj_t*)lv_event_get_current_target(e)); + if (r >= 0) s_ui->messageLocationAction(s_msg_meta[r].loc, (uintptr_t)lv_event_get_user_data(e) != 0); +} + +static void msgLocButton(lv_obj_t* parent, const char* text, bool save, bool accent, bool own) { + lv_obj_t* b = lv_button_create(parent); + lv_obj_set_size(b, LV_SIZE_CONTENT, 28); + lv_obj_set_style_pad_hor(b, 10, 0); + lv_obj_set_style_pad_ver(b, 0, 0); + lv_obj_set_style_radius(b, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_shadow_width(b, 0, 0); + // on our own amber bubble the main one goes dark instead + lv_obj_set_style_bg_color(b, lv_color_hex(accent ? (own ? theme::BG : theme::ACCENT) : theme::SURFACE_2), 0); + lv_obj_add_event_cb(b, onMsgLoc, LV_EVENT_CLICKED, (void*)(uintptr_t)(save ? 1 : 0)); + lv_obj_center(label(b, text, THEME_FONT_SMALL, accent ? (own ? theme::ACCENT : theme::BG) : theme::TEXT)); +} + +// A message's text, wrapped at `max_w` and shrunk to fit. "@[nick]" mentions +// (how a reply names who it answers) show as "@nick" in the accent -- dark, +// as the rest, on our own amber bubbles -- and underlined when the nick is +// ours (*mentions_me set). Plain text stays a label. +static lv_obj_t* msgText(lv_obj_t* parent, const char* text, bool own, int max_w, bool* mentions_me) { + *mentions_me = false; + const uint32_t col = own ? theme::BG : theme::TEXT; + if (!strstr(text, "@[")) { + lv_obj_t* t = label(parent, text, THEME_FONT_BODY, col); + lv_label_set_long_mode(t, LV_LABEL_LONG_WRAP); + lv_obj_set_style_max_width(t, max_w, 0); + lv_obj_set_width(t, LV_SIZE_CONTENT); + return t; + } + lv_obj_t* sg = lv_spangroup_create(parent); + lv_obj_remove_flag(sg, LV_OBJ_FLAG_CLICKABLE); // a hold still reaches the bubble (its menu) + lv_obj_set_style_text_font(sg, THEME_FONT_BODY, 0); + lv_obj_set_style_text_color(sg, lv_color_hex(col), 0); + lv_spangroup_set_mode(sg, LV_SPAN_MODE_BREAK); + const char* me = the_mesh.getNodeName(); + char part[MAX_TEXT_LEN + 1]; + const char* p = text; + while (*p) { + const char* at = strstr(p, "@["); + const char* close = at ? strchr(at + 2, ']') : nullptr; + if (at && (!close || close - at > 34)) close = nullptr; // not a mention: a nick is <= 31 chars + const char* end = at && close ? at : p + strlen(p); + if (at && !close) end = at + 2; // "@[" alone: plain, carry on after it + if (end > p) { + size_t n = end - p < (ptrdiff_t)sizeof(part) ? end - p : sizeof(part) - 1; + memcpy(part, p, n); + part[n] = '\0'; + lv_span_set_text(lv_spangroup_new_span(sg), part); + } + if (!at || !close) { p = end; continue; } + size_t n = close - (at + 2); + part[0] = '@'; + memcpy(part + 1, at + 2, n); + part[n + 1] = '\0'; + bool mine = strlen(me) == n && strncasecmp(me, at + 2, n) == 0; + lv_span_t* sp = lv_spangroup_new_span(sg); + lv_span_set_text(sp, part); + lv_style_t* st = lv_span_get_style(sp); + lv_style_set_text_color(st, lv_color_hex(own ? theme::BG : theme::ACCENT)); + if (mine) { lv_style_set_text_decor(st, LV_TEXT_DECOR_UNDERLINE); *mentions_me = true; } + p = close + 1; + } + lv_spangroup_refr_mode(sg); + uint32_t w = lv_spangroup_get_expand_width(sg, 0); + lv_obj_set_width(sg, w > (uint32_t)max_w ? max_w : (int32_t)w + 1); + lv_obj_set_height(sg, LV_SIZE_CONTENT); + return sg; +} + +// One message bubble. Own messages right-aligned in amber with dark text +// (the delivery mark too: green or red on amber can't be read), others left. +// loc: the text carries a position (Go / Save buttons); hold: a long press +// opens the message menu. Returns its row; *age_out = the age label. +static lv_obj_t* bubble(lv_obj_t* list, const char* from, const char* text, bool own, + uint32_t ts, const char* status, uint32_t status_col, bool loc, bool hold, + int relays, lv_obj_t** age_out) { + // Full-width row that pushes the bubble to its side. + lv_obj_t* row = lv_obj_create(list); + styleSurface(row, theme::BG); + lv_obj_set_style_bg_opa(row, LV_OPA_TRANSP, 0); + lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(row, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(row, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(row, own ? LV_FLEX_ALIGN_END : LV_FLEX_ALIGN_START, + LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); + + lv_obj_t* b = lv_obj_create(row); + lv_obj_remove_flag(b, LV_OBJ_FLAG_SCROLLABLE); + if (hold) { // hold: reply / path / target (ConversationScreen.h) + lv_obj_add_event_cb(b, onMsgHold, LV_EVENT_LONG_PRESSED, nullptr); + lv_obj_set_style_bg_color(b, lv_color_hex(own ? theme::mix(theme::ACCENT, theme::BG, 75) : theme::SURFACE_2), LV_STATE_PRESSED); + } else { + lv_obj_remove_flag(b, LV_OBJ_FLAG_CLICKABLE); + } + lv_obj_set_size(b, LV_SIZE_CONTENT, LV_SIZE_CONTENT); + lv_obj_set_style_max_width(b, 250, 0); + lv_obj_set_style_bg_color(b, lv_color_hex(own ? theme::ACCENT : theme::SURFACE), 0); + const uint32_t muted = own ? theme::mix(theme::BG, theme::ACCENT, 60) : theme::TEXT_MUTED; + lv_obj_set_style_border_width(b, 0, 0); + lv_obj_set_style_radius(b, theme::RADIUS, 0); + lv_obj_set_style_pad_all(b, 6, 0); + lv_obj_set_style_pad_row(b, 2, 0); + lv_obj_set_flex_flow(b, LV_FLEX_FLOW_COLUMN); + + char meta[32]; + uint32_t now = rtc_clock.getCurrentTime(); + fmtMsgAge(meta, sizeof(meta), now, ts, s_prefs); // "12s" / "5m" / "3h" / "2d" + + // 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 = flexBox(b, LV_FLEX_FLOW_ROW); + lv_obj_set_style_pad_column(hdr, 8, 0); + label(hdr, from, THEME_FONT_SMALL, own ? theme::BG : theme::ACCENT); // names are <= 31 chars: fits the bubble + if (meta_in_header) *age_out = label(hdr, meta, THEME_FONT_SMALL, muted); + } + bool mentions_me; + msgText(b, text, own, 238, &mentions_me); + if (mentions_me && !own) { // someone answering us: the bubble outlined + lv_obj_set_style_border_width(b, 1, 0); + lv_obj_set_style_border_color(b, lv_color_hex(theme::ACCENT), 0); + } + + if (loc) { // position: navigate there / keep it as a waypoint + lv_obj_t* acts = flexBox(b, LV_FLEX_FLOW_ROW); + lv_obj_set_style_pad_column(acts, 6, 0); + lv_obj_set_style_pad_top(acts, 2, 0); + msgLocButton(acts, UI_SYMBOL_COMPASS " Go", false, true, own); + msgLocButton(acts, UI_SYMBOL_FLAG " Save", true, false, own); + } + + if (!meta_in_header) { // the age, then the delivery mark in its colour (as L1) + lv_obj_t* line = flexBox(b, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(line, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_column(line, 6, 0); + *age_out = label(line, meta, THEME_FONT_SMALL, muted); + if (relays > 0) { // as L1: the count alone says it got out, no check beside it + lv_obj_t* c = lv_obj_create(line); + lv_obj_remove_style_all(c); + lv_obj_remove_flag(c, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(c, LV_SIZE_CONTENT, 15); + lv_obj_set_style_min_width(c, 15, 0); + lv_obj_set_style_pad_hor(c, 4, 0); + lv_obj_set_style_bg_opa(c, LV_OPA_COVER, 0); + lv_obj_set_style_bg_color(c, lv_color_hex(theme::OK), 0); + lv_obj_set_style_radius(c, LV_RADIUS_CIRCLE, 0); + lv_obj_t* n = label(c, "", THEME_FONT_SMALL, theme::BG); + lv_label_set_text_fmt(n, "%d", relays); + lv_obj_center(n); + } else if (status && status[0]) { + label(line, status, THEME_FONT_SMALL, own ? theme::BG : status_col); + } + } + return row; +} + +// Remember the position in `text` (if any) for its bubble's buttons, in +// slot r; label from a [WAY] tag, else the sender. Returns r or -1. +static int noteMsgLocation(int r, const char* text, const char* sender) { + MsgLoc& m = s_msg_locs[r]; + if (!geo::parseLatLon(text, m.lat, m.lon, m.label, sizeof(m.label))) return -1; + if (!m.label[0]) snprintf(m.label, sizeof(m.label), "%s", sender && sender[0] ? sender : "Msg loc"); + return r; +} + +void UITask::messageLocationAction(int idx, bool save) { + if (idx < 0 || idx >= s_msg_loc_n) return; + const MsgLoc& m = s_msg_locs[idx]; + if (save) { + if (_core->waypoints.full()) { showToast(waypointsFull()); return; } + if (_core->waypoints.add(m.lat, m.lon, rtc_clock.getCurrentTime(), m.label)) { + char t[48]; + snprintf(t, sizeof(t), "Saved %s", _core->waypoints.at(_core->waypoints.count() - 1).label); + showToast(t); + } + return; + } + // A place snapshotted from the text (kind 0): nothing to keep re-resolving. + navSetTarget(0, nullptr, m.lat, m.lon, m.label); + openMap(true); + navFrameTarget(); + refreshNavBar(); +} + +// The page of the open conversation into s_th_ch / s_th_dm (oldest first): +// THREAD_MAX_SHOWN messages ending _thread_skip before the newest. From the +// SD card's copy when it has at least what the ring has, else from the ring. +// `total` = messages in that source. +int UITask::loadThreadPage(int& total) { + const MessageHistory& h = _core->history; + char path[64]; + if (_thread_is_channel) { + int ring = h.histCountForChannel(_thread_channel); + int arc = 0; + int got = s_archive.ready() && s_archive.chPath(_thread_channel, path, sizeof(path)) + ? s_archive.window(path, _thread_skip, THREAD_MAX_SHOWN, s_th_ch, &arc) : 0; + if (arc > 0 && arc >= ring) { + total = arc; + return got; + } + total = ring; + int m = ring - _thread_skip; + if (m > THREAD_MAX_SHOWN) m = THREAD_MAX_SHOWN; + for (int i = 0; i < m; i++) s_th_ch[i] = h.chAtPos(h.histEntryForChannel(_thread_channel, _thread_skip + m - 1 - i)); + return m > 0 ? m : 0; + } + int ring = h.dmHistCountForContact(_thread_key); + int arc = 0, got = 0; + if (s_archive.ready()) { + histstore::SdArchive::dmPath(_thread_key, path, sizeof(path)); + got = s_archive.window(path, _thread_skip, THREAD_MAX_SHOWN, s_th_dm, &arc); + } + if (arc > 0 && arc >= ring) { + total = arc; + return got; + } + total = ring; + int m = ring - _thread_skip; + if (m > THREAD_MAX_SHOWN) m = THREAD_MAX_SHOWN; + for (int i = 0; i < m; i++) s_th_dm[i] = h.dmAtPos(h.dmHistEntryForContact(_thread_key, _thread_skip + m - 1 - i)); + return m > 0 ? m : 0; +} + +static void onThreadPage(lv_event_t* e) { s_ui->threadPage((int)(intptr_t)lv_event_get_user_data(e)); } + +// "Older messages" / "Newer messages" at the ends of a page. +static void pageButton(lv_obj_t* list, const char* text, int dir) { + lv_obj_t* b = lv_button_create(list); + lv_obj_set_size(b, LV_PCT(100), 32); + lv_obj_set_style_bg_color(b, lv_color_hex(theme::SURFACE), 0); + lv_obj_add_event_cb(b, onThreadPage, LV_EVENT_CLICKED, (void*)(intptr_t)dir); + lv_obj_center(label(b, text, THEME_FONT_SMALL, theme::TEXT_MUTED)); +} + +void UITask::threadPage(int dir) { + _thread_skip += dir > 0 ? THREAD_MAX_SHOWN : -THREAD_MAX_SHOWN; + if (_thread_skip < 0) _thread_skip = 0; + _thread_scroll_top = dir < 0; // newer: read on from the top of that page + refreshThread(); +} + +// Every 2 s on a conversation, as ui-new: the bubbles' ages ("12s", "14s"). +void UITask::refreshThreadAges() { + _next_thread_ages_ms = millis() + 2000; + uint32_t now = rtc_clock.getCurrentTime(); + for (int r = 0; r < s_th_rows_n; r++) { + if (!s_th_rows[r].age) continue; + char meta[32]; + fmtMsgAge(meta, sizeof(meta), now, _thread_is_channel ? s_th_ch[r].timestamp : s_th_dm[r].timestamp, _prefs); + setText(s_th_rows[r].age, meta); + } +} + +void UITask::refreshThread() { + if (!_thread_list) return; + _thread_dirty = false; + _thread_sig = threadSignature(); + _next_thread_ages_ms = millis() + 2000; // the ages are written below + const MessageHistory& h = _core->history; + int total = 0; + int n = loadThreadPage(total); + if (n == 0 && _thread_skip > 0) { // the page went away (history trimmed) + _thread_skip = 0; + n = loadThreadPage(total); + } + ContactInfo tc; + bool room = !_thread_is_channel && MessageHistory::contactByPrefix(_thread_key, tc) && tc.type == ADV_TYPE_ROOM; + + // Which message entry i is, and the delivery state its bubble shows. + auto keyOf = [&](int i) { + uint32_t ts = _thread_is_channel ? s_th_ch[i].timestamp : s_th_dm[i].timestamp; + const char* t = _thread_is_channel ? s_th_ch[i].text : s_th_dm[i].text; + uint32_t k = 2166136261u ^ ts; + for (; *t; t++) k = (k ^ (uint8_t)*t) * 16777619u; + return k; + }; + auto stateOf = [&](int i) -> uint32_t { + if (_thread_is_channel) return s_th_ch[i].relay_status | (uint32_t)s_th_ch[i].path_len << 8; + return s_th_dm[i].outgoing ? 0x100u | h.dmEffectiveStatus(s_th_dm[i]) | (uint32_t)s_th_dm[i].attempt << 16 : 0; + }; + // Entry i's bubble (at the list's end) and its menu / position slots. + auto build = [&](int i) { + MsgMeta& m = s_msg_meta[i]; + const char* st = NULL; uint32_t col = theme::TEXT_MUTED; + int nrel = 0; + lv_obj_t* age = nullptr; + lv_obj_t* row; + if (_thread_is_channel) { + const ChHistEntry& e = s_th_ch[i]; + // Channel text is "Sender: body"; our own posts are filed as "Me: body". + char from[40] = ""; + const char* body = e.text; + const char* sep = strstr(e.text, ": "); + if (sep && sep - e.text < (int)sizeof(from)) { + memcpy(from, e.text, sep - e.text); + from[sep - e.text] = '\0'; + body = sep + 2; + } + bool own = strcmp(from, "Me") == 0; + // Own posts: once a repeater echoed it, how many distinct repeaters did + // (markChannelRelayed), or a check if that's unknown; nothing before -- + // no echo is normal. + if (own && e.relay_status == ACK_OK) { + nrel = e.path_len & 63; + st = LV_SYMBOL_OK; col = theme::OK; + } else if (own) st = ""; + int loc = own ? -1 : noteMsgLocation(i, body, from); + m = { i, true, own, loc, "" }; + if (!own) snprintf(m.from, sizeof(m.from), "%s", from); + row = bubble(_thread_list, own ? NULL : from, body, own, e.timestamp, st, col, loc >= 0, true, nrel, &age); + } else { + const DmHistEntry& e = s_th_dm[i]; + if (e.outgoing) { + switch (h.dmEffectiveStatus(e)) { + case ACK_OK: st = LV_SYMBOL_OK; col = theme::OK; break; + case ACK_FAIL: st = LV_SYMBOL_CLOSE; col = theme::FAIL; break; + case ACK_PENDING: { // a dot per send, as ui-new: grows with each auto-resend + static char dots[9]; + int k = e.attempt + 1 < 8 ? e.attempt + 1 : 8; + memset(dots, '.', k); dots[k] = '\0'; + st = dots; + break; + } + default: st = ""; break; + } + } + // A room post is filed "Author: text": shown under its author. + char who[33] = ""; + const char* text = e.text; + if (!e.outgoing && room) text = contactctl::splitRoomPost(e.text, who, sizeof(who)); + else if (!e.outgoing) contactName(_thread_key, who, sizeof(who)); + int loc = e.outgoing ? -1 : noteMsgLocation(i, text, who); + m = { i, false, false, loc, "" }; + snprintf(m.from, sizeof(m.from), "%s", who); + row = bubble(_thread_list, room && !e.outgoing ? who : NULL, text, e.outgoing, e.timestamp, st, col, + loc >= 0, !e.outgoing, 0, &age); // nothing to show for our own DM + } + s_th_rows[i] = { keyOf(i), stateOf(i), row, age }; + }; + auto olderText = [&](char* t, int sz) { snprintf(t, sz, LV_SYMBOL_UP " Older messages (%d)", total - _thread_skip - n); }; + + // The same page as built, with messages added at the end (the oldest + // dropping off the top) and delivery marks changed: patch it. + int kept = -1; // bubbles still shown, after `drop` gone from the top + int drop = 0; + if (s_th_rows_newest && s_th_rows_n > 0 && n > 0 && _thread_skip == 0 && (total > n) == (s_older_lbl != nullptr)) { + for (drop = 0; drop < s_th_rows_n && kept < 0; drop++) { + int m = s_th_rows_n - drop; + if (m > n) continue; + bool same = true; + for (int r = 0; r < m && same; r++) same = s_th_rows[drop + r].key == keyOf(r); + if (same) kept = m; + } + drop--; + } + if (kept > 0) { + bool at_end = lv_obj_get_scroll_bottom(_thread_list) <= theme::PAD; // not when reading further up + for (int r = 0; r < drop; r++) { + if (s_new_div && s_th_rows[r].key == _thread_new_key) { lv_obj_delete(s_new_div); s_new_div = nullptr; } + lv_obj_delete(s_th_rows[r].row); + } + memmove(s_th_rows, s_th_rows + drop, kept * sizeof(ThreadRow)); + memmove(s_msg_meta, s_msg_meta + drop, kept * sizeof(MsgMeta)); + for (int r = 0; r < kept; r++) { + if (s_th_rows[r].state == stateOf(r)) { // unchanged: its slots, and a fresh age + const ChHistEntry* ce = _thread_is_channel ? &s_th_ch[r] : nullptr; + char meta[32]; + fmtMsgAge(meta, sizeof(meta), rtc_clock.getCurrentTime(), ce ? ce->timestamp : s_th_dm[r].timestamp, _prefs); + if (s_th_rows[r].age) setText(s_th_rows[r].age, meta); + s_msg_meta[r].pos = r; + if (s_msg_meta[r].loc >= 0 && drop > 0) { s_msg_locs[r] = s_msg_locs[r + drop]; s_msg_meta[r].loc = r; } + continue; + } + lv_obj_t* old = s_th_rows[r].row; // a delivery mark changed: that bubble anew, in its place + build(r); + lv_obj_move_to_index(s_th_rows[r].row, lv_obj_get_index(old)); + lv_obj_delete(old); + } + for (int r = kept; r < n; r++) build(r); + if (s_older_lbl) { char t[40]; olderText(t, sizeof(t)); setText(s_older_lbl, t); } + s_th_rows_n = s_msg_meta_n = s_msg_loc_n = n; + if (n > kept && at_end) { // new messages: down to them + layoutNow(_thread_list); + lv_obj_scroll_to_y(_thread_list, LV_COORD_MAX, LV_ANIM_OFF); + } + return; + } + + lv_obj_clean(_thread_list); + s_th_rows_n = 0; + s_older_lbl = nullptr; + if (total > _thread_skip + n) { + char t[40]; + olderText(t, sizeof(t)); + pageButton(_thread_list, t, 1); + s_older_lbl = lv_obj_get_child(lv_obj_get_child(_thread_list, -1), 0); + } + s_new_div = nullptr; + for (int i = 0; i < n; i++) { // oldest first + if (_thread_new >= 0 && _thread_skip + n - 1 - i == _thread_new) { _thread_new_key = keyOf(i); _thread_new_on = true; } + if (_thread_new_on && !s_new_div && keyOf(i) == _thread_new_key && (i > 0 || s_older_lbl)) s_new_div = newDivider(_thread_list); + build(i); + } + _thread_new = -1; + s_th_rows_n = s_msg_meta_n = s_msg_loc_n = n; + s_th_rows_newest = _thread_skip == 0; // a page back is built whole each time + if (_thread_skip > 0) pageButton(_thread_list, LV_SYMBOL_DOWN " Newer messages", -1); + if (n == 0) label(_thread_list, "No messages yet", THEME_FONT_BODY, theme::TEXT_MUTED); + layoutNow(_thread_list); + lv_obj_scroll_to_y(_thread_list, _thread_scroll_top ? 0 : LV_COORD_MAX, LV_ANIM_OFF); + if (s_new_div && !_thread_scroll_top) lv_obj_scroll_to_view(s_new_div, LV_ANIM_OFF); // from the bottom: "New" on top + _thread_scroll_top = false; +} + +// To the open conversation (channel or DM). The caller refreshes the thread. +bool UITask::sendThreadText(const char* text) { + bool ok; + if (_thread_is_channel) { + ok = _core->sendChannelText(_thread_channel, text); + } else { + ContactInfo c; + ok = MessageHistory::contactByPrefix(_thread_key, c) && _core->sendDirectText(c, text); + } + if (!ok) showToast("Send failed"); + return ok; +} + +void UITask::sendFromCompose() { + if (!_compose_ta) return; + const char* typed = lv_textarea_get_text(_compose_ta); + if (!typed || !typed[0]) return; + char text[MSG_TEXT_BUF]; + msgtext::expandOutgoing(typed, text, sizeof(text), _prefs); // {loc}, {time}, ... (MessageText.h) + if (composeLen(typed) > composeLimit()) { showToast("Too long"); return; } // the count beside the field says by how much + if (!sendThreadText(text)) return; + lv_textarea_set_text(_compose_ta, ""); + setKeyboardVisible(false); + refreshThread(); +} diff --git a/examples/companion_radio/ui-lvgl/UITask.cpp b/examples/companion_radio/ui-lvgl/UITask.cpp index ab6ba1fa..3268f695 100644 --- a/examples/companion_radio/ui-lvgl/UITask.cpp +++ b/examples/companion_radio/ui-lvgl/UITask.cpp @@ -128,1301 +128,7 @@ extern "C" EMSCRIPTEN_KEEPALIVE int sim_buzzer_freq_hz() { return s_ui ? (int)s_ extern "C" EMSCRIPTEN_KEEPALIVE int sim_buzzer_get_volume() { return s_ui ? (int)s_ui->buzzerVolume() : 0; } #endif -// ── Small helpers ───────────────────────────────────────────────────────────── - -// A plain box in `bg`. One in the page colour is left unpainted: the screen -// under it (or the popup panel) already is that colour, and filling it again -// costs a full-area blend per frame. The screen and the status bar, which -// something may scroll or slide under, are made opaque with styleOpaque(). -static void styleSurface(lv_obj_t* o, uint32_t bg) { - lv_obj_set_style_bg_color(o, lv_color_hex(bg), 0); - lv_obj_set_style_bg_opa(o, bg == theme::BG ? LV_OPA_TRANSP : LV_OPA_COVER, 0); - lv_obj_set_style_border_width(o, 0, 0); - lv_obj_set_style_radius(o, 0, 0); - lv_obj_set_style_pad_all(o, 0, 0); -} -static void styleOpaque(lv_obj_t* o, uint32_t bg) { - styleSurface(o, bg); - lv_obj_set_style_bg_opa(o, LV_OPA_COVER, 0); -} - -static bool growsWide(lv_obj_t* o) { // a flex grow along a row: the width is the layout's - lv_obj_t* p = lv_obj_get_parent(o); - if (!p || !lv_obj_get_style_flex_grow(o, LV_PART_MAIN) || lv_obj_get_style_layout(p, LV_PART_MAIN) != LV_LAYOUT_FLEX) return false; - lv_flex_flow_t f = lv_obj_get_style_flex_flow(p, LV_PART_MAIN); - return !(f & LV_FLEX_COLUMN); -} -// The width of `o`'s content, worked out from the styles -- or -1 where it -// hangs on its content or a row's flex grow. A new box's own size is only known -// after a layout pass, and LVGL lays out children before their parent, so -// each box sized in % of the one above adds a whole pass over the screen. -static int32_t innerW(lv_obj_t* o) { - if (!o || growsWide(o)) return -1; - int32_t w = lv_obj_get_style_width(o, LV_PART_MAIN); - if (LV_COORD_IS_PCT(w)) { - int32_t pw = innerW(lv_obj_get_parent(o)); - if (pw < 0) return -1; - w = pw * LV_COORD_GET_PCT(w) / 100; - } else if (!LV_COORD_IS_PX(w)) { - return -1; - } - w -= lv_obj_get_style_pad_left(o, LV_PART_MAIN) + lv_obj_get_style_pad_right(o, LV_PART_MAIN); - lv_border_side_t side = lv_obj_get_style_border_side(o, LV_PART_MAIN); - int32_t bw = lv_obj_get_style_border_width(o, LV_PART_MAIN); - if (side & LV_BORDER_SIDE_LEFT) w -= bw; - if (side & LV_BORDER_SIDE_RIGHT) w -= bw; - return w; -} -extern "C" bool lv_obj_refr_size(lv_obj_t* obj); // lv_obj_pos.c, in no header -// A wrapping label's size settled now, `w` wide. LVGL's first layout of a -// label works out its height at the width it had before -- 0 for a new one, -// a line per letter -- and only then measures it again at the new width. -static void labelFit(lv_obj_t* l, int32_t w) { - l->coords.x2 = l->coords.x1 + w - 1; - lv_obj_refr_size(l); -} -// A flex row whose one growing child gets its width before the first layout -// (rowsFit(), further down). -static bool s_fit_pending; -static lv_obj_t* s_fit_screen; // newScreen()'s, maybe still fading in: not lv_screen_active() yet -static void fitLater(lv_obj_t* row) { - lv_obj_add_flag(row, LV_OBJ_FLAG_USER_2); - s_fit_pending = true; -} -// The parent's full width, in pixels when that's known now (see innerW()). -static void fillWidth(lv_obj_t* o) { - int32_t w = innerW(lv_obj_get_parent(o)); - lv_obj_set_width(o, w >= 0 ? w : LV_PCT(100)); - if (w > 0 && lv_obj_check_type(o, &lv_label_class)) labelFit(o, w); -} - -static lv_obj_t* label(lv_obj_t* parent, const char* text, const lv_font_t* font, uint32_t color) { - lv_obj_t* l = lv_label_create(parent); - lv_label_set_text(l, text); - lv_obj_set_style_text_font(l, font, 0); - lv_obj_set_style_text_color(l, lv_color_hex(color), 0); - return l; -} -// Whether label `l` holds `text`. A LV_LABEL_LONG_DOT one that's cut short -// has the "..." written into its own copy of the text, the bytes they cover -// kept aside (lv_label_set_dots): it's read back past them. -static bool labelShows(lv_obj_t* l, const char* text) { - const lv_label_t* lb = (const lv_label_t*)l; - const char* cur = lb->text; - if (!cur) return false; - uint32_t d = lb->dot_begin; - if (d == 0xFFFFFFFF || lb->static_txt) return strcmp(cur, text) == 0; // no dots (lv_label.c LV_LABEL_DOT_BEGIN_INV) - if (strncmp(cur, text, d) != 0) return false; - size_t k = strnlen(lb->dot, sizeof(lb->dot)); // the bytes under the dots - if (strncmp(lb->dot, text + d, k) != 0) return false; - return k < sizeof(lb->dot) ? text[d + k] == '\0' : strcmp(cur + d + k, text + d + k) == 0; -} -// A label's text, set only when it differs: LVGL redraws a label on every -// set_text, the same text or not, and the refreshes that run every second -// mostly write back what's already there. -static void setText(lv_obj_t* l, const char* text) { - if (labelShows(l, text)) return; - lv_label_set_text(l, text); -} -// The same for a text colour: LVGL restyles, and redraws, on every set. -static void setTextColor(lv_obj_t* l, uint32_t color) { - lv_color_t c = lv_color_hex(color); - if (!lv_color_eq(lv_obj_get_style_text_color(l, LV_PART_MAIN), c)) lv_obj_set_style_text_color(l, c, 0); -} -static void setTextFmt(lv_obj_t* l, const char* fmt, ...) __attribute__((format(printf, 2, 3))); -static void setTextFmt(lv_obj_t* l, const char* fmt, ...) { - char b[160]; - va_list ap; - va_start(ap, fmt); - vsnprintf(b, sizeof(b), fmt, ap); - va_end(ap); - setText(l, b); -} -// A sentence across the parent's width, wrapping: a hint, a note, a status. -static lv_obj_t* noteLabel(lv_obj_t* parent, const char* text, const lv_font_t* font = THEME_FONT_SMALL, - uint32_t color = theme::TEXT_MUTED) { - lv_obj_t* l = label(parent, text, font, color); - lv_label_set_long_mode(l, LV_LABEL_LONG_WRAP); - fillWidth(l); - return l; -} - -// A list filling the rest of a flex column, scrolling on its own (the -// scrollbar only while it moves). -static lv_obj_t* scrollList(lv_obj_t* parent) { - lv_obj_t* l = lv_obj_create(parent); - styleSurface(l, theme::BG); - fillWidth(l); - lv_obj_set_flex_grow(l, 1); - lv_obj_set_flex_flow(l, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_pad_row(l, theme::GAP, 0); - lv_obj_set_scrollbar_mode(l, LV_SCROLLBAR_MODE_ACTIVE); - return l; -} - -// The dimmed full-screen layer under a popup; it swallows taps on what's below. -static lv_obj_t* dimOverlay(lv_obj_t* parent) { - lv_obj_t* o = lv_obj_create(parent); - lv_obj_remove_style_all(o); - lv_obj_set_size(o, LV_PCT(100), LV_PCT(100)); - lv_obj_set_style_bg_color(o, lv_color_hex(0x000000), 0); - lv_obj_set_style_bg_opa(o, LV_OPA_60, 0); - lv_obj_add_flag(o, LV_OBJ_FLAG_CLICKABLE); - lv_obj_remove_flag(o, LV_OBJ_FLAG_SCROLLABLE); - return o; -} - -// LV_EVENT_COVER_CHECK: an opaque thing LVGL can't tell is (an image, a box -// drawn by its children) hides all under its own area, and LVGL then starts -// drawing from it. -static void coversOwnArea(lv_event_t* e) { - lv_cover_check_info_t* info = (lv_cover_check_info_t*)lv_event_get_param(e); - lv_area_t a; - lv_obj_get_coords((lv_obj_t*)lv_event_get_current_target(e), &a); - const lv_area_t* r = info->area; - if (info->res != LV_COVER_RES_MASKED && r->x1 >= a.x1 && r->y1 >= a.y1 && r->x2 <= a.x2 && r->y2 <= a.y2) - info->res = LV_COVER_RES_COVER; -} - -static lv_obj_t* s_status_bar = nullptr; // on the top layer, opaque -// Where the screen shows below the status bar (its top when the bar is hidden). -static int32_t belowStatusBar() { - return s_status_bar && !lv_obj_has_flag(s_status_bar, LV_OBJ_FLAG_HIDDEN) ? lv_obj_get_y2(s_status_bar) + 1 : 0; -} - -// The screen under an open popup, frozen: once the dim is up, the screen is -// drawn once into an image, darkened as the dim layer darkens it, and shown -// opaque on top of the screen with the dim layer cleared -- LVGL then draws -// nothing below it. Live, the screen plus a full-screen blend under the popup -// cost ~28 ms a frame. One popup at a time; the image goes with its popup, and -// the popup dims live again if its screen goes first. Any dim level: the -// layer's own when it's frozen. -namespace freeze { - static lv_obj_t* s_img = nullptr; // the frozen screen, on that screen - static lv_obj_t* s_owner = nullptr; // the popup's dim layer - static lv_obj_t* s_strip = nullptr; // the status bar's dim (a popup on the top layer) - static lv_timer_t* s_retry = nullptr; // waiting for the screen to stop moving - static lv_opa_t s_dim = LV_OPA_60; // the dim layer's, frozen into the image - - static void imgDeleted(lv_event_t* e) { - lv_draw_buf_destroy((lv_draw_buf_t*)lv_event_get_user_data(e)); - s_img = nullptr; - if (s_owner) { // the screen went, the popup stays - if (s_strip) lv_obj_delete(s_strip); - lv_obj_set_style_bg_opa(s_owner, s_dim, 0); - s_owner = s_strip = nullptr; - } - } - static void ownerDeleted(lv_event_t* e) { - if (s_retry && lv_timer_get_user_data(s_retry) == lv_event_get_target(e)) { lv_timer_delete(s_retry); s_retry = nullptr; } - if (s_owner != lv_event_get_target(e)) return; - s_owner = s_strip = nullptr; - if (s_img) lv_obj_delete(s_img); - } - static bool moving(lv_obj_t* scr) { // a screen still sliding / fading in - if (lv_display_get_screen_prev(NULL)) return true; - for (uint32_t i = 0; i < lv_obj_get_child_count(scr); i++) - if (lv_anim_get(lv_obj_get_child(scr, i), NULL)) return true; - return false; - } - static void take(lv_obj_t* overlay); - static void retry(lv_timer_t* t) { - s_retry = nullptr; - take((lv_obj_t*)lv_timer_get_user_data(t)); - } - // Darkened into the display's own byte order (UI_FLUSH_DMA: big-endian; - // lv_snapshot can't make that one itself): drawn then as a plain copy. -#ifdef UI_FLUSH_DMA - static inline uint16_t out565(uint16_t c) { return __builtin_bswap16(c); } -#else - static inline uint16_t out565(uint16_t c) { return c; } -#endif - static void darken(lv_draw_buf_t* buf) { // as the dim layer over it would - const uint32_t keep = 256 - s_dim; // what's left under the black - for (uint32_t y = 0; y < buf->header.h; y++) { - uint16_t* p = (uint16_t*)(buf->data + y * buf->header.stride); - for (uint32_t x = 0; x < buf->header.w; x++) { - uint32_t c = p[x]; - p[x] = out565((uint16_t)(((((c >> 11) * keep) >> 8) << 11) | (((((c >> 5) & 0x3F) * keep) >> 8) << 5) | - (((c & 0x1F) * keep) >> 8))); - } - } -#ifdef UI_FLUSH_DMA - buf->header.cf = LV_COLOR_FORMAT_RGB565_SWAPPED; -#endif - } - static lv_draw_buf_t* snap(lv_obj_t* overlay, lv_obj_t* scr, bool top) { - if (!top) lv_obj_add_flag(overlay, LV_OBJ_FLAG_HIDDEN); - lv_draw_buf_t* buf = lv_snapshot_take(scr, LV_COLOR_FORMAT_RGB565); - if (!top) lv_obj_remove_flag(overlay, LV_OBJ_FLAG_HIDDEN); - return buf; - } - static void show(lv_obj_t* overlay, lv_obj_t* scr, bool top, lv_draw_buf_t* buf) { - s_img = lv_image_create(scr); - lv_image_set_src(s_img, buf); - lv_obj_add_flag(s_img, LV_OBJ_FLAG_IGNORE_LAYOUT); - lv_obj_remove_flag(s_img, LV_OBJ_FLAG_CLICKABLE); - lv_obj_set_pos(s_img, 0, 0); - lv_obj_add_event_cb(s_img, imgDeleted, LV_EVENT_DELETE, buf); - lv_obj_add_event_cb(s_img, coversOwnArea, LV_EVENT_COVER_CHECK, NULL); // no background: its own check says no - if (!top) lv_obj_move_foreground(overlay); // the image under the popup, over the rest - s_owner = overlay; - } - static void dimmed(lv_obj_t* overlay, bool top) { // the image holds the dim now - lv_obj_set_style_bg_opa(overlay, LV_OPA_TRANSP, 0); - if (top) { // the status bar, on the top layer too, stays dimmed - s_strip = lv_obj_create(overlay); - lv_obj_remove_style_all(s_strip); - lv_obj_remove_flag(s_strip, LV_OBJ_FLAG_CLICKABLE); - lv_obj_add_flag(s_strip, LV_OBJ_FLAG_IGNORE_LAYOUT); - lv_obj_set_size(s_strip, LV_PCT(100), theme::STATUS_H); - lv_obj_set_style_bg_color(s_strip, lv_color_hex(0x000000), 0); - lv_obj_set_style_bg_opa(s_strip, s_dim, 0); - } - } - static void take(lv_obj_t* overlay) { - if (s_img) return; // a popup over a popup: dimmed live - lv_obj_t* scr = lv_obj_get_screen(overlay); - bool top = scr == lv_layer_top(); - if (top) scr = lv_screen_active(); - if (moving(scr)) { - s_retry = lv_timer_create(retry, 60, overlay); - lv_timer_set_repeat_count(s_retry, 1); - return; - } - s_dim = lv_obj_get_style_bg_opa(overlay, LV_PART_MAIN); - lv_draw_buf_t* buf = snap(overlay, scr, top); - if (!buf) return; // no memory: dimmed live - darken(buf); - show(overlay, scr, top, buf); - dimmed(overlay, top); - } - static void onDimUp(lv_anim_t* a) { take((lv_obj_t*)a->var); } - // A popup opening over a still screen: frozen and dimmed at once, so the - // panel's rise redraws only the panel -- a dim fading up over the live - // screen redrew all of it every frame (~45 ms each). False when the screen - // is still moving or there's no memory: the caller fades the dim up. - static bool early(lv_obj_t* overlay, lv_opa_t dim) { - if (s_img) return false; - lv_obj_t* scr = lv_obj_get_screen(overlay); - bool top = scr == lv_layer_top(); - if (top) scr = lv_screen_active(); - if (moving(scr)) return false; - lv_draw_buf_t* buf = snap(overlay, scr, top); - if (!buf) return false; - s_dim = dim; - darken(buf); - show(overlay, scr, top, buf); - dimmed(overlay, top); - return true; - } - // A dim layer that's up at once, without the fade: frozen now. - static void now(lv_obj_t* overlay) { - lv_obj_add_event_cb(overlay, ownerDeleted, LV_EVENT_DELETE, NULL); - take(overlay); - } -} - -// A popup: the dimmed layer (into `overlay`, to close it by) and its panel -- -// the page colour, the accent hairline, a flex column -- rising into view. -// POP_FIT: as tall as its content, centred below the status bar, scrolling -// past that height. POP_FULL: all of that height; its own list scrolls. -// POP_TOP: as tall as its content, under the status bar, clear of a keyboard. -// POP_BOTTOM: as tall as its content, at the bottom, nothing dimmed -- for -// what's shown on the screen above it (a map area's preview). -enum PopFit : uint8_t { POP_FIT, POP_FULL, POP_TOP, POP_BOTTOM }; -static lv_obj_t* popupOpen(lv_obj_t* parent, PopFit fit, lv_obj_t*& overlay, int32_t inset = 8) { - overlay = dimOverlay(parent); - s_fit_pending = true; // rowsFit() - lv_obj_t* panel = lv_obj_create(overlay); - int32_t w = lv_display_get_horizontal_resolution(NULL), h = lv_display_get_vertical_resolution(NULL); - lv_obj_set_width(panel, w - 2 * inset); - if (fit == POP_FULL) { - lv_obj_set_height(panel, h - theme::STATUS_H - 12); - lv_obj_set_pos(panel, inset, theme::STATUS_H + 6); - } else if (fit == POP_TOP) { - lv_obj_set_height(panel, LV_SIZE_CONTENT); - lv_obj_align(panel, LV_ALIGN_TOP_MID, 0, theme::STATUS_H + 4); - } else if (fit == POP_BOTTOM) { - lv_obj_set_height(panel, LV_SIZE_CONTENT); - lv_obj_align(panel, LV_ALIGN_BOTTOM_MID, 0, -inset); - } else { - lv_obj_set_height(panel, LV_SIZE_CONTENT); - lv_obj_set_style_max_height(panel, h - theme::STATUS_H - 12, 0); - lv_obj_align(panel, LV_ALIGN_CENTER, 0, theme::STATUS_H / 2); - lv_obj_set_scrollbar_mode(panel, LV_SCROLLBAR_MODE_ACTIVE); - } - if (fit != POP_FIT) lv_obj_remove_flag(panel, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_bg_color(panel, lv_color_hex(theme::BG), 0); - lv_obj_set_style_border_color(panel, lv_color_hex(theme::ACCENT), 0); - lv_obj_set_style_border_width(panel, 1, 0); - lv_obj_set_style_radius(panel, theme::RADIUS, 0); - lv_obj_set_style_pad_all(panel, theme::PAD, 0); - lv_obj_set_style_pad_row(panel, 4, 0); - lv_obj_set_flex_flow(panel, LV_FLEX_FLOW_COLUMN); - if (fit == POP_BOTTOM) { // over a live screen, nothing dimmed - lv_obj_set_style_bg_opa(overlay, LV_OPA_TRANSP, 0); - anim::rise(panel); - } else { - lv_obj_add_event_cb(overlay, freeze::ownerDeleted, LV_EVENT_DELETE, NULL); - if (freeze::early(overlay, LV_OPA_60)) anim::rise(panel); - else anim::popup(overlay, LV_OPA_60, freeze::onDimUp); - } - return panel; -} - -// ── Info cards ── -// Label / value rows on a card (Diagnostics, GPS, node detail, About): the -// label small and muted on the left, the value on the right -- wrapping, -// right-aligned, when it's long -- and a hairline between rows. infoRow() -// returns the value label to update in place; infoSet() also hides its row -// while there's nothing to show. -static lv_obj_t* infoCard(lv_obj_t* parent) { - lv_obj_t* c = lv_obj_create(parent); - styleSurface(c, theme::SURFACE); - lv_obj_remove_flag(c, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_remove_flag(c, LV_OBJ_FLAG_CLICKABLE); - fillWidth(c); - lv_obj_set_height(c, LV_SIZE_CONTENT); - lv_obj_set_style_radius(c, theme::RADIUS, 0); - lv_obj_set_style_pad_hor(c, theme::PAD, 0); - lv_obj_set_flex_flow(c, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_pad_row(c, 0, 0); // the rows' own padding spaces them, around the hairline - return c; -} - -// A credit line that wraps cleanly: "(c) Name" and "CC-BY-SA" never split. -static const char* creditText(const char* in) { - static char out[160]; - size_t o = 0; - int paren = 0; - for (const char* p = in; *p && o + 4 < sizeof(out); p++) { - if (*p == '(') paren++; - else if (*p == ')' && paren) paren--; - if ((uint8_t)p[0] == 0xC2 && (uint8_t)p[1] == 0xA9 && p[2] == ' ') { // "(c) " -> "(c)" + no-break space - memcpy(out + o, "\xC2\xA9\xC2\xA0", 4); o += 4; p += 2; - } else if (*p == '-' && paren) { // licence names: a non-breaking hyphen - memcpy(out + o, "\xE2\x80\x91", 3); o += 3; - } else { - out[o++] = *p; - } - } - out[o] = '\0'; - return out; -} - -static lv_obj_t* infoLine(lv_obj_t* card) { // one row's box, the hairline above all but the first - lv_obj_t* r = lv_obj_create(card); - lv_obj_remove_style_all(r); - lv_obj_remove_flag(r, LV_OBJ_FLAG_CLICKABLE); - lv_obj_remove_flag(r, LV_OBJ_FLAG_SCROLLABLE); - fillWidth(r); - lv_obj_set_height(r, LV_SIZE_CONTENT); - lv_obj_set_style_pad_ver(r, 5, 0); - if (lv_obj_has_flag(card, LV_OBJ_FLAG_USER_1)) lv_obj_set_style_pad_hor(r, theme::PAD, 0); // in a group() - if (lv_obj_get_index(r) > 0) { - lv_obj_set_style_border_side(r, LV_BORDER_SIDE_TOP, 0); - lv_obj_set_style_border_width(r, 1, 0); - lv_obj_set_style_border_color(r, lv_color_hex(theme::SURFACE_2), 0); - } - return r; -} - -static lv_obj_t* infoRow(lv_obj_t* card, const char* key, const char* value, uint32_t col = theme::TEXT, - const lv_font_t* font = THEME_FONT_BODY) { - lv_obj_t* r = infoLine(card); - lv_obj_set_flex_flow(r, LV_FLEX_FLOW_ROW); - lv_obj_set_flex_align(r, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_set_style_pad_column(r, 8, 0); - lv_obj_t* k = label(r, key, THEME_FONT_SMALL, theme::TEXT_MUTED); - lv_label_set_long_mode(k, LV_LABEL_LONG_WRAP); - lv_obj_set_width(k, 1); - lv_obj_set_flex_grow(k, 1); - lv_obj_t* v = label(r, value, font, col); - lv_label_set_long_mode(v, LV_LABEL_LONG_WRAP); - lv_obj_set_width(v, LV_SIZE_CONTENT); - lv_obj_set_style_max_width(v, LV_PCT(66), 0); - lv_obj_set_style_text_align(v, LV_TEXT_ALIGN_RIGHT, 0); - fitLater(r); - return v; -} - -// A row whose text is a sentence (credits, notes): the label over it. -static lv_obj_t* infoNote(lv_obj_t* card, const char* key, const char* text) { - lv_obj_t* r = infoLine(card); - lv_obj_set_flex_flow(r, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_pad_row(r, 2, 0); - label(r, key, THEME_FONT_SMALL, theme::TEXT_MUTED); - lv_obj_t* t = noteLabel(r, text, THEME_FONT_SMALL, theme::TEXT); - return t; -} - -static lv_obj_t* sectionTitle(lv_obj_t* parent, const char* text); - -static void infoSet(lv_obj_t* value, const char* text) { - lv_obj_t* row = lv_obj_get_parent(value); - if (!text || !text[0]) { lv_obj_add_flag(row, LV_OBJ_FLAG_HIDDEN); return; } - lv_obj_remove_flag(row, LV_OBJ_FLAG_HIDDEN); - setText(value, text); -} - -// A bare flex row / column sized to its content, taps passing through. -static lv_obj_t* flexBox(lv_obj_t* parent, lv_flex_flow_t flow) { - lv_obj_t* b = lv_obj_create(parent); - lv_obj_remove_style_all(b); - lv_obj_remove_flag(b, LV_OBJ_FLAG_CLICKABLE); - lv_obj_set_size(b, LV_SIZE_CONTENT, LV_SIZE_CONTENT); - lv_obj_set_flex_flow(b, flow); - return b; -} - -// A full-width row of centred items that grows with them (button rows in -// popups, the clock's tabs and actions). -static lv_obj_t* centerRow(lv_obj_t* parent) { - lv_obj_t* r = lv_obj_create(parent); - styleSurface(r, theme::BG); - lv_obj_remove_flag(r, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_size(r, LV_PCT(100), LV_SIZE_CONTENT); - lv_obj_set_flex_flow(r, LV_FLEX_FLOW_ROW); - lv_obj_set_flex_align(r, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_set_style_pad_column(r, theme::GAP, 0); - return r; -} - -// A small pill button with a label, right-aligned in its parent row. -static lv_obj_t* pillButton(lv_obj_t* row, const char* text, uint32_t bg, lv_event_cb_t cb, uintptr_t user) { - lv_obj_t* b = lv_button_create(row); - lv_obj_set_size(b, LV_SIZE_CONTENT, 24); - lv_obj_set_style_pad_hor(b, 10, 0); - lv_obj_set_style_pad_ver(b, 0, 0); - lv_obj_set_style_radius(b, LV_RADIUS_CIRCLE, 0); - lv_obj_set_style_shadow_width(b, 0, 0); - lv_obj_set_style_bg_color(b, lv_color_hex(bg), 0); - lv_obj_align(b, LV_ALIGN_RIGHT_MID, -2, 0); - lv_obj_add_event_cb(b, cb, LV_EVENT_CLICKED, (void*)user); - lv_obj_center(label(b, text, THEME_FONT_SMALL, theme::TEXT)); - return b; -} - -// ── Settings kit ── -// Every settings page is built from the same few pieces: group() -- a section -// title and the card its rows sit on, hairlines between them -- and rows on -// it: settingRow() (label, hint under it, a control on the right: switch, -// choice, slider, segmented), listRow() (opens something: a chevron), -// actionRow() (does something now) and groupNote() (one line under a card). -// Rows built outside a group keep the older look: a card of their own. - -static inline bool isGroup(lv_obj_t* o) { return o && lv_obj_has_flag(o, LV_OBJ_FLAG_USER_1); } - -static lv_obj_t* group(lv_obj_t* parent, const char* title) { - if (title) sectionTitle(parent, title); - lv_obj_t* c = infoCard(parent); - lv_obj_set_style_pad_hor(c, 0, 0); // rows pad themselves: a pressed row lights edge to edge - lv_obj_add_flag(c, LV_OBJ_FLAG_USER_1); - return c; -} - -// A row that lights up under the finger. In a group() the card clips its -// corners while a row is held, so the first / last row's light is rounded -// with it -- only then: clipping a card's corners costs a few ms every frame. -static void groupPressClip(lv_event_t* e) { - lv_obj_t* card = lv_obj_get_parent((lv_obj_t*)lv_event_get_current_target(e)); - if (isGroup(card)) lv_obj_set_style_clip_corner(card, lv_event_get_code(e) == LV_EVENT_PRESSED, 0); -} -static void rowPressable(lv_obj_t* row) { - lv_obj_add_flag(row, LV_OBJ_FLAG_CLICKABLE); - lv_obj_set_style_bg_color(row, lv_color_hex(theme::SURFACE_2), LV_STATE_PRESSED); - lv_obj_set_style_bg_opa(row, LV_OPA_COVER, LV_STATE_PRESSED); - lv_obj_add_event_cb(row, groupPressClip, LV_EVENT_PRESSED, NULL); - lv_obj_add_event_cb(row, groupPressClip, LV_EVENT_RELEASED, NULL); - lv_obj_add_event_cb(row, groupPressClip, LV_EVENT_PRESS_LOST, NULL); -} - -static lv_obj_t* groupNote(lv_obj_t* parent, const char* text) { - lv_obj_t* l = noteLabel(parent, text); - lv_obj_set_style_pad_hor(l, theme::PAD, 0); - return l; -} - -// A group row's box: full width, at least a touch target tall, a flex row -// with the text column growing and whatever follows it on the right. -static lv_obj_t* groupLine(lv_obj_t* card, bool tappable) { - lv_obj_t* r = infoLine(card); - lv_obj_set_style_pad_hor(r, theme::PAD, 0); - lv_obj_set_style_pad_ver(r, 6, 0); - lv_obj_set_style_min_height(r, theme::ROW_H, 0); - lv_obj_set_flex_flow(r, LV_FLEX_FLOW_ROW); - lv_obj_set_flex_align(r, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_set_style_pad_column(r, 8, 0); - if (tappable) rowPressable(r); - return r; -} - -// The label (and the hint under it, wrapping) filling a group row's left side. -static lv_obj_t* groupText(lv_obj_t* row, const char* text, const char* hint, uint32_t col = theme::TEXT) { - lv_obj_t* t = flexBox(row, LV_FLEX_FLOW_COLUMN); - lv_obj_set_width(t, 1); - lv_obj_set_flex_grow(t, 1); - lv_obj_set_style_pad_row(t, 1, 0); - // The row's touch-target height lives here: a row's own min height comes - // after its flex layout, which then sits everything at the top. - lv_obj_set_style_min_height(t, theme::ROW_H - 12, 0); - lv_obj_set_flex_align(t, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); - lv_obj_t* l = noteLabel(t, text, THEME_FONT_BODY, col); - if (hint) { - noteLabel(t, hint); - } - lv_obj_set_user_data(row, l); // rowTitle() - fitLater(row); - return l; -} - -// A group row's text column grows into what the controls on its right leave, -// and LVGL lays out children before their parent: its labels were first -// measured 1 px wide (a line per letter), then at the real width, and each -// height that changed took another pass up the screen -- four in all. Before -// a frame's layout, rowsFit() gives each new column the width the flex will: -// its row's minus the controls', worked out from their styles and text. The -// grow stays, so a control this can't size only costs the old passes. -static constexpr lv_obj_flag_t NOT_LAID_OUT = - (lv_obj_flag_t)(LV_OBJ_FLAG_HIDDEN | LV_OBJ_FLAG_FLOATING | LV_OBJ_FLAG_IGNORE_LAYOUT); - -// `o`'s outer width as the layout will make it; -1 when that's unclear. -static int32_t layoutW(lv_obj_t* o) { - int32_t w = lv_obj_get_style_width(o, LV_PART_MAIN); - if (LV_COORD_IS_PX(w)) { - // as it is - } else if (w != LV_SIZE_CONTENT) { - return -1; - } else if (lv_obj_check_type(o, &lv_label_class)) { - lv_obj_refr_size(o); // the label's own measure, cached for the layout - w = lv_obj_get_width(o); - } else if (lv_obj_get_style_layout(o, LV_PART_MAIN) == LV_LAYOUT_FLEX && - lv_obj_get_style_flex_flow(o, LV_PART_MAIN) == LV_FLEX_FLOW_ROW) { - int32_t gap = lv_obj_get_style_pad_column(o, LV_PART_MAIN), n = 0; - w = lv_obj_get_style_pad_left(o, LV_PART_MAIN) + lv_obj_get_style_pad_right(o, LV_PART_MAIN); - for (uint32_t i = 0; i < lv_obj_get_child_count(o); i++) { - lv_obj_t* c = lv_obj_get_child(o, i); - if (lv_obj_has_flag_any(c, NOT_LAID_OUT)) continue; - int32_t cw = layoutW(c); - if (cw < 0) return -1; - w += cw + (n++ ? gap : 0); - } - w = LV_CLAMP(lv_obj_get_style_min_width(o, LV_PART_MAIN), w, lv_obj_get_style_max_width(o, LV_PART_MAIN)); - } else { - return -1; - } - return w + lv_obj_get_style_margin_left(o, LV_PART_MAIN) + lv_obj_get_style_margin_right(o, LV_PART_MAIN); -} - -static void rowFit(lv_obj_t* row) { - lv_obj_refr_size(row); // its own width now: a child's % size is of it - int32_t w = innerW(row), gap = lv_obj_get_style_pad_column(row, LV_PART_MAIN); - if (w < 0) return; - lv_obj_t* grow = nullptr; - for (uint32_t i = 0; i < lv_obj_get_child_count(row); i++) { - lv_obj_t* c = lv_obj_get_child(row, i); - if (lv_obj_has_flag_any(c, NOT_LAID_OUT)) continue; - if (lv_obj_get_style_flex_grow(c, LV_PART_MAIN)) { - if (grow) return; // one grows, or the share isn't worth working out - grow = c; - continue; - } - int32_t cw = layoutW(c); - if (cw < 0) return; - w -= cw + gap; - } - if (!grow || w < 1) return; - lv_obj_set_width(grow, w); - if (lv_obj_check_type(grow, &lv_label_class)) { labelFit(grow, w); return; } - lv_obj_refr_size(grow); - for (uint32_t i = 0; i < lv_obj_get_child_count(grow); i++) { // its labels, 100 % of it - lv_obj_t* l = lv_obj_get_child(grow, i); - if (lv_obj_check_type(l, &lv_label_class)) labelFit(l, w); - } -} - -// ... and every label given a width in pixels, which labelFit() then measures -// at it (the screen titles, a list row's cut-off name ...). -static void rowsFit(lv_obj_t* root) { - lv_obj_tree_walk(root, [](lv_obj_t* o, void*) { - if (lv_obj_has_flag(o, LV_OBJ_FLAG_USER_2)) { - lv_obj_remove_flag(o, LV_OBJ_FLAG_USER_2); - rowFit(o); - } else if (lv_obj_check_type(o, &lv_label_class)) { - int32_t w = lv_obj_get_style_width(o, LV_PART_MAIN); - if (LV_COORD_IS_PX(w) && w > 0 && lv_obj_get_width(o) != w) labelFit(o, w); - } - return LV_OBJ_TREE_WALK_NEXT; - }, nullptr); -} -static void rowsFitPending() { - if (!s_fit_pending) return; - s_fit_pending = false; - if (s_fit_screen && s_fit_screen != lv_screen_active()) rowsFit(s_fit_screen); - rowsFit(lv_screen_active()); - rowsFit(lv_layer_top()); -} -// lv_obj_update_layout() for a screen just built: its rows fitted first. -static void layoutNow(lv_obj_t* o) { - rowsFitPending(); - lv_obj_update_layout(o); -} - -// The title label of a settingRow() / listRow(), to recolour or rename it. -static lv_obj_t* rowTitle(lv_obj_t* row) { return (lv_obj_t*)lv_obj_get_user_data(row); } - -// Rewrites the hint under a row's title in place (the label made right after -// it) -- a rebuild would cut short the animation of the switch just tapped. -static void rowHintSet(lv_obj_t* row, const char* hint) { - lv_obj_t* h = lv_obj_get_sibling(rowTitle(row), 1); - if (h && lv_obj_check_type(h, &lv_label_class)) lv_label_set_text(h, hint); -} - -// A settings row: the label left (a muted hint under it, cut at `hint_w`), -// room on the right for a switch / dropdown / slider. -static lv_obj_t* settingRow(lv_obj_t* parent, const char* text, const char* hint, int hint_w = 150) { - if (isGroup(parent)) { - lv_obj_t* row = groupLine(parent, false); - groupText(row, text, hint); - return row; - } - lv_obj_t* row = lv_obj_create(parent); - styleSurface(row, theme::SURFACE); - lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_size(row, LV_PCT(100), theme::ROW_H); - lv_obj_set_style_radius(row, theme::RADIUS, 0); - lv_obj_t* t = label(row, text, THEME_FONT_BODY, theme::TEXT); - lv_obj_align(t, LV_ALIGN_TOP_LEFT, theme::PAD, hint ? 5 : 13); - lv_obj_set_user_data(row, t); - if (hint) { - lv_obj_t* h = label(row, hint, THEME_FONT_SMALL, theme::TEXT_MUTED); - lv_label_set_long_mode(h, LV_LABEL_LONG_DOT); - lv_obj_set_size(h, hint_w, 15); // fixed height: cut, don't wrap onto the label - lv_obj_align(h, LV_ALIGN_BOTTOM_LEFT, theme::PAD, -5); - } - return row; -} - -// A slider on the right of a settingRow(). Its track is the theme's 20 % -// accent tint worked out over the card, opaque: blended, it cost ~3 ms a frame. -static lv_obj_t* rowSlider(lv_obj_t* row, int32_t min, int32_t max, int32_t v) { - lv_obj_t* sl = lv_slider_create(row); - lv_slider_set_range(sl, min, max); - lv_slider_set_value(sl, v, LV_ANIM_OFF); - lv_obj_set_size(sl, 150, 8); - lv_obj_set_style_margin_right(sl, 8, 0); - lv_obj_set_ext_click_area(sl, 14); - lv_obj_set_style_bg_color(sl, lv_color_hex(theme::mix(theme::ACCENT, theme::SURFACE, 20)), LV_PART_MAIN); - lv_obj_set_style_bg_opa(sl, LV_OPA_COVER, LV_PART_MAIN); - return sl; -} - -// Does something at once (Reboot, Delete ...): its text in `col`, no chevron. -// Returns the label, for a confirm-by-second-tap text. -static lv_obj_t* actionRow(lv_obj_t* card, const char* text, lv_event_cb_t cb, void* user, - uint32_t col = theme::TEXT) { - lv_obj_t* r = groupLine(card, true); - lv_obj_t* l = groupText(r, text, nullptr, col); - lv_obj_add_event_cb(r, cb, LV_EVENT_CLICKED, user); - return l; -} - -// ── Choice: a value picked from a list ── -// In place of a dropdown: the value and a chevron; a tap opens the options -// over the screen, and a pick sends LV_EVENT_VALUE_CHANGED like a dropdown. -struct Choice { - char* opts; // "\n"-separated, owned - uint16_t sel, count; - char title[32]; - lv_obj_t* value; -}; -static lv_obj_t* s_pick_overlay = nullptr; -static lv_obj_t* s_pick_target = nullptr; - -static Choice* choiceOf(lv_obj_t* c) { return (Choice*)lv_obj_get_user_data(c); } - -// Option `i` of a "\n"-list, into buf. -static const char* choiceItem(const Choice* ch, int i, char* buf, size_t n) { - const char* p = ch->opts; - for (int k = 0; k < i && p; k++) { p = strchr(p, '\n'); if (p) p++; } - if (!p) { buf[0] = '\0'; return buf; } - const char* e = strchr(p, '\n'); - size_t len = e ? (size_t)(e - p) : strlen(p); - if (len >= n) len = n - 1; - memcpy(buf, p, len); - buf[len] = '\0'; - return buf; -} - -static void choiceShow(lv_obj_t* c) { - Choice* ch = choiceOf(c); - char v[48]; - lv_label_set_text(ch->value, choiceItem(ch, ch->sel, v, sizeof(v))); -} - -static void choiceSetOptions(lv_obj_t* c, const char* opts) { - Choice* ch = choiceOf(c); - lv_free(ch->opts); - size_t n = strlen(opts); - ch->opts = (char*)lv_malloc(n + 1); - memcpy(ch->opts, opts, n + 1); - ch->count = 1; - for (const char* p = opts; *p; p++) if (*p == '\n') ch->count++; - if (ch->sel >= ch->count) ch->sel = 0; - choiceShow(c); -} - -static int choiceSelected(lv_obj_t* c) { return choiceOf(c)->sel; } -static void choiceSetSelected(lv_obj_t* c, int i) { - Choice* ch = choiceOf(c); - if (i < 0 || i >= ch->count) return; - ch->sel = (uint16_t)i; - choiceShow(c); -} - -static void pickerClose() { - if (s_pick_overlay) lv_obj_delete_async(s_pick_overlay); - s_pick_overlay = s_pick_target = nullptr; -} - -static void onPickOption(lv_event_t* e) { - lv_obj_t* c = s_pick_target; - pickerClose(); - if (!c) return; - choiceSetSelected(c, (int)(uintptr_t)lv_event_get_user_data(e)); - lv_obj_send_event(c, LV_EVENT_VALUE_CHANGED, NULL); // may rebuild the screen: c is gone after this -} - -static void pickerOpen(lv_obj_t* c) { - Choice* ch = choiceOf(c); - pickerClose(); - s_pick_target = c; - lv_obj_t* panel = popupOpen(lv_layer_top(), POP_FIT, s_pick_overlay, 24); - lv_obj_add_event_cb(s_pick_overlay, [](lv_event_t* e) { // a tap beside the list: nothing changes - if (lv_event_get_target(e) == lv_event_get_current_target(e)) pickerClose(); - }, LV_EVENT_CLICKED, NULL); - lv_obj_set_style_pad_all(panel, 6, 0); - lv_obj_t* t = label(panel, ch->title, THEME_FONT_SMALL, theme::TEXT_MUTED); - lv_obj_set_style_pad_hor(t, 4, 0); - lv_obj_t* card = group(panel, nullptr); - lv_obj_t* sel_row = nullptr; - // Each option one label, a fixed one-line row: a time zone list is ~30 of - // them, and a group row's box + text column + label took ~3 ms each. - const int32_t H = 38, lh = lv_font_get_line_height(THEME_FONT_BODY), w = innerW(card); - for (int i = 0; i < ch->count; i++) { - char v[48]; - bool cur = i == ch->sel; - lv_obj_t* r = label(card, choiceItem(ch, i, v, sizeof(v)), THEME_FONT_BODY, cur ? theme::ACCENT : theme::TEXT); - lv_label_set_long_mode(r, LV_LABEL_LONG_CLIP); - lv_obj_set_size(r, w > 0 ? w : LV_PCT(100), H); - lv_obj_set_style_pad_left(r, theme::PAD, 0); - lv_obj_set_style_pad_right(r, theme::PAD + 20, 0); // the tick's room - lv_obj_set_style_pad_top(r, (H - lh) / 2, 0); - if (i > 0) { - lv_obj_set_style_border_side(r, LV_BORDER_SIDE_TOP, 0); - lv_obj_set_style_border_width(r, 1, 0); - lv_obj_set_style_border_color(r, lv_color_hex(theme::SURFACE_2), 0); - } - rowPressable(r); - if (cur) { - lv_obj_t* t = label(r, LV_SYMBOL_OK, THEME_FONT_BODY, theme::ACCENT); - lv_obj_add_flag(t, LV_OBJ_FLAG_IGNORE_LAYOUT); - lv_obj_align(t, LV_ALIGN_RIGHT_MID, 20, 0); // into the right padding - sel_row = r; - } - lv_obj_add_event_cb(r, onPickOption, LV_EVENT_CLICKED, (void*)(uintptr_t)i); - } - if (sel_row) { layoutNow(panel); lv_obj_scroll_to_view_recursive(sel_row, LV_ANIM_OFF); } -} - -// A choice: in a group row the value is bare text (accent) and a chevron; -// elsewhere it sits on a small surface of its own. -static lv_obj_t* choiceCreate(lv_obj_t* parent, const char* opts, int sel, const char* title) { - lv_obj_t* c = lv_obj_create(parent); - lv_obj_remove_style_all(c); - lv_obj_add_flag(c, LV_OBJ_FLAG_CLICKABLE); - lv_obj_remove_flag(c, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_size(c, LV_SIZE_CONTENT, LV_SIZE_CONTENT); - lv_obj_set_flex_flow(c, LV_FLEX_FLOW_ROW); - lv_obj_set_flex_align(c, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); // END + SIZE_CONTENT mis-sizes - lv_obj_set_style_pad_column(c, 6, 0); - lv_obj_set_ext_click_area(c, 10); - lv_obj_set_style_opa(c, LV_OPA_60, LV_STATE_PRESSED); - if (!isGroup(lv_obj_get_parent(parent))) { - lv_obj_set_style_bg_color(c, lv_color_hex(theme::SURFACE_2), 0); - lv_obj_set_style_bg_opa(c, LV_OPA_COVER, 0); - lv_obj_set_style_radius(c, theme::RADIUS_SM, 0); - lv_obj_set_style_pad_hor(c, 10, 0); - lv_obj_set_style_pad_ver(c, 8, 0); - } - Choice* ch = (Choice*)lv_malloc(sizeof(Choice)); - memset(ch, 0, sizeof(*ch)); - snprintf(ch->title, sizeof(ch->title), "%s", title ? title : ""); - ch->value = label(c, "", THEME_FONT_BODY, theme::ACCENT); - lv_obj_set_style_max_width(ch->value, 140, 0); // a long value wraps (LONG_DOT mis-sizes with SIZE_CONTENT) - lv_obj_set_style_text_align(ch->value, LV_TEXT_ALIGN_RIGHT, 0); - label(c, LV_SYMBOL_DOWN, THEME_FONT_SMALL, theme::TEXT_MUTED); - lv_obj_set_user_data(c, ch); - ch->sel = sel < 0 ? 0 : (uint16_t)sel; - choiceSetOptions(c, opts); - lv_obj_add_event_cb(c, [](lv_event_t* e) { pickerOpen((lv_obj_t*)lv_event_get_current_target(e)); }, - LV_EVENT_CLICKED, NULL); - lv_obj_add_event_cb(c, [](lv_event_t* e) { - lv_obj_t* o = (lv_obj_t*)lv_event_get_current_target(e); - if (s_pick_target == o) pickerClose(); - Choice* ch = choiceOf(o); - lv_free(ch->opts); - lv_free(ch); - }, LV_EVENT_DELETE, NULL); - return c; -} - - -// One settings row: label left, a choice right. -static lv_obj_t* choiceRow(lv_obj_t* parent, const char* text, const char* hint, const char* options, int sel, - lv_event_cb_t cb, void* user = nullptr) { - lv_obj_t* row = settingRow(parent, text, hint); - lv_obj_t* c = choiceCreate(row, options, sel, text); - lv_obj_align(c, LV_ALIGN_RIGHT_MID, -4, 0); - lv_obj_add_event_cb(c, cb, LV_EVENT_VALUE_CHANGED, user); - return c; -} - -// A value typed in a popup (frequency, name ...): shown as a choice is, with -// a pencil; the tap is the caller's. -static lv_obj_t* rowValue(lv_obj_t* row, const char* text, lv_event_cb_t cb, void* user = nullptr) { - lv_obj_t* c = flexBox(row, LV_FLEX_FLOW_ROW); - lv_obj_set_flex_align(c, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_set_style_pad_column(c, 6, 0); - lv_obj_add_flag(c, LV_OBJ_FLAG_CLICKABLE); - lv_obj_set_ext_click_area(c, 10); - lv_obj_set_style_opa(c, LV_OPA_60, LV_STATE_PRESSED); - label(c, text, THEME_FONT_BODY, theme::ACCENT); - label(c, LV_SYMBOL_EDIT, THEME_FONT_SMALL, theme::TEXT_MUTED); - lv_obj_add_event_cb(c, cb, LV_EVENT_CLICKED, user); - return c; -} - -// A tick on a listRow() (the one in use), left of its chevron. -static void rowCheck(lv_obj_t* row) { - lv_obj_t* l = label(row, LV_SYMBOL_OK, THEME_FONT_BODY, theme::ACCENT); - if (isGroup(lv_obj_get_parent(row))) lv_obj_move_to_index(l, (int32_t)lv_obj_get_child_count(row) - 2); - else lv_obj_align(l, LV_ALIGN_RIGHT_MID, -theme::PAD, 0); -} - -// The subtitle label of a listRow() / settingRow() with one, to update it. -static lv_obj_t* rowSub(lv_obj_t* row) { - if (isGroup(lv_obj_get_parent(row))) return lv_obj_get_child(lv_obj_get_child(row, 0), 1); - return lv_obj_get_child(row, 1); -} - -// Settings pages: the schema's, then this UI's own. Display, Power and Time -// split the schema's "Display & power" page (shared with L1, left as it is) -// into what their titles say. -enum : uint8_t { PG_KEYBOARD = settings::PG_COUNT, PG_ABOUT, PG_DISPLAY, PG_POWER, PG_TIME, PG_ALL }; - -// A schema setting by its NodePrefs field; -1 if there's none. -#define SETTING(field) settings::indexOf(offsetof(NodePrefs, field)) - -// A setting's current value as its choice shows it ("UTC+2", "3.4 V" ...). -static const char* settingText(const NodePrefs& p, int idx, char* buf, int n) { - buf[0] = '\0'; - return idx < 0 ? buf : settings::text(p, settings::ALL[idx], buf, n); -} -static lv_obj_t* s_sec_card[settings::SEC_COUNT]; // schemaRows()' groups, by section -static bool s_opts_from_map = false; // Map options opened from the map: back returns there -static int s_nav_section = -1; // Map options: one section of them (from the map), -1 all -static int32_t s_settings_y = 0; // Settings' scroll, kept while a page of it is open - -// A one-of-N choice: a row (or grid, with "\n" in the map) of checkable -// buttons, `sel` checked (-1: none). `item` is the unchecked button colour — -// SURFACE on a SURFACE_2 panel, SURFACE_2 on the page. -static lv_obj_t* segmented(lv_obj_t* parent, const char** map, int sel, int w, int h, - uint32_t item = theme::SURFACE_2) { - lv_obj_t* m = lv_buttonmatrix_create(parent); - lv_buttonmatrix_set_map(m, map); - lv_buttonmatrix_set_button_ctrl_all(m, LV_BUTTONMATRIX_CTRL_CHECKABLE); - lv_buttonmatrix_set_one_checked(m, true); - if (sel >= 0) lv_buttonmatrix_set_button_ctrl(m, sel, LV_BUTTONMATRIX_CTRL_CHECKED); - lv_obj_set_size(m, w, h); - lv_obj_set_style_pad_all(m, 0, 0); - lv_obj_set_style_pad_gap(m, 4, 0); - lv_obj_set_style_bg_opa(m, LV_OPA_TRANSP, 0); - lv_obj_set_style_border_width(m, 0, 0); - lv_obj_set_style_bg_color(m, lv_color_hex(item), LV_PART_ITEMS); - lv_obj_set_style_bg_color(m, lv_color_hex(theme::ACCENT), LV_PART_ITEMS | LV_STATE_CHECKED); - lv_obj_set_style_text_color(m, lv_color_hex(theme::TEXT), LV_PART_ITEMS); - lv_obj_set_style_text_color(m, lv_color_hex(theme::BG), LV_PART_ITEMS | LV_STATE_CHECKED); - lv_obj_set_style_text_font(m, THEME_FONT_SMALL, LV_PART_ITEMS); - lv_obj_set_style_shadow_width(m, 0, LV_PART_ITEMS); - lv_obj_set_style_radius(m, theme::RADIUS_SM, LV_PART_ITEMS); - return m; -} - -// A one-line text field, full width. The theme's padding leaves less than one -// line inside 34 px, which makes the field scroll vertically (text jumps as -// it's typed), so it's padded to fit exactly one body line: 34 = 2*1 border + -// 2*6 pad + 20. The theme draws the cursor only while FOCUSED. -static lv_obj_t* textField(lv_obj_t* parent, const char* placeholder = NULL) { - lv_obj_t* ta = lv_textarea_create(parent); - lv_textarea_set_one_line(ta, true); - if (placeholder) lv_textarea_set_placeholder_text(ta, placeholder); - lv_obj_set_size(ta, LV_PCT(100), 34); - lv_obj_set_style_border_width(ta, 1, 0); - lv_obj_set_style_pad_ver(ta, 6, 0); - lv_obj_set_style_pad_hor(ta, 10, 0); - lv_obj_set_scrollbar_mode(ta, LV_SCROLLBAR_MODE_OFF); - lv_obj_set_style_border_color(ta, lv_color_hex(theme::ACCENT), LV_PART_CURSOR | LV_STATE_FOCUSED); - lv_obj_set_style_border_width(ta, 2, LV_PART_CURSOR | LV_STATE_FOCUSED); - return ta; -} -// A field's cursor shown (FOCUSED, blinking) or not. LVGL starts and stops -// the blink only as the cursor moves, so it's nudged here: left running on a -// field without focus, it redraws the field every 400 ms for nothing. -// (lv_keyboard_set_textarea() focuses the field it's given.) -static void fieldFocus(lv_obj_t* ta, bool on) { - if (!ta) return; - if (on) lv_obj_add_state(ta, LV_STATE_FOCUSED); - else lv_obj_remove_state(ta, LV_STATE_FOCUSED); - lv_textarea_set_cursor_pos(ta, lv_textarea_get_cursor_pos(ta)); -} - -// A field limited in UTF-8 bytes (user data: the cap) -- names and other text -// that is stored in a fixed buffer. LVGL's own limit counts characters. -static void onByteCapInsert(lv_event_t* e) { - lv_obj_t* ta = (lv_obj_t*)lv_event_get_target(e); - const char* ins = (const char*)lv_event_get_param(e); - size_t cap = (size_t)(uintptr_t)lv_event_get_user_data(e); - if (ins && strlen(lv_textarea_get_text(ta)) + strlen(ins) > cap) lv_textarea_set_insert_replace(ta, ""); -} - -// The primary action of a screen or popup: full amber, dark text (Theme.h); -// call after its label is made. -static void stylePrimary(lv_obj_t* b) { - lv_obj_set_style_bg_color(b, lv_color_hex(theme::ACCENT), 0); - for (uint32_t i = 0; i < lv_obj_get_child_count(b); i++) - lv_obj_set_style_text_color(lv_obj_get_child(b, i), lv_color_hex(theme::BG), 0); -} - -// Something selected / on (a tab, a chip, a row): the accent with dark text, -// or back to the surface with light text. Every label under it follows. -static void textUnder(lv_obj_t* o, uint32_t col) { - for (uint32_t i = 0; i < lv_obj_get_child_count(o); i++) { - lv_obj_t* c = lv_obj_get_child(o, i); - if (lv_obj_check_type(c, &lv_label_class)) setTextColor(c, col); - else textUnder(c, col); - } -} -static void styleSelected(lv_obj_t* b, bool on) { - lv_obj_set_style_bg_color(b, lv_color_hex(on ? theme::ACCENT : theme::SURFACE), 0); - lv_obj_set_style_bg_color(b, lv_color_hex(on ? theme::mix(theme::ACCENT, theme::BG, 75) : theme::SURFACE_2), LV_STATE_PRESSED); - textUnder(b, on ? theme::BG : theme::TEXT); -} - -// A row of equal buttons at the foot of a popup or screen: buttonBar(), then -// barButton() for each -- `accent` for the main action or one that's on -// (barButtonOn() flips it later). Returns the button; its label is child 0. -static lv_obj_t* buttonBar(lv_obj_t* parent) { - lv_obj_t* bar = lv_obj_create(parent); - styleSurface(bar, theme::BG); - lv_obj_remove_flag(bar, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_size(bar, LV_PCT(100), LV_SIZE_CONTENT); - lv_obj_set_flex_flow(bar, LV_FLEX_FLOW_ROW); - lv_obj_set_style_pad_column(bar, theme::GAP, 0); - return bar; -} -static void barButtonOn(lv_obj_t* b, bool on) { styleSelected(b, on); } -static lv_obj_t* barButton(lv_obj_t* bar, const char* text, lv_event_cb_t cb, uintptr_t user, bool accent = false) { - lv_obj_t* b = lv_button_create(bar); - lv_obj_set_height(b, 40); - lv_obj_set_flex_grow(b, 1); - lv_obj_set_style_pad_hor(b, 4, 0); - lv_obj_set_style_radius(b, theme::RADIUS, 0); - lv_obj_set_style_shadow_width(b, 0, 0); - lv_obj_add_event_cb(b, cb, LV_EVENT_CLICKED, (void*)user); - lv_obj_center(label(b, text, THEME_FONT_SMALL, theme::TEXT)); - barButtonOn(b, accent); - return b; -} - -// Asking before something that can't be undone: under navPopupPanel("...?"), -// what happens in a sentence and the red button that does it. -static lv_obj_t* confirmBody(lv_obj_t* panel, const char* text, const char* button, lv_event_cb_t cb, uintptr_t user) { - noteLabel(panel, text); - lv_obj_t* b = lv_button_create(panel); - lv_obj_set_size(b, LV_PCT(100), 40); - lv_obj_set_style_shadow_width(b, 0, 0); - lv_obj_set_style_radius(b, theme::RADIUS, 0); - lv_obj_set_style_bg_color(b, lv_color_hex(theme::FAIL), 0); - lv_obj_add_event_cb(b, cb, LV_EVENT_CLICKED, (void*)user); - lv_obj_center(label(b, button, THEME_FONT_BODY, theme::TEXT)); - return b; -} - -// A button that asks by itself before it destroys: the first tap puts `ask` -// on its label, a second tap within 3 s is the yes. Left alone, the label -// comes back and the next tap asks again. One button asks at a time. -static lv_obj_t* s_ask_lbl = nullptr; -static char s_ask_text[64]; -static lv_timer_t* s_ask_timer = nullptr; -static void onAskDeleted(lv_event_t* e); -static void askReset() { - if (s_ask_timer) { lv_timer_delete(s_ask_timer); s_ask_timer = nullptr; } - if (!s_ask_lbl) return; - lv_obj_remove_event_cb(s_ask_lbl, onAskDeleted); - lv_label_set_text(s_ask_lbl, s_ask_text); - s_ask_lbl = nullptr; -} -static void onAskDeleted(lv_event_t* e) { (void)e; s_ask_lbl = nullptr; askReset(); } -static bool tapConfirmed(lv_obj_t* lbl, const char* ask) { - bool yes = lbl && lbl == s_ask_lbl; - askReset(); - if (yes || !lbl) return yes; - s_ask_lbl = lbl; - snprintf(s_ask_text, sizeof(s_ask_text), "%s", lv_label_get_text(lbl)); - lv_label_set_text(lbl, ask); - lv_obj_add_event_cb(lbl, onAskDeleted, LV_EVENT_DELETE, NULL); - s_ask_timer = lv_timer_create([](lv_timer_t*) { s_ask_timer = nullptr; askReset(); }, 3000, NULL); - lv_timer_set_repeat_count(s_ask_timer, 1); - return false; -} - -// "@[nick]" -> "@nick" in place, for one-line text (previews, quotes) where -// the bubble's highlight doesn't reach. -static void plainMentions(char* t) { - char* w = t; - for (const char* r = t; *r;) { - const char* close = r[0] == '@' && r[1] == '[' ? strchr(r + 2, ']') : nullptr; - if (close && close - r <= 34) { - *w++ = '@'; - for (const char* q = r + 2; q < close;) *w++ = *q++; - r = close + 1; - } else { - *w++ = *r++; - } - } - *w = '\0'; -} - -// Unread badge: amber pill with a count, on the right edge of `parent`. -static void badge(lv_obj_t* parent, int n, bool overflow) { - if (n <= 0) return; - lv_obj_t* b = lv_obj_create(parent); - lv_obj_remove_flag(b, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_remove_flag(b, LV_OBJ_FLAG_CLICKABLE); - lv_obj_set_size(b, LV_SIZE_CONTENT, 20); - lv_obj_set_style_bg_color(b, lv_color_hex(theme::ACCENT), 0); - lv_obj_set_style_border_width(b, 0, 0); - lv_obj_set_style_radius(b, LV_RADIUS_CIRCLE, 0); - lv_obj_set_style_pad_hor(b, 7, 0); - lv_obj_set_style_pad_ver(b, 0, 0); - lv_obj_align(b, LV_ALIGN_RIGHT_MID, -theme::PAD, 0); - lv_obj_t* l = label(b, "", THEME_FONT_SMALL, theme::BG); - lv_label_set_text_fmt(l, "%d%s", n, overflow ? "+" : ""); - lv_obj_center(l); -} - -// A vlist row's badge (its third child): made the first time there's a -// count, then its count set, hidden at none. -static void rowBadge(lv_obj_t* row, int n, bool overflow) { - lv_obj_t* b = lv_obj_get_child(row, 2); - if (!b) { badge(row, n, overflow); return; } - lv_obj_set_flag(b, LV_OBJ_FLAG_HIDDEN, n <= 0); - if (n <= 0) return; - char t[8]; - snprintf(t, sizeof(t), "%d%s", n, overflow ? "+" : ""); - setText(lv_obj_get_child(b, 0), t); -} - -// The same on a Home tile: in its top-right corner, clear of the icon. -static void tileBadge(lv_obj_t* tile, int n) { - if (n <= 0) return; - badge(tile, n, false); - lv_obj_align(lv_obj_get_child(tile, -1), LV_ALIGN_TOP_RIGHT, -2, 2); -} - -// Local time (NodePrefs::tz_offset_hours), now or at `utc`; false before the clock is set. -static bool localTime(const NodePrefs* p, struct tm& out, uint32_t utc = rtc_clock.getCurrentTime()) { - return localTm(utc, p ? p->tz_offset_hours : 0, out); -} -static const char* const MONTHS[] = { "Jan", "Feb", "Mar", "Apr", "May", "Jun", - "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" }; -// "14:05", or "2:05" (+ " PM" with suffix) with Settings > 12-hour clock; -// with `seconds`, ":09" follows unless Settings > Time > Clock seconds is off. -static void fmtClock(char* b, size_t n, const struct tm& ti, const NodePrefs* p, bool suffix, bool seconds = false) { - char sec[4] = ""; - if (seconds && p && !p->clock_hide_seconds) snprintf(sec, sizeof(sec), ":%02d", ti.tm_sec); - if (p && p->clock_12h) { - int h = ti.tm_hour % 12; - snprintf(b, n, "%d:%02d%s%s", h ? h : 12, ti.tm_min, sec, suffix ? (ti.tm_hour < 12 ? " AM" : " PM") : ""); - } else { - snprintf(b, n, "%02d:%02d%s", ti.tm_hour, ti.tm_min, sec); - } -} -// "Thu 25 Sep 2026". -static void fmtDate(char* b, size_t n, const struct tm& ti) { - static const char* DOW[] = { "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat" }; - snprintf(b, n, "%s %d %s %d", DOW[ti.tm_wday], ti.tm_mday, MONTHS[ti.tm_mon], ti.tm_year + 1900); -} - -// Settings changed here reach flash once their screen is left (as on the L1: -// a run of switches is one write, and the flash wears less), just after the -// next screen has come in -- a save stalls the UI (0.1-0.6 s on the ESP32's -// SPIFFS, far less on LittleFS). Else when the screen goes off, or a minute on. NodePrefs -// itself changes at once; shutdown() saves whatever is pending. -static uint32_t s_prefs_dirty_ms = 0; // when the first unsaved change came (0: none) -static uint32_t s_prefs_save_at = 0; // the screen was left: save from then (0: not yet) -static void prefsSave() { if (!s_prefs_dirty_ms) s_prefs_dirty_ms = millis() | 1; } -static void prefsSaveSoon(uint32_t delay_ms) { - if (s_prefs_dirty_ms && !s_prefs_save_at) s_prefs_save_at = (millis() + delay_ms) | 1; -} -static void prefsFlush() { - if (!s_prefs_dirty_ms) return; - bool due = s_prefs_save_at ? (int32_t)(millis() - s_prefs_save_at) >= 0 : millis() - s_prefs_dirty_ms >= 60000; - if (!due) return; - s_prefs_dirty_ms = s_prefs_save_at = 0; - the_mesh.savePrefs(); -} - -// The big clock in the boot splash's lettering, as ui-new draws it on the -// OLED and e-ink (ui-core/Lettering.h: block digits in 3 px strokes): HH:MM -// at SC x, the seconds after them at SC_SEC x on the same baseline. The -// lettering leans a glyph row at a time, steps SC px tall at this size; -// here the lean is worked out per screen row, so it's a smooth slant. -#include "../ui-core/Lettering.h" -namespace bigclock { -static const int SC = 4, SC_SEC = 2; -struct Text { char hm[8]; char sec[4]; }; - -static int lean(int py, int h, int sc) { return (2 * sc * (h - 1 - py) + (h - 1) / 2) / (h - 1); } // 2 glyph px at the top, 0 at the bottom -static int32_t width(const char* t, int sc) { - const int w = lettering::textW(t, 0); - return w <= 0 ? 0 : w * sc + ((int)strlen(t) - 1) * (sc + 1); -} -static void draw(lv_layer_t* layer, int32_t x, int32_t y, const char* t, int sc, lv_color_t col) { - const int h = lettering::LOGO_H * sc; - lv_draw_rect_dsc_t d; - lv_draw_rect_dsc_init(&d); - d.bg_color = col; - for (const char* p = t; *p; p++) { - const lettering::PixGlyph* g = lettering::pixGlyph(*p); - if (!g) continue; - const int gw = (int)strlen(g->rows[0]); - for (int py = 0; py < h; ) { - const int r = py / sc, off = lean(py, h, sc); - int end = py + 1; // the rows under it on the same glyph row, leaning as far - while (end < h && end / sc == r && lean(end, h, sc) == off) end++; - const char* row = g->rows[r]; - for (int c = 0; c < gw; c++) { // runs of ink as one rect - if (row[c] != '#') continue; - int e = c; - while (e + 1 < gw && row[e + 1] == '#') e++; - lv_area_t a = { x + off + c * sc, y + py, x + off + (e + 1) * sc - 1, y + end - 1 }; - lv_draw_rect(layer, &d, &a); - c = e; - } - py = end; - } - x += (gw + 2) * sc + sc + 1; - } -} - -static void onDraw(lv_event_t* e) { - lv_obj_t* o = (lv_obj_t*)lv_event_get_target(e); - const Text* t = (const Text*)lv_obj_get_user_data(o); - if (!t) return; - lv_area_t a; - lv_obj_get_coords(o, &a); - const lv_color_t col = lv_obj_get_style_text_color(o, LV_PART_MAIN); - draw(lv_event_get_layer(e), a.x1, a.y1, t->hm, SC, col); - if (t->sec[0]) - draw(lv_event_get_layer(e), a.x1 + width(t->hm, SC) + 3 * SC, a.y2 + 1 - lettering::LOGO_H * SC_SEC, t->sec, SC_SEC, col); -} -static void onDelete(lv_event_t* e) { lv_free(lv_obj_get_user_data((lv_obj_t*)lv_event_get_target(e))); } - -static lv_obj_t* create(lv_obj_t* parent) { - lv_obj_t* o = lv_obj_create(parent); - lv_obj_remove_style_all(o); - lv_obj_remove_flag(o, LV_OBJ_FLAG_CLICKABLE); - lv_obj_set_style_text_color(o, lv_color_hex(theme::TEXT), 0); - Text* t = (Text*)lv_malloc(sizeof(Text)); - if (t) { snprintf(t->hm, sizeof(t->hm), "--:--"); t->sec[0] = 0; } - lv_obj_set_user_data(o, t); - lv_obj_set_size(o, width("--:--", SC), lettering::LOGO_H * SC); - lv_obj_add_event_cb(o, onDraw, LV_EVENT_DRAW_MAIN_END, NULL); - lv_obj_add_event_cb(o, onDelete, LV_EVENT_DELETE, NULL); - return o; -} -static void set(lv_obj_t* o, const char* hm, const char* sec) { - Text* t = (Text*)lv_obj_get_user_data(o); - if (!t || (!strcmp(t->hm, hm) && !strcmp(t->sec, sec))) return; - snprintf(t->hm, sizeof(t->hm), "%s", hm); - snprintf(t->sec, sizeof(t->sec), "%s", sec); - const int32_t w = width(hm, SC) + (sec[0] ? 3 * SC + width(sec, SC_SEC) : 0); - if (w != lv_obj_get_width(o)) lv_obj_set_width(o, w); - lv_obj_invalidate(o); -} -} // namespace bigclock - -// A big clock (Home, the lock screen): the digits and, on a 12-hour clock, -// AM / PM beside them in `small`, on the digits' baseline. `big` null: the -// digits in the lettering (bigclock above). clockFaceSet() once a second; -// "--:--" until the time is known. -static lv_obj_t* clockFace(lv_obj_t* parent, const lv_font_t* big, const lv_font_t* small) { - lv_obj_t* f = flexBox(parent, LV_FLEX_FLOW_ROW); - lv_obj_set_flex_align(f, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_END, LV_FLEX_ALIGN_END); - lv_obj_set_style_pad_column(f, big ? 4 : 3 * bigclock::SC, 0); - if (big) label(f, "--:--", big, theme::TEXT); - else { bigclock::create(f); lv_obj_set_style_pad_bottom(f, 6, 0); } // clear of the line under it - lv_obj_t* ap = label(f, "", small, theme::TEXT_MUTED); - if (big) lv_obj_set_style_pad_bottom(ap, big->base_line - small->base_line, 0); - else lv_obj_set_style_translate_y(ap, small->base_line, 0); // its baseline on the digits' foot - lv_obj_add_flag(ap, LV_OBJ_FLAG_HIDDEN); - return f; -} -static void clockFaceSet(lv_obj_t* f, const struct tm* ti, const NodePrefs* p) { - lv_obj_t* digits = lv_obj_get_child(f, 0); - lv_obj_t* ap = lv_obj_get_child(f, 1); - if (lv_obj_check_type(digits, &lv_label_class)) { - char clk[12] = "--:--"; - if (ti) fmtClock(clk, sizeof(clk), *ti, p, false, true); - setText(digits, clk); - } else { - char hm[8] = "--:--", sec[4] = ""; - if (ti) { - if (p && p->clock_12h) snprintf(hm, sizeof(hm), "%d:%02d", ti->tm_hour % 12 ? ti->tm_hour % 12 : 12, ti->tm_min); - else snprintf(hm, sizeof(hm), "%02d:%02d", ti->tm_hour, ti->tm_min); - if (!p || !p->clock_hide_seconds) snprintf(sec, sizeof(sec), "%02d", ti->tm_sec); - } - bigclock::set(digits, hm, sec); - } - bool h12 = ti && p && p->clock_12h; - if (h12) setText(ap, ti->tm_hour < 12 ? "AM" : "PM"); - if (h12 != !lv_obj_has_flag(ap, LV_OBJ_FLAG_HIDDEN)) { - if (h12) lv_obj_remove_flag(ap, LV_OBJ_FLAG_HIDDEN); - else lv_obj_add_flag(ap, LV_OBJ_FLAG_HIDDEN); - } -} -// A message's age ("12s" / "5m" / "3h" / "2d"). After a restart the clock runs -// from the build date until GPS or the app sets it, so messages restored from -// the card look newer than "now": those show when they came ("25 Sep 14:05"). -static const NodePrefs* s_prefs = nullptr; // for the free helpers below; set in begin() -static bool clockBehind(uint32_t now, uint32_t ts) { return ts > 1000000000UL && ts > now + 120; } -static void fmtMsgAge(char* b, size_t n, uint32_t now, uint32_t ts, const NodePrefs* p) { - if (!clockBehind(now, ts)) { geo::fmtAgeShort(b, (int)n, now, ts ? ts : now); return; } - struct tm ti; - localTime(p, ti, ts); // set: clockBehind() says ts is - char clk[12]; - fmtClock(clk, sizeof(clk), ti, p, true); - snprintf(b, n, "%d %s %s", ti.tm_mday, MONTHS[ti.tm_mon], clk); -} - -static void contactName(const uint8_t* prefix, char* out, size_t n) { - ContactInfo c; - if (MessageHistory::contactByPrefix(prefix, c) && c.name[0]) snprintf(out, n, "%s", c.name); - else snprintf(out, n, "%02X%02X%02X%02X", prefix[0], prefix[1], prefix[2], prefix[3]); -} +#include "Widgets.h" // ── Lifecycle ───────────────────────────────────────────────────────────────── @@ -2481,1113 +1187,11 @@ static void onFactoryReset(lv_event_t* e) { s_ui->factoryResetStep((int)(uintptr // Home itself (pages, clock, minimap, apps) is HomeScreen.h. -// ── Virtual list ────────────────────────────────────────────────────────────── -// A long list (contacts, nodes, conversations) as only the rows it shows: a -// row scrolled out of sight takes the next item coming in. The items are -// kinds with a fixed height -- a row made once (make), its texts set for an -// item (bind) -- or fixed objects of their own (a section title, a note). -// The scroll extent comes from the items' heights, so the scrollbar, a throw -// and coming back to where it was work as with every row built. Built in the -// same time whatever the count; 256 contacts: 522 objects, 950 ms filling -// in and 28 ms a scrolled frame before, 19 objects and 13 ms now. -namespace vlist { -struct Kind { - int16_t h; - lv_obj_t* (*make)(lv_obj_t* list); // a row, its event callbacks on (they ask arg()) - void (*bind)(lv_obj_t* row, intptr_t arg); -}; -struct Item { const Kind* k; intptr_t arg; int32_t top; int16_t h; }; // k null: arg is its own object -struct Slot { lv_obj_t* o; const Kind* k; int item; int prev; }; -static const int SLOTS = 32; // a screenful of the shortest kind, a few kinds -struct List { - lv_obj_t* obj; - Item* items; - int n, cap; - int32_t total; - bool placed; // the items' tops are known (not while a new set comes in) - Slot slots[SLOTS]; - int nslots; -}; -static List* s_lists[8]; // a screen's and a popup's, two screens while one slides in +#include "VirtualList.h" -static List* of(lv_obj_t* obj) { - for (List* l : s_lists) if (l && l->obj == obj) return l; - return nullptr; -} +#include "ChatList.h" -static void place(lv_obj_t* o, int32_t y) { - if (lv_obj_get_style_y(o, LV_PART_MAIN) != y) lv_obj_set_y(o, y); - if (lv_obj_has_flag(o, LV_OBJ_FLAG_HIDDEN)) lv_obj_remove_flag(o, LV_OBJ_FLAG_HIDDEN); -} - -// The rows for what's in view: kept where they show the same item, the rest -// handed over (bound) to the items coming in, made only when none is free. -// all: every one bound again (the items changed). -static void update(List* l, bool all) { - if (!l->placed) return; - int32_t sy = lv_obj_get_scroll_y(l->obj), vh = lv_obj_get_content_height(l->obj); - if (vh <= 0) vh = LV_VER_RES; // not laid out yet: a screenful - int first = 0, hi = l->n; - while (first < hi) { // the first item reaching into view - int m = (first + hi) / 2; - if (l->items[m].top + l->items[m].h <= sy) first = m + 1; else hi = m; - } - int last = first; - while (last < l->n && l->items[last].top < sy + vh) last++; - for (int s = 0; s < l->nslots; s++) { - Slot& sl = l->slots[s]; - if (sl.item < 0) continue; - if (all || sl.item < first || sl.item >= last) { sl.prev = sl.item; sl.item = -1; } - } - for (int i = first; i < last; i++) { - Item& it = l->items[i]; - if (!it.k) continue; - int got = -1; - for (int s = 0; s < l->nslots && got < 0; s++) if (l->slots[s].item == i) got = s; - if (got >= 0) continue; // shown already - for (int s = 0; s < l->nslots; s++) { // a free one of its kind; the one that showed it, if any - Slot& sl = l->slots[s]; - if (sl.item >= 0 || sl.k != it.k) continue; - if (got < 0 || sl.prev == i) got = s; - if (sl.prev == i) break; - } - if (got < 0) { - if (l->nslots == SLOTS) continue; - got = l->nslots++; - l->slots[got] = { it.k->make(l->obj), it.k, -1, -1 }; - } - Slot& sl = l->slots[got]; - sl.item = i; - it.k->bind(sl.o, it.arg); - place(sl.o, it.top); - } - for (int s = 0; s < l->nslots; s++) { - Slot& sl = l->slots[s]; - if (sl.item < 0 && !lv_obj_has_flag(sl.o, LV_OBJ_FLAG_HIDDEN)) lv_obj_add_flag(sl.o, LV_OBJ_FLAG_HIDDEN); - } -} - -static void onEvent(lv_event_t* e) { - lv_obj_t* obj = (lv_obj_t*)lv_event_get_current_target(e); - List* l = of(obj); - if (!l) return; - switch (lv_event_get_code(e)) { - case LV_EVENT_SCROLL: case LV_EVENT_SIZE_CHANGED: update(l, false); break; - case LV_EVENT_GET_SELF_SIZE: { - lv_point_t* p = (lv_point_t*)lv_event_get_param(e); - if (p->y < l->total) p->y = l->total; - break; - } - case LV_EVENT_DELETE: - for (List*& x : s_lists) if (x == l) x = nullptr; - lv_free(l->items); - lv_free(l); - break; - default: break; - } -} - -// `obj` (a scrolling column: a screen body, a scrollList) becomes a virtual -// list; its children so far are taken away. -static void attach(lv_obj_t* obj) { - List* l = nullptr; - for (List*& x : s_lists) if (!x) { x = l = (List*)lv_malloc_zeroed(sizeof(List)); break; } - if (!l) return; // can't be: then a plain empty column - l->obj = obj; - lv_obj_clean(obj); - lv_obj_set_layout(obj, LV_LAYOUT_NONE); - for (lv_event_code_t c : { LV_EVENT_SCROLL, LV_EVENT_SIZE_CHANGED, LV_EVENT_GET_SELF_SIZE, LV_EVENT_DELETE }) - lv_obj_add_event_cb(obj, onEvent, c, NULL); -} - -// A new set of items: add() / own() them, then end(). The rows made stay -// (rebound); objects of their own given to own() before are deleted. -static void begin(lv_obj_t* obj) { - List* l = of(obj); - if (!l) return; - for (int i = 0; i < l->n; i++) if (!l->items[i].k) lv_obj_delete((lv_obj_t*)l->items[i].arg); - l->n = 0; - l->placed = false; -} -static void push(List* l, const Kind* k, intptr_t arg, int16_t h) { - if (l->n == l->cap) { - l->cap = l->cap ? l->cap * 2 : 64; - l->items = (Item*)lv_realloc(l->items, l->cap * sizeof(Item)); - } - l->items[l->n++] = { k, arg, 0, h }; -} -static void add(lv_obj_t* obj, const Kind* k, intptr_t arg) { if (List* l = of(obj)) push(l, k, arg, k->h); } -// An object of its own (made on the list, its height known after a layout). -static void own(lv_obj_t* obj, lv_obj_t* o) { if (List* l = of(obj)) push(l, nullptr, (intptr_t)o, 0); } - -static void end(lv_obj_t* obj) { - List* l = of(obj); - if (!l) return; - bool mine = false; - for (int i = 0; i < l->n && !mine; i++) mine = !l->items[i].k; - if (mine) lv_obj_update_layout(obj); // their heights - int32_t gap = lv_obj_get_style_pad_row(obj, LV_PART_MAIN), y = 0; - for (int i = 0; i < l->n; i++) { - Item& it = l->items[i]; - if (!it.k) { - lv_obj_t* o = (lv_obj_t*)it.arg; - it.h = (int16_t)lv_obj_get_height(o); - place(o, y); - } - it.top = y; - y += it.h + gap; - } - l->total = l->n ? y - gap : 0; - l->placed = true; - lv_obj_refresh_self_size(obj); - update(l, true); - lv_obj_readjust_scroll(obj, LV_ANIM_OFF); // fewer items: not scrolled past the end -} - -// The item a row's event is for (its arg). -static intptr_t arg(lv_event_t* e) { - lv_obj_t* o = (lv_obj_t*)lv_event_get_current_target(e); - List* l = of(lv_obj_get_parent(o)); - if (l) - for (int s = 0; s < l->nslots; s++) - if (l->slots[s].o == o && l->slots[s].item >= 0) return l->items[l->slots[s].item].arg; - return -1; -} - -// A row held down: what it shows stays until it's let go. -static bool pressed(lv_obj_t* obj) { - List* l = of(obj); - for (int s = 0; l && s < l->nslots; s++) - if (lv_obj_has_state(l->slots[s].o, LV_STATE_PRESSED)) return true; - return false; -} -} // namespace vlist - -// ── Conversation list ───────────────────────────────────────────────────────── - -static void onOpenChannel(lv_event_t* e) { - intptr_t i = vlist::arg(e); - if (i >= 0) s_ui->openChannel((uint8_t)i); -} - -// DM rows carry a 4-byte prefix; kept in a static table the rows point into. -static uint8_t s_dm_rows[MessageHistory::DM_HIST_MAX][4]; -static const int CONTACT_ROWS_MAX = 256; // "All" with a full contact table stays usable -static uint8_t (*s_contact_rows)[PUB_KEY_SIZE] = psramBuf(CONTACT_ROWS_MAX); -static uint16_t* s_contact_raw = psramBuf(CONTACT_ROWS_MAX); // their raw table index, for the name - -static uint16_t s_dm_pos[MessageHistory::DM_HIST_MAX]; // its newest message in the DM ring -static bool s_dm_known[MessageHistory::DM_HIST_MAX]; // a contact (has options on hold) -static void onOpenDMRow(lv_event_t* e) { - intptr_t r = vlist::arg(e); - if (r >= 0) s_ui->openDM(s_dm_rows[r]); -} -static void onOpenContactRow(lv_event_t* e) { - intptr_t i = vlist::arg(e); - if (i >= 0) s_ui->openDM(s_contact_rows[i]); -} -static void onNewChat(lv_event_t* e) { (void)e; s_ui->showContacts(); } -static void onChanRowHold(lv_event_t* e); // ChannelScreen.h -static void onChanAdd(lv_event_t* e); -static void onChanThreadMenu(lv_event_t* e); -static const int ROOM_ROWS_MAX = 32; -static uint8_t (*s_room_rows)[PUB_KEY_SIZE] = psramBuf(ROOM_ROWS_MAX); -static uint16_t s_room_raw[ROOM_ROWS_MAX]; // their raw table index, for the row -static bool s_chats_read_all = false; // Messages built with "Read all" in its header -static void onDMRowHold(lv_event_t* e); // ConversationScreen.h -static void onRoomRow(lv_event_t* e); -static void onRoomRowHold(lv_event_t* e); -static void onConvThreadMenu(lv_event_t* e); -static void onMsgHold(lv_event_t* e); -static void onChatFilter(lv_event_t* e); -enum : uint8_t { CF_CHANNELS, CF_ROOMS, CF_CONTACTS }; // favourites-only filters - -// One tappable list row: title, optional muted subtitle, optional badge. -// In a group(): a flat row, the subtitle wrapping, a chevron on the right. -static lv_obj_t* listRow(lv_obj_t* parent, const char* title, const char* sub, - lv_event_cb_t cb, void* user) { - if (isGroup(parent)) { - lv_obj_t* row = groupLine(parent, true); - groupText(row, title, sub); - label(row, LV_SYMBOL_RIGHT, THEME_FONT_SMALL, theme::TEXT_MUTED); - lv_obj_add_event_cb(row, cb, LV_EVENT_CLICKED, user); - return row; - } - lv_obj_t* row = lv_button_create(parent); - lv_obj_set_size(row, LV_PCT(100), theme::ROW_H); - lv_obj_set_style_bg_color(row, lv_color_hex(theme::SURFACE), 0); - lv_obj_set_style_bg_color(row, lv_color_hex(theme::SURFACE_2), LV_STATE_PRESSED); - lv_obj_set_style_radius(row, theme::RADIUS, 0); - lv_obj_set_style_shadow_width(row, 0, 0); - lv_obj_set_style_pad_all(row, 0, 0); - lv_obj_add_event_cb(row, cb, LV_EVENT_CLICKED, user); - lv_obj_t* t = label(row, title, THEME_FONT_BODY, theme::TEXT); - lv_label_set_long_mode(t, LV_LABEL_LONG_DOT); - lv_obj_set_width(t, 230); - lv_obj_align(t, LV_ALIGN_TOP_LEFT, theme::PAD, sub ? 5 : 13); - lv_obj_set_user_data(row, t); - if (sub) { - lv_obj_t* s = label(row, sub, THEME_FONT_SMALL, theme::TEXT_MUTED); - lv_label_set_long_mode(s, LV_LABEL_LONG_DOT); - lv_obj_set_size(s, 230, 15); // fixed height: LONG_DOT cuts instead of wrapping over the title - lv_obj_align(s, LV_ALIGN_BOTTOM_LEFT, theme::PAD, -5); - } - return row; -} - -// A listRow() for a vlist: the subtitle always there (shown or not), the -// right side free for a value or a badge; texts set by rowSet(). -static lv_obj_t* vRow(lv_obj_t* list, lv_event_cb_t cb, lv_event_cb_t hold) { - lv_obj_t* row = listRow(list, "", "", cb, NULL); - if (hold) lv_obj_add_event_cb(row, hold, LV_EVENT_LONG_PRESSED, NULL); - return row; -} -static void rowSet(lv_obj_t* row, const char* title, const char* sub, uint32_t title_col = theme::TEXT) { - lv_obj_t* t = rowTitle(row); - lv_obj_t* s = lv_obj_get_child(row, 1); - setText(t, title); - setTextColor(t, title_col); - int32_t y = sub ? 5 : 13; // as listRow() puts it - if (lv_obj_get_style_y(t, LV_PART_MAIN) != y) lv_obj_set_y(t, y); - if (sub) setText(s, sub); - if (!sub != lv_obj_has_flag(s, LV_OBJ_FLAG_HIDDEN)) lv_obj_set_flag(s, LV_OBJ_FLAG_HIDDEN, !sub); -} - -// The same with a value on the right (a distance, a signal), set by rowValue(). -static lv_obj_t* vRowValue(lv_obj_t* list, lv_event_cb_t cb) { - lv_obj_t* row = vRow(list, cb, NULL); - lv_obj_align(label(row, "", THEME_FONT_SMALL, theme::TEXT_MUTED), LV_ALIGN_RIGHT_MID, -theme::PAD, 0); - return row; -} -static void rowValue(lv_obj_t* row, const char* text, uint32_t col) { - lv_obj_t* r = lv_obj_get_child(row, 2); - setText(r, text); - setTextColor(r, col); -} - -static lv_obj_t* sectionTitle(lv_obj_t* parent, const char* text) { - lv_obj_t* l = label(parent, text, THEME_FONT_SMALL, theme::TEXT_MUTED); - lv_obj_set_style_pad_top(l, 4, 0); - return l; -} - -// Messages section title: a chevron and the title fold the section (tap), -// an "All" / "★ Fav" pill on the right (filter >= 0) flips its -// favourites-only filter. Folded, the title carries the unread count. -enum : uint8_t { FOLD_CHANNELS, FOLD_DIRECT, FOLD_ROOMS }; -static int s_chat_fold = -1; // bit per FOLD_*; -1: not read from NVS yet -static void onChatFold(lv_event_t* e) { s_ui->chatFold((int)(uintptr_t)lv_event_get_user_data(e)); } -static bool chatFolded(int which) { - if (s_chat_fold < 0) s_chat_fold = lvport::loadChatFold(); - return s_chat_fold & (1 << which); -} - -static void chatSection(lv_obj_t* parent, const char* text, int fold, int unread, int filter, bool fav_only) { - bool folded = chatFolded(fold); - lv_obj_t* row = lv_obj_create(parent); - styleSurface(row, theme::BG); - lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_size(row, LV_PCT(100), 30); - lv_obj_add_flag(row, LV_OBJ_FLAG_CLICKABLE); - lv_obj_set_style_opa(row, LV_OPA_60, LV_STATE_PRESSED); - lv_obj_add_event_cb(row, onChatFold, LV_EVENT_CLICKED, (void*)(uintptr_t)fold); - lv_obj_t* ch = label(row, folded ? LV_SYMBOL_RIGHT : LV_SYMBOL_DOWN, THEME_FONT_SMALL, theme::TEXT_MUTED); - lv_obj_align(ch, LV_ALIGN_LEFT_MID, 2, 0); - lv_obj_t* t = label(row, text, THEME_FONT_SMALL, theme::TEXT_MUTED); - lv_obj_align(t, LV_ALIGN_LEFT_MID, 22, 0); - if (folded && unread > 0) { // what's waiting inside - lv_obj_t* n = label(row, "", THEME_FONT_SMALL, theme::ACCENT); - lv_label_set_text_fmt(n, "%d new", unread); - lv_obj_align_to(n, t, LV_ALIGN_OUT_RIGHT_MID, 8, 0); - } - if (filter < 0 || folded) return; - styleSelected(pillButton(row, fav_only ? UI_SYMBOL_STAR " Fav" : "All", theme::SURFACE, onChatFilter, filter), fav_only); -} - -// Section title with an "All" / "★ Fav" pill on the right that flips the -// section's favourites-only filter. -static void sectionWithFilter(lv_obj_t* parent, const char* text, bool fav_only, uint8_t which) { - lv_obj_t* row = lv_obj_create(parent); - styleSurface(row, theme::BG); - lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_size(row, LV_PCT(100), 26); - lv_obj_align(label(row, text, THEME_FONT_SMALL, theme::TEXT_MUTED), LV_ALIGN_BOTTOM_LEFT, 0, -2); - styleSelected(pillButton(row, fav_only ? UI_SYMBOL_STAR " Fav" : "All", theme::SURFACE, onChatFilter, which), fav_only); -} - -void UITask::showChats() { - _screen = SCR_CHATS; - buildChats(); -} - -static void onMarkAllRead(lv_event_t* e) { (void)e; s_ui->markAllRead(); } -static lv_obj_t* headerButton(lv_obj_t* hdr, const char* text, lv_event_cb_t cb, int right, lv_obj_t** label_out); - -void UITask::buildChats() { - lv_obj_t* body = newScreen("Messages", true); - s_chats_read_all = unreadTotal() > 0; - if (_header) { // a new message first, whatever the lists below hold - lv_obj_t* nb = headerButton(_header, LV_SYMBOL_EDIT " New", onNewChat, 4, NULL); - stylePrimary(nb); - if (s_chats_read_all) { - lv_obj_t* rb = headerButton(_header, LV_SYMBOL_OK " Read all", onMarkAllRead, 0, NULL); - layoutNow(_header); - lv_obj_align_to(rb, nb, LV_ALIGN_OUT_LEFT_MID, -6, 0); - } - } - vlist::attach(body); - fillChats(); -} - -// Messages again where it is (a new message, a section folded, back from -// the lock): the rows in view rewritten, the rest as they scroll in. Built -// anew only when "Read all" comes or goes. -void UITask::refreshChats() { - if (_screen != SCR_CHATS) { s_chats_stale = false; return; } - if (!_body || !vlist::of(_body) || (unreadTotal() > 0) != s_chats_read_all) { - int y = _body ? lv_obj_get_scroll_y(_body) : 0; - buildChats(); - layoutNow(_body); - lv_obj_scroll_to_y(_body, y, LV_ANIM_OFF); - return; - } - s_chats_stale = vlist::pressed(_body); // held: once it's let go - if (!s_chats_stale) fillChats(); -} - -void UITask::fillChats() { - lv_obj_t* body = _body; - vlist::begin(body); - auto own = [body]() { vlist::own(body, lv_obj_get_child(body, -1)); }; - - // Channels: favourites first (unless turned off), hold a row for its options - static const vlist::Kind CHANNEL = { - theme::ROW_H, - [](lv_obj_t* list) { return vRow(list, onOpenChannel, onChanRowHold); }, - [](lv_obj_t* row, intptr_t i) { - UITask* ui = s_ui; - ChannelDetails ch; - if (!the_mesh.getChannel((int)i, ch)) ch.name[0] = '\0'; - char title[48], sub[64] = ""; - snprintf(title, sizeof(title), "%s%s%s", chanctl::favourite(ui->_prefs, (int)i) ? UI_SYMBOL_STAR " " : "", ch.name, - chanctl::notif(ui->_prefs, (int)i) == chanctl::NOTIF_MUTED ? " " UI_SYMBOL_MUTE : ""); - if (ui->_core->history.histCountForChannel((int)i) > 0) { - const ChHistEntry& e = ui->_core->history.chAtPos(ui->_core->history.histEntryForChannel((int)i, 0)); - snprintf(sub, sizeof(sub), "%s", e.text); - plainMentions(sub); - } - rowSet(row, title, sub[0] ? sub : NULL); - rowBadge(row, ui->_core->history.chUnread((int)i), ui->_core->history.chUnreadOverflow((int)i)); - }, - }; - bool ch_fav_only = _prefs && _prefs->ch_fav_only; - chatSection(body, "CHANNELS", FOLD_CHANNELS, _core->history.getTotalChannelUnread(), CF_CHANNELS, ch_fav_only); - own(); - bool fav_first = !_prefs || _prefs->fav_sort; - int ch_rows = 0; - for (int pass = fav_first ? 0 : 1; pass < 2 && !chatFolded(FOLD_CHANNELS); pass++) { - for (int i = 0; i < MAX_GROUP_CHANNELS; i++) { - ChannelDetails ch; - if (!the_mesh.getChannel(i, ch) || ch.name[0] == '\0') continue; - bool fav = chanctl::favourite(_prefs, i); - if (ch_fav_only && !fav) continue; - if (fav_first && fav != (pass == 0)) continue; - ch_rows++; - vlist::add(body, &CHANNEL, i); - } - } - if (!chatFolded(FOLD_CHANNELS)) { - if (ch_rows == 0 && ch_fav_only) { label(body, "No favourite channels", THEME_FONT_SMALL, theme::TEXT_MUTED); own(); } - lv_obj_t* add_ch = lv_button_create(body); - lv_obj_set_size(add_ch, LV_PCT(100), 32); - lv_obj_set_style_bg_color(add_ch, lv_color_hex(theme::BG), 0); - lv_obj_set_style_border_color(add_ch, lv_color_hex(theme::SURFACE_2), 0); - lv_obj_set_style_border_width(add_ch, 1, 0); - lv_obj_set_style_radius(add_ch, theme::RADIUS, 0); - lv_obj_set_style_shadow_width(add_ch, 0, 0); - lv_obj_add_event_cb(add_ch, onChanAdd, LV_EVENT_CLICKED, NULL); - lv_obj_center(label(add_ch, LV_SYMBOL_PLUS " Add channel", THEME_FONT_SMALL, theme::TEXT_MUTED)); - vlist::own(body, add_ch); - } - - // Recent direct conversations (DM ring, newest first, one row per contact) - static const vlist::Kind DM = { - theme::ROW_H, - [](lv_obj_t* list) { return vRow(list, onOpenDMRow, onDMRowHold); }, - [](lv_obj_t* row, intptr_t r) { - UITask* ui = s_ui; - const DmHistEntry& e = ui->_core->history.dmAtPos(s_dm_pos[r]); - ContactInfo c; - bool known = s_dm_known[r] && MessageHistory::contactByPrefix(s_dm_rows[r], c); - char name[48]; - contactName(s_dm_rows[r], name, sizeof(name)); - if (known && contactctl::favourite(c)) { char t[48]; snprintf(t, sizeof(t), UI_SYMBOL_STAR " %s", name); strcpy(name, t); } - if (known && contactctl::notif(ui->_prefs, c.id.pub_key) == contactctl::NOTIF_MUTED) strncat(name, " " UI_SYMBOL_MUTE, sizeof(name) - strlen(name) - 1); - char sub[64]; - snprintf(sub, sizeof(sub), "%s%s", e.outgoing ? "Me: " : "", e.text); - plainMentions(sub); - rowSet(row, name, sub); - rowBadge(row, ui->_core->dmUnread(s_dm_rows[r]), ui->_core->dmUnreadOverflow(s_dm_rows[r])); - }, - }; - chatSection(body, "DIRECT", FOLD_DIRECT, _core->dmUnreadTotal(), -1, false); - own(); - int rows = 0; - for (int j = 0; j < _core->history.dmHistCount() && rows < MessageHistory::DM_HIST_MAX && !chatFolded(FOLD_DIRECT); j++) { - int pos = _core->history.dmHistPosNewest(j); - const DmHistEntry& e = _core->history.dmAtPos(pos); - bool seen = false; - for (int r = 0; r < rows; r++) if (memcmp(s_dm_rows[r], e.prefix, 4) == 0) { seen = true; break; } - if (seen) continue; - ContactInfo c; - bool known = MessageHistory::contactByPrefix(e.prefix, c); - if (known && c.type == ADV_TYPE_ROOM) continue; // rooms have their own section - memcpy(s_dm_rows[rows], e.prefix, 4); - s_dm_pos[rows] = (uint16_t)pos; - s_dm_known[rows] = known; - vlist::add(body, &DM, rows); - rows++; - } - if (rows == 0 && !chatFolded(FOLD_DIRECT)) { label(body, "No conversations yet - New, top right", THEME_FONT_SMALL, theme::TEXT_MUTED); own(); } - - // Room servers: tap logs in (saved password or ask) and opens; hold: options - static const vlist::Kind ROOM = { - theme::ROW_H, - [](lv_obj_t* list) { return vRow(list, onRoomRow, onRoomRowHold); }, - [](lv_obj_t* row, intptr_t r) { - UITask* ui = s_ui; - ContactInfo c; - if (!the_mesh.getContactByIdx(s_room_raw[r], c) || memcmp(c.id.pub_key, s_room_rows[r], PUB_KEY_SIZE) != 0) { - rowSet(row, "?", NULL); // deleted meanwhile - return; - } - char title[48], sub[64]; - snprintf(title, sizeof(title), "%s%s", contactctl::favourite(c) ? UI_SYMBOL_STAR " " : "", c.name); - if (ui->_core->history.dmHistCountForContact(c.id.pub_key) > 0) { - const DmHistEntry& e = ui->_core->history.dmAtPos(ui->_core->history.dmHistEntryForContact(c.id.pub_key, 0)); - snprintf(sub, sizeof(sub), "%s%s", e.outgoing ? "Me: " : "", e.text); - plainMentions(sub); - } else { - snprintf(sub, sizeof(sub), "%s", ui->_core->rooms.isLoggedIn(c.id.pub_key) ? "Logged in" : "Tap to log in"); - } - rowSet(row, title, sub); - }, - }; - bool room_fav_only = _prefs && _prefs->room_fav_only; - chatSection(body, "ROOMS", FOLD_ROOMS, _core->roomUnread(), CF_ROOMS, room_fav_only); - own(); - int nrooms = 0, total = the_mesh.getNumContacts(); - for (int pass = fav_first ? 0 : 1; pass < 2 && !chatFolded(FOLD_ROOMS); pass++) { - for (int i = 0; i < total && nrooms < ROOM_ROWS_MAX; i++) { - ContactInfo c; - if (!the_mesh.getContactByIdx(MAX_ANON_CONTACTS + i, c) || c.type != ADV_TYPE_ROOM) continue; - bool fav = contactctl::favourite(c); - if (room_fav_only && !fav) continue; - if (fav_first && fav != (pass == 0)) continue; - memcpy(s_room_rows[nrooms], c.id.pub_key, PUB_KEY_SIZE); - s_room_raw[nrooms] = (uint16_t)(MAX_ANON_CONTACTS + i); - vlist::add(body, &ROOM, nrooms); - nrooms++; - } - } - if (nrooms == 0 && !chatFolded(FOLD_ROOMS)) { - label(body, room_fav_only ? "No favourite rooms" : "No room servers known", THEME_FONT_SMALL, theme::TEXT_MUTED); - own(); - } - vlist::end(body); -} - -// A section title tapped: folded / unfolded (kept in NVS), the list redrawn -// where it was. -void UITask::chatFold(int which) { - chatFolded(which); // loaded - s_chat_fold ^= 1 << which; - lvport::saveChatFold(s_chat_fold); - refreshChats(); -} - -// ── Contact picker (start a DM) ─────────────────────────────────────────────── - -void UITask::showContacts() { - _screen = SCR_CONTACTS; - buildContacts(); -} - -void UITask::buildContacts() { - lv_obj_t* body = newScreen("New message", true); - bool fav_only = _prefs && !_prefs->dm_show_all; // ui-new's default: favourites only - vlist::attach(body); - vlist::begin(body); - sectionWithFilter(body, "CONTACTS", fav_only, CF_CONTACTS); - vlist::own(body, lv_obj_get_child(body, -1)); - int total = the_mesh.getNumContacts(); - int rows = 0; - // +MAX_ANON_CONTACTS: getContactByIdx() takes the raw table index (see - // MessageHistory::contactByPrefix()). - for (int i = 0; i < total && rows < CONTACT_ROWS_MAX; i++) { - ContactInfo c; - if (!the_mesh.getContactByIdx(MAX_ANON_CONTACTS + i, c)) continue; - if (c.type != ADV_TYPE_CHAT) continue; - if (fav_only && !contactctl::favourite(c)) continue; - memcpy(s_contact_rows[rows], c.id.pub_key, PUB_KEY_SIZE); - s_contact_raw[rows] = (uint16_t)(MAX_ANON_CONTACTS + i); - rows++; - } -#ifdef PERF_LIST_MUL - for (int k = 0, real = rows; real && rows < CONTACT_ROWS_MAX; k++, rows++) { // a long list from the few there are - memcpy(s_contact_rows[rows], s_contact_rows[k % real], PUB_KEY_SIZE); - s_contact_raw[rows] = s_contact_raw[k % real]; - } -#endif - static const vlist::Kind ROW = { - theme::ROW_H, - [](lv_obj_t* list) { return vRow(list, onOpenContactRow, NULL); }, - [](lv_obj_t* row, intptr_t i) { - ContactInfo c; - bool same = the_mesh.getContactByIdx(s_contact_raw[i], c) && memcmp(c.id.pub_key, s_contact_rows[i], PUB_KEY_SIZE) == 0; - rowSet(row, same ? c.name : "?", NULL); // "?": deleted meanwhile - }, - }; - for (int i = 0; i < rows; i++) vlist::add(body, &ROW, i); - if (rows == 0) vlist::own(body, label(body, fav_only ? "No favourites - tap All" : "No contacts yet", THEME_FONT_BODY, theme::TEXT_MUTED)); - vlist::end(body); -} - -// ── Nearby ──────────────────────────────────────────────────────────────────── -// Contacts / live shares / heard adverts from NearbyModel, filtered by type -// chips, sorted by distance or recency. Tap a row for detail. Scan (nodes that -// answer a discover request right now) is a popup over the list with its own -// model, since it is a different set: who is in range, not who is known. - -static NearbyModel::Entry s_node; // the node open in SCR_NODE (a copy: the list re-sorts) -// Its info card's values (UITask::_node_info is the card). -static lv_obj_t *s_nd_type, *s_nd_status, *s_nd_dist, *s_nd_pos, *s_nd_heard, *s_nd_signal, *s_nd_id; -enum : uint8_t { NODE_MSG, NODE_PING, NODE_FAV, NODE_ADD, NODE_DELETE, NODE_NAV, NODE_ADMIN, NODE_WAYPOINT, NODE_PIN, - NODE_RESET_PATH, NODE_SHARE }; - -static void onNearbyChip(lv_event_t* e) { s_ui->setNearbyFilter((uint8_t)(uintptr_t)lv_event_get_user_data(e)); } -static void onNearbySort(lv_event_t* e) { (void)e; s_ui->toggleNearbySort(); } -static void onNearbyScan(lv_event_t* e) { (void)e; s_ui->startNearbyScan(); } -static void onNearbyRow(lv_event_t* e) { s_ui->openNode((int)vlist::arg(e)); } -static void onScanRow(lv_event_t* e) { s_ui->openScanNode((int)vlist::arg(e)); } -static void onScanClose(lv_event_t* e) { (void)e; s_ui->closeScanPopup(); } -static void onNodeAction(lv_event_t* e) { s_ui->nodeAction((uint8_t)(uintptr_t)lv_event_get_user_data(e)); } - -static lv_obj_t* headerButton(lv_obj_t* hdr, const char* text, lv_event_cb_t cb, int right, lv_obj_t** label_out) { - lv_obj_t* b = lv_button_create(hdr); - lv_obj_set_size(b, LV_SIZE_CONTENT, 28); - lv_obj_set_style_pad_hor(b, 10, 0); - lv_obj_set_style_pad_ver(b, 0, 0); - lv_obj_set_style_bg_color(b, lv_color_hex(theme::SURFACE), 0); - lv_obj_set_style_bg_color(b, lv_color_hex(theme::SURFACE_2), LV_STATE_PRESSED); - lv_obj_set_style_shadow_width(b, 0, 0); - lv_obj_align(b, LV_ALIGN_RIGHT_MID, -right, 0); - lv_obj_add_event_cb(b, cb, LV_EVENT_CLICKED, NULL); - lv_obj_t* l = label(b, text, THEME_FONT_SMALL, theme::TEXT); - lv_obj_center(l); - if (label_out) *label_out = l; - return b; -} - -void UITask::showNearby() { - _screen = SCR_NEARBY; - _scanning = false; - buildNearby(); // filter / sort persist -} - -void UITask::buildNearby() { - lv_obj_t* body = newScreen("Nodes", true); - lv_obj_set_style_pad_row(body, 4, 0); - if (_header) { - headerButton(_header, LV_SYMBOL_REFRESH, onNearbyScan, 4, NULL); // scan - lv_obj_set_width(headerButton(_header, "", onNearbySort, 46, &_nearby_sort_lbl), 62); // fits "Recent" / "Dist" - headerButton(_header, UI_SYMBOL_MAP, onOpenNodesMap, 114, NULL); // the Nodes map (the advert: the quick panel) - } - - // Type filter chips - _nearby_chips = lv_obj_create(body); - styleSurface(_nearby_chips, theme::BG); - lv_obj_remove_flag(_nearby_chips, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_size(_nearby_chips, LV_PCT(100), 26); - lv_obj_set_flex_flow(_nearby_chips, LV_FLEX_FLOW_ROW); - lv_obj_set_style_pad_column(_nearby_chips, 4, 0); - for (uint8_t f = 0; f < NearbyModel::F_COUNT; f++) { - lv_obj_t* c = lv_button_create(_nearby_chips); - lv_obj_set_height(c, 26); - lv_obj_set_flex_grow(c, 1); - lv_obj_set_style_pad_all(c, 0, 0); - lv_obj_set_style_radius(c, LV_RADIUS_CIRCLE, 0); - lv_obj_set_style_shadow_width(c, 0, 0); - bool on = f == _nearby->filter(); - lv_obj_add_event_cb(c, onNearbyChip, LV_EVENT_CLICKED, (void*)(uintptr_t)f); - lv_obj_center(label(c, NearbyModel::filterLabel(f), THEME_FONT_SMALL, theme::TEXT)); - styleSelected(c, on); // (Theme.h) - } - - _nearby_status = label(body, "", THEME_FONT_SMALL, theme::TEXT_MUTED); - - _nearby_list = scrollList(body); - vlist::attach(_nearby_list); - - _nearby_sig = 0; - refreshNearbyList(); -} - -void UITask::setNearbyFilter(uint8_t f) { - _nearby->setFilter(f); - buildNearby(); -} - -void UITask::toggleNearbySort() { - _nearby->setSortMode(_nearby->sortMode() == NearbyModel::SORT_DIST ? NearbyModel::SORT_TIME - : NearbyModel::SORT_DIST); - _nearby_sig = 0; - refreshNearbyList(); -} - -void UITask::startNearbyScan() { - _scanning = true; - _scan_until_ms = millis() + 8000; - _next_nearby_ms = millis() + 250; - the_mesh.sendNodeDiscoverReq(); - if (!_scan_overlay) showScanPopup(); - else { _scan_sig = 0; refreshScanPopup(); } -} - -// Dimmed full-screen overlay (swallows taps) holding a panel with the results. -// A child of the current screen, so it goes away with it. -void UITask::showScanPopup() { - lv_obj_t* panel = popupOpen(screen(), POP_FULL, _scan_overlay); - - lv_obj_t* hdr = lv_obj_create(panel); - styleSurface(hdr, theme::BG); - lv_obj_remove_flag(hdr, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_size(hdr, LV_PCT(100), 28); - lv_obj_align(label(hdr, "In range now", THEME_FONT_TITLE, theme::TEXT), LV_ALIGN_LEFT_MID, 0, 0); - headerButton(hdr, LV_SYMBOL_CLOSE, onScanClose, 0, NULL); - headerButton(hdr, LV_SYMBOL_REFRESH " Again", onNearbyScan, 44, NULL); - - _scan_status = label(panel, "", THEME_FONT_SMALL, theme::TEXT_MUTED); - _scan_list = scrollList(panel); - vlist::attach(_scan_list); - - _scan_sig = 0; - refreshScanPopup(); -} - -void UITask::closeScanPopup() { - if (_scan_overlay) lv_obj_delete_async(_scan_overlay); // may be closing from its own button - _scan_overlay = _scan_list = _scan_status = nullptr; - _scanning = false; -} - -void UITask::refreshScanPopup() { - if (!_scan_list) return; - _scan->refreshScan(); - int n = _scan->count(); - if (_scanning) setTextFmt(_scan_status, LV_SYMBOL_REFRESH " Listening for replies... %d", n); - else setTextFmt(_scan_status, "%d node%s answered the discover request", n, n == 1 ? "" : "s"); - - uint32_t sig = (uint32_t)n + (_scanning ? 0x10000u : 0); - for (int i = 0; i < n; i++) { - const NearbyModel::Entry& e = _scan->at(i); - sig = sig * 31 + e.rssi * 7 + e.snr_x4 + e.remote_snr_x4 * 3 + e.type * 5 + e.is_known; - for (int k = 0; k < 4; k++) sig = sig * 31 + e.pub_key[k]; - for (const char* p = e.name; *p; p++) sig = sig * 31 + (uint8_t)*p; - } - if (sig == _scan_sig || vlist::pressed(_scan_list)) return; // held: once it's let go - _scan_sig = sig; - - static const vlist::Kind ROW = { - theme::ROW_H, - [](lv_obj_t* list) { return vRowValue(list, onScanRow); }, - [](lv_obj_t* row, intptr_t i) { - const NearbyModel::Entry& e = s_ui->_scan->at((int)i); - char title[40], sub[48], right[12]; - if (e.name[0]) snprintf(title, sizeof(title), "%s", e.name); - else snprintf(title, sizeof(title), "%s %02X%02X%02X%02X", NearbyModel::typeName(e.type), - e.pub_key[0], e.pub_key[1], e.pub_key[2], e.pub_key[3]); - snprintf(sub, sizeof(sub), "%s - SNR %.1f / %.1f%s", NearbyModel::typeName(e.type), - e.snr_x4 / 4.0f, e.remote_snr_x4 / 4.0f, e.is_known ? "" : " - new"); - snprintf(right, sizeof(right), "%d dBm", e.rssi); - rowSet(row, title, sub); - rowValue(row, right, theme::TEXT_MUTED); - }, - }; - vlist::begin(_scan_list); - for (int i = 0; i < n; i++) vlist::add(_scan_list, &ROW, i); - if (n == 0) { - lv_obj_t* l = noteLabel(_scan_list, _scanning ? "Repeaters and rooms in range will answer." - : "Nobody answered. Try again later or move.", - THEME_FONT_BODY, theme::TEXT_MUTED); - lv_obj_set_style_pad_top(l, 8, 0); - vlist::own(_scan_list, l); - } - vlist::end(_scan_list); -} - -void UITask::openScanNode(int row) { - if (row < 0 || row >= _scan->count()) return; - s_node = _scan->at(row); - _node_from_scan = true; - _node_from_map = false; - _scanning = false; - _pinging = false; - _screen = SCR_NODE; - buildNode(); -} - -// Changes whenever a rebuild would show something different. -uint32_t UITask::nearbySignature() const { - uint32_t sig = (uint32_t)_nearby->count() * 2654435761u + _nearby->sortMode() + (_scanning ? 7 : 0) - + (_prefs && _prefs->units_imperial ? 13 : 0); - for (int i = 0; i < _nearby->count(); i++) { - const NearbyModel::Entry& e = _nearby->at(i); - sig = sig * 31 + e.contact_idx + (uint32_t)e.lastmod + (uint32_t)(e.dist_km * 100) + e.rssi + e.fav - + e.type * 5 + (e.is_live ? 3 : 0); - for (const char* p = e.name; *p; p++) sig = sig * 31 + (uint8_t)*p; - } - // Ages are shown in minutes, so let the list re-render once a minute anyway. - return sig + rtc_clock.getCurrentTime() / 60; -} - -// A Nearby row's texts: the name (a star on a favourite), what it is and how -// long ago it was heard, and on the right its distance (or the age). -struct NearbyText { char title[48], sub[48], right[16]; uint32_t right_col; }; -static void nearbyText(const NearbyModel::Entry& e, uint32_t now, bool imperial, NearbyText& t) { - const char* name = e.name[0] ? e.name : "(unknown)"; - snprintf(t.title, sizeof(t.title), "%s%s", e.fav ? UI_SYMBOL_STAR " " : "", name); - char age[8]; - geo::fmtAgeShort(age, sizeof(age), now, e.lastmod); - t.right[0] = 0; - if (e.dist_km >= 0.0f) geo::fmtDist(t.right, sizeof(t.right), e.dist_km, imperial); - else if (age[0]) snprintf(t.right, sizeof(t.right), "%s", age); - const char* kind = e.contact_idx >= 0 ? NearbyModel::typeName(e.type) : "not a contact"; - snprintf(t.sub, sizeof(t.sub), "%s%s%s%s", kind, e.is_live ? " - live" : "", - (e.dist_km >= 0.0f && age[0]) ? " - " : "", (e.dist_km >= 0.0f && age[0]) ? age : ""); - t.right_col = e.is_live ? theme::OK : theme::TEXT_MUTED; -} - -// A small arrow towards the node, drawn in the value label's left padding: -// its user data is the bearing + 1 (0: none). -static const int ROW_ARROW_W = 14; -static void onRowArrowDraw(lv_event_t* e) { - lv_obj_t* o = (lv_obj_t*)lv_event_get_target(e); - const int b = (int)(intptr_t)lv_obj_get_user_data(o) - 1; - if (b < 0) return; - lv_area_t a; - lv_obj_get_coords(o, &a); - const float cx = a.x1 + ROW_ARROW_W / 2 - 1, cy = (a.y1 + a.y2) / 2.0f; - const float ang = b * (float)M_PI / 180.0f, sx = sinf(ang), sy = -cosf(ang); - lv_draw_triangle_dsc_t tri; - lv_draw_triangle_dsc_init(&tri); - tri.color = lv_obj_get_style_text_color(o, LV_PART_MAIN); - tri.opa = LV_OPA_COVER; - tri.p[0].x = cx + sx * 6; tri.p[0].y = cy + sy * 6; // the tip - tri.p[1].x = cx - sx * 5 + sy * 4; tri.p[1].y = cy - sy * 5 - sx * 4; - tri.p[2].x = cx - sx * 5 - sy * 4; tri.p[2].y = cy - sy * 5 + sx * 4; - lv_draw_triangle(lv_event_get_layer(e), &tri); -} - -void UITask::refreshNearbyList() { - if (!_nearby_list) return; - _nearby->refreshModel(); - uint32_t sig = nearbySignature(); - int n = _nearby->count(); - - if (_nearby_sort_lbl) - setText(_nearby_sort_lbl, _nearby->sortMode() == NearbyModel::SORT_TIME ? "Recent" : "Dist"); - int32_t lat, lon; - bool gps = _nearby->ownPosition(lat, lon); - setTextFmt(_nearby_status, "%d node%s%s", n, n == 1 ? "" : "s", - gps ? "" : " - no GPS fix, distances unknown"); - if (sig == _nearby_sig || vlist::pressed(_nearby_list)) return; // held: once it's let go - _nearby_sig = sig; - - // The rows in view rewritten from the model (ages, distances, a new - // order); the list stays where it was scrolled. - static const vlist::Kind ROW = { - theme::ROW_H, - [](lv_obj_t* list) { - lv_obj_t* row = vRowValue(list, onNearbyRow); - lv_obj_add_event_cb(lv_obj_get_child(row, 2), onRowArrowDraw, LV_EVENT_DRAW_MAIN_END, NULL); - return row; - }, - [](lv_obj_t* row, intptr_t i) { - const NearbyModel::Entry& e = s_ui->_nearby->at((int)i); - NearbyText t; - nearbyText(e, rtc_clock.getCurrentTime(), s_ui->_prefs && s_ui->_prefs->units_imperial, t); - rowSet(row, t.title, t.sub, e.fav ? theme::ACCENT : theme::TEXT); - rowValue(row, t.right, t.right_col); - // By the distance, an arrow towards it - int b = -1; - int32_t lat, lon; - if (e.dist_km >= 0.0f && s_ui->_nearby->ownPosition(lat, lon)) b = geo::bearingDeg(lat, lon, e.lat_e6, e.lon_e6); - lv_obj_t* v = lv_obj_get_child(row, 2); - if ((intptr_t)lv_obj_get_user_data(v) != b + 1) { - lv_obj_set_user_data(v, (void*)(intptr_t)(b + 1)); - lv_obj_set_style_pad_left(v, b >= 0 ? ROW_ARROW_W : 0, 0); - lv_obj_invalidate(v); - } - }, - }; - vlist::begin(_nearby_list); - for (int i = 0; i < n; i++) vlist::add(_nearby_list, &ROW, i); - if (n == 0) { - lv_obj_t* l = label(_nearby_list, "Nobody here yet. Tap Scan to look around.", - THEME_FONT_BODY, theme::TEXT_MUTED); - lv_obj_set_style_pad_top(l, 12, 0); - vlist::own(_nearby_list, l); - } - vlist::end(_nearby_list); -} - -// ── Node detail ─────────────────────────────────────────────────────────────── - -void UITask::openNode(int row) { - if (row < 0 || row >= _nearby->count()) return; - s_node = _nearby->at(row); - _node_from_scan = false; - _node_from_map = false; - _pinging = false; - _screen = SCR_NODE; - buildNode(); -} - -static lv_obj_t* s_nd_fav = nullptr; -static void nodeFavText() { - if (s_nd_fav) lv_label_set_text(s_nd_fav, s_node.fav ? UI_SYMBOL_STAR " Remove from favourites" : UI_SYMBOL_STAR " Add to favourites"); -} - -static lv_obj_t* switchRow(lv_obj_t* parent, const char* text, const char* sub, uint8_t* pref); // below - -// One of the node's "Can ask for" switches; user data = its TELEM_PERM_* bit. -static void onNodeTelemSwitch(lv_event_t* e) { - ContactInfo* ci = the_mesh.lookupContactByPubKey(s_node.pub_key, PUB_KEY_SIZE); - if (!ci) return; - uint8_t bit = (uint8_t)(uintptr_t)lv_event_get_user_data(e); - uint8_t perms = contactctl::telemetry(*ci); - if (lv_obj_has_state((lv_obj_t*)lv_event_get_target(e), LV_STATE_CHECKED)) perms |= bit; else perms &= ~bit; - contactctl::setTelemetry(s_node.pub_key, perms); -} - -void UITask::buildNode() { - const NearbyModel::Entry& e = s_node; - lv_obj_t* body = newScreen(e.name[0] ? e.name : "(unknown)", true); - - lv_obj_t* info = scrollList(body); // the info, scrolling above the actions at the bottom - s_nd_fav = nullptr; - _node_path_lbl = nullptr; - nodeVizBuild(info); - nodeviz::s_bearing = nodeviz::s_hops = -2; // nothing drawn yet: the first set fills both - _node_info = infoCard(info); - s_nd_type = infoRow(_node_info, "Type", ""); - s_nd_status = infoRow(_node_info, "Status", ""); - s_nd_dist = infoRow(_node_info, "Distance", ""); - s_nd_pos = infoRow(_node_info, "Position", ""); - s_nd_heard = infoRow(_node_info, "Heard", ""); - s_nd_signal = infoRow(_node_info, "Signal", ""); - s_nd_id = infoRow(_node_info, "ID", ""); - _node_ping = label(info, "", THEME_FONT_BODY, theme::ACCENT); - - // The bar: what the node is for -- a message, its admin, adding it -- a - // ping and the way there. The rest, less often, in a group under the info. - lv_obj_t* acts = buttonBar(body); - bool contact = e.contact_idx >= 0; - bool admin = contact && (e.type == ADV_TYPE_REPEATER || e.type == ADV_TYPE_ROOM); - bool pos = e.lat_e6 != 0 || e.lon_e6 != 0; - if (contact && e.type == ADV_TYPE_CHAT) barButton(acts, LV_SYMBOL_ENVELOPE " Message", onNodeAction, NODE_MSG, true); - if (admin) barButton(acts, LV_SYMBOL_SETTINGS " Admin", onNodeAction, NODE_ADMIN, true); - if (!contact && e.has_key && !e.is_known) barButton(acts, LV_SYMBOL_PLUS " Add", onNodeAction, NODE_ADD, true); - if (e.has_key) barButton(acts, LV_SYMBOL_LOOP " Ping", onNodeAction, NODE_PING); - if (pos) barButton(acts, UI_SYMBOL_COMPASS " Navigate", onNodeAction, NODE_NAV); - - if (pos || contact) { - lv_obj_t* g = group(info, "More"); - if (pos) actionRow(g, UI_SYMBOL_FLAG " Save as waypoint", onNodeAction, (void*)(uintptr_t)NODE_WAYPOINT); - if (contact) s_nd_fav = actionRow(g, "", onNodeAction, (void*)(uintptr_t)NODE_FAV); - nodeFavText(); - if (contact && e.has_key) actionRow(g, UI_SYMBOL_TACK " Pin to Home", onNodeAction, (void*)(uintptr_t)NODE_PIN); - ContactInfo* ci = (contact && e.has_key) ? the_mesh.lookupContactByPubKey(e.pub_key, PUB_KEY_SIZE) : nullptr; - if (ci && contactctl::hasPath(*ci)) - _node_path_lbl = actionRow(g, LV_SYMBOL_REFRESH " Reset path", onNodeAction, (void*)(uintptr_t)NODE_RESET_PATH); - if (ci) actionRow(g, LV_SYMBOL_UPLOAD " Share with nodes in range", onNodeAction, (void*)(uintptr_t)NODE_SHARE); - if (contact) _node_delete_lbl = actionRow(g, LV_SYMBOL_TRASH " Delete contact", onNodeAction, - (void*)(uintptr_t)NODE_DELETE, theme::FAIL); - } - - // Where Settings > Privacy says "Allowed", what this contact may ask for. - ContactInfo* ci = (contact && e.has_key) ? the_mesh.lookupContactByPubKey(e.pub_key, PUB_KEY_SIZE) : nullptr; - if (ci && contactctl::telemetryPerContact(_prefs)) { - lv_obj_t* g = group(info, "Can ask for"); - static const struct { const char* text; const char* sub; uint8_t bit; } T[] = { - { "Status", "Battery; the two below need it", TELEM_PERM_BASE }, - { "Location", nullptr, TELEM_PERM_LOCATION }, - { "Sensors", nullptr, TELEM_PERM_ENVIRONMENT }, - }; - uint8_t perms = contactctl::telemetry(*ci); - for (const auto& t : T) { - lv_obj_t* sw = switchRow(g, t.text, t.sub, nullptr); - if (perms & t.bit) lv_obj_add_state(sw, LV_STATE_CHECKED); - lv_obj_add_event_cb(sw, onNodeTelemSwitch, LV_EVENT_VALUE_CHANGED, (void*)(uintptr_t)t.bit); - } - } - - refreshNode(); -} - -void UITask::refreshNode() { - if (!_node_info) return; - const NearbyModel::Entry& e = s_node; - // Refresh position / age / signal from its model while the node is still listed. - NearbyModel* model = _node_from_scan ? _scan : _nearby; - model->refreshModel(); - for (int i = 0; i < model->count(); i++) { - const NearbyModel::Entry& m = model->at(i); - bool same = e.has_key ? (m.has_key && memcmp(m.pub_key, e.pub_key, PUB_KEY_SIZE) == 0) - : (e.contact_idx >= 0) ? m.contact_idx == e.contact_idx - : strncmp(m.name, e.name, sizeof(m.name)) == 0; - if (same) { s_node = m; break; } - } - - // A scan row carries no contact index; is_known says whether it's in the contacts. - bool known = e.contact_idx >= 0 || (_node_from_scan && e.is_known); - char status[64] = "", dist[40] = "", pos[32] = "", heard[16] = "", sig[40] = "", id[12] = ""; - int o = 0; - auto tag = [&](const char* t) { o += snprintf(status + o, sizeof(status) - o, "%s%s", o ? ", " : "", t); }; - if (!known) tag("not a contact"); - if (e.is_live) tag(e.live_verified ? "live position" : "live position (channel)"); - if (e.fav) tag("favourite"); - if (status[0] >= 'a' && status[0] <= 'z') status[0] -= 'a' - 'A'; - int32_t lat, lon; - int bearing = -1; - if (e.lat_e6 != 0 || e.lon_e6 != 0) { - if (_nearby->ownPosition(lat, lon) && e.dist_km >= 0.0f) { - char d[16]; - geo::fmtDist(d, sizeof(d), e.dist_km, _prefs && _prefs->units_imperial); - int az = geo::bearingDeg(lat, lon, e.lat_e6, e.lon_e6); - bearing = az; - snprintf(dist, sizeof(dist), "%s %d\xC2\xB0 %s", d, az, geo::bearingCardinal(az)); - } - snprintf(pos, sizeof(pos), "%.5f, %.5f", e.lat_e6 / 1e6, e.lon_e6 / 1e6); - } else if (!_node_from_scan) { - snprintf(pos, sizeof(pos), "Not shared"); - } - char age[8]; - geo::fmtAgeShort(age, sizeof(age), rtc_clock.getCurrentTime(), e.lastmod); - if (age[0]) snprintf(heard, sizeof(heard), "%s ago", age); - if (_node_from_scan) - snprintf(sig, sizeof(sig), "%d dBm, SNR %.1f / %.1f", e.rssi, e.snr_x4 / 4.0f, e.remote_snr_x4 / 4.0f); - if (e.has_prefix) snprintf(id, sizeof(id), "%02X%02X%02X%02X", e.pub_key[0], e.pub_key[1], e.pub_key[2], e.pub_key[3]); - infoSet(s_nd_type, NearbyModel::typeName(e.type)); - infoSet(s_nd_status, status); - infoSet(s_nd_dist, dist); - infoSet(s_nd_pos, pos); - infoSet(s_nd_heard, heard); - infoSet(s_nd_signal, sig); - infoSet(s_nd_id, id); - ContactInfo ci; - int hops = -1; - if (!_node_from_scan && e.contact_idx >= 0 && the_mesh.getContactByIdx(e.contact_idx, ci)) - hops = ci.out_path_len == 0xFF ? 0xFF : (ci.out_path_len & 63); - nodeVizSet(bearing, hops); - - // Ping result (PingEngine releases its slot on reply; the view owns the timeout) - if (_pinging) { - int16_t out = 0, back = 0; uint32_t rtt = 0; - _core->ping.getResult(out, back, rtt); - if (!_core->ping.isActive() && (out || back || rtt)) { - setTextFmt(_node_ping, "Ping %lu ms - SNR out %.1f back %.1f", (unsigned long)rtt, - out / 4.0f, back / 4.0f); - _pinging = false; - } else if (millis() - _ping_started_ms > 3000) { - _core->ping.clear(); - setText(_node_ping, "Ping: no reply"); - _pinging = false; - } - } -} - -void UITask::nodeAction(uint8_t action) { - NearbyModel::Entry& e = s_node; - switch (action) { - case NODE_MSG: - openDM(e.pub_key); - break; - case NODE_PING: { - if (_pinging) break; - PingEngine::StartResult r = _core->ping.start(e.pub_key); - if (r == PingEngine::STARTED) { - _pinging = true; - _ping_started_ms = millis(); - _next_nearby_ms = millis() + 250; - lv_label_set_text(_node_ping, "Pinging..."); - } else { - lv_label_set_text(_node_ping, r == PingEngine::UNSUPPORTED ? "Ping needs 1-2 byte path hashes" - : "Ping failed"); - } - break; - } - case NODE_FAV: - if (contactctl::setFavourite(e.pub_key, !e.fav)) { // in place: the list stays where it was scrolled - e.fav = !e.fav; - nodeFavText(); - refreshNode(); - } - break; - case NODE_ADD: - if (the_mesh.addDiscoveredContact(e.pub_key, e.name, e.type)) { - _screen = SCR_NEARBY; - buildNearby(); - } else { - showToast("Contacts full"); - } - break; - case NODE_NAV: - // Followed by key when there is one (the Locator resolves live share, - // then advert position); otherwise a fixed point where it was seen. - if (e.has_prefix) navToNode(e.pub_key, e.lat_e6, e.lon_e6, e.name[0] ? e.name : "Node"); - else { - _core->locator.setTarget(0, nullptr, e.lat_e6, e.lon_e6, e.name[0] ? e.name : "Node"); - prefsSave(); - openMap(true); - navFrameTarget(); - } - break; - case NODE_ADMIN: - openAdmin(e.pub_key); - break; - case NODE_WAYPOINT: { - if (_core->waypoints.full()) { showToast(waypointsFull()); break; } - char t[48]; - if (_core->waypoints.add(e.lat_e6, e.lon_e6, rtc_clock.getCurrentTime(), e.name[0] ? e.name : "Node")) { - snprintf(t, sizeof(t), "Saved %s", _core->waypoints.at(_core->waypoints.count() - 1).label); - showToast(t); - } - break; - } - case NODE_PIN: // to the favourites dial (Home), as from a chat's options - pinPopup(false, 0, e.pub_key); - break; - case NODE_RESET_PATH: // the row goes, the chain above turns to flood - if (contactctl::resetPath(e.pub_key) && _node_path_lbl) { - lv_obj_add_flag(lv_obj_get_parent(lv_obj_get_parent(_node_path_lbl)), LV_OBJ_FLAG_HIDDEN); // label < text box < row - _node_path_lbl = nullptr; - refreshNode(); - } - break; - case NODE_SHARE: - showToast(contactctl::share(e.pub_key) ? "Sent to nodes in range" : "No advert saved to send"); - break; - case NODE_DELETE: - if (!tapConfirmed(_node_delete_lbl, LV_SYMBOL_TRASH " Tap again to delete")) break; - if (the_mesh.deleteContactByKey(e.pub_key)) { - _screen = SCR_NEARBY; - buildNearby(); - } - break; - } -} +#include "NodesView.h" #include "MapScreen.h" #include "Chart.h" @@ -3604,1367 +1208,9 @@ static void onOpenSchemaPage(lv_event_t* e); // Radio > Advanced #include "AdminScreen.h" #include "BotScreen.h" -// ── Conversation ────────────────────────────────────────────────────────────── +#include "ThreadView.h" -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) s_ui->setKeyboardVisible(false); -} - -// What a message may take is counted in UTF-8 bytes (the over-the-air limit), -// not characters: Cyrillic / Greek / accented letters take two bytes each. The -// limit is what the send leaves of it -- a channel post carries this node's -// name -- and a placeholder counts as what it expands to (MessageText.h). -int UITask::composeLimit() const { return msgtext::limit(_thread_is_channel, _prefs); } -int UITask::composeLen(const char* typed) const { return msgtext::sentLen(typed, _prefs); } - -static lv_obj_t* s_compose_left = nullptr; // the bytes left, a tag over the field's right end - -// Only once the end is near (40 bytes, as on the other screens): a tag sitting -// on the field's top edge, so the field keeps its width and its text. -static void composeLeftRefresh(lv_obj_t* ta) { - if (!s_compose_left || !ta) return; - const int left = s_ui->composeLimit() - s_ui->composeLen(lv_textarea_get_text(ta)); - if (left > 40) { lv_obj_add_flag(s_compose_left, LV_OBJ_FLAG_HIDDEN); return; } - char t[8]; - snprintf(t, sizeof(t), "%d", left); - setText(s_compose_left, t); - setTextColor(s_compose_left, left < 0 ? theme::FAIL : left <= 20 ? theme::ACCENT : theme::TEXT_MUTED); - lv_obj_remove_flag(s_compose_left, LV_OBJ_FLAG_HIDDEN); - lv_obj_update_layout(s_compose_left); - lv_obj_align_to(s_compose_left, ta, LV_ALIGN_OUT_TOP_RIGHT, -8, lv_obj_get_height(s_compose_left) / 2); -} - -static void onComposeInsert(lv_event_t* e) { - lv_obj_t* ta = (lv_obj_t*)lv_event_get_target(e); - const char* ins = (const char*)lv_event_get_param(e); - if (!ins) return; - char joined[2 * MAX_TEXT_LEN + 1]; // the text with the insert (at the end: the length is the same) - snprintf(joined, sizeof(joined), "%s%s", lv_textarea_get_text(ta), ins); - if (s_ui->composeLen(joined) > s_ui->composeLimit()) - lv_textarea_set_insert_replace(ta, ""); // reject: would exceed the byte limit -} - -static void onComposeChanged(lv_event_t* e) { composeLeftRefresh((lv_obj_t*)lv_event_get_target(e)); } - -static void onComposeClicked(lv_event_t* e) { (void)e; s_ui->setKeyboardVisible(true); } -static void onComposeMore(lv_event_t* e) { (void)e; s_ui->quickPopup(); } - -// 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); - fieldFocus(_compose_ta, true); - } else { - lv_obj_add_flag(_keyboard, LV_OBJ_FLAG_HIDDEN); - fieldFocus(_compose_ta, false); - } - 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 - layoutNow(_thread_list); - lv_obj_scroll_to_y(_thread_list, LV_COORD_MAX, LV_ANIM_OFF); - } -} - -static const int THREAD_MAX_SHOWN = 50; // newest bubbles built per conversation - -// Opens on the first unread (newest-first index `first`, -1: none), "New" -// above it: the page that holds it, a few read ones above. -void UITask::threadOpenAt(int first) { - _thread_new = first; - _thread_new_on = false; - _thread_skip = first < THREAD_MAX_SHOWN ? 0 : first - (THREAD_MAX_SHOWN - 4); -} - -// Back on over a conversation: what came in while asleep opens it again on -// its first unread (the typed text kept, the keyboard down); else just the -// fresh content. -void UITask::reopenThreadIfUnread() { - bool unread = _thread_is_channel ? _core->history.chUnread(_thread_channel) > 0 : _core->dmUnread(_thread_key) > 0; - if (!unread || _nav_overlay) { refreshThread(); return; } - char typed[MSG_TEXT_BUF] = ""; - if (_compose_ta) snprintf(typed, sizeof(typed), "%s", lv_textarea_get_text(_compose_ta)); - uint8_t key[PUB_KEY_SIZE]; // openDM() clears _thread_key before reading its argument - memcpy(key, _thread_key, sizeof(key)); - if (_thread_is_channel) openChannel(_thread_channel); - else openDM(key); - if (_compose_ta && typed[0]) { lv_textarea_set_text(_compose_ta, typed); composeLeftRefresh(_compose_ta); } -} - -void UITask::openChannel(uint8_t channel_idx) { - _thread_is_channel = true; - _thread_channel = channel_idx; - MessageHistory& h = _core->history; - threadOpenAt(h.firstUnreadChannel(channel_idx, h.chUnread(channel_idx))); - h.setChUnread(channel_idx, 0); - _screen = SCR_THREAD; - buildThread(); -} - -void UITask::openDM(const uint8_t* pub_key) { - _thread_is_channel = false; - memset(_thread_key, 0, sizeof(_thread_key)); - ContactInfo c; - if (MessageHistory::contactByPrefix(pub_key, c)) memcpy(_thread_key, c.id.pub_key, PUB_KEY_SIZE); - else memcpy(_thread_key, pub_key, 4); - threadOpenAt(_core->history.firstUnreadDm(_thread_key, _core->dmUnread(_thread_key))); - _core->clearDMUnread(_thread_key); - _screen = SCR_THREAD; - buildThread(); -} - -void UITask::buildThread() { - char title[40]; - bool can_send = true; - if (_thread_is_channel) { - ChannelDetails ch; - if (the_mesh.getChannel(_thread_channel, ch)) snprintf(title, sizeof(title), "%s", ch.name); // as named: "#" marks a hashtag channel - else snprintf(title, sizeof(title), "Channel %d", _thread_channel); - } else { - contactName(_thread_key, title, sizeof(title)); - ContactInfo c; - can_send = MessageHistory::contactByPrefix(_thread_key, c) && (c.type == ADV_TYPE_CHAT || c.type == ADV_TYPE_ROOM); - } - lv_obj_t* body = newScreen(title, true); - lv_obj_set_style_pad_all(body, 0, 0); - lv_obj_set_style_pad_row(body, 0, 0); - if (_header && (_thread_is_channel || can_send)) { // channel / conversation options - lv_obj_set_width(lv_obj_get_child(_header, 1), 200); // title, clear of the button - headerButton(_header, LV_SYMBOL_SETTINGS, _thread_is_channel ? onChanThreadMenu : onConvThreadMenu, 4, NULL); - } - - _thread_list = scrollList(body); - s_th_rows_n = 0; - lv_obj_set_style_pad_all(_thread_list, theme::PAD, 0); - - _compose_ta = nullptr; - _keyboard = nullptr; - s_compose_left = nullptr; - if (can_send) { - 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), 42); - lv_obj_set_style_pad_all(bar, 4, 0); - lv_obj_t* more = lv_button_create(bar); // quick messages, placeholders - lv_obj_set_size(more, 34, 34); - lv_obj_align(more, LV_ALIGN_LEFT_MID, 0, 0); - lv_obj_set_style_pad_all(more, 0, 0); - lv_obj_set_style_radius(more, theme::RADIUS, 0); - lv_obj_set_style_shadow_width(more, 0, 0); - lv_obj_set_style_bg_color(more, lv_color_hex(theme::SURFACE), 0); - lv_obj_add_event_cb(more, onComposeMore, LV_EVENT_CLICKED, NULL); - lv_obj_center(label(more, LV_SYMBOL_PLUS, THEME_FONT_BODY, theme::TEXT)); - _compose_ta = textField(bar, "Message"); // FOCUSED while the keyboard is up - lv_obj_add_event_cb(_compose_ta, onComposeInsert, LV_EVENT_INSERT, NULL); - lv_obj_add_event_cb(_compose_ta, onComposeChanged, LV_EVENT_VALUE_CHANGED, NULL); - lv_obj_set_width(_compose_ta, lv_display_get_horizontal_resolution(NULL) - 8 - 34 - 4); // beside "+" - lv_obj_align(_compose_ta, LV_ALIGN_RIGHT_MID, 0, 0); - lv_obj_add_flag(bar, LV_OBJ_FLAG_OVERFLOW_VISIBLE); // the count's tag rises above the bar - s_compose_left = label(bar, "", THEME_FONT_SMALL, theme::TEXT_MUTED); // bytes left - lv_obj_set_style_bg_color(s_compose_left, lv_color_hex(theme::BG), 0); - lv_obj_set_style_bg_opa(s_compose_left, LV_OPA_COVER, 0); - lv_obj_set_style_pad_hor(s_compose_left, 4, 0); - lv_obj_set_style_radius(s_compose_left, theme::RADIUS, 0); - lv_obj_add_flag(s_compose_left, LV_OBJ_FLAG_HIDDEN); - composeLeftRefresh(_compose_ta); - - // 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), 124); - lv_keyboard_set_textarea(_keyboard, _compose_ta); - fieldFocus(_compose_ta, false); // until the keyboard is up - 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_flag(_keyboard, LV_OBJ_FLAG_HIDDEN); - lv_obj_add_event_cb(_compose_ta, onComposeClicked, LV_EVENT_CLICKED, _keyboard); - } - refreshThread(); - if (_compose_ta && _share_text[0]) { // shareToMessage(): the text waits in the field - lv_textarea_set_text(_compose_ta, _share_text); - _share_text[0] = '\0'; - layoutNow(screen()); - setKeyboardVisible(true); - } -} - -// Changes whenever the open conversation's content or delivery markers change. -uint32_t UITask::threadSignature() const { - const MessageHistory& h = _core->history; - uint32_t sig = 0; - if (_thread_is_channel) { - int n = h.histCountForChannel(_thread_channel); - sig = n; - for (int j = 0; j < n && j < 8; j++) { - const ChHistEntry& e = h.chAtPos(h.histEntryForChannel(_thread_channel, j)); - sig = sig * 31 + e.timestamp + e.relay_status + e.path_len; // path_len: repeaters heard - } - } else { - int n = h.dmHistCountForContact(_thread_key); - sig = n; - for (int j = 0; j < n && j < 8; j++) { - const DmHistEntry& e = h.dmAtPos(h.dmHistEntryForContact(_thread_key, j)); - sig = sig * 31 + e.timestamp + h.dmEffectiveStatus(e) + e.attempt * 7; // attempt: a dot per resend - } - } - return sig; -} - -// Positions found in the shown messages ([WAY] / [LOC] / plain "lat,lon"), -// for the Go / Save buttons under such a bubble. -struct MsgLoc { int32_t lat, lon; char label[WAYPOINT_LABEL_LEN * 2]; }; -static MsgLoc* s_msg_locs = psramBuf(THREAD_MAX_SHOWN); -static int s_msg_loc_n = 0; - -// The messages shown, oldest first: copies from the SD card's history (which -// holds far more than the RAM ring) or from the ring, one page at a time. -static ChHistEntry* s_th_ch = psramBuf(THREAD_MAX_SHOWN); -static DmHistEntry* s_th_dm = psramBuf(THREAD_MAX_SHOWN); - -// What a held bubble is about: its entry (index into s_th_ch / s_th_dm), -// sender, position slot. One per shown message, at its index. -struct MsgMeta { int pos; bool channel; bool own; int loc; char from[32]; }; -static MsgMeta* s_msg_meta = psramBuf(THREAD_MAX_SHOWN); -static int s_msg_meta_n = 0; - -// The bubbles built, one per shown message: which message (key), its -// delivery state, its row and age label. A refresh that only adds messages -// or changes a delivery mark touches just those bubbles -- rebuilding all 50 -// on every repeater echo made the conversation stutter. -struct ThreadRow { uint32_t key, state; lv_obj_t* row; lv_obj_t* age; }; -static ThreadRow* s_th_rows = psramBuf(THREAD_MAX_SHOWN); -static lv_obj_t* s_older_lbl = nullptr; // "Older messages (n)" on the page's top -static bool s_th_rows_newest = false; // built for the newest page (the one patched) -static lv_obj_t* s_new_div = nullptr; // "New" above the first unread - -// The line with "New" over the first message unread when the thread opened. -static lv_obj_t* newDivider(lv_obj_t* list) { - lv_obj_t* d = lv_obj_create(list); - lv_obj_remove_style_all(d); - lv_obj_set_size(d, LV_PCT(100), LV_SIZE_CONTENT); - lv_obj_set_flex_flow(d, LV_FLEX_FLOW_ROW); - lv_obj_set_flex_align(d, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_set_style_pad_column(d, 6, 0); - lv_obj_remove_flag(d, LV_OBJ_FLAG_CLICKABLE); - for (int i = 0; i < 3; i++) { - if (i == 1) { label(d, "New", THEME_FONT_SMALL, theme::ACCENT); continue; } - lv_obj_t* l = lv_obj_create(d); - lv_obj_remove_style_all(l); - lv_obj_set_height(l, 1); - lv_obj_set_flex_grow(l, 1); - lv_obj_set_style_bg_color(l, lv_color_hex(theme::ACCENT), 0); - lv_obj_set_style_bg_opa(l, LV_OPA_COVER, 0); - } - return d; -} - -// The shown message a bubble (or a button in it) belongs to, or -1. -static int threadRowOf(lv_obj_t* o) { - for (; o; o = lv_obj_get_parent(o)) - for (int r = 0; r < s_th_rows_n; r++) if (s_th_rows[r].row == o) return r; - return -1; -} - -static void onMsgLoc(lv_event_t* e) { - int r = threadRowOf((lv_obj_t*)lv_event_get_current_target(e)); - if (r >= 0) s_ui->messageLocationAction(s_msg_meta[r].loc, (uintptr_t)lv_event_get_user_data(e) != 0); -} - -static void msgLocButton(lv_obj_t* parent, const char* text, bool save, bool accent, bool own) { - lv_obj_t* b = lv_button_create(parent); - lv_obj_set_size(b, LV_SIZE_CONTENT, 28); - lv_obj_set_style_pad_hor(b, 10, 0); - lv_obj_set_style_pad_ver(b, 0, 0); - lv_obj_set_style_radius(b, LV_RADIUS_CIRCLE, 0); - lv_obj_set_style_shadow_width(b, 0, 0); - // on our own amber bubble the main one goes dark instead - lv_obj_set_style_bg_color(b, lv_color_hex(accent ? (own ? theme::BG : theme::ACCENT) : theme::SURFACE_2), 0); - lv_obj_add_event_cb(b, onMsgLoc, LV_EVENT_CLICKED, (void*)(uintptr_t)(save ? 1 : 0)); - lv_obj_center(label(b, text, THEME_FONT_SMALL, accent ? (own ? theme::ACCENT : theme::BG) : theme::TEXT)); -} - -// A message's text, wrapped at `max_w` and shrunk to fit. "@[nick]" mentions -// (how a reply names who it answers) show as "@nick" in the accent -- dark, -// as the rest, on our own amber bubbles -- and underlined when the nick is -// ours (*mentions_me set). Plain text stays a label. -static lv_obj_t* msgText(lv_obj_t* parent, const char* text, bool own, int max_w, bool* mentions_me) { - *mentions_me = false; - const uint32_t col = own ? theme::BG : theme::TEXT; - if (!strstr(text, "@[")) { - lv_obj_t* t = label(parent, text, THEME_FONT_BODY, col); - lv_label_set_long_mode(t, LV_LABEL_LONG_WRAP); - lv_obj_set_style_max_width(t, max_w, 0); - lv_obj_set_width(t, LV_SIZE_CONTENT); - return t; - } - lv_obj_t* sg = lv_spangroup_create(parent); - lv_obj_remove_flag(sg, LV_OBJ_FLAG_CLICKABLE); // a hold still reaches the bubble (its menu) - lv_obj_set_style_text_font(sg, THEME_FONT_BODY, 0); - lv_obj_set_style_text_color(sg, lv_color_hex(col), 0); - lv_spangroup_set_mode(sg, LV_SPAN_MODE_BREAK); - const char* me = the_mesh.getNodeName(); - char part[MAX_TEXT_LEN + 1]; - const char* p = text; - while (*p) { - const char* at = strstr(p, "@["); - const char* close = at ? strchr(at + 2, ']') : nullptr; - if (at && (!close || close - at > 34)) close = nullptr; // not a mention: a nick is <= 31 chars - const char* end = at && close ? at : p + strlen(p); - if (at && !close) end = at + 2; // "@[" alone: plain, carry on after it - if (end > p) { - size_t n = end - p < (ptrdiff_t)sizeof(part) ? end - p : sizeof(part) - 1; - memcpy(part, p, n); - part[n] = '\0'; - lv_span_set_text(lv_spangroup_new_span(sg), part); - } - if (!at || !close) { p = end; continue; } - size_t n = close - (at + 2); - part[0] = '@'; - memcpy(part + 1, at + 2, n); - part[n + 1] = '\0'; - bool mine = strlen(me) == n && strncasecmp(me, at + 2, n) == 0; - lv_span_t* sp = lv_spangroup_new_span(sg); - lv_span_set_text(sp, part); - lv_style_t* st = lv_span_get_style(sp); - lv_style_set_text_color(st, lv_color_hex(own ? theme::BG : theme::ACCENT)); - if (mine) { lv_style_set_text_decor(st, LV_TEXT_DECOR_UNDERLINE); *mentions_me = true; } - p = close + 1; - } - lv_spangroup_refr_mode(sg); - uint32_t w = lv_spangroup_get_expand_width(sg, 0); - lv_obj_set_width(sg, w > (uint32_t)max_w ? max_w : (int32_t)w + 1); - lv_obj_set_height(sg, LV_SIZE_CONTENT); - return sg; -} - -// One message bubble. Own messages right-aligned in amber with dark text -// (the delivery mark too: green or red on amber can't be read), others left. -// loc: the text carries a position (Go / Save buttons); hold: a long press -// opens the message menu. Returns its row; *age_out = the age label. -static lv_obj_t* bubble(lv_obj_t* list, const char* from, const char* text, bool own, - uint32_t ts, const char* status, uint32_t status_col, bool loc, bool hold, - int relays, lv_obj_t** age_out) { - // Full-width row that pushes the bubble to its side. - lv_obj_t* row = lv_obj_create(list); - styleSurface(row, theme::BG); - lv_obj_set_style_bg_opa(row, LV_OPA_TRANSP, 0); - lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_remove_flag(row, LV_OBJ_FLAG_CLICKABLE); - lv_obj_set_size(row, LV_PCT(100), LV_SIZE_CONTENT); - lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW); - lv_obj_set_flex_align(row, own ? LV_FLEX_ALIGN_END : LV_FLEX_ALIGN_START, - LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); - - lv_obj_t* b = lv_obj_create(row); - lv_obj_remove_flag(b, LV_OBJ_FLAG_SCROLLABLE); - if (hold) { // hold: reply / path / target (ConversationScreen.h) - lv_obj_add_event_cb(b, onMsgHold, LV_EVENT_LONG_PRESSED, nullptr); - lv_obj_set_style_bg_color(b, lv_color_hex(own ? theme::mix(theme::ACCENT, theme::BG, 75) : theme::SURFACE_2), LV_STATE_PRESSED); - } else { - lv_obj_remove_flag(b, LV_OBJ_FLAG_CLICKABLE); - } - lv_obj_set_size(b, LV_SIZE_CONTENT, LV_SIZE_CONTENT); - lv_obj_set_style_max_width(b, 250, 0); - lv_obj_set_style_bg_color(b, lv_color_hex(own ? theme::ACCENT : theme::SURFACE), 0); - const uint32_t muted = own ? theme::mix(theme::BG, theme::ACCENT, 60) : theme::TEXT_MUTED; - lv_obj_set_style_border_width(b, 0, 0); - lv_obj_set_style_radius(b, theme::RADIUS, 0); - lv_obj_set_style_pad_all(b, 6, 0); - lv_obj_set_style_pad_row(b, 2, 0); - lv_obj_set_flex_flow(b, LV_FLEX_FLOW_COLUMN); - - char meta[32]; - uint32_t now = rtc_clock.getCurrentTime(); - fmtMsgAge(meta, sizeof(meta), now, ts, s_prefs); // "12s" / "5m" / "3h" / "2d" - - // 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 = flexBox(b, LV_FLEX_FLOW_ROW); - lv_obj_set_style_pad_column(hdr, 8, 0); - label(hdr, from, THEME_FONT_SMALL, own ? theme::BG : theme::ACCENT); // names are <= 31 chars: fits the bubble - if (meta_in_header) *age_out = label(hdr, meta, THEME_FONT_SMALL, muted); - } - bool mentions_me; - msgText(b, text, own, 238, &mentions_me); - if (mentions_me && !own) { // someone answering us: the bubble outlined - lv_obj_set_style_border_width(b, 1, 0); - lv_obj_set_style_border_color(b, lv_color_hex(theme::ACCENT), 0); - } - - if (loc) { // position: navigate there / keep it as a waypoint - lv_obj_t* acts = flexBox(b, LV_FLEX_FLOW_ROW); - lv_obj_set_style_pad_column(acts, 6, 0); - lv_obj_set_style_pad_top(acts, 2, 0); - msgLocButton(acts, UI_SYMBOL_COMPASS " Go", false, true, own); - msgLocButton(acts, UI_SYMBOL_FLAG " Save", true, false, own); - } - - if (!meta_in_header) { // the age, then the delivery mark in its colour (as L1) - lv_obj_t* line = flexBox(b, LV_FLEX_FLOW_ROW); - lv_obj_set_flex_align(line, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_set_style_pad_column(line, 6, 0); - *age_out = label(line, meta, THEME_FONT_SMALL, muted); - if (relays > 0) { // as L1: the count alone says it got out, no check beside it - lv_obj_t* c = lv_obj_create(line); - lv_obj_remove_style_all(c); - lv_obj_remove_flag(c, LV_OBJ_FLAG_CLICKABLE); - lv_obj_set_size(c, LV_SIZE_CONTENT, 15); - lv_obj_set_style_min_width(c, 15, 0); - lv_obj_set_style_pad_hor(c, 4, 0); - lv_obj_set_style_bg_opa(c, LV_OPA_COVER, 0); - lv_obj_set_style_bg_color(c, lv_color_hex(theme::OK), 0); - lv_obj_set_style_radius(c, LV_RADIUS_CIRCLE, 0); - lv_obj_t* n = label(c, "", THEME_FONT_SMALL, theme::BG); - lv_label_set_text_fmt(n, "%d", relays); - lv_obj_center(n); - } else if (status && status[0]) { - label(line, status, THEME_FONT_SMALL, own ? theme::BG : status_col); - } - } - return row; -} - -// Remember the position in `text` (if any) for its bubble's buttons, in -// slot r; label from a [WAY] tag, else the sender. Returns r or -1. -static int noteMsgLocation(int r, const char* text, const char* sender) { - MsgLoc& m = s_msg_locs[r]; - if (!geo::parseLatLon(text, m.lat, m.lon, m.label, sizeof(m.label))) return -1; - if (!m.label[0]) snprintf(m.label, sizeof(m.label), "%s", sender && sender[0] ? sender : "Msg loc"); - return r; -} - -void UITask::messageLocationAction(int idx, bool save) { - if (idx < 0 || idx >= s_msg_loc_n) return; - const MsgLoc& m = s_msg_locs[idx]; - if (save) { - if (_core->waypoints.full()) { showToast(waypointsFull()); return; } - if (_core->waypoints.add(m.lat, m.lon, rtc_clock.getCurrentTime(), m.label)) { - char t[48]; - snprintf(t, sizeof(t), "Saved %s", _core->waypoints.at(_core->waypoints.count() - 1).label); - showToast(t); - } - return; - } - // A place snapshotted from the text (kind 0): nothing to keep re-resolving. - navSetTarget(0, nullptr, m.lat, m.lon, m.label); - openMap(true); - navFrameTarget(); - refreshNavBar(); -} - -// The page of the open conversation into s_th_ch / s_th_dm (oldest first): -// THREAD_MAX_SHOWN messages ending _thread_skip before the newest. From the -// SD card's copy when it has at least what the ring has, else from the ring. -// `total` = messages in that source. -int UITask::loadThreadPage(int& total) { - const MessageHistory& h = _core->history; - char path[64]; - if (_thread_is_channel) { - int ring = h.histCountForChannel(_thread_channel); - int arc = 0; - int got = s_archive.ready() && s_archive.chPath(_thread_channel, path, sizeof(path)) - ? s_archive.window(path, _thread_skip, THREAD_MAX_SHOWN, s_th_ch, &arc) : 0; - if (arc > 0 && arc >= ring) { - total = arc; - return got; - } - total = ring; - int m = ring - _thread_skip; - if (m > THREAD_MAX_SHOWN) m = THREAD_MAX_SHOWN; - for (int i = 0; i < m; i++) s_th_ch[i] = h.chAtPos(h.histEntryForChannel(_thread_channel, _thread_skip + m - 1 - i)); - return m > 0 ? m : 0; - } - int ring = h.dmHistCountForContact(_thread_key); - int arc = 0, got = 0; - if (s_archive.ready()) { - histstore::SdArchive::dmPath(_thread_key, path, sizeof(path)); - got = s_archive.window(path, _thread_skip, THREAD_MAX_SHOWN, s_th_dm, &arc); - } - if (arc > 0 && arc >= ring) { - total = arc; - return got; - } - total = ring; - int m = ring - _thread_skip; - if (m > THREAD_MAX_SHOWN) m = THREAD_MAX_SHOWN; - for (int i = 0; i < m; i++) s_th_dm[i] = h.dmAtPos(h.dmHistEntryForContact(_thread_key, _thread_skip + m - 1 - i)); - return m > 0 ? m : 0; -} - -static void onThreadPage(lv_event_t* e) { s_ui->threadPage((int)(intptr_t)lv_event_get_user_data(e)); } - -// "Older messages" / "Newer messages" at the ends of a page. -static void pageButton(lv_obj_t* list, const char* text, int dir) { - lv_obj_t* b = lv_button_create(list); - lv_obj_set_size(b, LV_PCT(100), 32); - lv_obj_set_style_bg_color(b, lv_color_hex(theme::SURFACE), 0); - lv_obj_add_event_cb(b, onThreadPage, LV_EVENT_CLICKED, (void*)(intptr_t)dir); - lv_obj_center(label(b, text, THEME_FONT_SMALL, theme::TEXT_MUTED)); -} - -void UITask::threadPage(int dir) { - _thread_skip += dir > 0 ? THREAD_MAX_SHOWN : -THREAD_MAX_SHOWN; - if (_thread_skip < 0) _thread_skip = 0; - _thread_scroll_top = dir < 0; // newer: read on from the top of that page - refreshThread(); -} - -// Every 2 s on a conversation, as ui-new: the bubbles' ages ("12s", "14s"). -void UITask::refreshThreadAges() { - _next_thread_ages_ms = millis() + 2000; - uint32_t now = rtc_clock.getCurrentTime(); - for (int r = 0; r < s_th_rows_n; r++) { - if (!s_th_rows[r].age) continue; - char meta[32]; - fmtMsgAge(meta, sizeof(meta), now, _thread_is_channel ? s_th_ch[r].timestamp : s_th_dm[r].timestamp, _prefs); - setText(s_th_rows[r].age, meta); - } -} - -void UITask::refreshThread() { - if (!_thread_list) return; - _thread_dirty = false; - _thread_sig = threadSignature(); - _next_thread_ages_ms = millis() + 2000; // the ages are written below - const MessageHistory& h = _core->history; - int total = 0; - int n = loadThreadPage(total); - if (n == 0 && _thread_skip > 0) { // the page went away (history trimmed) - _thread_skip = 0; - n = loadThreadPage(total); - } - ContactInfo tc; - bool room = !_thread_is_channel && MessageHistory::contactByPrefix(_thread_key, tc) && tc.type == ADV_TYPE_ROOM; - - // Which message entry i is, and the delivery state its bubble shows. - auto keyOf = [&](int i) { - uint32_t ts = _thread_is_channel ? s_th_ch[i].timestamp : s_th_dm[i].timestamp; - const char* t = _thread_is_channel ? s_th_ch[i].text : s_th_dm[i].text; - uint32_t k = 2166136261u ^ ts; - for (; *t; t++) k = (k ^ (uint8_t)*t) * 16777619u; - return k; - }; - auto stateOf = [&](int i) -> uint32_t { - if (_thread_is_channel) return s_th_ch[i].relay_status | (uint32_t)s_th_ch[i].path_len << 8; - return s_th_dm[i].outgoing ? 0x100u | h.dmEffectiveStatus(s_th_dm[i]) | (uint32_t)s_th_dm[i].attempt << 16 : 0; - }; - // Entry i's bubble (at the list's end) and its menu / position slots. - auto build = [&](int i) { - MsgMeta& m = s_msg_meta[i]; - const char* st = NULL; uint32_t col = theme::TEXT_MUTED; - int nrel = 0; - lv_obj_t* age = nullptr; - lv_obj_t* row; - if (_thread_is_channel) { - const ChHistEntry& e = s_th_ch[i]; - // Channel text is "Sender: body"; our own posts are filed as "Me: body". - char from[40] = ""; - const char* body = e.text; - const char* sep = strstr(e.text, ": "); - if (sep && sep - e.text < (int)sizeof(from)) { - memcpy(from, e.text, sep - e.text); - from[sep - e.text] = '\0'; - body = sep + 2; - } - bool own = strcmp(from, "Me") == 0; - // Own posts: once a repeater echoed it, how many distinct repeaters did - // (markChannelRelayed), or a check if that's unknown; nothing before -- - // no echo is normal. - if (own && e.relay_status == ACK_OK) { - nrel = e.path_len & 63; - st = LV_SYMBOL_OK; col = theme::OK; - } else if (own) st = ""; - int loc = own ? -1 : noteMsgLocation(i, body, from); - m = { i, true, own, loc, "" }; - if (!own) snprintf(m.from, sizeof(m.from), "%s", from); - row = bubble(_thread_list, own ? NULL : from, body, own, e.timestamp, st, col, loc >= 0, true, nrel, &age); - } else { - const DmHistEntry& e = s_th_dm[i]; - if (e.outgoing) { - switch (h.dmEffectiveStatus(e)) { - case ACK_OK: st = LV_SYMBOL_OK; col = theme::OK; break; - case ACK_FAIL: st = LV_SYMBOL_CLOSE; col = theme::FAIL; break; - case ACK_PENDING: { // a dot per send, as ui-new: grows with each auto-resend - static char dots[9]; - int k = e.attempt + 1 < 8 ? e.attempt + 1 : 8; - memset(dots, '.', k); dots[k] = '\0'; - st = dots; - break; - } - default: st = ""; break; - } - } - // A room post is filed "Author: text": shown under its author. - char who[33] = ""; - const char* text = e.text; - if (!e.outgoing && room) text = contactctl::splitRoomPost(e.text, who, sizeof(who)); - else if (!e.outgoing) contactName(_thread_key, who, sizeof(who)); - int loc = e.outgoing ? -1 : noteMsgLocation(i, text, who); - m = { i, false, false, loc, "" }; - snprintf(m.from, sizeof(m.from), "%s", who); - row = bubble(_thread_list, room && !e.outgoing ? who : NULL, text, e.outgoing, e.timestamp, st, col, - loc >= 0, !e.outgoing, 0, &age); // nothing to show for our own DM - } - s_th_rows[i] = { keyOf(i), stateOf(i), row, age }; - }; - auto olderText = [&](char* t, int sz) { snprintf(t, sz, LV_SYMBOL_UP " Older messages (%d)", total - _thread_skip - n); }; - - // The same page as built, with messages added at the end (the oldest - // dropping off the top) and delivery marks changed: patch it. - int kept = -1; // bubbles still shown, after `drop` gone from the top - int drop = 0; - if (s_th_rows_newest && s_th_rows_n > 0 && n > 0 && _thread_skip == 0 && (total > n) == (s_older_lbl != nullptr)) { - for (drop = 0; drop < s_th_rows_n && kept < 0; drop++) { - int m = s_th_rows_n - drop; - if (m > n) continue; - bool same = true; - for (int r = 0; r < m && same; r++) same = s_th_rows[drop + r].key == keyOf(r); - if (same) kept = m; - } - drop--; - } - if (kept > 0) { - bool at_end = lv_obj_get_scroll_bottom(_thread_list) <= theme::PAD; // not when reading further up - for (int r = 0; r < drop; r++) { - if (s_new_div && s_th_rows[r].key == _thread_new_key) { lv_obj_delete(s_new_div); s_new_div = nullptr; } - lv_obj_delete(s_th_rows[r].row); - } - memmove(s_th_rows, s_th_rows + drop, kept * sizeof(ThreadRow)); - memmove(s_msg_meta, s_msg_meta + drop, kept * sizeof(MsgMeta)); - for (int r = 0; r < kept; r++) { - if (s_th_rows[r].state == stateOf(r)) { // unchanged: its slots, and a fresh age - const ChHistEntry* ce = _thread_is_channel ? &s_th_ch[r] : nullptr; - char meta[32]; - fmtMsgAge(meta, sizeof(meta), rtc_clock.getCurrentTime(), ce ? ce->timestamp : s_th_dm[r].timestamp, _prefs); - if (s_th_rows[r].age) setText(s_th_rows[r].age, meta); - s_msg_meta[r].pos = r; - if (s_msg_meta[r].loc >= 0 && drop > 0) { s_msg_locs[r] = s_msg_locs[r + drop]; s_msg_meta[r].loc = r; } - continue; - } - lv_obj_t* old = s_th_rows[r].row; // a delivery mark changed: that bubble anew, in its place - build(r); - lv_obj_move_to_index(s_th_rows[r].row, lv_obj_get_index(old)); - lv_obj_delete(old); - } - for (int r = kept; r < n; r++) build(r); - if (s_older_lbl) { char t[40]; olderText(t, sizeof(t)); setText(s_older_lbl, t); } - s_th_rows_n = s_msg_meta_n = s_msg_loc_n = n; - if (n > kept && at_end) { // new messages: down to them - layoutNow(_thread_list); - lv_obj_scroll_to_y(_thread_list, LV_COORD_MAX, LV_ANIM_OFF); - } - return; - } - - lv_obj_clean(_thread_list); - s_th_rows_n = 0; - s_older_lbl = nullptr; - if (total > _thread_skip + n) { - char t[40]; - olderText(t, sizeof(t)); - pageButton(_thread_list, t, 1); - s_older_lbl = lv_obj_get_child(lv_obj_get_child(_thread_list, -1), 0); - } - s_new_div = nullptr; - for (int i = 0; i < n; i++) { // oldest first - if (_thread_new >= 0 && _thread_skip + n - 1 - i == _thread_new) { _thread_new_key = keyOf(i); _thread_new_on = true; } - if (_thread_new_on && !s_new_div && keyOf(i) == _thread_new_key && (i > 0 || s_older_lbl)) s_new_div = newDivider(_thread_list); - build(i); - } - _thread_new = -1; - s_th_rows_n = s_msg_meta_n = s_msg_loc_n = n; - s_th_rows_newest = _thread_skip == 0; // a page back is built whole each time - if (_thread_skip > 0) pageButton(_thread_list, LV_SYMBOL_DOWN " Newer messages", -1); - if (n == 0) label(_thread_list, "No messages yet", THEME_FONT_BODY, theme::TEXT_MUTED); - layoutNow(_thread_list); - lv_obj_scroll_to_y(_thread_list, _thread_scroll_top ? 0 : LV_COORD_MAX, LV_ANIM_OFF); - if (s_new_div && !_thread_scroll_top) lv_obj_scroll_to_view(s_new_div, LV_ANIM_OFF); // from the bottom: "New" on top - _thread_scroll_top = false; -} - -// To the open conversation (channel or DM). The caller refreshes the thread. -bool UITask::sendThreadText(const char* text) { - bool ok; - if (_thread_is_channel) { - ok = _core->sendChannelText(_thread_channel, text); - } else { - ContactInfo c; - ok = MessageHistory::contactByPrefix(_thread_key, c) && _core->sendDirectText(c, text); - } - if (!ok) showToast("Send failed"); - return ok; -} - -void UITask::sendFromCompose() { - if (!_compose_ta) return; - const char* typed = lv_textarea_get_text(_compose_ta); - if (!typed || !typed[0]) return; - char text[MSG_TEXT_BUF]; - msgtext::expandOutgoing(typed, text, sizeof(text), _prefs); // {loc}, {time}, ... (MessageText.h) - if (composeLen(typed) > composeLimit()) { showToast("Too long"); return; } // the count beside the field says by how much - if (!sendThreadText(text)) return; - lv_textarea_set_text(_compose_ta, ""); - setKeyboardVisible(false); - refreshThread(); -} - -// ── Settings (first rows; the declarative schema replaces this later) ──────── - -static lv_obj_t* s_kb_main_dd = nullptr; -static lv_obj_t* s_kb_alt_dd = nullptr; - -static void onKeyboardAlphabet(lv_event_t* e) { - (void)e; - s_ui->setKeyboardAlphabets(choiceSelected(s_kb_main_dd), choiceSelected(s_kb_alt_dd)); -} - -// A preference toggle (0/1 byte in NodePrefs), saved on change. -static void onPrefSwitch(lv_event_t* e) { - uint8_t* pref = (uint8_t*)lv_event_get_user_data(e); - *pref = lv_obj_has_state((lv_obj_t*)lv_event_get_target(e), LV_STATE_CHECKED) ? 1 : 0; - prefsSave(); -} - -static void onGpsSwitch(lv_event_t* e) { - s_ui->setGps(lv_obj_has_state((lv_obj_t*)lv_event_get_target(e), LV_STATE_CHECKED)); -} - -// pref == nullptr: the caller wires the switch itself. -static lv_obj_t* switchRow(lv_obj_t* parent, const char* text, const char* sub, uint8_t* pref) { - lv_obj_t* row = settingRow(parent, text, sub, 236); // the hint clear of the switch - lv_obj_t* sw = lv_switch_create(row); - lv_obj_set_size(sw, 46, 24); - lv_obj_align(sw, LV_ALIGN_RIGHT_MID, -theme::PAD, 0); - if (pref) { - if (*pref) lv_obj_add_state(sw, LV_STATE_CHECKED); - lv_obj_add_event_cb(sw, onPrefSwitch, LV_EVENT_VALUE_CHANGED, pref); - } - return sw; -} - -// ── Schema-driven settings (ui-core/SettingsSchema.h) ───────────────────────── - -// Settings > Display: ui-new's Message alert as a switch -- the banner, or -// none (Normal / Off; the L2 has no corner for Compact's envelope). -static void onMsgBannerSwitch(lv_event_t* e) { - s_ui->setMsgBanner(lv_obj_has_state((lv_obj_t*)lv_event_get_target(e), LV_STATE_CHECKED)); -} -void UITask::msgBannerRow(lv_obj_t* card) { - lv_obj_t* sw = switchRow(card, "Message banner", "A new message drops in from the top", nullptr); - if (!_prefs || _prefs->msg_alert != NodePrefs::MSG_ALERT_OFF) lv_obj_add_state(sw, LV_STATE_CHECKED); - lv_obj_add_event_cb(sw, onMsgBannerSwitch, LV_EVENT_VALUE_CHANGED, NULL); -} -void UITask::setMsgBanner(bool on) { - if (!_prefs) return; - _prefs->msg_alert = on ? NodePrefs::MSG_ALERT_NORMAL : NodePrefs::MSG_ALERT_OFF; - prefsSave(); -} - -static void onSchemaSwitch(lv_event_t* e) { - s_ui->setSchemaValue((int)(uintptr_t)lv_event_get_user_data(e), - lv_obj_has_state((lv_obj_t*)lv_event_get_target(e), LV_STATE_CHECKED) ? 1 : 0); -} -static void onSchemaDropdown(lv_event_t* e) { - s_ui->setSchemaValue((int)(uintptr_t)lv_event_get_user_data(e), - choiceSelected((lv_obj_t*)lv_event_get_target(e))); -} -static void onOpenSchemaPage(lv_event_t* e) { - s_nav_section = -1; // from Settings: all of a page - s_ui->showSchemaSettings((int)(uintptr_t)lv_event_get_user_data(e)); -} -static void onHomeApps(lv_event_t* e) { (void)e; s_ui->homeEdit(true); } -static void onPruneContacts(lv_event_t* e) { (void)e; s_ui->pruneContacts(); } -static void onOpenQuickMsgs(lv_event_t* e); -static void onPinSetup(lv_event_t* e); // DeviceScreen.h -static void onBattCurve(lv_event_t* e) { (void)e; s_ui->showBattCurve(); } -static void onOpenOta(lv_event_t* e); // OtaScreen.h -static void bluetoothRow(lv_obj_t* body); -static void onVolumeSlider(lv_event_t* e) { - s_ui->setSoundVolume((int)lv_slider_get_value((lv_obj_t*)lv_event_get_target(e))); -} - - -// Map tools > Trail / Share / Alert options: that section of the Map options. -void UITask::showMapOptions(uint8_t section) { - s_nav_section = section; - showSchemaSettings(settings::PG_NAV); -} - -void UITask::showSchemaSettings(int page) { - if (page == settings::PG_NAV && !_nav_back) s_opts_from_map = _screen == SCR_MAP; - _screen = SCR_SETTINGS_NAV; - _settings_page = (uint8_t)(page < PG_ALL ? page : 0); - buildSchemaSettings(); -} - -// Contacts > prune now: first tap shows how many, the second (within 3 s) removes them. -void UITask::pruneContacts() { - int n = the_mesh.countStaleContacts(); - if (n == 0) { - showToast(_prefs && _prefs->contact_expiry_idx == 0 ? "Contact expiry is off" : "No inactive contacts"); - return; - } - char ask[40]; - snprintf(ask, sizeof(ask), LV_SYMBOL_TRASH " Remove %d contact%s?", n, n == 1 ? "" : "s"); - if (!tapConfirmed(_prune_lbl, ask)) return; - int removed = the_mesh.pruneStaleContacts(); - char t[40]; - snprintf(t, sizeof(t), "Removed %d contact%s", removed, removed == 1 ? "" : "s"); - showToast(t); -} - -void UITask::applyDisplayPrefs() { - if (!_prefs) return; - if (_prefs->display_brightness_pct) lvport::setBacklightPct(_prefs->display_brightness_pct); - else if (_display) _display->setBrightness(_prefs->display_brightness); -} - -// Brightness slider: live while dragging, saved on release. -static void onBrightnessSlider(lv_event_t* e) { - lv_obj_t* sl = (lv_obj_t*)lv_event_get_target(e); - s_ui->setBrightnessPct((uint8_t)lv_slider_get_value(sl), lv_event_get_code(e) == LV_EVENT_RELEASED); -} - -void UITask::setBrightnessPct(uint8_t pct, bool save) { - if (!_prefs) return; - _prefs->display_brightness_pct = pct; - _prefs->display_brightness = (uint8_t)((pct + 12) / 25 > 4 ? 4 : (pct + 12) / 25); // nearest level, for anything reading it - applyDisplayPrefs(); - if (save) prefsSave(); -} - -static void onTapWake(lv_event_t* e) { - s_ui->setTapWake(lv_obj_has_state((lv_obj_t*)lv_event_get_target(e), LV_STATE_CHECKED)); -} - -void UITask::setTapWake(bool on) { - _tap_wake = on; - lvport::saveTapWake(on); -} - -void UITask::buildSchemaSettings() { - static const char* const OWN[] = { "Keyboard", "About", "Display", "Power", "Time" }; - const char* title = _settings_page >= PG_KEYBOARD ? OWN[_settings_page - PG_KEYBOARD] : settings::pageTitle(_settings_page); - if (_settings_page == settings::PG_NAV && s_nav_section >= 0) - title = s_nav_section == settings::SEC_TRAIL ? "Trail options" - : s_nav_section == settings::SEC_LIVE_SHARE ? "Share options" : "Alert options"; - lv_obj_t* body = newScreen(title, true); - _prune_lbl = nullptr; - switch (_settings_page) { - case PG_KEYBOARD: buildKeyboardPage(body); return; - case PG_ABOUT: buildAboutPage(body); return; - case PG_DISPLAY: { - lv_obj_t* g = group(body, "SCREEN"); - schemaRow(g, SETTING(display_brightness)); - schemaRow(g, SETTING(auto_off_secs)); - schemaRow(g, SETTING(msg_wake)); - lv_obj_t* tw = switchRow(g, "Tap to wake", "Off: only the top button wakes it", nullptr); // in NVS - if (_tap_wake) lv_obj_add_state(tw, LV_STATE_CHECKED); - lv_obj_add_event_cb(tw, onTapWake, LV_EVENT_VALUE_CHANGED, NULL); - g = group(body, "LOCK"); - schemaRow(g, SETTING(auto_lock)); - listRow(g, "Screen PIN", pinSet() ? "On - asked when the screen wakes" : "Off", onPinSetup, NULL); - accentRow(group(body, "LOOK")); // DeviceScreen.h - return; - } - case PG_POWER: { - lv_obj_t* g = group(body, "BATTERY"); - schemaRow(g, SETTING(batt_display_mode)); - schemaRow(g, SETTING(low_batt_mv)); - listRow(g, "Battery curve", _prefs && battery::validCurve(_prefs->batt_curve_mv) ? "Your own" : "LiPo (built in)", - onBattCurve, NULL); - g = group(body, "GPS"); - schemaRow(g, SETTING(gps_interval)); - return; - } - case PG_TIME: { - lv_obj_t* g = group(body, nullptr); - for (int i = 0; i < settings::COUNT; i++) if (settings::ALL[i].section == settings::SEC_TIME) schemaRow(g, i); - return; - } - } - schemaRows(body, _settings_page); - if (_settings_page == settings::PG_SOUND) then(&UITask::schemaExtras, settings::PG_SOUND); // after its groups - if (_settings_page == settings::PG_MESSAGES) then(&UITask::schemaExtras, settings::PG_MESSAGES); - if (_settings_page == settings::PG_PRIVACY) then(&UITask::schemaExtras, settings::PG_PRIVACY); -} - -// ── Settings > Privacy: position, Bluetooth PIN; factory reset ───────────────── - -static void onOwnPos(lv_event_t* e) { (void)e; s_ui->ownPositionPopup(); } -static void onOwnPosPick(lv_event_t* e) { s_ui->ownPositionPick((int)(uintptr_t)lv_event_get_user_data(e)); } -static void onOwnPosKb(lv_event_t* e) { s_ui->ownPositionDone(lv_event_get_code(e) == LV_EVENT_READY); } -static void onBlePin(lv_event_t* e) { (void)e; s_ui->blePinPopup(); } -static void onBlePinKb(lv_event_t* e) { s_ui->blePinDone(lv_event_get_code(e) == LV_EVENT_READY); } - -enum : int { OWNPOS_TYPE, OWNPOS_WAYPOINTS, OWNPOS_CLEAR, OWNPOS_WP0 = 100 }; - -void UITask::ownPositionPopup() { - if (_core->gpsEnabled()) { showToast("The GPS sets your position while it's on", 2500); return; } - int32_t lat, lon; - bool have = _core->fixedPosition(lat, lon); - lv_obj_t* g = group(navPopupPanel("Fixed position", false), nullptr); - listRow(g, "Type coordinates", "Latitude, longitude in degrees", onOwnPosPick, (void*)(uintptr_t)OWNPOS_TYPE); - if (_core->waypoints.count() > 0) { - char sub[24]; - snprintf(sub, sizeof(sub), "%d saved", _core->waypoints.count()); - listRow(g, "From a waypoint", sub, onOwnPosPick, (void*)(uintptr_t)OWNPOS_WAYPOINTS); - } - if (have) actionRow(g, "Clear", onOwnPosPick, (void*)(uintptr_t)OWNPOS_CLEAR); -} - -void UITask::ownPositionPick(int code) { - const WaypointModel& wp = _core->waypoints; - if (code == OWNPOS_TYPE) { - int32_t lat, lon; - char cur[32] = ""; - if (_core->fixedPosition(lat, lon)) snprintf(cur, sizeof(cur), "%.5f, %.5f", lat / 1e6, lon / 1e6); - TextEntry t = { "Fixed position", onOwnPosKb }; - t.text = cur; - t.hint = "50.06143, 19.93658"; - t.max_bytes = 31; - t.accepted = "0123456789.,- "; - t.symbols = true; - navTextEntry(t); - return; - } - if (code == OWNPOS_WAYPOINTS) { - lv_obj_t* list = scrollList(navPopupPanel("From a waypoint", true)); - for (int i = 0; i < wp.count(); i++) { - const Waypoint& w = wp.at(i); - char title[WAYPOINT_LABEL_LEN + 8], sub[40]; - snprintf(title, sizeof(title), UI_SYMBOL_FLAG " %s", w.label[0] ? w.label : "(unnamed)"); - snprintf(sub, sizeof(sub), "%.5f, %.5f", w.lat_1e6 / 1e6, w.lon_1e6 / 1e6); - listRow(list, title, sub, onOwnPosPick, (void*)(uintptr_t)(OWNPOS_WP0 + i)); - } - return; - } - if (code == OWNPOS_CLEAR) _core->setFixedPosition(0, 0); - else if (code >= OWNPOS_WP0 && code - OWNPOS_WP0 < wp.count()) - _core->setFixedPosition(wp.at(code - OWNPOS_WP0).lat_1e6, wp.at(code - OWNPOS_WP0).lon_1e6); - navClosePopup(); - if (_screen == SCR_SETTINGS_NAV) rebuildSchemaSettings(); -} - -void UITask::ownPositionDone(bool ok) { - if (ok && _nav_ta) { - int32_t lat, lon; - if (!geo::parseLatLon(lv_textarea_get_text(_nav_ta), lat, lon) || (!lat && !lon)) { - showToast("Type it as latitude, longitude"); - return; // the field stays, to fix it - } - _core->setFixedPosition(lat, lon); - } - navClosePopup(); - if (ok && _screen == SCR_SETTINGS_NAV) rebuildSchemaSettings(); -} - -void UITask::blePinPopup() { - if (!_prefs) return; - char cur[8] = ""; - if (_prefs->ble_pin) snprintf(cur, sizeof(cur), "%06lu", (unsigned long)_prefs->ble_pin); - TextEntry t = { "Bluetooth PIN", onBlePinKb }; - t.text = cur; - t.hint = "6 digits, empty for a random one"; - t.max_bytes = 6; - t.accepted = "0123456789"; - t.symbols = true; - navTextEntry(t); -} - -void UITask::blePinDone(bool ok) { - if (ok && _nav_ta) { - const char* t = lv_textarea_get_text(_nav_ta); - if ((t[0] && strlen(t) != 6) || !_core->setBlePin((uint32_t)strtoul(t, nullptr, 10))) { - showToast("Six digits, not starting with 0"); - return; - } - showToast("Used from the next start", 2000); - } - navClosePopup(); - if (ok && _screen == SCR_SETTINGS_NAV) rebuildSchemaSettings(); -} - -void UITask::factoryResetStep(int stage) { - if (stage == 1) - confirmBody(navPopupPanel("Factory reset?", false), - "Erases contacts, channels, messages, settings and this node's keys. The SD card is left as it is.", - "Continue", onFactoryReset, 2); - else if (stage == 2) - confirmBody(navPopupPanel("Erase everything?", false), - "This can't be undone. The device restarts as a new node.", - LV_SYMBOL_TRASH " Erase", onFactoryReset, 3); - else { - navClosePopup(); - _core->factoryReset(); // restarts; back here only if the erase failed - showToast("Erase failed"); - } -} - -// The companion CLI's settings keys (ui-core/SettingsCli.h); a Settings page -// open on the screen is built again with the new value. -bool UITask::cliCommand(const char* command, char* reply, int n) { - if (!settingscli::handle(*_core, command, reply, n)) return false; - if (_screen == SCR_SETTINGS_NAV) rebuildSchemaSettings(); - return true; -} - -// What a page adds to its schema groups, once they are there. -void UITask::schemaExtras(int page) { - if (page == settings::PG_PRIVACY) { - if (lv_obj_t* c = s_sec_card[settings::SEC_PRIVACY]) { // with "Position in adverts" - char sub[40]; - int32_t lat, lon; - if (_core->gpsEnabled()) snprintf(sub, sizeof(sub), "From the GPS while it's on"); - else if (_core->fixedPosition(lat, lon)) snprintf(sub, sizeof(sub), "%.5f, %.5f", lat / 1e6, lon / 1e6); - else snprintf(sub, sizeof(sub), "Not set"); - listRow(c, "Fixed position", sub, onOwnPos, NULL); - } -#ifdef BLE_PIN_CODE - char pin[40]; - if (_prefs && _prefs->ble_pin) snprintf(pin, sizeof(pin), "%06lu", (unsigned long)_prefs->ble_pin); - else snprintf(pin, sizeof(pin), "Random each start, shown on screen"); - listRow(group(_body, "BLUETOOTH"), "Pairing PIN", pin, onBlePin, NULL); -#endif - } - if (page == settings::PG_SOUND) buildSoundRows(_body, false); // the melodies - if (page == settings::PG_MESSAGES) { - lv_obj_t* c = s_sec_card[settings::SEC_CONTACTS]; // the action that goes with "Contact expiry" - if (c) _prune_lbl = actionRow(c, LV_SYMBOL_TRASH " Remove inactive contacts now", onPruneContacts, NULL, theme::FAIL); - char sub[48]; - snprintf(sub, sizeof(sub), "%d of %d set - sent with one tap", msgtext::quickUsed(_prefs), msgtext::QUICK_COUNT); - if (lv_obj_t* m = s_sec_card[settings::SEC_MESSAGES]) listRow(m, "Quick messages", sub, onOpenQuickMsgs, NULL); - } -} - -// Settings > Keyboard: the two scripts the keyboard switches between. -void UITask::buildKeyboardPage(lv_obj_t* body) { - uint8_t main_a = _prefs ? _prefs->keyboard_main_alphabet : 0; - uint8_t alt_a = _prefs ? _prefs->keyboard_alt_alphabet : 0; - if (main_a >= NodePrefs::KB_ALPHABET_COUNT) main_a = 0; - if (alt_a >= NodePrefs::KB_ALPHABET_COUNT) alt_a = main_a; - lv_obj_t* g = group(body, "SCRIPTS"); - // Order matches NodePrefs::KB_ALPHABET_* (Latin, Cyrillic, Greek). - s_kb_main_dd = choiceRow(g, "Main", nullptr, "Latin\nCyrillic\nGreek", main_a, onKeyboardAlphabet); - // "None" = no second script (stored as alt == main, as ui-new does). - s_kb_alt_dd = choiceRow(g, "Additional", "The globe key switches to it", "None\nLatin\nCyrillic\nGreek", - alt_a == main_a ? 0 : alt_a + 1, onKeyboardAlphabet); - groupNote(body, "Hold a letter for accents and other variants."); -} - -// A QR code as a 1-bit image into dsc / data (the palette, then the rows): -// black on white with its quiet zone, s px a module. The image cache forgets -// an older picture at the same place. -template -static void qrImage(lv_image_dsc_t& dsc, uint8_t* data, size_t size, int n, int s, Dark dark) { - const int side = n + 2 * siteqr::QUIET, px = side * s, stride = (px + 7) / 8; - memset(data, 0, size); - const lv_color32_t pal[2] = { lv_color32_make(0xFF, 0xFF, 0xFF, 0xFF), lv_color32_make(0, 0, 0, 0xFF) }; - memcpy(data, pal, sizeof(pal)); - uint8_t* rows = data + 8; - for (int y = 0; y < px; y++) - for (int x = 0; x < px; x++) { - int mx = x / s - siteqr::QUIET, my = y / s - siteqr::QUIET; - if (mx >= 0 && my >= 0 && mx < n && my < n && dark(mx, my)) rows[y * stride + x / 8] |= 0x80 >> (x % 8); - } - lv_image_cache_drop(&dsc); - dsc.header.magic = LV_IMAGE_HEADER_MAGIC; - dsc.header.cf = LV_COLOR_FORMAT_I1; - dsc.header.w = px; - dsc.header.h = px; - dsc.header.stride = stride; - dsc.data_size = 8 + stride * px; - dsc.data = data; -} - -// The Solo site's QR code (ui-core/SiteQr.h), built once at 5 px a module. -static lv_obj_t* siteQr(lv_obj_t* parent) { - static const int S = 5, PX = (siteqr::N + 2 * siteqr::QUIET) * S; - static uint8_t data[8 + (PX + 7) / 8 * PX]; - static lv_image_dsc_t dsc; - if (!dsc.data) qrImage(dsc, data, sizeof(data), siteqr::N, S, siteqr::dark); - lv_obj_t* img = lv_image_create(parent); - lv_image_set_src(img, &dsc); - return img; -} - -// Your own contact's QR code (ui-core/ContactQr.h), made each time it is -// shown (the name can change), 3 px a module; null when it doesn't fit. -static lv_obj_t* contactQr(lv_obj_t* parent) { - static const int S = 3, PX = (contactqr::MAX_VERSION * 4 + 17 + 2 * siteqr::QUIET) * S; - static uint8_t data[8 + (PX + 7) / 8 * PX]; - static lv_image_dsc_t dsc; - static contactqr::Code code; - if (!contactqr::make(code)) return nullptr; - qrImage(dsc, data, sizeof(data), code.n, S, [](int x, int y) { return code.dark(x, y); }); - lv_obj_t* img = lv_image_create(parent); - lv_image_set_src(img, &dsc); - return img; -} - -// Settings > About: this node, the firmware, the manual's QR code, credits. -void UITask::buildAboutPage(lv_obj_t* body) { - lv_obj_t* about = infoCard(body); - infoRow(about, "Node", the_mesh.getNodeName()); - infoRow(about, "Firmware", FIRMWARE_VERSION); - if (!strstr(FIRMWARE_VERSION, FIRMWARE_BUILD_DATE)) infoRow(about, "Built", FIRMWARE_BUILD_DATE); - sectionTitle(body, "MY CONTACT"); // the phone app scans it to add you - lv_obj_t* me = infoCard(body); - lv_obj_set_flex_align(me, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_set_style_pad_ver(me, theme::PAD, 0); - lv_obj_set_style_pad_row(me, 6, 0); - contactQr(me); - label(me, "Scan with the MeshCore app to add this node", THEME_FONT_SMALL, theme::TEXT_MUTED); - sectionTitle(body, "MANUAL"); - lv_obj_t* man = infoCard(body); - lv_obj_set_flex_align(man, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_set_style_pad_ver(man, theme::PAD, 0); - lv_obj_set_style_pad_row(man, 6, 0); - siteQr(man); - label(man, siteqr::URL, THEME_FONT_SMALL, theme::TEXT_MUTED); - sectionTitle(body, "CREDITS"); - lv_obj_t* cr = infoCard(body); - infoNote(cr, "Map data", creditText((lvport::mountStorage() && mapview::s_provider->available()) - ? mapview::s_provider->attribution() : "\xC2\xA9 OpenStreetMap contributors")); - infoNote(cr, "Emoji", creditText("Twemoji \xC2\xA9 Twitter, Inc. and contributors (CC-BY\xC2\xA0" "4.0)")); -} - -// A page's schema rows, one group per section (Settings pages and the tools' -// options). Each section's card is kept in s_sec_card for rows added after. - -// On a screen's body the first group shows at once and the others follow -// (fill); a popup's list gets them all now. -static lv_obj_t* s_schema_body = nullptr; -static bool s_schema_one = false; // one section: its own page, no heading -static bool s_schema_bare = false; // no section headings - -void UITask::schemaRows(lv_obj_t* body, uint8_t page) { - for (lv_obj_t*& c : s_sec_card) c = nullptr; - s_schema_body = body; - s_schema_one = page == settings::PG_NAV && s_nav_section >= 0; - s_schema_bare = s_schema_one || page == settings::PG_RADIO; // one section: the page title says it - uint8_t sec = 0xFF; - bool first = true; - for (int i = 0; i < settings::COUNT; i++) { - const settings::Setting& st = settings::ALL[i]; - if (settings::sectionPage(st.section) != page || st.section == sec) continue; - if (s_schema_one && st.section != s_nav_section) continue; - sec = st.section; - if (first || body != _body) schemaSection(i); - else fill(&UITask::schemaSection, i, i + 1); - first = false; - } -} - -// The group of the section setting i starts, with its rows. -void UITask::schemaSection(int i) { - uint8_t sec = settings::ALL[i].section; - lv_obj_t* card = s_sec_card[sec] = group(s_schema_body, s_schema_bare ? nullptr : settings::sectionTitle(sec)); - if (sec == settings::SEC_SOUND) buildSoundRows(card, true); // On / Off / Auto - for (int k = i; k < settings::COUNT && settings::ALL[k].section == sec; k++) { - if (settings::ALL[k].offset == offsetof(NodePrefs, lock_compact)) continue; // ui-new's lock look; the slide card has one - if (settings::ALL[k].offset == offsetof(NodePrefs, msg_alert)) { msgBannerRow(card); continue; } // no corner envelope here - schemaRow(card, k); - } -} - -// One schema setting as a row of `card`: a switch, a choice, or (brightness, -// volume) a slider. -void UITask::schemaRow(lv_obj_t* card, int i) { - if (i < 0 || i >= settings::COUNT) return; - const settings::Setting& st = settings::ALL[i]; - uint8_t v = settings::get(*_prefs, st); - if (st.offset == offsetof(NodePrefs, buzzer_volume)) { // a five-step slider, heard on release - lv_obj_t* sl = rowSlider(settingRow(card, st.label, NULL), 0, 4, v); - lv_obj_add_event_cb(sl, onVolumeSlider, LV_EVENT_RELEASED, NULL); - return; - } - if (st.offset == offsetof(NodePrefs, display_brightness)) { // a slider here instead of five steps - uint8_t pct = _prefs->display_brightness_pct ? _prefs->display_brightness_pct - : (uint8_t)(_prefs->display_brightness * 25 > 5 ? _prefs->display_brightness * 25 : 5); - lv_obj_t* sl = rowSlider(settingRow(card, st.label, NULL), 5, 100, pct); - lv_obj_add_event_cb(sl, onBrightnessSlider, LV_EVENT_VALUE_CHANGED, NULL); - lv_obj_add_event_cb(sl, onBrightnessSlider, LV_EVENT_RELEASED, NULL); - return; - } - if (!st.option) { - lv_obj_t* sw = switchRow(card, st.label, st.hint, nullptr); - if (v) lv_obj_add_state(sw, LV_STATE_CHECKED); - lv_obj_add_event_cb(sw, onSchemaSwitch, LV_EVENT_VALUE_CHANGED, (void*)(uintptr_t)i); - return; - } - char opts[160]; - int o = 0; - for (uint8_t k = 0; k < st.count && o < (int)sizeof(opts) - 16; k++) { - if (k) opts[o++] = '\n'; - st.option(k, opts + o, sizeof(opts) - o, *_prefs); - o += strlen(opts + o); - } - opts[o] = '\0'; - choiceRow(card, st.label, st.hint, opts, v, onSchemaDropdown, (void*)(uintptr_t)i); -} - -void UITask::setSchemaValue(int idx, int v) { - if (!_prefs || idx < 0 || idx >= settings::COUNT) return; - const settings::Setting& st = settings::ALL[idx]; - settings::set(*_prefs, st, (uint8_t)v); - if (st.changed) st.changed(*_core); - prefsSave(); - if (st.offset == offsetof(NodePrefs, units_imperial) && _screen == SCR_SETTINGS_NAV) // other labels depend on it - rebuildSoon(&UITask::rebuildSchemaSettings); -} - -void UITask::rebuildSchemaSettings() { - int32_t y = _body ? lv_obj_get_scroll_y(_body) : 0; - buildSchemaSettings(); - fillFlush(); - if (_body) { layoutNow(_body); lv_obj_scroll_to_y(_body, y, LV_ANIM_OFF); } -} - -// A screen rebuilt for what a switch or segmented row changed (hints, rows -// that come and go), once its knob has moved: rebuilt at once, the 80-130 ms -// cut the animation short and the knob jumped. Not if the screen's gone by then. -static lv_timer_t* s_rebuild_t = nullptr; -void UITask::rebuildSoon(void (UITask::*fn)()) { - static void (UITask::*s_fn)() = nullptr; - static uint8_t s_scr = 0; - s_fn = fn; - s_scr = (uint8_t)_screen; - if (s_rebuild_t) lv_timer_delete(s_rebuild_t); - s_rebuild_t = lv_timer_create([](lv_timer_t*) { - s_rebuild_t = nullptr; - if ((uint8_t)s_ui->_screen == s_scr && !lv_display_get_screen_prev(NULL)) (s_ui->*s_fn)(); - }, 150, nullptr); - lv_timer_set_repeat_count(s_rebuild_t, 1); -} - -void UITask::showSettings() { - _screen = SCR_SETTINGS; - buildSettings(); -} - -static void onOpenRadio(lv_event_t* e); // RadioScreen.h -static void onOpenStorage(lv_event_t* e); // StorageScreen.h - -// Settings: groups of rows, each opening a page (chevron) or switching -// something on the spot. What the app is for first, the system last. -void UITask::buildSettings() { - bool restore = _nav_back; // back from one of its pages: where it was - lv_obj_t* body = newScreen("Settings", true); - // A group at a time (fillStart): the first shows at once. Coming back to - // where it was scrolled needs them all first. - fillStart(SETTINGS_GROUPS, 1, &UITask::settingsGroup); - if (restore) { fillFlush(); layoutNow(body); lv_obj_scroll_to_y(body, s_settings_y, LV_ANIM_OFF); } -} - -void UITask::settingsGroup(int i) { - lv_obj_t* body = _body; - char sub[48]; - lv_obj_t* g; - char v1[24], v2[24]; - switch (i) { - case 0: - if (!_prefs) break; - g = group(body, "DEVICE"); - listRow(g, LV_SYMBOL_EYE_OPEN " Display", "Brightness, screen off, lock, colour", - onOpenSchemaPage, (void*)(uintptr_t)PG_DISPLAY); - snprintf(sub, sizeof(sub), "Order, hide - %d of %d shown", home::shownCount(), home::APP_COUNT); - listRow(g, LV_SYMBOL_HOME " Home apps", sub, onHomeApps, NULL); - snprintf(sub, sizeof(sub), "Shutdown %s - GPS: %s", settingText(*_prefs, SETTING(low_batt_mv), v1, sizeof(v1)), - settingText(*_prefs, SETTING(gps_interval), v2, sizeof(v2))); - listRow(g, LV_SYMBOL_BATTERY_FULL " Power", sub, onOpenSchemaPage, (void*)(uintptr_t)PG_POWER); - { - char vol[12]; - settings::optVolume(_prefs->buzzer_volume, vol, sizeof(vol), *_prefs); - snprintf(sub, sizeof(sub), "%s, %s", soundctl::modeLabel(soundctl::mode(_prefs)), vol); - } - listRow(g, LV_SYMBOL_VOLUME_MAX " Sound", sub, onOpenSchemaPage, (void*)(uintptr_t)settings::PG_SOUND); - { - static const char* const SCRIPT[] = { "Latin", "Cyrillic", "Greek" }; - uint8_t ma = _prefs->keyboard_main_alphabet % NodePrefs::KB_ALPHABET_COUNT; - uint8_t aa = _prefs->keyboard_alt_alphabet % NodePrefs::KB_ALPHABET_COUNT; - snprintf(sub, sizeof(sub), aa == ma ? "%s" : "%s + %s", SCRIPT[ma], SCRIPT[aa]); - } - listRow(g, LV_SYMBOL_KEYBOARD " Keyboard", sub, onOpenSchemaPage, (void*)(uintptr_t)PG_KEYBOARD); - listRow(g, LV_SYMBOL_ENVELOPE " Messages & contacts", "Resend, adding, expiry, quick messages", - onOpenSchemaPage, (void*)(uintptr_t)settings::PG_MESSAGES); - break; - - case 1: - g = group(body, "CONNECTIONS"); - if (_prefs) { - int pi = radioctl::currentPreset(_prefs); - const char* pn = "Custom"; float f, b; uint8_t sf, cr; - if (pi >= 0) radioctl::presetAt(_prefs, pi, pn, f, b, sf, cr); - snprintf(sub, sizeof(sub), "%s - %.3f MHz, %d dBm", pn, _prefs->freq, _prefs->tx_power_dbm); - listRow(g, UI_SYMBOL_RADIO " Radio", sub, onOpenRadio, NULL); - listRow(g, LV_SYMBOL_EYE_CLOSE " Privacy", "Position, who can ask for telemetry, Bluetooth PIN", - onOpenSchemaPage, (void*)(uintptr_t)settings::PG_PRIVACY); - } - bluetoothRow(g); - wifiRow(g); - if (_core->gpsAvailable()) { // as WiFi: the switch turns it on / off, the row opens its details - lv_obj_t* sw = switchRow(g, LV_SYMBOL_GPS " GPS", "Tap for satellites and signal", nullptr); - if (_core->gpsEnabled()) lv_obj_add_state(sw, LV_STATE_CHECKED); - lv_obj_add_event_cb(sw, onGpsSwitch, LV_EVENT_VALUE_CHANGED, NULL); - lv_obj_t* row = lv_obj_get_parent(sw); - rowPressable(row); - lv_obj_add_event_cb(row, [](lv_event_t* e) { - if (lv_event_get_target(e) == lv_event_get_current_target(e)) onOpenGpsFromSettings(e); // not the switch - }, LV_EVENT_CLICKED, NULL); - } - break; - - case 2: - g = group(body, "MAP & DATA"); - if (_prefs) listRow(g, UI_SYMBOL_MAP " Map", "Trail, live sharing, arrival alert", - onOpenSchemaPage, (void*)(uintptr_t)settings::PG_NAV); - listRow(g, LV_SYMBOL_SD_CARD " Storage", "SD card, message history", onOpenStorage, NULL); - break; - - case 3: - g = group(body, "SYSTEM"); - listRow(g, LV_SYMBOL_EDIT " Name", the_mesh.getNodeName(), onNodeName, NULL); - if (_prefs) { - snprintf(sub, sizeof(sub), "%s, %s", settingText(*_prefs, SETTING(tz_offset_hours), v1, sizeof(v1)), - _prefs->clock_12h ? "12 h" : "24 h"); - listRow(g, UI_SYMBOL_CLOCK " Time", sub, onOpenSchemaPage, (void*)(uintptr_t)PG_TIME); - schemaRow(g, SETTING(units_imperial)); - } - listRow(g, LV_SYMBOL_DOWNLOAD " Firmware update", FIRMWARE_VERSION, onOpenOta, NULL); - listRow(g, LV_SYMBOL_LIST " About", "Node, your contact QR, manual, credits", onOpenSchemaPage, (void*)(uintptr_t)PG_ABOUT); - break; - - case 4: - g = group(body, nullptr); - actionRow(g, LV_SYMBOL_REFRESH " Reboot", onPowerRow, (void*)(uintptr_t)1); - actionRow(g, LV_SYMBOL_POWER " Power off", onPowerRow, (void*)(uintptr_t)0, theme::FAIL); - actionRow(g, LV_SYMBOL_TRASH " Factory reset", onFactoryReset, (void*)(uintptr_t)1, theme::FAIL); - break; - } -} - -void UITask::setKeyboardAlphabets(int main_idx, int alt_sel) { - if (!_prefs) return; - _prefs->keyboard_main_alphabet = (uint8_t)main_idx; - _prefs->keyboard_alt_alphabet = (uint8_t)(alt_sel == 0 ? main_idx : alt_sel - 1); - prefsSave(); -} +#include "SettingsPages.h" #include "ConversationScreen.h" #include "DeviceScreen.h" diff --git a/examples/companion_radio/ui-lvgl/VirtualList.h b/examples/companion_radio/ui-lvgl/VirtualList.h new file mode 100644 index 00000000..901c1ada --- /dev/null +++ b/examples/companion_radio/ui-lvgl/VirtualList.h @@ -0,0 +1,184 @@ +#pragma once +// Included into UITask.cpp only, where it stands (it uses what is defined above it). + +// ── Virtual list ────────────────────────────────────────────────────────────── +// A long list (contacts, nodes, conversations) as only the rows it shows: a +// row scrolled out of sight takes the next item coming in. The items are +// kinds with a fixed height -- a row made once (make), its texts set for an +// item (bind) -- or fixed objects of their own (a section title, a note). +// The scroll extent comes from the items' heights, so the scrollbar, a throw +// and coming back to where it was work as with every row built. Built in the +// same time whatever the count; 256 contacts: 522 objects, 950 ms filling +// in and 28 ms a scrolled frame before, 19 objects and 13 ms now. +namespace vlist { +struct Kind { + int16_t h; + lv_obj_t* (*make)(lv_obj_t* list); // a row, its event callbacks on (they ask arg()) + void (*bind)(lv_obj_t* row, intptr_t arg); +}; +struct Item { const Kind* k; intptr_t arg; int32_t top; int16_t h; }; // k null: arg is its own object +struct Slot { lv_obj_t* o; const Kind* k; int item; int prev; }; +static const int SLOTS = 32; // a screenful of the shortest kind, a few kinds +struct List { + lv_obj_t* obj; + Item* items; + int n, cap; + int32_t total; + bool placed; // the items' tops are known (not while a new set comes in) + Slot slots[SLOTS]; + int nslots; +}; +static List* s_lists[8]; // a screen's and a popup's, two screens while one slides in + +static List* of(lv_obj_t* obj) { + for (List* l : s_lists) if (l && l->obj == obj) return l; + return nullptr; +} + +static void place(lv_obj_t* o, int32_t y) { + if (lv_obj_get_style_y(o, LV_PART_MAIN) != y) lv_obj_set_y(o, y); + if (lv_obj_has_flag(o, LV_OBJ_FLAG_HIDDEN)) lv_obj_remove_flag(o, LV_OBJ_FLAG_HIDDEN); +} + +// The rows for what's in view: kept where they show the same item, the rest +// handed over (bound) to the items coming in, made only when none is free. +// all: every one bound again (the items changed). +static void update(List* l, bool all) { + if (!l->placed) return; + int32_t sy = lv_obj_get_scroll_y(l->obj), vh = lv_obj_get_content_height(l->obj); + if (vh <= 0) vh = LV_VER_RES; // not laid out yet: a screenful + int first = 0, hi = l->n; + while (first < hi) { // the first item reaching into view + int m = (first + hi) / 2; + if (l->items[m].top + l->items[m].h <= sy) first = m + 1; else hi = m; + } + int last = first; + while (last < l->n && l->items[last].top < sy + vh) last++; + for (int s = 0; s < l->nslots; s++) { + Slot& sl = l->slots[s]; + if (sl.item < 0) continue; + if (all || sl.item < first || sl.item >= last) { sl.prev = sl.item; sl.item = -1; } + } + for (int i = first; i < last; i++) { + Item& it = l->items[i]; + if (!it.k) continue; + int got = -1; + for (int s = 0; s < l->nslots && got < 0; s++) if (l->slots[s].item == i) got = s; + if (got >= 0) continue; // shown already + for (int s = 0; s < l->nslots; s++) { // a free one of its kind; the one that showed it, if any + Slot& sl = l->slots[s]; + if (sl.item >= 0 || sl.k != it.k) continue; + if (got < 0 || sl.prev == i) got = s; + if (sl.prev == i) break; + } + if (got < 0) { + if (l->nslots == SLOTS) continue; + got = l->nslots++; + l->slots[got] = { it.k->make(l->obj), it.k, -1, -1 }; + } + Slot& sl = l->slots[got]; + sl.item = i; + it.k->bind(sl.o, it.arg); + place(sl.o, it.top); + } + for (int s = 0; s < l->nslots; s++) { + Slot& sl = l->slots[s]; + if (sl.item < 0 && !lv_obj_has_flag(sl.o, LV_OBJ_FLAG_HIDDEN)) lv_obj_add_flag(sl.o, LV_OBJ_FLAG_HIDDEN); + } +} + +static void onEvent(lv_event_t* e) { + lv_obj_t* obj = (lv_obj_t*)lv_event_get_current_target(e); + List* l = of(obj); + if (!l) return; + switch (lv_event_get_code(e)) { + case LV_EVENT_SCROLL: case LV_EVENT_SIZE_CHANGED: update(l, false); break; + case LV_EVENT_GET_SELF_SIZE: { + lv_point_t* p = (lv_point_t*)lv_event_get_param(e); + if (p->y < l->total) p->y = l->total; + break; + } + case LV_EVENT_DELETE: + for (List*& x : s_lists) if (x == l) x = nullptr; + lv_free(l->items); + lv_free(l); + break; + default: break; + } +} + +// `obj` (a scrolling column: a screen body, a scrollList) becomes a virtual +// list; its children so far are taken away. +static void attach(lv_obj_t* obj) { + List* l = nullptr; + for (List*& x : s_lists) if (!x) { x = l = (List*)lv_malloc_zeroed(sizeof(List)); break; } + if (!l) return; // can't be: then a plain empty column + l->obj = obj; + lv_obj_clean(obj); + lv_obj_set_layout(obj, LV_LAYOUT_NONE); + for (lv_event_code_t c : { LV_EVENT_SCROLL, LV_EVENT_SIZE_CHANGED, LV_EVENT_GET_SELF_SIZE, LV_EVENT_DELETE }) + lv_obj_add_event_cb(obj, onEvent, c, NULL); +} + +// A new set of items: add() / own() them, then end(). The rows made stay +// (rebound); objects of their own given to own() before are deleted. +static void begin(lv_obj_t* obj) { + List* l = of(obj); + if (!l) return; + for (int i = 0; i < l->n; i++) if (!l->items[i].k) lv_obj_delete((lv_obj_t*)l->items[i].arg); + l->n = 0; + l->placed = false; +} +static void push(List* l, const Kind* k, intptr_t arg, int16_t h) { + if (l->n == l->cap) { + l->cap = l->cap ? l->cap * 2 : 64; + l->items = (Item*)lv_realloc(l->items, l->cap * sizeof(Item)); + } + l->items[l->n++] = { k, arg, 0, h }; +} +static void add(lv_obj_t* obj, const Kind* k, intptr_t arg) { if (List* l = of(obj)) push(l, k, arg, k->h); } +// An object of its own (made on the list, its height known after a layout). +static void own(lv_obj_t* obj, lv_obj_t* o) { if (List* l = of(obj)) push(l, nullptr, (intptr_t)o, 0); } + +static void end(lv_obj_t* obj) { + List* l = of(obj); + if (!l) return; + bool mine = false; + for (int i = 0; i < l->n && !mine; i++) mine = !l->items[i].k; + if (mine) lv_obj_update_layout(obj); // their heights + int32_t gap = lv_obj_get_style_pad_row(obj, LV_PART_MAIN), y = 0; + for (int i = 0; i < l->n; i++) { + Item& it = l->items[i]; + if (!it.k) { + lv_obj_t* o = (lv_obj_t*)it.arg; + it.h = (int16_t)lv_obj_get_height(o); + place(o, y); + } + it.top = y; + y += it.h + gap; + } + l->total = l->n ? y - gap : 0; + l->placed = true; + lv_obj_refresh_self_size(obj); + update(l, true); + lv_obj_readjust_scroll(obj, LV_ANIM_OFF); // fewer items: not scrolled past the end +} + +// The item a row's event is for (its arg). +static intptr_t arg(lv_event_t* e) { + lv_obj_t* o = (lv_obj_t*)lv_event_get_current_target(e); + List* l = of(lv_obj_get_parent(o)); + if (l) + for (int s = 0; s < l->nslots; s++) + if (l->slots[s].o == o && l->slots[s].item >= 0) return l->items[l->slots[s].item].arg; + return -1; +} + +// A row held down: what it shows stays until it's let go. +static bool pressed(lv_obj_t* obj) { + List* l = of(obj); + for (int s = 0; l && s < l->nslots; s++) + if (lv_obj_has_state(l->slots[s].o, LV_STATE_PRESSED)) return true; + return false; +} +} // namespace vlist diff --git a/examples/companion_radio/ui-lvgl/Widgets.h b/examples/companion_radio/ui-lvgl/Widgets.h new file mode 100644 index 00000000..b9a58f9f --- /dev/null +++ b/examples/companion_radio/ui-lvgl/Widgets.h @@ -0,0 +1,1300 @@ +#pragma once +// Included into UITask.cpp only, where it stands (it uses what is defined above it). +// The widget kit every screen builds from: label and text setters that skip +// needless redraws, popups, info cards, settings rows and pickers, the big clock. + +// ── Small helpers ───────────────────────────────────────────────────────────── + +// A plain box in `bg`. One in the page colour is left unpainted: the screen +// under it (or the popup panel) already is that colour, and filling it again +// costs a full-area blend per frame. The screen and the status bar, which +// something may scroll or slide under, are made opaque with styleOpaque(). +static void styleSurface(lv_obj_t* o, uint32_t bg) { + lv_obj_set_style_bg_color(o, lv_color_hex(bg), 0); + lv_obj_set_style_bg_opa(o, bg == theme::BG ? LV_OPA_TRANSP : LV_OPA_COVER, 0); + lv_obj_set_style_border_width(o, 0, 0); + lv_obj_set_style_radius(o, 0, 0); + lv_obj_set_style_pad_all(o, 0, 0); +} +static void styleOpaque(lv_obj_t* o, uint32_t bg) { + styleSurface(o, bg); + lv_obj_set_style_bg_opa(o, LV_OPA_COVER, 0); +} + +static bool growsWide(lv_obj_t* o) { // a flex grow along a row: the width is the layout's + lv_obj_t* p = lv_obj_get_parent(o); + if (!p || !lv_obj_get_style_flex_grow(o, LV_PART_MAIN) || lv_obj_get_style_layout(p, LV_PART_MAIN) != LV_LAYOUT_FLEX) return false; + lv_flex_flow_t f = lv_obj_get_style_flex_flow(p, LV_PART_MAIN); + return !(f & LV_FLEX_COLUMN); +} +// The width of `o`'s content, worked out from the styles -- or -1 where it +// hangs on its content or a row's flex grow. A new box's own size is only known +// after a layout pass, and LVGL lays out children before their parent, so +// each box sized in % of the one above adds a whole pass over the screen. +static int32_t innerW(lv_obj_t* o) { + if (!o || growsWide(o)) return -1; + int32_t w = lv_obj_get_style_width(o, LV_PART_MAIN); + if (LV_COORD_IS_PCT(w)) { + int32_t pw = innerW(lv_obj_get_parent(o)); + if (pw < 0) return -1; + w = pw * LV_COORD_GET_PCT(w) / 100; + } else if (!LV_COORD_IS_PX(w)) { + return -1; + } + w -= lv_obj_get_style_pad_left(o, LV_PART_MAIN) + lv_obj_get_style_pad_right(o, LV_PART_MAIN); + lv_border_side_t side = lv_obj_get_style_border_side(o, LV_PART_MAIN); + int32_t bw = lv_obj_get_style_border_width(o, LV_PART_MAIN); + if (side & LV_BORDER_SIDE_LEFT) w -= bw; + if (side & LV_BORDER_SIDE_RIGHT) w -= bw; + return w; +} +extern "C" bool lv_obj_refr_size(lv_obj_t* obj); // lv_obj_pos.c, in no header +// A wrapping label's size settled now, `w` wide. LVGL's first layout of a +// label works out its height at the width it had before -- 0 for a new one, +// a line per letter -- and only then measures it again at the new width. +static void labelFit(lv_obj_t* l, int32_t w) { + l->coords.x2 = l->coords.x1 + w - 1; + lv_obj_refr_size(l); +} +// A flex row whose one growing child gets its width before the first layout +// (rowsFit(), further down). +static bool s_fit_pending; +static lv_obj_t* s_fit_screen; // newScreen()'s, maybe still fading in: not lv_screen_active() yet +static void fitLater(lv_obj_t* row) { + lv_obj_add_flag(row, LV_OBJ_FLAG_USER_2); + s_fit_pending = true; +} +// The parent's full width, in pixels when that's known now (see innerW()). +static void fillWidth(lv_obj_t* o) { + int32_t w = innerW(lv_obj_get_parent(o)); + lv_obj_set_width(o, w >= 0 ? w : LV_PCT(100)); + if (w > 0 && lv_obj_check_type(o, &lv_label_class)) labelFit(o, w); +} + +static lv_obj_t* label(lv_obj_t* parent, const char* text, const lv_font_t* font, uint32_t color) { + lv_obj_t* l = lv_label_create(parent); + lv_label_set_text(l, text); + lv_obj_set_style_text_font(l, font, 0); + lv_obj_set_style_text_color(l, lv_color_hex(color), 0); + return l; +} +// Whether label `l` holds `text`. A LV_LABEL_LONG_DOT one that's cut short +// has the "..." written into its own copy of the text, the bytes they cover +// kept aside (lv_label_set_dots): it's read back past them. +static bool labelShows(lv_obj_t* l, const char* text) { + const lv_label_t* lb = (const lv_label_t*)l; + const char* cur = lb->text; + if (!cur) return false; + uint32_t d = lb->dot_begin; + if (d == 0xFFFFFFFF || lb->static_txt) return strcmp(cur, text) == 0; // no dots (lv_label.c LV_LABEL_DOT_BEGIN_INV) + if (strncmp(cur, text, d) != 0) return false; + size_t k = strnlen(lb->dot, sizeof(lb->dot)); // the bytes under the dots + if (strncmp(lb->dot, text + d, k) != 0) return false; + return k < sizeof(lb->dot) ? text[d + k] == '\0' : strcmp(cur + d + k, text + d + k) == 0; +} +// A label's text, set only when it differs: LVGL redraws a label on every +// set_text, the same text or not, and the refreshes that run every second +// mostly write back what's already there. +static void setText(lv_obj_t* l, const char* text) { + if (labelShows(l, text)) return; + lv_label_set_text(l, text); +} +// The same for a text colour: LVGL restyles, and redraws, on every set. +static void setTextColor(lv_obj_t* l, uint32_t color) { + lv_color_t c = lv_color_hex(color); + if (!lv_color_eq(lv_obj_get_style_text_color(l, LV_PART_MAIN), c)) lv_obj_set_style_text_color(l, c, 0); +} +static void setTextFmt(lv_obj_t* l, const char* fmt, ...) __attribute__((format(printf, 2, 3))); +static void setTextFmt(lv_obj_t* l, const char* fmt, ...) { + char b[160]; + va_list ap; + va_start(ap, fmt); + vsnprintf(b, sizeof(b), fmt, ap); + va_end(ap); + setText(l, b); +} +// A sentence across the parent's width, wrapping: a hint, a note, a status. +static lv_obj_t* noteLabel(lv_obj_t* parent, const char* text, const lv_font_t* font = THEME_FONT_SMALL, + uint32_t color = theme::TEXT_MUTED) { + lv_obj_t* l = label(parent, text, font, color); + lv_label_set_long_mode(l, LV_LABEL_LONG_WRAP); + fillWidth(l); + return l; +} + +// A list filling the rest of a flex column, scrolling on its own (the +// scrollbar only while it moves). +static lv_obj_t* scrollList(lv_obj_t* parent) { + lv_obj_t* l = lv_obj_create(parent); + styleSurface(l, theme::BG); + fillWidth(l); + lv_obj_set_flex_grow(l, 1); + lv_obj_set_flex_flow(l, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(l, theme::GAP, 0); + lv_obj_set_scrollbar_mode(l, LV_SCROLLBAR_MODE_ACTIVE); + return l; +} + +// The dimmed full-screen layer under a popup; it swallows taps on what's below. +static lv_obj_t* dimOverlay(lv_obj_t* parent) { + lv_obj_t* o = lv_obj_create(parent); + lv_obj_remove_style_all(o); + lv_obj_set_size(o, LV_PCT(100), LV_PCT(100)); + lv_obj_set_style_bg_color(o, lv_color_hex(0x000000), 0); + lv_obj_set_style_bg_opa(o, LV_OPA_60, 0); + lv_obj_add_flag(o, LV_OBJ_FLAG_CLICKABLE); + lv_obj_remove_flag(o, LV_OBJ_FLAG_SCROLLABLE); + return o; +} + +// LV_EVENT_COVER_CHECK: an opaque thing LVGL can't tell is (an image, a box +// drawn by its children) hides all under its own area, and LVGL then starts +// drawing from it. +static void coversOwnArea(lv_event_t* e) { + lv_cover_check_info_t* info = (lv_cover_check_info_t*)lv_event_get_param(e); + lv_area_t a; + lv_obj_get_coords((lv_obj_t*)lv_event_get_current_target(e), &a); + const lv_area_t* r = info->area; + if (info->res != LV_COVER_RES_MASKED && r->x1 >= a.x1 && r->y1 >= a.y1 && r->x2 <= a.x2 && r->y2 <= a.y2) + info->res = LV_COVER_RES_COVER; +} + +static lv_obj_t* s_status_bar = nullptr; // on the top layer, opaque +// Where the screen shows below the status bar (its top when the bar is hidden). +static int32_t belowStatusBar() { + return s_status_bar && !lv_obj_has_flag(s_status_bar, LV_OBJ_FLAG_HIDDEN) ? lv_obj_get_y2(s_status_bar) + 1 : 0; +} + +// The screen under an open popup, frozen: once the dim is up, the screen is +// drawn once into an image, darkened as the dim layer darkens it, and shown +// opaque on top of the screen with the dim layer cleared -- LVGL then draws +// nothing below it. Live, the screen plus a full-screen blend under the popup +// cost ~28 ms a frame. One popup at a time; the image goes with its popup, and +// the popup dims live again if its screen goes first. Any dim level: the +// layer's own when it's frozen. +namespace freeze { + static lv_obj_t* s_img = nullptr; // the frozen screen, on that screen + static lv_obj_t* s_owner = nullptr; // the popup's dim layer + static lv_obj_t* s_strip = nullptr; // the status bar's dim (a popup on the top layer) + static lv_timer_t* s_retry = nullptr; // waiting for the screen to stop moving + static lv_opa_t s_dim = LV_OPA_60; // the dim layer's, frozen into the image + + static void imgDeleted(lv_event_t* e) { + lv_draw_buf_destroy((lv_draw_buf_t*)lv_event_get_user_data(e)); + s_img = nullptr; + if (s_owner) { // the screen went, the popup stays + if (s_strip) lv_obj_delete(s_strip); + lv_obj_set_style_bg_opa(s_owner, s_dim, 0); + s_owner = s_strip = nullptr; + } + } + static void ownerDeleted(lv_event_t* e) { + if (s_retry && lv_timer_get_user_data(s_retry) == lv_event_get_target(e)) { lv_timer_delete(s_retry); s_retry = nullptr; } + if (s_owner != lv_event_get_target(e)) return; + s_owner = s_strip = nullptr; + if (s_img) lv_obj_delete(s_img); + } + static bool moving(lv_obj_t* scr) { // a screen still sliding / fading in + if (lv_display_get_screen_prev(NULL)) return true; + for (uint32_t i = 0; i < lv_obj_get_child_count(scr); i++) + if (lv_anim_get(lv_obj_get_child(scr, i), NULL)) return true; + return false; + } + static void take(lv_obj_t* overlay); + static void retry(lv_timer_t* t) { + s_retry = nullptr; + take((lv_obj_t*)lv_timer_get_user_data(t)); + } + // Darkened into the display's own byte order (UI_FLUSH_DMA: big-endian; + // lv_snapshot can't make that one itself): drawn then as a plain copy. +#ifdef UI_FLUSH_DMA + static inline uint16_t out565(uint16_t c) { return __builtin_bswap16(c); } +#else + static inline uint16_t out565(uint16_t c) { return c; } +#endif + static void darken(lv_draw_buf_t* buf) { // as the dim layer over it would + const uint32_t keep = 256 - s_dim; // what's left under the black + for (uint32_t y = 0; y < buf->header.h; y++) { + uint16_t* p = (uint16_t*)(buf->data + y * buf->header.stride); + for (uint32_t x = 0; x < buf->header.w; x++) { + uint32_t c = p[x]; + p[x] = out565((uint16_t)(((((c >> 11) * keep) >> 8) << 11) | (((((c >> 5) & 0x3F) * keep) >> 8) << 5) | + (((c & 0x1F) * keep) >> 8))); + } + } +#ifdef UI_FLUSH_DMA + buf->header.cf = LV_COLOR_FORMAT_RGB565_SWAPPED; +#endif + } + static lv_draw_buf_t* snap(lv_obj_t* overlay, lv_obj_t* scr, bool top) { + if (!top) lv_obj_add_flag(overlay, LV_OBJ_FLAG_HIDDEN); + lv_draw_buf_t* buf = lv_snapshot_take(scr, LV_COLOR_FORMAT_RGB565); + if (!top) lv_obj_remove_flag(overlay, LV_OBJ_FLAG_HIDDEN); + return buf; + } + static void show(lv_obj_t* overlay, lv_obj_t* scr, bool top, lv_draw_buf_t* buf) { + s_img = lv_image_create(scr); + lv_image_set_src(s_img, buf); + lv_obj_add_flag(s_img, LV_OBJ_FLAG_IGNORE_LAYOUT); + lv_obj_remove_flag(s_img, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_pos(s_img, 0, 0); + lv_obj_add_event_cb(s_img, imgDeleted, LV_EVENT_DELETE, buf); + lv_obj_add_event_cb(s_img, coversOwnArea, LV_EVENT_COVER_CHECK, NULL); // no background: its own check says no + if (!top) lv_obj_move_foreground(overlay); // the image under the popup, over the rest + s_owner = overlay; + } + static void dimmed(lv_obj_t* overlay, bool top) { // the image holds the dim now + lv_obj_set_style_bg_opa(overlay, LV_OPA_TRANSP, 0); + if (top) { // the status bar, on the top layer too, stays dimmed + s_strip = lv_obj_create(overlay); + lv_obj_remove_style_all(s_strip); + lv_obj_remove_flag(s_strip, LV_OBJ_FLAG_CLICKABLE); + lv_obj_add_flag(s_strip, LV_OBJ_FLAG_IGNORE_LAYOUT); + lv_obj_set_size(s_strip, LV_PCT(100), theme::STATUS_H); + lv_obj_set_style_bg_color(s_strip, lv_color_hex(0x000000), 0); + lv_obj_set_style_bg_opa(s_strip, s_dim, 0); + } + } + static void take(lv_obj_t* overlay) { + if (s_img) return; // a popup over a popup: dimmed live + lv_obj_t* scr = lv_obj_get_screen(overlay); + bool top = scr == lv_layer_top(); + if (top) scr = lv_screen_active(); + if (moving(scr)) { + s_retry = lv_timer_create(retry, 60, overlay); + lv_timer_set_repeat_count(s_retry, 1); + return; + } + s_dim = lv_obj_get_style_bg_opa(overlay, LV_PART_MAIN); + lv_draw_buf_t* buf = snap(overlay, scr, top); + if (!buf) return; // no memory: dimmed live + darken(buf); + show(overlay, scr, top, buf); + dimmed(overlay, top); + } + static void onDimUp(lv_anim_t* a) { take((lv_obj_t*)a->var); } + // A popup opening over a still screen: frozen and dimmed at once, so the + // panel's rise redraws only the panel -- a dim fading up over the live + // screen redrew all of it every frame (~45 ms each). False when the screen + // is still moving or there's no memory: the caller fades the dim up. + static bool early(lv_obj_t* overlay, lv_opa_t dim) { + if (s_img) return false; + lv_obj_t* scr = lv_obj_get_screen(overlay); + bool top = scr == lv_layer_top(); + if (top) scr = lv_screen_active(); + if (moving(scr)) return false; + lv_draw_buf_t* buf = snap(overlay, scr, top); + if (!buf) return false; + s_dim = dim; + darken(buf); + show(overlay, scr, top, buf); + dimmed(overlay, top); + return true; + } + // A dim layer that's up at once, without the fade: frozen now. + static void now(lv_obj_t* overlay) { + lv_obj_add_event_cb(overlay, ownerDeleted, LV_EVENT_DELETE, NULL); + take(overlay); + } +} + +// A popup: the dimmed layer (into `overlay`, to close it by) and its panel -- +// the page colour, the accent hairline, a flex column -- rising into view. +// POP_FIT: as tall as its content, centred below the status bar, scrolling +// past that height. POP_FULL: all of that height; its own list scrolls. +// POP_TOP: as tall as its content, under the status bar, clear of a keyboard. +// POP_BOTTOM: as tall as its content, at the bottom, nothing dimmed -- for +// what's shown on the screen above it (a map area's preview). +enum PopFit : uint8_t { POP_FIT, POP_FULL, POP_TOP, POP_BOTTOM }; +static lv_obj_t* popupOpen(lv_obj_t* parent, PopFit fit, lv_obj_t*& overlay, int32_t inset = 8) { + overlay = dimOverlay(parent); + s_fit_pending = true; // rowsFit() + lv_obj_t* panel = lv_obj_create(overlay); + int32_t w = lv_display_get_horizontal_resolution(NULL), h = lv_display_get_vertical_resolution(NULL); + lv_obj_set_width(panel, w - 2 * inset); + if (fit == POP_FULL) { + lv_obj_set_height(panel, h - theme::STATUS_H - 12); + lv_obj_set_pos(panel, inset, theme::STATUS_H + 6); + } else if (fit == POP_TOP) { + lv_obj_set_height(panel, LV_SIZE_CONTENT); + lv_obj_align(panel, LV_ALIGN_TOP_MID, 0, theme::STATUS_H + 4); + } else if (fit == POP_BOTTOM) { + lv_obj_set_height(panel, LV_SIZE_CONTENT); + lv_obj_align(panel, LV_ALIGN_BOTTOM_MID, 0, -inset); + } else { + lv_obj_set_height(panel, LV_SIZE_CONTENT); + lv_obj_set_style_max_height(panel, h - theme::STATUS_H - 12, 0); + lv_obj_align(panel, LV_ALIGN_CENTER, 0, theme::STATUS_H / 2); + lv_obj_set_scrollbar_mode(panel, LV_SCROLLBAR_MODE_ACTIVE); + } + if (fit != POP_FIT) lv_obj_remove_flag(panel, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_bg_color(panel, lv_color_hex(theme::BG), 0); + lv_obj_set_style_border_color(panel, lv_color_hex(theme::ACCENT), 0); + lv_obj_set_style_border_width(panel, 1, 0); + lv_obj_set_style_radius(panel, theme::RADIUS, 0); + lv_obj_set_style_pad_all(panel, theme::PAD, 0); + lv_obj_set_style_pad_row(panel, 4, 0); + lv_obj_set_flex_flow(panel, LV_FLEX_FLOW_COLUMN); + if (fit == POP_BOTTOM) { // over a live screen, nothing dimmed + lv_obj_set_style_bg_opa(overlay, LV_OPA_TRANSP, 0); + anim::rise(panel); + } else { + lv_obj_add_event_cb(overlay, freeze::ownerDeleted, LV_EVENT_DELETE, NULL); + if (freeze::early(overlay, LV_OPA_60)) anim::rise(panel); + else anim::popup(overlay, LV_OPA_60, freeze::onDimUp); + } + return panel; +} + +// ── Info cards ── +// Label / value rows on a card (Diagnostics, GPS, node detail, About): the +// label small and muted on the left, the value on the right -- wrapping, +// right-aligned, when it's long -- and a hairline between rows. infoRow() +// returns the value label to update in place; infoSet() also hides its row +// while there's nothing to show. +static lv_obj_t* infoCard(lv_obj_t* parent) { + lv_obj_t* c = lv_obj_create(parent); + styleSurface(c, theme::SURFACE); + lv_obj_remove_flag(c, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(c, LV_OBJ_FLAG_CLICKABLE); + fillWidth(c); + lv_obj_set_height(c, LV_SIZE_CONTENT); + lv_obj_set_style_radius(c, theme::RADIUS, 0); + lv_obj_set_style_pad_hor(c, theme::PAD, 0); + lv_obj_set_flex_flow(c, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(c, 0, 0); // the rows' own padding spaces them, around the hairline + return c; +} + +// A credit line that wraps cleanly: "(c) Name" and "CC-BY-SA" never split. +static const char* creditText(const char* in) { + static char out[160]; + size_t o = 0; + int paren = 0; + for (const char* p = in; *p && o + 4 < sizeof(out); p++) { + if (*p == '(') paren++; + else if (*p == ')' && paren) paren--; + if ((uint8_t)p[0] == 0xC2 && (uint8_t)p[1] == 0xA9 && p[2] == ' ') { // "(c) " -> "(c)" + no-break space + memcpy(out + o, "\xC2\xA9\xC2\xA0", 4); o += 4; p += 2; + } else if (*p == '-' && paren) { // licence names: a non-breaking hyphen + memcpy(out + o, "\xE2\x80\x91", 3); o += 3; + } else { + out[o++] = *p; + } + } + out[o] = '\0'; + return out; +} + +static lv_obj_t* infoLine(lv_obj_t* card) { // one row's box, the hairline above all but the first + lv_obj_t* r = lv_obj_create(card); + lv_obj_remove_style_all(r); + lv_obj_remove_flag(r, LV_OBJ_FLAG_CLICKABLE); + lv_obj_remove_flag(r, LV_OBJ_FLAG_SCROLLABLE); + fillWidth(r); + lv_obj_set_height(r, LV_SIZE_CONTENT); + lv_obj_set_style_pad_ver(r, 5, 0); + if (lv_obj_has_flag(card, LV_OBJ_FLAG_USER_1)) lv_obj_set_style_pad_hor(r, theme::PAD, 0); // in a group() + if (lv_obj_get_index(r) > 0) { + lv_obj_set_style_border_side(r, LV_BORDER_SIDE_TOP, 0); + lv_obj_set_style_border_width(r, 1, 0); + lv_obj_set_style_border_color(r, lv_color_hex(theme::SURFACE_2), 0); + } + return r; +} + +static lv_obj_t* infoRow(lv_obj_t* card, const char* key, const char* value, uint32_t col = theme::TEXT, + const lv_font_t* font = THEME_FONT_BODY) { + lv_obj_t* r = infoLine(card); + lv_obj_set_flex_flow(r, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(r, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_column(r, 8, 0); + lv_obj_t* k = label(r, key, THEME_FONT_SMALL, theme::TEXT_MUTED); + lv_label_set_long_mode(k, LV_LABEL_LONG_WRAP); + lv_obj_set_width(k, 1); + lv_obj_set_flex_grow(k, 1); + lv_obj_t* v = label(r, value, font, col); + lv_label_set_long_mode(v, LV_LABEL_LONG_WRAP); + lv_obj_set_width(v, LV_SIZE_CONTENT); + lv_obj_set_style_max_width(v, LV_PCT(66), 0); + lv_obj_set_style_text_align(v, LV_TEXT_ALIGN_RIGHT, 0); + fitLater(r); + return v; +} + +// A row whose text is a sentence (credits, notes): the label over it. +static lv_obj_t* infoNote(lv_obj_t* card, const char* key, const char* text) { + lv_obj_t* r = infoLine(card); + lv_obj_set_flex_flow(r, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(r, 2, 0); + label(r, key, THEME_FONT_SMALL, theme::TEXT_MUTED); + lv_obj_t* t = noteLabel(r, text, THEME_FONT_SMALL, theme::TEXT); + return t; +} + +static lv_obj_t* sectionTitle(lv_obj_t* parent, const char* text); + +static void infoSet(lv_obj_t* value, const char* text) { + lv_obj_t* row = lv_obj_get_parent(value); + if (!text || !text[0]) { lv_obj_add_flag(row, LV_OBJ_FLAG_HIDDEN); return; } + lv_obj_remove_flag(row, LV_OBJ_FLAG_HIDDEN); + setText(value, text); +} + +// A bare flex row / column sized to its content, taps passing through. +static lv_obj_t* flexBox(lv_obj_t* parent, lv_flex_flow_t flow) { + lv_obj_t* b = lv_obj_create(parent); + lv_obj_remove_style_all(b); + lv_obj_remove_flag(b, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(b, LV_SIZE_CONTENT, LV_SIZE_CONTENT); + lv_obj_set_flex_flow(b, flow); + return b; +} + +// A full-width row of centred items that grows with them (button rows in +// popups, the clock's tabs and actions). +static lv_obj_t* centerRow(lv_obj_t* parent) { + lv_obj_t* r = lv_obj_create(parent); + styleSurface(r, theme::BG); + lv_obj_remove_flag(r, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(r, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_flex_flow(r, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(r, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_column(r, theme::GAP, 0); + return r; +} + +// A small pill button with a label, right-aligned in its parent row. +static lv_obj_t* pillButton(lv_obj_t* row, const char* text, uint32_t bg, lv_event_cb_t cb, uintptr_t user) { + lv_obj_t* b = lv_button_create(row); + lv_obj_set_size(b, LV_SIZE_CONTENT, 24); + lv_obj_set_style_pad_hor(b, 10, 0); + lv_obj_set_style_pad_ver(b, 0, 0); + lv_obj_set_style_radius(b, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_shadow_width(b, 0, 0); + lv_obj_set_style_bg_color(b, lv_color_hex(bg), 0); + lv_obj_align(b, LV_ALIGN_RIGHT_MID, -2, 0); + lv_obj_add_event_cb(b, cb, LV_EVENT_CLICKED, (void*)user); + lv_obj_center(label(b, text, THEME_FONT_SMALL, theme::TEXT)); + return b; +} + +// ── Settings kit ── +// Every settings page is built from the same few pieces: group() -- a section +// title and the card its rows sit on, hairlines between them -- and rows on +// it: settingRow() (label, hint under it, a control on the right: switch, +// choice, slider, segmented), listRow() (opens something: a chevron), +// actionRow() (does something now) and groupNote() (one line under a card). +// Rows built outside a group keep the older look: a card of their own. + +static inline bool isGroup(lv_obj_t* o) { return o && lv_obj_has_flag(o, LV_OBJ_FLAG_USER_1); } + +static lv_obj_t* group(lv_obj_t* parent, const char* title) { + if (title) sectionTitle(parent, title); + lv_obj_t* c = infoCard(parent); + lv_obj_set_style_pad_hor(c, 0, 0); // rows pad themselves: a pressed row lights edge to edge + lv_obj_add_flag(c, LV_OBJ_FLAG_USER_1); + return c; +} + +// A row that lights up under the finger. In a group() the card clips its +// corners while a row is held, so the first / last row's light is rounded +// with it -- only then: clipping a card's corners costs a few ms every frame. +static void groupPressClip(lv_event_t* e) { + lv_obj_t* card = lv_obj_get_parent((lv_obj_t*)lv_event_get_current_target(e)); + if (isGroup(card)) lv_obj_set_style_clip_corner(card, lv_event_get_code(e) == LV_EVENT_PRESSED, 0); +} +static void rowPressable(lv_obj_t* row) { + lv_obj_add_flag(row, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_style_bg_color(row, lv_color_hex(theme::SURFACE_2), LV_STATE_PRESSED); + lv_obj_set_style_bg_opa(row, LV_OPA_COVER, LV_STATE_PRESSED); + lv_obj_add_event_cb(row, groupPressClip, LV_EVENT_PRESSED, NULL); + lv_obj_add_event_cb(row, groupPressClip, LV_EVENT_RELEASED, NULL); + lv_obj_add_event_cb(row, groupPressClip, LV_EVENT_PRESS_LOST, NULL); +} + +static lv_obj_t* groupNote(lv_obj_t* parent, const char* text) { + lv_obj_t* l = noteLabel(parent, text); + lv_obj_set_style_pad_hor(l, theme::PAD, 0); + return l; +} + +// A group row's box: full width, at least a touch target tall, a flex row +// with the text column growing and whatever follows it on the right. +static lv_obj_t* groupLine(lv_obj_t* card, bool tappable) { + lv_obj_t* r = infoLine(card); + lv_obj_set_style_pad_hor(r, theme::PAD, 0); + lv_obj_set_style_pad_ver(r, 6, 0); + lv_obj_set_style_min_height(r, theme::ROW_H, 0); + lv_obj_set_flex_flow(r, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(r, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_column(r, 8, 0); + if (tappable) rowPressable(r); + return r; +} + +// The label (and the hint under it, wrapping) filling a group row's left side. +static lv_obj_t* groupText(lv_obj_t* row, const char* text, const char* hint, uint32_t col = theme::TEXT) { + lv_obj_t* t = flexBox(row, LV_FLEX_FLOW_COLUMN); + lv_obj_set_width(t, 1); + lv_obj_set_flex_grow(t, 1); + lv_obj_set_style_pad_row(t, 1, 0); + // The row's touch-target height lives here: a row's own min height comes + // after its flex layout, which then sits everything at the top. + lv_obj_set_style_min_height(t, theme::ROW_H - 12, 0); + lv_obj_set_flex_align(t, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); + lv_obj_t* l = noteLabel(t, text, THEME_FONT_BODY, col); + if (hint) { + noteLabel(t, hint); + } + lv_obj_set_user_data(row, l); // rowTitle() + fitLater(row); + return l; +} + +// A group row's text column grows into what the controls on its right leave, +// and LVGL lays out children before their parent: its labels were first +// measured 1 px wide (a line per letter), then at the real width, and each +// height that changed took another pass up the screen -- four in all. Before +// a frame's layout, rowsFit() gives each new column the width the flex will: +// its row's minus the controls', worked out from their styles and text. The +// grow stays, so a control this can't size only costs the old passes. +static constexpr lv_obj_flag_t NOT_LAID_OUT = + (lv_obj_flag_t)(LV_OBJ_FLAG_HIDDEN | LV_OBJ_FLAG_FLOATING | LV_OBJ_FLAG_IGNORE_LAYOUT); + +// `o`'s outer width as the layout will make it; -1 when that's unclear. +static int32_t layoutW(lv_obj_t* o) { + int32_t w = lv_obj_get_style_width(o, LV_PART_MAIN); + if (LV_COORD_IS_PX(w)) { + // as it is + } else if (w != LV_SIZE_CONTENT) { + return -1; + } else if (lv_obj_check_type(o, &lv_label_class)) { + lv_obj_refr_size(o); // the label's own measure, cached for the layout + w = lv_obj_get_width(o); + } else if (lv_obj_get_style_layout(o, LV_PART_MAIN) == LV_LAYOUT_FLEX && + lv_obj_get_style_flex_flow(o, LV_PART_MAIN) == LV_FLEX_FLOW_ROW) { + int32_t gap = lv_obj_get_style_pad_column(o, LV_PART_MAIN), n = 0; + w = lv_obj_get_style_pad_left(o, LV_PART_MAIN) + lv_obj_get_style_pad_right(o, LV_PART_MAIN); + for (uint32_t i = 0; i < lv_obj_get_child_count(o); i++) { + lv_obj_t* c = lv_obj_get_child(o, i); + if (lv_obj_has_flag_any(c, NOT_LAID_OUT)) continue; + int32_t cw = layoutW(c); + if (cw < 0) return -1; + w += cw + (n++ ? gap : 0); + } + w = LV_CLAMP(lv_obj_get_style_min_width(o, LV_PART_MAIN), w, lv_obj_get_style_max_width(o, LV_PART_MAIN)); + } else { + return -1; + } + return w + lv_obj_get_style_margin_left(o, LV_PART_MAIN) + lv_obj_get_style_margin_right(o, LV_PART_MAIN); +} + +static void rowFit(lv_obj_t* row) { + lv_obj_refr_size(row); // its own width now: a child's % size is of it + int32_t w = innerW(row), gap = lv_obj_get_style_pad_column(row, LV_PART_MAIN); + if (w < 0) return; + lv_obj_t* grow = nullptr; + for (uint32_t i = 0; i < lv_obj_get_child_count(row); i++) { + lv_obj_t* c = lv_obj_get_child(row, i); + if (lv_obj_has_flag_any(c, NOT_LAID_OUT)) continue; + if (lv_obj_get_style_flex_grow(c, LV_PART_MAIN)) { + if (grow) return; // one grows, or the share isn't worth working out + grow = c; + continue; + } + int32_t cw = layoutW(c); + if (cw < 0) return; + w -= cw + gap; + } + if (!grow || w < 1) return; + lv_obj_set_width(grow, w); + if (lv_obj_check_type(grow, &lv_label_class)) { labelFit(grow, w); return; } + lv_obj_refr_size(grow); + for (uint32_t i = 0; i < lv_obj_get_child_count(grow); i++) { // its labels, 100 % of it + lv_obj_t* l = lv_obj_get_child(grow, i); + if (lv_obj_check_type(l, &lv_label_class)) labelFit(l, w); + } +} + +// ... and every label given a width in pixels, which labelFit() then measures +// at it (the screen titles, a list row's cut-off name ...). +static void rowsFit(lv_obj_t* root) { + lv_obj_tree_walk(root, [](lv_obj_t* o, void*) { + if (lv_obj_has_flag(o, LV_OBJ_FLAG_USER_2)) { + lv_obj_remove_flag(o, LV_OBJ_FLAG_USER_2); + rowFit(o); + } else if (lv_obj_check_type(o, &lv_label_class)) { + int32_t w = lv_obj_get_style_width(o, LV_PART_MAIN); + if (LV_COORD_IS_PX(w) && w > 0 && lv_obj_get_width(o) != w) labelFit(o, w); + } + return LV_OBJ_TREE_WALK_NEXT; + }, nullptr); +} +static void rowsFitPending() { + if (!s_fit_pending) return; + s_fit_pending = false; + if (s_fit_screen && s_fit_screen != lv_screen_active()) rowsFit(s_fit_screen); + rowsFit(lv_screen_active()); + rowsFit(lv_layer_top()); +} +// lv_obj_update_layout() for a screen just built: its rows fitted first. +static void layoutNow(lv_obj_t* o) { + rowsFitPending(); + lv_obj_update_layout(o); +} + +// The title label of a settingRow() / listRow(), to recolour or rename it. +static lv_obj_t* rowTitle(lv_obj_t* row) { return (lv_obj_t*)lv_obj_get_user_data(row); } + +// Rewrites the hint under a row's title in place (the label made right after +// it) -- a rebuild would cut short the animation of the switch just tapped. +static void rowHintSet(lv_obj_t* row, const char* hint) { + lv_obj_t* h = lv_obj_get_sibling(rowTitle(row), 1); + if (h && lv_obj_check_type(h, &lv_label_class)) lv_label_set_text(h, hint); +} + +// A settings row: the label left (a muted hint under it, cut at `hint_w`), +// room on the right for a switch / dropdown / slider. +static lv_obj_t* settingRow(lv_obj_t* parent, const char* text, const char* hint, int hint_w = 150) { + if (isGroup(parent)) { + lv_obj_t* row = groupLine(parent, false); + groupText(row, text, hint); + return row; + } + lv_obj_t* row = lv_obj_create(parent); + styleSurface(row, theme::SURFACE); + lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(row, LV_PCT(100), theme::ROW_H); + lv_obj_set_style_radius(row, theme::RADIUS, 0); + lv_obj_t* t = label(row, text, THEME_FONT_BODY, theme::TEXT); + lv_obj_align(t, LV_ALIGN_TOP_LEFT, theme::PAD, hint ? 5 : 13); + lv_obj_set_user_data(row, t); + if (hint) { + lv_obj_t* h = label(row, hint, THEME_FONT_SMALL, theme::TEXT_MUTED); + lv_label_set_long_mode(h, LV_LABEL_LONG_DOT); + lv_obj_set_size(h, hint_w, 15); // fixed height: cut, don't wrap onto the label + lv_obj_align(h, LV_ALIGN_BOTTOM_LEFT, theme::PAD, -5); + } + return row; +} + +// A slider on the right of a settingRow(). Its track is the theme's 20 % +// accent tint worked out over the card, opaque: blended, it cost ~3 ms a frame. +static lv_obj_t* rowSlider(lv_obj_t* row, int32_t min, int32_t max, int32_t v) { + lv_obj_t* sl = lv_slider_create(row); + lv_slider_set_range(sl, min, max); + lv_slider_set_value(sl, v, LV_ANIM_OFF); + lv_obj_set_size(sl, 150, 8); + lv_obj_set_style_margin_right(sl, 8, 0); + lv_obj_set_ext_click_area(sl, 14); + lv_obj_set_style_bg_color(sl, lv_color_hex(theme::mix(theme::ACCENT, theme::SURFACE, 20)), LV_PART_MAIN); + lv_obj_set_style_bg_opa(sl, LV_OPA_COVER, LV_PART_MAIN); + return sl; +} + +// Does something at once (Reboot, Delete ...): its text in `col`, no chevron. +// Returns the label, for a confirm-by-second-tap text. +static lv_obj_t* actionRow(lv_obj_t* card, const char* text, lv_event_cb_t cb, void* user, + uint32_t col = theme::TEXT) { + lv_obj_t* r = groupLine(card, true); + lv_obj_t* l = groupText(r, text, nullptr, col); + lv_obj_add_event_cb(r, cb, LV_EVENT_CLICKED, user); + return l; +} + +// ── Choice: a value picked from a list ── +// In place of a dropdown: the value and a chevron; a tap opens the options +// over the screen, and a pick sends LV_EVENT_VALUE_CHANGED like a dropdown. +struct Choice { + char* opts; // "\n"-separated, owned + uint16_t sel, count; + char title[32]; + lv_obj_t* value; +}; +static lv_obj_t* s_pick_overlay = nullptr; +static lv_obj_t* s_pick_target = nullptr; + +static Choice* choiceOf(lv_obj_t* c) { return (Choice*)lv_obj_get_user_data(c); } + +// Option `i` of a "\n"-list, into buf. +static const char* choiceItem(const Choice* ch, int i, char* buf, size_t n) { + const char* p = ch->opts; + for (int k = 0; k < i && p; k++) { p = strchr(p, '\n'); if (p) p++; } + if (!p) { buf[0] = '\0'; return buf; } + const char* e = strchr(p, '\n'); + size_t len = e ? (size_t)(e - p) : strlen(p); + if (len >= n) len = n - 1; + memcpy(buf, p, len); + buf[len] = '\0'; + return buf; +} + +static void choiceShow(lv_obj_t* c) { + Choice* ch = choiceOf(c); + char v[48]; + lv_label_set_text(ch->value, choiceItem(ch, ch->sel, v, sizeof(v))); +} + +static void choiceSetOptions(lv_obj_t* c, const char* opts) { + Choice* ch = choiceOf(c); + lv_free(ch->opts); + size_t n = strlen(opts); + ch->opts = (char*)lv_malloc(n + 1); + memcpy(ch->opts, opts, n + 1); + ch->count = 1; + for (const char* p = opts; *p; p++) if (*p == '\n') ch->count++; + if (ch->sel >= ch->count) ch->sel = 0; + choiceShow(c); +} + +static int choiceSelected(lv_obj_t* c) { return choiceOf(c)->sel; } +static void choiceSetSelected(lv_obj_t* c, int i) { + Choice* ch = choiceOf(c); + if (i < 0 || i >= ch->count) return; + ch->sel = (uint16_t)i; + choiceShow(c); +} + +static void pickerClose() { + if (s_pick_overlay) lv_obj_delete_async(s_pick_overlay); + s_pick_overlay = s_pick_target = nullptr; +} + +static void onPickOption(lv_event_t* e) { + lv_obj_t* c = s_pick_target; + pickerClose(); + if (!c) return; + choiceSetSelected(c, (int)(uintptr_t)lv_event_get_user_data(e)); + lv_obj_send_event(c, LV_EVENT_VALUE_CHANGED, NULL); // may rebuild the screen: c is gone after this +} + +static void pickerOpen(lv_obj_t* c) { + Choice* ch = choiceOf(c); + pickerClose(); + s_pick_target = c; + lv_obj_t* panel = popupOpen(lv_layer_top(), POP_FIT, s_pick_overlay, 24); + lv_obj_add_event_cb(s_pick_overlay, [](lv_event_t* e) { // a tap beside the list: nothing changes + if (lv_event_get_target(e) == lv_event_get_current_target(e)) pickerClose(); + }, LV_EVENT_CLICKED, NULL); + lv_obj_set_style_pad_all(panel, 6, 0); + lv_obj_t* t = label(panel, ch->title, THEME_FONT_SMALL, theme::TEXT_MUTED); + lv_obj_set_style_pad_hor(t, 4, 0); + lv_obj_t* card = group(panel, nullptr); + lv_obj_t* sel_row = nullptr; + // Each option one label, a fixed one-line row: a time zone list is ~30 of + // them, and a group row's box + text column + label took ~3 ms each. + const int32_t H = 38, lh = lv_font_get_line_height(THEME_FONT_BODY), w = innerW(card); + for (int i = 0; i < ch->count; i++) { + char v[48]; + bool cur = i == ch->sel; + lv_obj_t* r = label(card, choiceItem(ch, i, v, sizeof(v)), THEME_FONT_BODY, cur ? theme::ACCENT : theme::TEXT); + lv_label_set_long_mode(r, LV_LABEL_LONG_CLIP); + lv_obj_set_size(r, w > 0 ? w : LV_PCT(100), H); + lv_obj_set_style_pad_left(r, theme::PAD, 0); + lv_obj_set_style_pad_right(r, theme::PAD + 20, 0); // the tick's room + lv_obj_set_style_pad_top(r, (H - lh) / 2, 0); + if (i > 0) { + lv_obj_set_style_border_side(r, LV_BORDER_SIDE_TOP, 0); + lv_obj_set_style_border_width(r, 1, 0); + lv_obj_set_style_border_color(r, lv_color_hex(theme::SURFACE_2), 0); + } + rowPressable(r); + if (cur) { + lv_obj_t* t = label(r, LV_SYMBOL_OK, THEME_FONT_BODY, theme::ACCENT); + lv_obj_add_flag(t, LV_OBJ_FLAG_IGNORE_LAYOUT); + lv_obj_align(t, LV_ALIGN_RIGHT_MID, 20, 0); // into the right padding + sel_row = r; + } + lv_obj_add_event_cb(r, onPickOption, LV_EVENT_CLICKED, (void*)(uintptr_t)i); + } + if (sel_row) { layoutNow(panel); lv_obj_scroll_to_view_recursive(sel_row, LV_ANIM_OFF); } +} + +// A choice: in a group row the value is bare text (accent) and a chevron; +// elsewhere it sits on a small surface of its own. +static lv_obj_t* choiceCreate(lv_obj_t* parent, const char* opts, int sel, const char* title) { + lv_obj_t* c = lv_obj_create(parent); + lv_obj_remove_style_all(c); + lv_obj_add_flag(c, LV_OBJ_FLAG_CLICKABLE); + lv_obj_remove_flag(c, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(c, LV_SIZE_CONTENT, LV_SIZE_CONTENT); + lv_obj_set_flex_flow(c, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(c, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); // END + SIZE_CONTENT mis-sizes + lv_obj_set_style_pad_column(c, 6, 0); + lv_obj_set_ext_click_area(c, 10); + lv_obj_set_style_opa(c, LV_OPA_60, LV_STATE_PRESSED); + if (!isGroup(lv_obj_get_parent(parent))) { + lv_obj_set_style_bg_color(c, lv_color_hex(theme::SURFACE_2), 0); + lv_obj_set_style_bg_opa(c, LV_OPA_COVER, 0); + lv_obj_set_style_radius(c, theme::RADIUS_SM, 0); + lv_obj_set_style_pad_hor(c, 10, 0); + lv_obj_set_style_pad_ver(c, 8, 0); + } + Choice* ch = (Choice*)lv_malloc(sizeof(Choice)); + memset(ch, 0, sizeof(*ch)); + snprintf(ch->title, sizeof(ch->title), "%s", title ? title : ""); + ch->value = label(c, "", THEME_FONT_BODY, theme::ACCENT); + lv_obj_set_style_max_width(ch->value, 140, 0); // a long value wraps (LONG_DOT mis-sizes with SIZE_CONTENT) + lv_obj_set_style_text_align(ch->value, LV_TEXT_ALIGN_RIGHT, 0); + label(c, LV_SYMBOL_DOWN, THEME_FONT_SMALL, theme::TEXT_MUTED); + lv_obj_set_user_data(c, ch); + ch->sel = sel < 0 ? 0 : (uint16_t)sel; + choiceSetOptions(c, opts); + lv_obj_add_event_cb(c, [](lv_event_t* e) { pickerOpen((lv_obj_t*)lv_event_get_current_target(e)); }, + LV_EVENT_CLICKED, NULL); + lv_obj_add_event_cb(c, [](lv_event_t* e) { + lv_obj_t* o = (lv_obj_t*)lv_event_get_current_target(e); + if (s_pick_target == o) pickerClose(); + Choice* ch = choiceOf(o); + lv_free(ch->opts); + lv_free(ch); + }, LV_EVENT_DELETE, NULL); + return c; +} + + +// One settings row: label left, a choice right. +static lv_obj_t* choiceRow(lv_obj_t* parent, const char* text, const char* hint, const char* options, int sel, + lv_event_cb_t cb, void* user = nullptr) { + lv_obj_t* row = settingRow(parent, text, hint); + lv_obj_t* c = choiceCreate(row, options, sel, text); + lv_obj_align(c, LV_ALIGN_RIGHT_MID, -4, 0); + lv_obj_add_event_cb(c, cb, LV_EVENT_VALUE_CHANGED, user); + return c; +} + +// A value typed in a popup (frequency, name ...): shown as a choice is, with +// a pencil; the tap is the caller's. +static lv_obj_t* rowValue(lv_obj_t* row, const char* text, lv_event_cb_t cb, void* user = nullptr) { + lv_obj_t* c = flexBox(row, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(c, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_column(c, 6, 0); + lv_obj_add_flag(c, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_ext_click_area(c, 10); + lv_obj_set_style_opa(c, LV_OPA_60, LV_STATE_PRESSED); + label(c, text, THEME_FONT_BODY, theme::ACCENT); + label(c, LV_SYMBOL_EDIT, THEME_FONT_SMALL, theme::TEXT_MUTED); + lv_obj_add_event_cb(c, cb, LV_EVENT_CLICKED, user); + return c; +} + +// A tick on a listRow() (the one in use), left of its chevron. +static void rowCheck(lv_obj_t* row) { + lv_obj_t* l = label(row, LV_SYMBOL_OK, THEME_FONT_BODY, theme::ACCENT); + if (isGroup(lv_obj_get_parent(row))) lv_obj_move_to_index(l, (int32_t)lv_obj_get_child_count(row) - 2); + else lv_obj_align(l, LV_ALIGN_RIGHT_MID, -theme::PAD, 0); +} + +// The subtitle label of a listRow() / settingRow() with one, to update it. +static lv_obj_t* rowSub(lv_obj_t* row) { + if (isGroup(lv_obj_get_parent(row))) return lv_obj_get_child(lv_obj_get_child(row, 0), 1); + return lv_obj_get_child(row, 1); +} + +// Settings pages: the schema's, then this UI's own. Display, Power and Time +// split the schema's "Display & power" page (shared with L1, left as it is) +// into what their titles say. +enum : uint8_t { PG_KEYBOARD = settings::PG_COUNT, PG_ABOUT, PG_DISPLAY, PG_POWER, PG_TIME, PG_ALL }; + +// A schema setting by its NodePrefs field; -1 if there's none. +#define SETTING(field) settings::indexOf(offsetof(NodePrefs, field)) + +// A setting's current value as its choice shows it ("UTC+2", "3.4 V" ...). +static const char* settingText(const NodePrefs& p, int idx, char* buf, int n) { + buf[0] = '\0'; + return idx < 0 ? buf : settings::text(p, settings::ALL[idx], buf, n); +} +static lv_obj_t* s_sec_card[settings::SEC_COUNT]; // schemaRows()' groups, by section +static bool s_opts_from_map = false; // Map options opened from the map: back returns there +static int s_nav_section = -1; // Map options: one section of them (from the map), -1 all +static int32_t s_settings_y = 0; // Settings' scroll, kept while a page of it is open + +// A one-of-N choice: a row (or grid, with "\n" in the map) of checkable +// buttons, `sel` checked (-1: none). `item` is the unchecked button colour — +// SURFACE on a SURFACE_2 panel, SURFACE_2 on the page. +static lv_obj_t* segmented(lv_obj_t* parent, const char** map, int sel, int w, int h, + uint32_t item = theme::SURFACE_2) { + lv_obj_t* m = lv_buttonmatrix_create(parent); + lv_buttonmatrix_set_map(m, map); + lv_buttonmatrix_set_button_ctrl_all(m, LV_BUTTONMATRIX_CTRL_CHECKABLE); + lv_buttonmatrix_set_one_checked(m, true); + if (sel >= 0) lv_buttonmatrix_set_button_ctrl(m, sel, LV_BUTTONMATRIX_CTRL_CHECKED); + lv_obj_set_size(m, w, h); + lv_obj_set_style_pad_all(m, 0, 0); + lv_obj_set_style_pad_gap(m, 4, 0); + lv_obj_set_style_bg_opa(m, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(m, 0, 0); + lv_obj_set_style_bg_color(m, lv_color_hex(item), LV_PART_ITEMS); + lv_obj_set_style_bg_color(m, lv_color_hex(theme::ACCENT), LV_PART_ITEMS | LV_STATE_CHECKED); + lv_obj_set_style_text_color(m, lv_color_hex(theme::TEXT), LV_PART_ITEMS); + lv_obj_set_style_text_color(m, lv_color_hex(theme::BG), LV_PART_ITEMS | LV_STATE_CHECKED); + lv_obj_set_style_text_font(m, THEME_FONT_SMALL, LV_PART_ITEMS); + lv_obj_set_style_shadow_width(m, 0, LV_PART_ITEMS); + lv_obj_set_style_radius(m, theme::RADIUS_SM, LV_PART_ITEMS); + return m; +} + +// A one-line text field, full width. The theme's padding leaves less than one +// line inside 34 px, which makes the field scroll vertically (text jumps as +// it's typed), so it's padded to fit exactly one body line: 34 = 2*1 border + +// 2*6 pad + 20. The theme draws the cursor only while FOCUSED. +static lv_obj_t* textField(lv_obj_t* parent, const char* placeholder = NULL) { + lv_obj_t* ta = lv_textarea_create(parent); + lv_textarea_set_one_line(ta, true); + if (placeholder) lv_textarea_set_placeholder_text(ta, placeholder); + lv_obj_set_size(ta, LV_PCT(100), 34); + lv_obj_set_style_border_width(ta, 1, 0); + lv_obj_set_style_pad_ver(ta, 6, 0); + lv_obj_set_style_pad_hor(ta, 10, 0); + lv_obj_set_scrollbar_mode(ta, LV_SCROLLBAR_MODE_OFF); + lv_obj_set_style_border_color(ta, lv_color_hex(theme::ACCENT), LV_PART_CURSOR | LV_STATE_FOCUSED); + lv_obj_set_style_border_width(ta, 2, LV_PART_CURSOR | LV_STATE_FOCUSED); + return ta; +} +// A field's cursor shown (FOCUSED, blinking) or not. LVGL starts and stops +// the blink only as the cursor moves, so it's nudged here: left running on a +// field without focus, it redraws the field every 400 ms for nothing. +// (lv_keyboard_set_textarea() focuses the field it's given.) +static void fieldFocus(lv_obj_t* ta, bool on) { + if (!ta) return; + if (on) lv_obj_add_state(ta, LV_STATE_FOCUSED); + else lv_obj_remove_state(ta, LV_STATE_FOCUSED); + lv_textarea_set_cursor_pos(ta, lv_textarea_get_cursor_pos(ta)); +} + +// A field limited in UTF-8 bytes (user data: the cap) -- names and other text +// that is stored in a fixed buffer. LVGL's own limit counts characters. +static void onByteCapInsert(lv_event_t* e) { + lv_obj_t* ta = (lv_obj_t*)lv_event_get_target(e); + const char* ins = (const char*)lv_event_get_param(e); + size_t cap = (size_t)(uintptr_t)lv_event_get_user_data(e); + if (ins && strlen(lv_textarea_get_text(ta)) + strlen(ins) > cap) lv_textarea_set_insert_replace(ta, ""); +} + +// The primary action of a screen or popup: full amber, dark text (Theme.h); +// call after its label is made. +static void stylePrimary(lv_obj_t* b) { + lv_obj_set_style_bg_color(b, lv_color_hex(theme::ACCENT), 0); + for (uint32_t i = 0; i < lv_obj_get_child_count(b); i++) + lv_obj_set_style_text_color(lv_obj_get_child(b, i), lv_color_hex(theme::BG), 0); +} + +// Something selected / on (a tab, a chip, a row): the accent with dark text, +// or back to the surface with light text. Every label under it follows. +static void textUnder(lv_obj_t* o, uint32_t col) { + for (uint32_t i = 0; i < lv_obj_get_child_count(o); i++) { + lv_obj_t* c = lv_obj_get_child(o, i); + if (lv_obj_check_type(c, &lv_label_class)) setTextColor(c, col); + else textUnder(c, col); + } +} +static void styleSelected(lv_obj_t* b, bool on) { + lv_obj_set_style_bg_color(b, lv_color_hex(on ? theme::ACCENT : theme::SURFACE), 0); + lv_obj_set_style_bg_color(b, lv_color_hex(on ? theme::mix(theme::ACCENT, theme::BG, 75) : theme::SURFACE_2), LV_STATE_PRESSED); + textUnder(b, on ? theme::BG : theme::TEXT); +} + +// A row of equal buttons at the foot of a popup or screen: buttonBar(), then +// barButton() for each -- `accent` for the main action or one that's on +// (barButtonOn() flips it later). Returns the button; its label is child 0. +static lv_obj_t* buttonBar(lv_obj_t* parent) { + lv_obj_t* bar = lv_obj_create(parent); + styleSurface(bar, theme::BG); + lv_obj_remove_flag(bar, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(bar, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_flex_flow(bar, LV_FLEX_FLOW_ROW); + lv_obj_set_style_pad_column(bar, theme::GAP, 0); + return bar; +} +static void barButtonOn(lv_obj_t* b, bool on) { styleSelected(b, on); } +static lv_obj_t* barButton(lv_obj_t* bar, const char* text, lv_event_cb_t cb, uintptr_t user, bool accent = false) { + lv_obj_t* b = lv_button_create(bar); + lv_obj_set_height(b, 40); + lv_obj_set_flex_grow(b, 1); + lv_obj_set_style_pad_hor(b, 4, 0); + lv_obj_set_style_radius(b, theme::RADIUS, 0); + lv_obj_set_style_shadow_width(b, 0, 0); + lv_obj_add_event_cb(b, cb, LV_EVENT_CLICKED, (void*)user); + lv_obj_center(label(b, text, THEME_FONT_SMALL, theme::TEXT)); + barButtonOn(b, accent); + return b; +} + +// Asking before something that can't be undone: under navPopupPanel("...?"), +// what happens in a sentence and the red button that does it. +static lv_obj_t* confirmBody(lv_obj_t* panel, const char* text, const char* button, lv_event_cb_t cb, uintptr_t user) { + noteLabel(panel, text); + lv_obj_t* b = lv_button_create(panel); + lv_obj_set_size(b, LV_PCT(100), 40); + lv_obj_set_style_shadow_width(b, 0, 0); + lv_obj_set_style_radius(b, theme::RADIUS, 0); + lv_obj_set_style_bg_color(b, lv_color_hex(theme::FAIL), 0); + lv_obj_add_event_cb(b, cb, LV_EVENT_CLICKED, (void*)user); + lv_obj_center(label(b, button, THEME_FONT_BODY, theme::TEXT)); + return b; +} + +// A button that asks by itself before it destroys: the first tap puts `ask` +// on its label, a second tap within 3 s is the yes. Left alone, the label +// comes back and the next tap asks again. One button asks at a time. +static lv_obj_t* s_ask_lbl = nullptr; +static char s_ask_text[64]; +static lv_timer_t* s_ask_timer = nullptr; +static void onAskDeleted(lv_event_t* e); +static void askReset() { + if (s_ask_timer) { lv_timer_delete(s_ask_timer); s_ask_timer = nullptr; } + if (!s_ask_lbl) return; + lv_obj_remove_event_cb(s_ask_lbl, onAskDeleted); + lv_label_set_text(s_ask_lbl, s_ask_text); + s_ask_lbl = nullptr; +} +static void onAskDeleted(lv_event_t* e) { (void)e; s_ask_lbl = nullptr; askReset(); } +static bool tapConfirmed(lv_obj_t* lbl, const char* ask) { + bool yes = lbl && lbl == s_ask_lbl; + askReset(); + if (yes || !lbl) return yes; + s_ask_lbl = lbl; + snprintf(s_ask_text, sizeof(s_ask_text), "%s", lv_label_get_text(lbl)); + lv_label_set_text(lbl, ask); + lv_obj_add_event_cb(lbl, onAskDeleted, LV_EVENT_DELETE, NULL); + s_ask_timer = lv_timer_create([](lv_timer_t*) { s_ask_timer = nullptr; askReset(); }, 3000, NULL); + lv_timer_set_repeat_count(s_ask_timer, 1); + return false; +} + +// "@[nick]" -> "@nick" in place, for one-line text (previews, quotes) where +// the bubble's highlight doesn't reach. +static void plainMentions(char* t) { + char* w = t; + for (const char* r = t; *r;) { + const char* close = r[0] == '@' && r[1] == '[' ? strchr(r + 2, ']') : nullptr; + if (close && close - r <= 34) { + *w++ = '@'; + for (const char* q = r + 2; q < close;) *w++ = *q++; + r = close + 1; + } else { + *w++ = *r++; + } + } + *w = '\0'; +} + +// Unread badge: amber pill with a count, on the right edge of `parent`. +static void badge(lv_obj_t* parent, int n, bool overflow) { + if (n <= 0) return; + lv_obj_t* b = lv_obj_create(parent); + lv_obj_remove_flag(b, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(b, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(b, LV_SIZE_CONTENT, 20); + lv_obj_set_style_bg_color(b, lv_color_hex(theme::ACCENT), 0); + lv_obj_set_style_border_width(b, 0, 0); + lv_obj_set_style_radius(b, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_pad_hor(b, 7, 0); + lv_obj_set_style_pad_ver(b, 0, 0); + lv_obj_align(b, LV_ALIGN_RIGHT_MID, -theme::PAD, 0); + lv_obj_t* l = label(b, "", THEME_FONT_SMALL, theme::BG); + lv_label_set_text_fmt(l, "%d%s", n, overflow ? "+" : ""); + lv_obj_center(l); +} + +// A vlist row's badge (its third child): made the first time there's a +// count, then its count set, hidden at none. +static void rowBadge(lv_obj_t* row, int n, bool overflow) { + lv_obj_t* b = lv_obj_get_child(row, 2); + if (!b) { badge(row, n, overflow); return; } + lv_obj_set_flag(b, LV_OBJ_FLAG_HIDDEN, n <= 0); + if (n <= 0) return; + char t[8]; + snprintf(t, sizeof(t), "%d%s", n, overflow ? "+" : ""); + setText(lv_obj_get_child(b, 0), t); +} + +// The same on a Home tile: in its top-right corner, clear of the icon. +static void tileBadge(lv_obj_t* tile, int n) { + if (n <= 0) return; + badge(tile, n, false); + lv_obj_align(lv_obj_get_child(tile, -1), LV_ALIGN_TOP_RIGHT, -2, 2); +} + +// Local time (NodePrefs::tz_offset_hours), now or at `utc`; false before the clock is set. +static bool localTime(const NodePrefs* p, struct tm& out, uint32_t utc = rtc_clock.getCurrentTime()) { + return localTm(utc, p ? p->tz_offset_hours : 0, out); +} +static const char* const MONTHS[] = { "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" }; +// "14:05", or "2:05" (+ " PM" with suffix) with Settings > 12-hour clock; +// with `seconds`, ":09" follows unless Settings > Time > Clock seconds is off. +static void fmtClock(char* b, size_t n, const struct tm& ti, const NodePrefs* p, bool suffix, bool seconds = false) { + char sec[4] = ""; + if (seconds && p && !p->clock_hide_seconds) snprintf(sec, sizeof(sec), ":%02d", ti.tm_sec); + if (p && p->clock_12h) { + int h = ti.tm_hour % 12; + snprintf(b, n, "%d:%02d%s%s", h ? h : 12, ti.tm_min, sec, suffix ? (ti.tm_hour < 12 ? " AM" : " PM") : ""); + } else { + snprintf(b, n, "%02d:%02d%s", ti.tm_hour, ti.tm_min, sec); + } +} +// "Thu 25 Sep 2026". +static void fmtDate(char* b, size_t n, const struct tm& ti) { + static const char* DOW[] = { "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat" }; + snprintf(b, n, "%s %d %s %d", DOW[ti.tm_wday], ti.tm_mday, MONTHS[ti.tm_mon], ti.tm_year + 1900); +} + +// Settings changed here reach flash once their screen is left (as on the L1: +// a run of switches is one write, and the flash wears less), just after the +// next screen has come in -- a save stalls the UI (0.1-0.6 s on the ESP32's +// SPIFFS, far less on LittleFS). Else when the screen goes off, or a minute on. NodePrefs +// itself changes at once; shutdown() saves whatever is pending. +static uint32_t s_prefs_dirty_ms = 0; // when the first unsaved change came (0: none) +static uint32_t s_prefs_save_at = 0; // the screen was left: save from then (0: not yet) +static void prefsSave() { if (!s_prefs_dirty_ms) s_prefs_dirty_ms = millis() | 1; } +static void prefsSaveSoon(uint32_t delay_ms) { + if (s_prefs_dirty_ms && !s_prefs_save_at) s_prefs_save_at = (millis() + delay_ms) | 1; +} +static void prefsFlush() { + if (!s_prefs_dirty_ms) return; + bool due = s_prefs_save_at ? (int32_t)(millis() - s_prefs_save_at) >= 0 : millis() - s_prefs_dirty_ms >= 60000; + if (!due) return; + s_prefs_dirty_ms = s_prefs_save_at = 0; + the_mesh.savePrefs(); +} + +// The big clock in the boot splash's lettering, as ui-new draws it on the +// OLED and e-ink (ui-core/Lettering.h: block digits in 3 px strokes): HH:MM +// at SC x, the seconds after them at SC_SEC x on the same baseline. The +// lettering leans a glyph row at a time, steps SC px tall at this size; +// here the lean is worked out per screen row, so it's a smooth slant. +#include "../ui-core/Lettering.h" +namespace bigclock { +static const int SC = 4, SC_SEC = 2; +struct Text { char hm[8]; char sec[4]; }; + +static int lean(int py, int h, int sc) { return (2 * sc * (h - 1 - py) + (h - 1) / 2) / (h - 1); } // 2 glyph px at the top, 0 at the bottom +static int32_t width(const char* t, int sc) { + const int w = lettering::textW(t, 0); + return w <= 0 ? 0 : w * sc + ((int)strlen(t) - 1) * (sc + 1); +} +static void draw(lv_layer_t* layer, int32_t x, int32_t y, const char* t, int sc, lv_color_t col) { + const int h = lettering::LOGO_H * sc; + lv_draw_rect_dsc_t d; + lv_draw_rect_dsc_init(&d); + d.bg_color = col; + for (const char* p = t; *p; p++) { + const lettering::PixGlyph* g = lettering::pixGlyph(*p); + if (!g) continue; + const int gw = (int)strlen(g->rows[0]); + for (int py = 0; py < h; ) { + const int r = py / sc, off = lean(py, h, sc); + int end = py + 1; // the rows under it on the same glyph row, leaning as far + while (end < h && end / sc == r && lean(end, h, sc) == off) end++; + const char* row = g->rows[r]; + for (int c = 0; c < gw; c++) { // runs of ink as one rect + if (row[c] != '#') continue; + int e = c; + while (e + 1 < gw && row[e + 1] == '#') e++; + lv_area_t a = { x + off + c * sc, y + py, x + off + (e + 1) * sc - 1, y + end - 1 }; + lv_draw_rect(layer, &d, &a); + c = e; + } + py = end; + } + x += (gw + 2) * sc + sc + 1; + } +} + +static void onDraw(lv_event_t* e) { + lv_obj_t* o = (lv_obj_t*)lv_event_get_target(e); + const Text* t = (const Text*)lv_obj_get_user_data(o); + if (!t) return; + lv_area_t a; + lv_obj_get_coords(o, &a); + const lv_color_t col = lv_obj_get_style_text_color(o, LV_PART_MAIN); + draw(lv_event_get_layer(e), a.x1, a.y1, t->hm, SC, col); + if (t->sec[0]) + draw(lv_event_get_layer(e), a.x1 + width(t->hm, SC) + 3 * SC, a.y2 + 1 - lettering::LOGO_H * SC_SEC, t->sec, SC_SEC, col); +} +static void onDelete(lv_event_t* e) { lv_free(lv_obj_get_user_data((lv_obj_t*)lv_event_get_target(e))); } + +static lv_obj_t* create(lv_obj_t* parent) { + lv_obj_t* o = lv_obj_create(parent); + lv_obj_remove_style_all(o); + lv_obj_remove_flag(o, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_style_text_color(o, lv_color_hex(theme::TEXT), 0); + Text* t = (Text*)lv_malloc(sizeof(Text)); + if (t) { snprintf(t->hm, sizeof(t->hm), "--:--"); t->sec[0] = 0; } + lv_obj_set_user_data(o, t); + lv_obj_set_size(o, width("--:--", SC), lettering::LOGO_H * SC); + lv_obj_add_event_cb(o, onDraw, LV_EVENT_DRAW_MAIN_END, NULL); + lv_obj_add_event_cb(o, onDelete, LV_EVENT_DELETE, NULL); + return o; +} +static void set(lv_obj_t* o, const char* hm, const char* sec) { + Text* t = (Text*)lv_obj_get_user_data(o); + if (!t || (!strcmp(t->hm, hm) && !strcmp(t->sec, sec))) return; + snprintf(t->hm, sizeof(t->hm), "%s", hm); + snprintf(t->sec, sizeof(t->sec), "%s", sec); + const int32_t w = width(hm, SC) + (sec[0] ? 3 * SC + width(sec, SC_SEC) : 0); + if (w != lv_obj_get_width(o)) lv_obj_set_width(o, w); + lv_obj_invalidate(o); +} +} // namespace bigclock + +// A big clock (Home, the lock screen): the digits and, on a 12-hour clock, +// AM / PM beside them in `small`, on the digits' baseline. `big` null: the +// digits in the lettering (bigclock above). clockFaceSet() once a second; +// "--:--" until the time is known. +static lv_obj_t* clockFace(lv_obj_t* parent, const lv_font_t* big, const lv_font_t* small) { + lv_obj_t* f = flexBox(parent, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(f, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_END, LV_FLEX_ALIGN_END); + lv_obj_set_style_pad_column(f, big ? 4 : 3 * bigclock::SC, 0); + if (big) label(f, "--:--", big, theme::TEXT); + else { bigclock::create(f); lv_obj_set_style_pad_bottom(f, 6, 0); } // clear of the line under it + lv_obj_t* ap = label(f, "", small, theme::TEXT_MUTED); + if (big) lv_obj_set_style_pad_bottom(ap, big->base_line - small->base_line, 0); + else lv_obj_set_style_translate_y(ap, small->base_line, 0); // its baseline on the digits' foot + lv_obj_add_flag(ap, LV_OBJ_FLAG_HIDDEN); + return f; +} +static void clockFaceSet(lv_obj_t* f, const struct tm* ti, const NodePrefs* p) { + lv_obj_t* digits = lv_obj_get_child(f, 0); + lv_obj_t* ap = lv_obj_get_child(f, 1); + if (lv_obj_check_type(digits, &lv_label_class)) { + char clk[12] = "--:--"; + if (ti) fmtClock(clk, sizeof(clk), *ti, p, false, true); + setText(digits, clk); + } else { + char hm[8] = "--:--", sec[4] = ""; + if (ti) { + if (p && p->clock_12h) snprintf(hm, sizeof(hm), "%d:%02d", ti->tm_hour % 12 ? ti->tm_hour % 12 : 12, ti->tm_min); + else snprintf(hm, sizeof(hm), "%02d:%02d", ti->tm_hour, ti->tm_min); + if (!p || !p->clock_hide_seconds) snprintf(sec, sizeof(sec), "%02d", ti->tm_sec); + } + bigclock::set(digits, hm, sec); + } + bool h12 = ti && p && p->clock_12h; + if (h12) setText(ap, ti->tm_hour < 12 ? "AM" : "PM"); + if (h12 != !lv_obj_has_flag(ap, LV_OBJ_FLAG_HIDDEN)) { + if (h12) lv_obj_remove_flag(ap, LV_OBJ_FLAG_HIDDEN); + else lv_obj_add_flag(ap, LV_OBJ_FLAG_HIDDEN); + } +} +// A message's age ("12s" / "5m" / "3h" / "2d"). After a restart the clock runs +// from the build date until GPS or the app sets it, so messages restored from +// the card look newer than "now": those show when they came ("25 Sep 14:05"). +static const NodePrefs* s_prefs = nullptr; // for the free helpers below; set in begin() +static bool clockBehind(uint32_t now, uint32_t ts) { return ts > 1000000000UL && ts > now + 120; } +static void fmtMsgAge(char* b, size_t n, uint32_t now, uint32_t ts, const NodePrefs* p) { + if (!clockBehind(now, ts)) { geo::fmtAgeShort(b, (int)n, now, ts ? ts : now); return; } + struct tm ti; + localTime(p, ti, ts); // set: clockBehind() says ts is + char clk[12]; + fmtClock(clk, sizeof(clk), ti, p, true); + snprintf(b, n, "%d %s %s", ti.tm_mday, MONTHS[ti.tm_mon], clk); +} + +static void contactName(const uint8_t* prefix, char* out, size_t n) { + ContactInfo c; + if (MessageHistory::contactByPrefix(prefix, c) && c.name[0]) snprintf(out, n, "%s", c.name); + else snprintf(out, n, "%02X%02X%02X%02X", prefix[0], prefix[1], prefix[2], prefix[3]); +} diff --git a/examples/companion_radio/ui-lvgl/lv_conf.h b/examples/companion_radio/ui-lvgl/lv_conf.h index e17bdb58..ccd73f93 100644 --- a/examples/companion_radio/ui-lvgl/lv_conf.h +++ b/examples/companion_radio/ui-lvgl/lv_conf.h @@ -53,7 +53,7 @@ // Spans: message text with @[nick] mentions picked out (UITask.cpp msgText). #define LV_USE_SPAN 1 -// Snapshots: the screen under an open popup, frozen dimmed (UITask.cpp popupFreeze). +// Snapshots: the screen under an open popup, frozen dimmed (Widgets.h, namespace freeze). #define LV_USE_SNAPSHOT 1 #define LV_USE_THEME_DEFAULT 1