refactor(ui-lvgl): widgets, the virtual list, chats, nodes, thread and settings out of UITask.cpp

A pure move: each section now sits in its own header, included where its
code was, so the order of definitions is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-10-11 21:26:59 +02:00
co-authored by Claude Opus 5.5
parent e896e6af60
commit 4e95647c0d
8 changed files with 3791 additions and 3761 deletions
+393
View File
@@ -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<uint8_t[PUB_KEY_SIZE]>(CONTACT_ROWS_MAX);
static uint16_t* s_contact_raw = psramBuf<uint16_t>(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<uint8_t[PUB_KEY_SIZE]>(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);
}
@@ -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;
}
}
@@ -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 <class Dark>
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();
}
@@ -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<MsgLoc>(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<ChHistEntry>(THREAD_MAX_SHOWN);
static DmHistEntry* s_th_dm = psramBuf<DmHistEntry>(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<MsgMeta>(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<ThreadRow>(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();
}
File diff suppressed because it is too large Load Diff
@@ -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
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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