feat: Library improvements - bookmarks, search, and tab navigation

Adds bookmark functionality with persistent storage, quick menu for
in-reader actions, Search tab with character picker, and unified
tab bar navigation across all library tabs.

Includes:
- BookmarkStore and BookmarkListActivity for bookmark management
- QuickMenuActivity for in-reader quick actions
- Reader bookmark integration with visual indicators
- Enhanced tab bar with scrolling, overflow indicators, and cursor
- Search tab with character picker and result navigation
- Consistent tab bar navigation (Up from top enters tab bar mode)
This commit is contained in:
cottongin 2026-01-28 02:51:51 -05:00
commit 5dab3ad5a3
No known key found for this signature in database
GPG Key ID: 0ECC91FE4655C262
19 changed files with 2361 additions and 73 deletions

View File

@ -1,12 +1,11 @@
## Feature Requests:
1) Ability to clear all books and clear individual books from Recents.
1) search for books/library
2) Bookmarks
2a) crosspoint logo on firmware flashing screen
3) ability to add/remove books from lists on device.
4) quick menu
5) hide "system folders" from files view
3) quick menu
4) crosspoint logo on firmware flashing screen
5) ability to add/remove books from lists on device.
6) hide "system folders" from files view
- dictionaries/
6) sorting options for files view
7) search for books/library
7) sorting options for files view
8) Time spent reading tracking

301
src/BookmarkStore.cpp Normal file
View File

@ -0,0 +1,301 @@
#include "BookmarkStore.h"
#include <HardwareSerial.h>
#include <SDCardManager.h>
#include <Serialization.h>
#include <algorithm>
#include <functional>
#include "util/StringUtils.h"
// Include the BookmarkedBook struct definition
#include "activities/home/MyLibraryActivity.h"
namespace {
constexpr uint8_t BOOKMARKS_FILE_VERSION = 1;
constexpr char BOOKMARKS_FILENAME[] = "bookmarks.bin";
constexpr int MAX_BOOKMARKS_PER_BOOK = 100;
// Get cache directory path for a book (same logic as BookManager)
std::string getCacheDir(const std::string& bookPath) {
const size_t hash = std::hash<std::string>{}(bookPath);
if (StringUtils::checkFileExtension(bookPath, ".epub")) {
return "/.crosspoint/epub_" + std::to_string(hash);
} else if (StringUtils::checkFileExtension(bookPath, ".txt") ||
StringUtils::checkFileExtension(bookPath, ".TXT") ||
StringUtils::checkFileExtension(bookPath, ".md")) {
return "/.crosspoint/txt_" + std::to_string(hash);
}
return "";
}
} // namespace
std::string BookmarkStore::getBookmarksFilePath(const std::string& bookPath) {
const std::string cacheDir = getCacheDir(bookPath);
if (cacheDir.empty()) return "";
return cacheDir + "/" + BOOKMARKS_FILENAME;
}
std::vector<Bookmark> BookmarkStore::getBookmarks(const std::string& bookPath) {
std::vector<Bookmark> bookmarks;
loadBookmarks(bookPath, bookmarks);
return bookmarks;
}
bool BookmarkStore::addBookmark(const std::string& bookPath, const Bookmark& bookmark) {
std::vector<Bookmark> bookmarks;
loadBookmarks(bookPath, bookmarks);
// Check if bookmark already exists at this location
auto it = std::find_if(bookmarks.begin(), bookmarks.end(), [&](const Bookmark& b) {
return b.spineIndex == bookmark.spineIndex && b.contentOffset == bookmark.contentOffset;
});
if (it != bookmarks.end()) {
Serial.printf("[%lu] [BMS] Bookmark already exists at spine %u, offset %u\n",
millis(), bookmark.spineIndex, bookmark.contentOffset);
return false;
}
// Add new bookmark
bookmarks.push_back(bookmark);
// Trim to max size (remove oldest)
if (bookmarks.size() > MAX_BOOKMARKS_PER_BOOK) {
// Sort by timestamp and remove oldest
std::sort(bookmarks.begin(), bookmarks.end(), [](const Bookmark& a, const Bookmark& b) {
return a.timestamp > b.timestamp; // Newest first
});
bookmarks.resize(MAX_BOOKMARKS_PER_BOOK);
}
return saveBookmarks(bookPath, bookmarks);
}
bool BookmarkStore::removeBookmark(const std::string& bookPath, uint16_t spineIndex, uint32_t contentOffset) {
std::vector<Bookmark> bookmarks;
loadBookmarks(bookPath, bookmarks);
auto it = std::find_if(bookmarks.begin(), bookmarks.end(), [&](const Bookmark& b) {
return b.spineIndex == spineIndex && b.contentOffset == contentOffset;
});
if (it == bookmarks.end()) {
return false;
}
bookmarks.erase(it);
Serial.printf("[%lu] [BMS] Removed bookmark at spine %u, offset %u\n", millis(), spineIndex, contentOffset);
return saveBookmarks(bookPath, bookmarks);
}
bool BookmarkStore::isPageBookmarked(const std::string& bookPath, uint16_t spineIndex, uint32_t contentOffset) {
std::vector<Bookmark> bookmarks;
loadBookmarks(bookPath, bookmarks);
return std::any_of(bookmarks.begin(), bookmarks.end(), [&](const Bookmark& b) {
return b.spineIndex == spineIndex && b.contentOffset == contentOffset;
});
}
int BookmarkStore::getBookmarkCount(const std::string& bookPath) {
const std::string filePath = getBookmarksFilePath(bookPath);
if (filePath.empty()) return 0;
FsFile inputFile;
if (!SdMan.openFileForRead("BMS", filePath, inputFile)) {
return 0;
}
uint8_t version;
serialization::readPod(inputFile, version);
if (version != BOOKMARKS_FILE_VERSION) {
inputFile.close();
return 0;
}
uint8_t count;
serialization::readPod(inputFile, count);
inputFile.close();
return count;
}
std::vector<BookmarkedBook> BookmarkStore::getBooksWithBookmarks() {
std::vector<BookmarkedBook> result;
// Scan /.crosspoint/ directory for cache folders with bookmarks
auto crosspoint = SdMan.open("/.crosspoint");
if (!crosspoint || !crosspoint.isDirectory()) {
if (crosspoint) crosspoint.close();
return result;
}
crosspoint.rewindDirectory();
char name[256];
for (auto entry = crosspoint.openNextFile(); entry; entry = crosspoint.openNextFile()) {
entry.getName(name, sizeof(name));
if (!entry.isDirectory()) {
entry.close();
continue;
}
// Check if this directory has a bookmarks file
std::string dirPath = "/.crosspoint/";
dirPath += name;
std::string bookmarksPath = dirPath + "/" + BOOKMARKS_FILENAME;
if (SdMan.exists(bookmarksPath.c_str())) {
// Read the bookmarks file to get count and book info
FsFile bookmarksFile;
if (SdMan.openFileForRead("BMS", bookmarksPath, bookmarksFile)) {
uint8_t version;
serialization::readPod(bookmarksFile, version);
if (version == BOOKMARKS_FILE_VERSION) {
uint8_t count;
serialization::readPod(bookmarksFile, count);
// Read book metadata (stored at end of file)
std::string bookPath, bookTitle, bookAuthor;
// Skip bookmark entries to get to metadata
for (uint8_t i = 0; i < count; i++) {
std::string tempName;
uint16_t tempSpine;
uint32_t tempOffset, tempTimestamp;
uint16_t tempPage;
serialization::readString(bookmarksFile, tempName);
serialization::readPod(bookmarksFile, tempSpine);
serialization::readPod(bookmarksFile, tempOffset);
serialization::readPod(bookmarksFile, tempPage);
serialization::readPod(bookmarksFile, tempTimestamp);
}
// Read book metadata
serialization::readString(bookmarksFile, bookPath);
serialization::readString(bookmarksFile, bookTitle);
serialization::readString(bookmarksFile, bookAuthor);
if (!bookPath.empty() && count > 0) {
BookmarkedBook book;
book.path = bookPath;
book.title = bookTitle;
book.author = bookAuthor;
book.bookmarkCount = count;
result.push_back(book);
}
}
bookmarksFile.close();
}
}
entry.close();
}
crosspoint.close();
// Sort by title
std::sort(result.begin(), result.end(), [](const BookmarkedBook& a, const BookmarkedBook& b) {
return a.title < b.title;
});
return result;
}
void BookmarkStore::clearBookmarks(const std::string& bookPath) {
const std::string filePath = getBookmarksFilePath(bookPath);
if (filePath.empty()) return;
SdMan.remove(filePath.c_str());
Serial.printf("[%lu] [BMS] Cleared all bookmarks for %s\n", millis(), bookPath.c_str());
}
bool BookmarkStore::saveBookmarks(const std::string& bookPath, const std::vector<Bookmark>& bookmarks) {
const std::string cacheDir = getCacheDir(bookPath);
if (cacheDir.empty()) return false;
// Make sure the directory exists
SdMan.mkdir(cacheDir.c_str());
const std::string filePath = cacheDir + "/" + BOOKMARKS_FILENAME;
FsFile outputFile;
if (!SdMan.openFileForWrite("BMS", filePath, outputFile)) {
return false;
}
serialization::writePod(outputFile, BOOKMARKS_FILE_VERSION);
const uint8_t count = static_cast<uint8_t>(std::min(bookmarks.size(), static_cast<size_t>(255)));
serialization::writePod(outputFile, count);
for (size_t i = 0; i < count; i++) {
const auto& bookmark = bookmarks[i];
serialization::writeString(outputFile, bookmark.name);
serialization::writePod(outputFile, bookmark.spineIndex);
serialization::writePod(outputFile, bookmark.contentOffset);
serialization::writePod(outputFile, bookmark.pageNumber);
serialization::writePod(outputFile, bookmark.timestamp);
}
// Store book metadata at end (for getBooksWithBookmarks to read)
// Extract title from path if we don't have it
std::string title = bookPath;
const size_t lastSlash = title.find_last_of('/');
if (lastSlash != std::string::npos) {
title = title.substr(lastSlash + 1);
}
const size_t dot = title.find_last_of('.');
if (dot != std::string::npos) {
title.resize(dot);
}
serialization::writeString(outputFile, bookPath);
serialization::writeString(outputFile, title);
serialization::writeString(outputFile, ""); // Author (not always available)
outputFile.close();
Serial.printf("[%lu] [BMS] Bookmarks saved for %s (%d entries)\n", millis(), bookPath.c_str(), count);
return true;
}
bool BookmarkStore::loadBookmarks(const std::string& bookPath, std::vector<Bookmark>& bookmarks) {
bookmarks.clear();
const std::string filePath = getBookmarksFilePath(bookPath);
if (filePath.empty()) return false;
FsFile inputFile;
if (!SdMan.openFileForRead("BMS", filePath, inputFile)) {
return false;
}
uint8_t version;
serialization::readPod(inputFile, version);
if (version != BOOKMARKS_FILE_VERSION) {
Serial.printf("[%lu] [BMS] Unknown bookmarks file version: %u\n", millis(), version);
inputFile.close();
return false;
}
uint8_t count;
serialization::readPod(inputFile, count);
bookmarks.reserve(count);
for (uint8_t i = 0; i < count; i++) {
Bookmark bookmark;
serialization::readString(inputFile, bookmark.name);
serialization::readPod(inputFile, bookmark.spineIndex);
serialization::readPod(inputFile, bookmark.contentOffset);
serialization::readPod(inputFile, bookmark.pageNumber);
serialization::readPod(inputFile, bookmark.timestamp);
bookmarks.push_back(bookmark);
}
inputFile.close();
Serial.printf("[%lu] [BMS] Bookmarks loaded for %s (%d entries)\n", millis(), bookPath.c_str(), count);
return true;
}

