diff --git a/lv_conf.h b/lv_conf.h index c256a52..cba362c 100644 --- a/lv_conf.h +++ b/lv_conf.h @@ -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 diff --git a/src/ui/LvInput.cpp b/src/ui/LvInput.cpp index 4d19af2..69cc34e 100644 --- a/src/ui/LvInput.cpp +++ b/src/ui/LvInput.cpp @@ -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; diff --git a/src/ui/LvTheme.cpp b/src/ui/LvTheme.cpp index c6b16df..65e4c8e 100644 --- a/src/ui/LvTheme.cpp +++ b/src/ui/LvTheme.cpp @@ -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); diff --git a/src/ui/screens/LvContactsScreen.cpp b/src/ui/screens/LvContactsScreen.cpp index d0e4b63..b2cb96d 100644 --- a/src/ui/screens/LvContactsScreen.cpp +++ b/src/ui/screens/LvContactsScreen.cpp @@ -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 @@ -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&>(_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; } diff --git a/src/ui/screens/LvContactsScreen.h b/src/ui/screens/LvContactsScreen.h index 599098f..cb37fdc 100644 --- a/src/ui/screens/LvContactsScreen.h +++ b/src/ui/screens/LvContactsScreen.h @@ -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 _contactIndices; // Maps row -> node index in _am->nodes() + std::vector _contactIndices; lv_obj_t* _list = nullptr; lv_obj_t* _lblEmpty = nullptr; - std::vector _rows; - - // Object pool - static constexpr int ROW_POOL_SIZE = 10; - lv_obj_t* _poolRows[ROW_POOL_SIZE] = {}; - lv_obj_t* _poolNameLabels[ROW_POOL_SIZE] = {}; }; diff --git a/src/ui/screens/LvMessagesScreen.cpp b/src/ui/screens/LvMessagesScreen.cpp index b05be48..6571a74 100644 --- a/src/ui/screens/LvMessagesScreen.cpp +++ b/src/ui/screens/LvMessagesScreen.cpp @@ -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; } diff --git a/src/ui/screens/LvMessagesScreen.h b/src/ui/screens/LvMessagesScreen.h index d75c2c7..ea2ff52 100644 --- a/src/ui/screens/LvMessagesScreen.h +++ b/src/ui/screens/LvMessagesScreen.h @@ -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 _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 _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 { diff --git a/src/ui/screens/LvNodesScreen.cpp b/src/ui/screens/LvNodesScreen.cpp index ee10e91..bf66739 100644 --- a/src/ui/screens/LvNodesScreen.cpp +++ b/src/ui/screens/LvNodesScreen.cpp @@ -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& contacts, const std::vector& 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& contacts, const std::vector& 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(_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&>(_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(_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; } diff --git a/src/ui/screens/LvNodesScreen.h b/src/ui/screens/LvNodesScreen.h index 9e17074..26d472c 100644 --- a/src/ui/screens/LvNodesScreen.h +++ b/src/ui/screens/LvNodesScreen.h @@ -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 _sortedContactIndices; std::vector _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; }; diff --git a/src/ui/screens/LvSettingsScreen.cpp b/src/ui/screens/LvSettingsScreen.cpp index 35127f0..21f2fbe 100644 --- a/src/ui/screens/LvSettingsScreen.cpp +++ b/src/ui/screens/LvSettingsScreen.cpp @@ -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();