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:
parent
0ab8e516f4
commit
1496ce68a6
27
src/BadgeConfig.h
Normal file
27
src/BadgeConfig.h
Normal 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]);
|
||||
@ -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");
|
||||
|
||||
@ -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; }
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
};
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user