63
src/BookmarkStore.h Normal file
View File

@ -0,0 +1,63 @@
#pragma once
#include <string>
#include <vector>
// Forward declaration for BookmarkedBook (used by MyLibraryActivity)
struct BookmarkedBook;
// A single bookmark within a book
struct Bookmark {
std::string name; // Display name (e.g., "Chapter 1 - Page 42")
uint16_t spineIndex = 0; // For EPUB: which spine item
uint32_t contentOffset = 0; // Content offset for stable positioning
uint16_t pageNumber = 0; // Page number at time of bookmark (for display)
uint32_t timestamp = 0; // Unix timestamp when created
bool operator==(const Bookmark& other) const {
return spineIndex == other.spineIndex && contentOffset == other.contentOffset;
}
};
/**
* BookmarkStore manages bookmarks for books.
* Bookmarks are stored per-book in the book's cache directory:
* /.crosspoint/{epub_|txt_}<hash>/bookmarks.bin
*
* This is a static utility class, not a singleton, since bookmarks
* are loaded/saved on demand for specific books.
*/
class BookmarkStore {
public:
// Get all bookmarks for a book
static std::vector<Bookmark> getBookmarks(const std::string& bookPath);
// Add a bookmark to a book
// Returns true if added, false if bookmark already exists at that location
static bool addBookmark(const std::string& bookPath, const Bookmark& bookmark);
// Remove a bookmark from a book by content offset
// Returns true if removed, false if not found
static bool removeBookmark(const std::string& bookPath, uint16_t spineIndex, uint32_t contentOffset);
// Check if a specific page is bookmarked
static bool isPageBookmarked(const std::string& bookPath, uint16_t spineIndex, uint32_t contentOffset);
// Get count of bookmarks for a book (without loading all data)
static int getBookmarkCount(const std::string& bookPath);
// Get all books that have bookmarks (for Bookmarks tab)
static std::vector<BookmarkedBook> getBooksWithBookmarks();
// Delete all bookmarks for a book
static void clearBookmarks(const std::string& bookPath);
private:
// Get the bookmarks file path for a book
static std::string getBookmarksFilePath(const std::string& bookPath);
// Save bookmarks to file
static bool saveBookmarks(const std::string& bookPath, const std::vector<Bookmark>& bookmarks);
// Load bookmarks from file
static bool loadBookmarks(const std::string& bookPath, std::vector<Bookmark>& bookmarks);
};

View File

@ -86,7 +86,7 @@ class CrossPointSettings {
};
// Short power button press actions
enum SHORT_PWRBTN { IGNORE = 0, SLEEP = 1, PAGE_TURN = 2, DICTIONARY = 3, SHORT_PWRBTN_COUNT };
enum SHORT_PWRBTN { IGNORE = 0, SLEEP = 1, PAGE_TURN = 2, DICTIONARY = 3, QUICK_MENU = 4, SHORT_PWRBTN_COUNT };
// Hide battery percentage
enum HIDE_BATTERY_PERCENTAGE { HIDE_NEVER = 0, HIDE_READER = 1, HIDE_ALWAYS = 2, HIDE_BATTERY_PERCENTAGE_COUNT };

View File

