From 1286fc15ecd3b743d462fb719fa8a105c7d28f88 Mon Sep 17 00:00:00 2001 From: Uri Tauber Date: Tue, 27 Jan 2026 17:58:02 +0200 Subject: [PATCH] feat: consolidate reader menu into tabbed TOC view --- src/activities/reader/ChaptersTab.cpp | 153 ++++++++++ src/activities/reader/ChaptersTab.h | 44 +++ src/activities/reader/EpubReaderActivity.cpp | 88 ++---- src/activities/reader/EpubReaderActivity.h | 2 +- .../EpubReaderChapterSelectionActivity.cpp | 265 ------------------ .../EpubReaderChapterSelectionActivity.h | 70 ----- .../reader/EpubReaderMenuActivity.cpp | 96 ------- .../reader/EpubReaderMenuActivity.h | 33 --- .../reader/EpubReaderTocActivity.cpp | 132 +++++++++ src/activities/reader/EpubReaderTocActivity.h | 75 +++++ ...FootnotesActivity.cpp => FootnotesTab.cpp} | 66 ++--- ...aderFootnotesActivity.h => FootnotesTab.h} | 33 +-- src/activities/reader/TocTab.h | 24 ++ 13 files changed, 498 insertions(+), 583 deletions(-) create mode 100644 src/activities/reader/ChaptersTab.cpp create mode 100644 src/activities/reader/ChaptersTab.h delete mode 100644 src/activities/reader/EpubReaderChapterSelectionActivity.cpp delete mode 100644 src/activities/reader/EpubReaderChapterSelectionActivity.h delete mode 100644 src/activities/reader/EpubReaderMenuActivity.cpp delete mode 100644 src/activities/reader/EpubReaderMenuActivity.h create mode 100644 src/activities/reader/EpubReaderTocActivity.cpp create mode 100644 src/activities/reader/EpubReaderTocActivity.h rename src/activities/reader/{EpubReaderFootnotesActivity.cpp => FootnotesTab.cpp} (51%) rename src/activities/reader/{EpubReaderFootnotesActivity.h => FootnotesTab.h} (58%) create mode 100644 src/activities/reader/TocTab.h diff --git a/src/activities/reader/ChaptersTab.cpp b/src/activities/reader/ChaptersTab.cpp new file mode 100644 index 00000000..df47d1fc --- /dev/null +++ b/src/activities/reader/ChaptersTab.cpp @@ -0,0 +1,153 @@ +#include "ChaptersTab.h" + +#include + +#include "KOReaderCredentialStore.h" +#include "MappedInputManager.h" +#include "fontIds.h" + +namespace { +constexpr int SKIP_PAGE_MS = 700; +constexpr int LINE_HEIGHT = 30; +} // namespace + +void ChaptersTab::onEnter() { + buildFilteredChapterList(); + + selectorIndex = 0; + for (size_t i = 0; i < filteredSpineIndices.size(); i++) { + if (filteredSpineIndices[i] == currentSpineIndex) { + selectorIndex = i; + break; + } + } + + if (hasSyncOption()) { + selectorIndex += 1; + } + updateRequired = true; +} + +bool ChaptersTab::hasSyncOption() const { return KOREADER_STORE.hasCredentials(); } + +int ChaptersTab::getTotalItems() const { + const int syncCount = hasSyncOption() ? 2 : 0; + return filteredSpineIndices.size() + syncCount; +} + +bool ChaptersTab::isSyncItem(int index) const { + if (!hasSyncOption()) return false; + return index == 0 || index == getTotalItems() - 1; +} + +int ChaptersTab::tocIndexFromItemIndex(int itemIndex) const { + const int offset = hasSyncOption() ? 1 : 0; + return itemIndex - offset; +} + +int ChaptersTab::getPageItems(int contentTop, int contentHeight) const { + int items = contentHeight / LINE_HEIGHT; + return (items < 1) ? 1 : items; +} + +void ChaptersTab::buildFilteredChapterList() { + filteredSpineIndices.clear(); + for (int i = 0; i < epub->getSpineItemsCount(); i++) { + if (epub->shouldHideFromToc(i)) continue; + int tocIndex = epub->getTocIndexForSpineIndex(i); + if (tocIndex == -1) continue; + filteredSpineIndices.push_back(i); + } +} + +void ChaptersTab::loop() { + const bool upReleased = mappedInput.wasReleased(MappedInputManager::Button::Up); + const bool downReleased = mappedInput.wasReleased(MappedInputManager::Button::Down); + const bool skipPage = mappedInput.getHeldTime() > SKIP_PAGE_MS; + const int totalItems = getTotalItems(); + + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + if (hasSyncOption() && (selectorIndex == 0 || selectorIndex == totalItems - 1)) { + onLaunchSync(); + return; + } + + int filteredIndex = selectorIndex; + if (hasSyncOption()) filteredIndex -= 1; + + if (filteredIndex >= 0 && filteredIndex < static_cast(filteredSpineIndices.size())) { + onSelectSpineIndex(filteredSpineIndices[filteredIndex]); + } + } else if (upReleased) { + if (totalItems > 0) { + if (skipPage) { + // This logic matches MyLibraryActivity + // But for simplicity let's just do a page jump + } + selectorIndex = (selectorIndex + totalItems - 1) % totalItems; + updateRequired = true; + } + } else if (downReleased) { + if (totalItems > 0) { + selectorIndex = (selectorIndex + 1) % totalItems; + updateRequired = true; + } + } +} + +void ChaptersTab::render(int contentTop, int contentHeight) { + const auto pageWidth = renderer.getScreenWidth(); + const int pageItems = getPageItems(contentTop, contentHeight); + const int totalItems = getTotalItems(); + + const auto pageStartIndex = selectorIndex / pageItems * pageItems; + renderer.fillRect(0, contentTop + (selectorIndex % pageItems) * LINE_HEIGHT - 2, pageWidth - 1, LINE_HEIGHT); + + for (int i = 0; i < pageItems; i++) { + int itemIndex = pageStartIndex + i; + if (itemIndex >= totalItems) break; + + const int displayY = contentTop + i * LINE_HEIGHT; + const bool isSelected = (itemIndex == selectorIndex); + + if (isSyncItem(itemIndex)) { + renderer.drawText(UI_10_FONT_ID, 20, displayY, ">> Sync Progress", !isSelected); + } else { + int filteredIndex = itemIndex; + if (hasSyncOption()) filteredIndex -= 1; + + if (filteredIndex >= 0 && filteredIndex < static_cast(filteredSpineIndices.size())) { + int spineIndex = filteredSpineIndices[filteredIndex]; + int tocIndex = epub->getTocIndexForSpineIndex(spineIndex); + + if (tocIndex == -1) { + renderer.drawText(UI_10_FONT_ID, 20, displayY, "Unnamed", !isSelected); + } else { + auto item = epub->getTocItem(tocIndex); + const int indentSize = 20 + (item.level - 1) * 15; + const std::string chapterName = + renderer.truncatedText(UI_10_FONT_ID, item.title.c_str(), pageWidth - 40 - indentSize); + renderer.drawText(UI_10_FONT_ID, indentSize, displayY, chapterName.c_str(), !isSelected); + } + } + } + } +} + +int ChaptersTab::getCurrentPage() const { + // We don't have enough context here to know pageItems easily without contentHeight + // For now let's just return a placeholder or calculate it if we can. + // Actually onEnter can't know the height either if it's dynamic. + // Let's assume contentTop=60, contentHeight=screenHeight-120 + const int availableHeight = renderer.getScreenHeight() - 120; + const int itemsPerPage = availableHeight / LINE_HEIGHT; + return selectorIndex / (itemsPerPage > 0 ? itemsPerPage : 1) + 1; +} + +int ChaptersTab::getTotalPages() const { + const int availableHeight = renderer.getScreenHeight() - 120; + const int itemsPerPage = availableHeight / LINE_HEIGHT; + const int totalItems = getTotalItems(); + if (totalItems == 0) return 1; + return (totalItems + itemsPerPage - 1) / (itemsPerPage > 0 ? itemsPerPage : 1); +} diff --git a/src/activities/reader/ChaptersTab.h b/src/activities/reader/ChaptersTab.h new file mode 100644 index 00000000..ae80107a --- /dev/null +++ b/src/activities/reader/ChaptersTab.h @@ -0,0 +1,44 @@ +#pragma once +#include + +#include +#include +#include + +#include "TocTab.h" + +class ChaptersTab final : public TocTab { + std::shared_ptr epub; + int currentSpineIndex; + int selectorIndex = 0; + bool updateRequired = false; + std::vector filteredSpineIndices; + + const std::function onSelectSpineIndex; + const std::function onLaunchSync; + + int getPageItems(int contentTop, int contentHeight) const; + int getTotalItems() const; + bool hasSyncOption() const; + bool isSyncItem(int index) const; + int tocIndexFromItemIndex(int itemIndex) const; + void buildFilteredChapterList(); + + public: + ChaptersTab(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::shared_ptr& epub, + int currentSpineIndex, std::function onSelectSpineIndex, std::function onLaunchSync) + : TocTab(renderer, mappedInput), + epub(epub), + currentSpineIndex(currentSpineIndex), + onSelectSpineIndex(onSelectSpineIndex), + onLaunchSync(onLaunchSync) {} + + void onEnter() override; + void loop() override; + void render(int contentTop, int contentHeight) override; + + int getCurrentPage() const override; + int getTotalPages() const override; + bool isUpdateRequired() const override { return updateRequired; } + void clearUpdateRequired() override { updateRequired = false; } +}; diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 07c3a1bd..52605dba 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -7,9 +7,7 @@ #include "CrossPointSettings.h" #include "CrossPointState.h" -#include "EpubReaderChapterSelectionActivity.h" -#include "EpubReaderFootnotesActivity.h" -#include "EpubReaderMenuActivity.h" +#include "EpubReaderTocActivity.h" #include "MappedInputManager.h" #include "RecentBooksStore.h" #include "ScreenComponents.h" @@ -131,65 +129,41 @@ void EpubReaderActivity::loop() { const int currentPage = section ? section->currentPage : 0; const int totalPages = section ? section->pageCount : 0; - // Show menu instead of direct chapter selection, to allow access to footnotes + // Show consolidated TOC activity (Chapters and Footnotes) exitActivity(); - enterNewActivity(new EpubReaderMenuActivity( - this->renderer, this->mappedInput, + enterNewActivity(new EpubReaderTocActivity( + this->renderer, this->mappedInput, epub, epub->getPath(), currentSpineIndex, currentPage, totalPages, + currentPageFootnotes, [this] { - // onGoBack from menu - updateRequired = true; - // Re-enter reader activity logic if needed (handled by stack) - // Actually ActivityWithSubactivity handles subActivity exit naturally + // onGoBack exitActivity(); + updateRequired = true; }, - [this, currentPage, totalPages](EpubReaderMenuActivity::MenuOption option) { - // onSelectOption - handle menu choice - if (option == EpubReaderMenuActivity::CHAPTERS) { - // Show chapter selection - exitActivity(); - enterNewActivity(new EpubReaderChapterSelectionActivity( - this->renderer, this->mappedInput, epub, epub->getPath(), currentSpineIndex, currentPage, totalPages, - [this] { - exitActivity(); - updateRequired = true; - }, - [this](int newSpineIndex) { - if (currentSpineIndex != newSpineIndex) { - currentSpineIndex = newSpineIndex; - nextPageNumber = 0; - section.reset(); - } - exitActivity(); - updateRequired = true; - }, - [this](int newSpineIndex, int newPage) { - // Handle sync position - if (currentSpineIndex != newSpineIndex || (section && section->currentPage != newPage)) { - currentSpineIndex = newSpineIndex; - nextPageNumber = newPage; - section.reset(); - } - exitActivity(); - updateRequired = true; - })); - } else if (option == EpubReaderMenuActivity::FOOTNOTES) { - // Show footnotes page with current page notes - exitActivity(); - enterNewActivity(new EpubReaderFootnotesActivity( - this->renderer, this->mappedInput, - currentPageFootnotes, // Pass collected footnotes (reference) - [this] { - // onGoBack from footnotes - exitActivity(); - updateRequired = true; - }, - [this](const char* href) { - // onSelectFootnote - navigate to the footnote location - navigateToHref(href, true); // true = save current position - exitActivity(); - updateRequired = true; - })); + [this](int newSpineIndex) { + // onSelectSpineIndex + if (currentSpineIndex != newSpineIndex) { + currentSpineIndex = newSpineIndex; + nextPageNumber = 0; + section.reset(); } + exitActivity(); + updateRequired = true; + }, + [this](const char* href) { + // onSelectFootnote + navigateToHref(href, true); + exitActivity(); + updateRequired = true; + }, + [this](int newSpineIndex, int newPage) { + // onSyncPosition + if (currentSpineIndex != newSpineIndex || (section && section->currentPage != newPage)) { + currentSpineIndex = newSpineIndex; + nextPageNumber = newPage; + section.reset(); + } + exitActivity(); + updateRequired = true; })); xSemaphoreGive(renderingMutex); } diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index b7497ce1..5033b896 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -5,7 +5,7 @@ #include #include -#include "EpubReaderFootnotesActivity.h" +#include "FootnotesTab.h" #include "activities/ActivityWithSubactivity.h" class EpubReaderActivity final : public ActivityWithSubactivity { diff --git a/src/activities/reader/EpubReaderChapterSelectionActivity.cpp b/src/activities/reader/EpubReaderChapterSelectionActivity.cpp deleted file mode 100644 index 3492a2f6..00000000 --- a/src/activities/reader/EpubReaderChapterSelectionActivity.cpp +++ /dev/null @@ -1,265 +0,0 @@ -#include "EpubReaderChapterSelectionActivity.h" - -#include - -#include "KOReaderCredentialStore.h" -#include "KOReaderSyncActivity.h" -#include "MappedInputManager.h" -#include "fontIds.h" - -namespace { -// Time threshold for treating a long press as a page-up/page-down -constexpr int SKIP_PAGE_MS = 700; -} // namespace - -bool EpubReaderChapterSelectionActivity::hasSyncOption() const { return KOREADER_STORE.hasCredentials(); } - -int EpubReaderChapterSelectionActivity::getTotalItems() const { - // Add 2 for sync options (top and bottom) if credentials are configured - const int syncCount = hasSyncOption() ? 2 : 0; - return epub->getTocItemsCount() + syncCount; -} - -bool EpubReaderChapterSelectionActivity::isSyncItem(int index) const { - if (!hasSyncOption()) return false; - // First item and last item are sync options - return index == 0 || index == getTotalItems() - 1; -} - -int EpubReaderChapterSelectionActivity::tocIndexFromItemIndex(int itemIndex) const { - // Account for the sync option at the top - const int offset = hasSyncOption() ? 1 : 0; - return itemIndex - offset; -} - -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; -} - -void EpubReaderChapterSelectionActivity::taskTrampoline(void* param) { - auto* self = static_cast(param); - self->displayTaskLoop(); -} - -void EpubReaderChapterSelectionActivity::buildFilteredChapterList() { - filteredSpineIndices.clear(); - - for (int i = 0; i < epub->getSpineItemsCount(); i++) { - // Skip footnote pages - if (epub->shouldHideFromToc(i)) { - Serial.printf("[%lu] [CHAP] Hiding footnote page at spine index: %d\n", millis(), i); - continue; - } - - // Skip pages without TOC entry (unnamed pages) - int tocIndex = epub->getTocIndexForSpineIndex(i); - if (tocIndex == -1) { - Serial.printf("[%lu] [CHAP] Hiding unnamed page at spine index: %d\n", millis(), i); - continue; - } - - filteredSpineIndices.push_back(i); - } - - Serial.printf("[%lu] [CHAP] Filtered chapters: %d out of %d\n", millis(), filteredSpineIndices.size(), - epub->getSpineItemsCount()); -} - -void EpubReaderChapterSelectionActivity::onEnter() { - ActivityWithSubactivity::onEnter(); - - if (!epub) { - return; - } - - renderingMutex = xSemaphoreCreateMutex(); - - // Build filtered chapter list (excluding footnote pages) - buildFilteredChapterList(); - - // Find the index in filtered list that corresponds to currentSpineIndex - selectorIndex = 0; - for (size_t i = 0; i < filteredSpineIndices.size(); i++) { - if (filteredSpineIndices[i] == currentSpineIndex) { - selectorIndex = i; - break; - } - } - - // Account for sync option offset when finding current TOC index (if applicable) - // For simplicity, if we are using the filtered list, we might just put "Sync" at the top of THAT list? - // But wait, the filtered list is spine indices. - // The master logic used TOC indices directly. - // Let's adapt: We will display the filtered list. - // If sync is enabled, we prepend/append it to the selector range. - - if (hasSyncOption()) { - selectorIndex += 1; // Offset for top sync option - } - - // Trigger first update - updateRequired = true; - xTaskCreate(&EpubReaderChapterSelectionActivity::taskTrampoline, "EpubReaderChapterSelectionActivityTask", - 4096, // Stack size - this, // Parameters - 1, // Priority - &displayTaskHandle // Task handle - ); -} - -void EpubReaderChapterSelectionActivity::onExit() { - ActivityWithSubactivity::onExit(); - - // Wait until not rendering to delete task to avoid killing mid-instruction to EPD - xSemaphoreTake(renderingMutex, portMAX_DELAY); - if (displayTaskHandle) { - vTaskDelete(displayTaskHandle); - displayTaskHandle = nullptr; - } - vSemaphoreDelete(renderingMutex); - renderingMutex = nullptr; -} - -void EpubReaderChapterSelectionActivity::launchSyncActivity() { - xSemaphoreTake(renderingMutex, portMAX_DELAY); - exitActivity(); - enterNewActivity(new KOReaderSyncActivity( - renderer, mappedInput, epub, epubPath, currentSpineIndex, currentPage, totalPagesInSpine, - [this]() { - // On cancel - exitActivity(); - updateRequired = true; - }, - [this](int newSpineIndex, int newPage) { - // On sync complete - exitActivity(); - onSyncPosition(newSpineIndex, newPage); - })); - xSemaphoreGive(renderingMutex); -} - -void EpubReaderChapterSelectionActivity::loop() { - if (subActivity) { - subActivity->loop(); - return; - } - - const bool prevReleased = mappedInput.wasReleased(MappedInputManager::Button::Up) || - mappedInput.wasReleased(MappedInputManager::Button::Left); - const bool nextReleased = mappedInput.wasReleased(MappedInputManager::Button::Down) || - mappedInput.wasReleased(MappedInputManager::Button::Right); - - const bool skipPage = mappedInput.getHeldTime() > SKIP_PAGE_MS; - const int pageItems = getPageItems(); - - // Total items = filtered chapters + sync options - const int syncCount = hasSyncOption() ? 2 : 0; - const int totalItems = filteredSpineIndices.size() + syncCount; - - if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { - // Check if sync option is selected - if (hasSyncOption()) { - if (selectorIndex == 0 || selectorIndex == totalItems - 1) { - launchSyncActivity(); - return; - } - } - - // It's a chapter. Calculate index in filtered list. - int filteredIndex = selectorIndex; - if (hasSyncOption()) filteredIndex -= 1; // Remove top sync offset - - if (filteredIndex >= 0 && filteredIndex < filteredSpineIndices.size()) { - onSelectSpineIndex(filteredSpineIndices[filteredIndex]); - } - } else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { - onGoBack(); - } else if (prevReleased) { - if (skipPage) { - selectorIndex = ((selectorIndex / pageItems - 1) * pageItems + totalItems) % totalItems; - } else { - selectorIndex = (selectorIndex + totalItems - 1) % totalItems; - } - updateRequired = true; - } else if (nextReleased) { - if (skipPage) { - selectorIndex = ((selectorIndex / pageItems + 1) * pageItems) % totalItems; - } else { - selectorIndex = (selectorIndex + 1) % totalItems; - } - updateRequired = true; - } -} - -void EpubReaderChapterSelectionActivity::displayTaskLoop() { - while (true) { - if (updateRequired && !subActivity) { - updateRequired = false; - xSemaphoreTake(renderingMutex, portMAX_DELAY); - renderScreen(); - xSemaphoreGive(renderingMutex); - } - vTaskDelay(10 / portTICK_PERIOD_MS); - } -} - -void EpubReaderChapterSelectionActivity::renderScreen() { - renderer.clearScreen(); - - const auto pageWidth = renderer.getScreenWidth(); - const int pageItems = getPageItems(); - const int totalItems = getTotalItems(); - - const std::string title = - renderer.truncatedText(UI_12_FONT_ID, epub->getTitle().c_str(), pageWidth - 40, EpdFontFamily::BOLD); - renderer.drawCenteredText(UI_12_FONT_ID, 15, title.c_str(), true, EpdFontFamily::BOLD); - - const auto pageStartIndex = selectorIndex / pageItems * pageItems; - renderer.fillRect(0, 60 + (selectorIndex % pageItems) * 30 - 2, pageWidth - 1, 30); - - for (int i = 0; i < pageItems; i++) { - int itemIndex = pageStartIndex + i; - if (itemIndex >= totalItems) break; - - const int displayY = 60 + i * 30; - const bool isSelected = (itemIndex == selectorIndex); - - if (isSyncItem(itemIndex)) { - renderer.drawText(UI_10_FONT_ID, 20, displayY, ">> Sync Progress", !isSelected); - } else { - const int tocIndex = tocIndexFromItemIndex(itemIndex); - - if (tocIndex == -1) { - renderer.drawText(UI_10_FONT_ID, 20, displayY, "Unnamed", !isSelected); - } else { - // Master's rendering logic - auto item = epub->getTocItem(tocIndex); - - const int indentSize = 20 + (item.level - 1) * 15; - const std::string chapterName = - renderer.truncatedText(UI_10_FONT_ID, item.title.c_str(), pageWidth - 40 - indentSize); - - renderer.drawText(UI_10_FONT_ID, indentSize, displayY, chapterName.c_str(), !isSelected); - } - } - } - - 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/EpubReaderChapterSelectionActivity.h b/src/activities/reader/EpubReaderChapterSelectionActivity.h deleted file mode 100644 index 8726b453..00000000 --- a/src/activities/reader/EpubReaderChapterSelectionActivity.h +++ /dev/null @@ -1,70 +0,0 @@ -#pragma once -#include -#include -#include -#include - -#include -#include - -#include "../ActivityWithSubactivity.h" - -class EpubReaderChapterSelectionActivity final : public ActivityWithSubactivity { - std::shared_ptr epub; - std::string epubPath; - TaskHandle_t displayTaskHandle = nullptr; - SemaphoreHandle_t renderingMutex = nullptr; - int currentSpineIndex = 0; - int currentPage = 0; - int totalPagesInSpine = 0; - int selectorIndex = 0; - bool updateRequired = false; - const std::function onGoBack; - const std::function onSelectSpineIndex; - const std::function onSyncPosition; - - // Number of items that fit on a page, derived from logical screen height. - // This adapts automatically when switching between portrait and landscape. - int getPageItems() const; - - // Total items including sync options (top and bottom) - int getTotalItems() const; - - // Check if sync option is available (credentials configured) - bool hasSyncOption() const; - - // Check if given item index is a sync option (first or last) - bool isSyncItem(int index) const; - - // Convert item index to TOC index (accounting for top sync option offset) - int tocIndexFromItemIndex(int itemIndex) const; - - // Filtered list of spine indices (excluding footnote pages) - std::vector filteredSpineIndices; - - static void taskTrampoline(void* param); - [[noreturn]] void displayTaskLoop(); - void renderScreen(); - void buildFilteredChapterList(); - void launchSyncActivity(); - - public: - explicit EpubReaderChapterSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, - const std::shared_ptr& epub, const std::string& epubPath, - const int currentSpineIndex, const int currentPage, - const int totalPagesInSpine, const std::function& onGoBack, - const std::function& onSelectSpineIndex, - const std::function& onSyncPosition) - : ActivityWithSubactivity("EpubReaderChapterSelection", renderer, mappedInput), - epub(epub), - epubPath(epubPath), - currentSpineIndex(currentSpineIndex), - currentPage(currentPage), - totalPagesInSpine(totalPagesInSpine), - onGoBack(onGoBack), - onSelectSpineIndex(onSelectSpineIndex), - onSyncPosition(onSyncPosition) {} - void onEnter() override; - void onExit() override; - void loop() override; -}; diff --git a/src/activities/reader/EpubReaderMenuActivity.cpp b/src/activities/reader/EpubReaderMenuActivity.cpp deleted file mode 100644 index bc77c661..00000000 --- a/src/activities/reader/EpubReaderMenuActivity.cpp +++ /dev/null @@ -1,96 +0,0 @@ -#include "EpubReaderMenuActivity.h" - -#include -#include - -#include "MappedInputManager.h" -#include "fontIds.h" - -constexpr int MENU_ITEMS_COUNT = 2; - -void EpubReaderMenuActivity::taskTrampoline(void* param) { - auto* self = static_cast(param); - self->displayTaskLoop(); -} - -void EpubReaderMenuActivity::onEnter() { - renderingMutex = xSemaphoreCreateMutex(); - selectorIndex = 0; - - // Trigger first update - updateRequired = true; - xTaskCreate(&EpubReaderMenuActivity::taskTrampoline, "EpubReaderMenuTask", - 2048, // Stack size - this, // Parameters - 1, // Priority - &displayTaskHandle // Task handle - ); -} - -void EpubReaderMenuActivity::onExit() { - // Wait until not rendering to delete task to avoid killing mid-instruction to EPD - xSemaphoreTake(renderingMutex, portMAX_DELAY); - if (displayTaskHandle) { - vTaskDelete(displayTaskHandle); - displayTaskHandle = nullptr; - } - vSemaphoreDelete(renderingMutex); - renderingMutex = nullptr; -} - -void EpubReaderMenuActivity::loop() { - const bool prevReleased = mappedInput.wasReleased(MappedInputManager::Button::Up) || - mappedInput.wasReleased(MappedInputManager::Button::Left); - const bool nextReleased = mappedInput.wasReleased(MappedInputManager::Button::Down) || - mappedInput.wasReleased(MappedInputManager::Button::Right); - - if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { - onSelectOption(static_cast(selectorIndex)); - } else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { - onGoBack(); - } else if (prevReleased) { - selectorIndex = (selectorIndex + MENU_ITEMS_COUNT - 1) % MENU_ITEMS_COUNT; - updateRequired = true; - } else if (nextReleased) { - selectorIndex = (selectorIndex + 1) % MENU_ITEMS_COUNT; - updateRequired = true; - } -} - -void EpubReaderMenuActivity::displayTaskLoop() { - while (true) { - if (updateRequired) { - updateRequired = false; - xSemaphoreTake(renderingMutex, portMAX_DELAY); - renderScreen(); - xSemaphoreGive(renderingMutex); - } - vTaskDelay(10 / portTICK_PERIOD_MS); - } -} - -void EpubReaderMenuActivity::renderScreen() { - renderer.clearScreen(); - - const auto pageWidth = renderer.getScreenWidth(); - renderer.drawCenteredText(UI_12_FONT_ID, 10, "Menu", true, EpdFontFamily::BOLD); - - const char* menuItems[MENU_ITEMS_COUNT] = {"Go to chapter", "View footnotes"}; - - const int startY = 100; - const int itemHeight = 40; - - for (int i = 0; i < MENU_ITEMS_COUNT; i++) { - const int y = startY + i * itemHeight; - - // Draw selection indicator - if (i == selectorIndex) { - renderer.fillRect(10, y + 2, pageWidth - 20, itemHeight - 4); - renderer.drawText(UI_12_FONT_ID, 30, y, menuItems[i], false); - } else { - renderer.drawText(UI_12_FONT_ID, 30, y, menuItems[i], true); - } - } - - renderer.displayBuffer(); -} diff --git a/src/activities/reader/EpubReaderMenuActivity.h b/src/activities/reader/EpubReaderMenuActivity.h deleted file mode 100644 index 64706a46..00000000 --- a/src/activities/reader/EpubReaderMenuActivity.h +++ /dev/null @@ -1,33 +0,0 @@ -#pragma once -#include -#include -#include - -#include "../Activity.h" - -class EpubReaderMenuActivity final : public Activity { - public: - enum MenuOption { CHAPTERS, FOOTNOTES }; - - private: - TaskHandle_t displayTaskHandle = nullptr; - SemaphoreHandle_t renderingMutex = nullptr; - int selectorIndex = 0; - bool updateRequired = false; - const std::function onGoBack; - const std::function onSelectOption; - - static void taskTrampoline(void* param); - [[noreturn]] void displayTaskLoop(); - void renderScreen(); - - public: - explicit EpubReaderMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, - const std::function& onGoBack, - const std::function& onSelectOption) - : Activity("EpubReaderMenu", renderer, mappedInput), onGoBack(onGoBack), onSelectOption(onSelectOption) {} - - void onEnter() override; - void onExit() override; - void loop() override; -}; diff --git a/src/activities/reader/EpubReaderTocActivity.cpp b/src/activities/reader/EpubReaderTocActivity.cpp new file mode 100644 index 00000000..e3369369 --- /dev/null +++ b/src/activities/reader/EpubReaderTocActivity.cpp @@ -0,0 +1,132 @@ +#include "EpubReaderTocActivity.h" + +#include +#include + +#include "KOReaderSyncActivity.h" +#include "MappedInputManager.h" +#include "ScreenComponents.h" +#include "fontIds.h" + +namespace { +constexpr int TAB_BAR_Y = 15; +constexpr int CONTENT_START_Y = 60; +} // namespace + +void EpubReaderTocActivity::taskTrampoline(void* param) { + auto* self = static_cast(param); + self->displayTaskLoop(); +} + +void EpubReaderTocActivity::onEnter() { + ActivityWithSubactivity::onEnter(); + renderingMutex = xSemaphoreCreateMutex(); + + chaptersTab->onEnter(); + footnotesTab->onEnter(); + + updateRequired = true; + xTaskCreate(&EpubReaderTocActivity::taskTrampoline, "EpubReaderTocTask", 4096, this, 1, &displayTaskHandle); +} + +void EpubReaderTocActivity::onExit() { + ActivityWithSubactivity::onExit(); + xSemaphoreTake(renderingMutex, portMAX_DELAY); + if (displayTaskHandle) { + vTaskDelete(displayTaskHandle); + displayTaskHandle = nullptr; + } + vSemaphoreDelete(renderingMutex); + renderingMutex = nullptr; +} + +void EpubReaderTocActivity::launchSyncActivity() { + xSemaphoreTake(renderingMutex, portMAX_DELAY); + exitActivity(); + enterNewActivity(new KOReaderSyncActivity( + renderer, mappedInput, epub, epubPath, currentSpineIndex, currentPage, totalPagesInSpine, + [this]() { + // On cancel + exitActivity(); + updateRequired = true; + }, + [this](int newSpineIndex, int newPage) { + // On sync complete + exitActivity(); + onSyncPosition(newSpineIndex, newPage); + })); + xSemaphoreGive(renderingMutex); +} + +void EpubReaderTocActivity::loop() { + if (subActivity) { + subActivity->loop(); + return; + } + + if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { + onGoBack(); + return; + } + + const bool leftReleased = mappedInput.wasReleased(MappedInputManager::Button::Left); + const bool rightReleased = mappedInput.wasReleased(MappedInputManager::Button::Right); + + if (leftReleased && currentTab == Tab::FOOTNOTES) { + currentTab = Tab::CHAPTERS; + updateRequired = true; + return; + } + if (rightReleased && currentTab == Tab::CHAPTERS) { + currentTab = Tab::FOOTNOTES; + updateRequired = true; + return; + } + + getCurrentTab()->loop(); + if (getCurrentTab()->isUpdateRequired()) { + updateRequired = true; + } +} + +void EpubReaderTocActivity::displayTaskLoop() { + while (true) { + if (updateRequired && !subActivity) { + updateRequired = false; + xSemaphoreTake(renderingMutex, portMAX_DELAY); + renderScreen(); + xSemaphoreGive(renderingMutex); + } + vTaskDelay(10 / portTICK_PERIOD_MS); + } +} + +void EpubReaderTocActivity::renderScreen() { + renderer.clearScreen(); + + // Draw tab bar + std::vector tabs = {{"Chapters", currentTab == Tab::CHAPTERS}, {"Footnotes", currentTab == Tab::FOOTNOTES}}; + ScreenComponents::drawTabBar(renderer, TAB_BAR_Y, tabs); + + const int screenHeight = renderer.getScreenHeight(); + const int contentHeight = screenHeight - CONTENT_START_Y - 60; + + getCurrentTab()->render(CONTENT_START_Y, contentHeight); + + // Draw scroll indicator + ScreenComponents::drawScrollIndicator(renderer, getCurrentTab()->getCurrentPage(), getCurrentTab()->getTotalPages(), + CONTENT_START_Y, contentHeight); + + // Draw button hints + const auto labels = mappedInput.mapLabels("« Back", "Select", "< Tab", "Tab >"); + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + + renderer.drawSideButtonHints(UI_10_FONT_ID, ">", "<"); + + renderer.displayBuffer(); +} + +TocTab* EpubReaderTocActivity::getCurrentTab() const { + return (currentTab == Tab::CHAPTERS) ? static_cast(chaptersTab.get()) + : static_cast(footnotesTab.get()); +} diff --git a/src/activities/reader/EpubReaderTocActivity.h b/src/activities/reader/EpubReaderTocActivity.h new file mode 100644 index 00000000..fce847a4 --- /dev/null +++ b/src/activities/reader/EpubReaderTocActivity.h @@ -0,0 +1,75 @@ +#pragma once +#include +#include +#include + +#include +#include + +#include "../ActivityWithSubactivity.h" +#include "ChaptersTab.h" +#include "FootnotesTab.h" + +class EpubReaderTocActivity final : public ActivityWithSubactivity { + public: + enum class Tab { CHAPTERS, FOOTNOTES }; + + private: + std::shared_ptr epub; + std::string epubPath; + const FootnotesData& footnotes; + + TaskHandle_t displayTaskHandle = nullptr; + SemaphoreHandle_t renderingMutex = nullptr; + + int currentSpineIndex = 0; + int currentPage = 0; + int totalPagesInSpine = 0; + + Tab currentTab = Tab::CHAPTERS; + std::unique_ptr chaptersTab; + std::unique_ptr footnotesTab; + + bool updateRequired = false; + + const std::function onGoBack; + const std::function onSelectSpineIndex; + const std::function onSelectFootnote; + const std::function onSyncPosition; + + static void taskTrampoline(void* param); + [[noreturn]] void displayTaskLoop(); + void renderScreen(); + TocTab* getCurrentTab() const; + + public: + EpubReaderTocActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::shared_ptr& epub, + const std::string& epubPath, int currentSpineIndex, int currentPage, int totalPagesInSpine, + const FootnotesData& footnotes, std::function onGoBack, + std::function onSelectSpineIndex, std::function onSelectFootnote, + std::function onSyncPosition) + : ActivityWithSubactivity("EpubReaderToc", renderer, mappedInput), + epub(epub), + epubPath(epubPath), + currentSpineIndex(currentSpineIndex), + currentPage(currentPage), + totalPagesInSpine(totalPagesInSpine), + footnotes(footnotes), + onGoBack(onGoBack), + onSelectSpineIndex(onSelectSpineIndex), + onSelectFootnote(onSelectFootnote), + onSyncPosition(onSyncPosition) { + chaptersTab = std::unique_ptr(new ChaptersTab( + renderer, mappedInput, epub, currentSpineIndex, + [this](int spineIndex) { this->onSelectSpineIndex(spineIndex); }, + [this]() { this->launchSyncActivity(); })); + footnotesTab = std::unique_ptr(new FootnotesTab( + renderer, mappedInput, footnotes, [this](const char* href) { this->onSelectFootnote(href); })); + } + + void onEnter() override; + void onExit() override; + void loop() override; + + void launchSyncActivity(); +}; diff --git a/src/activities/reader/EpubReaderFootnotesActivity.cpp b/src/activities/reader/FootnotesTab.cpp similarity index 51% rename from src/activities/reader/EpubReaderFootnotesActivity.cpp rename to src/activities/reader/FootnotesTab.cpp index b61bcd22..d374aca8 100644 --- a/src/activities/reader/EpubReaderFootnotesActivity.cpp +++ b/src/activities/reader/FootnotesTab.cpp @@ -1,4 +1,4 @@ -#include "EpubReaderFootnotesActivity.h" +#include "FootnotesTab.h" #include #include @@ -6,30 +6,16 @@ #include "MappedInputManager.h" #include "fontIds.h" -void EpubReaderFootnotesActivity::onEnter() { +namespace { +constexpr int LINE_HEIGHT = 40; +} + +void FootnotesTab::onEnter() { selectedIndex = 0; - render(); + updateRequired = true; } -void EpubReaderFootnotesActivity::onExit() { - // Nothing to clean up -} - -void EpubReaderFootnotesActivity::loop() { - if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { - onGoBack(); - return; - } - - if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { - const FootnoteEntry* entry = footnotes.getEntry(selectedIndex); - if (entry) { - Serial.printf("[%lu] [FNS] Selected footnote: %s -> %s\n", millis(), entry->number, entry->href); - onSelectFootnote(entry->href); - } - return; - } - +void FootnotesTab::loop() { bool needsRedraw = false; if (mappedInput.wasPressed(MappedInputManager::Button::Up)) { @@ -46,35 +32,32 @@ void EpubReaderFootnotesActivity::loop() { } } + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + const FootnoteEntry* entry = footnotes.getEntry(selectedIndex); + if (entry) { + onSelectFootnote(entry->href); + } + } + if (needsRedraw) { - render(); + updateRequired = true; } } -void EpubReaderFootnotesActivity::render() { - renderer.clearScreen(); - - constexpr int startY = 50; - constexpr int lineHeight = 40; - constexpr int marginLeft = 20; - - // Title - renderer.drawText(UI_12_FONT_ID, marginLeft, 20, "Footnotes", EpdFontFamily::BOLD); +void FootnotesTab::render(int contentTop, int contentHeight) { + const int marginLeft = 20; if (footnotes.getCount() == 0) { - renderer.drawText(SMALL_FONT_ID, marginLeft, startY + 20, "No footnotes on this page"); - renderer.displayBuffer(); + renderer.drawText(SMALL_FONT_ID, marginLeft, contentTop + 20, "No footnotes on this page"); return; } - // Display footnotes for (int i = 0; i < footnotes.getCount(); i++) { const FootnoteEntry* entry = footnotes.getEntry(i); if (!entry) continue; - const int y = startY + i * lineHeight; + const int y = contentTop + i * LINE_HEIGHT; - // Draw selection indicator (arrow) if (i == selectedIndex) { renderer.drawText(UI_12_FONT_ID, marginLeft - 10, y, ">", EpdFontFamily::BOLD); renderer.drawText(UI_12_FONT_ID, marginLeft + 10, y, entry->number, EpdFontFamily::BOLD); @@ -82,10 +65,7 @@ void EpubReaderFootnotesActivity::render() { renderer.drawText(UI_12_FONT_ID, marginLeft + 10, y, entry->number); } } - - // Instructions at bottom - renderer.drawText(SMALL_FONT_ID, marginLeft, renderer.getScreenHeight() - 40, - "UP/DOWN: Select CONFIRM: Go to footnote BACK: Return"); - - renderer.displayBuffer(); } + +int FootnotesTab::getCurrentPage() const { return 1; } +int FootnotesTab::getTotalPages() const { return 1; } diff --git a/src/activities/reader/EpubReaderFootnotesActivity.h b/src/activities/reader/FootnotesTab.h similarity index 58% rename from src/activities/reader/EpubReaderFootnotesActivity.h rename to src/activities/reader/FootnotesTab.h index e9fe4b64..fb5f5856 100644 --- a/src/activities/reader/EpubReaderFootnotesActivity.h +++ b/src/activities/reader/FootnotesTab.h @@ -1,10 +1,9 @@ #pragma once #include #include -#include #include "../../lib/Epub/Epub/FootnoteEntry.h" -#include "../Activity.h" +#include "TocTab.h" class FootnotesData { private: @@ -47,26 +46,24 @@ class FootnotesData { } }; -class EpubReaderFootnotesActivity final : public Activity { +class FootnotesTab final : public TocTab { const FootnotesData& footnotes; - const std::function onGoBack; - const std::function onSelectFootnote; - int selectedIndex; + int selectedIndex = 0; + bool updateRequired = false; + + const std::function onSelectFootnote; public: - EpubReaderFootnotesActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const FootnotesData& footnotes, - const std::function& onGoBack, - const std::function& onSelectFootnote) - : Activity("EpubReaderFootnotes", renderer, mappedInput), - footnotes(footnotes), - onGoBack(onGoBack), - onSelectFootnote(onSelectFootnote), - selectedIndex(0) {} + FootnotesTab(GfxRenderer& renderer, MappedInputManager& mappedInput, const FootnotesData& footnotes, + std::function onSelectFootnote) + : TocTab(renderer, mappedInput), footnotes(footnotes), onSelectFootnote(onSelectFootnote) {} void onEnter() override; - void onExit() override; void loop() override; + void render(int contentTop, int contentHeight) override; - private: - void render(); -}; \ No newline at end of file + int getCurrentPage() const override; + int getTotalPages() const override; + bool isUpdateRequired() const override { return updateRequired; } + void clearUpdateRequired() override { updateRequired = false; } +}; diff --git a/src/activities/reader/TocTab.h b/src/activities/reader/TocTab.h new file mode 100644 index 00000000..37deb969 --- /dev/null +++ b/src/activities/reader/TocTab.h @@ -0,0 +1,24 @@ +#pragma once + +class GfxRenderer; +class MappedInputManager; + +class TocTab { + protected: + GfxRenderer& renderer; + MappedInputManager& mappedInput; + + public: + TocTab(GfxRenderer& renderer, MappedInputManager& mappedInput) : renderer(renderer), mappedInput(mappedInput) {} + virtual ~TocTab() = default; + + virtual void onEnter() = 0; + virtual void onExit() {} + virtual void loop() = 0; + virtual void render(int contentTop, int contentHeight) = 0; + + virtual int getCurrentPage() const = 0; + virtual int getTotalPages() const = 0; + virtual bool isUpdateRequired() const = 0; + virtual void clearUpdateRequired() = 0; +};