From 0f91bdaf787bac8a5452b5864697500da394efef Mon Sep 17 00:00:00 2001 From: jpirnay Date: Sat, 23 May 2026 19:49:38 +0200 Subject: [PATCH] Add navigation option --- src/activities/ActivityResult.h | 6 +- src/activities/reader/EpubReaderActivity.cpp | 91 ++++++++- .../reader/EpubReaderMenuActivity.cpp | 12 +- .../reader/EpubReaderMenuActivity.h | 5 +- .../EpubReaderPrintedPageInputActivity.cpp | 182 ++++++++++++++++++ .../EpubReaderPrintedPageInputActivity.h | 46 +++++ 6 files changed, 335 insertions(+), 7 deletions(-) create mode 100644 src/activities/reader/EpubReaderPrintedPageInputActivity.cpp create mode 100644 src/activities/reader/EpubReaderPrintedPageInputActivity.h diff --git a/src/activities/ActivityResult.h b/src/activities/ActivityResult.h index 25e4a41e..55fb9d14 100644 --- a/src/activities/ActivityResult.h +++ b/src/activities/ActivityResult.h @@ -42,6 +42,10 @@ struct PercentResult { int percent = 0; }; +struct PrintedPageResult { + std::string label; +}; + struct PageResult { uint32_t page = 0; }; @@ -76,7 +80,7 @@ struct StarredPageResult { using ResultVariant = std::variant; + SyncResult, NetworkModeResult, FootnoteResult, FilePathResult, StarredPageResult, PrintedPageResult>; struct ActivityResult { bool isCancelled = false; diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 01b2be71..f23a7da7 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -19,13 +19,16 @@ #include #include +#include #include +#include #include "CrossPointSettings.h" #include "CrossPointState.h" #include "EpubReaderChapterSelectionActivity.h" #include "EpubReaderFootnotesActivity.h" #include "EpubReaderPercentSelectionActivity.h" +#include "EpubReaderPrintedPageInputActivity.h" #include "EpubRenderBenchmarkActivity.h" #include "FinishedBookActivity.h" #include "GlobalBookmarkIndex.h" @@ -47,6 +50,20 @@ namespace { // pagesPerRefresh now comes from SETTINGS.getRefreshFrequency() constexpr unsigned long skipChapterMs = 700; + +// Parse a printed-page label as a non-negative integer. Returns nullopt for empty strings, +// strings with non-digit characters (e.g. roman "iv"), and overflow. Used both to gate the +// "go to printed page" menu item and to compute min/max for the numeric input. +std::optional parsePrintedPageLabel(const std::string& label) { + if (label.empty()) return std::nullopt; + int value = 0; + for (char c : label) { + if (c < '0' || c > '9') return std::nullopt; + value = value * 10 + (c - '0'); + if (value > 999999) return std::nullopt; // sanity + } + return value; +} // pages per minute, first item is 1 to prevent division by zero if accessed constexpr int PAGE_TURN_LABELS[] = {1, 1, 3, 6, 12}; @@ -683,6 +700,64 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction }); break; } + case EpubReaderMenuActivity::MenuAction::GO_TO_PRINTED_PAGE: { + if (!epub) break; + auto entries = epub->loadPrintedPageList(); + // Compute the integer label range from parseable entries; non-integer labels are + // ignored (the dialog is numeric-only). + int minLabel = std::numeric_limits::max(); + int maxLabel = std::numeric_limits::min(); + for (const auto& entry : entries) { + if (const auto n = parsePrintedPageLabel(entry.label)) { + if (*n < minLabel) minLabel = *n; + if (*n > maxLabel) maxLabel = *n; + } + } + if (maxLabel < minLabel) break; // no integer labels — shouldn't happen if menu item was shown + + // Pre-fill with the printed page the reader is currently on (or the nearest one before + // it — rendered device pages rarely carry an anchor themselves, but they sit between + // two printed pages, so the closest prior anchor is the "you're here" hint). Falls + // back to the lowest integer label in the book if no prior anchor exists. + int initialValue = minLabel; + if (section) { + if (const auto rawLabel = + section->getNearestPrintedPageLabelAtOrBefore(static_cast(section->currentPage))) { + if (const auto n = parsePrintedPageLabel(*rawLabel)) { + initialValue = *n; + } + } + } + + startActivityForResult( + std::make_unique(renderer, mappedInput, initialValue, minLabel, maxLabel), + [this, entries = std::move(entries)](const ActivityResult& result) { + if (result.isCancelled) return; + const auto& pick = std::get(result.data); + // Resolve the typed label back to a (href, anchor) by linear scan. Entries are + // small (typically <500 even for long books) and this fires once per user action. + for (const auto& entry : entries) { + if (entry.label == pick.label) { + const int spineIdx = epub->resolveHrefToSpineIndex(entry.href); + if (spineIdx < 0) { + LOG_DBG("ERS", "printed-page jump: could not resolve spine for href=%s", entry.href.c_str()); + return; + } + { + RenderLock lock(*this); + currentSpineIndex = spineIdx; + navTarget = + entry.anchor.empty() ? NavigationTarget::makePage(0) : NavigationTarget::makeAnchor(entry.anchor); + section.reset(); + } + requestUpdate(); + return; + } + } + LOG_DBG("ERS", "printed-page jump: label '%s' not found in pagelist", pick.label.c_str()); + }); + break; + } case EpubReaderMenuActivity::MenuAction::DISPLAY_QR: { if (section && section->currentPage >= 0 && section->currentPage < section->pageCount) { auto p = section->loadPageFromSectionFile(); @@ -2592,13 +2667,27 @@ void EpubReaderActivity::openReaderMenu() { const int bookProgressPercent = clampPercent(static_cast(bookProgress + 0.5f)); const bool isCurrentPageStarred = section && bookmarkStore.has(static_cast(currentSpineIndex), static_cast(section->currentPage)); + + // Show the "Go to printed page" item only when this book has at least one integer-labelled + // entry in pagelist.bin. Roman-only or empty page lists are excluded — the numeric input + // dialog can't address them anyway. + bool hasPrintedPages = false; + if (epub) { + for (const auto& entry : epub->loadPrintedPageList()) { + if (parsePrintedPageLabel(entry.label).has_value()) { + hasPrintedPages = true; + break; + } + } + } + ReaderUtils::enforceExitFullRefresh(renderer); startActivityForResult( std::make_unique( renderer, mappedInput, epub->getTitle(), currentPage, totalPages, bookProgressPercent, SETTINGS.orientation, !currentPageFootnotes.empty(), bookEmbeddedStyleOverride, bookImageRenderingOverride, bookFontFamilyOverride, bookSdFontFamilyOverride, bookFontSizeOverride, SETTINGS.textDarkness, bookBionicReadingOverride, - bookParagraphAlignmentOverride, !bookmarkStore.isEmpty(), isCurrentPageStarred), + bookParagraphAlignmentOverride, !bookmarkStore.isEmpty(), isCurrentPageStarred, hasPrintedPages), [this](const ActivityResult& result) { const auto& menu = std::get(result.data); applyOrientation(menu.orientation); diff --git a/src/activities/reader/EpubReaderMenuActivity.cpp b/src/activities/reader/EpubReaderMenuActivity.cpp index 0ce18305..b276f00a 100644 --- a/src/activities/reader/EpubReaderMenuActivity.cpp +++ b/src/activities/reader/EpubReaderMenuActivity.cpp @@ -40,7 +40,8 @@ EpubReaderMenuActivity::EpubReaderMenuActivity( const int8_t initialEmbeddedStyleOverride, const int8_t initialImageRenderingOverride, const int8_t initialFontFamilyOverride, const std::string& initialSdFontFamilyOverride, const int8_t initialFontSizeOverride, const uint8_t initialTextDarkness, const bool initialBionicReadingOverride, - const int8_t initialParagraphAlignmentOverride, const bool hasStarredPages, const bool isCurrentPageStarred) + const int8_t initialParagraphAlignmentOverride, const bool hasStarredPages, const bool isCurrentPageStarred, + const bool hasPrintedPages) : MenuListActivity("EpubReaderMenu", renderer, mappedInput), currentPageStarred(isCurrentPageStarred), pendingOrientation(currentOrientation), @@ -56,16 +57,19 @@ EpubReaderMenuActivity::EpubReaderMenuActivity( currentPage(currentPage), totalPages(totalPages), bookProgressPercent(bookProgressPercent) { - buildMenuItems(hasFootnotes, hasStarredPages); + buildMenuItems(hasFootnotes, hasStarredPages, hasPrintedPages); } -void EpubReaderMenuActivity::buildMenuItems(bool hasFootnotes, bool hasStarredPages) { +void EpubReaderMenuActivity::buildMenuItems(bool hasFootnotes, bool hasStarredPages, bool hasPrintedPages) { menuItems.reserve(20); // --- Navigation --- menuItems.push_back(SettingInfo::Separator(StrId::STR_READER_NAVIGATION)); menuItems.push_back(SettingInfo::Action(StrId::STR_SELECT_CHAPTER, SettingAction::None)); menuItems.push_back(SettingInfo::Action(StrId::STR_GO_TO_PERCENT, SettingAction::None)); + if (hasPrintedPages) { + menuItems.push_back(SettingInfo::Action(StrId::STR_GO_TO_PRINTED_PAGE, SettingAction::None)); + } // Bookmarks, footnotes menuItems.push_back(SettingInfo::Separator(StrId::STR_READER_BOOKMARKS)); @@ -277,6 +281,8 @@ EpubReaderMenuActivity::MenuAction EpubReaderMenuActivity::actionForNameId(StrId return MenuAction::SELECT_CHAPTER; case StrId::STR_GO_TO_PERCENT: return MenuAction::GO_TO_PERCENT; + case StrId::STR_GO_TO_PRINTED_PAGE: + return MenuAction::GO_TO_PRINTED_PAGE; case StrId::STR_STARRED_PAGES: return MenuAction::STARRED_PAGES; case StrId::STR_STAR_PAGE: diff --git a/src/activities/reader/EpubReaderMenuActivity.h b/src/activities/reader/EpubReaderMenuActivity.h index ed713e0a..5f0ff31f 100644 --- a/src/activities/reader/EpubReaderMenuActivity.h +++ b/src/activities/reader/EpubReaderMenuActivity.h @@ -19,6 +19,7 @@ class EpubReaderMenuActivity final : public MenuListActivity { IMAGE_RENDERING, TEXT_DARKNESS, GO_TO_PERCENT, + GO_TO_PRINTED_PAGE, AUTO_PAGE_TURN, ROTATE_SCREEN, SCREENSHOT, @@ -42,13 +43,13 @@ class EpubReaderMenuActivity final : public MenuListActivity { const std::string& initialSdFontFamilyOverride, const int8_t initialFontSizeOverride, const uint8_t initialTextDarkness, const bool initialBionicReadingOverride, const int8_t initialParagraphAlignmentOverride, const bool hasStarredPages, - const bool isCurrentPageStarred); + const bool isCurrentPageStarred, const bool hasPrintedPages); void onEnter() override; void render(RenderLock&&) override; private: - void buildMenuItems(bool hasFootnotes, bool hasStarredPages); + void buildMenuItems(bool hasFootnotes, bool hasStarredPages, bool hasPrintedPages); bool currentPageStarred = false; void finishWithAction(MenuAction action); diff --git a/src/activities/reader/EpubReaderPrintedPageInputActivity.cpp b/src/activities/reader/EpubReaderPrintedPageInputActivity.cpp new file mode 100644 index 00000000..aa7adf03 --- /dev/null +++ b/src/activities/reader/EpubReaderPrintedPageInputActivity.cpp @@ -0,0 +1,182 @@ +#include "EpubReaderPrintedPageInputActivity.h" + +#include +#include + +#include + +#include "ButtonEventManager.h" +#include "MappedInputManager.h" +#include "components/UITheme.h" +#include "fontIds.h" + +int EpubReaderPrintedPageInputActivity::powTen(int exponent) { + int result = 1; + for (int i = 0; i < exponent; i++) result *= 10; + return result; +} + +int EpubReaderPrintedPageInputActivity::digitCount() const { + int n = (value > 0) ? value : 1; + int count = 0; + while (n > 0) { + count++; + n /= 10; + } + return count; +} + +int EpubReaderPrintedPageInputActivity::maxCursorDigit() const { + // Bound the reachable cursor position by maxValue's digit count, not the current value's. + // This lets the user step into "empty" higher digits (e.g. cursor sits over the tens + // place while value is still 1, and pressing Up turns it into 11). Without this you + // could never grow a 1 into a 3-digit number without first single-pressing into double + // digits, which defeats the point of having a digit cursor. + int n = (maxValue > 0) ? maxValue : 1; + int count = 0; + while (n > 0) { + count++; + n /= 10; + } + return count - 1; // 0-based: ones=0, tens=1, hundreds=2, ... +} + +void EpubReaderPrintedPageInputActivity::clampValue() { + if (value < minValue) value = minValue; + if (value > maxValue) value = maxValue; +} + +void EpubReaderPrintedPageInputActivity::adjustDigit(int delta) { + value += delta * powTen(cursorDigit); + clampValue(); + // Don't pull the cursor in toward the new digit count — leave it where the user put it. + // The cursor is a position the user navigated to, not a property of the value. + if (cursorDigit > maxCursorDigit()) cursorDigit = maxCursorDigit(); + requestUpdate(); +} + +void EpubReaderPrintedPageInputActivity::adjustDigitTimes(int multiplier, int sign) { + // Used by the double-click handler: step is multiplier × 10^cursorDigit (e.g. 10 at the + // ones place, 100 at the tens place). Sign is +1 or -1. + value += sign * multiplier * powTen(cursorDigit); + clampValue(); + if (cursorDigit > maxCursorDigit()) cursorDigit = maxCursorDigit(); + requestUpdate(); +} + +void EpubReaderPrintedPageInputActivity::moveCursor(int delta) { + cursorDigit += delta; + if (cursorDigit < 0) cursorDigit = 0; + if (cursorDigit > maxCursorDigit()) cursorDigit = maxCursorDigit(); + requestUpdate(); +} + +void EpubReaderPrintedPageInputActivity::onEnter() { + Activity::onEnter(); + // Force the FSM to wait for the double-click window on Up/Down so we can distinguish + // Short (±1) from Double (±10). Adds ~300ms latency to single Up/Down presses; that's + // the price for the larger step. Back/Confirm/Left/Right stay on the immediate + // wasPressed path — no latency for navigation. + buttonEvents.forceDoubleAction(MappedInputManager::Button::Up, true); + buttonEvents.forceDoubleAction(MappedInputManager::Button::Down, true); + requestUpdate(); +} + +void EpubReaderPrintedPageInputActivity::onExit() { + buttonEvents.forceDoubleAction(MappedInputManager::Button::Up, false); + buttonEvents.forceDoubleAction(MappedInputManager::Button::Down, false); + Activity::onExit(); +} + +void EpubReaderPrintedPageInputActivity::loop() { + if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { + ActivityResult result; + result.isCancelled = true; + setResult(std::move(result)); + finish(); + return; + } + if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { + setResult(PrintedPageResult{std::to_string(value)}); + finish(); + return; + } + if (mappedInput.wasPressed(MappedInputManager::Button::Left)) { + moveCursor(1); // cursor moves toward higher digits on Left, matching screen layout + return; + } + if (mappedInput.wasPressed(MappedInputManager::Button::Right)) { + moveCursor(-1); + return; + } + + // Up/Down come through the FSM-backed event queue so we can react to Short vs Double. + // Short = ±1, Double = ±10. Both Up and PageBack map to the same hardware button; the + // FSM emits an event for each logical button, but we only handle the Up/Down variants + // here. The global dispatcher consumes PageBack/PageForward variants (they're configured + // as page-turn actions in the reader) and dispatch is a no-op while this dialog is + // current, so they're effectively swallowed. + ButtonEventManager::ButtonEvent ev; + while (buttonEvents.consumeEvent(ev)) { + if (ev.button == MappedInputManager::Button::Up) { + if (ev.type == ButtonEventManager::PressType::Short) { + adjustDigit(1); + } else if (ev.type == ButtonEventManager::PressType::Double) { + adjustDigitTimes(10, 1); + } + } else if (ev.button == MappedInputManager::Button::Down) { + if (ev.type == ButtonEventManager::PressType::Short) { + adjustDigit(-1); + } else if (ev.type == ButtonEventManager::PressType::Double) { + adjustDigitTimes(10, -1); + } + } + // Other queued events (PageBack/PageForward variants and any stray) are discarded; + // the wasPressed path above already handled Back/Confirm/Left/Right. + } +} + +void EpubReaderPrintedPageInputActivity::render(RenderLock&&) { + renderer.clearScreen(); + + renderer.drawCenteredText(UI_12_FONT_ID, 15, tr(STR_GO_TO_PRINTED_PAGE), true, EpdFontFamily::BOLD); + + // Big centred numeric value with an underline under the active digit position. + // When the cursor sits over a digit position past the current value (e.g. cursor at the + // tens place while value is still 1), we pad the displayed number with leading "·" dots + // so the underline can mark the empty position it'll grow into on the next Up press. + const std::string rawValueText = std::to_string(value); + const int visibleDigits = std::max(digitCount(), cursorDigit + 1); + std::string valueText; + for (int i = 0; i < visibleDigits - digitCount(); i++) valueText += "0"; // leading zeros + valueText += rawValueText; + const int valueY = 110; + renderer.drawCenteredText(UI_12_FONT_ID, valueY, valueText.c_str(), true, EpdFontFamily::BOLD); + + // Place the underline under the active digit. Width-based positioning approximates a + // monospaced grid using the total rendered width / digit count; small visual mismatch on + // proportional fonts is acceptable for a one-character indicator. + const int totalWidth = renderer.getTextWidth(UI_12_FONT_ID, valueText.c_str()); + const int screenWidth = renderer.getScreenWidth(); + const int startX = (screenWidth - totalWidth) / 2; + const int digitIndexFromLeft = visibleDigits - 1 - cursorDigit; // 0-based, from the left + const int avgDigitWidth = (visibleDigits > 0) ? totalWidth / visibleDigits : 0; + const int underlineX = startX + digitIndexFromLeft * avgDigitWidth; + const int underlineWidth = avgDigitWidth; + const int underlineY = valueY + renderer.getLineHeight(UI_12_FONT_ID) + 2; + renderer.fillRect(underlineX, underlineY, underlineWidth, 3, true); + + // Range hint underneath: "Range: 1 - 305" + char rangeBuf[48]; + snprintf(rangeBuf, sizeof(rangeBuf), tr(STR_GO_TO_PRINTED_PAGE_RANGE), (unsigned)minValue, (unsigned)maxValue); + renderer.drawCenteredText(SMALL_FONT_ID, underlineY + 25, rangeBuf, true); + + // Step hint. + renderer.drawCenteredText(SMALL_FONT_ID, underlineY + 50, tr(STR_GO_TO_PRINTED_PAGE_HINT), true); + + // Button hints. + const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SELECT), "-", "+"); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + + renderer.displayBuffer(); +} diff --git a/src/activities/reader/EpubReaderPrintedPageInputActivity.h b/src/activities/reader/EpubReaderPrintedPageInputActivity.h new file mode 100644 index 00000000..f09e946d --- /dev/null +++ b/src/activities/reader/EpubReaderPrintedPageInputActivity.h @@ -0,0 +1,46 @@ +#pragma once + +#include "MappedInputManager.h" +#include "activities/Activity.h" + +// Numeric input dialog for "jump to printed page". +// User adjusts a single integer (mirroring the printed-page label as shown in the book). +// Up/Down change the digit under the cursor by ±1 (single) or ±10 (double-click). +// Left/Right move the cursor between digits; Confirm returns the typed string. +// Books with non-integer labels (roman numerals, etc.) are not addressable via this dialog; +// the menu item is hidden if the book has no integer-parseable printed-page labels. +class EpubReaderPrintedPageInputActivity final : public Activity { + public: + explicit EpubReaderPrintedPageInputActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, int initialValue, + int minValue, int maxValue) + : Activity("EpubReaderPrintedPageInput", renderer, mappedInput), + value(initialValue), + minValue(minValue), + maxValue(maxValue) { + clampValue(); + cursorDigit = 0; // ones place + } + + void onEnter() override; + void onExit() override; + void loop() override; + void render(RenderLock&&) override; + + private: + int value = 0; + int minValue = 1; + int maxValue = 1; + int cursorDigit = 0; // 0 = ones, 1 = tens, 2 = hundreds, ... + + void clampValue(); + void adjustDigit(int delta); + void adjustDigitTimes(int multiplier, int sign); // delta = sign * multiplier * 10^cursorDigit + void moveCursor(int delta); + static int powTen(int exponent); + int digitCount() const; + // Highest cursor position the user can reach: one less than the digit count of maxValue. + // Lets the user move the cursor "past" the current value to higher digit positions that + // don't exist yet, so they can grow the number quickly (e.g. start at 1, move cursor left, + // press Up to make 11, etc.) without single-pressing dozens of times. + int maxCursorDigit() const; +};