diff --git a/src/activities/home/MyLibraryActivity.cpp b/src/activities/home/MyLibraryActivity.cpp index f4cf039..546daf2 100644 --- a/src/activities/home/MyLibraryActivity.cpp +++ b/src/activities/home/MyLibraryActivity.cpp @@ -5,10 +5,13 @@ #include #include +#include #include +#include #include "BookListStore.h" #include "BookManager.h" +#include "BookmarkStore.h" #include "CrossPointSettings.h" #include "HomeActivity.h" #include "MappedInputManager.h" @@ -60,6 +63,11 @@ constexpr int SKIP_PAGE_MS = 700; constexpr unsigned long GO_HOME_MS = 1000; constexpr unsigned long ACTION_MENU_MS = 700; // Long press to open action menu +// Special key indices for character picker (appended after regular characters) +constexpr int SEARCH_SPECIAL_SPACE = -1; +constexpr int SEARCH_SPECIAL_BACKSPACE = -2; +constexpr int SEARCH_SPECIAL_CLEAR = -3; + void sortFileList(std::vector& strs) { std::sort(begin(strs), end(strs), [](const std::string& str1, const std::string& str2) { if (str1.back() == '/' && str2.back() != '/') return true; @@ -76,9 +84,22 @@ int MyLibraryActivity::getPageItems() const { const int bottomBarHeight = 60; // Space for button hints const int bezelTop = renderer.getBezelOffsetTop(); const int bezelBottom = renderer.getBezelOffsetBottom(); + + // Search tab has compact layout: character picker (~30px) + query (~25px) + results + if (currentTab == Tab::Search) { + // Character picker: ~30px, Query: ~25px = 55px overhead + // Much more room for results than the old 5-row keyboard + constexpr int SEARCH_OVERHEAD = 55; + const int availableHeight = screenHeight - (BASE_CONTENT_START_Y + bezelTop) - bottomBarHeight - bezelBottom - SEARCH_OVERHEAD; + int items = availableHeight / RECENTS_LINE_HEIGHT; + if (items < 1) items = 1; + return items; + } + const int availableHeight = screenHeight - (BASE_CONTENT_START_Y + bezelTop) - bottomBarHeight - bezelBottom; - // Recent tab uses taller items (title + author), Lists and Files use single-line items - const int lineHeight = (currentTab == Tab::Recent) ? RECENTS_LINE_HEIGHT : LINE_HEIGHT; + // Recent and Bookmarks tabs use taller items (title + author), Lists and Files use single-line items + const int lineHeight = (currentTab == Tab::Recent || currentTab == Tab::Bookmarks) + ? RECENTS_LINE_HEIGHT : LINE_HEIGHT; int items = availableHeight / lineHeight; if (items < 1) { items = 1; @@ -87,12 +108,17 @@ int MyLibraryActivity::getPageItems() const { } int MyLibraryActivity::getCurrentItemCount() const { + // Add +1 for "Search..." shortcut in tabs that support it (all except Search itself) if (currentTab == Tab::Recent) { - return static_cast(recentBooks.size()); + return static_cast(recentBooks.size()) + 1; // +1 for Search shortcut } else if (currentTab == Tab::Lists) { - return static_cast(lists.size()); + return static_cast(lists.size()) + 1; // +1 for Search shortcut + } else if (currentTab == Tab::Bookmarks) { + return static_cast(bookmarkedBooks.size()) + 1; // +1 for Search shortcut + } else if (currentTab == Tab::Search) { + return static_cast(searchResults.size()); // No shortcut in Search tab } - return static_cast(files.size()); + return static_cast(files.size()) + 1; // +1 for Search shortcut } int MyLibraryActivity::getTotalPages() const { @@ -123,6 +149,202 @@ void MyLibraryActivity::loadRecentBooks() { void MyLibraryActivity::loadLists() { lists = BookListStore::listAllLists(); } +void MyLibraryActivity::loadBookmarkedBooks() { + bookmarkedBooks = BookmarkStore::getBooksWithBookmarks(); + + // Try to get better metadata from recent books + for (auto& book : bookmarkedBooks) { + for (const auto& recent : recentBooks) { + if (recent.path == book.path) { + if (!recent.title.empty()) book.title = recent.title; + if (!recent.author.empty()) book.author = recent.author; + break; + } + } + } +} + +void MyLibraryActivity::loadAllBooks() { + // Build index of all books on SD card for search + allBooks.clear(); + + // Helper lambda to recursively scan directories + std::function scanDirectory = [&](const std::string& path) { + auto dir = SdMan.open(path.c_str()); + if (!dir || !dir.isDirectory()) { + if (dir) dir.close(); + return; + } + + dir.rewindDirectory(); + char name[500]; + + for (auto file = dir.openNextFile(); file; file = dir.openNextFile()) { + file.getName(name, sizeof(name)); + if (name[0] == '.' || strcmp(name, "System Volume Information") == 0) { + file.close(); + continue; + } + + std::string fullPath = (path.back() == '/') ? path + name : path + "/" + name; + + if (file.isDirectory()) { + file.close(); + scanDirectory(fullPath); + } else { + auto filename = std::string(name); + if (StringUtils::checkFileExtension(filename, ".epub") || + StringUtils::checkFileExtension(filename, ".txt") || + StringUtils::checkFileExtension(filename, ".md")) { + SearchResult result; + result.path = fullPath; + + // Extract title from filename (remove extension) + result.title = filename; + const size_t dot = result.title.find_last_of('.'); + if (dot != std::string::npos) { + result.title.resize(dot); + } + + // Try to get metadata from recent books if available + for (const auto& recent : recentBooks) { + if (recent.path == fullPath) { + if (!recent.title.empty()) result.title = recent.title; + if (!recent.author.empty()) result.author = recent.author; + break; + } + } + + allBooks.push_back(result); + } + file.close(); + } + } + dir.close(); + }; + + scanDirectory("/"); + + // Sort alphabetically by title + std::sort(allBooks.begin(), allBooks.end(), [](const SearchResult& a, const SearchResult& b) { + return lexicographical_compare( + a.title.begin(), a.title.end(), b.title.begin(), b.title.end(), + [](char c1, char c2) { return tolower(c1) < tolower(c2); }); + }); + + // Build character set after loading books + buildSearchCharacters(); +} + +void MyLibraryActivity::buildSearchCharacters() { + // Build a set of unique characters from all book titles and authors + std::set charSet; + + for (const auto& book : allBooks) { + for (char c : book.title) { + // Convert to uppercase for display, store as uppercase + if (std::isalpha(static_cast(c))) { + charSet.insert(std::toupper(static_cast(c))); + } else if (std::isdigit(static_cast(c))) { + charSet.insert(c); + } else if (c == ' ') { + // Space handled separately as special key + } else if (std::ispunct(static_cast(c))) { + charSet.insert(c); + } + } + for (char c : book.author) { + if (std::isalpha(static_cast(c))) { + charSet.insert(std::toupper(static_cast(c))); + } else if (std::isdigit(static_cast(c))) { + charSet.insert(c); + } else if (std::ispunct(static_cast(c))) { + charSet.insert(c); + } + } + } + + // Convert set to vector, sorted: A-Z, then 0-9, then symbols + searchCharacters.clear(); + + // Add letters A-Z + for (char c = 'A'; c <= 'Z'; c++) { + if (charSet.count(c)) { + searchCharacters.push_back(c); + } + } + + // Add digits 0-9 + for (char c = '0'; c <= '9'; c++) { + if (charSet.count(c)) { + searchCharacters.push_back(c); + } + } + + // Add symbols (anything else in the set) + for (char c : charSet) { + if (!std::isalpha(static_cast(c)) && !std::isdigit(static_cast(c))) { + searchCharacters.push_back(c); + } + } + + // Reset character index if it's out of bounds + if (searchCharIndex >= static_cast(searchCharacters.size()) + 3) { // +3 for special keys + searchCharIndex = 0; + } +} + +void MyLibraryActivity::updateSearchResults() { + searchResults.clear(); + + if (searchQuery.empty()) { + // Don't show any results when query is empty - user needs to type something + return; + } + + // Convert query to lowercase for case-insensitive matching + std::string queryLower = searchQuery; + for (char& c : queryLower) c = tolower(c); + + for (const auto& book : allBooks) { + // Convert title, author, and path to lowercase + std::string titleLower = book.title; + std::string authorLower = book.author; + std::string pathLower = book.path; + for (char& c : titleLower) c = tolower(c); + for (char& c : authorLower) c = tolower(c); + for (char& c : pathLower) c = tolower(c); + + int score = 0; + + // Check for matches + if (titleLower.find(queryLower) != std::string::npos) { + score += 100; + // Bonus for match at start + if (titleLower.find(queryLower) == 0) score += 50; + } + if (!authorLower.empty() && authorLower.find(queryLower) != std::string::npos) { + score += 80; + if (authorLower.find(queryLower) == 0) score += 40; + } + if (pathLower.find(queryLower) != std::string::npos) { + score += 30; + } + + if (score > 0) { + SearchResult result = book; + result.matchScore = score; + searchResults.push_back(result); + } + } + + // Sort by match score (descending) + std::sort(searchResults.begin(), searchResults.end(), + [](const SearchResult& a, const SearchResult& b) { + return a.matchScore > b.matchScore; + }); +} + void MyLibraryActivity::loadFiles() { files.clear(); @@ -178,9 +400,20 @@ void MyLibraryActivity::onEnter() { // Load data for all tabs loadRecentBooks(); loadLists(); + loadBookmarkedBooks(); + loadAllBooks(); + updateSearchResults(); loadFiles(); selectorIndex = 0; + + // If entering Search tab, start in character picker mode + if (currentTab == Tab::Search) { + searchInResults = false; + inTabBar = false; + searchCharIndex = 0; + } + updateRequired = true; xTaskCreate(&MyLibraryActivity::taskTrampoline, "MyLibraryActivityTask", @@ -209,19 +442,24 @@ void MyLibraryActivity::onExit() { recentBooks.clear(); lists.clear(); + bookmarkedBooks.clear(); + searchResults.clear(); + allBooks.clear(); files.clear(); } bool MyLibraryActivity::isSelectedItemAFile() const { if (currentTab == Tab::Recent) { + // Don't count "Search..." shortcut as a file return !recentBooks.empty() && selectorIndex < static_cast(recentBooks.size()); - } else { - // Files tab - check if it's a file (not a directory) + } else if (currentTab == Tab::Files) { + // Files tab - check if it's a file (not a directory) and not "Search..." shortcut if (files.empty() || selectorIndex >= static_cast(files.size())) { return false; } return files[selectorIndex].back() != '/'; } + return false; } void MyLibraryActivity::openActionMenu() { @@ -502,6 +740,307 @@ void MyLibraryActivity::loop() { const int itemCount = getCurrentItemCount(); const int pageItems = getPageItems(); + // Handle tab bar navigation for non-Search tabs + if (inTabBar && currentTab != Tab::Search) { + // Left/Right switch tabs while staying in tab bar + if (mappedInput.wasReleased(MappedInputManager::Button::Left)) { + switch (currentTab) { + case Tab::Recent: + currentTab = Tab::Files; // Wrap from first to last + break; + case Tab::Lists: + currentTab = Tab::Recent; + break; + case Tab::Bookmarks: + currentTab = Tab::Lists; + break; + case Tab::Files: + currentTab = Tab::Search; + break; + default: + break; + } + selectorIndex = 0; + updateRequired = true; + return; + } + + if (mappedInput.wasReleased(MappedInputManager::Button::Right)) { + switch (currentTab) { + case Tab::Recent: + currentTab = Tab::Lists; + break; + case Tab::Lists: + currentTab = Tab::Bookmarks; + break; + case Tab::Bookmarks: + currentTab = Tab::Search; + break; + case Tab::Files: + currentTab = Tab::Recent; // Wrap from last to first + break; + default: + break; + } + selectorIndex = 0; + updateRequired = true; + return; + } + + // Down exits tab bar, enters list at top + if (mappedInput.wasReleased(MappedInputManager::Button::Down)) { + inTabBar = false; + selectorIndex = 0; + updateRequired = true; + return; + } + + // Up exits tab bar, jumps to bottom of list + if (mappedInput.wasReleased(MappedInputManager::Button::Up)) { + inTabBar = false; + if (itemCount > 0) { + selectorIndex = itemCount - 1; + } + updateRequired = true; + return; + } + + // Back goes home + if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { + onGoHome(); + return; + } + + return; + } + + // Handle Search tab navigation + if (currentTab == Tab::Search) { + const int charCount = static_cast(searchCharacters.size()); + const int totalPickerItems = charCount + 3; // +3 for SPC, <-, CLR + + if (inTabBar) { + // In tab bar mode - Left/Right switch tabs, Down goes to picker + // Use wasReleased for consistency with other tab switching code + if (mappedInput.wasReleased(MappedInputManager::Button::Left)) { + currentTab = Tab::Bookmarks; + selectorIndex = 0; + updateRequired = true; + return; + } + + if (mappedInput.wasReleased(MappedInputManager::Button::Right)) { + currentTab = Tab::Files; + selectorIndex = 0; + updateRequired = true; + return; + } + + // Down exits tab bar, goes to character picker + if (mappedInput.wasReleased(MappedInputManager::Button::Down)) { + inTabBar = false; + updateRequired = true; + return; + } + + // Up exits tab bar, jumps to bottom of results (if any) + if (mappedInput.wasReleased(MappedInputManager::Button::Up)) { + inTabBar = false; + if (!searchResults.empty()) { + searchInResults = true; + selectorIndex = static_cast(searchResults.size()) - 1; + } + updateRequired = true; + return; + } + + // Back goes home + if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { + onGoHome(); + return; + } + + return; + } else if (!searchInResults) { + // In character picker mode + + // Long press Left = jump to start + if (mappedInput.isPressed(MappedInputManager::Button::Left) && + mappedInput.getHeldTime() >= 700) { + searchCharIndex = 0; + updateRequired = true; + return; + } + + // Long press Right = jump to end + if (mappedInput.isPressed(MappedInputManager::Button::Right) && + mappedInput.getHeldTime() >= 700) { + searchCharIndex = totalPickerItems - 1; + updateRequired = true; + return; + } + + // Left/Right navigate through characters (with wrap) + if (mappedInput.wasPressed(MappedInputManager::Button::Left)) { + if (searchCharIndex > 0) { + searchCharIndex--; + } else { + searchCharIndex = totalPickerItems - 1; // Wrap to end + } + updateRequired = true; + return; + } + + if (mappedInput.wasPressed(MappedInputManager::Button::Right)) { + if (searchCharIndex < totalPickerItems - 1) { + searchCharIndex++; + } else { + searchCharIndex = 0; // Wrap to start + } + updateRequired = true; + return; + } + + // Down moves to results (if any exist) + if (mappedInput.wasPressed(MappedInputManager::Button::Down)) { + if (!searchResults.empty()) { + searchInResults = true; + selectorIndex = 0; + updateRequired = true; + } + return; + } + + // Up moves to tab bar + if (mappedInput.wasReleased(MappedInputManager::Button::Up)) { + inTabBar = true; + updateRequired = true; + return; + } + + // Confirm adds selected character or performs special action + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + if (searchCharIndex < charCount) { + // Regular character - add to query (as lowercase for search) + searchQuery += std::tolower(static_cast(searchCharacters[searchCharIndex])); + updateSearchResults(); + } else if (searchCharIndex == charCount) { + // SPC - add space + searchQuery += ' '; + updateSearchResults(); + } else if (searchCharIndex == charCount + 1) { + // <- Backspace + if (!searchQuery.empty()) { + searchQuery.pop_back(); + updateSearchResults(); + } + } else if (searchCharIndex == charCount + 2) { + // CLR - clear query + searchQuery.clear(); + updateSearchResults(); + } + updateRequired = true; + return; + } + + // Long press Back = clear entire query + if (mappedInput.isPressed(MappedInputManager::Button::Back) && + mappedInput.getHeldTime() >= 700) { + if (!searchQuery.empty()) { + searchQuery.clear(); + updateSearchResults(); + updateRequired = true; + } + return; + } + + // Short press Back = backspace (delete one char) + if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { + if (mappedInput.getHeldTime() >= 700) { + // Already handled by long press above, ignore release + return; + } + if (!searchQuery.empty()) { + searchQuery.pop_back(); + updateSearchResults(); + updateRequired = true; + } else { + // If query already empty, go home + onGoHome(); + } + return; + } + + return; // Don't process other input while in picker + } else { + // In results mode + + // Long press PageBack (side button) = jump to first result + if (mappedInput.isPressed(MappedInputManager::Button::PageBack) && + mappedInput.getHeldTime() >= 700) { + selectorIndex = 0; + updateRequired = true; + return; + } + + // Long press PageForward (side button) = jump to last result + if (mappedInput.isPressed(MappedInputManager::Button::PageForward) && + mappedInput.getHeldTime() >= 700) { + if (!searchResults.empty()) { + selectorIndex = static_cast(searchResults.size()) - 1; + } + updateRequired = true; + return; + } + + // Up/Down navigate through results + if (mappedInput.wasPressed(MappedInputManager::Button::Up)) { + if (selectorIndex > 0) { + selectorIndex--; + } else { + // At first result, move back to character picker + searchInResults = false; + } + updateRequired = true; + return; + } + + if (mappedInput.wasPressed(MappedInputManager::Button::Down)) { + if (selectorIndex < static_cast(searchResults.size()) - 1) { + selectorIndex++; + } else { + // At last result, wrap to character picker + searchInResults = false; + } + updateRequired = true; + return; + } + + // Left/Right do nothing in results (or could page?) + if (mappedInput.wasPressed(MappedInputManager::Button::Left) || + mappedInput.wasPressed(MappedInputManager::Button::Right)) { + return; + } + + // Confirm opens the selected book + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + if (!searchResults.empty() && selectorIndex < static_cast(searchResults.size())) { + onSelectBook(searchResults[selectorIndex].path, currentTab); + } + return; + } + + // Back button - go back to character picker + if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { + searchInResults = false; + updateRequired = true; + return; + } + + return; // Don't process other input + } + } + // Long press BACK (1s+) in Files tab goes to root folder if (currentTab == Tab::Files && mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= GO_HOME_MS) { @@ -544,6 +1083,29 @@ void MyLibraryActivity::loop() { return; } + // Check if "Search..." shortcut is selected (last item in non-Search tabs) + bool isSearchShortcut = false; + if (currentTab == Tab::Recent && selectorIndex == static_cast(recentBooks.size())) { + isSearchShortcut = true; + } else if (currentTab == Tab::Lists && selectorIndex == static_cast(lists.size())) { + isSearchShortcut = true; + } else if (currentTab == Tab::Bookmarks && selectorIndex == static_cast(bookmarkedBooks.size())) { + isSearchShortcut = true; + } else if (currentTab == Tab::Files && selectorIndex == static_cast(files.size())) { + isSearchShortcut = true; + } + + if (isSearchShortcut) { + // Switch to Search tab with character picker active + currentTab = Tab::Search; + selectorIndex = 0; + searchInResults = false; + inTabBar = false; + searchCharIndex = 0; + updateRequired = true; + return; + } + if (currentTab == Tab::Recent) { if (!recentBooks.empty() && selectorIndex < static_cast(recentBooks.size())) { onSelectBook(recentBooks[selectorIndex].path, currentTab); @@ -555,6 +1117,19 @@ void MyLibraryActivity::loop() { onSelectList(lists[selectorIndex]); } } + } else if (currentTab == Tab::Bookmarks) { + // Bookmarks tab - open BookmarkListActivity for the selected book + if (!bookmarkedBooks.empty() && selectorIndex < static_cast(bookmarkedBooks.size())) { + const auto& book = bookmarkedBooks[selectorIndex]; + if (onSelectBookmarkedBook) { + onSelectBookmarkedBook(book.path, book.title); + } + } + } else if (currentTab == Tab::Search) { + // Search tab - open selected result + if (!searchResults.empty() && selectorIndex < static_cast(searchResults.size())) { + onSelectBook(searchResults[selectorIndex].path, currentTab); + } } else { // Files tab if (!files.empty() && selectorIndex < static_cast(files.size())) { @@ -589,6 +1164,10 @@ void MyLibraryActivity::loop() { const std::string dirName = oldPath.substr(pos + 1) + "/"; selectorIndex = static_cast(findEntry(dirName)); + updateRequired = true; + } else if (currentTab == Tab::Search && searchInResults) { + // In Search tab viewing results, go back to character picker + searchInResults = false; updateRequired = true; } else { // Go home @@ -598,32 +1177,55 @@ void MyLibraryActivity::loop() { return; } - // Tab switching: Left/Right always control tabs (Recent <-> Lists <-> Files) - if (leftReleased) { - if (currentTab == Tab::Files) { - currentTab = Tab::Lists; - selectorIndex = 0; - updateRequired = true; - return; - } else if (currentTab == Tab::Lists) { - currentTab = Tab::Recent; - selectorIndex = 0; - updateRequired = true; - return; + // Tab switching: Left/Right control tabs with wrapping (except in Search tab where they navigate picker) + // Order: Recent <-> Lists <-> Bookmarks <-> Search <-> Files + if (leftReleased && currentTab != Tab::Search) { + switch (currentTab) { + case Tab::Recent: + currentTab = Tab::Files; // Wrap from first to last + break; + case Tab::Lists: + currentTab = Tab::Recent; + break; + case Tab::Bookmarks: + currentTab = Tab::Lists; + break; + case Tab::Search: + currentTab = Tab::Bookmarks; + break; + case Tab::Files: + currentTab = Tab::Search; + inTabBar = true; // Stay in tab bar mode when cycling to Search + break; } + selectorIndex = 0; + // Don't auto-activate keyboard when tab-switching - user can press Down to enter search + updateRequired = true; + return; } - if (rightReleased) { - if (currentTab == Tab::Recent) { - currentTab = Tab::Lists; - selectorIndex = 0; - updateRequired = true; - return; - } else if (currentTab == Tab::Lists) { - currentTab = Tab::Files; - selectorIndex = 0; - updateRequired = true; - return; + if (rightReleased && currentTab != Tab::Search) { + switch (currentTab) { + case Tab::Recent: + currentTab = Tab::Lists; + break; + case Tab::Lists: + currentTab = Tab::Bookmarks; + break; + case Tab::Bookmarks: + currentTab = Tab::Search; + inTabBar = true; // Stay in tab bar mode when cycling to Search + break; + case Tab::Search: + currentTab = Tab::Files; + break; + case Tab::Files: + currentTab = Tab::Recent; // Wrap from last to first + break; } + selectorIndex = 0; + // Don't auto-activate keyboard when tab-switching - user can press Down to enter search + updateRequired = true; + return; } // Navigation: Up/Down moves through items only @@ -632,9 +1234,14 @@ void MyLibraryActivity::loop() { if (prevReleased && itemCount > 0) { if (skipPage) { + // Long press - page up selectorIndex = ((selectorIndex / pageItems - 1) * pageItems + itemCount) % itemCount; + } else if (selectorIndex == 0) { + // At top of list, enter tab bar + inTabBar = true; } else { - selectorIndex = (selectorIndex + itemCount - 1) % itemCount; + // Normal up navigation + selectorIndex = selectorIndex - 1; } updateRequired = true; } else if (nextReleased && itemCount > 0) { @@ -711,15 +1318,22 @@ void MyLibraryActivity::render() const { // Normal state - draw library view // Draw tab bar std::vector tabs = {{"Recent", currentTab == Tab::Recent}, - {"Reading Lists", currentTab == Tab::Lists}, + {"Lists", currentTab == Tab::Lists}, + {"Bookmarks", currentTab == Tab::Bookmarks}, + {"Search", currentTab == Tab::Search}, {"Files", currentTab == Tab::Files}}; - ScreenComponents::drawTabBar(renderer, TAB_BAR_Y, tabs); + const int selectedTabIndex = static_cast(currentTab); + ScreenComponents::drawTabBar(renderer, TAB_BAR_Y, tabs, selectedTabIndex, inTabBar); // Draw content based on current tab if (currentTab == Tab::Recent) { renderRecentTab(); } else if (currentTab == Tab::Lists) { renderListsTab(); + } else if (currentTab == Tab::Bookmarks) { + renderBookmarksTab(); + } else if (currentTab == Tab::Search) { + renderSearchTab(); } else { renderFilesTab(); } @@ -733,8 +1347,22 @@ void MyLibraryActivity::render() const { // Note: text is rotated 90° CW, so ">" appears as "^" and "<" appears as "v" renderer.drawSideButtonHints(UI_10_FONT_ID, ">", "<"); - // Draw bottom button hints - const auto labels = mappedInput.mapLabels("« Back", "Open", "<", ">"); + // Draw bottom button hints - customize for Search tab states + std::string backLabel = "« Back"; + std::string confirmLabel = "Open"; + if (currentTab == Tab::Search) { + if (inTabBar) { + backLabel = "« Back"; + confirmLabel = ""; // No action in tab bar + } else if (!searchInResults) { + backLabel = "BKSP"; // Back = backspace (short), clear (long) + confirmLabel = "Select"; + } else { + backLabel = "« Back"; + confirmLabel = "Open"; + } + } + const auto labels = mappedInput.mapLabels(backLabel.c_str(), confirmLabel.c_str(), "<", ">"); renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); renderer.displayBuffer(); @@ -744,6 +1372,7 @@ void MyLibraryActivity::renderRecentTab() const { const auto pageWidth = renderer.getScreenWidth(); const int pageItems = getPageItems(); const int bookCount = static_cast(recentBooks.size()); + const int totalItems = bookCount + 1; // +1 for "Search..." shortcut // Calculate bezel-adjusted margins const int bezelTop = renderer.getBezelOffsetTop(); @@ -755,7 +1384,12 @@ void MyLibraryActivity::renderRecentTab() const { const int THUMB_RIGHT_MARGIN = BASE_THUMB_RIGHT_MARGIN + bezelRight; if (bookCount == 0) { - renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, CONTENT_START_Y, "No recent books"); + // Still show "Search..." even when empty + const bool searchSelected = (selectorIndex == 0); + if (searchSelected) { + renderer.fillRect(bezelLeft, CONTENT_START_Y - 2, pageWidth - RIGHT_MARGIN - bezelLeft, RECENTS_LINE_HEIGHT); + } + renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, CONTENT_START_Y, "Search...", !searchSelected); return; } @@ -907,12 +1541,24 @@ void MyLibraryActivity::renderRecentTab() const { renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, y + 32, truncatedAuthor.c_str(), !isSelected); } } + + // Draw "Search..." shortcut if it's on the current page + const int searchIndex = bookCount; // Last item + if (searchIndex >= pageStartIndex && searchIndex < pageStartIndex + pageItems) { + const int y = CONTENT_START_Y + (searchIndex % pageItems) * RECENTS_LINE_HEIGHT; + const bool isSelected = (selectorIndex == searchIndex); + if (isSelected) { + renderer.fillRect(bezelLeft, y - 2, pageWidth - RIGHT_MARGIN - bezelLeft, RECENTS_LINE_HEIGHT); + } + renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, y + 2, "Search...", !isSelected); + } } void MyLibraryActivity::renderListsTab() const { const auto pageWidth = renderer.getScreenWidth(); const int pageItems = getPageItems(); const int listCount = static_cast(lists.size()); + const int totalItems = listCount + 1; // +1 for "Search..." shortcut // Calculate bezel-adjusted margins const int bezelTop = renderer.getBezelOffsetTop(); @@ -923,8 +1569,12 @@ void MyLibraryActivity::renderListsTab() const { const int RIGHT_MARGIN = BASE_RIGHT_MARGIN + bezelRight; if (listCount == 0) { - renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, CONTENT_START_Y, "No lists found"); - renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, CONTENT_START_Y + LINE_HEIGHT, "Create lists in Companion App"); + // Still show "Search..." even when empty + const bool searchSelected = (selectorIndex == 0); + if (searchSelected) { + renderer.fillRect(bezelLeft, CONTENT_START_Y - 2, pageWidth - RIGHT_MARGIN - bezelLeft, LINE_HEIGHT); + } + renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, CONTENT_START_Y, "Search...", !searchSelected); return; } @@ -945,12 +1595,22 @@ void MyLibraryActivity::renderListsTab() const { renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, CONTENT_START_Y + (i % pageItems) * LINE_HEIGHT, item.c_str(), i != selectorIndex); } + + // Draw "Search..." shortcut if it's on the current page + const int searchIndex = listCount; // Last item + if (searchIndex >= pageStartIndex && searchIndex < pageStartIndex + pageItems) { + const int y = CONTENT_START_Y + (searchIndex % pageItems) * LINE_HEIGHT; + const bool isSelected = (selectorIndex == searchIndex); + // Selection highlight already drawn above, but need to handle if Search is selected + renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, y, "Search...", !isSelected); + } } void MyLibraryActivity::renderFilesTab() const { const auto pageWidth = renderer.getScreenWidth(); const int pageItems = getPageItems(); const int fileCount = static_cast(files.size()); + const int totalItems = fileCount + 1; // +1 for "Search..." shortcut // Calculate bezel-adjusted margins const int bezelTop = renderer.getBezelOffsetTop(); @@ -961,7 +1621,12 @@ void MyLibraryActivity::renderFilesTab() const { const int RIGHT_MARGIN = BASE_RIGHT_MARGIN + bezelRight; if (fileCount == 0) { - renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, CONTENT_START_Y, "No books found"); + // Still show "Search..." even when empty + const bool searchSelected = (selectorIndex == 0); + if (searchSelected) { + renderer.fillRect(bezelLeft, CONTENT_START_Y - 2, pageWidth - RIGHT_MARGIN - bezelLeft, LINE_HEIGHT); + } + renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, CONTENT_START_Y, "Search...", !searchSelected); return; } @@ -977,6 +1642,14 @@ void MyLibraryActivity::renderFilesTab() const { renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, CONTENT_START_Y + (i % pageItems) * LINE_HEIGHT, item.c_str(), i != selectorIndex); } + + // Draw "Search..." shortcut if it's on the current page + const int searchIndex = fileCount; // Last item + if (searchIndex >= pageStartIndex && searchIndex < pageStartIndex + pageItems) { + const int y = CONTENT_START_Y + (searchIndex % pageItems) * LINE_HEIGHT; + const bool isSelected = (selectorIndex == searchIndex); + renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, y, "Search...", !isSelected); + } } void MyLibraryActivity::renderActionMenu() const { @@ -1160,3 +1833,334 @@ void MyLibraryActivity::renderClearAllRecentsConfirmation() const { const auto labels = mappedInput.mapLabels("« Cancel", "Confirm", "", ""); renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); } + +void MyLibraryActivity::renderBookmarksTab() const { + const auto pageWidth = renderer.getScreenWidth(); + const int pageItems = getPageItems(); + const int bookCount = static_cast(bookmarkedBooks.size()); + const int totalItems = bookCount + 1; // +1 for "Search..." shortcut + + // Calculate bezel-adjusted margins + const int bezelTop = renderer.getBezelOffsetTop(); + const int bezelLeft = renderer.getBezelOffsetLeft(); + const int bezelRight = renderer.getBezelOffsetRight(); + const int CONTENT_START_Y = BASE_CONTENT_START_Y + bezelTop; + const int LEFT_MARGIN = BASE_LEFT_MARGIN + bezelLeft; + const int RIGHT_MARGIN = BASE_RIGHT_MARGIN + bezelRight; + + if (bookCount == 0) { + // Still show "Search..." even when empty + const bool searchSelected = (selectorIndex == 0); + if (searchSelected) { + renderer.fillRect(bezelLeft, CONTENT_START_Y - 2, pageWidth - RIGHT_MARGIN - bezelLeft, RECENTS_LINE_HEIGHT); + } + renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, CONTENT_START_Y, "No bookmarks saved", !searchSelected); + renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, CONTENT_START_Y + LINE_HEIGHT, "Search...", searchSelected); + return; + } + + const auto pageStartIndex = selectorIndex / pageItems * pageItems; + + // Draw selection highlight + renderer.fillRect(bezelLeft, CONTENT_START_Y + (selectorIndex % pageItems) * RECENTS_LINE_HEIGHT - 2, + pageWidth - RIGHT_MARGIN - bezelLeft, RECENTS_LINE_HEIGHT); + + // Draw items (similar to Recent tab but with bookmark count) + for (int i = pageStartIndex; i < bookCount && i < pageStartIndex + pageItems; i++) { + const auto& book = bookmarkedBooks[i]; + const int y = CONTENT_START_Y + (i % pageItems) * RECENTS_LINE_HEIGHT; + const bool isSelected = (i == selectorIndex); + + // Line 1: Title + std::string title = book.title; + if (title.empty()) { + title = book.path; + const size_t lastSlash = title.find_last_of('/'); + if (lastSlash != std::string::npos) { + title = title.substr(lastSlash + 1); + } + } + auto truncatedTitle = renderer.truncatedText(UI_12_FONT_ID, title.c_str(), pageWidth - LEFT_MARGIN - RIGHT_MARGIN); + renderer.drawText(UI_12_FONT_ID, LEFT_MARGIN, y + 2, truncatedTitle.c_str(), !isSelected); + + // Line 2: Bookmark count + std::string countText = std::to_string(book.bookmarkCount) + " bookmark" + (book.bookmarkCount != 1 ? "s" : ""); + renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, y + 32, countText.c_str(), !isSelected); + } + + // Draw "Search..." shortcut if it's on the current page + const int searchIndex = bookCount; // Last item + if (searchIndex >= pageStartIndex && searchIndex < pageStartIndex + pageItems) { + const int y = CONTENT_START_Y + (searchIndex % pageItems) * RECENTS_LINE_HEIGHT; + const bool isSelected = (selectorIndex == searchIndex); + renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, y + 2, "Search...", !isSelected); + } +} + +void MyLibraryActivity::renderSearchTab() const { + const auto pageWidth = renderer.getScreenWidth(); + const int pageItems = getPageItems(); + const int resultCount = static_cast(searchResults.size()); + + // Calculate bezel-adjusted margins + const int bezelTop = renderer.getBezelOffsetTop(); + const int bezelLeft = renderer.getBezelOffsetLeft(); + const int bezelRight = renderer.getBezelOffsetRight(); + const int CONTENT_START_Y = BASE_CONTENT_START_Y + bezelTop; + const int LEFT_MARGIN = BASE_LEFT_MARGIN + bezelLeft; + const int RIGHT_MARGIN = BASE_RIGHT_MARGIN + bezelRight; + + // Layout: Character picker -> Query -> Results + // Character picker height: ~30px + // Query line height: ~25px + constexpr int PICKER_HEIGHT = 30; + constexpr int QUERY_HEIGHT = 25; + + // Draw character picker at top + const int pickerY = CONTENT_START_Y; + renderCharacterPicker(pickerY); + + // Draw query string below picker + const int queryY = pickerY + PICKER_HEIGHT; + std::string displayQuery = searchQuery.empty() ? "(select characters above)" : searchQuery; + if (!searchInResults) { + displayQuery = searchQuery + "_"; // Show cursor when in picker + } + auto truncatedQuery = renderer.truncatedText(UI_10_FONT_ID, displayQuery.c_str(), pageWidth - LEFT_MARGIN - RIGHT_MARGIN); + renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, queryY, truncatedQuery.c_str()); + + // Draw results below query + const int resultsStartY = queryY + QUERY_HEIGHT; + + // Draw results section + if (resultCount == 0) { + if (searchQuery.empty()) { + renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, resultsStartY, "Select characters to search"); + } else { + renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, resultsStartY, "No results found"); + } + return; + } + + const auto pageStartIndex = selectorIndex / pageItems * pageItems; + + // Draw items - only show selection when in results mode + for (int i = pageStartIndex; i < resultCount && i < pageStartIndex + pageItems; i++) { + const auto& result = searchResults[i]; + const int y = resultsStartY + (i % pageItems) * RECENTS_LINE_HEIGHT; + const bool isSelected = searchInResults && (i == selectorIndex); + + // Draw selection highlight only when in results + if (isSelected) { + renderer.fillRect(bezelLeft, y - 2, pageWidth - RIGHT_MARGIN - bezelLeft, RECENTS_LINE_HEIGHT); + } + + // Calculate available text width + const int baseAvailableWidth = pageWidth - LEFT_MARGIN - RIGHT_MARGIN; + + // Extract tags for badges (only when NOT selected) + constexpr int badgeSpacing = 4; + constexpr int badgePadding = 10; + constexpr int badgeToEdgeGap = 8; + int totalBadgeWidth = 0; + BookTags tags; + + if (!isSelected) { + tags = StringUtils::extractBookTags(result.path); + if (!tags.extensionTag.empty()) { + totalBadgeWidth += renderer.getTextWidth(SMALL_FONT_ID, tags.extensionTag.c_str()) + badgePadding; + } + if (!tags.suffixTag.empty()) { + if (totalBadgeWidth > 0) { + totalBadgeWidth += badgeSpacing; + } + totalBadgeWidth += renderer.getTextWidth(SMALL_FONT_ID, tags.suffixTag.c_str()) + badgePadding; + } + } + + // Reserve space for badges when not selected + const int badgeReservedWidth = totalBadgeWidth > 0 ? (totalBadgeWidth + badgeSpacing + badgeToEdgeGap) : 0; + const int availableWidth = isSelected ? baseAvailableWidth : (baseAvailableWidth - badgeReservedWidth); + + // Line 1: Title + auto truncatedTitle = renderer.truncatedText(UI_12_FONT_ID, result.title.c_str(), availableWidth); + renderer.drawText(UI_12_FONT_ID, LEFT_MARGIN, y + 2, truncatedTitle.c_str(), !isSelected); + + // Draw badges right-aligned - only when NOT selected + if (!isSelected && totalBadgeWidth > 0) { + const int badgeAreaRight = LEFT_MARGIN + baseAvailableWidth - badgeToEdgeGap; + int badgeX = badgeAreaRight - totalBadgeWidth; + + const int titleLineHeight = renderer.getLineHeight(UI_12_FONT_ID); + const int badgeLineHeight = renderer.getLineHeight(SMALL_FONT_ID); + constexpr int badgeVerticalPadding = 4; + const int badgeHeight = badgeLineHeight + badgeVerticalPadding; + const int badgeY = y + 2 + (titleLineHeight - badgeHeight) / 2; + + if (!tags.extensionTag.empty()) { + int badgeWidth = ScreenComponents::drawPillBadge(renderer, badgeX, badgeY, tags.extensionTag.c_str(), + SMALL_FONT_ID, false); + badgeX += badgeWidth + badgeSpacing; + } + if (!tags.suffixTag.empty()) { + ScreenComponents::drawPillBadge(renderer, badgeX, badgeY, tags.suffixTag.c_str(), SMALL_FONT_ID, false); + } + } + + // Line 2: Author or path + std::string secondLine = result.author.empty() ? result.path : result.author; + auto truncatedSecond = renderer.truncatedText(UI_10_FONT_ID, secondLine.c_str(), baseAvailableWidth); + renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, y + 32, truncatedSecond.c_str(), !isSelected); + } +} + +void MyLibraryActivity::renderCharacterPicker(int y) const { + const auto pageWidth = renderer.getScreenWidth(); + const int bezelLeft = renderer.getBezelOffsetLeft(); + const int bezelRight = renderer.getBezelOffsetRight(); + + constexpr int charSpacing = 6; // Spacing between characters + constexpr int specialKeyPadding = 8; // Extra padding around special keys + constexpr int overflowIndicatorWidth = 16; // Space reserved for < > indicators + + // Calculate total width needed + const int charCount = static_cast(searchCharacters.size()); + const int totalItems = charCount + 3; // +3 for SPC, <-, CLR + + // Calculate character widths + int totalWidth = 0; + for (char c : searchCharacters) { + std::string label(1, c); + totalWidth += renderer.getTextWidth(UI_10_FONT_ID, label.c_str()) + charSpacing; + } + // Add special keys width + totalWidth += renderer.getTextWidth(UI_10_FONT_ID, "SPC") + specialKeyPadding; + totalWidth += renderer.getTextWidth(UI_10_FONT_ID, "<-") + specialKeyPadding; + totalWidth += renderer.getTextWidth(UI_10_FONT_ID, "CLR") + specialKeyPadding; + + // Calculate visible window - we'll scroll the character row + const int availableWidth = pageWidth - bezelLeft - bezelRight - 40; // 40 for margins (20 each side) + + // Determine scroll offset to keep selected character visible + int scrollOffset = 0; + int selectedX = 0; + int currentX = 0; + + // Calculate position of selected item + for (int i = 0; i < totalItems; i++) { + int itemWidth; + if (i < charCount) { + std::string label(1, searchCharacters[i]); + itemWidth = renderer.getTextWidth(UI_10_FONT_ID, label.c_str()) + charSpacing; + } else if (i == charCount) { + itemWidth = renderer.getTextWidth(UI_10_FONT_ID, "SPC") + specialKeyPadding; + } else if (i == charCount + 1) { + itemWidth = renderer.getTextWidth(UI_10_FONT_ID, "<-") + specialKeyPadding; + } else { + itemWidth = renderer.getTextWidth(UI_10_FONT_ID, "CLR") + specialKeyPadding; + } + + if (i == searchCharIndex) { + selectedX = currentX; + // Center the selected item in the visible area + scrollOffset = selectedX - availableWidth / 2 + itemWidth / 2; + if (scrollOffset < 0) scrollOffset = 0; + if (scrollOffset > totalWidth - availableWidth) { + scrollOffset = std::max(0, totalWidth - availableWidth); + } + break; + } + currentX += itemWidth; + } + + // Draw separator line + renderer.drawLine(bezelLeft + 20, y + 22, pageWidth - bezelRight - 20, y + 22); + + // Calculate visible area boundaries (leave room for overflow indicators) + const bool hasLeftOverflow = scrollOffset > 0; + const bool hasRightOverflow = totalWidth > availableWidth && scrollOffset < totalWidth - availableWidth; + const int visibleLeft = bezelLeft + 20 + (hasLeftOverflow ? overflowIndicatorWidth : 0); + const int visibleRight = pageWidth - bezelRight - 20 - (hasRightOverflow ? overflowIndicatorWidth : 0); + + // Draw characters + const int startX = bezelLeft + 20 - scrollOffset; + currentX = startX; + const bool showSelection = !searchInResults && !inTabBar; // Only show selection when in picker (not tab bar or results) + + for (int i = 0; i < totalItems; i++) { + std::string label; + int itemWidth; + bool isSpecial = false; + + if (i < charCount) { + label = std::string(1, searchCharacters[i]); + itemWidth = renderer.getTextWidth(UI_10_FONT_ID, label.c_str()); + } else if (i == charCount) { + label = "SPC"; + itemWidth = renderer.getTextWidth(UI_10_FONT_ID, label.c_str()); + isSpecial = true; + } else if (i == charCount + 1) { + label = "<-"; + itemWidth = renderer.getTextWidth(UI_10_FONT_ID, label.c_str()); + isSpecial = true; + } else { + label = "CLR"; + itemWidth = renderer.getTextWidth(UI_10_FONT_ID, label.c_str()); + isSpecial = true; + } + + // Only draw if visible (accounting for overflow indicator space) + const int drawX = currentX + (isSpecial ? specialKeyPadding / 2 : 0); + if (drawX + itemWidth > visibleLeft && drawX < visibleRight) { + const bool isSelected = showSelection && (i == searchCharIndex); + + if (isSelected) { + // Draw inverted background for selection + constexpr int padding = 2; + const int lineHeight = renderer.getLineHeight(UI_10_FONT_ID); + renderer.fillRect(drawX - padding, y - 2, itemWidth + padding * 2, lineHeight + 2); + // Draw text inverted (white on black) + renderer.drawText(UI_10_FONT_ID, drawX, y, label.c_str(), false); + } else { + renderer.drawText(UI_10_FONT_ID, drawX, y, label.c_str()); + } + } + + currentX += itemWidth + (isSpecial ? specialKeyPadding : charSpacing); + } + + // Draw overflow indicators if content extends beyond visible area + if (totalWidth > availableWidth) { + constexpr int triangleHeight = 12; // Height of the triangle (vertical) + constexpr int triangleWidth = 6; // Width of the triangle (horizontal) - thin/elongated + const int pickerLineHeight = renderer.getLineHeight(UI_10_FONT_ID); + const int triangleCenterY = y + pickerLineHeight / 2; + + // Left overflow indicator (more content to the left) - thin triangle pointing left + if (hasLeftOverflow) { + // Clear background behind indicator to hide any overlapping text + renderer.fillRect(bezelLeft, y - 2, overflowIndicatorWidth + 4, pickerLineHeight + 4, false); + // Draw left-pointing triangle: point on left, base on right + const int tipX = bezelLeft + 2; + for (int i = 0; i < triangleWidth; ++i) { + // Scale height based on position (0 at tip, full height at base) + const int lineHalfHeight = (triangleHeight * i) / (triangleWidth * 2); + renderer.drawLine(tipX + i, triangleCenterY - lineHalfHeight, + tipX + i, triangleCenterY + lineHalfHeight); + } + } + // Right overflow indicator (more content to the right) - thin triangle pointing right + if (hasRightOverflow) { + // Clear background behind indicator to hide any overlapping text + renderer.fillRect(pageWidth - bezelRight - overflowIndicatorWidth - 4, y - 2, overflowIndicatorWidth + 4, pickerLineHeight + 4, false); + // Draw right-pointing triangle: base on left, point on right + const int baseX = pageWidth - bezelRight - 2 - triangleWidth; + for (int i = 0; i < triangleWidth; ++i) { + // Scale height based on position (full height at base, 0 at tip) + const int lineHalfHeight = (triangleHeight * (triangleWidth - 1 - i)) / (triangleWidth * 2); + renderer.drawLine(baseX + i, triangleCenterY - lineHalfHeight, + baseX + i, triangleCenterY + lineHalfHeight); + } + } + } +} diff --git a/src/activities/home/MyLibraryActivity.h b/src/activities/home/MyLibraryActivity.h index 3d29d7b..c9fd48e 100644 --- a/src/activities/home/MyLibraryActivity.h +++ b/src/activities/home/MyLibraryActivity.h @@ -19,9 +19,25 @@ struct ThumbExistsCache { bool exists = false; // Whether thumbnail exists }; +// Search result for the Search tab +struct SearchResult { + std::string path; + std::string title; + std::string author; + int matchScore = 0; // Higher = better match +}; + +// Book with bookmarks info for the Bookmarks tab +struct BookmarkedBook { + std::string path; + std::string title; + std::string author; + int bookmarkCount = 0; +}; + class MyLibraryActivity final : public Activity { public: - enum class Tab { Recent, Lists, Files }; + enum class Tab { Recent, Lists, Bookmarks, Search, Files }; enum class UIState { Normal, ActionMenu, Confirming, ListActionMenu, ListConfirmingDelete, ClearAllRecentsConfirming }; enum class ActionType { Archive, Delete, RemoveFromRecents, ClearAllRecents }; @@ -32,6 +48,7 @@ class MyLibraryActivity final : public Activity { Tab currentTab = Tab::Recent; int selectorIndex = 0; bool updateRequired = false; + bool inTabBar = false; // true = focus on tab bar for switching tabs (all tabs) // Action menu state UIState uiState = UIState::Normal; @@ -61,6 +78,17 @@ class MyLibraryActivity final : public Activity { int listMenuSelection = 0; // 0 = Pin/Unpin, 1 = Delete std::string listActionTargetName; + // Bookmarks tab state + std::vector bookmarkedBooks; + + // Search tab state + std::string searchQuery; + std::vector searchResults; + std::vector allBooks; // Cached index of all books + std::vector searchCharacters; // Dynamic character set from library + int searchCharIndex = 0; // Current position in character picker + bool searchInResults = false; // true = navigating results, false = in character picker + // Files tab state (from FileSelectionActivity) std::string basepath = "/"; std::vector files; @@ -69,6 +97,7 @@ class MyLibraryActivity final : public Activity { const std::function onGoHome; const std::function onSelectBook; const std::function onSelectList; + const std::function onSelectBookmarkedBook; // Number of items that fit on a page int getPageItems() const; @@ -79,6 +108,9 @@ class MyLibraryActivity final : public Activity { // Data loading void loadRecentBooks(); void loadLists(); + void loadBookmarkedBooks(); + void loadAllBooks(); + void updateSearchResults(); void loadFiles(); size_t findEntry(const std::string& name) const; @@ -88,10 +120,16 @@ class MyLibraryActivity final : public Activity { void render() const; void renderRecentTab() const; void renderListsTab() const; + void renderBookmarksTab() const; + void renderSearchTab() const; void renderFilesTab() const; void renderActionMenu() const; void renderConfirmation() const; + // Search character picker helpers + void buildSearchCharacters(); + void renderCharacterPicker(int y) const; + // Action handling void openActionMenu(); void executeAction(); @@ -114,13 +152,15 @@ class MyLibraryActivity final : public Activity { const std::function& onGoHome, const std::function& onSelectBook, const std::function& onSelectList, + const std::function& onSelectBookmarkedBook = nullptr, Tab initialTab = Tab::Recent, std::string initialPath = "/") : Activity("MyLibrary", renderer, mappedInput), currentTab(initialTab), basepath(initialPath.empty() ? "/" : std::move(initialPath)), onGoHome(onGoHome), onSelectBook(onSelectBook), - onSelectList(onSelectList) {} + onSelectList(onSelectList), + onSelectBookmarkedBook(onSelectBookmarkedBook) {} void onEnter() override; void onExit() override; void loop() override;