#include "EpubReaderBookmarksActivity.h" #include #include #include #include #include #include #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(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 (confirmingDelete < DELETE_MODE_DISPLAY) { // Vertical swipe page-scrolls the list (touch nav without the side buttons). const int pageItems = GUI.getListPageItems(getListHeight(renderer), true); if (mappedInput.wasListScroll(selectorIndex, static_cast(bookmarks.size()), pageItems)) { requestUpdate(); return; } int downId = -1; if (mappedInput.wasItemTouchedDown(downId) && downId >= 0 && downId < static_cast(bookmarks.size())) { selectorIndex = downId; requestUpdate(); } } int tappedId = -1; const bool tapped = (confirmingDelete < DELETE_MODE_DISPLAY) && mappedInput.wasItemTapped(tappedId); if (tapped && tappedId >= 0 && tappedId < static_cast(bookmarks.size())) selectorIndex = tappedId; if (tapped || 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(); }