#include "EpubReaderBookmarksActivity.h" #include #include #include #include #include #include #include "MappedInputManager.h" #include "components/UITheme.h" #include "fontIds.h" namespace { constexpr int ENTER_DELETE_MODE_MS = 700; // 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(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() { auto openBookmark = [this] { if (bookmarks.empty()) { return; } auto bookmark = bookmarks.at(selectorIndex); ProgressChangeResult result{}; result.xpath = bookmark.xpath; result.percentage = bookmark.percentage; result.hasSavedProgress = true; if (bookmark.computedChapterPageCount > 0 && bookmark.computedChapterProgress < bookmark.computedChapterPageCount && bookmark.computedSpineIndex < epub->getSpineItemsCount()) { result.spineIndex = bookmark.computedSpineIndex; result.page = bookmark.computedChapterProgress; result.totalPages = bookmark.computedChapterPageCount; } setResult(std::move(result)); finish(); }; // Delete confirmation popup if (confirmPopup.handleInput(mappedInput, [this] { requestUpdate(); })) return; if (confirmingDelete) { // Popup dismissed without a selection (Back button or tap outside): cancel delete confirmingDelete = false; requestUpdate(); return; } if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { ActivityResult result; result.isCancelled = true; setResult(std::move(result)); finish(); return; } const auto orientation = renderer.getOrientation(); const bool isPortraitInverted = orientation == GfxRenderer::Orientation::PortraitInverted; const bool isLandscapeCw = orientation == GfxRenderer::Orientation::LandscapeClockwise; const bool isLandscapeCcw = orientation == GfxRenderer::Orientation::LandscapeCounterClockwise; const int hintGutterWidth = (isLandscapeCw || isLandscapeCcw) ? 40 : 0; const int contentX = isLandscapeCw ? hintGutterWidth : 0; const int contentWidth = renderer.getScreenWidth() - hintGutterWidth; const int contentY = isPortraitInverted ? 50 : 0; const int listY = contentY + LINE_HEIGHT; const int listHeight = getListHeight(renderer); int tapped = 0; int tx = 0; int ty = 0; if (mappedInput.wasScreenTouchDown(tx, ty) && tx >= contentX && tx < contentX + contentWidth && mappedInput.wasListItemTouchedDown(tapped, static_cast(bookmarks.size()), selectorIndex, listY, listHeight, true)) { if (selectorIndex != tapped) { selectorIndex = tapped; requestUpdate(); } return; } if (mappedInput.wasScreenTapped(tx, ty) && tx >= contentX && tx < contentX + contentWidth && mappedInput.wasListItemTapped(tapped, static_cast(bookmarks.size()), selectorIndex, listY, listHeight, true)) { selectorIndex = tapped; openBookmark(); return; } const auto swipe = mappedInput.wasSwipe(); if (swipe == MappedInputManager::SwipeDir::Up && !bookmarks.empty()) { selectorIndex = ButtonNavigator::nextPageIndex(selectorIndex, bookmarks.size(), GUI.getListPageItems(listHeight, true)); requestUpdate(); return; } if (swipe == MappedInputManager::SwipeDir::Down && !bookmarks.empty()) { selectorIndex = ButtonNavigator::previousPageIndex(selectorIndex, bookmarks.size(), GUI.getListPageItems(listHeight, true)); requestUpdate(); return; } if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { // Open openBookmark(); return; } if (mappedInput.isPressed(MappedInputManager::Button::Confirm) && mappedInput.getHeldTime() > ENTER_DELETE_MODE_MS) { if (bookmarks.empty()) { return; } confirmingDelete = true; const char* options[] = {tr(STR_CANCEL), tr(STR_DELETE)}; confirmPopup.show(tr(STR_CONFIRM_DELETE_BOOKMARK), options, 2, 0, [this](int idx) { confirmingDelete = false; if (idx == 1) { deleteSelectedBookmark(); } requestUpdate(); }); 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::deleteSelectedBookmark() { 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(); } } 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 ? selectorIndex : index).summary; }; const auto getBookmarkSubtitle = [this](int index) { auto bookmark = bookmarks.at(confirmingDelete ? 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) { // Render just the selected item near the top; the confirmation popup occupies the center GUI.drawList(renderer, Rect{contentX, listY, 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)); } } if (confirmPopup.processRender(renderer, mappedInput)) return; const auto confirmLabel = bookmarks.size() > 0 ? tr(STR_SELECT) : ""; const auto labels = mappedInput.mapLabels(tr(STR_BACK), confirmLabel, tr(STR_DIR_UP), tr(STR_DIR_DOWN)); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); renderer.displayBuffer(); }