feat: Add percent selection feature for jumping within the book

This commit is contained in:
Arthur Tazhitdinov 2026-02-02 18:06:41 +03:00
parent e5c0ddc9fa
commit 606b20d9d0
5 changed files with 339 additions and 2 deletions

View File

@ -5,12 +5,14 @@
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <SDCardManager.h> #include <SDCardManager.h>
#include "CrossPointSettings.h" #include "CrossPointSettings.h"
#include "CrossPointState.h" #include "CrossPointState.h"
#include "EpubReaderChapterSelectionActivity.h" #include "EpubReaderChapterSelectionActivity.h"
#include "MappedInputManager.h" #include "MappedInputManager.h"
#include "RecentBooksStore.h" #include "RecentBooksStore.h"
#include "ScreenComponents.h" #include "ScreenComponents.h"
#include "activities/reader/EpubReaderPercentSelectionActivity.h"
#include "fontIds.h" #include "fontIds.h"
namespace { namespace {
@ -22,6 +24,17 @@ constexpr int progressBarMarginTop = 1;
} // namespace } // namespace
// Clamp any percent-like value into the valid 0-100 range.
static int clampPercent(const int value) {
if (value < 0) {
return 0;
}
if (value > 100) {
return 100;
}
return value;
}
void EpubReaderActivity::taskTrampoline(void* param) { void EpubReaderActivity::taskTrampoline(void* param) {
auto* self = static_cast<EpubReaderActivity*>(param); auto* self = static_cast<EpubReaderActivity*>(param);
self->displayTaskLoop(); self->displayTaskLoop();
@ -123,7 +136,19 @@ void EpubReaderActivity::loop() {
return; return;
} }
// Enter chapter selection activity // Enter reader menu activity (suppressed after slider confirm/cancel).
if (suppressMenuOpenOnce) {
// If we're seeing the confirm release that closed the slider, consume it and return.
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
suppressMenuOpenOnce = false;
return;
}
// If confirm is no longer pressed and no release is pending, clear suppression.
if (!mappedInput.isPressed(MappedInputManager::Button::Confirm)) {
suppressMenuOpenOnce = false;
}
}
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
// Don't start activity transition while rendering // Don't start activity transition while rendering
xSemaphoreTake(renderingMutex, portMAX_DELAY); xSemaphoreTake(renderingMutex, portMAX_DELAY);
@ -225,6 +250,85 @@ void EpubReaderActivity::onReaderMenuBack() {
updateRequired = true; 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.
size_t targetSize = (bookSize * static_cast<size_t>(percent)) / 100;
if (percent >= 100 && bookSize > 0) {
// 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);
}
// Compute the overall reading position as a percent of the book.
int EpubReaderActivity::getCurrentPercent() const {
if (!epub || epub->getBookSize() == 0) {
return 0;
}
// Estimate within-spine progress based on the current page.
float chapterProgress = 0.0f;
if (section && section->pageCount > 0) {
chapterProgress = static_cast<float>(section->currentPage) / static_cast<float>(section->pageCount);
}
// Convert to overall progress using cumulative spine sizes.
const float progress = epub->calculateProgress(currentSpineIndex, chapterProgress);
const int percent = static_cast<int>(progress * 100.0f + 0.5f);
return clampPercent(percent);
}
void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action) { void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action) {
switch (action) { switch (action) {
case EpubReaderMenuActivity::MenuAction::SELECT_CHAPTER: { case EpubReaderMenuActivity::MenuAction::SELECT_CHAPTER: {
@ -268,6 +372,29 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
xSemaphoreGive(renderingMutex); xSemaphoreGive(renderingMutex);
break; break;
} }
case EpubReaderMenuActivity::MenuAction::GO_TO_PERCENT: {
// Launch the slider-based percent selector and return here on confirm/cancel.
const int initialPercent = getCurrentPercent();
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);
suppressMenuOpenOnce = true;
exitActivity();
updateRequired = true;
},
[this]() {
// Cancel selection and return to the reader.
suppressMenuOpenOnce = true;
exitActivity();
updateRequired = true;
}));
xSemaphoreGive(renderingMutex);
break;
}
case EpubReaderMenuActivity::MenuAction::GO_HOME: { case EpubReaderMenuActivity::MenuAction::GO_HOME: {
// 2. Trigger the reader's "Go Home" callback // 2. Trigger the reader's "Go Home" callback
if (onGoHome) { if (onGoHome) {
@ -398,6 +525,18 @@ void EpubReaderActivity::renderScreen() {
} }
cachedChapterTotalPageCount = 0; // resets to 0 to prevent reading cached progress again 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 < 0) {
newPage = 0;
} else if (newPage >= section->pageCount) {
newPage = section->pageCount - 1;
}
section->currentPage = newPage;
pendingPercentJump = false;
}
} }
renderer.clearScreen(); renderer.clearScreen();