@ -90,33 +90,155 @@ void ScreenComponents::drawBookProgressBar(const GfxRenderer& renderer, const si
renderer.fillRect(vieweableMarginLeft, progressBarY, barWidth, BOOK_PROGRESS_BAR_HEIGHT, true);
}
int ScreenComponents::drawTabBar(const GfxRenderer& renderer, const int y, const std::vector<TabInfo>& tabs) {
int ScreenComponents::drawTabBar(const GfxRenderer& renderer, const int y, const std::vector<TabInfo>& tabs, int selectedIndex, bool showCursor) {
constexpr int tabPadding = 20; // Horizontal padding between tabs
constexpr int leftMargin = 20; // Left margin for first tab
constexpr int rightMargin = 20; // Right margin
constexpr int underlineHeight = 2; // Height of selection underline
constexpr int underlineGap = 4; // Gap between text and underline
constexpr int cursorPadding = 4; // Space between bullet cursor and tab text
constexpr int overflowIndicatorWidth = 16; // Space reserved for < > indicators
const int lineHeight = renderer.getLineHeight(UI_12_FONT_ID);
const int tabBarHeight = lineHeight + underlineGap + underlineHeight;
const int screenWidth = renderer.getScreenWidth();
const int bezelLeft = renderer.getBezelOffsetLeft();
const int bezelRight = renderer.getBezelOffsetRight();
const int availableWidth = screenWidth - bezelLeft - bezelRight - leftMargin - rightMargin;
int currentX = leftMargin;
// Find selected index if not provided
if (selectedIndex < 0) {
for (size_t i = 0; i < tabs.size(); i++) {
if (tabs[i].selected) {
selectedIndex = static_cast<int>(i);
break;
}
}
}
// Calculate total width of all tabs and individual tab widths
std::vector<int> tabWidths;
int totalWidth = 0;
for (const auto& tab : tabs) {
const int textWidth =
renderer.getTextWidth(UI_12_FONT_ID, tab.label, tab.selected ? EpdFontFamily::BOLD : EpdFontFamily::REGULAR);
const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, tab.label, tab.selected ? EpdFontFamily::BOLD : EpdFontFamily::REGULAR);
tabWidths.push_back(textWidth);
totalWidth += textWidth;
}
totalWidth += static_cast<int>(tabs.size() - 1) * tabPadding; // Add padding between tabs
// Calculate scroll offset to keep selected tab visible
int scrollOffset = 0;
if (totalWidth > availableWidth && selectedIndex >= 0) {
// Calculate position of selected tab
int selectedStart = 0;
for (int i = 0; i < selectedIndex; i++) {
selectedStart += tabWidths[i] + tabPadding;
}
int selectedEnd = selectedStart + tabWidths[selectedIndex];
// If selected tab would be cut off on the right, scroll left
if (selectedEnd > availableWidth) {
scrollOffset = selectedEnd - availableWidth + tabPadding;
}
// If selected tab would be cut off on the left (after scrolling), adjust
if (selectedStart - scrollOffset < 0) {
scrollOffset = selectedStart;
}
}
int currentX = leftMargin + bezelLeft - scrollOffset;
// Bullet cursor settings
constexpr int bulletRadius = 3;
const int bulletCenterY = y + lineHeight / 2;
// Calculate visible area boundaries (leave room for overflow indicators)
const bool hasLeftOverflow = scrollOffset > 0;
const bool hasRightOverflow = totalWidth > availableWidth && scrollOffset < totalWidth - availableWidth;
const int visibleLeft = bezelLeft + (hasLeftOverflow ? overflowIndicatorWidth : 0);
const int visibleRight = screenWidth - bezelRight - (hasRightOverflow ? overflowIndicatorWidth : 0);
for (size_t i = 0; i < tabs.size(); i++) {
const auto& tab = tabs[i];
const int textWidth = tabWidths[i];
// Only draw if at least partially visible (accounting for overflow indicator space)
if (currentX + textWidth > visibleLeft && currentX < visibleRight) {
// Draw bullet cursor before selected tab when showCursor is true
if (showCursor && tab.selected) {
// Draw filled circle using distance-squared check
const int bulletCenterX = currentX - cursorPadding - bulletRadius;
const int radiusSq = bulletRadius * bulletRadius;
for (int dy = -bulletRadius; dy <= bulletRadius; ++dy) {
for (int dx = -bulletRadius; dx <= bulletRadius; ++dx) {
if (dx * dx + dy * dy <= radiusSq) {
renderer.drawPixel(bulletCenterX + dx, bulletCenterY + dy, true);
}
}
}
}
// Draw tab label
renderer.drawText(UI_12_FONT_ID, currentX, y, tab.label, true,
tab.selected ? EpdFontFamily::BOLD : EpdFontFamily::REGULAR);
// Draw bullet cursor after selected tab when showCursor is true
if (showCursor && tab.selected) {
// Draw filled circle using distance-squared check
const int bulletCenterX = currentX + textWidth + cursorPadding + bulletRadius;
const int radiusSq = bulletRadius * bulletRadius;
for (int dy = -bulletRadius; dy <= bulletRadius; ++dy) {
for (int dx = -bulletRadius; dx <= bulletRadius; ++dx) {
if (dx * dx + dy * dy <= radiusSq) {
renderer.drawPixel(bulletCenterX + dx, bulletCenterY + dy, true);
}
}
}
}
// Draw underline for selected tab
if (tab.selected) {
renderer.fillRect(currentX, y + lineHeight + underlineGap, textWidth, underlineHeight);
}
}
currentX += textWidth + tabPadding;
}
// Draw overflow indicators if content extends beyond visible area
if (totalWidth > availableWidth) {
constexpr int triangleHeight = 12; // Height of the triangle (vertical)
constexpr int triangleWidth = 6; // Width of the triangle (horizontal) - thin/elongated
const int triangleCenterY = y + lineHeight / 2;
// Left overflow indicator (more content to the left) - thin triangle pointing left
if (scrollOffset > 0) {
// Clear background behind indicator to hide any overlapping text
renderer.fillRect(bezelLeft, y - 2, overflowIndicatorWidth, lineHeight + 4, false);
// Draw left-pointing triangle: point on left, base on right
const int tipX = bezelLeft + 2;
for (int i = 0; i < triangleWidth; ++i) {
// Scale height based on position (0 at tip, full height at base)
const int lineHalfHeight = (triangleHeight * i) / (triangleWidth * 2);
renderer.drawLine(tipX + i, triangleCenterY - lineHalfHeight,
tipX + i, triangleCenterY + lineHalfHeight);
}
}
// Right overflow indicator (more content to the right) - thin triangle pointing right
if (scrollOffset < totalWidth - availableWidth) {
// Clear background behind indicator to hide any overlapping text
renderer.fillRect(screenWidth - bezelRight - overflowIndicatorWidth, y - 2, overflowIndicatorWidth, lineHeight + 4, false);
// Draw right-pointing triangle: base on left, point on right
const int baseX = screenWidth - bezelRight - 2 - triangleWidth;
for (int i = 0; i < triangleWidth; ++i) {
// Scale height based on position (full height at base, 0 at tip)
const int lineHalfHeight = (triangleHeight * (triangleWidth - 1 - i)) / (triangleWidth * 2);
renderer.drawLine(baseX + i, triangleCenterY - lineHalfHeight,
baseX + i, triangleCenterY + lineHalfHeight);
}
}
}
return tabBarHeight;
}

View File

