mirror of
https://github.com/torlando-tech/pyxis.git
synced 2026-07-28 14:29:42 +00:00
- AnnounceListScreen::tick(): drop the pre-sort 64-item gather cap so the sort always sees the true newest destinations (the path table is bounded by USTORE_DEFAULT_MAX_RECS=400 and items allocate in PSRAM, so the gather is bounded); only the render stays capped at MAX_DISPLAY. - tick() now re-adds the freshly rendered item containers to the focus group and focuses the first. The deferred gather meant show() (which adds widgets to the group) ran before the containers existed and lv_obj_clean dropped the old ones, so trackball navigation couldn't reach the list items. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UWZuYkHBRqNb6BZHV8sTG5
480 lines
18 KiB
C++
480 lines
18 KiB
C++
// Copyright (c) 2024 microReticulum contributors
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
#include "AnnounceListScreen.h"
|
|
#include "Theme.h"
|
|
|
|
#ifdef ARDUINO
|
|
|
|
#include <microReticulum/Log.h>
|
|
#include "../LVGL/LVGLLock.h"
|
|
#include <microReticulum/Transport.h>
|
|
#include <microReticulum/Identity.h>
|
|
#include <microReticulum/Destination.h>
|
|
#include <microReticulum/Persistence/DestinationEntry.h> // RNS::Persistence::DestinationEntry (post-graft path)
|
|
#include <microReticulum/Utilities/OS.h>
|
|
#include "../LVGL/LVGLInit.h"
|
|
#include <MsgPack.h>
|
|
|
|
using namespace RNS;
|
|
|
|
namespace UI {
|
|
namespace LXMF {
|
|
|
|
AnnounceListScreen::AnnounceListScreen(lv_obj_t* parent)
|
|
: _screen(nullptr), _header(nullptr), _list(nullptr),
|
|
_btn_back(nullptr), _btn_refresh(nullptr), _btn_announce(nullptr), _empty_label(nullptr) {
|
|
LVGL_LOCK();
|
|
|
|
// Create screen object
|
|
if (parent) {
|
|
_screen = lv_obj_create(parent);
|
|
} else {
|
|
_screen = lv_obj_create(lv_scr_act());
|
|
}
|
|
|
|
lv_obj_set_size(_screen, LV_PCT(100), LV_PCT(100));
|
|
lv_obj_clear_flag(_screen, LV_OBJ_FLAG_SCROLLABLE);
|
|
lv_obj_set_style_bg_color(_screen, Theme::surface(), 0);
|
|
lv_obj_set_style_bg_opa(_screen, LV_OPA_COVER, 0);
|
|
lv_obj_set_style_pad_all(_screen, 0, 0);
|
|
lv_obj_set_style_border_width(_screen, 0, 0);
|
|
lv_obj_set_style_radius(_screen, 0, 0);
|
|
|
|
// Create UI components
|
|
create_header();
|
|
create_list();
|
|
|
|
// Hide by default
|
|
hide();
|
|
|
|
TRACE("AnnounceListScreen created");
|
|
}
|
|
|
|
AnnounceListScreen::~AnnounceListScreen() {
|
|
LVGL_LOCK();
|
|
if (_screen) {
|
|
lv_obj_del(_screen);
|
|
}
|
|
}
|
|
|
|
void AnnounceListScreen::create_header() {
|
|
_header = lv_obj_create(_screen);
|
|
lv_obj_set_size(_header, LV_PCT(100), 36);
|
|
lv_obj_align(_header, LV_ALIGN_TOP_MID, 0, 0);
|
|
lv_obj_set_style_bg_color(_header, Theme::surfaceHeader(), 0);
|
|
lv_obj_set_style_border_width(_header, 0, 0);
|
|
lv_obj_set_style_radius(_header, 0, 0);
|
|
lv_obj_set_style_pad_all(_header, 0, 0);
|
|
|
|
// Back button
|
|
_btn_back = lv_btn_create(_header);
|
|
lv_obj_set_size(_btn_back, 50, 28);
|
|
lv_obj_align(_btn_back, LV_ALIGN_LEFT_MID, 2, 0);
|
|
lv_obj_set_style_bg_color(_btn_back, Theme::btnSecondary(), 0);
|
|
lv_obj_set_style_bg_color(_btn_back, Theme::btnSecondaryPressed(), LV_STATE_PRESSED);
|
|
lv_obj_add_event_cb(_btn_back, on_back_clicked, LV_EVENT_CLICKED, this);
|
|
|
|
lv_obj_t* label_back = lv_label_create(_btn_back);
|
|
lv_label_set_text(label_back, LV_SYMBOL_LEFT);
|
|
lv_obj_center(label_back);
|
|
lv_obj_set_style_text_color(label_back, Theme::textSecondary(), 0);
|
|
|
|
// Title
|
|
lv_obj_t* title = lv_label_create(_header);
|
|
lv_label_set_text(title, "Announces");
|
|
lv_obj_align(title, LV_ALIGN_LEFT_MID, 60, 0);
|
|
lv_obj_set_style_text_color(title, Theme::textPrimary(), 0);
|
|
lv_obj_set_style_text_font(title, &lv_font_montserrat_16, 0);
|
|
|
|
// Send Announce button (green)
|
|
_btn_announce = lv_btn_create(_header);
|
|
lv_obj_set_size(_btn_announce, 65, 28);
|
|
lv_obj_align(_btn_announce, LV_ALIGN_RIGHT_MID, -70, 0);
|
|
lv_obj_set_style_bg_color(_btn_announce, Theme::primary(), 0);
|
|
lv_obj_set_style_bg_color(_btn_announce, Theme::primaryPressed(), LV_STATE_PRESSED);
|
|
lv_obj_add_event_cb(_btn_announce, on_send_announce_clicked, LV_EVENT_CLICKED, this);
|
|
|
|
lv_obj_t* label_announce = lv_label_create(_btn_announce);
|
|
lv_label_set_text(label_announce, LV_SYMBOL_BELL); // Bell icon for announce
|
|
lv_obj_center(label_announce);
|
|
lv_obj_set_style_text_color(label_announce, Theme::textPrimary(), 0);
|
|
|
|
// Refresh button
|
|
_btn_refresh = lv_btn_create(_header);
|
|
lv_obj_set_size(_btn_refresh, 65, 28);
|
|
lv_obj_align(_btn_refresh, LV_ALIGN_RIGHT_MID, -2, 0);
|
|
lv_obj_set_style_bg_color(_btn_refresh, Theme::btnSecondary(), 0);
|
|
lv_obj_set_style_bg_color(_btn_refresh, Theme::btnSecondaryPressed(), LV_STATE_PRESSED);
|
|
lv_obj_add_event_cb(_btn_refresh, on_refresh_clicked, LV_EVENT_CLICKED, this);
|
|
|
|
lv_obj_t* label_refresh = lv_label_create(_btn_refresh);
|
|
lv_label_set_text(label_refresh, LV_SYMBOL_REFRESH);
|
|
lv_obj_center(label_refresh);
|
|
lv_obj_set_style_text_color(label_refresh, Theme::textPrimary(), 0);
|
|
}
|
|
|
|
void AnnounceListScreen::create_list() {
|
|
_list = lv_obj_create(_screen);
|
|
lv_obj_set_size(_list, LV_PCT(100), 204); // 240 - 36 (header)
|
|
lv_obj_align(_list, LV_ALIGN_TOP_MID, 0, 36);
|
|
lv_obj_set_style_pad_all(_list, 4, 0);
|
|
lv_obj_set_style_pad_gap(_list, 4, 0);
|
|
lv_obj_set_style_bg_color(_list, Theme::surface(), 0);
|
|
lv_obj_set_style_border_width(_list, 0, 0);
|
|
lv_obj_set_style_radius(_list, 0, 0);
|
|
lv_obj_set_flex_flow(_list, LV_FLEX_FLOW_COLUMN);
|
|
lv_obj_set_flex_align(_list, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
|
}
|
|
|
|
void AnnounceListScreen::refresh() {
|
|
// Defer the real work to tick() on the main loop (UIManager::update). The
|
|
// gather (iterate the live path table + per-entry Identity::recall /
|
|
// recall_app_data) must NOT run on the LVGL task or hold the render lock:
|
|
// once the path table became non-empty it raced the main-loop path-table
|
|
// writes and blocked past LVGLLock's 5s timeout — hanging, then crashing, on
|
|
// open. refresh() is safe to call from any task; it just arms the tick.
|
|
_refresh_pending.store(true);
|
|
}
|
|
|
|
void AnnounceListScreen::tick() {
|
|
// Runs on the main loop (UIManager::update), so iterating Transport::path_table()
|
|
// and reading the identity cache is serialized with — never concurrent to — the
|
|
// main-loop announce processing that writes them (both run in the Arduino loop).
|
|
// No LVGL lock is held during the gather; only the final render briefly takes it.
|
|
if (!_refresh_pending.exchange(false)) {
|
|
return;
|
|
}
|
|
|
|
// GATHER (no LVGL lock) — build the item list off the render lock.
|
|
std::vector<AnnounceItem> items;
|
|
const auto& dest_table = Transport::path_table();
|
|
for (auto it = dest_table.begin(); it != dest_table.end(); ++it) {
|
|
const Bytes& dest_hash = it->first;
|
|
const RNS::Persistence::DestinationEntry& dest_entry = it->second;
|
|
|
|
// Only lxmf.delivery destinations with a known identity belong in the list.
|
|
Identity identity = Identity::recall(dest_hash);
|
|
if (!identity) {
|
|
continue;
|
|
}
|
|
Bytes expected_hash = Destination::hash(identity, "lxmf", "delivery");
|
|
if (dest_hash != expected_hash) {
|
|
continue;
|
|
}
|
|
|
|
AnnounceItem item;
|
|
item.destination_hash = dest_hash;
|
|
item.hash_display = truncate_hash(dest_hash);
|
|
item.hops = dest_entry._hops;
|
|
item.timestamp = dest_entry._timestamp;
|
|
item.timestamp_str = format_timestamp(dest_entry._timestamp);
|
|
item.has_path = true; // it's in the path table we're iterating — skip the store probe
|
|
Bytes app_data = Identity::recall_app_data(dest_hash);
|
|
if (app_data && app_data.size() > 0) {
|
|
item.display_name = parse_display_name(app_data);
|
|
}
|
|
items.push_back(item);
|
|
// No pre-sort cap: gather every matching lxmf.delivery destination so the
|
|
// sort below always sees the true newest. The path table is bounded by
|
|
// USTORE_DEFAULT_MAX_RECS (400) and these allocate in PSRAM, so the gather
|
|
// is bounded; only the render is capped (MAX_DISPLAY).
|
|
}
|
|
|
|
std::sort(items.begin(), items.end(),
|
|
[](const AnnounceItem& a, const AnnounceItem& b) {
|
|
return a.timestamp > b.timestamp;
|
|
});
|
|
|
|
{
|
|
char log_buf[64];
|
|
snprintf(log_buf, sizeof(log_buf), "Announce list: %zu lxmf.delivery destinations", items.size());
|
|
INFO(log_buf);
|
|
}
|
|
|
|
// RENDER (brief LVGL lock) — no store access here, capped item count.
|
|
LVGL_LOCK();
|
|
lv_obj_clean(_list);
|
|
_announce_containers.clear();
|
|
_dest_hash_pool.clear();
|
|
_empty_label = nullptr;
|
|
_announces = std::move(items);
|
|
|
|
if (_announces.empty()) {
|
|
show_empty_state();
|
|
} else {
|
|
const size_t MAX_DISPLAY = 20;
|
|
size_t display_count = std::min(_announces.size(), MAX_DISPLAY);
|
|
_dest_hash_pool.reserve(display_count);
|
|
_announce_containers.reserve(display_count);
|
|
size_t count = 0;
|
|
for (const auto& item : _announces) {
|
|
if (count >= MAX_DISPLAY) break;
|
|
create_announce_item(item);
|
|
count++;
|
|
}
|
|
}
|
|
|
|
// Re-add the freshly created item containers to the focus group for trackball
|
|
// navigation. The gather is deferred to this main-loop tick, so show() (which
|
|
// adds widgets to the group) ran before these containers existed, and the
|
|
// lv_obj_clean above dropped the previous ones. The announces screen is current
|
|
// (UIManager gates this tick on it), so focusing the first item here is safe.
|
|
lv_group_t* group = LVGL::LVGLInit::get_default_group();
|
|
if (group) {
|
|
for (lv_obj_t* container : _announce_containers) {
|
|
lv_group_add_obj(group, container);
|
|
}
|
|
if (!_announce_containers.empty()) {
|
|
lv_group_focus_obj(_announce_containers[0]);
|
|
}
|
|
}
|
|
}
|
|
|
|
void AnnounceListScreen::show_empty_state() {
|
|
_empty_label = lv_label_create(_list);
|
|
lv_label_set_text(_empty_label, "No announces yet\n\nWaiting for LXMF\ndestinations to announce...");
|
|
lv_obj_set_style_text_color(_empty_label, Theme::textMuted(), 0);
|
|
lv_obj_set_style_text_align(_empty_label, LV_TEXT_ALIGN_CENTER, 0);
|
|
lv_obj_align(_empty_label, LV_ALIGN_CENTER, 0, 0);
|
|
}
|
|
|
|
void AnnounceListScreen::create_announce_item(const AnnounceItem& item) {
|
|
// Create container for announce item - compact 2-row layout matching ConversationListScreen
|
|
lv_obj_t* container = lv_obj_create(_list);
|
|
lv_obj_set_size(container, LV_PCT(100), 44);
|
|
lv_obj_set_style_bg_color(container, Theme::surfaceContainer(), 0);
|
|
lv_obj_set_style_bg_color(container, Theme::surfaceElevated(), LV_STATE_PRESSED);
|
|
lv_obj_set_style_border_width(container, 1, 0);
|
|
lv_obj_set_style_border_color(container, Theme::border(), 0);
|
|
lv_obj_set_style_radius(container, 6, 0);
|
|
lv_obj_set_style_pad_all(container, 0, 0);
|
|
lv_obj_add_flag(container, LV_OBJ_FLAG_CLICKABLE);
|
|
lv_obj_clear_flag(container, LV_OBJ_FLAG_SCROLLABLE);
|
|
|
|
// Focus style for trackball navigation
|
|
lv_obj_set_style_border_color(container, Theme::info(), LV_STATE_FOCUSED);
|
|
lv_obj_set_style_border_width(container, 2, LV_STATE_FOCUSED);
|
|
lv_obj_set_style_bg_color(container, Theme::surfaceElevated(), LV_STATE_FOCUSED);
|
|
|
|
// Store destination hash in user data using pool (avoids per-item heap allocations)
|
|
_dest_hash_pool.push_back(item.destination_hash);
|
|
lv_obj_set_user_data(container, &_dest_hash_pool.back());
|
|
lv_obj_add_event_cb(container, on_announce_clicked, LV_EVENT_CLICKED, this);
|
|
|
|
// Track container for focus group management
|
|
_announce_containers.push_back(container);
|
|
|
|
// Row 1: Display name (if available) or destination hash
|
|
lv_obj_t* label_name = lv_label_create(container);
|
|
if (item.display_name.length() > 0) {
|
|
lv_label_set_text(label_name, item.display_name.c_str());
|
|
} else {
|
|
lv_label_set_text(label_name, item.hash_display.c_str());
|
|
}
|
|
lv_obj_align(label_name, LV_ALIGN_TOP_LEFT, 6, 4);
|
|
lv_obj_set_style_text_color(label_name, Theme::info(), 0);
|
|
lv_obj_set_style_text_font(label_name, &lv_font_montserrat_14, 0);
|
|
|
|
// Row 2: Hops info (left) + Timestamp (right)
|
|
lv_obj_t* label_hops = lv_label_create(container);
|
|
lv_label_set_text(label_hops, format_hops(item.hops).c_str());
|
|
lv_obj_align(label_hops, LV_ALIGN_BOTTOM_LEFT, 6, -4);
|
|
lv_obj_set_style_text_color(label_hops, Theme::textTertiary(), 0);
|
|
|
|
lv_obj_t* label_time = lv_label_create(container);
|
|
lv_label_set_text(label_time, item.timestamp_str.c_str());
|
|
lv_obj_align(label_time, LV_ALIGN_BOTTOM_RIGHT, -6, -4);
|
|
lv_obj_set_style_text_color(label_time, Theme::textMuted(), 0);
|
|
|
|
// Status indicator (green dot if has path) - on row 1, right side
|
|
if (item.has_path) {
|
|
lv_obj_t* status_dot = lv_obj_create(container);
|
|
lv_obj_set_size(status_dot, 8, 8);
|
|
lv_obj_align(status_dot, LV_ALIGN_TOP_RIGHT, -6, 8);
|
|
lv_obj_set_style_bg_color(status_dot, Theme::success(), 0);
|
|
lv_obj_set_style_radius(status_dot, LV_RADIUS_CIRCLE, 0);
|
|
lv_obj_set_style_border_width(status_dot, 0, 0);
|
|
lv_obj_set_style_pad_all(status_dot, 0, 0);
|
|
}
|
|
}
|
|
|
|
void AnnounceListScreen::set_announce_selected_callback(AnnounceSelectedCallback callback) {
|
|
_announce_selected_callback = callback;
|
|
}
|
|
|
|
void AnnounceListScreen::set_back_callback(BackCallback callback) {
|
|
_back_callback = callback;
|
|
}
|
|
|
|
void AnnounceListScreen::set_send_announce_callback(SendAnnounceCallback callback) {
|
|
_send_announce_callback = callback;
|
|
}
|
|
|
|
void AnnounceListScreen::show() {
|
|
LVGL_LOCK();
|
|
lv_obj_clear_flag(_screen, LV_OBJ_FLAG_HIDDEN);
|
|
lv_obj_move_foreground(_screen);
|
|
|
|
// Add widgets to focus group for trackball navigation
|
|
lv_group_t* group = LVGL::LVGLInit::get_default_group();
|
|
if (group) {
|
|
// Add header buttons first
|
|
if (_btn_back) lv_group_add_obj(group, _btn_back);
|
|
if (_btn_announce) lv_group_add_obj(group, _btn_announce);
|
|
if (_btn_refresh) lv_group_add_obj(group, _btn_refresh);
|
|
|
|
// Add announce containers
|
|
for (lv_obj_t* container : _announce_containers) {
|
|
lv_group_add_obj(group, container);
|
|
}
|
|
|
|
// Focus on first announce if available, otherwise back button
|
|
if (!_announce_containers.empty()) {
|
|
lv_group_focus_obj(_announce_containers[0]);
|
|
} else if (_btn_back) {
|
|
lv_group_focus_obj(_btn_back);
|
|
}
|
|
}
|
|
}
|
|
|
|
void AnnounceListScreen::hide() {
|
|
LVGL_LOCK();
|
|
// Remove from focus group when hiding
|
|
lv_group_t* group = LVGL::LVGLInit::get_default_group();
|
|
if (group) {
|
|
if (_btn_back) lv_group_remove_obj(_btn_back);
|
|
if (_btn_announce) lv_group_remove_obj(_btn_announce);
|
|
if (_btn_refresh) lv_group_remove_obj(_btn_refresh);
|
|
|
|
// Remove announce containers
|
|
for (lv_obj_t* container : _announce_containers) {
|
|
lv_group_remove_obj(container);
|
|
}
|
|
}
|
|
|
|
lv_obj_add_flag(_screen, LV_OBJ_FLAG_HIDDEN);
|
|
}
|
|
|
|
lv_obj_t* AnnounceListScreen::get_object() {
|
|
return _screen;
|
|
}
|
|
|
|
void AnnounceListScreen::on_announce_clicked(lv_event_t* event) {
|
|
AnnounceListScreen* screen = (AnnounceListScreen*)lv_event_get_user_data(event);
|
|
lv_obj_t* target = lv_event_get_target(event);
|
|
|
|
Bytes* dest_hash = (Bytes*)lv_obj_get_user_data(target);
|
|
|
|
if (dest_hash && screen->_announce_selected_callback) {
|
|
screen->_announce_selected_callback(*dest_hash);
|
|
}
|
|
}
|
|
|
|
void AnnounceListScreen::on_back_clicked(lv_event_t* event) {
|
|
AnnounceListScreen* screen = (AnnounceListScreen*)lv_event_get_user_data(event);
|
|
|
|
if (screen->_back_callback) {
|
|
screen->_back_callback();
|
|
}
|
|
}
|
|
|
|
void AnnounceListScreen::on_refresh_clicked(lv_event_t* event) {
|
|
AnnounceListScreen* screen = (AnnounceListScreen*)lv_event_get_user_data(event);
|
|
screen->refresh();
|
|
}
|
|
|
|
void AnnounceListScreen::on_send_announce_clicked(lv_event_t* event) {
|
|
AnnounceListScreen* screen = (AnnounceListScreen*)lv_event_get_user_data(event);
|
|
if (screen->_send_announce_callback) {
|
|
screen->_send_announce_callback();
|
|
}
|
|
}
|
|
|
|
std::string AnnounceListScreen::format_timestamp(double timestamp) {
|
|
double now = Utilities::OS::time();
|
|
double diff = now - timestamp;
|
|
char buf[16];
|
|
|
|
if (diff < 60) {
|
|
return "Just now";
|
|
} else if (diff < 3600) {
|
|
int mins = (int)(diff / 60);
|
|
snprintf(buf, sizeof(buf), "%dm ago", mins);
|
|
return buf;
|
|
} else if (diff < 86400) {
|
|
int hours = (int)(diff / 3600);
|
|
snprintf(buf, sizeof(buf), "%dh ago", hours);
|
|
return buf;
|
|
} else {
|
|
int days = (int)(diff / 86400);
|
|
snprintf(buf, sizeof(buf), "%dd ago", days);
|
|
return buf;
|
|
}
|
|
}
|
|
|
|
std::string AnnounceListScreen::format_hops(uint8_t hops) {
|
|
if (hops == 0) {
|
|
return "Direct";
|
|
} else if (hops == 1) {
|
|
return "1 hop";
|
|
} else {
|
|
char buf[16];
|
|
snprintf(buf, sizeof(buf), "%u hops", hops);
|
|
return buf;
|
|
}
|
|
}
|
|
|
|
std::string AnnounceListScreen::truncate_hash(const Bytes& hash) {
|
|
return hash.toHex();
|
|
}
|
|
|
|
std::string AnnounceListScreen::parse_display_name(const Bytes& app_data) {
|
|
if (app_data.size() == 0) {
|
|
return std::string();
|
|
}
|
|
|
|
uint8_t first_byte = app_data.data()[0];
|
|
|
|
// Check for msgpack array format (LXMF 0.5.0+)
|
|
// fixarray: 0x90-0x9f (array with 0-15 elements)
|
|
// array16: 0xdc
|
|
if ((first_byte >= 0x90 && first_byte <= 0x9f) || first_byte == 0xdc) {
|
|
// Msgpack encoded: [display_name, stamp_cost, ...]
|
|
MsgPack::Unpacker unpacker;
|
|
unpacker.feed(app_data.data(), app_data.size());
|
|
|
|
// Get array size
|
|
MsgPack::arr_size_t arr_size;
|
|
if (!unpacker.deserialize(arr_size)) {
|
|
return std::string();
|
|
}
|
|
|
|
if (arr_size.size() < 1) {
|
|
return std::string();
|
|
}
|
|
|
|
// First element is display_name (can be nil or bytes)
|
|
if (unpacker.isNil()) {
|
|
unpacker.unpackNil();
|
|
return std::string();
|
|
}
|
|
|
|
MsgPack::bin_t<uint8_t> name_bin;
|
|
if (unpacker.deserialize(name_bin)) {
|
|
// Convert bytes to string
|
|
return std::string((const char*)name_bin.data(), name_bin.size());
|
|
}
|
|
|
|
return std::string();
|
|
} else {
|
|
// Original format: raw UTF-8 string
|
|
return app_data.toString();
|
|
}
|
|
}
|
|
|
|
} // namespace LXMF
|
|
} // namespace UI
|
|
|
|
#endif // ARDUINO
|