diff --git a/src/BadgeConfig.h b/src/BadgeConfig.h new file mode 100644 index 0000000..871fbf1 --- /dev/null +++ b/src/BadgeConfig.h @@ -0,0 +1,27 @@ +#pragma once +// ============================================================ +// BADGE CONFIGURATION +// Edit these arrays to customize which file extensions and +// filename suffixes display badges in the Recents/Lists views. +// ============================================================ + +// Extension mappings: {".ext", "BADGE_TEXT"} +// The extension match is case-insensitive +static const char* EXTENSION_BADGES[][2] = { + {".epub", "epub"}, + {".txt", "txt"}, + {".md", "md"}, + // Add more: {".xtc", "xtc"}, +}; + +// Suffix mappings: {"-suffix", "BADGE_TEXT"} +// Matched at end of filename (before extension), case-insensitive +static const char* SUFFIX_BADGES[][2] = { + {"-x4", "X4"}, + {"-x4p", "X4+"}, + {"-og", "OG"}, + // Add more: {"-kindle", "K"}, +}; + +static const int EXTENSION_BADGE_COUNT = sizeof(EXTENSION_BADGES) / sizeof(EXTENSION_BADGES[0]); +static const int SUFFIX_BADGE_COUNT = sizeof(SUFFIX_BADGES) / sizeof(SUFFIX_BADGES[0]); diff --git a/src/RecentBooksStore.cpp b/src/RecentBooksStore.cpp index 11ea5e4..9fee37f 100644 --- a/src/RecentBooksStore.cpp +++ b/src/RecentBooksStore.cpp @@ -46,6 +46,12 @@ bool RecentBooksStore::removeBook(const std::string& path) { return true; } +void RecentBooksStore::clearAll() { + recentBooks.clear(); + saveToFile(); + Serial.printf("[%lu] [RBS] Cleared all recent books\n", millis()); +} + bool RecentBooksStore::saveToFile() const { // Make sure the directory exists SdMan.mkdir("/.crosspoint"); diff --git a/src/RecentBooksStore.h b/src/RecentBooksStore.h index 439c6ef..a7a3e79 100644 --- a/src/RecentBooksStore.h +++ b/src/RecentBooksStore.h @@ -29,6 +29,9 @@ class RecentBooksStore { // Returns true if the book was found and removed bool removeBook(const std::string& path); + // Clear all recent books from the list + void clearAll(); + // Get the list of recent books (most recent first) const std::vector& getBooks() const { return recentBooks; } diff --git a/src/ScreenComponents.cpp b/src/ScreenComponents.cpp index b5fa96e..bfa0973 100644 --- a/src/ScreenComponents.cpp +++ b/src/ScreenComponents.cpp @@ -2,6 +2,7 @@ #include +#include #include #include @@ -179,3 +180,86 @@ void ScreenComponents::drawProgressBar(const GfxRenderer& renderer, const int x, const std::string percentText = std::to_string(percent) + "%"; renderer.drawCenteredText(UI_10_FONT_ID, y + height + 15, percentText.c_str()); } + +int ScreenComponents::drawPillBadge(const GfxRenderer& renderer, const int x, const int y, const char* text, + const int fontId, const bool inverted) { + // Calculate dimensions + const int textWidth = renderer.getTextWidth(fontId, text); + const int lineHeight = renderer.getLineHeight(fontId); + + // Badge padding and sizing + constexpr int horizontalPadding = 5; + constexpr int verticalPadding = 2; + constexpr int cornerRadius = 5; + + const int badgeWidth = textWidth + horizontalPadding * 2; + const int badgeHeight = lineHeight + verticalPadding * 2; + const int badgeY = y - verticalPadding; // Adjust y to center around text baseline + + // Fill color: inverted = white fill (false), normal = black fill (true) + const bool fillColor = !inverted; + + // Ensure radius doesn't exceed half the badge dimensions + const int radius = std::min({cornerRadius, badgeWidth / 2, badgeHeight / 2}); + + // Fill center rectangle (between left and right corner columns) + if (badgeWidth > radius * 2) { + renderer.fillRect(x + radius, badgeY, badgeWidth - radius * 2, badgeHeight, fillColor); + } + + // Fill left and right edge strips (between top and bottom corners) + if (badgeHeight > radius * 2) { + renderer.fillRect(x, badgeY + radius, radius, badgeHeight - radius * 2, fillColor); + renderer.fillRect(x + badgeWidth - radius, badgeY + radius, radius, badgeHeight - radius * 2, fillColor); + } + + // Fill the four corner arcs using distance-squared check + const int radiusSq = radius * radius; + + // Top-left corner: center at (x + radius, badgeY + radius), direction (-1, -1) + for (int dy = 0; dy < radius; ++dy) { + for (int dx = 0; dx < radius; ++dx) { + const int distSq = (radius - 1 - dx) * (radius - 1 - dx) + (radius - 1 - dy) * (radius - 1 - dy); + if (distSq < radiusSq) { + renderer.drawPixel(x + dx, badgeY + dy, fillColor); + } + } + } + + // Top-right corner: center at (x + badgeWidth - radius, badgeY + radius), direction (+1, -1) + for (int dy = 0; dy < radius; ++dy) { + for (int dx = 0; dx < radius; ++dx) { + const int distSq = dx * dx + (radius - 1 - dy) * (radius - 1 - dy); + if (distSq < radiusSq) { + renderer.drawPixel(x + badgeWidth - radius + dx, badgeY + dy, fillColor); + } + } + } + + // Bottom-left corner: center at (x + radius, badgeY + badgeHeight - radius), direction (-1, +1) + for (int dy = 0; dy < radius; ++dy) { + for (int dx = 0; dx < radius; ++dx) { + const int distSq = (radius - 1 - dx) * (radius - 1 - dx) + dy * dy; + if (distSq < radiusSq) { + renderer.drawPixel(x + dx, badgeY + badgeHeight - radius + dy, fillColor); + } + } + } + + // Bottom-right corner: center at (x + badgeWidth - radius, badgeY + badgeHeight - radius), direction (+1, +1) + for (int dy = 0; dy < radius; ++dy) { + for (int dx = 0; dx < radius; ++dx) { + const int distSq = dx * dx + dy * dy; + if (distSq < radiusSq) { + renderer.drawPixel(x + badgeWidth - radius + dx, badgeY + badgeHeight - radius + dy, fillColor); + } + } + } + + // Draw text centered in badge + // Text color: inverted = black (true), normal = white (false) + const int textX = x + horizontalPadding; + renderer.drawText(fontId, textX, y, text, inverted); + + return badgeWidth; +} diff --git a/src/ScreenComponents.h b/src/ScreenComponents.h index 9857811..0499448 100644 --- a/src/ScreenComponents.h +++ b/src/ScreenComponents.h @@ -42,4 +42,16 @@ class ScreenComponents { */ static void drawProgressBar(const GfxRenderer& renderer, int x, int y, int width, int height, size_t current, size_t total); + + /** + * Draw a pill-shaped badge with text. + * @param renderer The graphics renderer + * @param x Left position of the badge + * @param y Top position of the badge (baseline of text) + * @param text Text to display in the badge + * @param fontId Font ID to use for the text + * @param inverted If true, draw white fill with black text (for selected items) + * @return The width of the badge drawn (for chaining multiple badges) + */ + static int drawPillBadge(const GfxRenderer& renderer, int x, int y, const char* text, int fontId, bool inverted); }; diff --git a/src/activities/home/ListViewActivity.cpp b/src/activities/home/ListViewActivity.cpp index 63903b5..aa94e62 100644 --- a/src/activities/home/ListViewActivity.cpp +++ b/src/activities/home/ListViewActivity.cpp @@ -31,8 +31,6 @@ std::string getMicroThumbPathForBook(const std::string& bookPath) { if (StringUtils::checkFileExtension(bookPath, ".epub")) { return "/.crosspoint/epub_" + std::to_string(hash) + "/micro_thumb.bmp"; - } else if (StringUtils::checkFileExtension(bookPath, ".xtc") || StringUtils::checkFileExtension(bookPath, ".xtch")) { - return "/.crosspoint/xtc_" + 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"; } @@ -234,7 +232,7 @@ void ListViewActivity::render() const { } // Use full width if no thumbnail - const int availableWidth = hasThumb ? textMaxWidth : (pageWidth - LEFT_MARGIN - RIGHT_MARGIN); + const int baseAvailableWidth = hasThumb ? textMaxWidth : (pageWidth - LEFT_MARGIN - RIGHT_MARGIN); // Line 1: Title std::string title = book.title; @@ -250,12 +248,60 @@ void ListViewActivity::render() const { 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 truncatedBookTitle = renderer.truncatedText(UI_12_FONT_ID, title.c_str(), availableWidth); renderer.drawText(UI_12_FONT_ID, LEFT_MARGIN, y + 2, truncatedBookTitle.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(), availableWidth); + 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); } } diff --git a/src/activities/home/MyLibraryActivity.cpp b/src/activities/home/MyLibraryActivity.cpp index 9e9f52b..83a5ab9 100644 --- a/src/activities/home/MyLibraryActivity.cpp +++ b/src/activities/home/MyLibraryActivity.cpp @@ -10,6 +10,7 @@ #include "BookListStore.h" #include "BookManager.h" #include "CrossPointSettings.h" +#include "HomeActivity.h" #include "MappedInputManager.h" #include "RecentBooksStore.h" #include "ScreenComponents.h" @@ -19,6 +20,16 @@ // 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 { // Layout constants constexpr int TAB_BAR_Y = 15; @@ -33,13 +44,11 @@ constexpr int 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/Xtc/Txt classes + // 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, ".xtc") || StringUtils::checkFileExtension(bookPath, ".xtch")) { - return "/.crosspoint/xtc_" + 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"; } @@ -187,6 +196,8 @@ void MyLibraryActivity::onExit() { // 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 @@ -249,14 +260,20 @@ void MyLibraryActivity::executeAction() { if (selectedAction == ActionType::Archive) { success = BookManager::archiveBook(actionTargetPath); - } else { + } 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(); - loadFiles(); + if (selectedAction != ActionType::RemoveFromRecents) { + loadFiles(); // Only reload files for Archive/Delete + } // Adjust selector if needed const int itemCount = getCurrentItemCount(); @@ -321,6 +338,9 @@ void MyLibraryActivity::executeListAction() { 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; @@ -329,13 +349,13 @@ void MyLibraryActivity::loop() { } if (mappedInput.wasReleased(MappedInputManager::Button::Up)) { - menuSelection = 0; // Archive + menuSelection = (menuSelection + maxMenuSelection) % (maxMenuSelection + 1); updateRequired = true; return; } if (mappedInput.wasReleased(MappedInputManager::Button::Down)) { - menuSelection = 1; // Delete + menuSelection = (menuSelection + 1) % (maxMenuSelection + 1); updateRequired = true; return; } @@ -346,8 +366,35 @@ void MyLibraryActivity::loop() { ignoreNextConfirmRelease = false; return; } - selectedAction = (menuSelection == 0) ? ActionType::Archive : ActionType::Delete; - uiState = UIState::Confirming; + + // 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; } @@ -429,6 +476,26 @@ void MyLibraryActivity::loop() { 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(); @@ -579,12 +646,21 @@ void MyLibraryActivity::loop() { } 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); } @@ -618,6 +694,12 @@ void MyLibraryActivity::render() const { return; } + if (uiState == UIState::ClearAllRecentsConfirming) { + renderClearAllRecentsConfirmation(); + renderer.displayBuffer(); + return; + } + // Normal state - draw library view // Draw tab bar std::vector tabs = {{"Recent", currentTab == Tab::Recent}, @@ -735,7 +817,7 @@ void MyLibraryActivity::renderRecentTab() const { } // Use full width if no thumbnail, otherwise use reduced width - const int availableWidth = hasThumb ? textMaxWidth : (pageWidth - LEFT_MARGIN - RIGHT_MARGIN); + const int baseAvailableWidth = hasThumb ? textMaxWidth : (pageWidth - LEFT_MARGIN - RIGHT_MARGIN); // Line 1: Title std::string title = book.title; @@ -751,12 +833,60 @@ void MyLibraryActivity::renderRecentTab() const { 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(), availableWidth); + 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); } } @@ -828,11 +958,13 @@ void MyLibraryActivity::renderActionMenu() const { auto truncatedName = renderer.truncatedText(UI_10_FONT_ID, actionTargetName.c_str(), pageWidth - 40); renderer.drawCenteredText(UI_10_FONT_ID, filenameY, truncatedName.c_str()); - // Menu options - const int menuStartY = pageHeight / 2 - 30; - constexpr int menuLineHeight = 40; - constexpr int menuItemWidth = 120; + // 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) { @@ -846,6 +978,21 @@ void MyLibraryActivity::renderActionMenu() const { } 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, ">", "<"); @@ -859,7 +1006,21 @@ void MyLibraryActivity::renderConfirmation() const { const auto pageHeight = renderer.getScreenHeight(); // Title based on action - const char* actionTitle = (selectedAction == ActionType::Archive) ? "Archive Book?" : "Delete Book?"; + 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 @@ -872,9 +1033,12 @@ void MyLibraryActivity::renderConfirmation() const { 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 { + } 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 @@ -941,3 +1105,20 @@ void MyLibraryActivity::renderListDeleteConfirmation() const { 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); +} diff --git a/src/activities/home/MyLibraryActivity.h b/src/activities/home/MyLibraryActivity.h index 6507ce5..3d29d7b 100644 --- a/src/activities/home/MyLibraryActivity.h +++ b/src/activities/home/MyLibraryActivity.h @@ -22,8 +22,8 @@ struct ThumbExistsCache { class MyLibraryActivity final : public Activity { public: enum class Tab { Recent, Lists, Files }; - enum class UIState { Normal, ActionMenu, Confirming, ListActionMenu, ListConfirmingDelete }; - enum class ActionType { Archive, Delete }; + enum class UIState { Normal, ActionMenu, Confirming, ListActionMenu, ListConfirmingDelete, ClearAllRecentsConfirming }; + enum class ActionType { Archive, Delete, RemoveFromRecents, ClearAllRecents }; private: TaskHandle_t displayTaskHandle = nullptr; @@ -47,6 +47,12 @@ class MyLibraryActivity final : public Activity { // Static thumbnail existence cache - persists across activity enter/exit static constexpr int MAX_THUMB_CACHE = 10; static ThumbExistsCache thumbExistsCache[MAX_THUMB_CACHE]; + + public: + // Clear the thumbnail existence cache (call when disk cache is cleared) + static void clearThumbExistsCache(); + + private: // Lists tab state std::vector lists; @@ -100,6 +106,9 @@ class MyLibraryActivity final : public Activity { void renderListActionMenu() const; void renderListDeleteConfirmation() const; + // Clear all recents confirmation + void renderClearAllRecentsConfirmation() const; + public: explicit MyLibraryActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::function& onGoHome, diff --git a/src/util/StringUtils.cpp b/src/util/StringUtils.cpp index 2426b68..928cd61 100644 --- a/src/util/StringUtils.cpp +++ b/src/util/StringUtils.cpp @@ -1,7 +1,11 @@ #include "StringUtils.h" +#include +#include #include +#include "BadgeConfig.h" + namespace StringUtils { std::string sanitizeFilename(const std::string& name, size_t maxLength) { @@ -80,4 +84,58 @@ void utf8TruncateChars(std::string& str, const size_t numChars) { } } +// Helper for case-insensitive string comparison +static bool endsWithCaseInsensitive(const std::string& str, const char* suffix) { + const size_t suffixLen = strlen(suffix); + if (str.length() < suffixLen) { + return false; + } + const std::string strEnd = str.substr(str.length() - suffixLen); + for (size_t i = 0; i < suffixLen; i++) { + if (tolower(static_cast(strEnd[i])) != tolower(static_cast(suffix[i]))) { + return false; + } + } + return true; +} + +BookTags extractBookTags(const std::string& path) { + BookTags tags; + + // Extract filename from path + size_t lastSlash = path.find_last_of('/'); + std::string filename = (lastSlash != std::string::npos) ? path.substr(lastSlash + 1) : path; + + // Find extension position + size_t dotPos = filename.find_last_of('.'); + std::string extension; + std::string basename; + + if (dotPos != std::string::npos) { + extension = filename.substr(dotPos); // Includes the dot + basename = filename.substr(0, dotPos); + } else { + basename = filename; + } + + // Check extension against EXTENSION_BADGES (case-insensitive) + for (int i = 0; i < EXTENSION_BADGE_COUNT; i++) { + if (endsWithCaseInsensitive(extension, EXTENSION_BADGES[i][0])) { + tags.extensionTag = EXTENSION_BADGES[i][1]; + break; + } + } + + // Check basename for suffix matches (case-insensitive) + // Check longer suffixes first to handle cases like "-x4p" vs "-x4" + for (int i = 0; i < SUFFIX_BADGE_COUNT; i++) { + if (endsWithCaseInsensitive(basename, SUFFIX_BADGES[i][0])) { + tags.suffixTag = SUFFIX_BADGES[i][1]; + break; + } + } + + return tags; +} + } // namespace StringUtils diff --git a/src/util/StringUtils.h b/src/util/StringUtils.h index 5c8332f..0d2b6bc 100644 --- a/src/util/StringUtils.h +++ b/src/util/StringUtils.h @@ -4,6 +4,12 @@ #include +// Book badge tags extracted from filename +struct BookTags { + std::string extensionTag; // Display text for extension badge (e.g., "epub"), or empty + std::string suffixTag; // Display text for suffix badge (e.g., "X4"), or empty +}; + namespace StringUtils { /** @@ -25,4 +31,8 @@ size_t utf8RemoveLastChar(std::string& str); // Truncate string by removing N UTF-8 characters from the end void utf8TruncateChars(std::string& str, size_t numChars); + +// Extract badge tags from a book path based on extension and filename suffixes +// Uses configuration from BadgeConfig.h +BookTags extractBookTags(const std::string& path); } // namespace StringUtils