feat: Implement bookmark functionality for epub reader
Replace bookmark stubs with full add/remove/navigate implementation: - BookmarkStore: per-book binary persistence on SD card with v2 format supporting text snippets (backward-compatible with v1) - Visual bookmark ribbon indicator drawn on bookmarked pages via fillPolygon - Reader menu dynamically shows Add/Remove Bookmark based on current page state - Bookmark selection activity with chapter name, first sentence snippet, and page number display; long-press to delete with confirmation - Go to Bookmark falls back to Table of Contents when no bookmarks exist - Smart snippet extraction: skips partial sentences (lowercase first word) to capture the first full sentence on the page - Label truncation reserves space for page suffix so it's never cut off - Half refresh forced on menu exit to clear popup/menu artifacts Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
#include "CrossPointSettings.h"
|
#include "CrossPointSettings.h"
|
||||||
#include "CrossPointState.h"
|
#include "CrossPointState.h"
|
||||||
|
#include "EpubReaderBookmarkSelectionActivity.h"
|
||||||
#include "EpubReaderChapterSelectionActivity.h"
|
#include "EpubReaderChapterSelectionActivity.h"
|
||||||
#include "EpubReaderPercentSelectionActivity.h"
|
#include "EpubReaderPercentSelectionActivity.h"
|
||||||
#include "KOReaderCredentialStore.h"
|
#include "KOReaderCredentialStore.h"
|
||||||
@@ -15,6 +16,7 @@
|
|||||||
#include "RecentBooksStore.h"
|
#include "RecentBooksStore.h"
|
||||||
#include "components/UITheme.h"
|
#include "components/UITheme.h"
|
||||||
#include "fontIds.h"
|
#include "fontIds.h"
|
||||||
|
#include "util/BookmarkStore.h"
|
||||||
#include "util/Dictionary.h"
|
#include "util/Dictionary.h"
|
||||||
#include "util/LookupHistory.h"
|
#include "util/LookupHistory.h"
|
||||||
|
|
||||||
@@ -235,10 +237,13 @@ void EpubReaderActivity::loop() {
|
|||||||
}
|
}
|
||||||
const int bookProgressPercent = clampPercent(static_cast<int>(bookProgress + 0.5f));
|
const int bookProgressPercent = clampPercent(static_cast<int>(bookProgress + 0.5f));
|
||||||
const bool hasDictionary = Dictionary::exists();
|
const bool hasDictionary = Dictionary::exists();
|
||||||
|
const bool isBookmarked = BookmarkStore::hasBookmark(
|
||||||
|
epub->getCachePath(), currentSpineIndex, section ? section->currentPage : 0);
|
||||||
exitActivity();
|
exitActivity();
|
||||||
enterNewActivity(new EpubReaderMenuActivity(
|
enterNewActivity(new EpubReaderMenuActivity(
|
||||||
this->renderer, this->mappedInput, epub->getTitle(), currentPage, totalPages, bookProgressPercent,
|
this->renderer, this->mappedInput, epub->getTitle(), currentPage, totalPages, bookProgressPercent,
|
||||||
SETTINGS.orientation, hasDictionary, [this](const uint8_t orientation) { onReaderMenuBack(orientation); },
|
SETTINGS.orientation, hasDictionary, isBookmarked,
|
||||||
|
[this](const uint8_t orientation) { onReaderMenuBack(orientation); },
|
||||||
[this](EpubReaderMenuActivity::MenuAction action) { onReaderMenuConfirm(action); }));
|
[this](EpubReaderMenuActivity::MenuAction action) { onReaderMenuConfirm(action); }));
|
||||||
xSemaphoreGive(renderingMutex);
|
xSemaphoreGive(renderingMutex);
|
||||||
}
|
}
|
||||||
@@ -332,6 +337,8 @@ void EpubReaderActivity::onReaderMenuBack(const uint8_t orientation) {
|
|||||||
// Apply the user-selected orientation when the menu is dismissed.
|
// Apply the user-selected orientation when the menu is dismissed.
|
||||||
// This ensures the menu can be navigated without immediately rotating the screen.
|
// This ensures the menu can be navigated without immediately rotating the screen.
|
||||||
applyOrientation(orientation);
|
applyOrientation(orientation);
|
||||||
|
// Force a half refresh on the next render to clear menu/popup artifacts
|
||||||
|
pagesUntilFullRefresh = 1;
|
||||||
updateRequired = true;
|
updateRequired = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -400,21 +407,151 @@ void EpubReaderActivity::jumpToPercent(int percent) {
|
|||||||
void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action) {
|
void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action) {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case EpubReaderMenuActivity::MenuAction::ADD_BOOKMARK: {
|
case EpubReaderMenuActivity::MenuAction::ADD_BOOKMARK: {
|
||||||
// Stub — bookmark feature coming soon
|
const int page = section ? section->currentPage : 0;
|
||||||
|
|
||||||
|
// Extract first full sentence from the current page for the bookmark snippet.
|
||||||
|
// If the first word is lowercase, the page starts mid-sentence — skip to the
|
||||||
|
// next sentence boundary and start collecting from there.
|
||||||
|
std::string snippet;
|
||||||
|
if (section) {
|
||||||
|
auto p = section->loadPageFromSectionFile();
|
||||||
|
if (p) {
|
||||||
|
// Gather all words on the page into a flat list for easier traversal
|
||||||
|
std::vector<std::string> allWords;
|
||||||
|
for (const auto& element : p->elements) {
|
||||||
|
const auto* line = static_cast<const PageLine*>(element.get());
|
||||||
|
if (!line) continue;
|
||||||
|
const auto& block = line->getBlock();
|
||||||
|
if (!block) continue;
|
||||||
|
for (const auto& word : block->getWords()) {
|
||||||
|
allWords.push_back(word);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!allWords.empty()) {
|
||||||
|
size_t startIdx = 0;
|
||||||
|
|
||||||
|
// Check if the first word starts with a lowercase letter (mid-sentence)
|
||||||
|
const char firstChar = allWords[0].empty() ? '\0' : allWords[0][0];
|
||||||
|
if (firstChar >= 'a' && firstChar <= 'z') {
|
||||||
|
// Skip past the end of this partial sentence
|
||||||
|
for (size_t i = 0; i < allWords.size(); i++) {
|
||||||
|
if (!allWords[i].empty()) {
|
||||||
|
char last = allWords[i].back();
|
||||||
|
if (last == '.' || last == '!' || last == '?' || last == ':') {
|
||||||
|
startIdx = i + 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If no sentence boundary found, fall back to using everything from the start
|
||||||
|
if (startIdx >= allWords.size()) {
|
||||||
|
startIdx = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect words from startIdx until the next sentence boundary
|
||||||
|
for (size_t i = startIdx; i < allWords.size(); i++) {
|
||||||
|
if (!snippet.empty()) snippet += " ";
|
||||||
|
snippet += allWords[i];
|
||||||
|
if (!allWords[i].empty()) {
|
||||||
|
char last = allWords[i].back();
|
||||||
|
if (last == '.' || last == '!' || last == '?' || last == ':') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BookmarkStore::addBookmark(epub->getCachePath(), currentSpineIndex, page, snippet);
|
||||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||||
GUI.drawPopup(renderer, "Coming soon");
|
GUI.drawPopup(renderer, "Bookmark added");
|
||||||
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||||
xSemaphoreGive(renderingMutex);
|
xSemaphoreGive(renderingMutex);
|
||||||
vTaskDelay(1500 / portTICK_PERIOD_MS);
|
vTaskDelay(750 / portTICK_PERIOD_MS);
|
||||||
|
// Exit the menu and return to reading — the bookmark indicator will show on re-render,
|
||||||
|
// and next menu open will reflect the updated state.
|
||||||
|
exitActivity();
|
||||||
|
pagesUntilFullRefresh = 1;
|
||||||
|
updateRequired = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case EpubReaderMenuActivity::MenuAction::REMOVE_BOOKMARK: {
|
||||||
|
const int page = section ? section->currentPage : 0;
|
||||||
|
BookmarkStore::removeBookmark(epub->getCachePath(), currentSpineIndex, page);
|
||||||
|
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||||
|
GUI.drawPopup(renderer, "Bookmark removed");
|
||||||
|
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||||
|
xSemaphoreGive(renderingMutex);
|
||||||
|
vTaskDelay(750 / portTICK_PERIOD_MS);
|
||||||
|
exitActivity();
|
||||||
|
pagesUntilFullRefresh = 1;
|
||||||
|
updateRequired = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case EpubReaderMenuActivity::MenuAction::GO_TO_BOOKMARK: {
|
case EpubReaderMenuActivity::MenuAction::GO_TO_BOOKMARK: {
|
||||||
// Stub — bookmark feature coming soon
|
auto bookmarks = BookmarkStore::load(epub->getCachePath());
|
||||||
|
|
||||||
|
if (bookmarks.empty()) {
|
||||||
|
// No bookmarks: fall back to Table of Contents if available, otherwise go back
|
||||||
|
if (epub->getTocItemsCount() > 0) {
|
||||||
|
const int currentP = section ? section->currentPage : 0;
|
||||||
|
const int totalP = section ? section->pageCount : 0;
|
||||||
|
const int spineIdx = currentSpineIndex;
|
||||||
|
const std::string path = epub->getPath();
|
||||||
|
|
||||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||||
GUI.drawPopup(renderer, "Coming soon");
|
exitActivity();
|
||||||
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
enterNewActivity(new EpubReaderChapterSelectionActivity(
|
||||||
|
this->renderer, this->mappedInput, epub, path, spineIdx, currentP, totalP,
|
||||||
|
[this] {
|
||||||
|
exitActivity();
|
||||||
|
updateRequired = true;
|
||||||
|
},
|
||||||
|
[this](const int newSpineIndex) {
|
||||||
|
if (currentSpineIndex != newSpineIndex) {
|
||||||
|
currentSpineIndex = newSpineIndex;
|
||||||
|
nextPageNumber = 0;
|
||||||
|
section.reset();
|
||||||
|
}
|
||||||
|
exitActivity();
|
||||||
|
updateRequired = true;
|
||||||
|
},
|
||||||
|
[this](const int newSpineIndex, const int newPage) {
|
||||||
|
if (currentSpineIndex != newSpineIndex || (section && section->currentPage != newPage)) {
|
||||||
|
currentSpineIndex = newSpineIndex;
|
||||||
|
nextPageNumber = newPage;
|
||||||
|
section.reset();
|
||||||
|
}
|
||||||
|
exitActivity();
|
||||||
|
updateRequired = true;
|
||||||
|
}));
|
||||||
|
xSemaphoreGive(renderingMutex);
|
||||||
|
}
|
||||||
|
// If no TOC either, just return to reader (menu already closed by callback)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||||
|
exitActivity();
|
||||||
|
enterNewActivity(new EpubReaderBookmarkSelectionActivity(
|
||||||
|
this->renderer, this->mappedInput, epub, std::move(bookmarks), epub->getCachePath(),
|
||||||
|
[this] {
|
||||||
|
exitActivity();
|
||||||
|
updateRequired = true;
|
||||||
|
},
|
||||||
|
[this](const int newSpineIndex, const int newPage) {
|
||||||
|
if (currentSpineIndex != newSpineIndex || (section && section->currentPage != newPage)) {
|
||||||
|
currentSpineIndex = newSpineIndex;
|
||||||
|
nextPageNumber = newPage;
|
||||||
|
section.reset();
|
||||||
|
}
|
||||||
|
exitActivity();
|
||||||
|
updateRequired = true;
|
||||||
|
}));
|
||||||
xSemaphoreGive(renderingMutex);
|
xSemaphoreGive(renderingMutex);
|
||||||
vTaskDelay(1500 / portTICK_PERIOD_MS);
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case EpubReaderMenuActivity::MenuAction::DELETE_DICT_CACHE: {
|
case EpubReaderMenuActivity::MenuAction::DELETE_DICT_CACHE: {
|
||||||
@@ -832,6 +969,22 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
|
|||||||
const int orientedMarginRight, const int orientedMarginBottom,
|
const int orientedMarginRight, const int orientedMarginBottom,
|
||||||
const int orientedMarginLeft) {
|
const int orientedMarginLeft) {
|
||||||
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
|
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
|
||||||
|
|
||||||
|
// Draw bookmark ribbon indicator in top-right corner if current page is bookmarked
|
||||||
|
if (section && BookmarkStore::hasBookmark(epub->getCachePath(), currentSpineIndex, section->currentPage)) {
|
||||||
|
const int screenWidth = renderer.getScreenWidth();
|
||||||
|
const int bkWidth = 12;
|
||||||
|
const int bkHeight = 22;
|
||||||
|
const int bkX = screenWidth - orientedMarginRight - bkWidth + 2;
|
||||||
|
const int bkY = 0;
|
||||||
|
const int notchDepth = bkHeight / 3;
|
||||||
|
const int centerX = bkX + bkWidth / 2;
|
||||||
|
|
||||||
|
const int xPoints[5] = {bkX, bkX + bkWidth, bkX + bkWidth, centerX, bkX};
|
||||||
|
const int yPoints[5] = {bkY, bkY, bkY + bkHeight, bkY + bkHeight - notchDepth, bkY + bkHeight};
|
||||||
|
renderer.fillPolygon(xPoints, yPoints, 5, true);
|
||||||
|
}
|
||||||
|
|
||||||
renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
|
renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
|
||||||
if (pagesUntilFullRefresh <= 1) {
|
if (pagesUntilFullRefresh <= 1) {
|
||||||
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
||||||
|
|||||||
262
src/activities/reader/EpubReaderBookmarkSelectionActivity.cpp
Normal file
262
src/activities/reader/EpubReaderBookmarkSelectionActivity.cpp
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
#include "EpubReaderBookmarkSelectionActivity.h"
|
||||||
|
|
||||||
|
#include <GfxRenderer.h>
|
||||||
|
|
||||||
|
#include "MappedInputManager.h"
|
||||||
|
#include "components/UITheme.h"
|
||||||
|
#include "fontIds.h"
|
||||||
|
|
||||||
|
int EpubReaderBookmarkSelectionActivity::getTotalItems() const { return static_cast<int>(bookmarks.size()); }
|
||||||
|
|
||||||
|
int EpubReaderBookmarkSelectionActivity::getPageItems() const {
|
||||||
|
constexpr int lineHeight = 30;
|
||||||
|
|
||||||
|
const int screenHeight = renderer.getScreenHeight();
|
||||||
|
const auto orientation = renderer.getOrientation();
|
||||||
|
const bool isPortraitInverted = orientation == GfxRenderer::Orientation::PortraitInverted;
|
||||||
|
const int hintGutterHeight = isPortraitInverted ? 50 : 0;
|
||||||
|
const int startY = 60 + hintGutterHeight;
|
||||||
|
const int availableHeight = screenHeight - startY - lineHeight;
|
||||||
|
return std::max(1, availableHeight / lineHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string EpubReaderBookmarkSelectionActivity::getBookmarkPrefix(const Bookmark& bookmark) const {
|
||||||
|
std::string label;
|
||||||
|
if (epub) {
|
||||||
|
const int tocIndex = epub->getTocIndexForSpineIndex(bookmark.spineIndex);
|
||||||
|
if (tocIndex >= 0 && tocIndex < epub->getTocItemsCount()) {
|
||||||
|
label = epub->getTocItem(tocIndex).title;
|
||||||
|
} else {
|
||||||
|
label = "Chapter " + std::to_string(bookmark.spineIndex + 1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
label = "Chapter " + std::to_string(bookmark.spineIndex + 1);
|
||||||
|
}
|
||||||
|
if (!bookmark.snippet.empty()) {
|
||||||
|
label += " - " + bookmark.snippet;
|
||||||
|
}
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string EpubReaderBookmarkSelectionActivity::getPageSuffix(const Bookmark& bookmark) {
|
||||||
|
return " - Page " + std::to_string(bookmark.pageNumber + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
void EpubReaderBookmarkSelectionActivity::taskTrampoline(void* param) {
|
||||||
|
auto* self = static_cast<EpubReaderBookmarkSelectionActivity*>(param);
|
||||||
|
self->displayTaskLoop();
|
||||||
|
}
|
||||||
|
|
||||||
|
void EpubReaderBookmarkSelectionActivity::onEnter() {
|
||||||
|
ActivityWithSubactivity::onEnter();
|
||||||
|
|
||||||
|
renderingMutex = xSemaphoreCreateMutex();
|
||||||
|
|
||||||
|
// Trigger first update
|
||||||
|
updateRequired = true;
|
||||||
|
xTaskCreate(&EpubReaderBookmarkSelectionActivity::taskTrampoline, "BookmarkSelTask",
|
||||||
|
4096, // Stack size
|
||||||
|
this, // Parameters
|
||||||
|
1, // Priority
|
||||||
|
&displayTaskHandle // Task handle
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void EpubReaderBookmarkSelectionActivity::onExit() {
|
||||||
|
ActivityWithSubactivity::onExit();
|
||||||
|
|
||||||
|
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||||
|
if (displayTaskHandle) {
|
||||||
|
vTaskDelete(displayTaskHandle);
|
||||||
|
displayTaskHandle = nullptr;
|
||||||
|
}
|
||||||
|
vSemaphoreDelete(renderingMutex);
|
||||||
|
renderingMutex = nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
void EpubReaderBookmarkSelectionActivity::loop() {
|
||||||
|
if (subActivity) {
|
||||||
|
subActivity->loop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const int totalItems = getTotalItems();
|
||||||
|
|
||||||
|
if (totalItems == 0) {
|
||||||
|
// All bookmarks deleted, go back
|
||||||
|
if (mappedInput.wasReleased(MappedInputManager::Button::Back) ||
|
||||||
|
mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||||
|
onGoBack();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete confirmation mode: wait for confirm (delete) or back (cancel)
|
||||||
|
if (deleteConfirmMode) {
|
||||||
|
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||||
|
if (ignoreNextConfirmRelease) {
|
||||||
|
// Ignore the release from the initial long press
|
||||||
|
ignoreNextConfirmRelease = false;
|
||||||
|
} else {
|
||||||
|
// Confirm delete
|
||||||
|
BookmarkStore::removeBookmark(cachePath, bookmarks[pendingDeleteIndex].spineIndex,
|
||||||
|
bookmarks[pendingDeleteIndex].pageNumber);
|
||||||
|
bookmarks.erase(bookmarks.begin() + pendingDeleteIndex);
|
||||||
|
if (selectorIndex >= static_cast<int>(bookmarks.size())) {
|
||||||
|
selectorIndex = std::max(0, static_cast<int>(bookmarks.size()) - 1);
|
||||||
|
}
|
||||||
|
deleteConfirmMode = false;
|
||||||
|
updateRequired = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||||
|
deleteConfirmMode = false;
|
||||||
|
ignoreNextConfirmRelease = false;
|
||||||
|
updateRequired = true;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect long press on Confirm to trigger delete
|
||||||
|
constexpr unsigned long DELETE_HOLD_MS = 700;
|
||||||
|
if (mappedInput.isPressed(MappedInputManager::Button::Confirm) && mappedInput.getHeldTime() >= DELETE_HOLD_MS) {
|
||||||
|
if (totalItems > 0 && selectorIndex >= 0 && selectorIndex < totalItems) {
|
||||||
|
deleteConfirmMode = true;
|
||||||
|
ignoreNextConfirmRelease = true;
|
||||||
|
pendingDeleteIndex = selectorIndex;
|
||||||
|
updateRequired = true;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const int pageItems = getPageItems();
|
||||||
|
|
||||||
|
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||||
|
if (selectorIndex >= 0 && selectorIndex < totalItems) {
|
||||||
|
const auto& b = bookmarks[selectorIndex];
|
||||||
|
onSelectBookmark(b.spineIndex, b.pageNumber);
|
||||||
|
} else {
|
||||||
|
onGoBack();
|
||||||
|
}
|
||||||
|
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||||
|
onGoBack();
|
||||||
|
}
|
||||||
|
|
||||||
|
buttonNavigator.onNextRelease([this, totalItems] {
|
||||||
|
selectorIndex = ButtonNavigator::nextIndex(selectorIndex, totalItems);
|
||||||
|
updateRequired = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
buttonNavigator.onPreviousRelease([this, totalItems] {
|
||||||
|
selectorIndex = ButtonNavigator::previousIndex(selectorIndex, totalItems);
|
||||||
|
updateRequired = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
buttonNavigator.onNextContinuous([this, totalItems, pageItems] {
|
||||||
|
selectorIndex = ButtonNavigator::nextPageIndex(selectorIndex, totalItems, pageItems);
|
||||||
|
updateRequired = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
buttonNavigator.onPreviousContinuous([this, totalItems, pageItems] {
|
||||||
|
selectorIndex = ButtonNavigator::previousPageIndex(selectorIndex, totalItems, pageItems);
|
||||||
|
updateRequired = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void EpubReaderBookmarkSelectionActivity::displayTaskLoop() {
|
||||||
|
while (true) {
|
||||||
|
if (updateRequired && !subActivity) {
|
||||||
|
updateRequired = false;
|
||||||
|
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||||
|
renderScreen();
|
||||||
|
xSemaphoreGive(renderingMutex);
|
||||||
|
}
|
||||||
|
vTaskDelay(10 / portTICK_PERIOD_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void EpubReaderBookmarkSelectionActivity::renderScreen() {
|
||||||
|
renderer.clearScreen();
|
||||||
|
|
||||||
|
const auto pageWidth = renderer.getScreenWidth();
|
||||||
|
const auto orientation = renderer.getOrientation();
|
||||||
|
const bool isLandscapeCw = orientation == GfxRenderer::Orientation::LandscapeClockwise;
|
||||||
|
const bool isLandscapeCcw = orientation == GfxRenderer::Orientation::LandscapeCounterClockwise;
|
||||||
|
const bool isPortraitInverted = orientation == GfxRenderer::Orientation::PortraitInverted;
|
||||||
|
const int hintGutterWidth = (isLandscapeCw || isLandscapeCcw) ? 30 : 0;
|
||||||
|
const int contentX = isLandscapeCw ? hintGutterWidth : 0;
|
||||||
|
const int contentWidth = pageWidth - hintGutterWidth;
|
||||||
|
const int hintGutterHeight = isPortraitInverted ? 50 : 0;
|
||||||
|
const int contentY = hintGutterHeight;
|
||||||
|
const int pageItems = getPageItems();
|
||||||
|
const int totalItems = getTotalItems();
|
||||||
|
|
||||||
|
// Title
|
||||||
|
const int titleX =
|
||||||
|
contentX + (contentWidth - renderer.getTextWidth(UI_12_FONT_ID, "Go to Bookmark", EpdFontFamily::BOLD)) / 2;
|
||||||
|
renderer.drawText(UI_12_FONT_ID, titleX, 15 + contentY, "Go to Bookmark", true, EpdFontFamily::BOLD);
|
||||||
|
|
||||||
|
if (totalItems == 0) {
|
||||||
|
renderer.drawCenteredText(UI_10_FONT_ID, 100 + contentY, "No bookmarks", true);
|
||||||
|
} else {
|
||||||
|
const auto pageStartIndex = selectorIndex / pageItems * pageItems;
|
||||||
|
renderer.fillRect(contentX, 60 + contentY + (selectorIndex % pageItems) * 30 - 2, contentWidth - 1, 30);
|
||||||
|
|
||||||
|
const int maxLabelWidth = contentWidth - 40 - contentX - 20;
|
||||||
|
|
||||||
|
for (int i = 0; i < pageItems; i++) {
|
||||||
|
int itemIndex = pageStartIndex + i;
|
||||||
|
if (itemIndex >= totalItems) break;
|
||||||
|
const int displayY = 60 + contentY + i * 30;
|
||||||
|
const bool isSelected = (itemIndex == selectorIndex);
|
||||||
|
|
||||||
|
const std::string suffix = getPageSuffix(bookmarks[itemIndex]);
|
||||||
|
const int suffixWidth = renderer.getTextWidth(UI_10_FONT_ID, suffix.c_str());
|
||||||
|
|
||||||
|
// Truncate the prefix (chapter + snippet) to leave room for the page suffix
|
||||||
|
const std::string prefix = getBookmarkPrefix(bookmarks[itemIndex]);
|
||||||
|
const std::string truncatedPrefix =
|
||||||
|
renderer.truncatedText(UI_10_FONT_ID, prefix.c_str(), maxLabelWidth - suffixWidth);
|
||||||
|
|
||||||
|
const std::string label = truncatedPrefix + suffix;
|
||||||
|
|
||||||
|
renderer.drawText(UI_10_FONT_ID, contentX + 20, displayY, label.c_str(), !isSelected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deleteConfirmMode && pendingDeleteIndex < static_cast<int>(bookmarks.size())) {
|
||||||
|
// Draw delete confirmation overlay
|
||||||
|
const std::string suffix = getPageSuffix(bookmarks[pendingDeleteIndex]);
|
||||||
|
std::string msg = "Delete bookmark" + suffix + "?";
|
||||||
|
|
||||||
|
constexpr int margin = 15;
|
||||||
|
constexpr int popupY = 200;
|
||||||
|
const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, msg.c_str(), EpdFontFamily::BOLD);
|
||||||
|
const int textHeight = renderer.getLineHeight(UI_12_FONT_ID);
|
||||||
|
const int w = textWidth + margin * 2;
|
||||||
|
const int h = textHeight + margin * 2;
|
||||||
|
const int x = (renderer.getScreenWidth() - w) / 2;
|
||||||
|
|
||||||
|
renderer.fillRect(x - 2, popupY - 2, w + 4, h + 4, true);
|
||||||
|
renderer.fillRect(x, popupY, w, h, false);
|
||||||
|
|
||||||
|
const int textX = x + (w - textWidth) / 2;
|
||||||
|
const int textY = popupY + margin - 2;
|
||||||
|
renderer.drawText(UI_12_FONT_ID, textX, textY, msg.c_str(), true, EpdFontFamily::BOLD);
|
||||||
|
|
||||||
|
const auto labels = mappedInput.mapLabels("Cancel", "Delete", "", "");
|
||||||
|
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||||
|
} else {
|
||||||
|
if (!bookmarks.empty()) {
|
||||||
|
const char* deleteHint = "Hold select to delete";
|
||||||
|
const int hintWidth = renderer.getTextWidth(SMALL_FONT_ID, deleteHint);
|
||||||
|
renderer.drawText(SMALL_FONT_ID, (renderer.getScreenWidth() - hintWidth) / 2,
|
||||||
|
renderer.getScreenHeight() - 70, deleteHint);
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto labels = mappedInput.mapLabels("\xC2\xAB Back", "Select", "Up", "Down");
|
||||||
|
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderer.displayBuffer();
|
||||||
|
}
|
||||||
60
src/activities/reader/EpubReaderBookmarkSelectionActivity.h
Normal file
60
src/activities/reader/EpubReaderBookmarkSelectionActivity.h
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <Epub.h>
|
||||||
|
#include <freertos/FreeRTOS.h>
|
||||||
|
#include <freertos/semphr.h>
|
||||||
|
#include <freertos/task.h>
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "../ActivityWithSubactivity.h"
|
||||||
|
#include "util/BookmarkStore.h"
|
||||||
|
#include "util/ButtonNavigator.h"
|
||||||
|
|
||||||
|
class EpubReaderBookmarkSelectionActivity final : public ActivityWithSubactivity {
|
||||||
|
std::shared_ptr<Epub> epub;
|
||||||
|
std::vector<Bookmark> bookmarks;
|
||||||
|
std::string cachePath;
|
||||||
|
TaskHandle_t displayTaskHandle = nullptr;
|
||||||
|
SemaphoreHandle_t renderingMutex = nullptr;
|
||||||
|
ButtonNavigator buttonNavigator;
|
||||||
|
int selectorIndex = 0;
|
||||||
|
bool updateRequired = false;
|
||||||
|
bool deleteConfirmMode = false;
|
||||||
|
bool ignoreNextConfirmRelease = false;
|
||||||
|
int pendingDeleteIndex = 0;
|
||||||
|
const std::function<void()> onGoBack;
|
||||||
|
const std::function<void(int newSpineIndex, int newPage)> onSelectBookmark;
|
||||||
|
|
||||||
|
// Number of items that fit on a page, derived from logical screen height.
|
||||||
|
int getPageItems() const;
|
||||||
|
|
||||||
|
int getTotalItems() const;
|
||||||
|
|
||||||
|
// Build the prefix portion of a bookmark label (chapter + snippet, without page suffix)
|
||||||
|
std::string getBookmarkPrefix(const Bookmark& bookmark) const;
|
||||||
|
|
||||||
|
// Build the page suffix (e.g. " - Page 5")
|
||||||
|
static std::string getPageSuffix(const Bookmark& bookmark);
|
||||||
|
|
||||||
|
static void taskTrampoline(void* param);
|
||||||
|
[[noreturn]] void displayTaskLoop();
|
||||||
|
void renderScreen();
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit EpubReaderBookmarkSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
||||||
|
const std::shared_ptr<Epub>& epub,
|
||||||
|
std::vector<Bookmark> bookmarks,
|
||||||
|
const std::string& cachePath,
|
||||||
|
const std::function<void()>& onGoBack,
|
||||||
|
const std::function<void(int newSpineIndex, int newPage)>& onSelectBookmark)
|
||||||
|
: ActivityWithSubactivity("EpubReaderBookmarkSelection", renderer, mappedInput),
|
||||||
|
epub(epub),
|
||||||
|
bookmarks(std::move(bookmarks)),
|
||||||
|
cachePath(cachePath),
|
||||||
|
onGoBack(onGoBack),
|
||||||
|
onSelectBookmark(onSelectBookmark) {}
|
||||||
|
void onEnter() override;
|
||||||
|
void onExit() override;
|
||||||
|
void loop() override;
|
||||||
|
};
|
||||||
@@ -16,6 +16,7 @@ class EpubReaderMenuActivity final : public ActivityWithSubactivity {
|
|||||||
// Menu actions available from the reader menu.
|
// Menu actions available from the reader menu.
|
||||||
enum class MenuAction {
|
enum class MenuAction {
|
||||||
ADD_BOOKMARK,
|
ADD_BOOKMARK,
|
||||||
|
REMOVE_BOOKMARK,
|
||||||
LOOKUP,
|
LOOKUP,
|
||||||
LOOKED_UP_WORDS,
|
LOOKED_UP_WORDS,
|
||||||
ROTATE_SCREEN,
|
ROTATE_SCREEN,
|
||||||
@@ -31,10 +32,11 @@ class EpubReaderMenuActivity final : public ActivityWithSubactivity {
|
|||||||
explicit EpubReaderMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& title,
|
explicit EpubReaderMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& title,
|
||||||
const int currentPage, const int totalPages, const int bookProgressPercent,
|
const int currentPage, const int totalPages, const int bookProgressPercent,
|
||||||
const uint8_t currentOrientation, const bool hasDictionary,
|
const uint8_t currentOrientation, const bool hasDictionary,
|
||||||
|
const bool isBookmarked,
|
||||||
const std::function<void(uint8_t)>& onBack,
|
const std::function<void(uint8_t)>& onBack,
|
||||||
const std::function<void(MenuAction)>& onAction)
|
const std::function<void(MenuAction)>& onAction)
|
||||||
: ActivityWithSubactivity("EpubReaderMenu", renderer, mappedInput),
|
: ActivityWithSubactivity("EpubReaderMenu", renderer, mappedInput),
|
||||||
menuItems(buildMenuItems(hasDictionary)),
|
menuItems(buildMenuItems(hasDictionary, isBookmarked)),
|
||||||
title(title),
|
title(title),
|
||||||
pendingOrientation(currentOrientation),
|
pendingOrientation(currentOrientation),
|
||||||
currentPage(currentPage),
|
currentPage(currentPage),
|
||||||
@@ -70,9 +72,13 @@ class EpubReaderMenuActivity final : public ActivityWithSubactivity {
|
|||||||
const std::function<void(uint8_t)> onBack;
|
const std::function<void(uint8_t)> onBack;
|
||||||
const std::function<void(MenuAction)> onAction;
|
const std::function<void(MenuAction)> onAction;
|
||||||
|
|
||||||
static std::vector<MenuItem> buildMenuItems(bool hasDictionary) {
|
static std::vector<MenuItem> buildMenuItems(bool hasDictionary, bool isBookmarked) {
|
||||||
std::vector<MenuItem> items;
|
std::vector<MenuItem> items;
|
||||||
|
if (isBookmarked) {
|
||||||
|
items.push_back({MenuAction::REMOVE_BOOKMARK, "Remove Bookmark"});
|
||||||
|
} else {
|
||||||
items.push_back({MenuAction::ADD_BOOKMARK, "Add Bookmark"});
|
items.push_back({MenuAction::ADD_BOOKMARK, "Add Bookmark"});
|
||||||
|
}
|
||||||
if (hasDictionary) {
|
if (hasDictionary) {
|
||||||
items.push_back({MenuAction::LOOKUP, "Lookup Word"});
|
items.push_back({MenuAction::LOOKUP, "Lookup Word"});
|
||||||
items.push_back({MenuAction::LOOKED_UP_WORDS, "Lookup Word History"});
|
items.push_back({MenuAction::LOOKED_UP_WORDS, "Lookup Word History"});
|
||||||
|
|||||||
158
src/util/BookmarkStore.cpp
Normal file
158
src/util/BookmarkStore.cpp
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
#include "BookmarkStore.h"
|
||||||
|
|
||||||
|
#include <HalStorage.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
std::string BookmarkStore::filePath(const std::string& cachePath) { return cachePath + "/bookmarks.bin"; }
|
||||||
|
|
||||||
|
std::vector<Bookmark> BookmarkStore::load(const std::string& cachePath) {
|
||||||
|
std::vector<Bookmark> bookmarks;
|
||||||
|
FsFile f;
|
||||||
|
if (!Storage.openFileForRead("BKM", filePath(cachePath), f)) {
|
||||||
|
return bookmarks;
|
||||||
|
}
|
||||||
|
|
||||||
|
// File format v2: [version(1)] [count(2)] [entries...]
|
||||||
|
// Each entry: [spine(2)] [page(2)] [snippetLen(1)] [snippet(snippetLen)]
|
||||||
|
// v1 (no version byte): [count(2)] [entries of 4 bytes each]
|
||||||
|
// We detect v1 by checking if the first byte could be a version marker (0xFF).
|
||||||
|
|
||||||
|
uint8_t firstByte;
|
||||||
|
if (f.read(&firstByte, 1) != 1) {
|
||||||
|
f.close();
|
||||||
|
return bookmarks;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint16_t count;
|
||||||
|
bool hasSnippets;
|
||||||
|
|
||||||
|
if (firstByte == 0xFF) {
|
||||||
|
// v2 format: version marker was 0xFF
|
||||||
|
hasSnippets = true;
|
||||||
|
uint8_t countBytes[2];
|
||||||
|
if (f.read(countBytes, 2) != 2) {
|
||||||
|
f.close();
|
||||||
|
return bookmarks;
|
||||||
|
}
|
||||||
|
count = static_cast<uint16_t>(countBytes[0]) | (static_cast<uint16_t>(countBytes[1]) << 8);
|
||||||
|
} else {
|
||||||
|
// v1 format: first byte was part of the count
|
||||||
|
hasSnippets = false;
|
||||||
|
uint8_t secondByte;
|
||||||
|
if (f.read(&secondByte, 1) != 1) {
|
||||||
|
f.close();
|
||||||
|
return bookmarks;
|
||||||
|
}
|
||||||
|
count = static_cast<uint16_t>(firstByte) | (static_cast<uint16_t>(secondByte) << 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count > MAX_BOOKMARKS) {
|
||||||
|
count = MAX_BOOKMARKS;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (uint16_t i = 0; i < count; i++) {
|
||||||
|
uint8_t entry[4];
|
||||||
|
if (f.read(entry, 4) != 4) break;
|
||||||
|
Bookmark b;
|
||||||
|
b.spineIndex = static_cast<int16_t>(static_cast<uint16_t>(entry[0]) | (static_cast<uint16_t>(entry[1]) << 8));
|
||||||
|
b.pageNumber = static_cast<int16_t>(static_cast<uint16_t>(entry[2]) | (static_cast<uint16_t>(entry[3]) << 8));
|
||||||
|
|
||||||
|
if (hasSnippets) {
|
||||||
|
uint8_t snippetLen;
|
||||||
|
if (f.read(&snippetLen, 1) != 1) break;
|
||||||
|
if (snippetLen > 0) {
|
||||||
|
std::vector<uint8_t> buf(snippetLen);
|
||||||
|
if (f.read(buf.data(), snippetLen) != snippetLen) break;
|
||||||
|
b.snippet = std::string(buf.begin(), buf.end());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bookmarks.push_back(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
f.close();
|
||||||
|
return bookmarks;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool BookmarkStore::save(const std::string& cachePath, const std::vector<Bookmark>& bookmarks) {
|
||||||
|
FsFile f;
|
||||||
|
if (!Storage.openFileForWrite("BKM", filePath(cachePath), f)) {
|
||||||
|
Serial.printf("[%lu] [BKM] Could not save bookmarks!\n", millis());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write v2 format: version marker + count + entries with snippets
|
||||||
|
uint8_t version = 0xFF;
|
||||||
|
f.write(&version, 1);
|
||||||
|
|
||||||
|
uint16_t count = static_cast<uint16_t>(bookmarks.size());
|
||||||
|
uint8_t header[2] = {static_cast<uint8_t>(count & 0xFF), static_cast<uint8_t>((count >> 8) & 0xFF)};
|
||||||
|
f.write(header, 2);
|
||||||
|
|
||||||
|
for (const auto& b : bookmarks) {
|
||||||
|
uint8_t entry[4];
|
||||||
|
entry[0] = static_cast<uint8_t>(b.spineIndex & 0xFF);
|
||||||
|
entry[1] = static_cast<uint8_t>((b.spineIndex >> 8) & 0xFF);
|
||||||
|
entry[2] = static_cast<uint8_t>(b.pageNumber & 0xFF);
|
||||||
|
entry[3] = static_cast<uint8_t>((b.pageNumber >> 8) & 0xFF);
|
||||||
|
f.write(entry, 4);
|
||||||
|
|
||||||
|
// Write snippet: length byte + string data
|
||||||
|
uint8_t snippetLen = static_cast<uint8_t>(std::min(static_cast<int>(b.snippet.size()), MAX_SNIPPET_LENGTH));
|
||||||
|
f.write(&snippetLen, 1);
|
||||||
|
if (snippetLen > 0) {
|
||||||
|
f.write(reinterpret_cast<const uint8_t*>(b.snippet.c_str()), snippetLen);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
f.close();
|
||||||
|
Serial.printf("[%lu] [BKM] Saved %d bookmarks\n", millis(), count);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool BookmarkStore::addBookmark(const std::string& cachePath, int spineIndex, int page, const std::string& snippet) {
|
||||||
|
auto bookmarks = load(cachePath);
|
||||||
|
|
||||||
|
// Check for duplicate
|
||||||
|
for (const auto& b : bookmarks) {
|
||||||
|
if (b.spineIndex == spineIndex && b.pageNumber == page) {
|
||||||
|
return true; // Already bookmarked
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (static_cast<int>(bookmarks.size()) >= MAX_BOOKMARKS) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Bookmark b;
|
||||||
|
b.spineIndex = static_cast<int16_t>(spineIndex);
|
||||||
|
b.pageNumber = static_cast<int16_t>(page);
|
||||||
|
b.snippet = snippet.substr(0, MAX_SNIPPET_LENGTH);
|
||||||
|
bookmarks.push_back(b);
|
||||||
|
|
||||||
|
return save(cachePath, bookmarks);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool BookmarkStore::removeBookmark(const std::string& cachePath, int spineIndex, int page) {
|
||||||
|
auto bookmarks = load(cachePath);
|
||||||
|
|
||||||
|
auto it = std::remove_if(bookmarks.begin(), bookmarks.end(),
|
||||||
|
[spineIndex, page](const Bookmark& b) {
|
||||||
|
return b.spineIndex == spineIndex && b.pageNumber == page;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (it == bookmarks.end()) {
|
||||||
|
return false; // Not found
|
||||||
|
}
|
||||||
|
|
||||||
|
bookmarks.erase(it, bookmarks.end());
|
||||||
|
return save(cachePath, bookmarks);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool BookmarkStore::hasBookmark(const std::string& cachePath, int spineIndex, int page) {
|
||||||
|
auto bookmarks = load(cachePath);
|
||||||
|
return std::any_of(bookmarks.begin(), bookmarks.end(), [spineIndex, page](const Bookmark& b) {
|
||||||
|
return b.spineIndex == spineIndex && b.pageNumber == page;
|
||||||
|
});
|
||||||
|
}
|
||||||
24
src/util/BookmarkStore.h
Normal file
24
src/util/BookmarkStore.h
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <cstdint>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
struct Bookmark {
|
||||||
|
int16_t spineIndex;
|
||||||
|
int16_t pageNumber;
|
||||||
|
std::string snippet; // First sentence or text excerpt from the page
|
||||||
|
};
|
||||||
|
|
||||||
|
class BookmarkStore {
|
||||||
|
public:
|
||||||
|
static std::vector<Bookmark> load(const std::string& cachePath);
|
||||||
|
static bool save(const std::string& cachePath, const std::vector<Bookmark>& bookmarks);
|
||||||
|
static bool addBookmark(const std::string& cachePath, int spineIndex, int page, const std::string& snippet = "");
|
||||||
|
static bool removeBookmark(const std::string& cachePath, int spineIndex, int page);
|
||||||
|
static bool hasBookmark(const std::string& cachePath, int spineIndex, int page);
|
||||||
|
|
||||||
|
private:
|
||||||
|
static std::string filePath(const std::string& cachePath);
|
||||||
|
static constexpr int MAX_BOOKMARKS = 200;
|
||||||
|
static constexpr int MAX_SNIPPET_LENGTH = 120;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user