perf(l2): long lists build only the rows on screen

New message, Nodes (and its Scan popup), Admin and Messages show their
items through one virtual list: a screenful of rows, handed to the next
item as one scrolls out, the scroll extent from the items' heights. 256
contacts: 522 objects and ~950 ms of filling in (frames up to 96 ms)
before, 19 objects at once now; a scrolled frame 28.5 -> 11.7 ms. Nodes
257 -> 49 objects. A new message rewrites Messages' rows in place
(46 -> 20 ms) instead of building the screen again; it's rebuilt only
when "Read all" comes or goes. A held row keeps what it shows until let
go. Perf tools: -D PERF_LISTS, -D PERF_LIST_MUL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-10-01 20:56:14 +02:00
co-authored by Claude Opus 5.5
parent 71e3dd8a8b
commit c8e1f4a604
7 changed files with 531 additions and 158 deletions
+22 -16
View File
@@ -63,7 +63,7 @@ static void onAdminPwKb(lv_event_t* e) { s_ui->adminLogin(lv_event_get_code(e) =
static void onAdminTextKb(lv_event_t* e) { s_ui->adminTextDone(lv_event_get_code(e) == LV_EVENT_READY); }
static void onAdminCancelWait(lv_event_t* e) { (void)e; s_ui->adminCancelWait(); }
static void onOpenAdminPick(lv_event_t* e) { (void)e; s_ui->showAdminPick(); }
static void onAdminPickRow(lv_event_t* e) { s_ui->adminPick((int)(uintptr_t)lv_event_get_user_data(e)); }
static void onAdminPickRow(lv_event_t* e) { s_ui->adminPick((int)vlist::arg(e)); }
namespace adminview {
static const int PICK_MAX = 128;
@@ -80,6 +80,8 @@ void UITask::showAdminPick() {
void UITask::buildAdminPick() {
lv_obj_t* body = newScreen("Admin", true);
vlist::attach(body);
vlist::begin(body);
int rows = 0;
for (int pass = 0; pass < 2; pass++) { // favourites first
for (int i = 0; i < the_mesh.getNumContacts() && rows < adminview::PICK_MAX; i++) {
@@ -92,23 +94,27 @@ void UITask::buildAdminPick() {
rows++;
}
}
if (rows) sectionTitle(body, "LOG IN TO");
fillStart(rows, 8, &UITask::adminPickRow); // the rest as the loop goes (fill)
if (rows) vlist::own(body, sectionTitle(body, "LOG IN TO"));
static const vlist::Kind ROW = {
theme::ROW_H,
[](lv_obj_t* list) { return vRow(list, onAdminPickRow, NULL); },
[](lv_obj_t* row, intptr_t i) {
ContactInfo c;
if (!the_mesh.getContactByIdx(adminview::s_pick_raw[i], c) || memcmp(c.id.pub_key, adminview::s_pick[i], PUB_KEY_SIZE) != 0) {
rowSet(row, "?", NULL); // deleted meanwhile
return;
}
char name[48];
snprintf(name, sizeof(name), "%s%s", contactctl::favourite(c) ? UI_SYMBOL_STAR " " : "", c.name);
rowSet(row, name, c.type == ADV_TYPE_ROOM ? "Room server" : "Repeater");
},
};
for (int i = 0; i < rows; i++) vlist::add(body, &ROW, i);
if (!rows) {
noteLabel(body, "No repeaters or room servers yet. They show up here once their advert is heard.",
THEME_FONT_BODY, theme::TEXT_MUTED);
vlist::own(body, noteLabel(body, "No repeaters or room servers yet. They show up here once their advert is heard.",
THEME_FONT_BODY, theme::TEXT_MUTED));
}
}
void UITask::adminPickRow(int i) {
ContactInfo c;
if (!the_mesh.getContactByIdx(adminview::s_pick_raw[i], c) || memcmp(c.id.pub_key, adminview::s_pick[i], PUB_KEY_SIZE) != 0) {
listRow(_body, "?", NULL, onAdminPickRow, (void*)(uintptr_t)i); // deleted meanwhile
return;
}
char name[48];
snprintf(name, sizeof(name), "%s%s", contactctl::favourite(c) ? UI_SYMBOL_STAR " " : "", c.name);
listRow(_body, name, c.type == ADV_TYPE_ROOM ? "Room server" : "Repeater", onAdminPickRow, (void*)(uintptr_t)i);
vlist::end(body);
}
void UITask::adminPick(int row) {
@@ -41,8 +41,10 @@ static void onChanDropdown(lv_event_t* e) {
choiceSelected((lv_obj_t*)lv_event_get_target(e)));
}
static void onChanRowHold(lv_event_t* e) {
intptr_t i = vlist::arg(e);
if (i < 0) return;
lv_indev_wait_release(lv_indev_active()); // the hold isn't also a tap that opens the channel
s_ui->channelMenu((int)(uintptr_t)lv_event_get_user_data(e));
s_ui->channelMenu((int)i);
}
static void onChanThreadMenu(lv_event_t* e) { (void)e; s_ui->channelMenu(-1); }
static void onChanAdd(lv_event_t* e) { (void)e; s_ui->showChannelEdit(-1); }
@@ -39,13 +39,20 @@ static void onMsgAction(lv_event_t* e) { s_ui->messageAction((uint8_t)(uintptr_t
static void onRoomLoginKb(lv_event_t* e) { s_ui->roomLoginDone(lv_event_get_code(e) == LV_EVENT_READY); }
static void onDMRowHold(lv_event_t* e) {
intptr_t r = vlist::arg(e);
if (r < 0 || !s_dm_known[r]) return; // no options for who isn't a contact
lv_indev_wait_release(lv_indev_active()); // the hold isn't also a tap that opens it
s_ui->conversationMenu(s_dm_rows[(uintptr_t)lv_event_get_user_data(e)]);
s_ui->conversationMenu(s_dm_rows[r]);
}
static void onRoomRow(lv_event_t* e) {
intptr_t r = vlist::arg(e);
if (r >= 0) s_ui->openRoom(s_room_rows[r]);
}
static void onRoomRow(lv_event_t* e) { s_ui->openRoom(s_room_rows[(uintptr_t)lv_event_get_user_data(e)]); }
static void onRoomRowHold(lv_event_t* e) {
intptr_t r = vlist::arg(e);
if (r < 0) return;
lv_indev_wait_release(lv_indev_active());
s_ui->conversationMenu(s_room_rows[(uintptr_t)lv_event_get_user_data(e)]);
s_ui->conversationMenu(s_room_rows[r]);
}
static void onConvThreadMenu(lv_event_t* e) { (void)e; s_ui->conversationMenu(nullptr); }
static void onMsgHold(lv_event_t* e) {
@@ -120,7 +127,7 @@ void UITask::roomPoll() {
if (o == RoomSessions::LOGGED_IN) {
showToast("Logged in", 1200);
if (mine && picking) openDM(s_login_key);
else if (_screen == SCR_CHATS && !_nav_overlay) buildChats();
else if (_screen == SCR_CHATS && !_nav_overlay) refreshChats();
} else if (o == RoomSessions::LOGIN_FAILED) {
showToast("Login failed - wrong password?", 3000);
if (mine && picking) roomLoginPopup(s_login_key);
@@ -383,5 +390,5 @@ void UITask::toggleChatFilter(uint8_t which) {
case CF_CONTACTS: _prefs->dm_show_all ^= 1; break;
}
prefsSave();
if (_screen == SCR_CONTACTS) buildContacts(); else buildChats();
if (_screen == SCR_CONTACTS) buildContacts(); else refreshChats();
}
@@ -240,7 +240,7 @@ void UITask::unlockScreen() {
s_lock = s_lock_clock = s_lock_date = s_lock_unread = s_lock_slider = s_pin_dots = s_pin_msg = nullptr;
_pin_entry[0] = '\0';
if (_screen == SCR_HOME) refreshHome();
else if (_screen == SCR_CHATS) buildChats(); // counts moved on while locked
else if (_screen == SCR_CHATS) refreshChats(); // counts moved on while locked
}
bool UITask::locked() const { return devview::s_lock != nullptr; }
@@ -238,5 +238,5 @@ void UITask::setBluetooth(bool on) {
void UITask::markAllRead() {
_core->markAllRead();
showToast("All marked read", 1200);
if (_screen == SCR_CHATS) buildChats();
if (_screen == SCR_CHATS) refreshChats();
}
+489 -130
View File
@@ -66,6 +66,8 @@ template <class T> static T* psramBuf(size_t n) {
return (T*)calloc(n, sizeof(T));
}
static bool s_chats_stale = false; // Messages: a refresh waits for a held row to be let go
#include "../ui-core/UiCore.h" // shared UI Core (header-only, this TU)
#include "../ui-core/NearbyModel.h"
#include "../ui-core/EtaTracker.h"
@@ -1165,6 +1167,18 @@ static void badge(lv_obj_t* parent, int n, bool overflow) {
lv_obj_center(l);
}
// A vlist row's badge (its third child): made the first time there's a
// count, then its count set, hidden at none.
static void rowBadge(lv_obj_t* row, int n, bool overflow) {
lv_obj_t* b = lv_obj_get_child(row, 2);
if (!b) { badge(row, n, overflow); return; }
lv_obj_set_flag(b, LV_OBJ_FLAG_HIDDEN, n <= 0);
if (n <= 0) return;
char t[8];
snprintf(t, sizeof(t), "%d%s", n, overflow ? "+" : "");
setText(lv_obj_get_child(b, 0), t);
}
// The same on a Home tile: in its top-right corner, clear of the icon.
static void tileBadge(lv_obj_t* tile, int n) {
if (n <= 0) return;
@@ -1336,7 +1350,10 @@ static constexpr uint32_t LOCK_OFF_MS = 30000; // the lock screen's own auto-o
#if defined(UI_PERF_TEST)
// Perf tools (-D UI_PERF_TEST, output lines start "PERF"): each settings page
// and most screens built and drawn once, draw profiles, a popup test, then a
// walk through the screens. -D PERF_KEEP_USB leaves the USB popup up;
// walk through the screens. -D PERF_LISTS times the long lists (opened until
// all is in, scrolled, refreshed); -D PERF_LIST_MUL fills New message's
// with the few contacts there are, repeated up to 256.
// -D PERF_KEEP_USB leaves the USB popup up;
// -D PERF_WRAP_TEXT plus -Wl,--wrap= of lv_text_get_size_attributes,
// lv_layout_apply, lv_obj_get_style_prop_internal and lv_obj_send_event adds
// what layout costs per screen (text measured, flex runs, style reads, events).
@@ -1779,6 +1796,71 @@ void UITask::loop() {
one("contacts", 0, &UITask::showContacts);
one("chats", 0, &UITask::showChats);
one("nearby", 0, &UITask::showNearby);
#ifdef PERF_LISTS
{ // -D PERF_LISTS: the long lists -- opened until all is built, scrolled, refreshed
auto list = [this](const char* tag, void (UITask::*fn)(), void (UITask::*again)()) {
showHome(); lv_refr_now(NULL);
size_t h0 = heap_caps_get_free_size(MALLOC_CAP_SPIRAM);
uint32_t t = micros();
(this->*fn)();
uint32_t b = micros() - t;
PerfFrames f;
uint32_t t0 = millis();
while (millis() - t0 < 3000 && (fillPending() || lv_anim_count_running() || millis() - t0 < 100)) {
uint32_t u = micros();
lv_timer_handler();
fillTick();
u = micros() - u;
if (u > 3000) { f.n++; f.sum += u; if (u > f.max) f.max = u; }
delay(1);
}
uint32_t ready = micros() - t;
uint32_t n = 0;
lv_obj_tree_walk(lv_screen_active(), [](lv_obj_t*, void* u) { ++*(uint32_t*)u; return LV_OBJ_TREE_WALK_NEXT; }, &n);
long kb = ((long)h0 - (long)heap_caps_get_free_size(MALLOC_CAP_SPIRAM)) / 1024;
struct S { lv_obj_t* o; int h; } s = { nullptr, 10 }; // the list: the most to scroll
lv_obj_tree_walk(lv_screen_active(), [](lv_obj_t* o, void* u) {
S* s = (S*)u;
int v = lv_obj_get_scroll_bottom(o) + lv_obj_get_scroll_y(o);
if (lv_obj_has_flag(o, LV_OBJ_FLAG_SCROLLABLE) && v > s->h) { s->h = v; s->o = o; }
return LV_OBJ_TREE_WALK_NEXT;
}, &s);
PerfFrames sc;
if (s.o) {
for (int i = 0; i < 60 && lv_obj_get_scroll_bottom(s.o) > 0; i++) { // a steady fling, 18 px a frame
lv_obj_scroll_by(s.o, 0, -18, LV_ANIM_OFF);
uint32_t u = micros();
lv_refr_now(NULL);
u = micros() - u;
sc.n++; sc.sum += u; if (u > sc.max) sc.max = u;
}
lv_obj_scroll_to_y(s.o, 0, LV_ANIM_OFF);
}
uint32_t tr = 0;
if (again) { // what a new message / a changed node costs
lv_refr_now(NULL);
tr = micros();
(this->*again)();
lv_refr_now(NULL);
tr = micros() - tr;
}
Serial.printf("PERF list %-9s open %5.1f ms, all built %6.1f ms (%2lu frames avg %4.1f max %5.1f) | %4lu objects, %4ld KB"
" | scroll %2lu frames avg %4.1f max %5.1f (list %d px) | refresh %5.1f ms\n",
tag, b / 1000.0f, ready / 1000.0f, (unsigned long)f.n, f.n ? f.sum / 1000.0f / f.n : 0.0f, f.max / 1000.0f,
(unsigned long)n, kb, (unsigned long)sc.n, sc.n ? sc.sum / 1000.0f / sc.n : 0.0f, sc.max / 1000.0f,
s.h, tr / 1000.0f);
};
bool all = _prefs->dm_show_all;
list("fav", &UITask::showContacts, nullptr);
_prefs->dm_show_all = true; // not saved
list("contacts", &UITask::showContacts, nullptr);
_prefs->dm_show_all = all;
list("chats", &UITask::showChats, &UITask::buildChats);
list("chats-new", &UITask::showChats, &UITask::refreshChats); // a new message, in place
list("nearby", &UITask::showNearby, &UITask::perfNearbyAgain);
list("admin", &UITask::showAdminPick, nullptr);
}
#endif
#ifdef PERF_CONTROLS
{ // -D PERF_CONTROLS: every control on the settings screens used once (perfControls)
auto ctls = [this](const char* tag, int page, void (UITask::*fn)()) {
@@ -1874,6 +1956,7 @@ void UITask::loop() {
refreshStatusBar();
refreshLock();
if (_screen == SCR_HOME) refreshHome();
if (s_chats_stale && !_nav_overlay) refreshChats(); // a row was held when it came
refreshDiag();
refreshCompass();
refreshGps();
@@ -2166,7 +2249,7 @@ void UITask::onMessageArrived(const UiEvent& ev) {
(ev.kind == UIEventType::channelMessage ? _thread_is_channel && ev.idx == _thread_channel
: !_thread_is_channel && memcmp(_thread_key, ev.key, 4) == 0);
if (!open_here) bannerShow(icon, ev.text, text); // not over the very conversation it's in
if (_screen == SCR_CHATS && !_nav_overlay) buildChats(); // new unread counts (not under an open popup)
if (_screen == SCR_CHATS && !_nav_overlay) refreshChats(); // new unread counts (not under an open popup)
}
bool UITask::isViewingChannel(uint8_t channel_idx) {
@@ -2714,10 +2797,193 @@ static void onPowerRow(lv_event_t* e);
// Home itself (pages, clock, minimap, apps) is HomeScreen.h.
// ── Virtual list ──────────────────────────────────────────────────────────────
// A long list (contacts, nodes, conversations) as only the rows it shows: a
// row scrolled out of sight takes the next item coming in. The items are
// kinds with a fixed height -- a row made once (make), its texts set for an
// item (bind) -- or fixed objects of their own (a section title, a note).
// The scroll extent comes from the items' heights, so the scrollbar, a throw
// and coming back to where it was work as with every row built. Built in the
// same time whatever the count; 256 contacts: 522 objects, 950 ms filling
// in and 28 ms a scrolled frame before, 19 objects and 13 ms now.
namespace vlist {
struct Kind {
int16_t h;
lv_obj_t* (*make)(lv_obj_t* list); // a row, its event callbacks on (they ask arg())
void (*bind)(lv_obj_t* row, intptr_t arg);
};
struct Item { const Kind* k; intptr_t arg; int32_t top; int16_t h; }; // k null: arg is its own object
struct Slot { lv_obj_t* o; const Kind* k; int item; int prev; };
static const int SLOTS = 32; // a screenful of the shortest kind, a few kinds
struct List {
lv_obj_t* obj;
Item* items;
int n, cap;
int32_t total;
bool placed; // the items' tops are known (not while a new set comes in)
Slot slots[SLOTS];
int nslots;
};
static List* s_lists[8]; // a screen's and a popup's, two screens while one slides in
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
// ── Conversation list ─────────────────────────────────────────────────────────
static void onOpenChannel(lv_event_t* e) {
s_ui->openChannel((uint8_t)(uintptr_t)lv_event_get_user_data(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.
@@ -2726,11 +2992,15 @@ static const int CONTACT_ROWS_MAX = 256; // "All" with a full contact table st
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) {
s_ui->openDM(s_dm_rows[(uintptr_t)lv_event_get_user_data(e)]);
intptr_t r = vlist::arg(e);
if (r >= 0) s_ui->openDM(s_dm_rows[r]);
}
static void onOpenContactRow(lv_event_t* e) {
s_ui->openDM(s_contact_rows[(uintptr_t)lv_event_get_user_data(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
@@ -2738,6 +3008,8 @@ 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);
@@ -2779,6 +3051,36 @@ static lv_obj_t* listRow(lv_obj_t* parent, const char* title, const char* sub,
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);
@@ -2857,19 +3159,64 @@ static lv_obj_t* headerButton(lv_obj_t* hdr, const char* text, lv_event_cb_t cb,
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 (unreadTotal() > 0) {
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_off);
int ch_rows = 0;
for (int pass = fav_first ? 0 : 1; pass < 2 && !chatFolded(FOLD_CHANNELS); pass++) {
@@ -2880,37 +3227,49 @@ void UITask::buildChats() {
if (ch_fav_only && !fav) continue;
if (fav_first && fav != (pass == 0)) continue;
ch_rows++;
char title[48], sub[64] = "";
snprintf(title, sizeof(title), "%s%s%s", fav ? UI_SYMBOL_STAR " " : "", ch.name,
chanctl::notif(_prefs, i) == chanctl::NOTIF_MUTED ? " " UI_SYMBOL_MUTE : "");
int n = _core->history.histCountForChannel(i);
if (n > 0) {
const ChHistEntry& e = _core->history.chAtPos(_core->history.histEntryForChannel(i, 0));
snprintf(sub, sizeof(sub), "%s", e.text);
plainMentions(sub);
}
lv_obj_t* row = listRow(body, title, sub[0] ? sub : NULL, onOpenChannel, (void*)(uintptr_t)i);
lv_obj_add_event_cb(row, onChanRowHold, LV_EVENT_LONG_PRESSED, (void*)(uintptr_t)i);
badge(row, _core->history.chUnread(i), _core->history.chUnreadOverflow(i));
vlist::add(body, &CHANNEL, i);
}
}
if (ch_rows == 0 && ch_fav_only && !chatFolded(FOLD_CHANNELS)) label(body, "No favourite channels", THEME_FONT_SMALL, theme::TEXT_MUTED);
lv_obj_t* add_ch = lv_button_create(body);
if (chatFolded(FOLD_CHANNELS)) lv_obj_add_flag(add_ch, LV_OBJ_FLAG_HIDDEN);
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));
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++) {
const DmHistEntry& e = _core->history.dmAtPos(_core->history.dmHistPosNewest(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;
@@ -2918,49 +3277,58 @@ void UITask::buildChats() {
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);
char name[48];
contactName(e.prefix, 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(_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);
lv_obj_t* row = listRow(body, name, sub, onOpenDMRow, (void*)(uintptr_t)rows);
if (known) lv_obj_add_event_cb(row, onDMRowHold, LV_EVENT_LONG_PRESSED, (void*)(uintptr_t)rows);
badge(row, _core->dmUnread(e.prefix), _core->dmUnreadOverflow(e.prefix));
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);
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();
const int MAX_ROOMS = ROOM_ROWS_MAX;
for (int pass = fav_first ? 0 : 1; pass < 2 && !chatFolded(FOLD_ROOMS); pass++) {
for (int i = 0; i < total && nrooms < MAX_ROOMS; i++) {
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);
char title[48], sub[64];
snprintf(title, sizeof(title), "%s%s", fav ? UI_SYMBOL_STAR " " : "", c.name);
if (_core->history.dmHistCountForContact(c.id.pub_key) > 0) {
const DmHistEntry& e = _core->history.dmAtPos(_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", _core->rooms.isLoggedIn(c.id.pub_key) ? "Logged in" : "Tap to log in");
}
lv_obj_t* row = listRow(body, title, sub, onRoomRow, (void*)(uintptr_t)nrooms);
lv_obj_add_event_cb(row, onRoomRowHold, LV_EVENT_LONG_PRESSED, (void*)(uintptr_t)nrooms);
s_room_raw[nrooms] = (uint16_t)(MAX_ANON_CONTACTS + i);
vlist::add(body, &ROOM, nrooms);
nrooms++;
}
}
if (nrooms == 0 && !chatFolded(FOLD_ROOMS))
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
@@ -2969,9 +3337,7 @@ void UITask::chatFold(int which) {
chatFolded(which); // loaded
s_chat_fold ^= 1 << which;
lvport::saveChatFold(s_chat_fold);
int y = _body ? lv_obj_get_scroll_y(_body) : 0;
buildChats();
if (_body) { layoutNow(_body); lv_obj_scroll_to_y(_body, y, LV_ANIM_OFF); }
refreshChats();
}
// ── Contact picker (start a DM) ───────────────────────────────────────────────
@@ -2984,7 +3350,10 @@ void UITask::showContacts() {
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
@@ -2998,14 +3367,24 @@ void UITask::buildContacts() {
s_contact_raw[rows] = (uint16_t)(MAX_ANON_CONTACTS + i);
rows++;
}
fillStart(rows, 8, &UITask::contactRow);
if (rows == 0) label(body, fav_only ? "No favourites - tap All" : "No contacts yet", THEME_FONT_BODY, theme::TEXT_MUTED);
}
void UITask::contactRow(int 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;
listRow(_body, same ? c.name : "?", NULL, onOpenContactRow, (void*)(uintptr_t)i); // "?": deleted meanwhile
#ifdef PERF_LIST_MUL
for (int k = 0, real = rows; real && rows < CONTACT_ROWS_MAX; k++, rows++) { // a long list from the few there are
memcpy(s_contact_rows[rows], s_contact_rows[k % real], PUB_KEY_SIZE);
s_contact_raw[rows] = s_contact_raw[k % real];
}
#endif
static const vlist::Kind ROW = {
theme::ROW_H,
[](lv_obj_t* list) { return vRow(list, onOpenContactRow, NULL); },
[](lv_obj_t* row, intptr_t i) {
ContactInfo c;
bool same = the_mesh.getContactByIdx(s_contact_raw[i], c) && memcmp(c.id.pub_key, s_contact_rows[i], PUB_KEY_SIZE) == 0;
rowSet(row, same ? c.name : "?", NULL); // "?": deleted meanwhile
},
};
for (int i = 0; i < rows; i++) vlist::add(body, &ROW, i);
if (rows == 0) vlist::own(body, label(body, fav_only ? "No favourites - tap All" : "No contacts yet", THEME_FONT_BODY, theme::TEXT_MUTED));
vlist::end(body);
}
// ── Nearby ────────────────────────────────────────────────────────────────────
@@ -3023,8 +3402,8 @@ static void onNearbyChip(lv_event_t* e) { s_ui->setNearbyFilter((uint8_t)(uintpt
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 onAdvertRow(lv_event_t* e); // QuickScreen.h
static void onNearbyRow(lv_event_t* e) { s_ui->openNode((int)(uintptr_t)lv_event_get_user_data(e)); }
static void onScanRow(lv_event_t* e) { s_ui->openScanNode((int)(uintptr_t)lv_event_get_user_data(e)); }
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)); }
@@ -3083,6 +3462,7 @@ void UITask::buildNearby() {
_nearby_status = label(body, "", THEME_FONT_SMALL, theme::TEXT_MUTED);
_nearby_list = scrollList(body);
vlist::attach(_nearby_list);
_nearby_sig = 0;
refreshNearbyList();
@@ -3124,6 +3504,7 @@ void UITask::showScanPopup() {
_scan_status = label(panel, "", THEME_FONT_SMALL, theme::TEXT_MUTED);
_scan_list = scrollList(panel);
vlist::attach(_scan_list);
_scan_sig = 0;
refreshScanPopup();
@@ -3148,28 +3529,35 @@ void UITask::refreshScanPopup() {
sig = sig * 31 + e.rssi * 7 + e.snr_x4 + e.is_known;
for (int k = 0; k < 4; k++) sig = sig * 31 + e.pub_key[k];
}
if (sig == _scan_sig) return;
if (sig == _scan_sig || vlist::pressed(_scan_list)) return; // held: once it's let go
_scan_sig = sig;
lv_obj_clean(_scan_list);
for (int i = 0; i < n; i++) {
const NearbyModel::Entry& e = _scan->at(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);
lv_obj_t* row = listRow(_scan_list, title, sub, onScanRow, (void*)(uintptr_t)i);
lv_obj_align(label(row, right, THEME_FONT_SMALL, theme::TEXT_MUTED), LV_ALIGN_RIGHT_MID, -theme::PAD, 0);
}
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) {
@@ -3212,36 +3600,6 @@ static void nearbyText(const NearbyModel::Entry& e, uint32_t now, bool imperial,
t.right_col = e.is_live ? theme::OK : theme::TEXT_MUTED;
}
void UITask::nearbyRow(int i) {
const NearbyModel::Entry& e = _nearby->at(i);
NearbyText t;
nearbyText(e, rtc_clock.getCurrentTime(), _prefs && _prefs->units_imperial, t);
lv_obj_t* row = listRow(_nearby_list, t.title, t.sub, onNearbyRow, (void*)(uintptr_t)i);
if (e.fav) lv_obj_set_style_text_color(rowTitle(row), lv_color_hex(theme::ACCENT), 0);
if (t.right[0]) lv_obj_align(label(row, t.right, THEME_FONT_SMALL, t.right_col), LV_ALIGN_RIGHT_MID, -theme::PAD, 0);
}
// Row i rewritten in place with what the model holds now.
void UITask::nearbyRowSet(lv_obj_t* row, int i) {
const NearbyModel::Entry& e = _nearby->at(i);
NearbyText t;
nearbyText(e, rtc_clock.getCurrentTime(), _prefs && _prefs->units_imperial, t);
lv_obj_t* title = rowTitle(row);
setText(title, t.title);
lv_color_t tc = lv_color_hex(e.fav ? theme::ACCENT : theme::TEXT);
if (!lv_color_eq(lv_obj_get_style_text_color(title, LV_PART_MAIN), tc)) lv_obj_set_style_text_color(title, tc, 0);
setText(rowSub(row), t.sub);
lv_obj_t* r = lv_obj_get_child(row, 2);
if (!r && t.right[0]) {
r = label(row, t.right, THEME_FONT_SMALL, t.right_col);
lv_obj_align(r, LV_ALIGN_RIGHT_MID, -theme::PAD, 0);
} else if (r) {
setText(r, t.right);
lv_color_t rc = lv_color_hex(t.right_col);
if (!lv_color_eq(lv_obj_get_style_text_color(r, LV_PART_MAIN), rc)) lv_obj_set_style_text_color(r, rc, 0);
}
}
void UITask::refreshNearbyList() {
if (!_nearby_list) return;
_nearby->refreshModel();
@@ -3254,30 +3612,31 @@ void UITask::refreshNearbyList() {
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) return;
if (sig == _nearby_sig || vlist::pressed(_nearby_list)) return; // held: once it's let go
_nearby_sig = sig;
// The same number of nodes (the usual case: ages, distances, signal
// change): the rows rewritten in place, not built again. Rows still to
// come while the list fills in are made from the model as it is then.
int built = (int)lv_obj_get_child_count(_nearby_list);
if (n > 0 && built > 0 && built + fillPending() == n && lv_obj_check_type(lv_obj_get_child(_nearby_list, 0), &lv_button_class)) {
for (int i = 0; i < built; i++) nearbyRowSet(lv_obj_get_child(_nearby_list, i), i);
return;
}
int32_t scroll = lv_obj_get_scroll_y(_nearby_list);
s_fill_n = 0; // rows still queued: of the old list
lv_obj_clean(_nearby_list);
fillStart(n, 6 + scroll / (theme::ROW_H + theme::GAP), &UITask::nearbyRow); // down to where it was
// 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) { return vRowValue(list, onNearbyRow); },
[](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);
},
};
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);
}
if (scroll > 0) {
layoutNow(_nearby_list);
lv_obj_scroll_to_y(_nearby_list, scroll, LV_ANIM_OFF);
}
vlist::end(_nearby_list);
}
// ── Node detail ───────────────────────────────────────────────────────────────
@@ -4541,7 +4900,7 @@ extern "C" EMSCRIPTEN_KEEPALIVE void sim_open(const char* name) {
{ "clock", &UITask::showClock }, { "radio", &UITask::showRadio }, { "scopes", &UITask::showScopes },
{ "repeater", &UITask::showRepeater }, { "bot", &UITask::showBot }, { "storage", &UITask::showStorage },
{ "diag", &UITask::showDiag }, { "compass", &UITask::showCompass }, { "admin", &UITask::showAdminPick },
{ "quick", &UITask::showQuickMsgs },
{ "quick", &UITask::showQuickMsgs }, { "contacts", &UITask::showContacts },
};
for (auto& s : SCREENS) if (!strcmp(s.n, name)) { (s_ui->*s.fn)(); return; }
if (!strncmp(name, "page", 4)) { s_ui->showSchemaSettings(atoi(name + 4)); return; }
+3 -4
View File
@@ -314,14 +314,16 @@ private:
bool homeMapFit();
#ifdef UI_PERF_TEST
void perfMap(bool reset = false);
void perfNearbyAgain() { _nearby_sig = 0; refreshNearbyList(); }
#endif
void homeMapLayout();
void homeMapLoop();
void refreshHome();
void homeSwipePoll();
void buildChats();
void refreshChats();
void fillChats();
void buildContacts();
void contactRow(int i);
void fill(void (UITask::*fn)(int), int from, int to);
void then(void (UITask::*fn)(int), int arg);
void fillStart(int n, int first, void (UITask::*row)(int));
@@ -341,7 +343,6 @@ private:
void buildKeyboardPage(lv_obj_t* body);
void buildAboutPage(lv_obj_t* body);
void buildAdminPick();
void adminPickRow(int i);
void buildOta();
void refreshOta();
void otaTick();
@@ -350,8 +351,6 @@ private:
bool wifiInUse() const; // a map download or an update holds the WiFi
void buildNearby();
void refreshNearbyList();
void nearbyRow(int i);
void nearbyRowSet(lv_obj_t* row, int i);
uint32_t nearbySignature() const;
void showScanPopup();
void refreshScanPopup();