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
10 changed files with 459 additions and 23 deletions

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,