From ddbe49f5363d23018fb5c95c01f0c75cdb0b4ed0 Mon Sep 17 00:00:00 2001 From: Arthur Tazhitdinov Date: Thu, 5 Feb 2026 15:17:51 +0300 Subject: [PATCH] feat: Go To Position for epubs (#666) ## Summary * Adds Go To % action in Epub Reader menu with slider style percent selector image --- ### AI Usage While CrossPoint doesn't have restrictions on AI tools in contributing, please be transparent about their usage as it helps set the right context for reviewers. Did you use AI tools to help write this code? _**< PARTIALLY >**_ --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/activities/reader/EpubReaderActivity.cpp | 123 +++++++++++++++- src/activities/reader/EpubReaderActivity.h | 7 + .../reader/EpubReaderMenuActivity.cpp | 10 +- .../reader/EpubReaderMenuActivity.h | 12 +- .../EpubReaderPercentSelectionActivity.cpp | 139 ++++++++++++++++++ .../EpubReaderPercentSelectionActivity.h | 46 ++++++ 6 files changed, 332 insertions(+), 5 deletions(-) create mode 100644 src/activities/reader/EpubReaderPercentSelectionActivity.cpp create mode 100644 src/activities/reader/EpubReaderPercentSelectionActivity.h diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 0c3a8aba..f7c3d888 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -8,6 +8,7 @@ #include "CrossPointSettings.h" #include "CrossPointState.h" #include "EpubReaderChapterSelectionActivity.h" +#include "EpubReaderPercentSelectionActivity.h" #include "MappedInputManager.h" #include "RecentBooksStore.h" #include "components/UITheme.h" @@ -20,6 +21,16 @@ constexpr unsigned long goHomeMs = 1000; constexpr int statusBarMargin = 19; constexpr int progressBarMarginTop = 1; +int clampPercent(int percent) { + if (percent < 0) { + return 0; + } + if (percent > 100) { + return 100; + } + return percent; +} + // Apply the logical reader orientation to the renderer. // This centralizes orientation mapping so we don't duplicate switch logic elsewhere. void applyReaderOrientation(GfxRenderer& renderer, const uint8_t orientation) { @@ -132,14 +143,22 @@ void EpubReaderActivity::loop() { return; } - // Enter chapter selection activity + // Enter reader menu activity. if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { // Don't start activity transition while rendering xSemaphoreTake(renderingMutex, portMAX_DELAY); + const int currentPage = section ? section->currentPage + 1 : 0; + const int totalPages = section ? section->pageCount : 0; + float bookProgress = 0.0f; + if (epub && epub->getBookSize() > 0 && section && section->pageCount > 0) { + const float chapterProgress = static_cast(section->currentPage) / static_cast(section->pageCount); + bookProgress = epub->calculateProgress(currentSpineIndex, chapterProgress) * 100.0f; + } + const int bookProgressPercent = clampPercent(static_cast(bookProgress + 0.5f)); exitActivity(); enterNewActivity(new EpubReaderMenuActivity( - this->renderer, this->mappedInput, epub->getTitle(), SETTINGS.orientation, - [this](const uint8_t orientation) { onReaderMenuBack(orientation); }, + this->renderer, this->mappedInput, epub->getTitle(), currentPage, totalPages, bookProgressPercent, + SETTINGS.orientation, [this](const uint8_t orientation) { onReaderMenuBack(orientation); }, [this](EpubReaderMenuActivity::MenuAction action) { onReaderMenuConfirm(action); })); xSemaphoreGive(renderingMutex); } @@ -236,6 +255,68 @@ void EpubReaderActivity::onReaderMenuBack(const uint8_t orientation) { updateRequired = true; } +// Translate an absolute percent into a spine index plus a normalized position +// within that spine so we can jump after the section is loaded. +void EpubReaderActivity::jumpToPercent(int percent) { + if (!epub) { + return; + } + + const size_t bookSize = epub->getBookSize(); + if (bookSize == 0) { + return; + } + + // Normalize input to 0-100 to avoid invalid jumps. + percent = clampPercent(percent); + + // Convert percent into a byte-like absolute position across the spine sizes. + // Use an overflow-safe computation: (bookSize / 100) * percent + (bookSize % 100) * percent / 100 + size_t targetSize = + (bookSize / 100) * static_cast(percent) + (bookSize % 100) * static_cast(percent) / 100; + if (percent >= 100) { + // Ensure the final percent lands inside the last spine item. + targetSize = bookSize - 1; + } + + const int spineCount = epub->getSpineItemsCount(); + if (spineCount == 0) { + return; + } + + int targetSpineIndex = spineCount - 1; + size_t prevCumulative = 0; + + for (int i = 0; i < spineCount; i++) { + const size_t cumulative = epub->getCumulativeSpineItemSize(i); + if (targetSize <= cumulative) { + // Found the spine item containing the absolute position. + targetSpineIndex = i; + prevCumulative = (i > 0) ? epub->getCumulativeSpineItemSize(i - 1) : 0; + break; + } + } + + const size_t cumulative = epub->getCumulativeSpineItemSize(targetSpineIndex); + const size_t spineSize = (cumulative > prevCumulative) ? (cumulative - prevCumulative) : 0; + // Store a normalized position within the spine so it can be applied once loaded. + pendingSpineProgress = + (spineSize == 0) ? 0.0f : static_cast(targetSize - prevCumulative) / static_cast(spineSize); + if (pendingSpineProgress < 0.0f) { + pendingSpineProgress = 0.0f; + } else if (pendingSpineProgress > 1.0f) { + pendingSpineProgress = 1.0f; + } + + // Reset state so renderScreen() reloads and repositions on the target spine. + xSemaphoreTake(renderingMutex, portMAX_DELAY); + currentSpineIndex = targetSpineIndex; + nextPageNumber = 0; + pendingPercentJump = true; + section.reset(); + xSemaphoreGive(renderingMutex); +} + void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action) { switch (action) { case EpubReaderMenuActivity::MenuAction::SELECT_CHAPTER: { @@ -279,6 +360,32 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction xSemaphoreGive(renderingMutex); break; } + case EpubReaderMenuActivity::MenuAction::GO_TO_PERCENT: { + // Launch the slider-based percent selector and return here on confirm/cancel. + float bookProgress = 0.0f; + if (epub && epub->getBookSize() > 0 && section && section->pageCount > 0) { + const float chapterProgress = static_cast(section->currentPage) / static_cast(section->pageCount); + bookProgress = epub->calculateProgress(currentSpineIndex, chapterProgress) * 100.0f; + } + const int initialPercent = clampPercent(static_cast(bookProgress + 0.5f)); + xSemaphoreTake(renderingMutex, portMAX_DELAY); + exitActivity(); + enterNewActivity(new EpubReaderPercentSelectionActivity( + renderer, mappedInput, initialPercent, + [this](const int percent) { + // Apply the new position and exit back to the reader. + jumpToPercent(percent); + exitActivity(); + updateRequired = true; + }, + [this]() { + // Cancel selection and return to the reader. + exitActivity(); + updateRequired = true; + })); + xSemaphoreGive(renderingMutex); + break; + } case EpubReaderMenuActivity::MenuAction::GO_HOME: { // 2. Trigger the reader's "Go Home" callback if (onGoHome) { @@ -437,6 +544,16 @@ void EpubReaderActivity::renderScreen() { } cachedChapterTotalPageCount = 0; // resets to 0 to prevent reading cached progress again } + + if (pendingPercentJump && section->pageCount > 0) { + // Apply the pending percent jump now that we know the new section's page count. + int newPage = static_cast(pendingSpineProgress * static_cast(section->pageCount)); + if (newPage >= section->pageCount) { + newPage = section->pageCount - 1; + } + section->currentPage = newPage; + pendingPercentJump = false; + } } renderer.clearScreen(); diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index 24d58e9c..c1738871 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -18,6 +18,11 @@ class EpubReaderActivity final : public ActivityWithSubactivity { int pagesUntilFullRefresh = 0; int cachedSpineIndex = 0; int cachedChapterTotalPageCount = 0; + // Signals that the next render should reposition within the newly loaded section + // based on a cross-book percentage jump. + bool pendingPercentJump = false; + // Normalized 0.0-1.0 progress within the target spine item, computed from book percentage. + float pendingSpineProgress = 0.0f; bool updateRequired = false; const std::function onGoBack; const std::function onGoHome; @@ -29,6 +34,8 @@ class EpubReaderActivity final : public ActivityWithSubactivity { int orientedMarginBottom, int orientedMarginLeft); void renderStatusBar(int orientedMarginRight, int orientedMarginBottom, int orientedMarginLeft) const; void saveProgress(int spineIndex, int currentPage, int pageCount); + // Jump to a percentage of the book (0-100), mapping it to spine and page. + void jumpToPercent(int percent); void onReaderMenuBack(uint8_t orientation); void onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action); void applyOrientation(uint8_t orientation); diff --git a/src/activities/reader/EpubReaderMenuActivity.cpp b/src/activities/reader/EpubReaderMenuActivity.cpp index 315536f5..96307230 100644 --- a/src/activities/reader/EpubReaderMenuActivity.cpp +++ b/src/activities/reader/EpubReaderMenuActivity.cpp @@ -106,8 +106,16 @@ void EpubReaderMenuActivity::renderScreen() { contentX + (contentWidth - renderer.getTextWidth(UI_12_FONT_ID, truncTitle.c_str(), EpdFontFamily::BOLD)) / 2; renderer.drawText(UI_12_FONT_ID, titleX, 15 + contentY, truncTitle.c_str(), true, EpdFontFamily::BOLD); + // Progress summary + std::string progressLine; + if (totalPages > 0) { + progressLine = "Chapter: " + std::to_string(currentPage) + "/" + std::to_string(totalPages) + " pages | "; + } + progressLine += "Book: " + std::to_string(bookProgressPercent) + "%"; + renderer.drawCenteredText(UI_10_FONT_ID, 45, progressLine.c_str()); + // Menu Items - const int startY = 60 + contentY; + const int startY = 75 + contentY; constexpr int lineHeight = 30; for (size_t i = 0; i < menuItems.size(); ++i) { diff --git a/src/activities/reader/EpubReaderMenuActivity.h b/src/activities/reader/EpubReaderMenuActivity.h index 7e783ad0..b91d1a28 100644 --- a/src/activities/reader/EpubReaderMenuActivity.h +++ b/src/activities/reader/EpubReaderMenuActivity.h @@ -13,14 +13,19 @@ class EpubReaderMenuActivity final : public ActivityWithSubactivity { public: - enum class MenuAction { SELECT_CHAPTER, ROTATE_SCREEN, GO_HOME, DELETE_CACHE }; + // Menu actions available from the reader menu. + enum class MenuAction { SELECT_CHAPTER, GO_TO_PERCENT, ROTATE_SCREEN, GO_HOME, DELETE_CACHE }; explicit EpubReaderMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& title, + const int currentPage, const int totalPages, const int bookProgressPercent, const uint8_t currentOrientation, const std::function& onBack, const std::function& onAction) : ActivityWithSubactivity("EpubReaderMenu", renderer, mappedInput), title(title), pendingOrientation(currentOrientation), + currentPage(currentPage), + totalPages(totalPages), + bookProgressPercent(bookProgressPercent), onBack(onBack), onAction(onAction) {} @@ -34,8 +39,10 @@ class EpubReaderMenuActivity final : public ActivityWithSubactivity { std::string label; }; + // Fixed menu layout (order matters for up/down navigation). const std::vector menuItems = {{MenuAction::SELECT_CHAPTER, "Go to Chapter"}, {MenuAction::ROTATE_SCREEN, "Reading Orientation"}, + {MenuAction::GO_TO_PERCENT, "Go to %"}, {MenuAction::GO_HOME, "Go Home"}, {MenuAction::DELETE_CACHE, "Delete Book Cache"}}; @@ -46,6 +53,9 @@ class EpubReaderMenuActivity final : public ActivityWithSubactivity { std::string title = "Reader Menu"; uint8_t pendingOrientation = 0; const std::vector orientationLabels = {"Portrait", "Landscape CW", "Inverted", "Landscape CCW"}; + int currentPage = 0; + int totalPages = 0; + int bookProgressPercent = 0; const std::function onBack; const std::function onAction; diff --git a/src/activities/reader/EpubReaderPercentSelectionActivity.cpp b/src/activities/reader/EpubReaderPercentSelectionActivity.cpp new file mode 100644 index 00000000..74dd5229 --- /dev/null +++ b/src/activities/reader/EpubReaderPercentSelectionActivity.cpp @@ -0,0 +1,139 @@ +#include "EpubReaderPercentSelectionActivity.h" + +#include + +#include "MappedInputManager.h" +#include "components/UITheme.h" +#include "fontIds.h" + +namespace { +// Fine/coarse slider step sizes for percent adjustments. +constexpr int kSmallStep = 1; +constexpr int kLargeStep = 10; +} // namespace + +void EpubReaderPercentSelectionActivity::onEnter() { + ActivityWithSubactivity::onEnter(); + // Set up rendering task and mark first frame dirty. + renderingMutex = xSemaphoreCreateMutex(); + updateRequired = true; + xTaskCreate(&EpubReaderPercentSelectionActivity::taskTrampoline, "EpubPercentSlider", 4096, this, 1, + &displayTaskHandle); +} + +void EpubReaderPercentSelectionActivity::onExit() { + ActivityWithSubactivity::onExit(); + // Ensure the render task is stopped before freeing the mutex. + xSemaphoreTake(renderingMutex, portMAX_DELAY); + if (displayTaskHandle) { + vTaskDelete(displayTaskHandle); + displayTaskHandle = nullptr; + } + vSemaphoreDelete(renderingMutex); + renderingMutex = nullptr; +} + +void EpubReaderPercentSelectionActivity::taskTrampoline(void* param) { + auto* self = static_cast(param); + self->displayTaskLoop(); +} + +void EpubReaderPercentSelectionActivity::displayTaskLoop() { + while (true) { + // Render only when the view is dirty and no subactivity is running. + if (updateRequired && !subActivity) { + updateRequired = false; + xSemaphoreTake(renderingMutex, portMAX_DELAY); + renderScreen(); + xSemaphoreGive(renderingMutex); + } + vTaskDelay(10 / portTICK_PERIOD_MS); + } +} + +void EpubReaderPercentSelectionActivity::adjustPercent(const int delta) { + // Apply delta and clamp within 0-100. + percent += delta; + if (percent < 0) { + percent = 0; + } else if (percent > 100) { + percent = 100; + } + updateRequired = true; +} + +void EpubReaderPercentSelectionActivity::loop() { + if (subActivity) { + subActivity->loop(); + return; + } + + // Back cancels, confirm selects, arrows adjust the percent. + if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { + onCancel(); + return; + } + + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + onSelect(percent); + return; + } + + if (mappedInput.wasReleased(MappedInputManager::Button::Left)) { + adjustPercent(-kSmallStep); + return; + } + + if (mappedInput.wasReleased(MappedInputManager::Button::Right)) { + adjustPercent(kSmallStep); + return; + } + + if (mappedInput.wasReleased(MappedInputManager::Button::Up)) { + adjustPercent(kLargeStep); + return; + } + + if (mappedInput.wasReleased(MappedInputManager::Button::Down)) { + adjustPercent(-kLargeStep); + return; + } +} + +void EpubReaderPercentSelectionActivity::renderScreen() { + renderer.clearScreen(); + + // Title and numeric percent value. + renderer.drawCenteredText(UI_12_FONT_ID, 15, "Go to Position", true, EpdFontFamily::BOLD); + + const std::string percentText = std::to_string(percent) + "%"; + renderer.drawCenteredText(UI_12_FONT_ID, 90, percentText.c_str(), true, EpdFontFamily::BOLD); + + // Draw slider track. + const int screenWidth = renderer.getScreenWidth(); + constexpr int barWidth = 360; + constexpr int barHeight = 16; + const int barX = (screenWidth - barWidth) / 2; + const int barY = 140; + + renderer.drawRect(barX, barY, barWidth, barHeight); + + // Fill slider based on percent. + const int fillWidth = (barWidth - 4) * percent / 100; + if (fillWidth > 0) { + renderer.fillRect(barX + 2, barY + 2, fillWidth, barHeight - 4); + } + + // Draw a simple knob centered at the current percent. + const int knobX = barX + 2 + fillWidth - 2; + renderer.fillRect(knobX, barY - 4, 4, barHeight + 8, true); + + // Hint text for step sizes. + renderer.drawCenteredText(SMALL_FONT_ID, barY + 30, "Left/Right: 1% Up/Down: 10%", true); + + // Button hints follow the current front button layout. + const auto labels = mappedInput.mapLabels("« Back", "Select", "-", "+"); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + + renderer.displayBuffer(); +} diff --git a/src/activities/reader/EpubReaderPercentSelectionActivity.h b/src/activities/reader/EpubReaderPercentSelectionActivity.h new file mode 100644 index 00000000..56238935 --- /dev/null +++ b/src/activities/reader/EpubReaderPercentSelectionActivity.h @@ -0,0 +1,46 @@ +#pragma once +#include +#include +#include + +#include + +#include "MappedInputManager.h" +#include "activities/ActivityWithSubactivity.h" + +class EpubReaderPercentSelectionActivity final : public ActivityWithSubactivity { + public: + // Slider-style percent selector for jumping within a book. + explicit EpubReaderPercentSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, + const int initialPercent, const std::function& onSelect, + const std::function& onCancel) + : ActivityWithSubactivity("EpubReaderPercentSelection", renderer, mappedInput), + percent(initialPercent), + onSelect(onSelect), + onCancel(onCancel) {} + + void onEnter() override; + void onExit() override; + void loop() override; + + private: + // Current percent value (0-100) shown on the slider. + int percent = 0; + // Render dirty flag for the task loop. + bool updateRequired = false; + // FreeRTOS task and mutex for rendering. + TaskHandle_t displayTaskHandle = nullptr; + SemaphoreHandle_t renderingMutex = nullptr; + + // Callback invoked when the user confirms a percent. + const std::function onSelect; + // Callback invoked when the user cancels the slider. + const std::function onCancel; + + static void taskTrampoline(void* param); + [[noreturn]] void displayTaskLoop(); + // Render the slider UI. + void renderScreen(); + // Change the current percent by a delta and clamp within bounds. + void adjustPercent(int delta); +};