Playing w/ LVGL

This commit is contained in:
DeFiDude
2026-04-03 16:12:40 -06:00
parent fecf8249ba
commit 77de14baef
10 changed files with 337 additions and 671 deletions
+1
View File
@@ -54,6 +54,7 @@ extern const lv_font_t lv_font_ratdeck_14;
#define LV_USE_BTNMATRIX 1
#define LV_USE_TEXTAREA 1
#define LV_USE_LIST 1
#define LV_USE_MENU 1
#define LV_USE_BAR 1
#define LV_USE_SLIDER 1
#define LV_USE_SWITCH 1
+13 -2
View File
@@ -125,7 +125,17 @@ static bool s_keyReady = false;
static unsigned long s_lastTouchMs = 0;
static bool s_cursorVisible = false;
// Suppress touch for 300ms after trackball/keyboard use to prevent accidental taps
static unsigned long s_lastKeyMs = 0;
static constexpr unsigned long TOUCH_SUPPRESS_MS = 300;
static void touchpad_read_cb(lv_indev_drv_t *indev_driver, lv_indev_data_t *data) {
// Ignore touch input briefly after trackball/keyboard use
if (millis() - s_lastKeyMs < TOUCH_SUPPRESS_MS) {
data->state = LV_INDEV_STATE_REL;
return;
}
if (s_touch->isTouched()) {
lv_obj_clear_flag(s_cursor, LV_OBJ_FLAG_HIDDEN);
s_cursorVisible = true;
@@ -144,6 +154,7 @@ static void touchpad_read_cb(lv_indev_drv_t *indev_driver, lv_indev_data_t *data
static void keypad_read_cb(lv_indev_drv_t* drv, lv_indev_data_t* data) {
if (s_keyReady) {
s_lastKeyMs = millis();
lv_obj_add_flag(s_cursor, LV_OBJ_FLAG_HIDDEN);
data->key = s_lastKey;
data->state = s_keyState;
@@ -199,8 +210,8 @@ void feedKey(const KeyEvent& evt) {
uint32_t key = 0;
if (evt.enter) key = LV_KEY_ENTER;
else if (evt.up) key = LV_KEY_UP;
else if (evt.down) key = LV_KEY_DOWN;
else if (evt.up) key = LV_KEY_PREV;
else if (evt.down) key = LV_KEY_NEXT;
else if (evt.left) key = LV_KEY_LEFT;
else if (evt.right) key = LV_KEY_RIGHT;
else if (evt.del) key = LV_KEY_BACKSPACE;
+5
View File
@@ -104,8 +104,13 @@ void init(lv_disp_t* disp) {
lv_style_init(&s_listBtnFocused);
lv_style_set_bg_color(&s_listBtnFocused, lv_color_hex(Theme::SELECTION_BG));
lv_style_set_bg_opa(&s_listBtnFocused, LV_OPA_COVER);
lv_style_set_text_color(&s_listBtnFocused, lv_color_hex(Theme::ACCENT));
lv_style_set_border_color(&s_listBtnFocused, lv_color_hex(Theme::PRIMARY));
lv_style_set_border_width(&s_listBtnFocused, 1);
// Override LVGL's default blue focus outline
lv_style_set_outline_width(&s_listBtnFocused, 0);
lv_style_set_outline_opa(&s_listBtnFocused, LV_OPA_TRANSP);
// Dropdown
lv_style_init(&s_dropdown);
+43 -124
View File
@@ -1,5 +1,7 @@
#include "LvContactsScreen.h"
#include "ui/Theme.h"
#include "ui/LvTheme.h"
#include "ui/LvInput.h"
#include "ui/UIManager.h"
#include "reticulum/AnnounceManager.h"
#include <Arduino.h>
@@ -18,63 +20,16 @@ void LvContactsScreen::createUI(lv_obj_t* parent) {
_list = lv_obj_create(parent);
lv_obj_set_size(_list, lv_pct(100), lv_pct(100));
lv_obj_set_pos(_list, 0, 0);
lv_obj_set_flex_grow(_list, 1);
lv_obj_set_style_bg_color(_list, lv_color_hex(Theme::BG), 0);
lv_obj_set_style_bg_opa(_list, LV_OPA_COVER, 0);
lv_obj_set_style_border_width(_list, 0, 0);
lv_obj_set_style_pad_all(_list, 0, 0);
lv_obj_set_style_pad_row(_list, 0, 0);
lv_obj_set_style_radius(_list, 0, 0);
lv_obj_add_style(_list, LvTheme::styleList(), 0);
lv_obj_set_layout(_list, LV_LAYOUT_FLEX);
lv_obj_set_flex_flow(_list, LV_FLEX_FLOW_COLUMN);
// Pre-allocate row pool
const lv_font_t* font = &lv_font_ratdeck_14;
for (int i = 0; i < ROW_POOL_SIZE; i++) {
lv_obj_t* row = lv_obj_create(_list);
lv_obj_set_size(row, Theme::CONTENT_W, 28);
lv_obj_set_style_bg_color(row, lv_color_hex(Theme::BG), 0);
lv_obj_set_style_bg_opa(row, LV_OPA_COVER, 0);
lv_obj_set_style_border_color(row, lv_color_hex(Theme::BORDER), 0);
lv_obj_set_style_border_width(row, 1, 0);
lv_obj_set_style_border_side(row, LV_BORDER_SIDE_BOTTOM, 0);
lv_obj_set_style_pad_all(row, 0, 0);
lv_obj_set_style_radius(row, 0, 0);
lv_obj_clear_flag(row, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_flag(row, LV_OBJ_FLAG_CLICKABLE);
lv_obj_add_flag(row, LV_OBJ_FLAG_HIDDEN);
lv_obj_set_user_data(row, (void*)(intptr_t)i);
lv_obj_add_event_cb(row, [](lv_event_t* e) {
auto* self = (LvContactsScreen*)lv_event_get_user_data(e);
int poolIdx = (int)(intptr_t)lv_obj_get_user_data(lv_event_get_target(e));
int dataIdx = self->_viewportStart + poolIdx;
if (dataIdx < (int)self->_contactIndices.size() && self->_onSelect) {
self->_selectedIdx = dataIdx;
self->syncVisibleRows();
int nodeIdx = self->_contactIndices[dataIdx];
self->_onSelect(self->_am->nodes()[nodeIdx].hash.toHex());
}
}, LV_EVENT_CLICKED, this);
lv_obj_t* lbl = lv_label_create(row);
lv_obj_set_style_text_font(lbl, font, 0);
lv_obj_set_style_text_color(lbl, lv_color_hex(Theme::ACCENT), 0);
lv_label_set_text(lbl, "");
lv_obj_align(lbl, LV_ALIGN_LEFT_MID, 8, 0);
_poolRows[i] = row;
_poolNameLabels[i] = lbl;
}
_lastContactCount = -1;
rebuildList();
}
void LvContactsScreen::onEnter() {
_lastContactCount = -1;
_selectedIdx = 0;
_viewportStart = 0;
rebuildList();
}
@@ -87,15 +42,12 @@ void LvContactsScreen::refreshUI() {
}
}
void LvContactsScreen::updateSelection(int oldIdx, int newIdx) {
syncVisibleRows();
}
void LvContactsScreen::rebuildList() {
if (!_am || !_list) return;
_rows.clear();
_contactIndices.clear();
lv_obj_clean(_list);
const auto& nodes = _am->nodes();
for (int i = 0; i < (int)nodes.size(); i++) {
if (nodes[i].saved) _contactIndices.push_back(i);
@@ -106,63 +58,55 @@ void LvContactsScreen::rebuildList() {
if (count == 0) {
lv_obj_clear_flag(_lblEmpty, LV_OBJ_FLAG_HIDDEN);
lv_obj_add_flag(_list, LV_OBJ_FLAG_HIDDEN);
for (int i = 0; i < ROW_POOL_SIZE; i++) lv_obj_add_flag(_poolRows[i], LV_OBJ_FLAG_HIDDEN);
return;
}
lv_obj_add_flag(_lblEmpty, LV_OBJ_FLAG_HIDDEN);
lv_obj_clear_flag(_list, LV_OBJ_FLAG_HIDDEN);
if (_selectedIdx >= count) _selectedIdx = count - 1;
if (_selectedIdx < 0) _selectedIdx = 0;
syncVisibleRows();
}
void LvContactsScreen::syncVisibleRows() {
if (!_am || !_list) return;
int count = (int)_contactIndices.size();
const auto& nodes = _am->nodes();
if (count == 0) {
for (int i = 0; i < ROW_POOL_SIZE; i++) lv_obj_add_flag(_poolRows[i], LV_OBJ_FLAG_HIDDEN);
return;
}
// Compute viewport centered on selection
int halfPool = ROW_POOL_SIZE / 2;
_viewportStart = _selectedIdx - halfPool;
if (_viewportStart < 0) _viewportStart = 0;
if (_viewportStart + ROW_POOL_SIZE > count) {
_viewportStart = count - ROW_POOL_SIZE;
if (_viewportStart < 0) _viewportStart = 0;
}
for (int i = 0; i < ROW_POOL_SIZE; i++) {
int contactIdx = _viewportStart + i;
if (contactIdx >= count) {
lv_obj_add_flag(_poolRows[i], LV_OBJ_FLAG_HIDDEN);
continue;
}
lv_obj_clear_flag(_poolRows[i], LV_OBJ_FLAG_HIDDEN);
int nodeIdx = _contactIndices[contactIdx];
if (nodeIdx < 0 || nodeIdx >= (int)nodes.size()) {
lv_obj_add_flag(_poolRows[i], LV_OBJ_FLAG_HIDDEN);
continue;
}
for (int i = 0; i < count; i++) {
int nodeIdx = _contactIndices[i];
const auto& node = nodes[nodeIdx];
bool isSelected = (contactIdx == _selectedIdx);
lv_obj_set_style_bg_color(_poolRows[i], lv_color_hex(
isSelected ? Theme::SELECTION_BG : Theme::BG), 0);
lv_label_set_text(_poolNameLabels[i], node.name.c_str());
lv_obj_t* row = lv_obj_create(_list);
lv_obj_set_size(row, Theme::CONTENT_W, 32);
lv_obj_add_style(row, LvTheme::styleListBtn(), 0);
lv_obj_add_style(row, LvTheme::styleListBtnFocused(), LV_STATE_FOCUSED);
lv_obj_set_style_border_side(row, LV_BORDER_SIDE_BOTTOM, 0);
lv_obj_set_style_border_width(row, 1, 0);
lv_obj_set_style_border_color(row, lv_color_hex(Theme::BORDER), 0);
lv_obj_clear_flag(row, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_flag(row, LV_OBJ_FLAG_CLICKABLE);
lv_obj_set_user_data(row, (void*)(intptr_t)i);
lv_obj_add_event_cb(row, [](lv_event_t* e) {
auto* self = (LvContactsScreen*)lv_event_get_user_data(e);
int idx = (int)(intptr_t)lv_obj_get_user_data(lv_event_get_target(e));
if (idx < (int)self->_contactIndices.size() && self->_onSelect) {
int nodeIdx = self->_contactIndices[idx];
self->_onSelect(self->_am->nodes()[nodeIdx].hash.toHex());
}
}, LV_EVENT_CLICKED, this);
lv_group_add_obj(LvInput::group(), row);
lv_obj_add_event_cb(row, [](lv_event_t* e) {
lv_obj_scroll_to_view(lv_event_get_target(e), LV_ANIM_ON);
}, LV_EVENT_FOCUSED, nullptr);
lv_obj_t* lbl = lv_label_create(row);
lv_obj_set_style_text_font(lbl, &lv_font_ratdeck_14, 0);
lv_obj_set_style_text_color(lbl, lv_color_hex(Theme::ACCENT), 0);
lv_label_set_text(lbl, node.name.c_str());
lv_obj_align(lbl, LV_ALIGN_LEFT_MID, 8, 0);
}
}
bool LvContactsScreen::handleLongPress() {
if (!_am || _contactIndices.empty()) return false;
if (_selectedIdx < 0 || _selectedIdx >= (int)_contactIndices.size()) return false;
lv_obj_t* focused = lv_group_get_focused(LvInput::group());
if (!focused) return false;
_deleteIdx = (int)(intptr_t)lv_obj_get_user_data(focused);
if (_deleteIdx < 0 || _deleteIdx >= (int)_contactIndices.size()) return false;
_confirmDelete = true;
if (_ui) _ui->lvStatusBar().showToast("Delete contact? Enter=Yes Esc=No", 5000);
return true;
@@ -171,17 +115,15 @@ bool LvContactsScreen::handleLongPress() {
bool LvContactsScreen::handleKey(const KeyEvent& event) {
if (!_am || _contactIndices.empty()) return false;
// Confirm delete mode
if (_confirmDelete) {
if (event.enter || event.character == '\n' || event.character == '\r') {
if (_selectedIdx >= 0 && _selectedIdx < (int)_contactIndices.size()) {
int nodeIdx = _contactIndices[_selectedIdx];
if (_deleteIdx >= 0 && _deleteIdx < (int)_contactIndices.size()) {
int nodeIdx = _contactIndices[_deleteIdx];
if (nodeIdx >= 0 && nodeIdx < (int)_am->nodes().size()) {
auto& nodes = const_cast<std::vector<DiscoveredNode>&>(_am->nodes());
nodes.erase(nodes.begin() + nodeIdx);
_am->saveContacts();
if (_ui) _ui->lvStatusBar().showToast("Contact deleted", 1200);
_selectedIdx = 0;
rebuildList();
}
}
@@ -193,28 +135,5 @@ bool LvContactsScreen::handleKey(const KeyEvent& event) {
return true;
}
int count = (int)_contactIndices.size();
if (event.up) {
if (_selectedIdx > 0) {
_selectedIdx--;
syncVisibleRows();
}
return true;
}
if (event.down) {
if (_selectedIdx < count - 1) {
_selectedIdx++;
syncVisibleRows();
}
return true;
}
if (event.enter || event.character == '\n' || event.character == '\r') {
if (_selectedIdx >= 0 && _selectedIdx < count && _onSelect) {
int nodeIdx = _contactIndices[_selectedIdx];
_onSelect(_am->nodes()[nodeIdx].hash.toHex());
}
return true;
}
return false;
}
+2 -11
View File
@@ -25,24 +25,15 @@ public:
private:
void rebuildList();
void syncVisibleRows();
void updateSelection(int oldIdx, int newIdx);
AnnounceManager* _am = nullptr;
class UIManager* _ui = nullptr;
NodeSelectedCallback _onSelect;
bool _confirmDelete = false;
int _deleteIdx = -1;
int _lastContactCount = -1;
int _selectedIdx = 0;
int _viewportStart = 0;
std::vector<int> _contactIndices; // Maps row -> node index in _am->nodes()
std::vector<int> _contactIndices;
lv_obj_t* _list = nullptr;
lv_obj_t* _lblEmpty = nullptr;
std::vector<lv_obj_t*> _rows;
// Object pool
static constexpr int ROW_POOL_SIZE = 10;
lv_obj_t* _poolRows[ROW_POOL_SIZE] = {};
lv_obj_t* _poolNameLabels[ROW_POOL_SIZE] = {};
};
+77 -169
View File
@@ -1,5 +1,7 @@
#include "LvMessagesScreen.h"
#include "ui/Theme.h"
#include "ui/LvTheme.h"
#include "ui/LvInput.h"
#include "ui/UIManager.h"
#include "reticulum/LXMFManager.h"
#include "reticulum/AnnounceManager.h"
@@ -22,94 +24,16 @@ void LvMessagesScreen::createUI(lv_obj_t* parent) {
_list = lv_obj_create(parent);
lv_obj_set_size(_list, lv_pct(100), lv_pct(100));
lv_obj_set_pos(_list, 0, 0);
lv_obj_set_flex_grow(_list, 1);
lv_obj_set_style_bg_color(_list, lv_color_hex(Theme::BG), 0);
lv_obj_set_style_bg_opa(_list, LV_OPA_COVER, 0);
lv_obj_set_style_border_width(_list, 0, 0);
lv_obj_set_style_pad_all(_list, 0, 0);
lv_obj_set_style_pad_row(_list, 0, 0);
lv_obj_set_style_radius(_list, 0, 0);
lv_obj_add_style(_list, LvTheme::styleList(), 0);
lv_obj_set_layout(_list, LV_LAYOUT_FLEX);
lv_obj_set_flex_flow(_list, LV_FLEX_FLOW_COLUMN);
// Pre-allocate row pool (following LvNodesScreen pattern)
const lv_font_t* nameFont = &lv_font_ratdeck_14;
const lv_font_t* smallFont = &lv_font_ratdeck_12;
for (int i = 0; i < ROW_POOL_SIZE; i++) {
lv_obj_t* row = lv_obj_create(_list);
lv_obj_set_size(row, Theme::CONTENT_W, 38);
lv_obj_set_style_bg_color(row, lv_color_hex(Theme::BG), 0);
lv_obj_set_style_bg_opa(row, LV_OPA_COVER, 0);
lv_obj_set_style_border_color(row, lv_color_hex(Theme::BORDER), 0);
lv_obj_set_style_border_width(row, 1, 0);
lv_obj_set_style_border_side(row, LV_BORDER_SIDE_BOTTOM, 0);
lv_obj_set_style_pad_all(row, 0, 0);
lv_obj_set_style_radius(row, 0, 0);
lv_obj_clear_flag(row, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_flag(row, LV_OBJ_FLAG_CLICKABLE);
lv_obj_add_flag(row, LV_OBJ_FLAG_HIDDEN);
lv_obj_set_user_data(row, (void*)(intptr_t)i);
lv_obj_add_event_cb(row, [](lv_event_t* e) {
auto* self = (LvMessagesScreen*)lv_event_get_user_data(e);
lv_obj_t* target = lv_event_get_target(e);
int poolIdx = (int)(intptr_t)lv_obj_get_user_data(target);
int dataIdx = self->_viewportStart + poolIdx;
if (dataIdx < (int)self->_sortedPeers.size() && self->_onOpen) {
self->_selectedIdx = dataIdx;
self->syncVisibleRows();
self->_onOpen(self->_sortedPeers[dataIdx]);
}
}, LV_EVENT_CLICKED, this);
// Unread dot
lv_obj_t* dot = lv_obj_create(row);
lv_obj_set_size(dot, 6, 6);
lv_obj_set_style_radius(dot, 3, 0);
lv_obj_set_style_bg_color(dot, lv_color_hex(Theme::PRIMARY), 0);
lv_obj_set_style_bg_opa(dot, LV_OPA_COVER, 0);
lv_obj_set_style_border_width(dot, 0, 0);
lv_obj_set_style_pad_all(dot, 0, 0);
lv_obj_set_pos(dot, 5, 8);
lv_obj_add_flag(dot, LV_OBJ_FLAG_HIDDEN);
// Name label (line 1)
lv_obj_t* nameLbl = lv_label_create(row);
lv_obj_set_style_text_font(nameLbl, nameFont, 0);
lv_obj_set_style_text_color(nameLbl, lv_color_hex(Theme::PRIMARY), 0);
lv_label_set_text(nameLbl, "");
lv_obj_align(nameLbl, LV_ALIGN_TOP_LEFT, 14, 2);
// Time label (line 1, right)
lv_obj_t* timeLbl = lv_label_create(row);
lv_obj_set_style_text_font(timeLbl, &lv_font_ratdeck_10, 0);
lv_obj_set_style_text_color(timeLbl, lv_color_hex(Theme::MUTED), 0);
lv_label_set_text(timeLbl, "");
lv_obj_align(timeLbl, LV_ALIGN_TOP_RIGHT, -4, 4);
// Preview label (line 2)
lv_obj_t* prevLbl = lv_label_create(row);
lv_obj_set_style_text_font(prevLbl, smallFont, 0);
lv_obj_set_style_text_color(prevLbl, lv_color_hex(Theme::MUTED), 0);
lv_label_set_text(prevLbl, "");
lv_obj_align(prevLbl, LV_ALIGN_BOTTOM_LEFT, 14, -4);
_poolRows[i] = row;
_poolDots[i] = dot;
_poolNameLabels[i] = nameLbl;
_poolTimeLabels[i] = timeLbl;
_poolPreviewLabels[i] = prevLbl;
}
_lastConvCount = -1;
rebuildList();
}
void LvMessagesScreen::onEnter() {
_lastConvCount = -1;
_selectedIdx = 0;
_viewportStart = 0;
rebuildList();
}
@@ -122,11 +46,6 @@ void LvMessagesScreen::refreshUI() {
}
}
// Update only the selection highlight via pool sync
void LvMessagesScreen::updateSelection(int oldIdx, int newIdx) {
syncVisibleRows();
}
void LvMessagesScreen::rebuildList() {
if (!_lxmf || !_list) return;
@@ -137,10 +56,11 @@ void LvMessagesScreen::rebuildList() {
_sortedPeers.clear();
_sortedConvs.clear();
lv_obj_clean(_list);
if (count == 0) {
lv_obj_clear_flag(_lblEmpty, LV_OBJ_FLAG_HIDDEN);
lv_obj_add_flag(_list, LV_OBJ_FLAG_HIDDEN);
for (int i = 0; i < ROW_POOL_SIZE; i++) lv_obj_add_flag(_poolRows[i], LV_OBJ_FLAG_HIDDEN);
return;
}
@@ -158,7 +78,6 @@ void LvMessagesScreen::rebuildList() {
ci.preview = s->lastPreview;
ci.hasUnread = s->unreadCount > 0;
}
// Resolve display name
std::string peerName;
if (_am) peerName = _am->lookupName(ci.peerHex);
ci.displayName = !peerName.empty() ? peerName.substr(0, 15) : ci.peerHex.substr(0, 12);
@@ -171,79 +90,97 @@ void LvMessagesScreen::rebuildList() {
for (auto& ci : _sortedConvs) _sortedPeers.push_back(ci.peerHex);
if (_selectedIdx >= count) _selectedIdx = count - 1;
if (_selectedIdx < 0) _selectedIdx = 0;
// Build list items with focus group support
const lv_font_t* nameFont = &lv_font_ratdeck_14;
const lv_font_t* smallFont = &lv_font_ratdeck_12;
syncVisibleRows();
}
for (int i = 0; i < count; i++) {
const auto& ci = _sortedConvs[i];
void LvMessagesScreen::syncVisibleRows() {
if (!_list) return;
int count = (int)_sortedConvs.size();
lv_obj_t* row = lv_obj_create(_list);
lv_obj_set_size(row, Theme::CONTENT_W, 46);
lv_obj_add_style(row, LvTheme::styleListBtn(), 0);
lv_obj_add_style(row, LvTheme::styleListBtnFocused(), LV_STATE_FOCUSED);
lv_obj_set_style_border_side(row, LV_BORDER_SIDE_BOTTOM, 0);
lv_obj_set_style_border_width(row, 1, 0);
lv_obj_set_style_border_color(row, lv_color_hex(Theme::BORDER), 0);
lv_obj_clear_flag(row, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_flag(row, LV_OBJ_FLAG_CLICKABLE);
lv_obj_set_user_data(row, (void*)(intptr_t)i);
if (count == 0) {
for (int i = 0; i < ROW_POOL_SIZE; i++) lv_obj_add_flag(_poolRows[i], LV_OBJ_FLAG_HIDDEN);
return;
}
lv_obj_add_event_cb(row, [](lv_event_t* e) {
auto* self = (LvMessagesScreen*)lv_event_get_user_data(e);
int idx = (int)(intptr_t)lv_obj_get_user_data(lv_event_get_target(e));
if (idx < (int)self->_sortedPeers.size() && self->_onOpen) {
self->_onOpen(self->_sortedPeers[idx]);
}
}, LV_EVENT_CLICKED, this);
// Compute viewport centered on selection
int halfPool = ROW_POOL_SIZE / 2;
_viewportStart = _selectedIdx - halfPool;
if (_viewportStart < 0) _viewportStart = 0;
if (_viewportStart + ROW_POOL_SIZE > count) {
_viewportStart = count - ROW_POOL_SIZE;
if (_viewportStart < 0) _viewportStart = 0;
}
lv_group_add_obj(LvInput::group(), row);
lv_obj_add_event_cb(row, [](lv_event_t* e) {
lv_obj_scroll_to_view(lv_event_get_target(e), LV_ANIM_ON);
}, LV_EVENT_FOCUSED, nullptr);
for (int i = 0; i < ROW_POOL_SIZE; i++) {
int convIdx = _viewportStart + i;
if (convIdx >= count) {
lv_obj_add_flag(_poolRows[i], LV_OBJ_FLAG_HIDDEN);
continue;
}
lv_obj_clear_flag(_poolRows[i], LV_OBJ_FLAG_HIDDEN);
const auto& ci = _sortedConvs[convIdx];
bool isSelected = (convIdx == _selectedIdx);
// Selection highlight
lv_obj_set_style_bg_color(_poolRows[i], lv_color_hex(
isSelected ? Theme::SELECTION_BG : Theme::BG), 0);
int leftPad = 14;
// Unread dot
if (ci.hasUnread) {
lv_obj_clear_flag(_poolDots[i], LV_OBJ_FLAG_HIDDEN);
} else {
lv_obj_add_flag(_poolDots[i], LV_OBJ_FLAG_HIDDEN);
lv_obj_t* dot = lv_obj_create(row);
lv_obj_set_size(dot, 6, 6);
lv_obj_set_style_radius(dot, 3, 0);
lv_obj_set_style_bg_color(dot, lv_color_hex(Theme::PRIMARY), 0);
lv_obj_set_style_bg_opa(dot, LV_OPA_COVER, 0);
lv_obj_set_style_border_width(dot, 0, 0);
lv_obj_set_style_pad_all(dot, 0, 0);
lv_obj_set_pos(dot, 4, 7);
}
// Name
lv_label_set_text(_poolNameLabels[i], ci.displayName.c_str());
// Name (top-left, first line)
lv_obj_t* nameLbl = lv_label_create(row);
lv_obj_set_style_text_font(nameLbl, nameFont, 0);
lv_obj_set_style_text_color(nameLbl, lv_color_hex(Theme::PRIMARY), 0);
lv_label_set_text(nameLbl, ci.displayName.c_str());
lv_obj_set_pos(nameLbl, leftPad, 1);
// Time
// Time (top-right)
if (ci.lastTs > 1700000000) {
time_t t = (time_t)ci.lastTs;
struct tm* tm = localtime(&t);
if (tm) {
char timeBuf[8];
snprintf(timeBuf, sizeof(timeBuf), "%02d:%02d", tm->tm_hour, tm->tm_min);
lv_label_set_text(_poolTimeLabels[i], timeBuf);
} else {
lv_label_set_text(_poolTimeLabels[i], "");
lv_obj_t* timeLbl = lv_label_create(row);
lv_obj_set_style_text_font(timeLbl, &lv_font_ratdeck_10, 0);
lv_obj_set_style_text_color(timeLbl, lv_color_hex(Theme::MUTED), 0);
lv_label_set_text(timeLbl, timeBuf);
lv_obj_align(timeLbl, LV_ALIGN_TOP_RIGHT, -4, 3);
}
} else {
lv_label_set_text(_poolTimeLabels[i], "");
}
// Preview
lv_label_set_text(_poolPreviewLabels[i], ci.preview.c_str());
// Preview (second line, below name)
if (!ci.preview.empty()) {
lv_obj_t* prevLbl = lv_label_create(row);
lv_obj_set_style_text_font(prevLbl, smallFont, 0);
lv_obj_set_style_text_color(prevLbl, lv_color_hex(Theme::MUTED), 0);
lv_label_set_long_mode(prevLbl, LV_LABEL_LONG_CLIP);
lv_obj_set_width(prevLbl, Theme::CONTENT_W - leftPad - 8);
lv_label_set_text(prevLbl, ci.preview.c_str());
lv_obj_set_pos(prevLbl, leftPad, 20);
}
}
}
int LvMessagesScreen::getFocusedPeerIdx() const {
lv_obj_t* focused = lv_group_get_focused(LvInput::group());
if (!focused) return -1;
return (int)(intptr_t)lv_obj_get_user_data(focused);
}
bool LvMessagesScreen::handleLongPress() {
if (!_lxmf) return false;
int count = (int)_lxmf->conversations().size();
if (count == 0 || _selectedIdx >= count) return false;
int idx = getFocusedPeerIdx();
if (idx < 0 || idx >= (int)_sortedPeers.size()) return false;
_lpPeerIdx = idx;
_lpState = LP_MENU;
_menuIdx = 0;
if (_ui) _ui->lvStatusBar().showToast("Up/Down: Add Friend | Delete | Cancel", 5000);
@@ -262,10 +199,8 @@ bool LvMessagesScreen::handleKey(const KeyEvent& event) {
return true;
}
if (event.enter || event.character == '\n' || event.character == '\r') {
int count = (int)_lxmf->conversations().size();
if (_menuIdx == 0 && _selectedIdx < (int)_sortedPeers.size()) {
// Add friend
const auto& peerHex = _sortedPeers[_selectedIdx];
if (_menuIdx == 0 && _lpPeerIdx < (int)_sortedPeers.size()) {
const auto& peerHex = _sortedPeers[_lpPeerIdx];
if (_am) {
const DiscoveredNode* existing = _am->findNodeByHex(peerHex);
if (existing && !existing->saved) {
@@ -280,13 +215,11 @@ bool LvMessagesScreen::handleKey(const KeyEvent& event) {
if (_ui) _ui->lvStatusBar().showToast("Already a friend", 1200);
}
}
} else if (_menuIdx == 1 && _selectedIdx < (int)_sortedPeers.size()) {
// Confirm delete
} else if (_menuIdx == 1 && _lpPeerIdx < (int)_sortedPeers.size()) {
_lpState = LP_CONFIRM_DELETE;
if (_ui) _ui->lvStatusBar().showToast("Delete chat? Enter=Yes Esc=No", 5000);
return true;
} else {
// Cancel
if (_ui) _ui->lvStatusBar().showToast("Cancelled", 800);
}
_lpState = LP_NONE;
@@ -303,8 +236,8 @@ bool LvMessagesScreen::handleKey(const KeyEvent& event) {
// Confirm delete mode
if (_lpState == LP_CONFIRM_DELETE) {
if (event.enter || event.character == '\n' || event.character == '\r') {
if (_selectedIdx < (int)_sortedPeers.size()) {
const auto& peerHex = _sortedPeers[_selectedIdx];
if (_lpPeerIdx < (int)_sortedPeers.size()) {
const auto& peerHex = _sortedPeers[_lpPeerIdx];
_lxmf->markRead(peerHex);
extern MessageStore messageStore;
messageStore.deleteConversation(peerHex);
@@ -313,7 +246,6 @@ bool LvMessagesScreen::handleKey(const KeyEvent& event) {
_ui->lvStatusBar().showToast("Chat deleted", 1200);
_ui->lvTabBar().setUnreadCount(LvTabBar::TAB_MSGS, _lxmf->unreadCount());
}
_selectedIdx = 0;
_lastConvCount = -1;
rebuildList();
}
@@ -325,30 +257,6 @@ bool LvMessagesScreen::handleKey(const KeyEvent& event) {
return true;
}
int count = (int)_lxmf->conversations().size();
if (count == 0) return false;
if (event.up) {
if (_selectedIdx > 0) {
int prev = _selectedIdx;
_selectedIdx--;
syncVisibleRows();
}
return true;
}
if (event.down) {
if (_selectedIdx < count - 1) {
int prev = _selectedIdx;
_selectedIdx++;
syncVisibleRows();
}
return true;
}
if (event.enter || event.character == '\n' || event.character == '\r') {
if (_selectedIdx < (int)_sortedPeers.size() && _onOpen) {
_onOpen(_sortedPeers[_selectedIdx]);
}
return true;
}
// Let LVGL focus group handle up/down/enter navigation
return false;
}
+3 -14
View File
@@ -27,8 +27,7 @@ public:
private:
void rebuildList();
void syncVisibleRows();
void updateSelection(int oldIdx, int newIdx);
int getFocusedPeerIdx() const;
LXMFManager* _lxmf = nullptr;
AnnounceManager* _am = nullptr;
@@ -36,24 +35,14 @@ private:
OpenCallback _onOpen;
int _lastConvCount = -1;
int _lastUnreadTotal = 0;
int _selectedIdx = 0;
int _viewportStart = 0;
std::vector<std::string> _sortedPeers;
enum LongPressState { LP_NONE, LP_MENU, LP_CONFIRM_DELETE };
LongPressState _lpState = LP_NONE;
int _menuIdx = 0; // 0=Add Friend, 1=Delete Chat, 2=Cancel
int _lpPeerIdx = -1;
int _menuIdx = 0;
lv_obj_t* _list = nullptr;
lv_obj_t* _lblEmpty = nullptr;
std::vector<lv_obj_t*> _rows;
// Object pool for conversation rows
static constexpr int ROW_POOL_SIZE = 10;
lv_obj_t* _poolRows[ROW_POOL_SIZE] = {};
lv_obj_t* _poolNameLabels[ROW_POOL_SIZE] = {};
lv_obj_t* _poolPreviewLabels[ROW_POOL_SIZE] = {};
lv_obj_t* _poolTimeLabels[ROW_POOL_SIZE] = {};
lv_obj_t* _poolDots[ROW_POOL_SIZE] = {};
// Cached sorted conversation data
struct ConvInfo {
+122 -275
View File
@@ -1,6 +1,7 @@
#include "LvNodesScreen.h"
#include "ui/Theme.h"
#include "ui/LvTheme.h"
#include "ui/LvInput.h"
#include "ui/UIManager.h"
#include "reticulum/AnnounceManager.h"
#include "config/UserConfig.h"
@@ -13,83 +14,23 @@ void LvNodesScreen::createUI(lv_obj_t* parent) {
lv_obj_set_style_bg_color(parent, lv_color_hex(Theme::BG), 0);
lv_obj_set_style_pad_all(parent, 0, 0);
// Empty state label
_lblEmpty = lv_label_create(parent);
lv_obj_set_style_text_font(_lblEmpty, &lv_font_ratdeck_14, 0);
lv_obj_set_style_text_color(_lblEmpty, lv_color_hex(Theme::MUTED), 0);
lv_label_set_text(_lblEmpty, "No nodes discovered");
lv_obj_center(_lblEmpty);
// Scrollable list container
_list = lv_obj_create(parent);
lv_obj_set_size(_list, lv_pct(100), lv_pct(100));
lv_obj_set_pos(_list, 0, 0);
lv_obj_set_flex_grow(_list, 1);
lv_obj_set_style_bg_color(_list, lv_color_hex(Theme::BG), 0);
lv_obj_set_style_bg_opa(_list, LV_OPA_COVER, 0);
lv_obj_set_style_border_width(_list, 0, 0);
lv_obj_set_style_pad_all(_list, 0, 0);
lv_obj_set_style_pad_row(_list, 0, 0);
lv_obj_set_style_radius(_list, 0, 0);
lv_obj_add_style(_list, LvTheme::styleList(), 0);
lv_obj_set_layout(_list, LV_LAYOUT_FLEX);
lv_obj_set_flex_flow(_list, LV_FLEX_FLOW_COLUMN);
// Pre-allocate ROW_POOL_SIZE row widgets
const lv_font_t* font = &lv_font_ratdeck_14;
const lv_font_t* smallFont = &lv_font_ratdeck_10;
rebuildList();
for (int i = 0; i < ROW_POOL_SIZE; i++) {
lv_obj_t* row = lv_obj_create(_list);
lv_obj_set_size(row, Theme::CONTENT_W, 24);
lv_obj_set_style_bg_color(row, lv_color_hex(Theme::BG), 0);
lv_obj_set_style_bg_opa(row, LV_OPA_COVER, 0);
lv_obj_set_style_border_width(row, 0, 0);
lv_obj_set_style_pad_all(row, 0, 0);
lv_obj_set_style_radius(row, 0, 0);
lv_obj_clear_flag(row, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_flag(row, LV_OBJ_FLAG_CLICKABLE);
lv_obj_add_flag(row, LV_OBJ_FLAG_HIDDEN);
lv_obj_set_user_data(row, (void*)(intptr_t)i);
lv_obj_add_event_cb(row, [](lv_event_t* e) {
auto* self = (LvNodesScreen*)lv_event_get_user_data(e);
int poolIdx = (int)(intptr_t)lv_obj_get_user_data(lv_event_get_target(e));
int entryIdx = self->_viewportStart + poolIdx;
if (entryIdx < self->_totalEntries) {
self->_selectedIdx = entryIdx;
self->syncVisibleRows();
int nodeIdx = self->getNodeIdxForEntry(entryIdx);
if (nodeIdx >= 0 && nodeIdx < (int)self->_am->nodes().size()) {
self->showActionMenu(nodeIdx);
}
}
}, LV_EVENT_CLICKED, this);
lv_obj_t* nameLbl = lv_label_create(row);
lv_obj_set_style_text_font(nameLbl, font, 0);
lv_obj_set_style_text_color(nameLbl, lv_color_hex(Theme::PRIMARY), 0);
lv_label_set_text(nameLbl, "");
lv_obj_align(nameLbl, LV_ALIGN_LEFT_MID, 8, 0);
lv_obj_t* infoLbl = lv_label_create(row);
lv_obj_set_style_text_font(infoLbl, smallFont, 0);
lv_obj_set_style_text_color(infoLbl, lv_color_hex(Theme::SECONDARY), 0);
lv_label_set_text(infoLbl, "");
lv_obj_align(infoLbl, LV_ALIGN_RIGHT_MID, -4, 0);
_poolRows[i] = row;
_poolNameLabels[i] = nameLbl;
_poolInfoLabels[i] = infoLbl;
}
_lastNodeCount = -1;
_lastContactCount = -1;
updateSortOrder();
syncVisibleRows();
// --- Action modal overlay (hidden initially, on top layer so it floats above scroll) ---
// --- Action modal overlay (on top layer, centered) ---
_overlay = lv_obj_create(lv_layer_top());
lv_obj_set_size(_overlay, 180, 100);
// Center on screen (accounting for status bar offset)
lv_obj_set_pos(_overlay, (320 - 180) / 2, 20 + (Theme::CONTENT_H - 100) / 2);
lv_obj_set_style_bg_color(_overlay, lv_color_hex(0x001100), 0);
lv_obj_set_style_bg_opa(_overlay, LV_OPA_COVER, 0);
@@ -106,7 +47,6 @@ void LvNodesScreen::createUI(lv_obj_t* parent) {
const char* menuText[] = {"Add Contact", "Message", "Back"};
for (int i = 0; i < 3; i++) {
// Use a container for each menu item so it's tappable with a larger hit area
lv_obj_t* btn = lv_obj_create(_overlay);
lv_obj_set_size(btn, 166, 26);
lv_obj_set_style_bg_color(btn, lv_color_hex(Theme::BG), 0);
@@ -121,7 +61,6 @@ void LvNodesScreen::createUI(lv_obj_t* parent) {
auto* self = (LvNodesScreen*)lv_event_get_user_data(e);
int idx = (int)(intptr_t)lv_obj_get_user_data(lv_event_get_target(e));
self->_menuIdx = idx;
// Simulate enter key to execute the menu action
KeyEvent tap = {};
tap.enter = true;
self->handleKey(tap);
@@ -136,7 +75,7 @@ void LvNodesScreen::createUI(lv_obj_t* parent) {
_menuBtns[i] = btn;
}
// Nickname input widgets (hidden in menu mode)
// Nickname input widgets
_nicknameBox = lv_obj_create(_overlay);
lv_obj_set_size(_nicknameBox, 166, LV_SIZE_CONTENT);
lv_obj_set_style_bg_opa(_nicknameBox, LV_OPA_TRANSP, 0);
@@ -168,10 +107,7 @@ void LvNodesScreen::createUI(lv_obj_t* parent) {
void LvNodesScreen::onEnter() {
_lastNodeCount = -1;
_lastContactCount = -1;
_selectedIdx = 0;
_viewportStart = 0;
updateSortOrder();
syncVisibleRows();
rebuildList();
}
void LvNodesScreen::refreshUI() {
@@ -185,193 +121,139 @@ void LvNodesScreen::refreshUI() {
if (countDelta > 0 || contactDelta > 0) {
_lastRebuild = now;
if (countDelta > 3 || contactDelta > 0) {
updateSortOrder();
syncVisibleRows();
rebuildList();
}
}
}
void LvNodesScreen::updateSortOrder() {
if (!_am) return;
void LvNodesScreen::rebuildList() {
if (!_am || !_list) return;
lv_obj_clean(_list);
_sortedContactIndices.clear();
_sortedOnlineIndices.clear();
const auto& nodes = _am->nodes();
int count = (int)nodes.size();
_lastNodeCount = count;
_sortedContactIndices.clear();
_sortedOnlineIndices.clear();
for (int i = 0; i < count; i++) {
if (nodes[i].saved) _sortedContactIndices.push_back(i);
else _sortedOnlineIndices.push_back(i);
}
_lastContactCount = (int)_sortedContactIndices.size();
// Sort online nodes: most recently seen first
std::sort(_sortedOnlineIndices.begin(), _sortedOnlineIndices.end(), [&nodes](int a, int b) {
return nodes[a].lastSeen > nodes[b].lastSeen;
});
// Total entries: contacts header (if any) + contacts + online header + online nodes
_totalEntries = 0;
if (!_sortedContactIndices.empty()) _totalEntries += 1 + (int)_sortedContactIndices.size();
if (count > 0) _totalEntries += 1 + (int)_sortedOnlineIndices.size();
// Clamp selection
if (_selectedIdx >= _totalEntries) _selectedIdx = _totalEntries - 1;
if (_selectedIdx < 0) _selectedIdx = 0;
// Skip headers
while (_selectedIdx < _totalEntries && getNodeIdxForEntry(_selectedIdx) == -1) _selectedIdx++;
if (_selectedIdx >= _totalEntries) _selectedIdx = _totalEntries - 1;
_dataChanged = true;
}
// Map a logical entry index to a node index, or -1 for section headers
static int mapEntryToNodeIdx(int entry, const std::vector<int>& contacts, const std::vector<int>& online) {
int pos = 0;
if (!contacts.empty()) {
if (entry == pos) return -1; // Contacts header
pos++;
if (entry < pos + (int)contacts.size()) return contacts[entry - pos];
pos += (int)contacts.size();
}
// Online header
if (entry == pos) return -1;
pos++;
if (entry < pos + (int)online.size()) return online[entry - pos];
return -1;
}
// Determine if entry is a header, and which section
// Returns: 0 = contacts header, 1 = online header, -1 = not a header
static int headerType(int entry, const std::vector<int>& contacts, const std::vector<int>& online) {
int pos = 0;
if (!contacts.empty()) {
if (entry == pos) return 0;
pos += 1 + (int)contacts.size();
}
if (entry == pos) return 1;
return -1;
}
void LvNodesScreen::syncVisibleRows() {
if (!_am || !_list) return;
if (_totalEntries == 0) {
if (_sortedContactIndices.empty() && _sortedOnlineIndices.empty()) {
lv_obj_clear_flag(_lblEmpty, LV_OBJ_FLAG_HIDDEN);
lv_obj_add_flag(_list, LV_OBJ_FLAG_HIDDEN);
for (int i = 0; i < ROW_POOL_SIZE; i++) lv_obj_add_flag(_poolRows[i], LV_OBJ_FLAG_HIDDEN);
return;
}
lv_obj_add_flag(_lblEmpty, LV_OBJ_FLAG_HIDDEN);
lv_obj_clear_flag(_list, LV_OBJ_FLAG_HIDDEN);
const auto& nodes = _am->nodes();
bool devMode = _cfg && _cfg->settings().devMode;
// Compute viewport: center selected item with buffer
int halfPool = ROW_POOL_SIZE / 2;
_viewportStart = _selectedIdx - halfPool;
if (_viewportStart < 0) _viewportStart = 0;
if (_viewportStart + ROW_POOL_SIZE > _totalEntries) {
_viewportStart = _totalEntries - ROW_POOL_SIZE;
if (_viewportStart < 0) _viewportStart = 0;
}
auto addHeader = [&](const char* text) {
lv_obj_t* hdr = lv_obj_create(_list);
lv_obj_set_size(hdr, Theme::CONTENT_W, 22);
lv_obj_set_style_bg_opa(hdr, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_color(hdr, lv_color_hex(Theme::BORDER), 0);
lv_obj_set_style_border_width(hdr, 1, 0);
lv_obj_set_style_border_side(hdr, LV_BORDER_SIDE_BOTTOM, 0);
lv_obj_set_style_pad_all(hdr, 0, 0);
lv_obj_set_style_radius(hdr, 0, 0);
lv_obj_clear_flag(hdr, LV_OBJ_FLAG_SCROLLABLE | LV_OBJ_FLAG_CLICKABLE);
lv_obj_t* lbl = lv_label_create(hdr);
lv_obj_set_style_text_font(lbl, &lv_font_ratdeck_12, 0);
lv_obj_set_style_text_color(lbl, lv_color_hex(Theme::ACCENT), 0);
lv_label_set_text(lbl, text);
lv_obj_align(lbl, LV_ALIGN_LEFT_MID, 4, 0);
};
for (int i = 0; i < ROW_POOL_SIZE; i++) {
int entryIdx = _viewportStart + i;
if (entryIdx >= _totalEntries) {
lv_obj_add_flag(_poolRows[i], LV_OBJ_FLAG_HIDDEN);
continue;
}
auto addNodeRow = [&](int nodeIdx) {
const auto& node = nodes[nodeIdx];
lv_obj_clear_flag(_poolRows[i], LV_OBJ_FLAG_HIDDEN);
bool isSelected = (entryIdx == _selectedIdx);
lv_obj_t* row = lv_obj_create(_list);
lv_obj_set_size(row, Theme::CONTENT_W, 26);
lv_obj_add_style(row, LvTheme::styleListBtn(), 0);
lv_obj_add_style(row, LvTheme::styleListBtnFocused(), LV_STATE_FOCUSED);
lv_obj_clear_flag(row, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_flag(row, LV_OBJ_FLAG_CLICKABLE);
lv_obj_set_user_data(row, (void*)(intptr_t)nodeIdx);
int hdr = headerType(entryIdx, _sortedContactIndices, _sortedOnlineIndices);
if (hdr >= 0) {
// Section header row
char buf[32];
if (hdr == 0) {
snprintf(buf, sizeof(buf), "Contacts (%d)", (int)_sortedContactIndices.size());
} else {
snprintf(buf, sizeof(buf), "Online (%d)", (int)_sortedOnlineIndices.size());
lv_obj_add_event_cb(row, [](lv_event_t* e) {
auto* self = (LvNodesScreen*)lv_event_get_user_data(e);
int idx = (int)(intptr_t)lv_obj_get_user_data(lv_event_get_target(e));
if (idx >= 0 && idx < (int)self->_am->nodes().size()) {
self->showActionMenu(idx);
}
lv_obj_set_size(_poolRows[i], Theme::CONTENT_W, 22);
lv_obj_set_style_bg_color(_poolRows[i], lv_color_hex(Theme::BG), 0);
lv_obj_set_style_border_width(_poolRows[i], 1, 0);
lv_obj_set_style_border_color(_poolRows[i], lv_color_hex(Theme::BORDER), 0);
lv_obj_set_style_border_side(_poolRows[i], LV_BORDER_SIDE_BOTTOM, 0);
lv_obj_set_style_text_font(_poolNameLabels[i], &lv_font_ratdeck_12, 0);
lv_obj_set_style_text_color(_poolNameLabels[i], lv_color_hex(Theme::ACCENT), 0);
lv_label_set_text(_poolNameLabels[i], buf);
lv_obj_align(_poolNameLabels[i], LV_ALIGN_LEFT_MID, 4, 0);
lv_label_set_text(_poolInfoLabels[i], "");
}, LV_EVENT_CLICKED, this);
lv_group_add_obj(LvInput::group(), row);
lv_obj_add_event_cb(row, [](lv_event_t* e) {
lv_obj_scroll_to_view(lv_event_get_target(e), LV_ANIM_ON);
}, LV_EVENT_FOCUSED, nullptr);
// Name + hash
std::string truncName = node.name.substr(0, devMode ? 12 : 15);
std::string displayHash = node.hash.toHex().substr(0, 12);
char buf[64];
snprintf(buf, sizeof(buf), "%s [%s]", truncName.c_str(), displayHash.c_str());
lv_obj_t* nameLbl = lv_label_create(row);
lv_obj_set_style_text_font(nameLbl, &lv_font_ratdeck_12, 0);
lv_obj_set_style_text_color(nameLbl, lv_color_hex(
node.saved ? Theme::ACCENT : Theme::PRIMARY), 0);
lv_label_set_text(nameLbl, buf);
lv_obj_align(nameLbl, LV_ALIGN_LEFT_MID, 8, 0);
// Info (hops + age)
unsigned long ageSec = (millis() - node.lastSeen) / 1000;
char infoBuf[32];
if (node.hops > 0 && node.hops < 128) {
if (ageSec < 60) snprintf(infoBuf, sizeof(infoBuf), "%dhop %lus", node.hops, ageSec);
else snprintf(infoBuf, sizeof(infoBuf), "%dhop %lum", node.hops, ageSec / 60);
} else {
// Node row
int nodeIdx = mapEntryToNodeIdx(entryIdx, _sortedContactIndices, _sortedOnlineIndices);
if (nodeIdx < 0 || nodeIdx >= (int)nodes.size()) {
lv_obj_add_flag(_poolRows[i], LV_OBJ_FLAG_HIDDEN);
continue;
}
const auto& node = nodes[nodeIdx];
lv_obj_set_size(_poolRows[i], Theme::CONTENT_W, 24);
lv_obj_set_style_bg_color(_poolRows[i], lv_color_hex(
isSelected ? Theme::SELECTION_BG : Theme::BG), 0);
lv_obj_set_style_border_width(_poolRows[i], 0, 0);
lv_obj_set_style_border_side(_poolRows[i], LV_BORDER_SIDE_NONE, 0);
// Name + hash (use smaller font to reduce truncation)
bool devMode = _cfg && _cfg->settings().devMode;
std::string truncName = node.name.substr(0, devMode ? 12 : 15);
std::string displayHash = node.hash.toHex().substr(0, 12);
char buf[64];
snprintf(buf, sizeof(buf), "%s [%s]", truncName.c_str(), displayHash.c_str());
lv_obj_set_style_text_font(_poolNameLabels[i], &lv_font_ratdeck_12, 0);
lv_obj_set_style_text_color(_poolNameLabels[i], lv_color_hex(
node.saved ? Theme::ACCENT : Theme::PRIMARY), 0);
lv_label_set_text(_poolNameLabels[i], buf);
lv_obj_align(_poolNameLabels[i], LV_ALIGN_LEFT_MID, 8, 0);
// Hops + age + optional RSSI (dev mode)
unsigned long ageSec = (millis() - node.lastSeen) / 1000;
char infoBuf[32];
if (node.hops > 0 && node.hops < 128) {
if (ageSec < 60) snprintf(infoBuf, sizeof(infoBuf), "%dhop %lus", node.hops, ageSec);
else snprintf(infoBuf, sizeof(infoBuf), "%dhop %lum", node.hops, ageSec / 60);
} else {
if (ageSec < 60) snprintf(infoBuf, sizeof(infoBuf), "%lus", ageSec);
else snprintf(infoBuf, sizeof(infoBuf), "%lum", ageSec / 60);
}
if (devMode && node.rssi != 0) {
char rssiBuf[16];
snprintf(rssiBuf, sizeof(rssiBuf), " %ddB", node.rssi);
strncat(infoBuf, rssiBuf, sizeof(infoBuf) - strlen(infoBuf) - 1);
}
lv_label_set_text(_poolInfoLabels[i], infoBuf);
lv_obj_align(_poolInfoLabels[i], LV_ALIGN_RIGHT_MID, -4, 0);
if (ageSec < 60) snprintf(infoBuf, sizeof(infoBuf), "%lus", ageSec);
else snprintf(infoBuf, sizeof(infoBuf), "%lum", ageSec / 60);
}
if (devMode && node.rssi != 0) {
char rssiBuf[16];
snprintf(rssiBuf, sizeof(rssiBuf), " %ddB", node.rssi);
strncat(infoBuf, rssiBuf, sizeof(infoBuf) - strlen(infoBuf) - 1);
}
lv_obj_t* infoLbl = lv_label_create(row);
lv_obj_set_style_text_font(infoLbl, &lv_font_ratdeck_10, 0);
lv_obj_set_style_text_color(infoLbl, lv_color_hex(Theme::SECONDARY), 0);
lv_label_set_text(infoLbl, infoBuf);
lv_obj_align(infoLbl, LV_ALIGN_RIGHT_MID, -4, 0);
};
// Build list: Contacts section, then Online section
if (!_sortedContactIndices.empty()) {
char hdrBuf[32];
snprintf(hdrBuf, sizeof(hdrBuf), "Contacts (%d)", (int)_sortedContactIndices.size());
addHeader(hdrBuf);
for (int idx : _sortedContactIndices) addNodeRow(idx);
}
_dataChanged = false;
scrollToSelected();
}
// Helper: get node index for a given logical entry, -1 for headers
int LvNodesScreen::getNodeIdxForEntry(int entry) const {
return mapEntryToNodeIdx(entry, _sortedContactIndices, _sortedOnlineIndices);
}
void LvNodesScreen::scrollToSelected() {
// With widget pool, scrolling is handled by viewport recomputation in syncVisibleRows
// Find which pool row corresponds to selected entry and scroll to it
int poolIdx = _selectedIdx - _viewportStart;
if (poolIdx >= 0 && poolIdx < ROW_POOL_SIZE && _poolRows[poolIdx]) {
lv_obj_scroll_to_view(_poolRows[poolIdx], LV_ANIM_OFF);
{
char hdrBuf[32];
snprintf(hdrBuf, sizeof(hdrBuf), "Online (%d)", (int)_sortedOnlineIndices.size());
addHeader(hdrBuf);
for (int idx : _sortedOnlineIndices) addNodeRow(idx);
}
}
int LvNodesScreen::getFocusedNodeIdx() const {
lv_obj_t* focused = lv_group_get_focused(LvInput::group());
if (!focused) return -1;
return (int)(intptr_t)lv_obj_get_user_data(focused);
}
// --- Action modal helpers ---
void LvNodesScreen::showActionMenu(int nodeIdx) {
@@ -396,7 +278,6 @@ void LvNodesScreen::hideOverlay() {
void LvNodesScreen::showNicknameInput() {
_actionState = NodeAction::NICKNAME_INPUT;
// Pre-fill with announce name
if (_am && _actionNodeIdx >= 0 && _actionNodeIdx < (int)_am->nodes().size()) {
_nicknameText = String(_am->nodes()[_actionNodeIdx].name.c_str());
}
@@ -413,8 +294,6 @@ void LvNodesScreen::updateMenuSelection() {
lv_obj_set_style_bg_color(_menuBtns[i], lv_color_hex(
sel ? Theme::PRIMARY : 0x001100), 0);
lv_obj_set_style_bg_opa(_menuBtns[i], LV_OPA_COVER, 0);
lv_obj_set_style_pad_bottom(_menuLabels[i], 2, 0);
lv_obj_set_style_radius(_menuLabels[i], 3, 0);
}
}
@@ -426,13 +305,13 @@ void LvNodesScreen::updateNicknameDisplay() {
}
bool LvNodesScreen::handleLongPress() {
if (!_am || _totalEntries == 0) return false;
if (_selectedIdx < 0 || _selectedIdx >= _totalEntries) return false;
int nodeIdx = getNodeIdxForEntry(_selectedIdx);
if (!_am) return false;
int nodeIdx = getFocusedNodeIdx();
if (nodeIdx < 0 || nodeIdx >= (int)_am->nodes().size()) return false;
const auto& node = _am->nodes()[nodeIdx];
if (node.saved) {
_confirmDelete = true;
_actionNodeIdx = nodeIdx;
if (_ui) _ui->lvStatusBar().showToast("Remove friend? Enter=Yes Esc=No", 5000);
} else {
showActionMenu(nodeIdx);
@@ -441,18 +320,16 @@ bool LvNodesScreen::handleLongPress() {
}
bool LvNodesScreen::handleKey(const KeyEvent& event) {
if (!_am || _totalEntries == 0) return false;
if (!_am) return false;
// --- Nickname input mode ---
if (_actionState == NodeAction::NICKNAME_INPUT) {
if (event.enter || event.character == '\n' || event.character == '\r') {
// Save contact with nickname
if (_actionNodeIdx >= 0 && _actionNodeIdx < (int)_am->nodes().size()) {
auto& node = const_cast<DiscoveredNode&>(_am->nodes()[_actionNodeIdx]);
String finalName = _nicknameText;
finalName.trim();
if (finalName.isEmpty()) {
// Fallback: announce name → hex hash
if (!node.name.empty()) finalName = String(node.name.c_str());
else finalName = String(node.hash.toHex().substr(0, 12).c_str());
}
@@ -461,14 +338,13 @@ bool LvNodesScreen::handleKey(const KeyEvent& event) {
_am->saveContacts();
if (_ui) _ui->lvStatusBar().showToast("Contact saved!", 1200);
hideOverlay();
updateSortOrder();
syncVisibleRows();
rebuildList();
} else {
hideOverlay();
}
return true;
}
if (event.character == 0x1B) { hideOverlay(); return true; } // Esc
if (event.character == 0x1B) { hideOverlay(); return true; }
if (event.character == '\b' || event.character == 0x7F) {
if (_nicknameText.length() > 0) _nicknameText.remove(_nicknameText.length() - 1);
updateNicknameDisplay();
@@ -479,7 +355,7 @@ bool LvNodesScreen::handleKey(const KeyEvent& event) {
updateNicknameDisplay();
return true;
}
return true; // Consume all keys in input mode
return true;
}
// --- Action menu mode ---
@@ -494,10 +370,10 @@ bool LvNodesScreen::handleKey(const KeyEvent& event) {
}
if (event.enter || event.character == '\n' || event.character == '\r') {
switch (_menuIdx) {
case 0: // Add Contact
case 0:
showNicknameInput();
break;
case 1: // Message
case 1:
if (_actionNodeIdx >= 0 && _actionNodeIdx < (int)_am->nodes().size() && _onSelect) {
std::string hex = _am->nodes()[_actionNodeIdx].hash.toHex();
hideOverlay();
@@ -506,30 +382,25 @@ bool LvNodesScreen::handleKey(const KeyEvent& event) {
hideOverlay();
}
break;
case 2: // Back
case 2:
hideOverlay();
break;
}
return true;
}
if (event.character == 0x1B) { hideOverlay(); return true; } // Esc
return true; // Consume all keys in menu mode
if (event.character == 0x1B) { hideOverlay(); return true; }
return true;
}
// --- Browse mode (normal) ---
// Confirm delete mode
// --- Confirm delete mode ---
if (_confirmDelete) {
if (event.enter || event.character == '\n' || event.character == '\r') {
int nodeIdx = getNodeIdxForEntry(_selectedIdx);
if (nodeIdx >= 0 && nodeIdx < (int)_am->nodes().size()) {
if (_actionNodeIdx >= 0 && _actionNodeIdx < (int)_am->nodes().size()) {
auto& nodes = const_cast<std::vector<DiscoveredNode>&>(_am->nodes());
nodes.erase(nodes.begin() + nodeIdx);
nodes.erase(nodes.begin() + _actionNodeIdx);
_am->saveContacts();
if (_ui) _ui->lvStatusBar().showToast("Contact deleted", 1200);
_selectedIdx = 0;
updateSortOrder();
syncVisibleRows();
rebuildList();
}
_confirmDelete = false;
return true;
@@ -539,42 +410,18 @@ bool LvNodesScreen::handleKey(const KeyEvent& event) {
return true;
}
if (event.up) {
int prev = _selectedIdx;
_selectedIdx--;
while (_selectedIdx >= 0 && getNodeIdxForEntry(_selectedIdx) == -1) _selectedIdx--;
if (_selectedIdx < 0) _selectedIdx = prev;
if (_selectedIdx != prev) syncVisibleRows();
return true;
}
if (event.down) {
int prev = _selectedIdx;
_selectedIdx++;
while (_selectedIdx < _totalEntries && getNodeIdxForEntry(_selectedIdx) == -1) _selectedIdx++;
if (_selectedIdx >= _totalEntries) _selectedIdx = prev;
if (_selectedIdx != prev) syncVisibleRows();
return true;
}
if (event.enter || event.character == '\n' || event.character == '\r') {
int nodeIdx = getNodeIdxForEntry(_selectedIdx);
if (nodeIdx >= 0 && nodeIdx < (int)_am->nodes().size()) {
showActionMenu(nodeIdx);
}
return true;
}
// 's' or 'S' to save/unsave contact
if (event.character == 's' || event.character == 'S') {
int nodeIdx = getNodeIdxForEntry(_selectedIdx);
int nodeIdx = getFocusedNodeIdx();
if (nodeIdx >= 0 && nodeIdx < (int)_am->nodes().size()) {
auto& node = const_cast<DiscoveredNode&>(_am->nodes()[nodeIdx]);
node.saved = !node.saved;
if (node.saved) {
_am->saveContacts();
}
updateSortOrder();
syncVisibleRows();
if (node.saved) _am->saveContacts();
rebuildList();
}
return true;
}
// Let LVGL focus group handle up/down/enter navigation
return false;
}
+2 -14
View File
@@ -26,10 +26,8 @@ public:
const char* title() const override { return "Nodes"; }
private:
void updateSortOrder();
void syncVisibleRows();
void scrollToSelected();
int getNodeIdxForEntry(int entry) const;
void rebuildList();
int getFocusedNodeIdx() const;
// Action modal helpers
enum class NodeAction { BROWSE, ACTION_MENU, NICKNAME_INPUT };
@@ -60,24 +58,14 @@ private:
lv_obj_t* _nicknameHint = nullptr;
int _lastNodeCount = -1;
int _lastContactCount = -1;
int _selectedIdx = 0;
int _totalEntries = 0; // Total displayable entries (contacts + headers + online)
// Sorted index vectors (into _am->nodes())
std::vector<int> _sortedContactIndices;
std::vector<int> _sortedOnlineIndices;
bool _dataChanged = false;
unsigned long _lastRebuild = 0;
static constexpr unsigned long REBUILD_INTERVAL_MS = 5000;
// Widget pool — fixed set of pre-allocated row widgets
static constexpr int ROW_POOL_SIZE = 14;
lv_obj_t* _poolRows[ROW_POOL_SIZE] = {};
lv_obj_t* _poolNameLabels[ROW_POOL_SIZE] = {};
lv_obj_t* _poolInfoLabels[ROW_POOL_SIZE] = {};
int _viewportStart = 0; // First visible sorted index
lv_obj_t* _list = nullptr;
lv_obj_t* _lblEmpty = nullptr;
};
+69 -62
View File
@@ -1,6 +1,7 @@
#include "LvSettingsScreen.h"
#include "ui/Theme.h"
#include "ui/LvTheme.h"
#include "ui/LvInput.h"
#include "config/Config.h"
#include "config/UserConfig.h"
#include "ui/screens/LvTimezoneScreen.h" // For TIMEZONE_TABLE
@@ -734,11 +735,9 @@ void LvSettingsScreen::rebuildCategoryList() {
lv_obj_t* row = lv_obj_create(_scrollContainer);
lv_obj_set_size(row, Theme::CONTENT_W, 34);
lv_obj_set_style_bg_color(row, lv_color_hex(selected ? Theme::SELECTION_BG : Theme::BG), 0);
lv_obj_set_style_bg_opa(row, LV_OPA_COVER, 0);
lv_obj_set_style_border_width(row, 0, 0);
lv_obj_add_style(row, LvTheme::styleListBtn(), 0);
lv_obj_add_style(row, LvTheme::styleListBtnFocused(), LV_STATE_FOCUSED);
lv_obj_set_style_pad_all(row, 0, 0);
lv_obj_set_style_radius(row, 0, 0);
lv_obj_clear_flag(row, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_flag(row, LV_OBJ_FLAG_CLICKABLE);
lv_obj_set_user_data(row, (void*)(intptr_t)i);
@@ -748,6 +747,10 @@ void LvSettingsScreen::rebuildCategoryList() {
self->_categoryIdx = idx;
self->enterCategory(idx);
}, LV_EVENT_CLICKED, this);
lv_group_add_obj(LvInput::group(), row);
lv_obj_add_event_cb(row, [](lv_event_t* e) {
lv_obj_scroll_to_view(lv_event_get_target(e), LV_ANIM_ON);
}, LV_EVENT_FOCUSED, nullptr);
// Category name + count
char buf[48];
@@ -776,6 +779,11 @@ void LvSettingsScreen::rebuildCategoryList() {
_rowObjs.push_back(row);
}
// Restore focus to current category
if (_categoryIdx >= 0 && _categoryIdx < (int)_rowObjs.size()) {
lv_group_focus_obj(_rowObjs[_categoryIdx]);
}
}
void LvSettingsScreen::rebuildItemList() {
@@ -816,12 +824,11 @@ void LvSettingsScreen::rebuildItemList() {
lv_obj_t* row = lv_obj_create(_scrollContainer);
lv_obj_set_size(row, Theme::CONTENT_W, 22);
lv_obj_set_style_bg_color(row, lv_color_hex(
(selected && editable) ? Theme::SELECTION_BG : Theme::BG), 0);
lv_obj_set_style_bg_opa(row, LV_OPA_COVER, 0);
lv_obj_set_style_border_width(row, 0, 0);
lv_obj_add_style(row, LvTheme::styleListBtn(), 0);
if (editable) {
lv_obj_add_style(row, LvTheme::styleListBtnFocused(), LV_STATE_FOCUSED);
}
lv_obj_set_style_pad_all(row, 0, 0);
lv_obj_set_style_radius(row, 0, 0);
lv_obj_clear_flag(row, LV_OBJ_FLAG_SCROLLABLE);
if (editable) {
lv_obj_add_flag(row, LV_OBJ_FLAG_CLICKABLE);
@@ -829,7 +836,6 @@ void LvSettingsScreen::rebuildItemList() {
lv_obj_add_event_cb(row, [](lv_event_t* e) {
auto* self = (LvSettingsScreen*)lv_event_get_user_data(e);
int idx = (int)(intptr_t)lv_obj_get_user_data(lv_event_get_target(e));
// Cancel any in-progress edit before switching items
if (self->_editing || self->_textEditing || self->_freqEditing) {
self->_editing = false;
self->_textEditing = false;
@@ -841,6 +847,10 @@ void LvSettingsScreen::rebuildItemList() {
tap.enter = true;
self->handleKey(tap);
}, LV_EVENT_CLICKED, this);
lv_group_add_obj(LvInput::group(), row);
lv_obj_add_event_cb(row, [](lv_event_t* e) {
lv_obj_scroll_to_view(lv_event_get_target(e), LV_ANIM_ON);
}, LV_EVENT_FOCUSED, nullptr);
}
// Label
@@ -911,6 +921,12 @@ void LvSettingsScreen::rebuildItemList() {
_rowObjs.push_back(row);
}
// Restore focus to the currently selected item after rebuild
int focusOffset = _selectedIdx - _catRangeStart;
if (focusOffset >= 0 && focusOffset < (int)_rowObjs.size()) {
lv_group_focus_obj(_rowObjs[focusOffset]);
}
}
void LvSettingsScreen::rebuildWifiList() {
@@ -944,25 +960,48 @@ void LvSettingsScreen::rebuildWifiList() {
return;
}
// Make header tappable to go back
lv_obj_add_flag(headerRow, LV_OBJ_FLAG_CLICKABLE);
lv_obj_add_event_cb(headerRow, [](lv_event_t* e) {
auto* self = (LvSettingsScreen*)lv_event_get_user_data(e);
self->_view = SettingsView::ITEM_LIST;
self->rebuildItemList();
}, LV_EVENT_CLICKED, this);
for (int i = 0; i < (int)_wifiResults.size(); i++) {
auto& net = _wifiResults[i];
bool selected = (i == _wifiPickerIdx);
lv_obj_t* row = lv_obj_create(_scrollContainer);
lv_obj_set_size(row, Theme::CONTENT_W, 22);
lv_obj_set_style_bg_color(row, lv_color_hex(selected ? Theme::SELECTION_BG : Theme::BG), 0);
lv_obj_add_style(row, LvTheme::styleListBtn(), 0);
lv_obj_add_style(row, LvTheme::styleListBtnFocused(), LV_STATE_FOCUSED);
lv_obj_set_style_bg_opa(row, LV_OPA_COVER, 0);
lv_obj_set_style_border_width(row, 0, 0);
lv_obj_set_style_pad_all(row, 0, 0);
lv_obj_set_style_radius(row, 0, 0);
lv_obj_clear_flag(row, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_flag(row, LV_OBJ_FLAG_CLICKABLE);
lv_obj_set_user_data(row, (void*)(intptr_t)i);
lv_obj_add_event_cb(row, [](lv_event_t* e) {
auto* self = (LvSettingsScreen*)lv_event_get_user_data(e);
int idx = (int)(intptr_t)lv_obj_get_user_data(lv_event_get_target(e));
if (idx < (int)self->_wifiResults.size()) {
auto& net = self->_wifiResults[idx];
if (self->_cfg) { self->_cfg->settings().wifiSTASSID = net.ssid; self->applyAndSave(); }
}
self->_view = SettingsView::ITEM_LIST;
self->rebuildItemList();
}, LV_EVENT_CLICKED, this);
lv_group_add_obj(LvInput::group(), row);
lv_obj_add_event_cb(row, [](lv_event_t* e) {
lv_obj_scroll_to_view(lv_event_get_target(e), LV_ANIM_ON);
}, LV_EVENT_FOCUSED, nullptr);
// Lock + SSID
char buf[48];
snprintf(buf, sizeof(buf), "%s %s", net.encrypted ? "*" : " ", net.ssid.c_str());
lv_obj_t* lbl = lv_label_create(row);
lv_obj_set_style_text_font(lbl, font, 0);
lv_obj_set_style_text_color(lbl, lv_color_hex(selected ? Theme::ACCENT : Theme::PRIMARY), 0);
lv_obj_set_style_text_color(lbl, lv_color_hex(Theme::PRIMARY), 0);
lv_label_set_text(lbl, buf);
lv_obj_align(lbl, LV_ALIGN_LEFT_MID, 4, 0);
@@ -1056,23 +1095,11 @@ void LvSettingsScreen::exitToCategories() {
bool LvSettingsScreen::handleKey(const KeyEvent& event) {
switch (_view) {
case SettingsView::CATEGORY_LIST: {
if (event.up) {
if (_categoryIdx > 0) {
int prev = _categoryIdx;
_categoryIdx--;
updateCategorySelection(prev, _categoryIdx);
}
return true;
}
if (event.down) {
if (_categoryIdx < (int)_categories.size() - 1) {
int prev = _categoryIdx;
_categoryIdx++;
updateCategorySelection(prev, _categoryIdx);
}
return true;
}
// LVGL focus group handles up/down navigation
if (event.enter || event.character == '\n' || event.character == '\r') {
// Get focused category from LVGL group
lv_obj_t* focused = lv_group_get_focused(LvInput::group());
if (focused) _categoryIdx = (int)(intptr_t)lv_obj_get_user_data(focused);
enterCategory(_categoryIdx);
return true;
}
@@ -1188,23 +1215,14 @@ bool LvSettingsScreen::handleKey(const KeyEvent& event) {
return true;
}
// Browse mode
if (event.up) {
int prev = _selectedIdx;
skipToNextEditable(-1);
if (_selectedIdx != prev) updateItemSelection(prev, _selectedIdx);
return true;
}
if (event.down) {
int prev = _selectedIdx;
skipToNextEditable(1);
if (_selectedIdx != prev) updateItemSelection(prev, _selectedIdx);
return true;
}
// Browse mode — LVGL focus group handles up/down
if (event.del || event.character == 8 || event.character == 0x1B) {
exitToCategories(); return true;
}
if (event.enter || event.character == '\n' || event.character == '\r') {
// Sync _selectedIdx from LVGL focus
lv_obj_t* focused = lv_group_get_focused(LvInput::group());
if (focused) _selectedIdx = (int)(intptr_t)lv_obj_get_user_data(focused);
if (!isEditable(_selectedIdx)) return true;
auto& item = _items[_selectedIdx];
if (item.type == SettingType::ACTION) {
@@ -1252,26 +1270,15 @@ bool LvSettingsScreen::handleKey(const KeyEvent& event) {
}
case SettingsView::WIFI_PICKER: {
if (event.up) {
if (_wifiPickerIdx > 0) {
int prev = _wifiPickerIdx;
_wifiPickerIdx--;
updateWifiSelection(prev, _wifiPickerIdx);
}
return true;
}
if (event.down) {
if (_wifiPickerIdx < (int)_wifiResults.size() - 1) {
int prev = _wifiPickerIdx;
_wifiPickerIdx++;
updateWifiSelection(prev, _wifiPickerIdx);
}
return true;
}
// LVGL handles up/down navigation, click handler handles selection
if (event.enter || event.character == '\n' || event.character == '\r') {
if (_wifiPickerIdx < (int)_wifiResults.size()) {
auto& net = _wifiResults[_wifiPickerIdx];
if (_cfg) { _cfg->settings().wifiSTASSID = net.ssid; applyAndSave(); }
lv_obj_t* focused = lv_group_get_focused(LvInput::group());
if (focused) {
int idx = (int)(intptr_t)lv_obj_get_user_data(focused);
if (idx < (int)_wifiResults.size()) {
auto& net = _wifiResults[idx];
if (_cfg) { _cfg->settings().wifiSTASSID = net.ssid; applyAndSave(); }
}
}
_view = SettingsView::ITEM_LIST;
rebuildItemList();