diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 5ccfb4fe..a510f8f3 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -20,22 +20,10 @@ constexpr unsigned long goHomeMs = 1000; constexpr int statusBarMargin = 19; constexpr int progressBarMarginTop = 1; -} // namespace - -void EpubReaderActivity::taskTrampoline(void* param) { - auto* self = static_cast(param); - self->displayTaskLoop(); -} - -void EpubReaderActivity::onEnter() { - ActivityWithSubactivity::onEnter(); - - if (!epub) { - return; - } - - // Configure screen orientation based on settings - switch (SETTINGS.orientation) { +// 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) { + switch (orientation) { case CrossPointSettings::ORIENTATION::PORTRAIT: renderer.setOrientation(GfxRenderer::Orientation::Portrait); break; @@ -51,6 +39,25 @@ void EpubReaderActivity::onEnter() { default: break; } +} + +} // namespace + +void EpubReaderActivity::taskTrampoline(void* param) { + auto* self = static_cast(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(); @@ -127,11 +134,10 @@ void EpubReaderActivity::loop() { if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { // Don't start activity transition while rendering xSemaphoreTake(renderingMutex, portMAX_DELAY); - const int currentPage = section ? section->currentPage : 0; - const int totalPages = section ? section->pageCount : 0; exitActivity(); 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); })); xSemaphoreGive(renderingMutex); } @@ -220,8 +226,11 @@ void EpubReaderActivity::loop() { } } -void EpubReaderActivity::onReaderMenuBack() { +void EpubReaderActivity::onReaderMenuBack(const uint8_t orientation) { 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; } @@ -303,6 +312,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() { while (true) { if (updateRequired) { diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index ca7c0dc9..24d58e9c 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -29,8 +29,9 @@ 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); - void onReaderMenuBack(); + void onReaderMenuBack(uint8_t orientation); void onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action); + void applyOrientation(uint8_t orientation); public: explicit EpubReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::unique_ptr epub, diff --git a/src/activities/reader/EpubReaderChapterSelectionActivity.cpp b/src/activities/reader/EpubReaderChapterSelectionActivity.cpp index a77b37d0..932a4812 100644 --- a/src/activities/reader/EpubReaderChapterSelectionActivity.cpp +++ b/src/activities/reader/EpubReaderChapterSelectionActivity.cpp @@ -2,6 +2,8 @@ #include +#include + #include "KOReaderCredentialStore.h" #include "KOReaderSyncActivity.h" #include "MappedInputManager.h" @@ -34,20 +36,18 @@ int EpubReaderChapterSelectionActivity::tocIndexFromItemIndex(int itemIndex) con int EpubReaderChapterSelectionActivity::getPageItems() const { // Layout constants used in renderScreen - constexpr int startY = 60; constexpr int lineHeight = 30; const int screenHeight = renderer.getScreenHeight(); - const int endY = screenHeight - lineHeight; - - const int availableHeight = endY - startY; - int items = availableHeight / lineHeight; - - // Ensure we always have at least one item per page to avoid division by zero - if (items < 1) { - items = 1; - } - return items; + const auto orientation = renderer.getOrientation(); + // In inverted portrait, the button hints are drawn near the logical top. + // Reserve vertical space so list items do not collide with the hints. + const bool isPortraitInverted = orientation == GfxRenderer::Orientation::PortraitInverted; + const int hintGutterHeight = isPortraitInverted ? 50 : 0; + const int startY = 60 + hintGutterHeight; + const int availableHeight = screenHeight - startY - lineHeight; + // Clamp to at least one item to avoid division by zero and empty paging. + return std::max(1, availableHeight / lineHeight); } void EpubReaderChapterSelectionActivity::taskTrampoline(void* param) { @@ -178,39 +178,54 @@ void EpubReaderChapterSelectionActivity::renderScreen() { renderer.clearScreen(); 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 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; - 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++) { int itemIndex = pageStartIndex + i; if (itemIndex >= totalItems) break; - const int displayY = 60 + i * 30; + const int displayY = 60 + contentY + i * 30; const bool isSelected = (itemIndex == selectorIndex); 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 { const int tocIndex = tocIndexFromItemIndex(itemIndex); 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 = - 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); } } - // Skip button hints in landscape CW mode (they overlap content) - if (renderer.getOrientation() != GfxRenderer::LandscapeClockwise) { - const auto labels = mappedInput.mapLabels("« Back", "Select", "Up", "Down"); - renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); - } + const auto labels = mappedInput.mapLabels("« Back", "Select", "Up", "Down"); + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); renderer.displayBuffer(); } diff --git a/src/activities/reader/EpubReaderMenuActivity.cpp b/src/activities/reader/EpubReaderMenuActivity.cpp index 5ce4881d..25e3397a 100644 --- a/src/activities/reader/EpubReaderMenuActivity.cpp +++ b/src/activities/reader/EpubReaderMenuActivity.cpp @@ -56,9 +56,16 @@ void EpubReaderMenuActivity::loop() { selectedIndex = (selectedIndex + 1) % menuItems.size(); updateRequired = true; } 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 auto actionCallback = onAction; - auto selectedAction = menuItems[selectedIndex].action; // 2. Execute the callback actionCallback(selectedAction); @@ -66,7 +73,8 @@ void EpubReaderMenuActivity::loop() { // 3. CRITICAL: Return immediately. 'this' is likely deleted now. return; } 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 } } @@ -74,14 +82,31 @@ void EpubReaderMenuActivity::loop() { void EpubReaderMenuActivity::renderScreen() { renderer.clearScreen(); 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 const std::string truncTitle = - renderer.truncatedText(UI_12_FONT_ID, title.c_str(), pageWidth - 40, EpdFontFamily::BOLD); - renderer.drawCenteredText(UI_12_FONT_ID, 15, truncTitle.c_str(), true, EpdFontFamily::BOLD); + renderer.truncatedText(UI_12_FONT_ID, title.c_str(), contentWidth - 40, 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 - constexpr int startY = 60; + const int startY = 60 + contentY; constexpr int lineHeight = 30; for (size_t i = 0; i < menuItems.size(); ++i) { @@ -89,10 +114,18 @@ void EpubReaderMenuActivity::renderScreen() { const bool isSelected = (static_cast(i) == selectedIndex); 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 diff --git a/src/activities/reader/EpubReaderMenuActivity.h b/src/activities/reader/EpubReaderMenuActivity.h index bd253f81..7e783ad0 100644 --- a/src/activities/reader/EpubReaderMenuActivity.h +++ b/src/activities/reader/EpubReaderMenuActivity.h @@ -13,12 +13,14 @@ class EpubReaderMenuActivity final : public ActivityWithSubactivity { 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, - const std::function& onBack, const std::function& onAction) + const uint8_t currentOrientation, const std::function& onBack, + const std::function& onAction) : ActivityWithSubactivity("EpubReaderMenu", renderer, mappedInput), title(title), + pendingOrientation(currentOrientation), onBack(onBack), onAction(onAction) {} @@ -33,6 +35,7 @@ class EpubReaderMenuActivity final : public ActivityWithSubactivity { }; const std::vector menuItems = {{MenuAction::SELECT_CHAPTER, "Go to Chapter"}, + {MenuAction::ROTATE_SCREEN, "Reading Orientation"}, {MenuAction::GO_HOME, "Go Home"}, {MenuAction::DELETE_CACHE, "Delete Book Cache"}}; @@ -41,8 +44,10 @@ class EpubReaderMenuActivity final : public ActivityWithSubactivity { TaskHandle_t displayTaskHandle = nullptr; SemaphoreHandle_t renderingMutex = nullptr; std::string title = "Reader Menu"; + uint8_t pendingOrientation = 0; + const std::vector orientationLabels = {"Portrait", "Landscape CW", "Inverted", "Landscape CCW"}; - const std::function onBack; + const std::function onBack; const std::function onAction; static void taskTrampoline(void* param); diff --git a/src/activities/reader/XtcReaderChapterSelectionActivity.cpp b/src/activities/reader/XtcReaderChapterSelectionActivity.cpp index ad806a30..90d81247 100644 --- a/src/activities/reader/XtcReaderChapterSelectionActivity.cpp +++ b/src/activities/reader/XtcReaderChapterSelectionActivity.cpp @@ -2,6 +2,8 @@ #include +#include + #include "MappedInputManager.h" #include "fontIds.h" @@ -10,18 +12,18 @@ constexpr int SKIP_PAGE_MS = 700; } // namespace int XtcReaderChapterSelectionActivity::getPageItems() const { - constexpr int startY = 60; constexpr int lineHeight = 30; const int screenHeight = renderer.getScreenHeight(); - const int endY = screenHeight - lineHeight; - - const int availableHeight = endY - startY; - int items = availableHeight / lineHeight; - if (items < 1) { - items = 1; - } - return items; + const auto orientation = renderer.getOrientation(); + // In inverted portrait, the hint row is drawn near the logical top. + // Reserve vertical space so the list starts below the hints. + const bool isPortraitInverted = orientation == GfxRenderer::Orientation::PortraitInverted; + const int hintGutterHeight = isPortraitInverted ? 50 : 0; + const int startY = 60 + hintGutterHeight; + const int availableHeight = screenHeight - startY - lineHeight; + // Clamp to at least one item to prevent empty page math. + return std::max(1, availableHeight / lineHeight); } int XtcReaderChapterSelectionActivity::findChapterIndexForPage(uint32_t page) const { @@ -131,29 +133,44 @@ void XtcReaderChapterSelectionActivity::renderScreen() { renderer.clearScreen(); 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(); - 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(); 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(); return; } 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(chapters.size()) && i < pageStartIndex + pageItems; i++) { const auto& chapter = chapters[i]; 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) - if (renderer.getOrientation() != GfxRenderer::LandscapeClockwise) { - const auto labels = mappedInput.mapLabels("« Back", "Select", "Up", "Down"); - renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); - } + const auto labels = mappedInput.mapLabels("« Back", "Select", "Up", "Down"); + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); renderer.displayBuffer(); }