#include "MyLibraryActivity.h" #include #include #include #include #include #include "BookListStore.h" #include "BookManager.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 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(); 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; int items = availableHeight / lineHeight; if (items < 1) { items = 1; } return items; } int MyLibraryActivity::getCurrentItemCount() const { if (currentTab == Tab::Recent) { return static_cast(recentBooks.size()); } else if (currentTab == Tab::Lists) { return static_cast(lists.size()); } return static_cast(files.size()); } 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::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(); loadFiles(); selectorIndex = 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(); files.clear(); } bool MyLibraryActivity::isSelectedItemAFile() const { if (currentTab == Tab::Recent) { return !recentBooks.empty() && selectorIndex < static_cast(recentBooks.size()); } else { // Files tab - check if it's a file (not a directory) if (files.empty() || selectorIndex >= static_cast(files.size())) { return false; } return files[selectorIndex].back() != '/'; } } 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(); // 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; } 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 { // 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 { // Go home onGoHome(); } } 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; } } 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; } } // Navigation: Up/Down moves through items only const bool prevReleased = upReleased; const bool nextReleased = downReleased; if (prevReleased && itemCount > 0) { if (skipPage) { selectorIndex = ((selectorIndex / pageItems - 1) * pageItems + itemCount) % itemCount; } else { selectorIndex = (selectorIndex + itemCount - 1) % itemCount; } 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}, {"Reading Lists", currentTab == Tab::Lists}, {"Files", currentTab == Tab::Files}}; ScreenComponents::drawTabBar(renderer, TAB_BAR_Y, tabs); // Draw content based on current tab if (currentTab == Tab::Recent) { renderRecentTab(); } else if (currentTab == Tab::Lists) { renderListsTab(); } 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 const auto labels = mappedInput.mapLabels("« Back", "Open", "<", ">"); 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()); // 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) { renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, CONTENT_START_Y, "No recent books"); 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); } } } void MyLibraryActivity::renderListsTab() const { const auto pageWidth = renderer.getScreenWidth(); const int pageItems = getPageItems(); const int listCount = static_cast(lists.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; 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"); 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); } } void MyLibraryActivity::renderFilesTab() const { const auto pageWidth = renderer.getScreenWidth(); const int pageItems = getPageItems(); const int fileCount = static_cast(files.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; if (fileCount == 0) { renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, CONTENT_START_Y, "No books found"); 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); } } 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); }