feat: Add BookmarkStore and BookmarkListActivity for bookmark management
Introduces persistent bookmark storage with JSON-based file format and a dedicated activity for viewing bookmarks organized by book.
This commit is contained in:
parent
8288cd2890
commit
4080184b27
301
src/BookmarkStore.cpp
Normal file
301
src/BookmarkStore.cpp
Normal 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
63
src/BookmarkStore.h
Normal 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);
|
||||||
|
};
|
||||||
262
src/activities/home/BookmarkListActivity.cpp
Normal file
262
src/activities/home/BookmarkListActivity.cpp
Normal 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);
|
||||||
|
}
|
||||||
65
src/activities/home/BookmarkListActivity.h
Normal file
65
src/activities/home/BookmarkListActivity.h
Normal 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;
|
||||||
|
};
|
||||||
Loading…
x
Reference in New Issue
Block a user