feat: quick rotate option in epub reader menu (#685)

## Summary

* adds rotation setting in epub reader menu, actual rotation happens on
going back
* improves button hint drawing to draw correctly in all orientations

<img width="860" height="1147" alt="image"
src="https://github.com/user-attachments/assets/91ceeca6-729f-4304-b68a-e412f6e2c9a7"
/>


---

### 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  >**_
This commit is contained in:
Arthur Tazhitdinov 2026-02-05 14:53:35 +03:00 committed by GitHub
parent 23ecc52261
commit ee987f07ff
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 175 additions and 66 deletions

View File

@ -20,22 +20,10 @@ constexpr unsigned long goHomeMs = 1000;
constexpr int statusBarMargin = 19; constexpr int statusBarMargin = 19;
constexpr int progressBarMarginTop = 1; constexpr int progressBarMarginTop = 1;
} // namespace // Apply the logical reader orientation to the renderer.
// This centralizes orientation mapping so we don't duplicate switch logic elsewhere.
void EpubReaderActivity::taskTrampoline(void* param) { void applyReaderOrientation(GfxRenderer& renderer, const uint8_t orientation) {
auto* self = static_cast<EpubReaderActivity*>(param); switch (orientation) {
self->displayTaskLoop();
}
void EpubReaderActivity::onEnter() {
ActivityWithSubactivity::onEnter();
if (!epub) {
return;
}
// Configure screen orientation based on settings
switch (SETTINGS.orientation) {
case CrossPointSettings::ORIENTATION::PORTRAIT: case CrossPointSettings::ORIENTATION::PORTRAIT:
renderer.setOrientation(GfxRenderer::Orientation::Portrait); renderer.setOrientation(GfxRenderer::Orientation::Portrait);
break; break;
@ -51,6 +39,25 @@ void EpubReaderActivity::onEnter() {
default: default:
break; break;
} }
}
} // namespace
void EpubReaderActivity::taskTrampoline(void* param) {
auto* self = static_cast<EpubReaderActivity*>(param);
self->displayTaskLoop();
}
void EpubReaderActivity::onEnter() {
ActivityWithSubactivity::onEnter();
if (!epub) {
return;
}
// Configure screen orientation based on settings
// NOTE: This affects layout math and must be applied before any render calls.
applyReaderOrientation(renderer, SETTINGS.orientation);
renderingMutex = xSemaphoreCreateMutex(); renderingMutex = xSemaphoreCreateMutex();
@ -129,11 +136,10 @@ void EpubReaderActivity::loop() {
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);
const int currentPage = section ? section->currentPage : 0;
const int totalPages = section ? section->pageCount : 0;
exitActivity(); exitActivity();
enterNewActivity(new EpubReaderMenuActivity( enterNewActivity(new EpubReaderMenuActivity(
this->renderer, this->mappedInput, epub->getTitle(), [this]() { onReaderMenuBack(); }, this->renderer, this->mappedInput, epub->getTitle(), SETTINGS.orientation,
[this](const uint8_t orientation) { onReaderMenuBack(orientation); },
[this](EpubReaderMenuActivity::MenuAction action) { onReaderMenuConfirm(action); })); [this](EpubReaderMenuActivity::MenuAction action) { onReaderMenuConfirm(action); }));
xSemaphoreGive(renderingMutex); xSemaphoreGive(renderingMutex);
} }
@ -222,8 +228,11 @@ void EpubReaderActivity::loop() {
} }
} }
void EpubReaderActivity::onReaderMenuBack() { void EpubReaderActivity::onReaderMenuBack(const uint8_t orientation) {
exitActivity(); exitActivity();
// Apply the user-selected orientation when the menu is dismissed.
// This ensures the menu can be navigated without immediately rotating the screen.
applyOrientation(orientation);
updateRequired = true; updateRequired = true;
} }
@ -305,6 +314,32 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
} }
} }
void EpubReaderActivity::applyOrientation(const uint8_t orientation) {
// No-op if the selected orientation matches current settings.
if (SETTINGS.orientation == orientation) {
return;
}
// Preserve current reading position so we can restore after reflow.
xSemaphoreTake(renderingMutex, portMAX_DELAY);
if (section) {
cachedSpineIndex = currentSpineIndex;
cachedChapterTotalPageCount = section->pageCount;
nextPageNumber = section->currentPage;
}
// Persist the selection so the reader keeps the new orientation on next launch.
SETTINGS.orientation = orientation;
SETTINGS.saveToFile();
// Update renderer orientation to match the new logical coordinate system.
applyReaderOrientation(renderer, SETTINGS.orientation);
// Reset section to force re-layout in the new orientation.
section.reset();
xSemaphoreGive(renderingMutex);
}
void EpubReaderActivity::displayTaskLoop() { void EpubReaderActivity::displayTaskLoop() {
while (true) { while (true) {
if (updateRequired) { if (updateRequired) {

View File

@ -29,8 +29,9 @@ 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);
void onReaderMenuBack(); void onReaderMenuBack(uint8_t orientation);
void onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action); void onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action);
void applyOrientation(uint8_t orientation);
public: public:
explicit EpubReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::unique_ptr<Epub> epub, explicit EpubReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::unique_ptr<Epub> epub,

View File

@ -2,6 +2,8 @@
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <algorithm>
#include "KOReaderCredentialStore.h" #include "KOReaderCredentialStore.h"
#include "KOReaderSyncActivity.h" #include "KOReaderSyncActivity.h"
#include "MappedInputManager.h" #include "MappedInputManager.h"
@ -35,20 +37,18 @@ int EpubReaderChapterSelectionActivity::tocIndexFromItemIndex(int itemIndex) con
int EpubReaderChapterSelectionActivity::getPageItems() const { int EpubReaderChapterSelectionActivity::getPageItems() const {
// Layout constants used in renderScreen // Layout constants used in renderScreen
constexpr int startY = 60;
constexpr int lineHeight = 30; constexpr int lineHeight = 30;
const int screenHeight = renderer.getScreenHeight(); const int screenHeight = renderer.getScreenHeight();
const int endY = screenHeight - lineHeight; const auto orientation = renderer.getOrientation();
// In inverted portrait, the button hints are drawn near the logical top.
const int availableHeight = endY - startY; // Reserve vertical space so list items do not collide with the hints.
int items = availableHeight / lineHeight; const bool isPortraitInverted = orientation == GfxRenderer::Orientation::PortraitInverted;
const int hintGutterHeight = isPortraitInverted ? 50 : 0;
// Ensure we always have at least one item per page to avoid division by zero const int startY = 60 + hintGutterHeight;
if (items < 1) { const int availableHeight = screenHeight - startY - lineHeight;
items = 1; // Clamp to at least one item to avoid division by zero and empty paging.
} return std::max(1, availableHeight / lineHeight);
return items;
} }
void EpubReaderChapterSelectionActivity::taskTrampoline(void* param) { void EpubReaderChapterSelectionActivity::taskTrampoline(void* param) {
@ -179,39 +179,54 @@ void EpubReaderChapterSelectionActivity::renderScreen() {
renderer.clearScreen(); renderer.clearScreen();
const auto pageWidth = renderer.getScreenWidth(); const auto pageWidth = renderer.getScreenWidth();
const auto orientation = renderer.getOrientation();
// Landscape orientation: reserve a horizontal gutter for button hints.
const bool isLandscapeCw = orientation == GfxRenderer::Orientation::LandscapeClockwise;
const bool isLandscapeCcw = orientation == GfxRenderer::Orientation::LandscapeCounterClockwise;
// Inverted portrait: reserve vertical space for hints at the top.
const bool isPortraitInverted = orientation == GfxRenderer::Orientation::PortraitInverted;
const int hintGutterWidth = (isLandscapeCw || isLandscapeCcw) ? 30 : 0;
// Landscape CW places hints on the left edge; CCW keeps them on the right.
const int contentX = isLandscapeCw ? hintGutterWidth : 0;
const int contentWidth = pageWidth - hintGutterWidth;
const int hintGutterHeight = isPortraitInverted ? 50 : 0;
const int contentY = hintGutterHeight;
const int pageItems = getPageItems(); const int pageItems = getPageItems();
const int totalItems = getTotalItems(); const int totalItems = getTotalItems();
renderer.drawCenteredText(UI_12_FONT_ID, 15, "Go to Chapter", true, EpdFontFamily::BOLD); // Manual centering to honor content gutters.
const int titleX =
contentX + (contentWidth - renderer.getTextWidth(UI_12_FONT_ID, "Go to Chapter", EpdFontFamily::BOLD)) / 2;
renderer.drawText(UI_12_FONT_ID, titleX, 15 + contentY, "Go to Chapter", true, EpdFontFamily::BOLD);
const auto pageStartIndex = selectorIndex / pageItems * pageItems; const auto pageStartIndex = selectorIndex / pageItems * pageItems;
renderer.fillRect(0, 60 + (selectorIndex % pageItems) * 30 - 2, pageWidth - 1, 30); // Highlight only the content area, not the hint gutters.
renderer.fillRect(contentX, 60 + contentY + (selectorIndex % pageItems) * 30 - 2, contentWidth - 1, 30);
for (int i = 0; i < pageItems; i++) { for (int i = 0; i < pageItems; i++) {
int itemIndex = pageStartIndex + i; int itemIndex = pageStartIndex + i;
if (itemIndex >= totalItems) break; if (itemIndex >= totalItems) break;
const int displayY = 60 + i * 30; const int displayY = 60 + contentY + i * 30;
const bool isSelected = (itemIndex == selectorIndex); const bool isSelected = (itemIndex == selectorIndex);
if (isSyncItem(itemIndex)) { if (isSyncItem(itemIndex)) {
renderer.drawText(UI_10_FONT_ID, 20, displayY, ">> Sync Progress", !isSelected); // Sync option uses a fixed label and stays aligned to the content margin.
renderer.drawText(UI_10_FONT_ID, contentX + 20, displayY, ">> Sync Progress", !isSelected);
} else { } else {
const int tocIndex = tocIndexFromItemIndex(itemIndex); const int tocIndex = tocIndexFromItemIndex(itemIndex);
auto item = epub->getTocItem(tocIndex); auto item = epub->getTocItem(tocIndex);
const int indentSize = 20 + (item.level - 1) * 15; // Indent per TOC level while keeping content within the gutter-safe region.
const int indentSize = contentX + 20 + (item.level - 1) * 15;
const std::string chapterName = const std::string chapterName =
renderer.truncatedText(UI_10_FONT_ID, item.title.c_str(), pageWidth - 40 - indentSize); renderer.truncatedText(UI_10_FONT_ID, item.title.c_str(), contentWidth - 40 - indentSize);
renderer.drawText(UI_10_FONT_ID, indentSize, displayY, chapterName.c_str(), !isSelected); renderer.drawText(UI_10_FONT_ID, indentSize, displayY, chapterName.c_str(), !isSelected);
} }
} }
// Skip button hints in landscape CW mode (they overlap content) const auto labels = mappedInput.mapLabels("« Back", "Select", "Up", "Down");
if (renderer.getOrientation() != GfxRenderer::LandscapeClockwise) { GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
const auto labels = mappedInput.mapLabels("« Back", "Select", "Up", "Down");
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
}
renderer.displayBuffer(); renderer.displayBuffer();
} }

View File

@ -57,9 +57,16 @@ void EpubReaderMenuActivity::loop() {
selectedIndex = (selectedIndex + 1) % menuItems.size(); selectedIndex = (selectedIndex + 1) % menuItems.size();
updateRequired = true; updateRequired = true;
} else if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { } else if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
const auto selectedAction = menuItems[selectedIndex].action;
if (selectedAction == MenuAction::ROTATE_SCREEN) {
// Cycle orientation preview locally; actual rotation happens on menu exit.
pendingOrientation = (pendingOrientation + 1) % orientationLabels.size();
updateRequired = true;
return;
}
// 1. Capture the callback and action locally // 1. Capture the callback and action locally
auto actionCallback = onAction; auto actionCallback = onAction;
auto selectedAction = menuItems[selectedIndex].action;
// 2. Execute the callback // 2. Execute the callback
actionCallback(selectedAction); actionCallback(selectedAction);
@ -67,7 +74,8 @@ void EpubReaderMenuActivity::loop() {
// 3. CRITICAL: Return immediately. 'this' is likely deleted now. // 3. CRITICAL: Return immediately. 'this' is likely deleted now.
return; return;
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { } else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
onBack(); // Return the pending orientation to the parent so it can apply on exit.
onBack(pendingOrientation);
return; // Also return here just in case return; // Also return here just in case
} }
} }
@ -75,14 +83,31 @@ void EpubReaderMenuActivity::loop() {
void EpubReaderMenuActivity::renderScreen() { void EpubReaderMenuActivity::renderScreen() {
renderer.clearScreen(); renderer.clearScreen();
const auto pageWidth = renderer.getScreenWidth(); const auto pageWidth = renderer.getScreenWidth();
const auto orientation = renderer.getOrientation();
// Landscape orientation: button hints are drawn along a vertical edge, so we
// reserve a horizontal gutter to prevent overlap with menu content.
const bool isLandscapeCw = orientation == GfxRenderer::Orientation::LandscapeClockwise;
const bool isLandscapeCcw = orientation == GfxRenderer::Orientation::LandscapeCounterClockwise;
// Inverted portrait: button hints appear near the logical top, so we reserve
// vertical space to keep the header and list clear.
const bool isPortraitInverted = orientation == GfxRenderer::Orientation::PortraitInverted;
const int hintGutterWidth = (isLandscapeCw || isLandscapeCcw) ? 30 : 0;
// Landscape CW places hints on the left edge; CCW keeps them on the right.
const int contentX = isLandscapeCw ? hintGutterWidth : 0;
const int contentWidth = pageWidth - hintGutterWidth;
const int hintGutterHeight = isPortraitInverted ? 50 : 0;
const int contentY = hintGutterHeight;
// Title // Title
const std::string truncTitle = const std::string truncTitle =
renderer.truncatedText(UI_12_FONT_ID, title.c_str(), pageWidth - 40, EpdFontFamily::BOLD); renderer.truncatedText(UI_12_FONT_ID, title.c_str(), contentWidth - 40, EpdFontFamily::BOLD);
renderer.drawCenteredText(UI_12_FONT_ID, 15, truncTitle.c_str(), true, EpdFontFamily::BOLD); // Manual centering so we can respect the content gutter.
const int titleX =
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);
// Menu Items // Menu Items
constexpr int startY = 60; const int startY = 60 + contentY;
constexpr int lineHeight = 30; constexpr int lineHeight = 30;
for (size_t i = 0; i < menuItems.size(); ++i) { for (size_t i = 0; i < menuItems.size(); ++i) {
@ -90,10 +115,18 @@ void EpubReaderMenuActivity::renderScreen() {
const bool isSelected = (static_cast<int>(i) == selectedIndex); const bool isSelected = (static_cast<int>(i) == selectedIndex);
if (isSelected) { if (isSelected) {
renderer.fillRect(0, displayY, pageWidth - 1, lineHeight, true); // Highlight only the content area so we don't paint over hint gutters.
renderer.fillRect(contentX, displayY, contentWidth - 1, lineHeight, true);
} }
renderer.drawText(UI_10_FONT_ID, 20, displayY, menuItems[i].label.c_str(), !isSelected); renderer.drawText(UI_10_FONT_ID, contentX + 20, displayY, menuItems[i].label.c_str(), !isSelected);
if (menuItems[i].action == MenuAction::ROTATE_SCREEN) {
// Render current orientation value on the right edge of the content area.
const auto value = orientationLabels[pendingOrientation];
const auto width = renderer.getTextWidth(UI_10_FONT_ID, value);
renderer.drawText(UI_10_FONT_ID, contentX + contentWidth - 20 - width, displayY, value, !isSelected);
}
} }
// Footer / Hints // Footer / Hints

View File

@ -13,12 +13,14 @@
class EpubReaderMenuActivity final : public ActivityWithSubactivity { class EpubReaderMenuActivity final : public ActivityWithSubactivity {
public: public:
enum class MenuAction { SELECT_CHAPTER, GO_HOME, DELETE_CACHE }; enum class MenuAction { SELECT_CHAPTER, ROTATE_SCREEN, 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 uint8_t currentOrientation, const std::function<void(uint8_t)>& onBack,
const std::function<void(MenuAction)>& onAction)
: ActivityWithSubactivity("EpubReaderMenu", renderer, mappedInput), : ActivityWithSubactivity("EpubReaderMenu", renderer, mappedInput),
title(title), title(title),
pendingOrientation(currentOrientation),
onBack(onBack), onBack(onBack),
onAction(onAction) {} onAction(onAction) {}
@ -33,6 +35,7 @@ class EpubReaderMenuActivity final : public ActivityWithSubactivity {
}; };
const std::vector<MenuItem> menuItems = {{MenuAction::SELECT_CHAPTER, "Go to Chapter"}, const std::vector<MenuItem> menuItems = {{MenuAction::SELECT_CHAPTER, "Go to Chapter"},
{MenuAction::ROTATE_SCREEN, "Reading Orientation"},
{MenuAction::GO_HOME, "Go Home"}, {MenuAction::GO_HOME, "Go Home"},
{MenuAction::DELETE_CACHE, "Delete Book Cache"}}; {MenuAction::DELETE_CACHE, "Delete Book Cache"}};
@ -41,8 +44,10 @@ class EpubReaderMenuActivity final : public ActivityWithSubactivity {
TaskHandle_t displayTaskHandle = nullptr; TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr; SemaphoreHandle_t renderingMutex = nullptr;
std::string title = "Reader Menu"; std::string title = "Reader Menu";
uint8_t pendingOrientation = 0;
const std::vector<const char*> orientationLabels = {"Portrait", "Landscape CW", "Inverted", "Landscape CCW"};
const std::function<void()> onBack; const std::function<void(uint8_t)> onBack;
const std::function<void(MenuAction)> onAction; const std::function<void(MenuAction)> onAction;
static void taskTrampoline(void* param); static void taskTrampoline(void* param);

View File

@ -2,6 +2,8 @@
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <algorithm>
#include "MappedInputManager.h" #include "MappedInputManager.h"
#include "components/UITheme.h" #include "components/UITheme.h"
#include "fontIds.h" #include "fontIds.h"
@ -11,18 +13,18 @@ constexpr int SKIP_PAGE_MS = 700;
} // namespace } // namespace
int XtcReaderChapterSelectionActivity::getPageItems() const { int XtcReaderChapterSelectionActivity::getPageItems() const {
constexpr int startY = 60;
constexpr int lineHeight = 30; constexpr int lineHeight = 30;
const int screenHeight = renderer.getScreenHeight(); const int screenHeight = renderer.getScreenHeight();
const int endY = screenHeight - lineHeight; const auto orientation = renderer.getOrientation();
// In inverted portrait, the hint row is drawn near the logical top.
const int availableHeight = endY - startY; // Reserve vertical space so the list starts below the hints.
int items = availableHeight / lineHeight; const bool isPortraitInverted = orientation == GfxRenderer::Orientation::PortraitInverted;
if (items < 1) { const int hintGutterHeight = isPortraitInverted ? 50 : 0;
items = 1; const int startY = 60 + hintGutterHeight;
} const int availableHeight = screenHeight - startY - lineHeight;
return items; // Clamp to at least one item to prevent empty page math.
return std::max(1, availableHeight / lineHeight);
} }
int XtcReaderChapterSelectionActivity::findChapterIndexForPage(uint32_t page) const { int XtcReaderChapterSelectionActivity::findChapterIndexForPage(uint32_t page) const {
@ -132,22 +134,40 @@ void XtcReaderChapterSelectionActivity::renderScreen() {
renderer.clearScreen(); renderer.clearScreen();
const auto pageWidth = renderer.getScreenWidth(); const auto pageWidth = renderer.getScreenWidth();
const auto orientation = renderer.getOrientation();
// Landscape orientation: reserve a horizontal gutter for button hints.
const bool isLandscapeCw = orientation == GfxRenderer::Orientation::LandscapeClockwise;
const bool isLandscapeCcw = orientation == GfxRenderer::Orientation::LandscapeCounterClockwise;
// Inverted portrait: reserve vertical space for hints at the top.
const bool isPortraitInverted = orientation == GfxRenderer::Orientation::PortraitInverted;
const int hintGutterWidth = (isLandscapeCw || isLandscapeCcw) ? 30 : 0;
// Landscape CW places hints on the left edge; CCW keeps them on the right.
const int contentX = isLandscapeCw ? hintGutterWidth : 0;
const int contentWidth = pageWidth - hintGutterWidth;
const int hintGutterHeight = isPortraitInverted ? 50 : 0;
const int contentY = hintGutterHeight;
const int pageItems = getPageItems(); const int pageItems = getPageItems();
renderer.drawCenteredText(UI_12_FONT_ID, 15, "Select Chapter", true, EpdFontFamily::BOLD); // Manual centering to honor content gutters.
const int titleX =
contentX + (contentWidth - renderer.getTextWidth(UI_12_FONT_ID, "Select Chapter", EpdFontFamily::BOLD)) / 2;
renderer.drawText(UI_12_FONT_ID, titleX, 15 + contentY, "Select Chapter", true, EpdFontFamily::BOLD);
const auto& chapters = xtc->getChapters(); const auto& chapters = xtc->getChapters();
if (chapters.empty()) { if (chapters.empty()) {
renderer.drawCenteredText(UI_10_FONT_ID, 120, "No chapters"); // Center the empty state within the gutter-safe content region.
const int emptyX = contentX + (contentWidth - renderer.getTextWidth(UI_10_FONT_ID, "No chapters")) / 2;
renderer.drawText(UI_10_FONT_ID, emptyX, 120 + contentY, "No chapters");
renderer.displayBuffer(); renderer.displayBuffer();
return; return;
} }
const auto pageStartIndex = selectorIndex / pageItems * pageItems; const auto pageStartIndex = selectorIndex / pageItems * pageItems;
renderer.fillRect(0, 60 + (selectorIndex % pageItems) * 30 - 2, pageWidth - 1, 30); // Highlight only the content area, not the hint gutters.
renderer.fillRect(contentX, 60 + contentY + (selectorIndex % pageItems) * 30 - 2, contentWidth - 1, 30);
for (int i = pageStartIndex; i < static_cast<int>(chapters.size()) && i < pageStartIndex + pageItems; i++) { for (int i = pageStartIndex; i < static_cast<int>(chapters.size()) && i < pageStartIndex + pageItems; i++) {
const auto& chapter = chapters[i]; const auto& chapter = chapters[i];
const char* title = chapter.name.empty() ? "Unnamed" : chapter.name.c_str(); const char* title = chapter.name.empty() ? "Unnamed" : chapter.name.c_str();
renderer.drawText(UI_10_FONT_ID, 20, 60 + (i % pageItems) * 30, title, i != selectorIndex); renderer.drawText(UI_10_FONT_ID, contentX + 20, 60 + contentY + (i % pageItems) * 30, title, i != selectorIndex);
} }
// Skip button hints in landscape CW mode (they overlap content) // Skip button hints in landscape CW mode (they overlap content)