@ -23,7 +23,9 @@ class ScreenComponents {
// Draw a horizontal tab bar with underline indicator for selected tab
// Returns the height of the tab bar (for positioning content below)
static int drawTabBar(const GfxRenderer& renderer, int y, const std::vector<TabInfo>& tabs);
// When selectedIndex is provided, tabs scroll so the selected tab is visible
// When showCursor is true, bullet indicators are drawn around the selected tab
static int drawTabBar(const GfxRenderer& renderer, int y, const std::vector<TabInfo>& tabs, int selectedIndex = -1, bool showCursor = false);
// Draw a scroll/page indicator on the right side of the screen
// Shows up/down arrows and current page fraction (e.g., "1/3")

View File

@ -0,0 +1,262 @@
#include "BookmarkListActivity.h"
#include <GfxRenderer.h>
#include "BookmarkStore.h"
#include "MappedInputManager.h"
#include "ScreenComponents.h"
#include "fontIds.h"
namespace {
constexpr int BASE_TAB_BAR_Y = 15;
constexpr int BASE_CONTENT_START_Y = 60;
constexpr int LINE_HEIGHT = 50; // Taller for bookmark name + location
constexpr int BASE_LEFT_MARGIN = 20;
constexpr int BASE_RIGHT_MARGIN = 40;
constexpr unsigned long ACTION_MENU_MS = 700; // Long press to delete
} // namespace
int BookmarkListActivity::getPageItems() const {
const int screenHeight = renderer.getScreenHeight();
const int bottomBarHeight = 60;
const int bezelTop = renderer.getBezelOffsetTop();
const int bezelBottom = renderer.getBezelOffsetBottom();
const int availableHeight = screenHeight - (BASE_CONTENT_START_Y + bezelTop) - bottomBarHeight - bezelBottom;
int items = availableHeight / LINE_HEIGHT;
if (items < 1) items = 1;
return items;
}
int BookmarkListActivity::getTotalPages() const {
const int itemCount = static_cast<int>(bookmarks.size());
const int pageItems = getPageItems();
if (itemCount == 0) return 1;
return (itemCount + pageItems - 1) / pageItems;
}
int BookmarkListActivity::getCurrentPage() const {
const int pageItems = getPageItems();
return selectorIndex / pageItems + 1;
}
void BookmarkListActivity::loadBookmarks() {
bookmarks = BookmarkStore::getBookmarks(bookPath);
}
void BookmarkListActivity::taskTrampoline(void* param) {
auto* self = static_cast<BookmarkListActivity*>(param);
self->displayTaskLoop();
}
void BookmarkListActivity::onEnter() {
Activity::onEnter();
renderingMutex = xSemaphoreCreateMutex();
loadBookmarks();
selectorIndex = 0;
updateRequired = true;
xTaskCreate(&BookmarkListActivity::taskTrampoline, "BookmarkListTask",
2048, // Stack size
this, // Parameters
1, // Priority
&displayTaskHandle // Task handle
);
}
void BookmarkListActivity::onExit() {
Activity::onExit();
xSemaphoreTake(renderingMutex, portMAX_DELAY);
if (displayTaskHandle) {
vTaskDelete(displayTaskHandle);
displayTaskHandle = nullptr;
vTaskDelay(10 / portTICK_PERIOD_MS);
}
vSemaphoreDelete(renderingMutex);
renderingMutex = nullptr;
bookmarks.clear();
}
void BookmarkListActivity::loop() {
// Handle confirmation state
if (uiState == UIState::Confirming) {
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
uiState = UIState::Normal;
updateRequired = true;
return;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
// Delete the bookmark
if (!bookmarks.empty() && selectorIndex < static_cast<int>(bookmarks.size())) {
const auto& bm = bookmarks[selectorIndex];
BookmarkStore::removeBookmark(bookPath, bm.spineIndex, bm.contentOffset);
loadBookmarks();
// Adjust selector if needed
if (selectorIndex >= static_cast<int>(bookmarks.size()) && !bookmarks.empty()) {
selectorIndex = static_cast<int>(bookmarks.size()) - 1;
} else if (bookmarks.empty()) {
selectorIndex = 0;
}
}
uiState = UIState::Normal;
updateRequired = true;
return;
}
return;
}
// Normal state handling
const int itemCount = static_cast<int>(bookmarks.size());
const int pageItems = getPageItems();
// Long press Confirm to delete bookmark
if (mappedInput.isPressed(MappedInputManager::Button::Confirm) &&
mappedInput.getHeldTime() >= ACTION_MENU_MS && !bookmarks.empty() &&
selectorIndex < itemCount) {
uiState = UIState::Confirming;
updateRequired = true;
return;
}
// Short press Confirm - navigate to bookmark
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (mappedInput.getHeldTime() >= ACTION_MENU_MS) {
return; // Was a long press
}
if (!bookmarks.empty() && selectorIndex < itemCount) {
const auto& bm = bookmarks[selectorIndex];
onSelectBookmark(bm.spineIndex, bm.contentOffset);
}
return;
}
// Back button
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
onGoBack();
return;
}
// Navigation
const bool upReleased = mappedInput.wasReleased(MappedInputManager::Button::Up);
const bool downReleased = mappedInput.wasReleased(MappedInputManager::Button::Down);
if (upReleased && itemCount > 0) {
selectorIndex = (selectorIndex + itemCount - 1) % itemCount;
updateRequired = true;
} else if (downReleased && itemCount > 0) {
selectorIndex = (selectorIndex + 1) % itemCount;
updateRequired = true;
}
}
void BookmarkListActivity::displayTaskLoop() {
while (true) {
if (updateRequired) {
updateRequired = false;
xSemaphoreTake(renderingMutex, portMAX_DELAY);
render();
xSemaphoreGive(renderingMutex);
}
vTaskDelay(10 / portTICK_PERIOD_MS);
}
}
void BookmarkListActivity::render() const {
renderer.clearScreen();
if (uiState == UIState::Confirming) {
renderConfirmation();
renderer.displayBuffer();
return;
}
const auto pageWidth = renderer.getScreenWidth();
const int pageItems = getPageItems();
const int itemCount = static_cast<int>(bookmarks.size());
// Calculate bezel-adjusted margins
const int bezelTop = renderer.getBezelOffsetTop();
const int bezelLeft = renderer.getBezelOffsetLeft();
const int bezelRight = renderer.getBezelOffsetRight();
const int bezelBottom = renderer.getBezelOffsetBottom();
const int CONTENT_START_Y = BASE_CONTENT_START_Y + bezelTop;
const int LEFT_MARGIN = BASE_LEFT_MARGIN + bezelLeft;
const int RIGHT_MARGIN = BASE_RIGHT_MARGIN + bezelRight;
// Draw title
auto truncatedTitle = renderer.truncatedText(UI_12_FONT_ID, bookTitle.c_str(), pageWidth - LEFT_MARGIN - RIGHT_MARGIN);
renderer.drawText(UI_12_FONT_ID, LEFT_MARGIN, BASE_TAB_BAR_Y + bezelTop, truncatedTitle.c_str(), true, EpdFontFamily::BOLD);
if (itemCount == 0) {
renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, CONTENT_START_Y, "No bookmarks");
const auto labels = mappedInput.mapLabels("\xc2\xab Back", "", "", "");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer();
return;
}
const auto pageStartIndex = selectorIndex / pageItems * pageItems;
// Draw selection highlight
renderer.fillRect(bezelLeft, CONTENT_START_Y + (selectorIndex % pageItems) * LINE_HEIGHT - 2,
pageWidth - RIGHT_MARGIN - bezelLeft, LINE_HEIGHT);
// Draw items
for (int i = pageStartIndex; i < itemCount && i < pageStartIndex + pageItems; i++) {
const auto& bm = bookmarks[i];
const int y = CONTENT_START_Y + (i % pageItems) * LINE_HEIGHT;
const bool isSelected = (i == selectorIndex);
// Line 1: Bookmark name
auto truncatedName = renderer.truncatedText(UI_10_FONT_ID, bm.name.c_str(), pageWidth - LEFT_MARGIN - RIGHT_MARGIN);
renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, y + 2, truncatedName.c_str(), !isSelected);
// Line 2: Location info
std::string locText = "Page " + std::to_string(bm.pageNumber + 1);
renderer.drawText(SMALL_FONT_ID, LEFT_MARGIN, y + 26, locText.c_str(), !isSelected);
}
// Draw scroll indicator
const int screenHeight = renderer.getScreenHeight();
const int contentHeight = screenHeight - CONTENT_START_Y - 60 - bezelBottom;
ScreenComponents::drawScrollIndicator(renderer, getCurrentPage(), getTotalPages(), CONTENT_START_Y, contentHeight);
// Draw side button hints
renderer.drawSideButtonHints(UI_10_FONT_ID, ">", "<");
// Draw bottom button hints
const auto labels = mappedInput.mapLabels("\xc2\xab Back", "Go to", "", "");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer();
}
void BookmarkListActivity::renderConfirmation() const {
const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = renderer.getScreenHeight();
// Title
renderer.drawCenteredText(UI_12_FONT_ID, 20, "Delete Bookmark?", true, EpdFontFamily::BOLD);
// Show bookmark name
if (!bookmarks.empty() && selectorIndex < static_cast<int>(bookmarks.size())) {
const auto& bm = bookmarks[selectorIndex];
auto truncatedName = renderer.truncatedText(UI_10_FONT_ID, bm.name.c_str(), pageWidth - 40);
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 20, truncatedName.c_str());
}
// Warning text
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 20, "This cannot be undone.");
// Draw bottom button hints
const auto labels = mappedInput.mapLabels("\xc2\xab Cancel", "Delete", "", "");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
}

