## Summary This PR enhances the EPUB reader's bookmark system with two complementary improvements: a per-page bookmark indicator icon and toggle behavior on the existing long-press action. --- ### What Changed **Bookmark Toggle (was: add-only)** The long-press Confirm action now toggles bookmarks rather than always adding. `addBookmark()` checks whether a bookmark with the same xpath already exists in the in-memory cache: - If found → removes it and shows "Bookmark removed." - If not found → adds it and shows "Bookmark added." A new `STR_BOOKMARK_REMOVED` translation string was added to support the removal message. **Bookmark Icon Indicator** A `BookmarkIcon` is now drawn at the top-right corner of the page whenever the current page has a bookmark. `updateBookmarkFlag()` is called at render time to determine whether the current page is bookmarked. **In-Memory Bookmark Cache** Bookmarks are now loaded into `cachedBookmarks` on `onEnter()` rather than being re-read from disk on every toggle. All subsequent add/remove operations work against this cache and flush to disk, avoiding redundant file reads on each bookmark action. **Faster bookmarks list** Previously, calculating "page X/Y" for each entry required decompressing the entire spine item. We now persist `si`/`pc`/`pp` (spine index, page count, and page progress) in the bookmark JSON when saving, and restore them when loading. This avoids the expensive `toCrossPoint()` loop in `onEnter()`, significantly reducing the cost of initializing the bookmarks list. --- ### Files Changed - `EpubReaderActivity.cpp` — `addBookmark()` toggle logic, `updateBookmarkFlag()` (new), icon rendering in `renderContents()`, cache initialization in `onEnter()` - `EpubReaderActivity.h` — new fields: `currentPageBookmarked`, `bookmarkRemoved`, `cachedBookmarks`; new method declaration `updateBookmarkFlag()` --- ### AI Usage While CrossPoint doesn't have restrictions on AI tools in contributing, please be transparent about their usage as it helps set the right context for reviewers. Did you use AI tools to help write this code? _**< PARTIALLY >**_ --------- Co-authored-by: Julia Nguyen <julia@uxj.io>
226 lines
8.8 KiB
C++
226 lines
8.8 KiB
C++
#include "EpubReaderBookmarksActivity.h"
|
|
|
|
#include <GfxRenderer.h>
|
|
#include <HalStorage.h>
|
|
#include <I18n.h>
|
|
#include <JsonSettingsIO.h>
|
|
#include <util/BookmarkUtil.h>
|
|
|
|
#include <algorithm>
|
|
|
|
#include "MappedInputManager.h"
|
|
#include "ProgressMapper.h"
|
|
#include "components/UITheme.h"
|
|
#include "fontIds.h"
|
|
|
|
namespace {
|
|
constexpr int ENTER_DELETE_MODE_MS = 700;
|
|
constexpr int DELETE_MODE_OFF = 0;
|
|
constexpr int DELETE_MODE_DISPLAY = 1;
|
|
constexpr int DELETE_MODE_CONFIRM = 2;
|
|
|
|
// Layout constants used in renderScreen
|
|
constexpr int LINE_HEIGHT = 60;
|
|
} // namespace
|
|
|
|
void EpubReaderBookmarksActivity::onEnter() {
|
|
Activity::onEnter();
|
|
|
|
if (!epub) {
|
|
return;
|
|
}
|
|
|
|
const std::string path = BookmarkUtil::getBookmarkPath(epubPath);
|
|
if (Storage.exists(path.c_str())) {
|
|
String json = Storage.readFile(path.c_str());
|
|
if (json.isEmpty()) {
|
|
LOG_ERR("EPB", "Failed to load bookmarks from %s. Empty bookmark file", path.c_str());
|
|
bookmarks.clear();
|
|
bookmarks.shrink_to_fit();
|
|
} else {
|
|
JsonSettingsIO::loadBookmarks(bookmarks, json.c_str());
|
|
}
|
|
} else {
|
|
LOG_DBG("EPB", "No bookmark file found at %s, starting with empty bookmarks", path.c_str());
|
|
bookmarks.clear();
|
|
bookmarks.shrink_to_fit();
|
|
}
|
|
LOG_DBG("EPB", "Loaded %d bookmarks for book: %s", static_cast<int>(bookmarks.size()), epubPath.c_str());
|
|
|
|
// Trigger first update
|
|
requestUpdate();
|
|
}
|
|
|
|
void EpubReaderBookmarksActivity::onExit() { Activity::onExit(); }
|
|
|
|
int EpubReaderBookmarksActivity::getGutterBottom(const GfxRenderer& renderer) {
|
|
const auto orientation = renderer.getOrientation();
|
|
const bool isPortrait = orientation == GfxRenderer::Orientation::Portrait;
|
|
return isPortrait ? 75 : 40; // Reserve vertical space for button hints at the bottom
|
|
}
|
|
|
|
int EpubReaderBookmarksActivity::getListHeight(const GfxRenderer& renderer) {
|
|
const auto pageHeight = renderer.getScreenHeight();
|
|
return pageHeight - getGutterBottom(renderer) - LINE_HEIGHT; // Reserve vertical space for title and button hints
|
|
}
|
|
|
|
void EpubReaderBookmarksActivity::loop() {
|
|
// Delete confirmation mode
|
|
if (confirmingDelete >= DELETE_MODE_DISPLAY) {
|
|
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
|
if (confirmingDelete == DELETE_MODE_DISPLAY) {
|
|
confirmingDelete = DELETE_MODE_CONFIRM; // first confirmation, update text
|
|
requestUpdate();
|
|
return;
|
|
}
|
|
bookmarks.erase(bookmarks.begin() + selectorIndex);
|
|
const std::string path = BookmarkUtil::getBookmarkPath(epubPath);
|
|
Storage.mkdir(BookmarkUtil::getBookmarksDir().c_str());
|
|
if (!JsonSettingsIO::saveBookmarks(bookmarks, path.c_str())) {
|
|
LOG_ERR("EPB", "Failed to save bookmarks after delete");
|
|
}
|
|
|
|
// Move selector up if we deleted the last item
|
|
if (selectorIndex >= bookmarks.size() && selectorIndex > 0) {
|
|
selectorIndex--;
|
|
}
|
|
|
|
if (bookmarks.empty()) {
|
|
ActivityResult result;
|
|
result.isCancelled = true;
|
|
setResult(std::move(result));
|
|
finish();
|
|
return;
|
|
}
|
|
|
|
requestUpdate();
|
|
confirmingDelete = DELETE_MODE_OFF;
|
|
return;
|
|
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
|
requestUpdate();
|
|
confirmingDelete = DELETE_MODE_OFF;
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { // Open
|
|
if (bookmarks.empty()) {
|
|
return;
|
|
}
|
|
auto bookmark = bookmarks.at(selectorIndex);
|
|
CrossPointPosition pos = ProgressMapper::toCrossPoint(epub, {bookmark.xpath, bookmark.percentage}, renderer);
|
|
setResult(ProgressChangeResult{pos.spineIndex, pos.pageNumber});
|
|
finish();
|
|
return;
|
|
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
|
ActivityResult result;
|
|
result.isCancelled = true;
|
|
setResult(std::move(result));
|
|
finish();
|
|
return;
|
|
}
|
|
|
|
if (mappedInput.isPressed(MappedInputManager::Button::Confirm) && mappedInput.getHeldTime() > ENTER_DELETE_MODE_MS) {
|
|
if (bookmarks.empty()) {
|
|
return;
|
|
}
|
|
confirmingDelete = DELETE_MODE_DISPLAY;
|
|
requestUpdate();
|
|
}
|
|
|
|
buttonNavigator.onNextRelease([this] {
|
|
selectorIndex = ButtonNavigator::nextIndex(selectorIndex, bookmarks.size());
|
|
requestUpdate();
|
|
});
|
|
|
|
buttonNavigator.onPreviousRelease([this] {
|
|
selectorIndex = ButtonNavigator::previousIndex(selectorIndex, bookmarks.size());
|
|
requestUpdate();
|
|
});
|
|
|
|
buttonNavigator.onNextContinuous([this] {
|
|
selectorIndex = ButtonNavigator::nextPageIndex(selectorIndex, bookmarks.size(),
|
|
GUI.getListPageItems(getListHeight(renderer), true));
|
|
requestUpdate();
|
|
});
|
|
|
|
buttonNavigator.onPreviousContinuous([this] {
|
|
selectorIndex = ButtonNavigator::previousPageIndex(selectorIndex, bookmarks.size(),
|
|
GUI.getListPageItems(getListHeight(renderer), true));
|
|
requestUpdate();
|
|
});
|
|
}
|
|
|
|
void EpubReaderBookmarksActivity::render(RenderLock&&) {
|
|
renderer.clearScreen();
|
|
|
|
const auto pageWidth = renderer.getScreenWidth();
|
|
const auto pageHeight = renderer.getScreenHeight();
|
|
const auto orientation = renderer.getOrientation();
|
|
// Landscape orientation: reserve a horizontal gutter for button hints.
|
|
const bool isLandscapeCw = orientation == GfxRenderer::Orientation::LandscapeClockwise;
|
|
const bool isLandscapeCcw = orientation == GfxRenderer::Orientation::LandscapeCounterClockwise;
|
|
// Inverted portrait: reserve vertical space for hints at the top.
|
|
const bool isPortraitInverted = orientation == GfxRenderer::Orientation::PortraitInverted;
|
|
const bool isPortrait = orientation == GfxRenderer::Orientation::Portrait;
|
|
const int hintGutterWidth = (isLandscapeCw || isLandscapeCcw) ? 40 : 0;
|
|
// Landscape CW places hints on the left edge; CCW keeps them on the right.
|
|
const int contentX = isLandscapeCw ? hintGutterWidth : 0;
|
|
const int contentWidth = pageWidth - hintGutterWidth;
|
|
const int hintGutterHeight = isPortraitInverted ? 50 : 0;
|
|
const int hintGutterBottom = getGutterBottom(renderer);
|
|
const int contentY = hintGutterHeight;
|
|
const int listY = contentY + LINE_HEIGHT; // Reserve vertical space for title
|
|
const int listHeight = getListHeight(renderer);
|
|
const int numBookmarks = bookmarks.size();
|
|
|
|
// Manual centering to honor content gutters.
|
|
const int titleX =
|
|
contentX + (contentWidth - renderer.getTextWidth(UI_12_FONT_ID, tr(STR_BOOKMARKS), EpdFontFamily::BOLD)) / 2;
|
|
renderer.drawText(UI_12_FONT_ID, titleX, 15 + contentY, tr(STR_BOOKMARKS), true, EpdFontFamily::BOLD);
|
|
|
|
const auto getBookmarkTitle = [this](int index) {
|
|
return bookmarks.at(confirmingDelete >= DELETE_MODE_DISPLAY ? selectorIndex : index).summary;
|
|
};
|
|
const auto getBookmarkSubtitle = [this](int index) {
|
|
auto bookmark = bookmarks.at(confirmingDelete >= DELETE_MODE_DISPLAY ? selectorIndex : index);
|
|
auto tocIndex = epub->getTocIndexForSpineIndex(bookmark.computedSpineIndex);
|
|
auto tocTitle = (tocIndex >= 0) ? (epub->getTocItem(tocIndex)).title : tr(STR_UNNAMED);
|
|
std::string subtitle = std::to_string((int)(std::clamp(bookmark.percentage, 0.0f, 1.0f) * 100.0f + 0.5f)) + "% - ";
|
|
if (bookmark.computedChapterPageCount > 0) {
|
|
subtitle += std::to_string(bookmark.computedChapterProgress + 1) + "/" +
|
|
std::to_string(bookmark.computedChapterPageCount) + " - ";
|
|
}
|
|
return subtitle + tocTitle;
|
|
};
|
|
const auto getBookmarkIcon = [isPortrait](int index) {
|
|
// only enabled icon in portrait mode due to limitation with rotating icons for other orientations
|
|
return isPortrait ? UIIcon::Bookmark : UIIcon::None;
|
|
};
|
|
|
|
if (numBookmarks > 0) {
|
|
if (confirmingDelete >= DELETE_MODE_DISPLAY) {
|
|
GUI.drawHelpText(renderer, Rect{0, pageHeight / 2 - LINE_HEIGHT * 2, contentWidth, LINE_HEIGHT},
|
|
tr(STR_CONFIRM_DELETE_BOOKMARK));
|
|
|
|
// render list with just the selected item for the user to confirm to delete
|
|
GUI.drawList(renderer, Rect{contentX, pageHeight / 2, contentWidth, LINE_HEIGHT}, 1, 0, getBookmarkTitle,
|
|
getBookmarkSubtitle, getBookmarkIcon);
|
|
} else {
|
|
GUI.drawList(renderer, Rect{contentX, listY, contentWidth, listHeight}, numBookmarks, selectorIndex,
|
|
getBookmarkTitle, getBookmarkSubtitle, getBookmarkIcon);
|
|
|
|
GUI.drawHelpText(renderer, Rect{contentX, pageHeight - hintGutterBottom, contentWidth, LINE_HEIGHT},
|
|
tr(STR_HOLD_OPEN_TO_DELETE));
|
|
}
|
|
}
|
|
|
|
const auto backLabel = confirmingDelete >= DELETE_MODE_DISPLAY ? tr(STR_CANCEL) : tr(STR_BACK);
|
|
const auto confirmLabel =
|
|
bookmarks.size() > 0 ? (confirmingDelete >= DELETE_MODE_DISPLAY ? tr(STR_DELETE) : tr(STR_SELECT)) : "";
|
|
const auto labels = mappedInput.mapLabels(backLabel, confirmLabel, tr(STR_DIR_UP), tr(STR_DIR_DOWN));
|
|
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
|
|
|
renderer.displayBuffer();
|
|
}
|