View File

@ -18,6 +18,13 @@ class EpubReaderActivity final : public ActivityWithSubactivity {
int pagesUntilFullRefresh = 0; int pagesUntilFullRefresh = 0;
int cachedSpineIndex = 0; int cachedSpineIndex = 0;
int cachedChapterTotalPageCount = 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;
// Prevents the reader menu from reopening due to the confirm button used to exit the slider.
bool suppressMenuOpenOnce = false;
bool updateRequired = false; bool updateRequired = false;
const std::function<void()> onGoBack; const std::function<void()> onGoBack;
const std::function<void()> onGoHome; const std::function<void()> onGoHome;
@ -29,6 +36,10 @@ class EpubReaderActivity final : public ActivityWithSubactivity {
int orientedMarginBottom, int orientedMarginLeft); int orientedMarginBottom, int orientedMarginLeft);
void renderStatusBar(int orientedMarginRight, int orientedMarginBottom, int orientedMarginLeft) const; void renderStatusBar(int orientedMarginRight, int orientedMarginBottom, int orientedMarginLeft) const;
void saveProgress(int spineIndex, int currentPage, int pageCount); 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);
// Compute the current reading position as an integer percent (0-100).
int getCurrentPercent() const;
void onReaderMenuBack(); void onReaderMenuBack();
void onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action); void onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action);

View File

@ -13,7 +13,8 @@
class EpubReaderMenuActivity final : public ActivityWithSubactivity { class EpubReaderMenuActivity final : public ActivityWithSubactivity {
public: public:
enum class MenuAction { SELECT_CHAPTER, GO_HOME, DELETE_CACHE }; // Menu actions available from the reader menu.
enum class MenuAction { SELECT_CHAPTER, GO_TO_PERCENT, GO_HOME, DELETE_CACHE };
explicit EpubReaderMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& title, explicit EpubReaderMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& title,
const std::function<void()>& onBack, const std::function<void(MenuAction)>& onAction) const std::function<void()>& onBack, const std::function<void(MenuAction)>& onAction)
@ -32,7 +33,9 @@ class EpubReaderMenuActivity final : public ActivityWithSubactivity {
std::string label; std::string label;
}; };
// Fixed menu layout (order matters for up/down navigation).
const std::vector<MenuItem> menuItems = {{MenuAction::SELECT_CHAPTER, "Go to Chapter"}, const std::vector<MenuItem> menuItems = {{MenuAction::SELECT_CHAPTER, "Go to Chapter"},
{MenuAction::GO_TO_PERCENT, "Go to %"},
{MenuAction::GO_HOME, "Go Home"}, {MenuAction::GO_HOME, "Go Home"},
{MenuAction::DELETE_CACHE, "Delete Book Cache"}}; {MenuAction::DELETE_CACHE, "Delete Book Cache"}};

View File

@ -0,0 +1,138 @@
#include "EpubReaderPercentSelectionActivity.h"
#include <GfxRenderer.h>
#include "MappedInputManager.h"
#include "fontIds.h"
namespace {
// Fine/coarse slider step sizes for percent adjustments.
constexpr int kSmallStep = 1;
constexpr int kLargeStep = 10;
}
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, 70, 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 = 120;
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", "-", "+");
renderer.drawButtonHints(UI_10_FONT_ID, 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 "activities/ActivityWithSubactivity.h"
#include "MappedInputManager.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);
};