View File

@ -0,0 +1,65 @@
#pragma once
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <freertos/task.h>
#include <functional>
#include <string>
#include <vector>
#include "../Activity.h"
#include "BookmarkStore.h"
/**
* BookmarkListActivity displays all bookmarks for a specific book.
* - Short press: Navigate to bookmark location
* - Long press Confirm: Delete bookmark (with confirmation)
* - Back: Return to previous screen
*/
class BookmarkListActivity final : public Activity {
public:
enum class UIState { Normal, Confirming };
private:
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
std::string bookPath;
std::string bookTitle;
std::vector<Bookmark> bookmarks;
int selectorIndex = 0;
bool updateRequired = false;
UIState uiState = UIState::Normal;
// Callbacks
const std::function<void()> onGoBack;
const std::function<void(uint16_t spineIndex, uint32_t contentOffset)> onSelectBookmark;
// Number of items that fit on a page
int getPageItems() const;
int getTotalPages() const;
int getCurrentPage() const;
// Data loading
void loadBookmarks();
// Rendering
static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop();
void render() const;
void renderConfirmation() const;
public:
explicit BookmarkListActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const std::string& bookPath, const std::string& bookTitle,
const std::function<void()>& onGoBack,
const std::function<void(uint16_t spineIndex, uint32_t contentOffset)>& onSelectBookmark)
: Activity("BookmarkList", renderer, mappedInput),
bookPath(bookPath),
bookTitle(bookTitle),
onGoBack(onGoBack),
onSelectBookmark(onSelectBookmark) {}
void onEnter() override;
void onExit() override;
void loop() override;
};

File diff suppressed because it is too large Load Diff

View File

