feat: Go To Position for epubs (#666)

## Summary

* Adds Go To % action in Epub Reader menu with slider style percent
selector

<img width="860" height="1147" alt="image"
src="https://github.com/user-attachments/assets/a38ecc71-429e-40e8-94ac-37fb1509dbd9"
/>

---

### 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>
This commit is contained in:
Arthur Tazhitdinov 2026-02-05 15:17:51 +03:00 committed by GitHub
parent 17fedd2a69
commit ddbe49f536
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 332 additions and 5 deletions

View File

@ -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<float>(section->currentPage) / static_cast<float>(section->pageCount);
bookProgress = epub->calculateProgress(currentSpineIndex, chapterProgress) * 100.0f;
}
const int bookProgressPercent = clampPercent(static_cast<int>(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<size_t>(percent) + (bookSize % 100) * static_cast<size_t>(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<float>(targetSize - prevCumulative) / static_cast<float>(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<float>(section->currentPage) / static_cast<float>(section->pageCount);
bookProgress = epub->calculateProgress(currentSpineIndex, chapterProgress) * 100.0f;
}
const int initialPercent = clampPercent(static_cast<int>(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<int>(pendingSpineProgress * static_cast<float>(section->pageCount));
if (newPage >= section->pageCount) {
newPage = section->pageCount - 1;
}
section->currentPage = newPage;
pendingPercentJump = false;
}
}
renderer.clearScreen();

View File

@ -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<void()> onGoBack;
const std::function<void()> 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);

View File

@ -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) {

View File

@ -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<void(uint8_t)>& onBack,
const std::function<void(MenuAction)>& 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<MenuItem> 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<const char*> orientationLabels = {"Portrait", "Landscape CW", "Inverted", "Landscape CCW"};
int currentPage = 0;
int totalPages = 0;
int bookProgressPercent = 0;
const std::function<void(uint8_t)> onBack;
const std::function<void(MenuAction)> onAction;

View File

@ -0,0 +1,139 @@
#include "EpubReaderPercentSelectionActivity.h"
#include <GfxRenderer.h>
#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<EpubReaderPercentSelectionActivity*>(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();
}

View File

@ -0,0 +1,46 @@
#pragma once
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <freertos/task.h>
#include <functional>
#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<void(int)>& onSelect,
const std::function<void()>& 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<void(int)> onSelect;
// Callback invoked when the user cancels the slider.
const std::function<void()> 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);
};