feat: Recents view improvements with badges, removal, and clearing

- Add pill badge system for displaying file type and suffix tags
- Add "Remove from Recents" option to remove individual books
- Add "Clear All Recents" option to clear entire recents list
- Add clearThumbExistsCache() for cache invalidation
- Create BadgeConfig.h for customizable badge mappings
- Add extractBookTags() utility for parsing filename badges
- Add drawPillBadge() component for rendering badges
This commit is contained in:
cottongin 2026-01-27 20:33:27 -05:00
parent 0ab8e516f4
commit 1496ce68a6
No known key found for this signature in database
GPG Key ID: 0ECC91FE4655C262
10 changed files with 459 additions and 23 deletions

27
src/BadgeConfig.h Normal file
View File

@ -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]);

View File

@ -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");

View File

@ -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<RecentBook>& getBooks() const { return recentBooks; }

View File

@ -2,6 +2,7 @@
#include <GfxRenderer.h>
#include <algorithm>
#include <cstdint>
#include <string>
@ -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;
}

View File

@ -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);
};

View File

@ -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);
}
}

View File

@ -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<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";
}
@ -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<TabInfo> 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);
}

View File

@ -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<std::string> 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<void()>& onGoHome,

View File

@ -1,7 +1,11 @@
#include "StringUtils.h"
#include <algorithm>
#include <cctype>
#include <cstring>
#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<unsigned char>(strEnd[i])) != tolower(static_cast<unsigned char>(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

View File

@ -4,6 +4,12 @@
#include <string>
// 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