@ -19,9 +19,25 @@ struct ThumbExistsCache {
bool exists = false; // Whether thumbnail exists
};
// Search result for the Search tab
struct SearchResult {
std::string path;
std::string title;
std::string author;
int matchScore = 0; // Higher = better match
};
// Book with bookmarks info for the Bookmarks tab
struct BookmarkedBook {
std::string path;
std::string title;
std::string author;
int bookmarkCount = 0;
};
class MyLibraryActivity final : public Activity {
public:
enum class Tab { Recent, Lists, Files };
enum class Tab { Recent, Lists, Bookmarks, Search, Files };
enum class UIState { Normal, ActionMenu, Confirming, ListActionMenu, ListConfirmingDelete, ClearAllRecentsConfirming };
enum class ActionType { Archive, Delete, RemoveFromRecents, ClearAllRecents };
@ -32,6 +48,7 @@ class MyLibraryActivity final : public Activity {
Tab currentTab = Tab::Recent;
int selectorIndex = 0;
bool updateRequired = false;
bool inTabBar = false; // true = focus on tab bar for switching tabs (all tabs)
// Action menu state
UIState uiState = UIState::Normal;
@ -61,6 +78,17 @@ class MyLibraryActivity final : public Activity {
int listMenuSelection = 0; // 0 = Pin/Unpin, 1 = Delete
std::string listActionTargetName;
// Bookmarks tab state
std::vector<BookmarkedBook> bookmarkedBooks;
// Search tab state
std::string searchQuery;
std::vector<SearchResult> searchResults;
std::vector<SearchResult> allBooks; // Cached index of all books
std::vector<char> searchCharacters; // Dynamic character set from library
int searchCharIndex = 0; // Current position in character picker
bool searchInResults = false; // true = navigating results, false = in character picker
// Files tab state (from FileSelectionActivity)
std::string basepath = "/";
std::vector<std::string> files;
@ -69,6 +97,7 @@ class MyLibraryActivity final : public Activity {
const std::function<void()> onGoHome;
const std::function<void(const std::string& path, Tab fromTab)> onSelectBook;
const std::function<void(const std::string& listName)> onSelectList;
const std::function<void(const std::string& path, const std::string& title)> onSelectBookmarkedBook;
// Number of items that fit on a page
int getPageItems() const;
@ -79,6 +108,9 @@ class MyLibraryActivity final : public Activity {
// Data loading
void loadRecentBooks();
void loadLists();
void loadBookmarkedBooks();
void loadAllBooks();
void updateSearchResults();
void loadFiles();
size_t findEntry(const std::string& name) const;
@ -88,10 +120,16 @@ class MyLibraryActivity final : public Activity {
void render() const;
void renderRecentTab() const;
void renderListsTab() const;
void renderBookmarksTab() const;
void renderSearchTab() const;
void renderFilesTab() const;
void renderActionMenu() const;
void renderConfirmation() const;
// Search character picker helpers
void buildSearchCharacters();
void renderCharacterPicker(int y) const;
// Action handling
void openActionMenu();
void executeAction();
@ -114,13 +152,15 @@ class MyLibraryActivity final : public Activity {
const std::function<void()>& onGoHome,
const std::function<void(const std::string& path, Tab fromTab)>& onSelectBook,
const std::function<void(const std::string& listName)>& onSelectList,
const std::function<void(const std::string& path, const std::string& title)>& onSelectBookmarkedBook = nullptr,
Tab initialTab = Tab::Recent, std::string initialPath = "/")
: Activity("MyLibrary", renderer, mappedInput),
currentTab(initialTab),
basepath(initialPath.empty() ? "/" : std::move(initialPath)),
onGoHome(onGoHome),
onSelectBook(onSelectBook),
onSelectList(onSelectList) {}
onSelectList(onSelectList),
onSelectBookmarkedBook(onSelectBookmarkedBook) {}
void onEnter() override;
void onExit() override;
void loop() override;

View File

@ -8,6 +8,7 @@
#include <Serialization.h>
#include "BookManager.h"
#include "BookmarkStore.h"
#include "CrossPointSettings.h"
#include "CrossPointState.h"
#include "EpubReaderChapterSelectionActivity.h"
@ -17,6 +18,7 @@
#include "activities/dictionary/DictionaryMenuActivity.h"
#include "activities/dictionary/DictionarySearchActivity.h"
#include "activities/dictionary/EpubWordSelectionActivity.h"
#include "activities/util/QuickMenuActivity.h"
#include "fontIds.h"
namespace {
@ -366,6 +368,149 @@ void EpubReaderActivity::loop() {
return;
}
// Quick Menu power button press
if (SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::QUICK_MENU &&
mappedInput.wasReleased(MappedInputManager::Button::Power)) {
xSemaphoreTake(renderingMutex, portMAX_DELAY);
// Check if current page is bookmarked
bool isBookmarked = false;
if (section) {
const uint32_t contentOffset = section->getContentOffsetForPage(section->currentPage);
isBookmarked = BookmarkStore::isPageBookmarked(epub->getPath(), currentSpineIndex, contentOffset);
}
exitActivity();
enterNewActivity(new QuickMenuActivity(
renderer, mappedInput,
[this](QuickMenuAction action) {
// Cache values before exitActivity
EpubReaderActivity* self = this;
GfxRenderer& cachedRenderer = renderer;
MappedInputManager& cachedMappedInput = mappedInput;
Section* cachedSection = section.get();
SemaphoreHandle_t cachedMutex = renderingMutex;
exitActivity();
if (action == QuickMenuAction::DICTIONARY) {
// Open dictionary menu
self->enterNewActivity(new DictionaryMenuActivity(
cachedRenderer, cachedMappedInput,
[self](DictionaryMode mode) {
GfxRenderer& r = self->renderer;
MappedInputManager& m = self->mappedInput;
Section* s = self->section.get();
SemaphoreHandle_t mtx = self->renderingMutex;
self->exitActivity();
if (mode == DictionaryMode::ENTER_WORD) {
self->enterNewActivity(new DictionarySearchActivity(r, m,
[self]() {
self->exitActivity();
self->updateRequired = true;
}, ""));
} else if (s) {
xSemaphoreTake(mtx, portMAX_DELAY);
auto page = s->loadPageFromSectionFile();
if (page) {
int mt, mr, mb, ml;
r.getOrientedViewableTRBL(&mt, &mr, &mb, &ml);
mt += SETTINGS.screenMargin;
ml += SETTINGS.screenMargin;
const int fontId = SETTINGS.getReaderFontId();
self->enterNewActivity(new EpubWordSelectionActivity(
r, m, std::move(page), fontId, ml, mt,
[self](const std::string& word) {
self->exitActivity();
self->enterNewActivity(new DictionarySearchActivity(
self->renderer, self->mappedInput,
[self]() {
self->exitActivity();
self->updateRequired = true;
}, word));
},
[self]() {
self->exitActivity();
self->updateRequired = true;
}));
xSemaphoreGive(mtx);
} else {
xSemaphoreGive(mtx);
self->updateRequired = true;
}
} else {
self->updateRequired = true;
}
},
[self]() {
self->exitActivity();
self->updateRequired = true;
},
self->section != nullptr));
} else if (action == QuickMenuAction::ADD_BOOKMARK) {
// Toggle bookmark on current page
if (self->section) {
const uint32_t contentOffset = self->section->getContentOffsetForPage(self->section->currentPage);
const std::string& bookPath = self->epub->getPath();
if (BookmarkStore::isPageBookmarked(bookPath, self->currentSpineIndex, contentOffset)) {
// Remove bookmark
BookmarkStore::removeBookmark(bookPath, self->currentSpineIndex, contentOffset);
} else {
// Add bookmark with auto-generated name
Bookmark bm;
bm.spineIndex = self->currentSpineIndex;
bm.contentOffset = contentOffset;
bm.pageNumber = self->section->currentPage;
bm.timestamp = millis() / 1000; // Approximate timestamp
// Generate name: "Chapter - Page X" or fallback
std::string chapterTitle;
const int tocIndex = self->epub->getTocIndexForSpineIndex(self->currentSpineIndex);
if (tocIndex >= 0) {
chapterTitle = self->epub->getTocItem(tocIndex).title;
}
if (!chapterTitle.empty()) {
bm.name = chapterTitle + " - Page " + std::to_string(self->section->currentPage + 1);
} else {
bm.name = "Page " + std::to_string(self->section->currentPage + 1);
}
BookmarkStore::addBookmark(bookPath, bm);
}
}
self->updateRequired = true;
} else if (action == QuickMenuAction::CLEAR_CACHE) {
// Navigate to Clear Cache activity
if (self->onGoToClearCache) {
xSemaphoreGive(cachedMutex);
self->onGoToClearCache();
return;
}
self->updateRequired = true;
} else if (action == QuickMenuAction::GO_TO_SETTINGS) {
// Navigate to Settings activity
if (self->onGoToSettings) {
xSemaphoreGive(cachedMutex);
self->onGoToSettings();
return;
}
self->updateRequired = true;
}
},
[this]() {
EpubReaderActivity* self = this;
exitActivity();
self->updateRequired = true;
},
isBookmarked));
xSemaphoreGive(renderingMutex);
return;
}
const bool prevReleased = mappedInput.wasReleased(MappedInputManager::Button::PageBack) ||
mappedInput.wasReleased(MappedInputManager::Button::Left);
const bool nextReleased = mappedInput.wasReleased(MappedInputManager::Button::PageForward) ||
@ -632,6 +777,24 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
const int orientedMarginRight, const int orientedMarginBottom,
const int orientedMarginLeft) {
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
// Draw bookmark indicator (folded corner) if this page is bookmarked
if (section) {
const uint32_t contentOffset = section->getContentOffsetForPage(section->currentPage);
if (BookmarkStore::isPageBookmarked(epub->getPath(), currentSpineIndex, contentOffset)) {
// Draw folded corner in top-right
const int screenWidth = renderer.getScreenWidth();
constexpr int cornerSize = 20;
const int cornerX = screenWidth - orientedMarginRight - cornerSize;
const int cornerY = orientedMarginTop;
// Draw triangle (folded corner effect)
const int xPoints[3] = {cornerX, cornerX + cornerSize, cornerX + cornerSize};
const int yPoints[3] = {cornerY, cornerY, cornerY + cornerSize};
renderer.fillPolygon(xPoints, yPoints, 3, true); // Black triangle
}
}
renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
if (pagesUntilFullRefresh <= 1) {
renderer.displayBuffer(EInkDisplay::HALF_REFRESH);

View File

@ -18,6 +18,8 @@ class EpubReaderActivity final : public ActivityWithSubactivity {
bool updateRequired = false;
const std::function<void()> onGoBack;
const std::function<void()> onGoHome;
const std::function<void()> onGoToClearCache;
const std::function<void()> onGoToSettings;
// End-of-book prompt state
bool showingEndOfBookPrompt = false;
@ -38,11 +40,15 @@ class EpubReaderActivity final : public ActivityWithSubactivity {
public:
explicit EpubReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::unique_ptr<Epub> epub,
const std::function<void()>& onGoBack, const std::function<void()>& onGoHome)
const std::function<void()>& onGoBack, const std::function<void()>& onGoHome,
const std::function<void()>& onGoToClearCache = nullptr,
const std::function<void()>& onGoToSettings = nullptr)
: ActivityWithSubactivity("EpubReader", renderer, mappedInput),
epub(std::move(epub)),
onGoBack(onGoBack),
onGoHome(onGoHome) {}
onGoHome(onGoHome),
onGoToClearCache(onGoToClearCache),
onGoToSettings(onGoToSettings) {}
void onEnter() override;
void onExit() override;
void loop() override;

View File

@ -62,7 +62,11 @@ void ReaderActivity::onGoToEpubReader(std::unique_ptr<Epub> epub) {
currentBookPath = epubPath;
exitActivity();
enterNewActivity(new EpubReaderActivity(
renderer, mappedInput, std::move(epub), [this, epubPath] { goToLibrary(epubPath); }, [this] { onGoBack(); }));
renderer, mappedInput, std::move(epub),
[this, epubPath] { goToLibrary(epubPath); },
[this] { onGoBack(); },
onGoToClearCache,
onGoToSettings));
}
void ReaderActivity::onGoToTxtReader(std::unique_ptr<Txt> txt) {

View File

@ -13,6 +13,8 @@ class ReaderActivity final : public ActivityWithSubactivity {
MyLibraryActivity::Tab libraryTab; // Track which tab to return to
const std::function<void()> onGoBack;
const std::function<void(const std::string&, MyLibraryActivity::Tab)> onGoToLibrary;
const std::function<void()> onGoToClearCache;
const std::function<void()> onGoToSettings;
static std::unique_ptr<Epub> loadEpub(const std::string& path);
static std::unique_ptr<Txt> loadTxt(const std::string& path);
static bool isTxtFile(const std::string& path);
@ -25,11 +27,15 @@ class ReaderActivity final : public ActivityWithSubactivity {
public:
explicit ReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string initialBookPath,
MyLibraryActivity::Tab libraryTab, const std::function<void()>& onGoBack,
const std::function<void(const std::string&, MyLibraryActivity::Tab)>& onGoToLibrary)
const std::function<void(const std::string&, MyLibraryActivity::Tab)>& onGoToLibrary,
const std::function<void()>& onGoToClearCache = nullptr,
const std::function<void()>& onGoToSettings = nullptr)
: ActivityWithSubactivity("Reader", renderer, mappedInput),
initialBookPath(std::move(initialBookPath)),
libraryTab(libraryTab),
onGoBack(onGoBack),
onGoToLibrary(onGoToLibrary) {}
onGoToLibrary(onGoToLibrary),
onGoToClearCache(onGoToClearCache),
onGoToSettings(onGoToSettings) {}
void onEnter() override;
};

View File

@ -20,6 +20,8 @@ class TxtReaderActivity final : public ActivityWithSubactivity {
bool updateRequired = false;
const std::function<void()> onGoBack;
const std::function<void()> onGoHome;
const std::function<void()> onGoToClearCache;
const std::function<void()> onGoToSettings;
// End-of-book prompt state
bool showingEndOfBookPrompt = false;
@ -56,11 +58,15 @@ class TxtReaderActivity final : public ActivityWithSubactivity {
public:
explicit TxtReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::unique_ptr<Txt> txt,
const std::function<void()>& onGoBack, const std::function<void()>& onGoHome)
const std::function<void()>& onGoBack, const std::function<void()>& onGoHome,
const std::function<void()>& onGoToClearCache = nullptr,
const std::function<void()>& onGoToSettings = nullptr)
: ActivityWithSubactivity("TxtReader", renderer, mappedInput),
txt(std::move(txt)),
onGoBack(onGoBack),
onGoHome(onGoHome) {}
onGoHome(onGoHome),
onGoToClearCache(onGoToClearCache),
onGoToSettings(onGoToSettings) {}
void onEnter() override;
void onExit() override;
void loop() override;

View File

@ -84,7 +84,7 @@ const SettingInfo controlsSettings[controlsSettingsCount] = {
{"Prev, Next", "Next, Prev"}),
SettingInfo::Toggle("Long-press Chapter Skip", &CrossPointSettings::longPressChapterSkip),
SettingInfo::Enum("Short Power Button Click", &CrossPointSettings::shortPwrBtn,
{"Ignore", "Sleep", "Page Turn", "Dictionary"})};
{"Ignore", "Sleep", "Page Turn", "Dictionary", "Quick Menu"})};
constexpr int systemSettingsCount = 4;
const SettingInfo systemSettings[systemSettingsCount] = {

View File

@ -0,0 +1,173 @@
#include "QuickMenuActivity.h"
#include <GfxRenderer.h>
#include "MappedInputManager.h"
#include "fontIds.h"
namespace {
constexpr int MENU_ITEM_COUNT = 4;
const char* MENU_ITEMS[MENU_ITEM_COUNT] = {"Dictionary", "Bookmark", "Clear Cache", "Settings"};
const char* MENU_DESCRIPTIONS_ADD[MENU_ITEM_COUNT] = {
"Look up a word",
"Add bookmark to this page",
"Free up storage space",
"Open settings menu"
};
const char* MENU_DESCRIPTIONS_REMOVE[MENU_ITEM_COUNT] = {
"Look up a word",
"Remove bookmark from this page",
"Free up storage space",
"Open settings menu"
};
} // namespace
void QuickMenuActivity::taskTrampoline(void* param) {
auto* self = static_cast<QuickMenuActivity*>(param);
self->displayTaskLoop();
}
void QuickMenuActivity::onEnter() {
Activity::onEnter();
renderingMutex = xSemaphoreCreateMutex();
// Reset selection
selectedIndex = 0;
// Trigger first update
updateRequired = true;
xTaskCreate(&QuickMenuActivity::taskTrampoline, "QuickMenuTask",
2048, // Stack size
this, // Parameters
1, // Priority
&displayTaskHandle // Task handle
);
}
void QuickMenuActivity::onExit() {
Activity::onExit();
// Wait until not rendering to delete task
xSemaphoreTake(renderingMutex, portMAX_DELAY);
if (displayTaskHandle) {
vTaskDelete(displayTaskHandle);
displayTaskHandle = nullptr;
vTaskDelay(10 / portTICK_PERIOD_MS); // Let idle task free stack
}
vSemaphoreDelete(renderingMutex);
renderingMutex = nullptr;
}
void QuickMenuActivity::loop() {
// Handle back button - cancel
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
onCancel();
return;
}
// Handle confirm button - select current option
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
QuickMenuAction action;
switch (selectedIndex) {
case 0:
action = QuickMenuAction::DICTIONARY;
break;
case 1:
action = QuickMenuAction::ADD_BOOKMARK;
break;
case 2:
action = QuickMenuAction::CLEAR_CACHE;
break;
case 3:
default:
action = QuickMenuAction::GO_TO_SETTINGS;
break;
}
onActionSelected(action);
return;
}
// Handle navigation
const bool prevPressed = mappedInput.wasPressed(MappedInputManager::Button::Up) ||
mappedInput.wasPressed(MappedInputManager::Button::Left);
const bool nextPressed = mappedInput.wasPressed(MappedInputManager::Button::Down) ||
mappedInput.wasPressed(MappedInputManager::Button::Right);
if (prevPressed) {
selectedIndex = (selectedIndex + MENU_ITEM_COUNT - 1) % MENU_ITEM_COUNT;
updateRequired = true;
} else if (nextPressed) {
selectedIndex = (selectedIndex + 1) % MENU_ITEM_COUNT;
updateRequired = true;
}
}
void QuickMenuActivity::displayTaskLoop() {
while (true) {
if (updateRequired) {
updateRequired = false;
xSemaphoreTake(renderingMutex, portMAX_DELAY);
render();
xSemaphoreGive(renderingMutex);
}
vTaskDelay(10 / portTICK_PERIOD_MS);
}
}
void QuickMenuActivity::render() const {
renderer.clearScreen();
const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = renderer.getScreenHeight();
// Get bezel offsets
const int bezelTop = renderer.getBezelOffsetTop();
const int bezelLeft = renderer.getBezelOffsetLeft();
const int bezelRight = renderer.getBezelOffsetRight();
const int bezelBottom = renderer.getBezelOffsetBottom();
// Calculate usable content area
const int marginLeft = 20 + bezelLeft;
const int marginRight = 20 + bezelRight;
const int marginTop = 15 + bezelTop;
const int contentWidth = pageWidth - marginLeft - marginRight;
const int contentHeight = pageHeight - marginTop - 60 - bezelBottom; // 60 for button hints
// Draw header
renderer.drawCenteredText(UI_12_FONT_ID, marginTop, "Quick Menu", true, EpdFontFamily::BOLD);
// Select descriptions based on bookmark state
const char* const* descriptions = isPageBookmarked ? MENU_DESCRIPTIONS_REMOVE : MENU_DESCRIPTIONS_ADD;
// Draw menu items centered in content area
constexpr int itemHeight = 50; // Height for each menu item (including description)
const int startY = marginTop + (contentHeight - (MENU_ITEM_COUNT * itemHeight)) / 2;
for (int i = 0; i < MENU_ITEM_COUNT; i++) {
const int itemY = startY + i * itemHeight;
const bool isSelected = (i == selectedIndex);
// Draw selection highlight (black fill) for selected item
if (isSelected) {
renderer.fillRect(marginLeft + 10, itemY - 2, contentWidth - 20, itemHeight - 6);
}
// Draw menu item text
const char* itemText = MENU_ITEMS[i];
// For bookmark item, show different text based on state
if (i == 1) {
itemText = isPageBookmarked ? "Remove Bookmark" : "Add Bookmark";
}
renderer.drawText(UI_10_FONT_ID, marginLeft + 20, itemY, itemText, !isSelected);
renderer.drawText(SMALL_FONT_ID, marginLeft + 20, itemY + 22, descriptions[i], !isSelected);
}
// Draw help text at bottom
const auto labels = mappedInput.mapLabels("\xc2\xab Back", "Select", "", "");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer();
}

View File

@ -0,0 +1,46 @@
#pragma once
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <freertos/task.h>
#include <functional>
#include "../Activity.h"
// Enum for quick menu selection
enum class QuickMenuAction { DICTIONARY, ADD_BOOKMARK, CLEAR_CACHE, GO_TO_SETTINGS };
/**
* QuickMenuActivity presents a quick access menu triggered by short power button press.
* Options:
* - "Dictionary" - Look up a word
* - "Add/Remove Bookmark" - Toggle bookmark on current page
*
* The onActionSelected callback is called with the user's choice.
* The onCancel callback is called if the user presses back.
*/
class QuickMenuActivity final : public Activity {
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
int selectedIndex = 0;
bool updateRequired = false;
const std::function<void(QuickMenuAction)> onActionSelected;
const std::function<void()> onCancel;
const bool isPageBookmarked; // True if current page already has a bookmark
static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop();
void render() const;
public:
explicit QuickMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const std::function<void(QuickMenuAction)>& onActionSelected,
const std::function<void()>& onCancel, bool isPageBookmarked = false)
: Activity("QuickMenu", renderer, mappedInput),
onActionSelected(onActionSelected),
onCancel(onCancel),
isPageBookmarked(isPageBookmarked) {}
void onEnter() override;
void onExit() override;
void loop() override;
};

View File

@ -21,11 +21,13 @@
#include "activities/boot_sleep/BootActivity.h"
#include "activities/boot_sleep/SleepActivity.h"
#include "activities/browser/OpdsBookBrowserActivity.h"
#include "activities/home/BookmarkListActivity.h"
#include "activities/home/HomeActivity.h"
#include "activities/home/ListViewActivity.h"
#include "activities/home/MyLibraryActivity.h"
#include "activities/network/CrossPointWebServerActivity.h"
#include "activities/reader/ReaderActivity.h"
#include "activities/settings/ClearCacheActivity.h"
#include "activities/settings/SettingsActivity.h"
#include "activities/util/FullScreenMessageActivity.h"
#include "fontIds.h"
@ -336,10 +338,13 @@ void enterDeepSleep() {
void onGoHome();
void onGoToMyLibrary();
void onGoToMyLibraryWithTab(const std::string& path, MyLibraryActivity::Tab tab);
void onGoToClearCache();
void onGoToSettings();
void onGoToReader(const std::string& initialEpubPath, MyLibraryActivity::Tab fromTab) {
exitActivity();
enterNewActivity(
new ReaderActivity(renderer, mappedInputManager, initialEpubPath, fromTab, onGoHome, onGoToMyLibraryWithTab));
new ReaderActivity(renderer, mappedInputManager, initialEpubPath, fromTab, onGoHome, onGoToMyLibraryWithTab,
onGoToClearCache, onGoToSettings));
}
void onContinueReading() { onGoToReader(APP_STATE.openEpubPath, MyLibraryActivity::Tab::Recent); }
@ -348,7 +353,7 @@ void onGoToReaderFromList(const std::string& bookPath) {
exitActivity();
// When opening from a list, treat it like opening from Recent (will return to list view via back)
enterNewActivity(new ReaderActivity(renderer, mappedInputManager, bookPath, MyLibraryActivity::Tab::Recent, onGoHome,
onGoToMyLibraryWithTab));
onGoToMyLibraryWithTab, onGoToClearCache, onGoToSettings));
}
// View a specific list
@ -358,6 +363,22 @@ void onGoToListView(const std::string& listName) {
new ListViewActivity(renderer, mappedInputManager, listName, onGoToMyLibrary, onGoToReaderFromList));
}
// View bookmarks for a specific book
void onGoToBookmarkList(const std::string& bookPath, const std::string& bookTitle) {
exitActivity();
enterNewActivity(new BookmarkListActivity(
renderer, mappedInputManager, bookPath, bookTitle,
onGoToMyLibrary, // On back, return to library
[bookPath](uint16_t spineIndex, uint32_t contentOffset) {
// Navigate to bookmark location in the book
// For now, just open the book (TODO: pass bookmark location to reader)
exitActivity();
enterNewActivity(new ReaderActivity(renderer, mappedInputManager, bookPath,
MyLibraryActivity::Tab::Bookmarks, onGoHome, onGoToMyLibraryWithTab,
onGoToClearCache, onGoToSettings));
}));
}
// Go to pinned list (if exists) or Lists tab
void onGoToListsOrPinned() {
exitActivity();
@ -368,7 +389,7 @@ void onGoToListsOrPinned() {
} else {
// Go to Lists tab in My Library
enterNewActivity(new MyLibraryActivity(renderer, mappedInputManager, onGoHome, onGoToReader, onGoToListView,
MyLibraryActivity::Tab::Lists));
onGoToBookmarkList, MyLibraryActivity::Tab::Lists));
}
}
@ -382,14 +403,19 @@ void onGoToSettings() {
enterNewActivity(new SettingsActivity(renderer, mappedInputManager, onGoHome));
}
void onGoToClearCache() {
exitActivity();
enterNewActivity(new ClearCacheActivity(renderer, mappedInputManager, onGoHome));
}
void onGoToMyLibrary() {
exitActivity();
enterNewActivity(new MyLibraryActivity(renderer, mappedInputManager, onGoHome, onGoToReader, onGoToListView));
enterNewActivity(new MyLibraryActivity(renderer, mappedInputManager, onGoHome, onGoToReader, onGoToListView, onGoToBookmarkList));
}
void onGoToMyLibraryWithTab(const std::string& path, MyLibraryActivity::Tab tab) {
exitActivity();
enterNewActivity(new MyLibraryActivity(renderer, mappedInputManager, onGoHome, onGoToReader, onGoToListView, tab, path));
enterNewActivity(new MyLibraryActivity(renderer, mappedInputManager, onGoHome, onGoToReader, onGoToListView, onGoToBookmarkList, tab, path));
}
void onGoToBrowser() {