Add navigation option

This commit is contained in:
jpirnay
2026-05-23 19:49:38 +02:00
parent 3671bdba0b
commit 0f91bdaf78
6 changed files with 335 additions and 7 deletions
+5 -1
View File
@@ -42,6 +42,10 @@ struct PercentResult {
int percent = 0; int percent = 0;
}; };
struct PrintedPageResult {
std::string label;
};
struct PageResult { struct PageResult {
uint32_t page = 0; uint32_t page = 0;
}; };
@@ -76,7 +80,7 @@ struct StarredPageResult {
using ResultVariant = using ResultVariant =
std::variant<std::monostate, WifiResult, KeyboardResult, MenuResult, ChapterResult, PercentResult, PageResult, std::variant<std::monostate, WifiResult, KeyboardResult, MenuResult, ChapterResult, PercentResult, PageResult,
SyncResult, NetworkModeResult, FootnoteResult, FilePathResult, StarredPageResult>; SyncResult, NetworkModeResult, FootnoteResult, FilePathResult, StarredPageResult, PrintedPageResult>;
struct ActivityResult { struct ActivityResult {
bool isCancelled = false; bool isCancelled = false;
+90 -1
View File
@@ -19,13 +19,16 @@
#include <esp_system.h> #include <esp_system.h>
#include <algorithm> #include <algorithm>
#include <limits>
#include <memory> #include <memory>
#include <optional>
#include "CrossPointSettings.h" #include "CrossPointSettings.h"
#include "CrossPointState.h" #include "CrossPointState.h"
#include "EpubReaderChapterSelectionActivity.h" #include "EpubReaderChapterSelectionActivity.h"
#include "EpubReaderFootnotesActivity.h" #include "EpubReaderFootnotesActivity.h"
#include "EpubReaderPercentSelectionActivity.h" #include "EpubReaderPercentSelectionActivity.h"
#include "EpubReaderPrintedPageInputActivity.h"
#include "EpubRenderBenchmarkActivity.h" #include "EpubRenderBenchmarkActivity.h"
#include "FinishedBookActivity.h" #include "FinishedBookActivity.h"
#include "GlobalBookmarkIndex.h" #include "GlobalBookmarkIndex.h"
@@ -47,6 +50,20 @@
namespace { namespace {
// pagesPerRefresh now comes from SETTINGS.getRefreshFrequency() // pagesPerRefresh now comes from SETTINGS.getRefreshFrequency()
constexpr unsigned long skipChapterMs = 700; 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<int> 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 // pages per minute, first item is 1 to prevent division by zero if accessed
constexpr int PAGE_TURN_LABELS[] = {1, 1, 3, 6, 12}; constexpr int PAGE_TURN_LABELS[] = {1, 1, 3, 6, 12};
@@ -683,6 +700,64 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
}); });
break; 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<int>::max();
int maxLabel = std::numeric_limits<int>::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<uint16_t>(section->currentPage))) {
if (const auto n = parsePrintedPageLabel(*rawLabel)) {
initialValue = *n;
}
}
}
startActivityForResult(
std::make_unique<EpubReaderPrintedPageInputActivity>(renderer, mappedInput, initialValue, minLabel, maxLabel),
[this, entries = std::move(entries)](const ActivityResult& result) {
if (result.isCancelled) return;
const auto& pick = std::get<PrintedPageResult>(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: { case EpubReaderMenuActivity::MenuAction::DISPLAY_QR: {
if (section && section->currentPage >= 0 && section->currentPage < section->pageCount) { if (section && section->currentPage >= 0 && section->currentPage < section->pageCount) {
auto p = section->loadPageFromSectionFile(); auto p = section->loadPageFromSectionFile();
@@ -2592,13 +2667,27 @@ void EpubReaderActivity::openReaderMenu() {
const int bookProgressPercent = clampPercent(static_cast<int>(bookProgress + 0.5f)); const int bookProgressPercent = clampPercent(static_cast<int>(bookProgress + 0.5f));
const bool isCurrentPageStarred = section && bookmarkStore.has(static_cast<uint16_t>(currentSpineIndex), const bool isCurrentPageStarred = section && bookmarkStore.has(static_cast<uint16_t>(currentSpineIndex),
static_cast<uint16_t>(section->currentPage)); static_cast<uint16_t>(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); ReaderUtils::enforceExitFullRefresh(renderer);
startActivityForResult( startActivityForResult(
std::make_unique<EpubReaderMenuActivity>( std::make_unique<EpubReaderMenuActivity>(
renderer, mappedInput, epub->getTitle(), currentPage, totalPages, bookProgressPercent, SETTINGS.orientation, renderer, mappedInput, epub->getTitle(), currentPage, totalPages, bookProgressPercent, SETTINGS.orientation,
!currentPageFootnotes.empty(), bookEmbeddedStyleOverride, bookImageRenderingOverride, bookFontFamilyOverride, !currentPageFootnotes.empty(), bookEmbeddedStyleOverride, bookImageRenderingOverride, bookFontFamilyOverride,
bookSdFontFamilyOverride, bookFontSizeOverride, SETTINGS.textDarkness, bookBionicReadingOverride, bookSdFontFamilyOverride, bookFontSizeOverride, SETTINGS.textDarkness, bookBionicReadingOverride,
bookParagraphAlignmentOverride, !bookmarkStore.isEmpty(), isCurrentPageStarred), bookParagraphAlignmentOverride, !bookmarkStore.isEmpty(), isCurrentPageStarred, hasPrintedPages),
[this](const ActivityResult& result) { [this](const ActivityResult& result) {
const auto& menu = std::get<MenuResult>(result.data); const auto& menu = std::get<MenuResult>(result.data);
applyOrientation(menu.orientation); applyOrientation(menu.orientation);
@@ -40,7 +40,8 @@ EpubReaderMenuActivity::EpubReaderMenuActivity(
const int8_t initialEmbeddedStyleOverride, const int8_t initialImageRenderingOverride, const int8_t initialEmbeddedStyleOverride, const int8_t initialImageRenderingOverride,
const int8_t initialFontFamilyOverride, const std::string& initialSdFontFamilyOverride, const int8_t initialFontFamilyOverride, const std::string& initialSdFontFamilyOverride,
const int8_t initialFontSizeOverride, const uint8_t initialTextDarkness, const bool initialBionicReadingOverride, 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), : MenuListActivity("EpubReaderMenu", renderer, mappedInput),
currentPageStarred(isCurrentPageStarred), currentPageStarred(isCurrentPageStarred),
pendingOrientation(currentOrientation), pendingOrientation(currentOrientation),
@@ -56,16 +57,19 @@ EpubReaderMenuActivity::EpubReaderMenuActivity(
currentPage(currentPage), currentPage(currentPage),
totalPages(totalPages), totalPages(totalPages),
bookProgressPercent(bookProgressPercent) { 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); menuItems.reserve(20);
// --- Navigation --- // --- Navigation ---
menuItems.push_back(SettingInfo::Separator(StrId::STR_READER_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_SELECT_CHAPTER, SettingAction::None));
menuItems.push_back(SettingInfo::Action(StrId::STR_GO_TO_PERCENT, 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 // Bookmarks, footnotes
menuItems.push_back(SettingInfo::Separator(StrId::STR_READER_BOOKMARKS)); menuItems.push_back(SettingInfo::Separator(StrId::STR_READER_BOOKMARKS));
@@ -277,6 +281,8 @@ EpubReaderMenuActivity::MenuAction EpubReaderMenuActivity::actionForNameId(StrId
return MenuAction::SELECT_CHAPTER; return MenuAction::SELECT_CHAPTER;
case StrId::STR_GO_TO_PERCENT: case StrId::STR_GO_TO_PERCENT:
return MenuAction::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: case StrId::STR_STARRED_PAGES:
return MenuAction::STARRED_PAGES; return MenuAction::STARRED_PAGES;
case StrId::STR_STAR_PAGE: case StrId::STR_STAR_PAGE:
@@ -19,6 +19,7 @@ class EpubReaderMenuActivity final : public MenuListActivity {
IMAGE_RENDERING, IMAGE_RENDERING,
TEXT_DARKNESS, TEXT_DARKNESS,
GO_TO_PERCENT, GO_TO_PERCENT,
GO_TO_PRINTED_PAGE,
AUTO_PAGE_TURN, AUTO_PAGE_TURN,
ROTATE_SCREEN, ROTATE_SCREEN,
SCREENSHOT, SCREENSHOT,
@@ -42,13 +43,13 @@ class EpubReaderMenuActivity final : public MenuListActivity {
const std::string& initialSdFontFamilyOverride, const int8_t initialFontSizeOverride, const std::string& initialSdFontFamilyOverride, const int8_t initialFontSizeOverride,
const uint8_t initialTextDarkness, const bool initialBionicReadingOverride, const uint8_t initialTextDarkness, const bool initialBionicReadingOverride,
const int8_t initialParagraphAlignmentOverride, const bool hasStarredPages, const int8_t initialParagraphAlignmentOverride, const bool hasStarredPages,
const bool isCurrentPageStarred); const bool isCurrentPageStarred, const bool hasPrintedPages);
void onEnter() override; void onEnter() override;
void render(RenderLock&&) override; void render(RenderLock&&) override;
private: private:
void buildMenuItems(bool hasFootnotes, bool hasStarredPages); void buildMenuItems(bool hasFootnotes, bool hasStarredPages, bool hasPrintedPages);
bool currentPageStarred = false; bool currentPageStarred = false;
void finishWithAction(MenuAction action); void finishWithAction(MenuAction action);
@@ -0,0 +1,182 @@
#include "EpubReaderPrintedPageInputActivity.h"
#include <GfxRenderer.h>
#include <I18n.h>
#include <algorithm>
#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();
}
@@ -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;
};