feat: epub bookmarks (#1337)

Co-authored-by: vedi0boy <nate@origin8publishing.com>
Co-authored-by: Uri Tauber <uritaube@gmail.com>
This commit is contained in:
Nathanael Maher
2026-05-27 15:52:01 -04:00
committed by GitHub
co-authored by vedi0boy Uri Tauber
parent bbb3e06eb1
commit 36a3a0cc3a
26 changed files with 700 additions and 128 deletions
@@ -0,0 +1,224 @@
#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());
// pre-compute bookmark page values for quicker rendering
for (auto& bookmark : bookmarks) {
CrossPointPosition pos = ProgressMapper::toCrossPoint(epub, {bookmark.xpath, bookmark.percentage}, renderer);
bookmark.computedSpineIndex = pos.spineIndex;
bookmark.computedChapterPageCount = pos.totalPages;
bookmark.computedChapterProgress = pos.pageNumber;
}
}
} 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--;
}
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);
return std::to_string((int)bookmark.percentage) + "% - " + std::to_string(bookmark.computedChapterProgress) + "/" +
std::to_string(bookmark.computedChapterPageCount) + " - " + 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_CONFIRM_TO_DELETE));
}
} else {
GUI.drawHelpText(renderer, Rect{contentX, LINE_HEIGHT * 2, contentWidth, LINE_HEIGHT},
tr(STR_BOOKMARK_INSTRUCTIONS));
}
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_OPEN)) : "";
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();
}