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 "CrossPointState.h"
|
||||
#include "EpubReaderBookmarkSelectionActivity.h"
|
||||
#include "EpubReaderChapterSelectionActivity.h"
|
||||
#include "EpubReaderPercentSelectionActivity.h"
|
||||
#include "KOReaderCredentialStore.h"
|
||||
@@ -15,6 +16,7 @@
|
||||
#include "RecentBooksStore.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
#include "util/BookmarkStore.h"
|
||||
#include "util/Dictionary.h"
|
||||
#include "util/LookupHistory.h"
|
||||
|
||||
@@ -235,10 +237,13 @@ void EpubReaderActivity::loop() {
|
||||
}
|
||||
const int bookProgressPercent = clampPercent(static_cast<int>(bookProgress + 0.5f));
|
||||
const bool hasDictionary = Dictionary::exists();
|
||||
const bool isBookmarked = BookmarkStore::hasBookmark(
|
||||
epub->getCachePath(), currentSpineIndex, section ? section->currentPage : 0);
|
||||
exitActivity();
|
||||
enterNewActivity(new EpubReaderMenuActivity(
|
||||
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); }));
|
||||
xSemaphoreGive(renderingMutex);
|
||||
}
|
||||
@@ -332,6 +337,8 @@ void EpubReaderActivity::onReaderMenuBack(const uint8_t orientation) {
|
||||
// Apply the user-selected orientation when the menu is dismissed.
|
||||
// This ensures the menu can be navigated without immediately rotating the screen.
|
||||
applyOrientation(orientation);
|
||||
// Force a half refresh on the next render to clear menu/popup artifacts
|
||||
pagesUntilFullRefresh = 1;
|
||||
updateRequired = true;
|
||||
}
|
||||
|
||||
@@ -400,21 +407,151 @@ void EpubReaderActivity::jumpToPercent(int percent) {
|
||||
void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action) {
|
||||
switch (action) {
|
||||
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);
|
||||
GUI.drawPopup(renderer, "Coming soon");
|
||||
GUI.drawPopup(renderer, "Bookmark added");
|
||||
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||
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;
|
||||
}
|
||||
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);
|
||||
exitActivity();
|
||||
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);
|
||||
GUI.drawPopup(renderer, "Coming soon");
|
||||
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||
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);
|
||||
vTaskDelay(1500 / portTICK_PERIOD_MS);
|
||||
break;
|
||||
}
|
||||
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 orientedMarginLeft) {
|
||||
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);
|
||||
if (pagesUntilFullRefresh <= 1) {
|
||||
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.
|
||||
enum class MenuAction {
|
||||
ADD_BOOKMARK,
|
||||
REMOVE_BOOKMARK,
|
||||
LOOKUP,
|
||||
LOOKED_UP_WORDS,
|
||||
ROTATE_SCREEN,
|
||||
@@ -31,10 +32,11 @@ class EpubReaderMenuActivity final : public ActivityWithSubactivity {
|
||||
explicit EpubReaderMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& title,
|
||||
const int currentPage, const int totalPages, const int bookProgressPercent,
|
||||
const uint8_t currentOrientation, const bool hasDictionary,
|
||||
const bool isBookmarked,
|
||||
const std::function<void(uint8_t)>& onBack,
|
||||
const std::function<void(MenuAction)>& onAction)
|
||||
: ActivityWithSubactivity("EpubReaderMenu", renderer, mappedInput),
|
||||
menuItems(buildMenuItems(hasDictionary)),
|
||||
menuItems(buildMenuItems(hasDictionary, isBookmarked)),
|
||||
title(title),
|
||||
pendingOrientation(currentOrientation),
|
||||
currentPage(currentPage),
|
||||
@@ -70,9 +72,13 @@ class EpubReaderMenuActivity final : public ActivityWithSubactivity {
|
||||
const std::function<void(uint8_t)> onBack;
|
||||
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;
|
||||
items.push_back({MenuAction::ADD_BOOKMARK, "Add Bookmark"});
|
||||
if (isBookmarked) {
|
||||
items.push_back({MenuAction::REMOVE_BOOKMARK, "Remove Bookmark"});
|
||||
} else {
|
||||
items.push_back({MenuAction::ADD_BOOKMARK, "Add Bookmark"});
|
||||
}
|
||||
if (hasDictionary) {
|
||||
items.push_back({MenuAction::LOOKUP, "Lookup Word"});
|
||||
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