#include "MyLibraryActivity.h" #include #include #include #include #include #include #include #include "BookListStore.h" #include "BookManager.h" #include "BookmarkStore.h" #include "CrossPointSettings.h" #include "HomeActivity.h" #include "MappedInputManager.h" #include "RecentBooksStore.h" #include "ScreenComponents.h" #include "fontIds.h" #include "util/StringUtils.h" // Static thumbnail existence cache definition ThumbExistsCache MyLibraryActivity::thumbExistsCache[MyLibraryActivity::MAX_THUMB_CACHE]; void MyLibraryActivity::clearThumbExistsCache() { for (int i = 0; i < MAX_THUMB_CACHE; i++) { thumbExistsCache[i].bookPath.clear(); thumbExistsCache[i].thumbPath.clear(); thumbExistsCache[i].checked = false; thumbExistsCache[i].exists = false; } Serial.printf("[%lu] [MYLIB] Thumbnail existence cache cleared\n", millis()); } namespace { // Base layout constants (bezel offsets added at render time) constexpr int BASE_TAB_BAR_Y = 15; constexpr int BASE_CONTENT_START_Y = 60; constexpr int LINE_HEIGHT = 30; constexpr int RECENTS_LINE_HEIGHT = 65; // Increased for two-line items constexpr int BASE_LEFT_MARGIN = 20; constexpr int BASE_RIGHT_MARGIN = 40; // Extra space for scroll indicator constexpr int MICRO_THUMB_WIDTH = 45; constexpr int MICRO_THUMB_HEIGHT = 60; constexpr int BASE_THUMB_RIGHT_MARGIN = 50; // Space from right edge for thumbnail // Helper function to get the micro-thumb path for a book based on its file path std::string getMicroThumbPathForBook(const std::string& bookPath) { // Calculate cache path using same hash method as Epub/Txt classes const size_t hash = std::hash{}(bookPath); if (StringUtils::checkFileExtension(bookPath, ".epub")) { return "/.crosspoint/epub_" + std::to_string(hash) + "/micro_thumb.bmp"; } else if (StringUtils::checkFileExtension(bookPath, ".txt") || StringUtils::checkFileExtension(bookPath, ".TXT")) { return "/.crosspoint/txt_" + std::to_string(hash) + "/micro_thumb.bmp"; } return ""; } // Timing thresholds 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; if (str1.back() != '/' && str2.back() == '/') return false; return lexicographical_compare( begin(str1), end(str1), begin(str2), end(str2), [](const char& char1, const char& char2) { return tolower(char1) < tolower(char2); }); }); } } // namespace int MyLibraryActivity::getPageItems() const { const int screenHeight = renderer.getScreenHeight(); 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 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; } return items; } 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()) + 1; // +1 for Search shortcut } else if (currentTab == Tab::Lists) { 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()) + 1; // +1 for Search shortcut } int MyLibraryActivity::getTotalPages() const { const int itemCount = getCurrentItemCount(); const int pageItems = getPageItems(); if (itemCount == 0) return 1; return (itemCount + pageItems - 1) / pageItems; } int MyLibraryActivity::getCurrentPage() const { const int pageItems = getPageItems(); return selectorIndex / pageItems + 1; } void MyLibraryActivity::loadRecentBooks() { recentBooks.clear(); const auto& books = RECENT_BOOKS.getBooks(); recentBooks.reserve(books.size()); for (const auto& book : books) { // Skip if file no longer exists if (!SdMan.exists(book.path.c_str())) { continue; } recentBooks.push_back(book); } } 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(); auto root = SdMan.open(basepath.c_str()); if (!root || !root.isDirectory()) { if (root) root.close(); return; } root.rewindDirectory(); char name[500]; for (auto file = root.openNextFile(); file; file = root.openNextFile()) { file.getName(name, sizeof(name)); if (name[0] == '.' || strcmp(name, "System Volume Information") == 0) { file.close(); continue; } if (file.isDirectory()) { files.emplace_back(std::string(name) + "/"); } else { auto filename = std::string(name); if (StringUtils::checkFileExtension(filename, ".epub") || StringUtils::checkFileExtension(filename, ".xtch") || StringUtils::checkFileExtension(filename, ".xtc") || StringUtils::checkFileExtension(filename, ".txt") || StringUtils::checkFileExtension(filename, ".md")) { files.emplace_back(filename); } } file.close(); } root.close(); sortFileList(files); } size_t MyLibraryActivity::findEntry(const std::string& name) const { for (size_t i = 0; i < files.size(); i++) { if (files[i] == name) return i; } return 0; } void MyLibraryActivity::taskTrampoline(void* param) { auto* self = static_cast(param); self->displayTaskLoop(); } void MyLibraryActivity::onEnter() { Activity::onEnter(); renderingMutex = xSemaphoreCreateMutex(); // 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", 4096, // Stack size (increased for epub metadata loading) this, // Parameters 1, // Priority &displayTaskHandle // Task handle ); } void MyLibraryActivity::onExit() { Activity::onExit(); // Wait until not rendering to delete task to avoid killing mid-instruction to // EPD xSemaphoreTake(renderingMutex, portMAX_DELAY); if (displayTaskHandle) { // Log stack high-water mark before deleting task (stack size: 4096 bytes) LOG_STACK_WATERMARK("MyLibraryActivity", displayTaskHandle); vTaskDelete(displayTaskHandle); displayTaskHandle = nullptr; vTaskDelay(10 / portTICK_PERIOD_MS); // Let idle task free stack } vSemaphoreDelete(renderingMutex); renderingMutex = nullptr; 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 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() { if (!isSelectedItemAFile()) { return; } if (currentTab == Tab::Recent) { const auto& book = recentBooks[selectorIndex]; actionTargetPath = book.path; // Use title if available, otherwise extract from path if (!book.title.empty()) { actionTargetName = book.title; } else { actionTargetName = book.path; const size_t lastSlash = actionTargetName.find_last_of('/'); if (lastSlash != std::string::npos) { actionTargetName = actionTargetName.substr(lastSlash + 1); } } } else { if (basepath.back() != '/') { actionTargetPath = basepath + "/" + files[selectorIndex]; } else { actionTargetPath = basepath + files[selectorIndex]; } actionTargetName = files[selectorIndex]; } uiState = UIState::ActionMenu; menuSelection = 0; // Default to Archive ignoreNextConfirmRelease = true; // Ignore the release from the long-press that opened this menu updateRequired = true; } void MyLibraryActivity::executeAction() { bool success = false; if (selectedAction == ActionType::Archive) { success = BookManager::archiveBook(actionTargetPath); } else if (selectedAction == ActionType::Delete) { success = BookManager::deleteBook(actionTargetPath); } else if (selectedAction == ActionType::RemoveFromRecents) { // Just remove from recents list, don't touch the file success = RECENT_BOOKS.removeBook(actionTargetPath); } // Note: ClearAllRecents is handled directly in loop() via ClearAllRecentsConfirming state if (success) { // Reload data loadRecentBooks(); if (selectedAction != ActionType::RemoveFromRecents) { loadFiles(); // Only reload files for Archive/Delete } // Adjust selector if needed const int itemCount = getCurrentItemCount(); if (selectorIndex >= itemCount && itemCount > 0) { selectorIndex = itemCount - 1; } else if (itemCount == 0) { selectorIndex = 0; } } uiState = UIState::Normal; updateRequired = true; } void MyLibraryActivity::togglePinForSelectedList() { if (lists.empty() || selectorIndex >= static_cast(lists.size())) return; const std::string& selected = lists[selectorIndex]; if (selected == SETTINGS.pinnedListName) { // Unpin - clear the pinned list SETTINGS.pinnedListName[0] = '\0'; } else { // Pin this list (replaces any previously pinned list) strncpy(SETTINGS.pinnedListName, selected.c_str(), sizeof(SETTINGS.pinnedListName) - 1); SETTINGS.pinnedListName[sizeof(SETTINGS.pinnedListName) - 1] = '\0'; } SETTINGS.saveToFile(); updateRequired = true; } void MyLibraryActivity::openListActionMenu() { listActionTargetName = lists[selectorIndex]; listMenuSelection = 0; // Default to Pin/Unpin uiState = UIState::ListActionMenu; ignoreNextConfirmRelease = true; updateRequired = true; } void MyLibraryActivity::executeListAction() { // Clear pinned status if deleting the pinned list if (listActionTargetName == SETTINGS.pinnedListName) { SETTINGS.pinnedListName[0] = '\0'; SETTINGS.saveToFile(); } BookListStore::deleteList(listActionTargetName); // Reload lists loadLists(); // Adjust selector if needed if (selectorIndex >= static_cast(lists.size()) && !lists.empty()) { selectorIndex = static_cast(lists.size()) - 1; } else if (lists.empty()) { selectorIndex = 0; } uiState = UIState::Normal; updateRequired = true; } void MyLibraryActivity::loop() { // Handle action menu state if (uiState == UIState::ActionMenu) { // Menu has 4 options in Recent tab, 2 options in Files tab const int maxMenuSelection = (currentTab == Tab::Recent) ? 3 : 1; if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { uiState = UIState::Normal; ignoreNextConfirmRelease = false; updateRequired = true; return; } if (mappedInput.wasReleased(MappedInputManager::Button::Up)) { menuSelection = (menuSelection + maxMenuSelection) % (maxMenuSelection + 1); updateRequired = true; return; } if (mappedInput.wasReleased(MappedInputManager::Button::Down)) { menuSelection = (menuSelection + 1) % (maxMenuSelection + 1); updateRequired = true; return; } if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { // Ignore the release from the long-press that opened this menu if (ignoreNextConfirmRelease) { ignoreNextConfirmRelease = false; return; } // Map menu selection to action type if (currentTab == Tab::Recent) { // Recent tab: Archive(0), Delete(1), Remove from Recents(2), Clear All Recents(3) switch (menuSelection) { case 0: selectedAction = ActionType::Archive; break; case 1: selectedAction = ActionType::Delete; break; case 2: selectedAction = ActionType::RemoveFromRecents; break; case 3: selectedAction = ActionType::ClearAllRecents; break; } } else { // Files tab: Archive(0), Delete(1) selectedAction = (menuSelection == 0) ? ActionType::Archive : ActionType::Delete; } // Clear All Recents needs its own confirmation dialog if (selectedAction == ActionType::ClearAllRecents) { uiState = UIState::ClearAllRecentsConfirming; } else { uiState = UIState::Confirming; } updateRequired = true; return; } return; } // Handle confirmation state if (uiState == UIState::Confirming) { if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { uiState = UIState::ActionMenu; updateRequired = true; return; } if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { executeAction(); return; } return; } // Handle list action menu state if (uiState == UIState::ListActionMenu) { if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { uiState = UIState::Normal; ignoreNextConfirmRelease = false; updateRequired = true; return; } if (mappedInput.wasReleased(MappedInputManager::Button::Up)) { listMenuSelection = 0; // Pin/Unpin updateRequired = true; return; } if (mappedInput.wasReleased(MappedInputManager::Button::Down)) { listMenuSelection = 1; // Delete updateRequired = true; return; } if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { // Ignore the release from the long-press that opened this menu if (ignoreNextConfirmRelease) { ignoreNextConfirmRelease = false; return; } if (listMenuSelection == 0) { // Pin/Unpin - toggle and return to normal togglePinForSelectedList(); uiState = UIState::Normal; } else { // Delete - go to confirmation uiState = UIState::ListConfirmingDelete; } updateRequired = true; return; } return; } // Handle list delete confirmation state if (uiState == UIState::ListConfirmingDelete) { if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { uiState = UIState::Normal; updateRequired = true; return; } if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { executeListAction(); return; } return; } // Handle clear all recents confirmation state if (uiState == UIState::ClearAllRecentsConfirming) { if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { uiState = UIState::ActionMenu; updateRequired = true; return; } if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { RECENT_BOOKS.clearAll(); loadRecentBooks(); selectorIndex = 0; uiState = UIState::Normal; updateRequired = true; return; } return; } // Normal state handling 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) { if (basepath != "/") { basepath = "/"; loadFiles(); selectorIndex = 0; updateRequired = true; } return; } // Long press Confirm to open list action menu (only on Lists tab) constexpr unsigned long LIST_ACTION_MENU_MS = 700; if (currentTab == Tab::Lists && mappedInput.isPressed(MappedInputManager::Button::Confirm) && mappedInput.getHeldTime() >= LIST_ACTION_MENU_MS && !lists.empty() && selectorIndex < static_cast(lists.size())) { openListActionMenu(); return; } // Long press Confirm to open action menu (only for files, not directories) if (mappedInput.isPressed(MappedInputManager::Button::Confirm) && mappedInput.getHeldTime() >= ACTION_MENU_MS && isSelectedItemAFile()) { openActionMenu(); return; } const bool upReleased = mappedInput.wasReleased(MappedInputManager::Button::Up); const bool downReleased = mappedInput.wasReleased(MappedInputManager::Button::Down); const bool leftReleased = mappedInput.wasReleased(MappedInputManager::Button::Left); const bool rightReleased = mappedInput.wasReleased(MappedInputManager::Button::Right); const bool skipPage = mappedInput.getHeldTime() > SKIP_PAGE_MS; // Confirm button - open selected item (short press) if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { // Ignore if it was a long press that triggered the action menu if (mappedInput.getHeldTime() >= ACTION_MENU_MS) { 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); } } else if (currentTab == Tab::Lists) { // Lists tab - open selected list if (!lists.empty() && selectorIndex < static_cast(lists.size())) { if (onSelectList) { 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())) { if (basepath.back() != '/') basepath += "/"; if (files[selectorIndex].back() == '/') { // Enter directory basepath += files[selectorIndex].substr(0, files[selectorIndex].length() - 1); loadFiles(); selectorIndex = 0; updateRequired = true; } else { // Open file onSelectBook(basepath + files[selectorIndex], currentTab); } } } return; } // Back button if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { if (mappedInput.getHeldTime() < GO_HOME_MS) { if (currentTab == Tab::Files && basepath != "/") { // Go up one directory, remembering the directory we came from const std::string oldPath = basepath; basepath.replace(basepath.find_last_of('/'), std::string::npos, ""); if (basepath.empty()) basepath = "/"; loadFiles(); // Select the directory we just came from const auto pos = oldPath.find_last_of('/'); 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 onGoHome(); } } 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 && 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 const bool prevReleased = upReleased; const bool nextReleased = downReleased; 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 { // Normal up navigation selectorIndex = selectorIndex - 1; } updateRequired = true; } else if (nextReleased && itemCount > 0) { if (skipPage) { selectorIndex = ((selectorIndex / pageItems + 1) * pageItems) % itemCount; } else { selectorIndex = (selectorIndex + 1) % itemCount; } updateRequired = true; } } void MyLibraryActivity::displayTaskLoop() { bool coverPreloaded = false; while (true) { if (updateRequired) { updateRequired = false; xSemaphoreTake(renderingMutex, portMAX_DELAY); render(); xSemaphoreGive(renderingMutex); // After first render, pre-allocate cover buffer for Home screen // This happens in background so Home screen loads faster when user navigates there if (!coverPreloaded) { coverPreloaded = true; HomeActivity::preloadCoverBuffer(); } } vTaskDelay(10 / portTICK_PERIOD_MS); } } void MyLibraryActivity::render() const { renderer.clearScreen(); // Handle different UI states if (uiState == UIState::ActionMenu) { renderActionMenu(); renderer.displayBuffer(); return; } if (uiState == UIState::Confirming) { renderConfirmation(); renderer.displayBuffer(); return; } if (uiState == UIState::ListActionMenu) { renderListActionMenu(); renderer.displayBuffer(); return; } if (uiState == UIState::ListConfirmingDelete) { renderListDeleteConfirmation(); renderer.displayBuffer(); return; } if (uiState == UIState::ClearAllRecentsConfirming) { renderClearAllRecentsConfirmation(); renderer.displayBuffer(); return; } // Calculate bezel-adjusted margins const int bezelTop = renderer.getBezelOffsetTop(); const int bezelBottom = renderer.getBezelOffsetBottom(); const int TAB_BAR_Y = BASE_TAB_BAR_Y + bezelTop; const int CONTENT_START_Y = BASE_CONTENT_START_Y + bezelTop; // Normal state - draw library view // Draw tab bar std::vector tabs = {{"Recent", currentTab == Tab::Recent}, {"Lists", currentTab == Tab::Lists}, {"Bookmarks", currentTab == Tab::Bookmarks}, {"Search", currentTab == Tab::Search}, {"Files", currentTab == Tab::Files}}; 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(); } // Draw scroll indicator const int screenHeight = renderer.getScreenHeight(); const int contentHeight = screenHeight - CONTENT_START_Y - 60 - bezelBottom; // 60 for bottom bar ScreenComponents::drawScrollIndicator(renderer, getCurrentPage(), getTotalPages(), CONTENT_START_Y, contentHeight); // Draw side button hints (up/down navigation on right side) // Note: text is rotated 90° CW, so ">" appears as "^" and "<" appears as "v" renderer.drawSideButtonHints(UI_10_FONT_ID, ">", "<"); // 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(); } 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(); 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; const int THUMB_RIGHT_MARGIN = BASE_THUMB_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, "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); // Calculate available text width (leaving space for thumbnail on the right) const int textMaxWidth = pageWidth - LEFT_MARGIN - RIGHT_MARGIN - MICRO_THUMB_WIDTH - 10; const int thumbX = pageWidth - THUMB_RIGHT_MARGIN - MICRO_THUMB_WIDTH; // Draw items for (int i = pageStartIndex; i < bookCount && i < pageStartIndex + pageItems; i++) { const auto& book = recentBooks[i]; const int y = CONTENT_START_Y + (i % pageItems) * RECENTS_LINE_HEIGHT; const bool isSelected = (i == selectorIndex); // Try to load and draw micro-thumbnail (with existence caching) bool hasThumb = false; // Check if we have cached existence info for this book ThumbExistsCache* existsCache = nullptr; std::string microThumbPath; bool thumbExists = false; bool existsCacheHit = false; if (i < MAX_THUMB_CACHE) { existsCache = &thumbExistsCache[i]; if (existsCache->checked && existsCache->bookPath == book.path) { // Use cached existence info existsCacheHit = true; thumbExists = existsCache->exists; microThumbPath = existsCache->thumbPath; } } // If not cached, check existence and cache the result if (!existsCacheHit) { microThumbPath = getMicroThumbPathForBook(book.path); thumbExists = !microThumbPath.empty() && SdMan.exists(microThumbPath.c_str()); // Cache the result if (existsCache != nullptr) { existsCache->bookPath = book.path; existsCache->thumbPath = microThumbPath; existsCache->exists = thumbExists; existsCache->checked = true; } } // Load and render thumbnail if it exists if (thumbExists) { FsFile thumbFile; if (SdMan.openFileForRead("MYL", microThumbPath, thumbFile)) { Bitmap bitmap(thumbFile); if (bitmap.parseHeaders() == BmpReaderError::Ok) { const int bmpW = bitmap.getWidth(); const int bmpH = bitmap.getHeight(); const float scaleX = static_cast(MICRO_THUMB_WIDTH) / static_cast(bmpW); const float scaleY = static_cast(MICRO_THUMB_HEIGHT) / static_cast(bmpH); const float scale = std::min(scaleX, scaleY); const int drawnW = static_cast(bmpW * scale); const int drawnH = static_cast(bmpH * scale); const int thumbY = y + (RECENTS_LINE_HEIGHT - drawnH) / 2; if (isSelected) { renderer.fillRect(thumbX, thumbY, drawnW, drawnH, false); } renderer.drawBitmap(bitmap, thumbX, thumbY, MICRO_THUMB_WIDTH, MICRO_THUMB_HEIGHT, 0, 0, isSelected); hasThumb = true; } thumbFile.close(); } } // Use full width if no thumbnail, otherwise use reduced width const int baseAvailableWidth = hasThumb ? textMaxWidth : (pageWidth - LEFT_MARGIN - RIGHT_MARGIN); // Line 1: Title std::string title = book.title; if (title.empty()) { // Fallback for older entries or files without metadata title = book.path; const size_t lastSlash = title.find_last_of('/'); if (lastSlash != std::string::npos) { title = title.substr(lastSlash + 1); } const size_t dot = title.find_last_of('.'); if (dot != std::string::npos) { title.resize(dot); } } // Extract tags for badges (only if we'll show them - when NOT selected) constexpr int badgeSpacing = 4; // Gap between badges constexpr int badgePadding = 10; // Horizontal padding inside badge (5 each side) constexpr int badgeToThumbGap = 8; // Gap between rightmost badge and cover art int totalBadgeWidth = 0; BookTags tags; if (!isSelected) { tags = StringUtils::extractBookTags(book.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; } } // When selected, use full width (no badges shown) // When not selected, reserve space for badges at the right edge (plus gap to thumbnail) const int badgeReservedWidth = totalBadgeWidth > 0 ? (totalBadgeWidth + badgeSpacing + badgeToThumbGap) : 0; const int availableWidth = isSelected ? baseAvailableWidth : (baseAvailableWidth - badgeReservedWidth); auto truncatedTitle = renderer.truncatedText(UI_12_FONT_ID, title.c_str(), availableWidth); renderer.drawText(UI_12_FONT_ID, LEFT_MARGIN, y + 2, truncatedTitle.c_str(), !isSelected); // Draw badges right-aligned (near thumbnail or right edge) - only when NOT selected if (!isSelected && totalBadgeWidth > 0) { // Position badges at the right edge of the available text area (with gap to thumbnail) const int badgeAreaRight = LEFT_MARGIN + baseAvailableWidth - badgeToThumbGap; int badgeX = badgeAreaRight - totalBadgeWidth; // Center badge vertically within title line height const int titleLineHeight = renderer.getLineHeight(UI_12_FONT_ID); const int badgeLineHeight = renderer.getLineHeight(SMALL_FONT_ID); const int badgeVerticalPadding = 4; // 2px padding top + bottom in badge 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 if (!book.author.empty()) { auto truncatedAuthor = renderer.truncatedText(UI_10_FONT_ID, book.author.c_str(), baseAvailableWidth); 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(); 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 (listCount == 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, LINE_HEIGHT); } renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, CONTENT_START_Y, "Search...", !searchSelected); return; } const auto pageStartIndex = selectorIndex / pageItems * pageItems; // Draw selection highlight renderer.fillRect(bezelLeft, CONTENT_START_Y + (selectorIndex % pageItems) * LINE_HEIGHT - 2, pageWidth - RIGHT_MARGIN - bezelLeft, LINE_HEIGHT); // Draw items for (int i = pageStartIndex; i < listCount && i < pageStartIndex + pageItems; i++) { // Add indicator for pinned list std::string displayName = lists[i]; if (displayName == SETTINGS.pinnedListName) { displayName = "• " + displayName + " •"; } auto item = renderer.truncatedText(UI_10_FONT_ID, displayName.c_str(), pageWidth - LEFT_MARGIN - RIGHT_MARGIN); 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(); 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 (fileCount == 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, LINE_HEIGHT); } renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, CONTENT_START_Y, "Search...", !searchSelected); return; } const auto pageStartIndex = selectorIndex / pageItems * pageItems; // Draw selection highlight renderer.fillRect(bezelLeft, CONTENT_START_Y + (selectorIndex % pageItems) * LINE_HEIGHT - 2, pageWidth - RIGHT_MARGIN - bezelLeft, LINE_HEIGHT); // Draw items for (int i = pageStartIndex; i < fileCount && i < pageStartIndex + pageItems; i++) { auto item = renderer.truncatedText(UI_10_FONT_ID, files[i].c_str(), pageWidth - LEFT_MARGIN - RIGHT_MARGIN); 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 { const auto pageWidth = renderer.getScreenWidth(); const auto pageHeight = renderer.getScreenHeight(); // Bezel compensation const int bezelTop = renderer.getBezelOffsetTop(); const int bezelLeft = renderer.getBezelOffsetLeft(); const int bezelRight = renderer.getBezelOffsetRight(); // Title renderer.drawCenteredText(UI_12_FONT_ID, 20 + bezelTop, "Book Actions", true, EpdFontFamily::BOLD); // Show filename const int filenameY = 70 + bezelTop; auto truncatedName = renderer.truncatedText(UI_10_FONT_ID, actionTargetName.c_str(), pageWidth - 40 - bezelLeft - bezelRight); renderer.drawCenteredText(UI_10_FONT_ID, filenameY, truncatedName.c_str()); // Menu options - 4 for Recent tab, 2 for Files tab const bool isRecentTab = (currentTab == Tab::Recent); const int menuItemCount = isRecentTab ? 4 : 2; constexpr int menuLineHeight = 35; constexpr int menuItemWidth = 160; const int menuX = (pageWidth - menuItemWidth) / 2; const int menuStartY = pageHeight / 2 - (menuItemCount * menuLineHeight) / 2; // Archive option if (menuSelection == 0) { renderer.fillRect(menuX - 10, menuStartY - 5, menuItemWidth + 20, menuLineHeight); } renderer.drawCenteredText(UI_10_FONT_ID, menuStartY, "Archive", menuSelection != 0); // Delete option if (menuSelection == 1) { renderer.fillRect(menuX - 10, menuStartY + menuLineHeight - 5, menuItemWidth + 20, menuLineHeight); } renderer.drawCenteredText(UI_10_FONT_ID, menuStartY + menuLineHeight, "Delete", menuSelection != 1); // Recent tab only: Remove from Recents and Clear All Recents if (isRecentTab) { // Remove from Recents option if (menuSelection == 2) { renderer.fillRect(menuX - 10, menuStartY + menuLineHeight * 2 - 5, menuItemWidth + 20, menuLineHeight); } renderer.drawCenteredText(UI_10_FONT_ID, menuStartY + menuLineHeight * 2, "Remove from Recents", menuSelection != 2); // Clear All Recents option if (menuSelection == 3) { renderer.fillRect(menuX - 10, menuStartY + menuLineHeight * 3 - 5, menuItemWidth + 20, menuLineHeight); } renderer.drawCenteredText(UI_10_FONT_ID, menuStartY + menuLineHeight * 3, "Clear All Recents", menuSelection != 3); } // Draw side button hints (up/down navigation) renderer.drawSideButtonHints(UI_10_FONT_ID, ">", "<"); // Draw bottom button hints const auto labels = mappedInput.mapLabels("« Cancel", "Select", "", ""); renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); } void MyLibraryActivity::renderConfirmation() const { const auto pageWidth = renderer.getScreenWidth(); const auto pageHeight = renderer.getScreenHeight(); // Title based on action const char* actionTitle; switch (selectedAction) { case ActionType::Archive: actionTitle = "Archive Book?"; break; case ActionType::Delete: actionTitle = "Delete Book?"; break; case ActionType::RemoveFromRecents: actionTitle = "Remove from Recents?"; break; default: actionTitle = "Confirm Action"; break; } renderer.drawCenteredText(UI_12_FONT_ID, 20, actionTitle, true, EpdFontFamily::BOLD); // Show filename const int filenameY = pageHeight / 2 - 40; auto truncatedName = renderer.truncatedText(UI_10_FONT_ID, actionTargetName.c_str(), pageWidth - 40); renderer.drawCenteredText(UI_10_FONT_ID, filenameY, truncatedName.c_str()); // Warning text const int warningY = pageHeight / 2; if (selectedAction == ActionType::Archive) { renderer.drawCenteredText(UI_10_FONT_ID, warningY, "Book will be moved to archive."); renderer.drawCenteredText(UI_10_FONT_ID, warningY + 25, "Reading progress will be saved."); } else if (selectedAction == ActionType::Delete) { renderer.drawCenteredText(UI_10_FONT_ID, warningY, "Book will be permanently deleted!", true, EpdFontFamily::BOLD); renderer.drawCenteredText(UI_10_FONT_ID, warningY + 25, "This cannot be undone."); } else if (selectedAction == ActionType::RemoveFromRecents) { renderer.drawCenteredText(UI_10_FONT_ID, warningY, "Book will be removed from recents."); renderer.drawCenteredText(UI_10_FONT_ID, warningY + 25, "The file will not be deleted."); } // Draw bottom button hints const auto labels = mappedInput.mapLabels("« Cancel", "Confirm", "", ""); renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); } void MyLibraryActivity::renderListActionMenu() const { const auto pageWidth = renderer.getScreenWidth(); const auto pageHeight = renderer.getScreenHeight(); // Title renderer.drawCenteredText(UI_12_FONT_ID, 20, "List Actions", true, EpdFontFamily::BOLD); // Show list name auto truncatedName = renderer.truncatedText(UI_10_FONT_ID, listActionTargetName.c_str(), pageWidth - 40); renderer.drawCenteredText(UI_10_FONT_ID, 70, truncatedName.c_str()); // Menu options const int menuStartY = pageHeight / 2 - 30; constexpr int menuLineHeight = 40; constexpr int menuItemWidth = 120; const int menuX = (pageWidth - menuItemWidth) / 2; // Pin/Unpin option (dynamic label) const bool isPinned = (listActionTargetName == SETTINGS.pinnedListName); const char* pinLabel = isPinned ? "Unpin" : "Pin"; if (listMenuSelection == 0) { renderer.fillRect(menuX - 10, menuStartY - 5, menuItemWidth + 20, menuLineHeight); } renderer.drawCenteredText(UI_10_FONT_ID, menuStartY, pinLabel, listMenuSelection != 0); // Delete option if (listMenuSelection == 1) { renderer.fillRect(menuX - 10, menuStartY + menuLineHeight - 5, menuItemWidth + 20, menuLineHeight); } renderer.drawCenteredText(UI_10_FONT_ID, menuStartY + menuLineHeight, "Delete", listMenuSelection != 1); // Draw side button hints (up/down navigation) renderer.drawSideButtonHints(UI_10_FONT_ID, ">", "<"); // Draw bottom button hints const auto labels = mappedInput.mapLabels("« Cancel", "Select", "", ""); renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); } void MyLibraryActivity::renderListDeleteConfirmation() const { const auto pageWidth = renderer.getScreenWidth(); const auto pageHeight = renderer.getScreenHeight(); // Title renderer.drawCenteredText(UI_12_FONT_ID, 20, "Delete List?", true, EpdFontFamily::BOLD); // Show list name auto truncatedName = renderer.truncatedText(UI_10_FONT_ID, listActionTargetName.c_str(), pageWidth - 40); renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 40, truncatedName.c_str()); // Warning text renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, "List will be permanently deleted!", true, EpdFontFamily::BOLD); renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 25, "This cannot be undone."); // Draw bottom button hints const auto labels = mappedInput.mapLabels("« Cancel", "Confirm", "", ""); renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); } void MyLibraryActivity::renderClearAllRecentsConfirmation() const { const auto pageHeight = renderer.getScreenHeight(); // Title renderer.drawCenteredText(UI_12_FONT_ID, 20, "Clear All Recents?", true, EpdFontFamily::BOLD); // Warning text const int warningY = pageHeight / 2 - 20; renderer.drawCenteredText(UI_10_FONT_ID, warningY, "All books will be removed from"); renderer.drawCenteredText(UI_10_FONT_ID, warningY + 25, "the recent list."); renderer.drawCenteredText(UI_10_FONT_ID, warningY + 60, "Book files will not be deleted."); // Draw bottom button hints 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); } } } }