From 8288cd2890a1749845be3dfc5470fa19a765f168 Mon Sep 17 00:00:00 2001 From: cottongin Date: Tue, 27 Jan 2026 22:28:11 -0500 Subject: [PATCH 1/7] wishlist cleanup, clean branch start --- CrossPoint-ef.md | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/CrossPoint-ef.md b/CrossPoint-ef.md index 864871e..f0de613 100644 --- a/CrossPoint-ef.md +++ b/CrossPoint-ef.md @@ -1,12 +1,11 @@ ## Feature Requests: -1) Ability to clear all books and clear individual books from Recents. +1) search for books/library 2) Bookmarks -2a) crosspoint logo on firmware flashing screen -3) ability to add/remove books from lists on device. -4) quick menu -5) hide "system folders" from files view +3) quick menu +4) crosspoint logo on firmware flashing screen +5) ability to add/remove books from lists on device. +6) hide "system folders" from files view - dictionaries/ -6) sorting options for files view -7) search for books/library +7) sorting options for files view 8) Time spent reading tracking \ No newline at end of file From 4080184b27be53ae64c2916469e040668585469e Mon Sep 17 00:00:00 2001 From: cottongin Date: Wed, 28 Jan 2026 02:20:09 -0500 Subject: [PATCH 2/7] feat: Add BookmarkStore and BookmarkListActivity for bookmark management Introduces persistent bookmark storage with JSON-based file format and a dedicated activity for viewing bookmarks organized by book. --- src/BookmarkStore.cpp | 301 +++++++++++++++++++ src/BookmarkStore.h | 63 ++++ src/activities/home/BookmarkListActivity.cpp | 262 ++++++++++++++++ src/activities/home/BookmarkListActivity.h | 65 ++++ 4 files changed, 691 insertions(+) create mode 100644 src/BookmarkStore.cpp create mode 100644 src/BookmarkStore.h create mode 100644 src/activities/home/BookmarkListActivity.cpp create mode 100644 src/activities/home/BookmarkListActivity.h diff --git a/src/BookmarkStore.cpp b/src/BookmarkStore.cpp new file mode 100644 index 0000000..693f176 --- /dev/null +++ b/src/BookmarkStore.cpp @@ -0,0 +1,301 @@ +#include "BookmarkStore.h" + +#include +#include +#include + +#include +#include + +#include "util/StringUtils.h" + +// Include the BookmarkedBook struct definition +#include "activities/home/MyLibraryActivity.h" + +namespace { +constexpr uint8_t BOOKMARKS_FILE_VERSION = 1; +constexpr char BOOKMARKS_FILENAME[] = "bookmarks.bin"; +constexpr int MAX_BOOKMARKS_PER_BOOK = 100; + +// Get cache directory path for a book (same logic as BookManager) +std::string getCacheDir(const std::string& bookPath) { + const size_t hash = std::hash{}(bookPath); + + if (StringUtils::checkFileExtension(bookPath, ".epub")) { + return "/.crosspoint/epub_" + std::to_string(hash); + } else if (StringUtils::checkFileExtension(bookPath, ".txt") || + StringUtils::checkFileExtension(bookPath, ".TXT") || + StringUtils::checkFileExtension(bookPath, ".md")) { + return "/.crosspoint/txt_" + std::to_string(hash); + } + return ""; +} +} // namespace + +std::string BookmarkStore::getBookmarksFilePath(const std::string& bookPath) { + const std::string cacheDir = getCacheDir(bookPath); + if (cacheDir.empty()) return ""; + return cacheDir + "/" + BOOKMARKS_FILENAME; +} + +std::vector BookmarkStore::getBookmarks(const std::string& bookPath) { + std::vector bookmarks; + loadBookmarks(bookPath, bookmarks); + return bookmarks; +} + +bool BookmarkStore::addBookmark(const std::string& bookPath, const Bookmark& bookmark) { + std::vector bookmarks; + loadBookmarks(bookPath, bookmarks); + + // Check if bookmark already exists at this location + auto it = std::find_if(bookmarks.begin(), bookmarks.end(), [&](const Bookmark& b) { + return b.spineIndex == bookmark.spineIndex && b.contentOffset == bookmark.contentOffset; + }); + + if (it != bookmarks.end()) { + Serial.printf("[%lu] [BMS] Bookmark already exists at spine %u, offset %u\n", + millis(), bookmark.spineIndex, bookmark.contentOffset); + return false; + } + + // Add new bookmark + bookmarks.push_back(bookmark); + + // Trim to max size (remove oldest) + if (bookmarks.size() > MAX_BOOKMARKS_PER_BOOK) { + // Sort by timestamp and remove oldest + std::sort(bookmarks.begin(), bookmarks.end(), [](const Bookmark& a, const Bookmark& b) { + return a.timestamp > b.timestamp; // Newest first + }); + bookmarks.resize(MAX_BOOKMARKS_PER_BOOK); + } + + return saveBookmarks(bookPath, bookmarks); +} + +bool BookmarkStore::removeBookmark(const std::string& bookPath, uint16_t spineIndex, uint32_t contentOffset) { + std::vector bookmarks; + loadBookmarks(bookPath, bookmarks); + + auto it = std::find_if(bookmarks.begin(), bookmarks.end(), [&](const Bookmark& b) { + return b.spineIndex == spineIndex && b.contentOffset == contentOffset; + }); + + if (it == bookmarks.end()) { + return false; + } + + bookmarks.erase(it); + Serial.printf("[%lu] [BMS] Removed bookmark at spine %u, offset %u\n", millis(), spineIndex, contentOffset); + + return saveBookmarks(bookPath, bookmarks); +} + +bool BookmarkStore::isPageBookmarked(const std::string& bookPath, uint16_t spineIndex, uint32_t contentOffset) { + std::vector bookmarks; + loadBookmarks(bookPath, bookmarks); + + return std::any_of(bookmarks.begin(), bookmarks.end(), [&](const Bookmark& b) { + return b.spineIndex == spineIndex && b.contentOffset == contentOffset; + }); +} + +int BookmarkStore::getBookmarkCount(const std::string& bookPath) { + const std::string filePath = getBookmarksFilePath(bookPath); + if (filePath.empty()) return 0; + + FsFile inputFile; + if (!SdMan.openFileForRead("BMS", filePath, inputFile)) { + return 0; + } + + uint8_t version; + serialization::readPod(inputFile, version); + if (version != BOOKMARKS_FILE_VERSION) { + inputFile.close(); + return 0; + } + + uint8_t count; + serialization::readPod(inputFile, count); + inputFile.close(); + + return count; +} + +std::vector BookmarkStore::getBooksWithBookmarks() { + std::vector result; + + // Scan /.crosspoint/ directory for cache folders with bookmarks + auto crosspoint = SdMan.open("/.crosspoint"); + if (!crosspoint || !crosspoint.isDirectory()) { + if (crosspoint) crosspoint.close(); + return result; + } + + crosspoint.rewindDirectory(); + char name[256]; + + for (auto entry = crosspoint.openNextFile(); entry; entry = crosspoint.openNextFile()) { + entry.getName(name, sizeof(name)); + + if (!entry.isDirectory()) { + entry.close(); + continue; + } + + // Check if this directory has a bookmarks file + std::string dirPath = "/.crosspoint/"; + dirPath += name; + std::string bookmarksPath = dirPath + "/" + BOOKMARKS_FILENAME; + + if (SdMan.exists(bookmarksPath.c_str())) { + // Read the bookmarks file to get count and book info + FsFile bookmarksFile; + if (SdMan.openFileForRead("BMS", bookmarksPath, bookmarksFile)) { + uint8_t version; + serialization::readPod(bookmarksFile, version); + + if (version == BOOKMARKS_FILE_VERSION) { + uint8_t count; + serialization::readPod(bookmarksFile, count); + + // Read book metadata (stored at end of file) + std::string bookPath, bookTitle, bookAuthor; + + // Skip bookmark entries to get to metadata + for (uint8_t i = 0; i < count; i++) { + std::string tempName; + uint16_t tempSpine; + uint32_t tempOffset, tempTimestamp; + uint16_t tempPage; + serialization::readString(bookmarksFile, tempName); + serialization::readPod(bookmarksFile, tempSpine); + serialization::readPod(bookmarksFile, tempOffset); + serialization::readPod(bookmarksFile, tempPage); + serialization::readPod(bookmarksFile, tempTimestamp); + } + + // Read book metadata + serialization::readString(bookmarksFile, bookPath); + serialization::readString(bookmarksFile, bookTitle); + serialization::readString(bookmarksFile, bookAuthor); + + if (!bookPath.empty() && count > 0) { + BookmarkedBook book; + book.path = bookPath; + book.title = bookTitle; + book.author = bookAuthor; + book.bookmarkCount = count; + result.push_back(book); + } + } + bookmarksFile.close(); + } + } + entry.close(); + } + crosspoint.close(); + + // Sort by title + std::sort(result.begin(), result.end(), [](const BookmarkedBook& a, const BookmarkedBook& b) { + return a.title < b.title; + }); + + return result; +} + +void BookmarkStore::clearBookmarks(const std::string& bookPath) { + const std::string filePath = getBookmarksFilePath(bookPath); + if (filePath.empty()) return; + + SdMan.remove(filePath.c_str()); + Serial.printf("[%lu] [BMS] Cleared all bookmarks for %s\n", millis(), bookPath.c_str()); +} + +bool BookmarkStore::saveBookmarks(const std::string& bookPath, const std::vector& bookmarks) { + const std::string cacheDir = getCacheDir(bookPath); + if (cacheDir.empty()) return false; + + // Make sure the directory exists + SdMan.mkdir(cacheDir.c_str()); + + const std::string filePath = cacheDir + "/" + BOOKMARKS_FILENAME; + + FsFile outputFile; + if (!SdMan.openFileForWrite("BMS", filePath, outputFile)) { + return false; + } + + serialization::writePod(outputFile, BOOKMARKS_FILE_VERSION); + const uint8_t count = static_cast(std::min(bookmarks.size(), static_cast(255))); + serialization::writePod(outputFile, count); + + for (size_t i = 0; i < count; i++) { + const auto& bookmark = bookmarks[i]; + serialization::writeString(outputFile, bookmark.name); + serialization::writePod(outputFile, bookmark.spineIndex); + serialization::writePod(outputFile, bookmark.contentOffset); + serialization::writePod(outputFile, bookmark.pageNumber); + serialization::writePod(outputFile, bookmark.timestamp); + } + + // Store book metadata at end (for getBooksWithBookmarks to read) + // Extract title from path if we don't have it + std::string title = bookPath; + const size_t lastSlash = title.find_last_of('/'); + if (lastSlash != std::string::npos) { + title = title.substr(lastSlash + 1); + } + const size_t dot = title.find_last_of('.'); + if (dot != std::string::npos) { + title.resize(dot); + } + + serialization::writeString(outputFile, bookPath); + serialization::writeString(outputFile, title); + serialization::writeString(outputFile, ""); // Author (not always available) + + outputFile.close(); + Serial.printf("[%lu] [BMS] Bookmarks saved for %s (%d entries)\n", millis(), bookPath.c_str(), count); + return true; +} + +bool BookmarkStore::loadBookmarks(const std::string& bookPath, std::vector& bookmarks) { + bookmarks.clear(); + + const std::string filePath = getBookmarksFilePath(bookPath); + if (filePath.empty()) return false; + + FsFile inputFile; + if (!SdMan.openFileForRead("BMS", filePath, inputFile)) { + return false; + } + + uint8_t version; + serialization::readPod(inputFile, version); + if (version != BOOKMARKS_FILE_VERSION) { + Serial.printf("[%lu] [BMS] Unknown bookmarks file version: %u\n", millis(), version); + inputFile.close(); + return false; + } + + uint8_t count; + serialization::readPod(inputFile, count); + bookmarks.reserve(count); + + for (uint8_t i = 0; i < count; i++) { + Bookmark bookmark; + serialization::readString(inputFile, bookmark.name); + serialization::readPod(inputFile, bookmark.spineIndex); + serialization::readPod(inputFile, bookmark.contentOffset); + serialization::readPod(inputFile, bookmark.pageNumber); + serialization::readPod(inputFile, bookmark.timestamp); + bookmarks.push_back(bookmark); + } + + inputFile.close(); + Serial.printf("[%lu] [BMS] Bookmarks loaded for %s (%d entries)\n", millis(), bookPath.c_str(), count); + return true; +} diff --git a/src/BookmarkStore.h b/src/BookmarkStore.h new file mode 100644 index 0000000..1ec1d66 --- /dev/null +++ b/src/BookmarkStore.h @@ -0,0 +1,63 @@ +#pragma once +#include +#include + +// Forward declaration for BookmarkedBook (used by MyLibraryActivity) +struct BookmarkedBook; + +// A single bookmark within a book +struct Bookmark { + std::string name; // Display name (e.g., "Chapter 1 - Page 42") + uint16_t spineIndex = 0; // For EPUB: which spine item + uint32_t contentOffset = 0; // Content offset for stable positioning + uint16_t pageNumber = 0; // Page number at time of bookmark (for display) + uint32_t timestamp = 0; // Unix timestamp when created + + bool operator==(const Bookmark& other) const { + return spineIndex == other.spineIndex && contentOffset == other.contentOffset; + } +}; + +/** + * BookmarkStore manages bookmarks for books. + * Bookmarks are stored per-book in the book's cache directory: + * /.crosspoint/{epub_|txt_}/bookmarks.bin + * + * This is a static utility class, not a singleton, since bookmarks + * are loaded/saved on demand for specific books. + */ +class BookmarkStore { + public: + // Get all bookmarks for a book + static std::vector getBookmarks(const std::string& bookPath); + + // Add a bookmark to a book + // Returns true if added, false if bookmark already exists at that location + static bool addBookmark(const std::string& bookPath, const Bookmark& bookmark); + + // Remove a bookmark from a book by content offset + // Returns true if removed, false if not found + static bool removeBookmark(const std::string& bookPath, uint16_t spineIndex, uint32_t contentOffset); + + // Check if a specific page is bookmarked + static bool isPageBookmarked(const std::string& bookPath, uint16_t spineIndex, uint32_t contentOffset); + + // Get count of bookmarks for a book (without loading all data) + static int getBookmarkCount(const std::string& bookPath); + + // Get all books that have bookmarks (for Bookmarks tab) + static std::vector getBooksWithBookmarks(); + + // Delete all bookmarks for a book + static void clearBookmarks(const std::string& bookPath); + + private: + // Get the bookmarks file path for a book + static std::string getBookmarksFilePath(const std::string& bookPath); + + // Save bookmarks to file + static bool saveBookmarks(const std::string& bookPath, const std::vector& bookmarks); + + // Load bookmarks from file + static bool loadBookmarks(const std::string& bookPath, std::vector& bookmarks); +}; diff --git a/src/activities/home/BookmarkListActivity.cpp b/src/activities/home/BookmarkListActivity.cpp new file mode 100644 index 0000000..93e45dc --- /dev/null +++ b/src/activities/home/BookmarkListActivity.cpp @@ -0,0 +1,262 @@ +#include "BookmarkListActivity.h" + +#include + +#include "BookmarkStore.h" +#include "MappedInputManager.h" +#include "ScreenComponents.h" +#include "fontIds.h" + +namespace { +constexpr int BASE_TAB_BAR_Y = 15; +constexpr int BASE_CONTENT_START_Y = 60; +constexpr int LINE_HEIGHT = 50; // Taller for bookmark name + location +constexpr int BASE_LEFT_MARGIN = 20; +constexpr int BASE_RIGHT_MARGIN = 40; +constexpr unsigned long ACTION_MENU_MS = 700; // Long press to delete +} // namespace + +int BookmarkListActivity::getPageItems() const { + const int screenHeight = renderer.getScreenHeight(); + const int bottomBarHeight = 60; + const int bezelTop = renderer.getBezelOffsetTop(); + const int bezelBottom = renderer.getBezelOffsetBottom(); + const int availableHeight = screenHeight - (BASE_CONTENT_START_Y + bezelTop) - bottomBarHeight - bezelBottom; + int items = availableHeight / LINE_HEIGHT; + if (items < 1) items = 1; + return items; +} + +int BookmarkListActivity::getTotalPages() const { + const int itemCount = static_cast(bookmarks.size()); + const int pageItems = getPageItems(); + if (itemCount == 0) return 1; + return (itemCount + pageItems - 1) / pageItems; +} + +int BookmarkListActivity::getCurrentPage() const { + const int pageItems = getPageItems(); + return selectorIndex / pageItems + 1; +} + +void BookmarkListActivity::loadBookmarks() { + bookmarks = BookmarkStore::getBookmarks(bookPath); +} + +void BookmarkListActivity::taskTrampoline(void* param) { + auto* self = static_cast(param); + self->displayTaskLoop(); +} + +void BookmarkListActivity::onEnter() { + Activity::onEnter(); + + renderingMutex = xSemaphoreCreateMutex(); + + loadBookmarks(); + selectorIndex = 0; + updateRequired = true; + + xTaskCreate(&BookmarkListActivity::taskTrampoline, "BookmarkListTask", + 2048, // Stack size + this, // Parameters + 1, // Priority + &displayTaskHandle // Task handle + ); +} + +void BookmarkListActivity::onExit() { + Activity::onExit(); + + xSemaphoreTake(renderingMutex, portMAX_DELAY); + if (displayTaskHandle) { + vTaskDelete(displayTaskHandle); + displayTaskHandle = nullptr; + vTaskDelay(10 / portTICK_PERIOD_MS); + } + vSemaphoreDelete(renderingMutex); + renderingMutex = nullptr; + + bookmarks.clear(); +} + +void BookmarkListActivity::loop() { + // Handle confirmation state + if (uiState == UIState::Confirming) { + if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { + uiState = UIState::Normal; + updateRequired = true; + return; + } + + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + // Delete the bookmark + if (!bookmarks.empty() && selectorIndex < static_cast(bookmarks.size())) { + const auto& bm = bookmarks[selectorIndex]; + BookmarkStore::removeBookmark(bookPath, bm.spineIndex, bm.contentOffset); + loadBookmarks(); + + // Adjust selector if needed + if (selectorIndex >= static_cast(bookmarks.size()) && !bookmarks.empty()) { + selectorIndex = static_cast(bookmarks.size()) - 1; + } else if (bookmarks.empty()) { + selectorIndex = 0; + } + } + uiState = UIState::Normal; + updateRequired = true; + return; + } + + return; + } + + // Normal state handling + const int itemCount = static_cast(bookmarks.size()); + const int pageItems = getPageItems(); + + // Long press Confirm to delete bookmark + if (mappedInput.isPressed(MappedInputManager::Button::Confirm) && + mappedInput.getHeldTime() >= ACTION_MENU_MS && !bookmarks.empty() && + selectorIndex < itemCount) { + uiState = UIState::Confirming; + updateRequired = true; + return; + } + + // Short press Confirm - navigate to bookmark + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + if (mappedInput.getHeldTime() >= ACTION_MENU_MS) { + return; // Was a long press + } + + if (!bookmarks.empty() && selectorIndex < itemCount) { + const auto& bm = bookmarks[selectorIndex]; + onSelectBookmark(bm.spineIndex, bm.contentOffset); + } + return; + } + + // Back button + if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { + onGoBack(); + return; + } + + // Navigation + const bool upReleased = mappedInput.wasReleased(MappedInputManager::Button::Up); + const bool downReleased = mappedInput.wasReleased(MappedInputManager::Button::Down); + + if (upReleased && itemCount > 0) { + selectorIndex = (selectorIndex + itemCount - 1) % itemCount; + updateRequired = true; + } else if (downReleased && itemCount > 0) { + selectorIndex = (selectorIndex + 1) % itemCount; + updateRequired = true; + } +} + +void BookmarkListActivity::displayTaskLoop() { + while (true) { + if (updateRequired) { + updateRequired = false; + xSemaphoreTake(renderingMutex, portMAX_DELAY); + render(); + xSemaphoreGive(renderingMutex); + } + vTaskDelay(10 / portTICK_PERIOD_MS); + } +} + +void BookmarkListActivity::render() const { + renderer.clearScreen(); + + if (uiState == UIState::Confirming) { + renderConfirmation(); + renderer.displayBuffer(); + return; + } + + const auto pageWidth = renderer.getScreenWidth(); + const int pageItems = getPageItems(); + const int itemCount = static_cast(bookmarks.size()); + + // Calculate bezel-adjusted margins + const int bezelTop = renderer.getBezelOffsetTop(); + const int bezelLeft = renderer.getBezelOffsetLeft(); + const int bezelRight = renderer.getBezelOffsetRight(); + const int bezelBottom = renderer.getBezelOffsetBottom(); + const int CONTENT_START_Y = BASE_CONTENT_START_Y + bezelTop; + const int LEFT_MARGIN = BASE_LEFT_MARGIN + bezelLeft; + const int RIGHT_MARGIN = BASE_RIGHT_MARGIN + bezelRight; + + // Draw title + auto truncatedTitle = renderer.truncatedText(UI_12_FONT_ID, bookTitle.c_str(), pageWidth - LEFT_MARGIN - RIGHT_MARGIN); + renderer.drawText(UI_12_FONT_ID, LEFT_MARGIN, BASE_TAB_BAR_Y + bezelTop, truncatedTitle.c_str(), true, EpdFontFamily::BOLD); + + if (itemCount == 0) { + renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, CONTENT_START_Y, "No bookmarks"); + + const auto labels = mappedInput.mapLabels("\xc2\xab Back", "", "", ""); + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + renderer.displayBuffer(); + return; + } + + const auto pageStartIndex = selectorIndex / pageItems * pageItems; + + // Draw selection highlight + renderer.fillRect(bezelLeft, CONTENT_START_Y + (selectorIndex % pageItems) * LINE_HEIGHT - 2, + pageWidth - RIGHT_MARGIN - bezelLeft, LINE_HEIGHT); + + // Draw items + for (int i = pageStartIndex; i < itemCount && i < pageStartIndex + pageItems; i++) { + const auto& bm = bookmarks[i]; + const int y = CONTENT_START_Y + (i % pageItems) * LINE_HEIGHT; + const bool isSelected = (i == selectorIndex); + + // Line 1: Bookmark name + auto truncatedName = renderer.truncatedText(UI_10_FONT_ID, bm.name.c_str(), pageWidth - LEFT_MARGIN - RIGHT_MARGIN); + renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, y + 2, truncatedName.c_str(), !isSelected); + + // Line 2: Location info + std::string locText = "Page " + std::to_string(bm.pageNumber + 1); + renderer.drawText(SMALL_FONT_ID, LEFT_MARGIN, y + 26, locText.c_str(), !isSelected); + } + + // Draw scroll indicator + const int screenHeight = renderer.getScreenHeight(); + const int contentHeight = screenHeight - CONTENT_START_Y - 60 - bezelBottom; + ScreenComponents::drawScrollIndicator(renderer, getCurrentPage(), getTotalPages(), CONTENT_START_Y, contentHeight); + + // Draw side button hints + renderer.drawSideButtonHints(UI_10_FONT_ID, ">", "<"); + + // Draw bottom button hints + const auto labels = mappedInput.mapLabels("\xc2\xab Back", "Go to", "", ""); + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + + renderer.displayBuffer(); +} + +void BookmarkListActivity::renderConfirmation() const { + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); + + // Title + renderer.drawCenteredText(UI_12_FONT_ID, 20, "Delete Bookmark?", true, EpdFontFamily::BOLD); + + // Show bookmark name + if (!bookmarks.empty() && selectorIndex < static_cast(bookmarks.size())) { + const auto& bm = bookmarks[selectorIndex]; + auto truncatedName = renderer.truncatedText(UI_10_FONT_ID, bm.name.c_str(), pageWidth - 40); + renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 20, truncatedName.c_str()); + } + + // Warning text + renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 20, "This cannot be undone."); + + // Draw bottom button hints + const auto labels = mappedInput.mapLabels("\xc2\xab Cancel", "Delete", "", ""); + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); +} diff --git a/src/activities/home/BookmarkListActivity.h b/src/activities/home/BookmarkListActivity.h new file mode 100644 index 0000000..75b62a2 --- /dev/null +++ b/src/activities/home/BookmarkListActivity.h @@ -0,0 +1,65 @@ +#pragma once +#include +#include +#include + +#include +#include +#include + +#include "../Activity.h" +#include "BookmarkStore.h" + +/** + * BookmarkListActivity displays all bookmarks for a specific book. + * - Short press: Navigate to bookmark location + * - Long press Confirm: Delete bookmark (with confirmation) + * - Back: Return to previous screen + */ +class BookmarkListActivity final : public Activity { + public: + enum class UIState { Normal, Confirming }; + + private: + TaskHandle_t displayTaskHandle = nullptr; + SemaphoreHandle_t renderingMutex = nullptr; + + std::string bookPath; + std::string bookTitle; + std::vector bookmarks; + int selectorIndex = 0; + bool updateRequired = false; + UIState uiState = UIState::Normal; + + // Callbacks + const std::function onGoBack; + const std::function onSelectBookmark; + + // Number of items that fit on a page + int getPageItems() const; + int getTotalPages() const; + int getCurrentPage() const; + + // Data loading + void loadBookmarks(); + + // Rendering + static void taskTrampoline(void* param); + [[noreturn]] void displayTaskLoop(); + void render() const; + void renderConfirmation() const; + + public: + explicit BookmarkListActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, + const std::string& bookPath, const std::string& bookTitle, + const std::function& onGoBack, + const std::function& onSelectBookmark) + : Activity("BookmarkList", renderer, mappedInput), + bookPath(bookPath), + bookTitle(bookTitle), + onGoBack(onGoBack), + onSelectBookmark(onSelectBookmark) {} + void onEnter() override; + void onExit() override; + void loop() override; +}; From e991fb10a6aa4c579452ed6c5c1464e108986543 Mon Sep 17 00:00:00 2001 From: cottongin Date: Wed, 28 Jan 2026 02:20:19 -0500 Subject: [PATCH 3/7] feat: Add QuickMenuActivity for in-reader quick actions Provides a popup menu accessible during reading for quick access to bookmarks, settings, and other common actions. --- src/activities/util/QuickMenuActivity.cpp | 173 ++++++++++++++++++++++ src/activities/util/QuickMenuActivity.h | 46 ++++++ 2 files changed, 219 insertions(+) create mode 100644 src/activities/util/QuickMenuActivity.cpp create mode 100644 src/activities/util/QuickMenuActivity.h diff --git a/src/activities/util/QuickMenuActivity.cpp b/src/activities/util/QuickMenuActivity.cpp new file mode 100644 index 0000000..bfaa896 --- /dev/null +++ b/src/activities/util/QuickMenuActivity.cpp @@ -0,0 +1,173 @@ +#include "QuickMenuActivity.h" + +#include + +#include "MappedInputManager.h" +#include "fontIds.h" + +namespace { +constexpr int MENU_ITEM_COUNT = 4; +const char* MENU_ITEMS[MENU_ITEM_COUNT] = {"Dictionary", "Bookmark", "Clear Cache", "Settings"}; +const char* MENU_DESCRIPTIONS_ADD[MENU_ITEM_COUNT] = { + "Look up a word", + "Add bookmark to this page", + "Free up storage space", + "Open settings menu" +}; +const char* MENU_DESCRIPTIONS_REMOVE[MENU_ITEM_COUNT] = { + "Look up a word", + "Remove bookmark from this page", + "Free up storage space", + "Open settings menu" +}; +} // namespace + +void QuickMenuActivity::taskTrampoline(void* param) { + auto* self = static_cast(param); + self->displayTaskLoop(); +} + +void QuickMenuActivity::onEnter() { + Activity::onEnter(); + + renderingMutex = xSemaphoreCreateMutex(); + + // Reset selection + selectedIndex = 0; + + // Trigger first update + updateRequired = true; + + xTaskCreate(&QuickMenuActivity::taskTrampoline, "QuickMenuTask", + 2048, // Stack size + this, // Parameters + 1, // Priority + &displayTaskHandle // Task handle + ); +} + +void QuickMenuActivity::onExit() { + Activity::onExit(); + + // Wait until not rendering to delete task + xSemaphoreTake(renderingMutex, portMAX_DELAY); + if (displayTaskHandle) { + vTaskDelete(displayTaskHandle); + displayTaskHandle = nullptr; + vTaskDelay(10 / portTICK_PERIOD_MS); // Let idle task free stack + } + vSemaphoreDelete(renderingMutex); + renderingMutex = nullptr; +} + +void QuickMenuActivity::loop() { + // Handle back button - cancel + if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { + onCancel(); + return; + } + + // Handle confirm button - select current option + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + QuickMenuAction action; + switch (selectedIndex) { + case 0: + action = QuickMenuAction::DICTIONARY; + break; + case 1: + action = QuickMenuAction::ADD_BOOKMARK; + break; + case 2: + action = QuickMenuAction::CLEAR_CACHE; + break; + case 3: + default: + action = QuickMenuAction::GO_TO_SETTINGS; + break; + } + onActionSelected(action); + return; + } + + // Handle navigation + const bool prevPressed = mappedInput.wasPressed(MappedInputManager::Button::Up) || + mappedInput.wasPressed(MappedInputManager::Button::Left); + const bool nextPressed = mappedInput.wasPressed(MappedInputManager::Button::Down) || + mappedInput.wasPressed(MappedInputManager::Button::Right); + + if (prevPressed) { + selectedIndex = (selectedIndex + MENU_ITEM_COUNT - 1) % MENU_ITEM_COUNT; + updateRequired = true; + } else if (nextPressed) { + selectedIndex = (selectedIndex + 1) % MENU_ITEM_COUNT; + updateRequired = true; + } +} + +void QuickMenuActivity::displayTaskLoop() { + while (true) { + if (updateRequired) { + updateRequired = false; + xSemaphoreTake(renderingMutex, portMAX_DELAY); + render(); + xSemaphoreGive(renderingMutex); + } + vTaskDelay(10 / portTICK_PERIOD_MS); + } +} + +void QuickMenuActivity::render() const { + renderer.clearScreen(); + + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); + + // Get bezel offsets + const int bezelTop = renderer.getBezelOffsetTop(); + const int bezelLeft = renderer.getBezelOffsetLeft(); + const int bezelRight = renderer.getBezelOffsetRight(); + const int bezelBottom = renderer.getBezelOffsetBottom(); + + // Calculate usable content area + const int marginLeft = 20 + bezelLeft; + const int marginRight = 20 + bezelRight; + const int marginTop = 15 + bezelTop; + const int contentWidth = pageWidth - marginLeft - marginRight; + const int contentHeight = pageHeight - marginTop - 60 - bezelBottom; // 60 for button hints + + // Draw header + renderer.drawCenteredText(UI_12_FONT_ID, marginTop, "Quick Menu", true, EpdFontFamily::BOLD); + + // Select descriptions based on bookmark state + const char* const* descriptions = isPageBookmarked ? MENU_DESCRIPTIONS_REMOVE : MENU_DESCRIPTIONS_ADD; + + // Draw menu items centered in content area + constexpr int itemHeight = 50; // Height for each menu item (including description) + const int startY = marginTop + (contentHeight - (MENU_ITEM_COUNT * itemHeight)) / 2; + + for (int i = 0; i < MENU_ITEM_COUNT; i++) { + const int itemY = startY + i * itemHeight; + const bool isSelected = (i == selectedIndex); + + // Draw selection highlight (black fill) for selected item + if (isSelected) { + renderer.fillRect(marginLeft + 10, itemY - 2, contentWidth - 20, itemHeight - 6); + } + + // Draw menu item text + const char* itemText = MENU_ITEMS[i]; + // For bookmark item, show different text based on state + if (i == 1) { + itemText = isPageBookmarked ? "Remove Bookmark" : "Add Bookmark"; + } + + renderer.drawText(UI_10_FONT_ID, marginLeft + 20, itemY, itemText, !isSelected); + renderer.drawText(SMALL_FONT_ID, marginLeft + 20, itemY + 22, descriptions[i], !isSelected); + } + + // Draw help text at bottom + const auto labels = mappedInput.mapLabels("\xc2\xab Back", "Select", "", ""); + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + + renderer.displayBuffer(); +} diff --git a/src/activities/util/QuickMenuActivity.h b/src/activities/util/QuickMenuActivity.h new file mode 100644 index 0000000..708f80c --- /dev/null +++ b/src/activities/util/QuickMenuActivity.h @@ -0,0 +1,46 @@ +#pragma once +#include +#include +#include + +#include + +#include "../Activity.h" + +// Enum for quick menu selection +enum class QuickMenuAction { DICTIONARY, ADD_BOOKMARK, CLEAR_CACHE, GO_TO_SETTINGS }; + +/** + * QuickMenuActivity presents a quick access menu triggered by short power button press. + * Options: + * - "Dictionary" - Look up a word + * - "Add/Remove Bookmark" - Toggle bookmark on current page + * + * The onActionSelected callback is called with the user's choice. + * The onCancel callback is called if the user presses back. + */ +class QuickMenuActivity final : public Activity { + TaskHandle_t displayTaskHandle = nullptr; + SemaphoreHandle_t renderingMutex = nullptr; + int selectedIndex = 0; + bool updateRequired = false; + const std::function onActionSelected; + const std::function onCancel; + const bool isPageBookmarked; // True if current page already has a bookmark + + static void taskTrampoline(void* param); + [[noreturn]] void displayTaskLoop(); + void render() const; + + public: + explicit QuickMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, + const std::function& onActionSelected, + const std::function& onCancel, bool isPageBookmarked = false) + : Activity("QuickMenu", renderer, mappedInput), + onActionSelected(onActionSelected), + onCancel(onCancel), + isPageBookmarked(isPageBookmarked) {} + void onEnter() override; + void onExit() override; + void loop() override; +}; From 245d5a7dd84902932c881a7a23adccbb0a305312 Mon Sep 17 00:00:00 2001 From: cottongin Date: Wed, 28 Jan 2026 02:20:29 -0500 Subject: [PATCH 4/7] feat: Integrate bookmark support into reader activities Adds bookmark add/remove functionality to EpubReaderActivity and base ReaderActivity, with visual indicator for bookmarked pages. --- src/activities/reader/EpubReaderActivity.cpp | 163 +++++++++++++++++++ src/activities/reader/EpubReaderActivity.h | 10 +- src/activities/reader/ReaderActivity.cpp | 6 +- src/activities/reader/ReaderActivity.h | 10 +- src/activities/reader/TxtReaderActivity.h | 10 +- 5 files changed, 192 insertions(+), 7 deletions(-) diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 1082d93..7bbc4fb 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -8,6 +8,7 @@ #include #include "BookManager.h" +#include "BookmarkStore.h" #include "CrossPointSettings.h" #include "CrossPointState.h" #include "EpubReaderChapterSelectionActivity.h" @@ -17,6 +18,7 @@ #include "activities/dictionary/DictionaryMenuActivity.h" #include "activities/dictionary/DictionarySearchActivity.h" #include "activities/dictionary/EpubWordSelectionActivity.h" +#include "activities/util/QuickMenuActivity.h" #include "fontIds.h" namespace { @@ -366,6 +368,149 @@ void EpubReaderActivity::loop() { return; } + // Quick Menu power button press + if (SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::QUICK_MENU && + mappedInput.wasReleased(MappedInputManager::Button::Power)) { + xSemaphoreTake(renderingMutex, portMAX_DELAY); + + // Check if current page is bookmarked + bool isBookmarked = false; + if (section) { + const uint32_t contentOffset = section->getContentOffsetForPage(section->currentPage); + isBookmarked = BookmarkStore::isPageBookmarked(epub->getPath(), currentSpineIndex, contentOffset); + } + + exitActivity(); + enterNewActivity(new QuickMenuActivity( + renderer, mappedInput, + [this](QuickMenuAction action) { + // Cache values before exitActivity + EpubReaderActivity* self = this; + GfxRenderer& cachedRenderer = renderer; + MappedInputManager& cachedMappedInput = mappedInput; + Section* cachedSection = section.get(); + SemaphoreHandle_t cachedMutex = renderingMutex; + + exitActivity(); + + if (action == QuickMenuAction::DICTIONARY) { + // Open dictionary menu + self->enterNewActivity(new DictionaryMenuActivity( + cachedRenderer, cachedMappedInput, + [self](DictionaryMode mode) { + GfxRenderer& r = self->renderer; + MappedInputManager& m = self->mappedInput; + Section* s = self->section.get(); + SemaphoreHandle_t mtx = self->renderingMutex; + + self->exitActivity(); + + if (mode == DictionaryMode::ENTER_WORD) { + self->enterNewActivity(new DictionarySearchActivity(r, m, + [self]() { + self->exitActivity(); + self->updateRequired = true; + }, "")); + } else if (s) { + xSemaphoreTake(mtx, portMAX_DELAY); + auto page = s->loadPageFromSectionFile(); + if (page) { + int mt, mr, mb, ml; + r.getOrientedViewableTRBL(&mt, &mr, &mb, &ml); + mt += SETTINGS.screenMargin; + ml += SETTINGS.screenMargin; + const int fontId = SETTINGS.getReaderFontId(); + + self->enterNewActivity(new EpubWordSelectionActivity( + r, m, std::move(page), fontId, ml, mt, + [self](const std::string& word) { + self->exitActivity(); + self->enterNewActivity(new DictionarySearchActivity( + self->renderer, self->mappedInput, + [self]() { + self->exitActivity(); + self->updateRequired = true; + }, word)); + }, + [self]() { + self->exitActivity(); + self->updateRequired = true; + })); + xSemaphoreGive(mtx); + } else { + xSemaphoreGive(mtx); + self->updateRequired = true; + } + } else { + self->updateRequired = true; + } + }, + [self]() { + self->exitActivity(); + self->updateRequired = true; + }, + self->section != nullptr)); + } else if (action == QuickMenuAction::ADD_BOOKMARK) { + // Toggle bookmark on current page + if (self->section) { + const uint32_t contentOffset = self->section->getContentOffsetForPage(self->section->currentPage); + const std::string& bookPath = self->epub->getPath(); + + if (BookmarkStore::isPageBookmarked(bookPath, self->currentSpineIndex, contentOffset)) { + // Remove bookmark + BookmarkStore::removeBookmark(bookPath, self->currentSpineIndex, contentOffset); + } else { + // Add bookmark with auto-generated name + Bookmark bm; + bm.spineIndex = self->currentSpineIndex; + bm.contentOffset = contentOffset; + bm.pageNumber = self->section->currentPage; + bm.timestamp = millis() / 1000; // Approximate timestamp + + // Generate name: "Chapter - Page X" or fallback + std::string chapterTitle; + const int tocIndex = self->epub->getTocIndexForSpineIndex(self->currentSpineIndex); + if (tocIndex >= 0) { + chapterTitle = self->epub->getTocItem(tocIndex).title; + } + if (!chapterTitle.empty()) { + bm.name = chapterTitle + " - Page " + std::to_string(self->section->currentPage + 1); + } else { + bm.name = "Page " + std::to_string(self->section->currentPage + 1); + } + + BookmarkStore::addBookmark(bookPath, bm); + } + } + self->updateRequired = true; + } else if (action == QuickMenuAction::CLEAR_CACHE) { + // Navigate to Clear Cache activity + if (self->onGoToClearCache) { + xSemaphoreGive(cachedMutex); + self->onGoToClearCache(); + return; + } + self->updateRequired = true; + } else if (action == QuickMenuAction::GO_TO_SETTINGS) { + // Navigate to Settings activity + if (self->onGoToSettings) { + xSemaphoreGive(cachedMutex); + self->onGoToSettings(); + return; + } + self->updateRequired = true; + } + }, + [this]() { + EpubReaderActivity* self = this; + exitActivity(); + self->updateRequired = true; + }, + isBookmarked)); + xSemaphoreGive(renderingMutex); + return; + } + const bool prevReleased = mappedInput.wasReleased(MappedInputManager::Button::PageBack) || mappedInput.wasReleased(MappedInputManager::Button::Left); const bool nextReleased = mappedInput.wasReleased(MappedInputManager::Button::PageForward) || @@ -632,6 +777,24 @@ void EpubReaderActivity::renderContents(std::unique_ptr page, const int or const int orientedMarginRight, const int orientedMarginBottom, const int orientedMarginLeft) { page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop); + + // Draw bookmark indicator (folded corner) if this page is bookmarked + if (section) { + const uint32_t contentOffset = section->getContentOffsetForPage(section->currentPage); + if (BookmarkStore::isPageBookmarked(epub->getPath(), currentSpineIndex, contentOffset)) { + // Draw folded corner in top-right + const int screenWidth = renderer.getScreenWidth(); + constexpr int cornerSize = 20; + const int cornerX = screenWidth - orientedMarginRight - cornerSize; + const int cornerY = orientedMarginTop; + + // Draw triangle (folded corner effect) + const int xPoints[3] = {cornerX, cornerX + cornerSize, cornerX + cornerSize}; + const int yPoints[3] = {cornerY, cornerY, cornerY + cornerSize}; + renderer.fillPolygon(xPoints, yPoints, 3, true); // Black triangle + } + } + renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft); if (pagesUntilFullRefresh <= 1) { renderer.displayBuffer(EInkDisplay::HALF_REFRESH); diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index b87a51d..32f861e 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -18,6 +18,8 @@ class EpubReaderActivity final : public ActivityWithSubactivity { bool updateRequired = false; const std::function onGoBack; const std::function onGoHome; + const std::function onGoToClearCache; + const std::function onGoToSettings; // End-of-book prompt state bool showingEndOfBookPrompt = false; @@ -38,11 +40,15 @@ class EpubReaderActivity final : public ActivityWithSubactivity { public: explicit EpubReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::unique_ptr epub, - const std::function& onGoBack, const std::function& onGoHome) + const std::function& onGoBack, const std::function& onGoHome, + const std::function& onGoToClearCache = nullptr, + const std::function& onGoToSettings = nullptr) : ActivityWithSubactivity("EpubReader", renderer, mappedInput), epub(std::move(epub)), onGoBack(onGoBack), - onGoHome(onGoHome) {} + onGoHome(onGoHome), + onGoToClearCache(onGoToClearCache), + onGoToSettings(onGoToSettings) {} void onEnter() override; void onExit() override; void loop() override; diff --git a/src/activities/reader/ReaderActivity.cpp b/src/activities/reader/ReaderActivity.cpp index 667afed..d425498 100644 --- a/src/activities/reader/ReaderActivity.cpp +++ b/src/activities/reader/ReaderActivity.cpp @@ -62,7 +62,11 @@ void ReaderActivity::onGoToEpubReader(std::unique_ptr epub) { currentBookPath = epubPath; exitActivity(); enterNewActivity(new EpubReaderActivity( - renderer, mappedInput, std::move(epub), [this, epubPath] { goToLibrary(epubPath); }, [this] { onGoBack(); })); + renderer, mappedInput, std::move(epub), + [this, epubPath] { goToLibrary(epubPath); }, + [this] { onGoBack(); }, + onGoToClearCache, + onGoToSettings)); } void ReaderActivity::onGoToTxtReader(std::unique_ptr txt) { diff --git a/src/activities/reader/ReaderActivity.h b/src/activities/reader/ReaderActivity.h index c2753f3..0a1cecf 100644 --- a/src/activities/reader/ReaderActivity.h +++ b/src/activities/reader/ReaderActivity.h @@ -13,6 +13,8 @@ class ReaderActivity final : public ActivityWithSubactivity { MyLibraryActivity::Tab libraryTab; // Track which tab to return to const std::function onGoBack; const std::function onGoToLibrary; + const std::function onGoToClearCache; + const std::function onGoToSettings; static std::unique_ptr loadEpub(const std::string& path); static std::unique_ptr loadTxt(const std::string& path); static bool isTxtFile(const std::string& path); @@ -25,11 +27,15 @@ class ReaderActivity final : public ActivityWithSubactivity { public: explicit ReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string initialBookPath, MyLibraryActivity::Tab libraryTab, const std::function& onGoBack, - const std::function& onGoToLibrary) + const std::function& onGoToLibrary, + const std::function& onGoToClearCache = nullptr, + const std::function& onGoToSettings = nullptr) : ActivityWithSubactivity("Reader", renderer, mappedInput), initialBookPath(std::move(initialBookPath)), libraryTab(libraryTab), onGoBack(onGoBack), - onGoToLibrary(onGoToLibrary) {} + onGoToLibrary(onGoToLibrary), + onGoToClearCache(onGoToClearCache), + onGoToSettings(onGoToSettings) {} void onEnter() override; }; diff --git a/src/activities/reader/TxtReaderActivity.h b/src/activities/reader/TxtReaderActivity.h index 04369be..7c2208f 100644 --- a/src/activities/reader/TxtReaderActivity.h +++ b/src/activities/reader/TxtReaderActivity.h @@ -20,6 +20,8 @@ class TxtReaderActivity final : public ActivityWithSubactivity { bool updateRequired = false; const std::function onGoBack; const std::function onGoHome; + const std::function onGoToClearCache; + const std::function onGoToSettings; // End-of-book prompt state bool showingEndOfBookPrompt = false; @@ -56,11 +58,15 @@ class TxtReaderActivity final : public ActivityWithSubactivity { public: explicit TxtReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::unique_ptr txt, - const std::function& onGoBack, const std::function& onGoHome) + const std::function& onGoBack, const std::function& onGoHome, + const std::function& onGoToClearCache = nullptr, + const std::function& onGoToSettings = nullptr) : ActivityWithSubactivity("TxtReader", renderer, mappedInput), txt(std::move(txt)), onGoBack(onGoBack), - onGoHome(onGoHome) {} + onGoHome(onGoHome), + onGoToClearCache(onGoToClearCache), + onGoToSettings(onGoToSettings) {} void onEnter() override; void onExit() override; void loop() override; From 69a26ccb0e3cf5de7e38adca631dda825a8a8c82 Mon Sep 17 00:00:00 2001 From: cottongin Date: Wed, 28 Jan 2026 02:20:38 -0500 Subject: [PATCH 5/7] feat: Enhanced tab bar with scrolling, overflow indicators, and cursor Tab bar now scrolls to keep selected tab visible when content overflows. Adds triangle overflow indicators and optional bullet cursor indicators around the active tab for visual focus feedback. --- src/ScreenComponents.cpp | 142 ++++++++++++++++++++++++++++++++++++--- src/ScreenComponents.h | 4 +- 2 files changed, 135 insertions(+), 11 deletions(-) diff --git a/src/ScreenComponents.cpp b/src/ScreenComponents.cpp index deb5140..d6cf35f 100644 --- a/src/ScreenComponents.cpp +++ b/src/ScreenComponents.cpp @@ -90,33 +90,155 @@ void ScreenComponents::drawBookProgressBar(const GfxRenderer& renderer, const si renderer.fillRect(vieweableMarginLeft, progressBarY, barWidth, BOOK_PROGRESS_BAR_HEIGHT, true); } -int ScreenComponents::drawTabBar(const GfxRenderer& renderer, const int y, const std::vector& tabs) { +int ScreenComponents::drawTabBar(const GfxRenderer& renderer, const int y, const std::vector& tabs, int selectedIndex, bool showCursor) { constexpr int tabPadding = 20; // Horizontal padding between tabs constexpr int leftMargin = 20; // Left margin for first tab + constexpr int rightMargin = 20; // Right margin constexpr int underlineHeight = 2; // Height of selection underline constexpr int underlineGap = 4; // Gap between text and underline + constexpr int cursorPadding = 4; // Space between bullet cursor and tab text + constexpr int overflowIndicatorWidth = 16; // Space reserved for < > indicators const int lineHeight = renderer.getLineHeight(UI_12_FONT_ID); const int tabBarHeight = lineHeight + underlineGap + underlineHeight; + const int screenWidth = renderer.getScreenWidth(); + const int bezelLeft = renderer.getBezelOffsetLeft(); + const int bezelRight = renderer.getBezelOffsetRight(); + const int availableWidth = screenWidth - bezelLeft - bezelRight - leftMargin - rightMargin; - int currentX = leftMargin; + // Find selected index if not provided + if (selectedIndex < 0) { + for (size_t i = 0; i < tabs.size(); i++) { + if (tabs[i].selected) { + selectedIndex = static_cast(i); + break; + } + } + } + // Calculate total width of all tabs and individual tab widths + std::vector tabWidths; + int totalWidth = 0; for (const auto& tab : tabs) { - const int textWidth = - renderer.getTextWidth(UI_12_FONT_ID, tab.label, tab.selected ? EpdFontFamily::BOLD : EpdFontFamily::REGULAR); + const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, tab.label, tab.selected ? EpdFontFamily::BOLD : EpdFontFamily::REGULAR); + tabWidths.push_back(textWidth); + totalWidth += textWidth; + } + totalWidth += static_cast(tabs.size() - 1) * tabPadding; // Add padding between tabs - // Draw tab label - renderer.drawText(UI_12_FONT_ID, currentX, y, tab.label, true, - tab.selected ? EpdFontFamily::BOLD : EpdFontFamily::REGULAR); + // Calculate scroll offset to keep selected tab visible + int scrollOffset = 0; + if (totalWidth > availableWidth && selectedIndex >= 0) { + // Calculate position of selected tab + int selectedStart = 0; + for (int i = 0; i < selectedIndex; i++) { + selectedStart += tabWidths[i] + tabPadding; + } + int selectedEnd = selectedStart + tabWidths[selectedIndex]; - // Draw underline for selected tab - if (tab.selected) { - renderer.fillRect(currentX, y + lineHeight + underlineGap, textWidth, underlineHeight); + // If selected tab would be cut off on the right, scroll left + if (selectedEnd > availableWidth) { + scrollOffset = selectedEnd - availableWidth + tabPadding; + } + // If selected tab would be cut off on the left (after scrolling), adjust + if (selectedStart - scrollOffset < 0) { + scrollOffset = selectedStart; + } + } + + int currentX = leftMargin + bezelLeft - scrollOffset; + + // Bullet cursor settings + constexpr int bulletRadius = 3; + const int bulletCenterY = y + lineHeight / 2; + + // Calculate visible area boundaries (leave room for overflow indicators) + const bool hasLeftOverflow = scrollOffset > 0; + const bool hasRightOverflow = totalWidth > availableWidth && scrollOffset < totalWidth - availableWidth; + const int visibleLeft = bezelLeft + (hasLeftOverflow ? overflowIndicatorWidth : 0); + const int visibleRight = screenWidth - bezelRight - (hasRightOverflow ? overflowIndicatorWidth : 0); + + for (size_t i = 0; i < tabs.size(); i++) { + const auto& tab = tabs[i]; + const int textWidth = tabWidths[i]; + + // Only draw if at least partially visible (accounting for overflow indicator space) + if (currentX + textWidth > visibleLeft && currentX < visibleRight) { + // Draw bullet cursor before selected tab when showCursor is true + if (showCursor && tab.selected) { + // Draw filled circle using distance-squared check + const int bulletCenterX = currentX - cursorPadding - bulletRadius; + const int radiusSq = bulletRadius * bulletRadius; + for (int dy = -bulletRadius; dy <= bulletRadius; ++dy) { + for (int dx = -bulletRadius; dx <= bulletRadius; ++dx) { + if (dx * dx + dy * dy <= radiusSq) { + renderer.drawPixel(bulletCenterX + dx, bulletCenterY + dy, true); + } + } + } + } + + // Draw tab label + renderer.drawText(UI_12_FONT_ID, currentX, y, tab.label, true, + tab.selected ? EpdFontFamily::BOLD : EpdFontFamily::REGULAR); + + // Draw bullet cursor after selected tab when showCursor is true + if (showCursor && tab.selected) { + // Draw filled circle using distance-squared check + const int bulletCenterX = currentX + textWidth + cursorPadding + bulletRadius; + const int radiusSq = bulletRadius * bulletRadius; + for (int dy = -bulletRadius; dy <= bulletRadius; ++dy) { + for (int dx = -bulletRadius; dx <= bulletRadius; ++dx) { + if (dx * dx + dy * dy <= radiusSq) { + renderer.drawPixel(bulletCenterX + dx, bulletCenterY + dy, true); + } + } + } + } + + // Draw underline for selected tab + if (tab.selected) { + renderer.fillRect(currentX, y + lineHeight + underlineGap, textWidth, underlineHeight); + } } currentX += textWidth + tabPadding; } + // Draw overflow indicators if content extends beyond visible area + if (totalWidth > availableWidth) { + constexpr int triangleHeight = 12; // Height of the triangle (vertical) + constexpr int triangleWidth = 6; // Width of the triangle (horizontal) - thin/elongated + const int triangleCenterY = y + lineHeight / 2; + + // Left overflow indicator (more content to the left) - thin triangle pointing left + if (scrollOffset > 0) { + // Clear background behind indicator to hide any overlapping text + renderer.fillRect(bezelLeft, y - 2, overflowIndicatorWidth, lineHeight + 4, false); + // Draw left-pointing triangle: point on left, base on right + const int tipX = bezelLeft + 2; + for (int i = 0; i < triangleWidth; ++i) { + // Scale height based on position (0 at tip, full height at base) + const int lineHalfHeight = (triangleHeight * i) / (triangleWidth * 2); + renderer.drawLine(tipX + i, triangleCenterY - lineHalfHeight, + tipX + i, triangleCenterY + lineHalfHeight); + } + } + // Right overflow indicator (more content to the right) - thin triangle pointing right + if (scrollOffset < totalWidth - availableWidth) { + // Clear background behind indicator to hide any overlapping text + renderer.fillRect(screenWidth - bezelRight - overflowIndicatorWidth, y - 2, overflowIndicatorWidth, lineHeight + 4, false); + // Draw right-pointing triangle: base on left, point on right + const int baseX = screenWidth - bezelRight - 2 - triangleWidth; + for (int i = 0; i < triangleWidth; ++i) { + // Scale height based on position (full height at base, 0 at tip) + const int lineHalfHeight = (triangleHeight * (triangleWidth - 1 - i)) / (triangleWidth * 2); + renderer.drawLine(baseX + i, triangleCenterY - lineHalfHeight, + baseX + i, triangleCenterY + lineHalfHeight); + } + } + } + return tabBarHeight; } diff --git a/src/ScreenComponents.h b/src/ScreenComponents.h index 0499448..7e46926 100644 --- a/src/ScreenComponents.h +++ b/src/ScreenComponents.h @@ -23,7 +23,9 @@ class ScreenComponents { // Draw a horizontal tab bar with underline indicator for selected tab // Returns the height of the tab bar (for positioning content below) - static int drawTabBar(const GfxRenderer& renderer, int y, const std::vector& tabs); + // When selectedIndex is provided, tabs scroll so the selected tab is visible + // When showCursor is true, bullet indicators are drawn around the selected tab + static int drawTabBar(const GfxRenderer& renderer, int y, const std::vector& tabs, int selectedIndex = -1, bool showCursor = false); // Draw a scroll/page indicator on the right side of the screen // Shows up/down arrows and current page fraction (e.g., "1/3") From e1fcec7d6940544dea4aad5157f752190c9b75b6 Mon Sep 17 00:00:00 2001 From: cottongin Date: Wed, 28 Jan 2026 02:20:48 -0500 Subject: [PATCH 6/7] feat: Search tab with character picker and unified tab bar navigation Adds Search tab to MyLibraryActivity with character picker for building search queries, result navigation with long press jump-to-end support, and Bookmarks tab integration. Implements consistent tab bar navigation across all tabs - pressing Up from top of any list enters tab bar mode with visible cursor indicators, Left/Right switches tabs, Down enters list at top, and Up jumps to bottom of list. --- src/activities/home/MyLibraryActivity.cpp | 1082 ++++++++++++++++++++- src/activities/home/MyLibraryActivity.h | 44 +- 2 files changed, 1085 insertions(+), 41 deletions(-) diff --git a/src/activities/home/MyLibraryActivity.cpp b/src/activities/home/MyLibraryActivity.cpp index f4cf039..546daf2 100644 --- a/src/activities/home/MyLibraryActivity.cpp +++ b/src/activities/home/MyLibraryActivity.cpp @@ -5,10 +5,13 @@ #include #include +#include #include +#include #include "BookListStore.h" #include "BookManager.h" +#include "BookmarkStore.h" #include "CrossPointSettings.h" #include "HomeActivity.h" #include "MappedInputManager.h" @@ -60,6 +63,11 @@ constexpr int SKIP_PAGE_MS = 700; constexpr unsigned long GO_HOME_MS = 1000; constexpr unsigned long ACTION_MENU_MS = 700; // Long press to open action menu +// Special key indices for character picker (appended after regular characters) +constexpr int SEARCH_SPECIAL_SPACE = -1; +constexpr int SEARCH_SPECIAL_BACKSPACE = -2; +constexpr int SEARCH_SPECIAL_CLEAR = -3; + void sortFileList(std::vector& strs) { std::sort(begin(strs), end(strs), [](const std::string& str1, const std::string& str2) { if (str1.back() == '/' && str2.back() != '/') return true; @@ -76,9 +84,22 @@ int MyLibraryActivity::getPageItems() const { const int bottomBarHeight = 60; // Space for button hints const int bezelTop = renderer.getBezelOffsetTop(); const int bezelBottom = renderer.getBezelOffsetBottom(); + + // Search tab has compact layout: character picker (~30px) + query (~25px) + results + if (currentTab == Tab::Search) { + // Character picker: ~30px, Query: ~25px = 55px overhead + // Much more room for results than the old 5-row keyboard + constexpr int SEARCH_OVERHEAD = 55; + const int availableHeight = screenHeight - (BASE_CONTENT_START_Y + bezelTop) - bottomBarHeight - bezelBottom - SEARCH_OVERHEAD; + int items = availableHeight / RECENTS_LINE_HEIGHT; + if (items < 1) items = 1; + return items; + } + const int availableHeight = screenHeight - (BASE_CONTENT_START_Y + bezelTop) - bottomBarHeight - bezelBottom; - // Recent tab uses taller items (title + author), Lists and Files use single-line items - const int lineHeight = (currentTab == Tab::Recent) ? RECENTS_LINE_HEIGHT : LINE_HEIGHT; + // Recent and Bookmarks tabs use taller items (title + author), Lists and Files use single-line items + const int lineHeight = (currentTab == Tab::Recent || currentTab == Tab::Bookmarks) + ? RECENTS_LINE_HEIGHT : LINE_HEIGHT; int items = availableHeight / lineHeight; if (items < 1) { items = 1; @@ -87,12 +108,17 @@ int MyLibraryActivity::getPageItems() const { } int MyLibraryActivity::getCurrentItemCount() const { + // Add +1 for "Search..." shortcut in tabs that support it (all except Search itself) if (currentTab == Tab::Recent) { - return static_cast(recentBooks.size()); + return static_cast(recentBooks.size()) + 1; // +1 for Search shortcut } else if (currentTab == Tab::Lists) { - return static_cast(lists.size()); + return static_cast(lists.size()) + 1; // +1 for Search shortcut + } else if (currentTab == Tab::Bookmarks) { + return static_cast(bookmarkedBooks.size()) + 1; // +1 for Search shortcut + } else if (currentTab == Tab::Search) { + return static_cast(searchResults.size()); // No shortcut in Search tab } - return static_cast(files.size()); + return static_cast(files.size()) + 1; // +1 for Search shortcut } int MyLibraryActivity::getTotalPages() const { @@ -123,6 +149,202 @@ void MyLibraryActivity::loadRecentBooks() { void MyLibraryActivity::loadLists() { lists = BookListStore::listAllLists(); } +void MyLibraryActivity::loadBookmarkedBooks() { + bookmarkedBooks = BookmarkStore::getBooksWithBookmarks(); + + // Try to get better metadata from recent books + for (auto& book : bookmarkedBooks) { + for (const auto& recent : recentBooks) { + if (recent.path == book.path) { + if (!recent.title.empty()) book.title = recent.title; + if (!recent.author.empty()) book.author = recent.author; + break; + } + } + } +} + +void MyLibraryActivity::loadAllBooks() { + // Build index of all books on SD card for search + allBooks.clear(); + + // Helper lambda to recursively scan directories + std::function scanDirectory = [&](const std::string& path) { + auto dir = SdMan.open(path.c_str()); + if (!dir || !dir.isDirectory()) { + if (dir) dir.close(); + return; + } + + dir.rewindDirectory(); + char name[500]; + + for (auto file = dir.openNextFile(); file; file = dir.openNextFile()) { + file.getName(name, sizeof(name)); + if (name[0] == '.' || strcmp(name, "System Volume Information") == 0) { + file.close(); + continue; + } + + std::string fullPath = (path.back() == '/') ? path + name : path + "/" + name; + + if (file.isDirectory()) { + file.close(); + scanDirectory(fullPath); + } else { + auto filename = std::string(name); + if (StringUtils::checkFileExtension(filename, ".epub") || + StringUtils::checkFileExtension(filename, ".txt") || + StringUtils::checkFileExtension(filename, ".md")) { + SearchResult result; + result.path = fullPath; + + // Extract title from filename (remove extension) + result.title = filename; + const size_t dot = result.title.find_last_of('.'); + if (dot != std::string::npos) { + result.title.resize(dot); + } + + // Try to get metadata from recent books if available + for (const auto& recent : recentBooks) { + if (recent.path == fullPath) { + if (!recent.title.empty()) result.title = recent.title; + if (!recent.author.empty()) result.author = recent.author; + break; + } + } + + allBooks.push_back(result); + } + file.close(); + } + } + dir.close(); + }; + + scanDirectory("/"); + + // Sort alphabetically by title + std::sort(allBooks.begin(), allBooks.end(), [](const SearchResult& a, const SearchResult& b) { + return lexicographical_compare( + a.title.begin(), a.title.end(), b.title.begin(), b.title.end(), + [](char c1, char c2) { return tolower(c1) < tolower(c2); }); + }); + + // Build character set after loading books + buildSearchCharacters(); +} + +void MyLibraryActivity::buildSearchCharacters() { + // Build a set of unique characters from all book titles and authors + std::set charSet; + + for (const auto& book : allBooks) { + for (char c : book.title) { + // Convert to uppercase for display, store as uppercase + if (std::isalpha(static_cast(c))) { + charSet.insert(std::toupper(static_cast(c))); + } else if (std::isdigit(static_cast(c))) { + charSet.insert(c); + } else if (c == ' ') { + // Space handled separately as special key + } else if (std::ispunct(static_cast(c))) { + charSet.insert(c); + } + } + for (char c : book.author) { + if (std::isalpha(static_cast(c))) { + charSet.insert(std::toupper(static_cast(c))); + } else if (std::isdigit(static_cast(c))) { + charSet.insert(c); + } else if (std::ispunct(static_cast(c))) { + charSet.insert(c); + } + } + } + + // Convert set to vector, sorted: A-Z, then 0-9, then symbols + searchCharacters.clear(); + + // Add letters A-Z + for (char c = 'A'; c <= 'Z'; c++) { + if (charSet.count(c)) { + searchCharacters.push_back(c); + } + } + + // Add digits 0-9 + for (char c = '0'; c <= '9'; c++) { + if (charSet.count(c)) { + searchCharacters.push_back(c); + } + } + + // Add symbols (anything else in the set) + for (char c : charSet) { + if (!std::isalpha(static_cast(c)) && !std::isdigit(static_cast(c))) { + searchCharacters.push_back(c); + } + } + + // Reset character index if it's out of bounds + if (searchCharIndex >= static_cast(searchCharacters.size()) + 3) { // +3 for special keys + searchCharIndex = 0; + } +} + +void MyLibraryActivity::updateSearchResults() { + searchResults.clear(); + + if (searchQuery.empty()) { + // Don't show any results when query is empty - user needs to type something + return; + } + + // Convert query to lowercase for case-insensitive matching + std::string queryLower = searchQuery; + for (char& c : queryLower) c = tolower(c); + + for (const auto& book : allBooks) { + // Convert title, author, and path to lowercase + std::string titleLower = book.title; + std::string authorLower = book.author; + std::string pathLower = book.path; + for (char& c : titleLower) c = tolower(c); + for (char& c : authorLower) c = tolower(c); + for (char& c : pathLower) c = tolower(c); + + int score = 0; + + // Check for matches + if (titleLower.find(queryLower) != std::string::npos) { + score += 100; + // Bonus for match at start + if (titleLower.find(queryLower) == 0) score += 50; + } + if (!authorLower.empty() && authorLower.find(queryLower) != std::string::npos) { + score += 80; + if (authorLower.find(queryLower) == 0) score += 40; + } + if (pathLower.find(queryLower) != std::string::npos) { + score += 30; + } + + if (score > 0) { + SearchResult result = book; + result.matchScore = score; + searchResults.push_back(result); + } + } + + // Sort by match score (descending) + std::sort(searchResults.begin(), searchResults.end(), + [](const SearchResult& a, const SearchResult& b) { + return a.matchScore > b.matchScore; + }); +} + void MyLibraryActivity::loadFiles() { files.clear(); @@ -178,9 +400,20 @@ void MyLibraryActivity::onEnter() { // Load data for all tabs loadRecentBooks(); loadLists(); + loadBookmarkedBooks(); + loadAllBooks(); + updateSearchResults(); loadFiles(); selectorIndex = 0; + + // If entering Search tab, start in character picker mode + if (currentTab == Tab::Search) { + searchInResults = false; + inTabBar = false; + searchCharIndex = 0; + } + updateRequired = true; xTaskCreate(&MyLibraryActivity::taskTrampoline, "MyLibraryActivityTask", @@ -209,19 +442,24 @@ void MyLibraryActivity::onExit() { recentBooks.clear(); lists.clear(); + bookmarkedBooks.clear(); + searchResults.clear(); + allBooks.clear(); files.clear(); } bool MyLibraryActivity::isSelectedItemAFile() const { if (currentTab == Tab::Recent) { + // Don't count "Search..." shortcut as a file return !recentBooks.empty() && selectorIndex < static_cast(recentBooks.size()); - } else { - // Files tab - check if it's a file (not a directory) + } else if (currentTab == Tab::Files) { + // Files tab - check if it's a file (not a directory) and not "Search..." shortcut if (files.empty() || selectorIndex >= static_cast(files.size())) { return false; } return files[selectorIndex].back() != '/'; } + return false; } void MyLibraryActivity::openActionMenu() { @@ -502,6 +740,307 @@ void MyLibraryActivity::loop() { const int itemCount = getCurrentItemCount(); const int pageItems = getPageItems(); + // Handle tab bar navigation for non-Search tabs + if (inTabBar && currentTab != Tab::Search) { + // Left/Right switch tabs while staying in tab bar + if (mappedInput.wasReleased(MappedInputManager::Button::Left)) { + switch (currentTab) { + case Tab::Recent: + currentTab = Tab::Files; // Wrap from first to last + break; + case Tab::Lists: + currentTab = Tab::Recent; + break; + case Tab::Bookmarks: + currentTab = Tab::Lists; + break; + case Tab::Files: + currentTab = Tab::Search; + break; + default: + break; + } + selectorIndex = 0; + updateRequired = true; + return; + } + + if (mappedInput.wasReleased(MappedInputManager::Button::Right)) { + switch (currentTab) { + case Tab::Recent: + currentTab = Tab::Lists; + break; + case Tab::Lists: + currentTab = Tab::Bookmarks; + break; + case Tab::Bookmarks: + currentTab = Tab::Search; + break; + case Tab::Files: + currentTab = Tab::Recent; // Wrap from last to first + break; + default: + break; + } + selectorIndex = 0; + updateRequired = true; + return; + } + + // Down exits tab bar, enters list at top + if (mappedInput.wasReleased(MappedInputManager::Button::Down)) { + inTabBar = false; + selectorIndex = 0; + updateRequired = true; + return; + } + + // Up exits tab bar, jumps to bottom of list + if (mappedInput.wasReleased(MappedInputManager::Button::Up)) { + inTabBar = false; + if (itemCount > 0) { + selectorIndex = itemCount - 1; + } + updateRequired = true; + return; + } + + // Back goes home + if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { + onGoHome(); + return; + } + + return; + } + + // Handle Search tab navigation + if (currentTab == Tab::Search) { + const int charCount = static_cast(searchCharacters.size()); + const int totalPickerItems = charCount + 3; // +3 for SPC, <-, CLR + + if (inTabBar) { + // In tab bar mode - Left/Right switch tabs, Down goes to picker + // Use wasReleased for consistency with other tab switching code + if (mappedInput.wasReleased(MappedInputManager::Button::Left)) { + currentTab = Tab::Bookmarks; + selectorIndex = 0; + updateRequired = true; + return; + } + + if (mappedInput.wasReleased(MappedInputManager::Button::Right)) { + currentTab = Tab::Files; + selectorIndex = 0; + updateRequired = true; + return; + } + + // Down exits tab bar, goes to character picker + if (mappedInput.wasReleased(MappedInputManager::Button::Down)) { + inTabBar = false; + updateRequired = true; + return; + } + + // Up exits tab bar, jumps to bottom of results (if any) + if (mappedInput.wasReleased(MappedInputManager::Button::Up)) { + inTabBar = false; + if (!searchResults.empty()) { + searchInResults = true; + selectorIndex = static_cast(searchResults.size()) - 1; + } + updateRequired = true; + return; + } + + // Back goes home + if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { + onGoHome(); + return; + } + + return; + } else if (!searchInResults) { + // In character picker mode + + // Long press Left = jump to start + if (mappedInput.isPressed(MappedInputManager::Button::Left) && + mappedInput.getHeldTime() >= 700) { + searchCharIndex = 0; + updateRequired = true; + return; + } + + // Long press Right = jump to end + if (mappedInput.isPressed(MappedInputManager::Button::Right) && + mappedInput.getHeldTime() >= 700) { + searchCharIndex = totalPickerItems - 1; + updateRequired = true; + return; + } + + // Left/Right navigate through characters (with wrap) + if (mappedInput.wasPressed(MappedInputManager::Button::Left)) { + if (searchCharIndex > 0) { + searchCharIndex--; + } else { + searchCharIndex = totalPickerItems - 1; // Wrap to end + } + updateRequired = true; + return; + } + + if (mappedInput.wasPressed(MappedInputManager::Button::Right)) { + if (searchCharIndex < totalPickerItems - 1) { + searchCharIndex++; + } else { + searchCharIndex = 0; // Wrap to start + } + updateRequired = true; + return; + } + + // Down moves to results (if any exist) + if (mappedInput.wasPressed(MappedInputManager::Button::Down)) { + if (!searchResults.empty()) { + searchInResults = true; + selectorIndex = 0; + updateRequired = true; + } + return; + } + + // Up moves to tab bar + if (mappedInput.wasReleased(MappedInputManager::Button::Up)) { + inTabBar = true; + updateRequired = true; + return; + } + + // Confirm adds selected character or performs special action + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + if (searchCharIndex < charCount) { + // Regular character - add to query (as lowercase for search) + searchQuery += std::tolower(static_cast(searchCharacters[searchCharIndex])); + updateSearchResults(); + } else if (searchCharIndex == charCount) { + // SPC - add space + searchQuery += ' '; + updateSearchResults(); + } else if (searchCharIndex == charCount + 1) { + // <- Backspace + if (!searchQuery.empty()) { + searchQuery.pop_back(); + updateSearchResults(); + } + } else if (searchCharIndex == charCount + 2) { + // CLR - clear query + searchQuery.clear(); + updateSearchResults(); + } + updateRequired = true; + return; + } + + // Long press Back = clear entire query + if (mappedInput.isPressed(MappedInputManager::Button::Back) && + mappedInput.getHeldTime() >= 700) { + if (!searchQuery.empty()) { + searchQuery.clear(); + updateSearchResults(); + updateRequired = true; + } + return; + } + + // Short press Back = backspace (delete one char) + if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { + if (mappedInput.getHeldTime() >= 700) { + // Already handled by long press above, ignore release + return; + } + if (!searchQuery.empty()) { + searchQuery.pop_back(); + updateSearchResults(); + updateRequired = true; + } else { + // If query already empty, go home + onGoHome(); + } + return; + } + + return; // Don't process other input while in picker + } else { + // In results mode + + // Long press PageBack (side button) = jump to first result + if (mappedInput.isPressed(MappedInputManager::Button::PageBack) && + mappedInput.getHeldTime() >= 700) { + selectorIndex = 0; + updateRequired = true; + return; + } + + // Long press PageForward (side button) = jump to last result + if (mappedInput.isPressed(MappedInputManager::Button::PageForward) && + mappedInput.getHeldTime() >= 700) { + if (!searchResults.empty()) { + selectorIndex = static_cast(searchResults.size()) - 1; + } + updateRequired = true; + return; + } + + // Up/Down navigate through results + if (mappedInput.wasPressed(MappedInputManager::Button::Up)) { + if (selectorIndex > 0) { + selectorIndex--; + } else { + // At first result, move back to character picker + searchInResults = false; + } + updateRequired = true; + return; + } + + if (mappedInput.wasPressed(MappedInputManager::Button::Down)) { + if (selectorIndex < static_cast(searchResults.size()) - 1) { + selectorIndex++; + } else { + // At last result, wrap to character picker + searchInResults = false; + } + updateRequired = true; + return; + } + + // Left/Right do nothing in results (or could page?) + if (mappedInput.wasPressed(MappedInputManager::Button::Left) || + mappedInput.wasPressed(MappedInputManager::Button::Right)) { + return; + } + + // Confirm opens the selected book + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + if (!searchResults.empty() && selectorIndex < static_cast(searchResults.size())) { + onSelectBook(searchResults[selectorIndex].path, currentTab); + } + return; + } + + // Back button - go back to character picker + if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { + searchInResults = false; + updateRequired = true; + return; + } + + return; // Don't process other input + } + } + // Long press BACK (1s+) in Files tab goes to root folder if (currentTab == Tab::Files && mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= GO_HOME_MS) { @@ -544,6 +1083,29 @@ void MyLibraryActivity::loop() { return; } + // Check if "Search..." shortcut is selected (last item in non-Search tabs) + bool isSearchShortcut = false; + if (currentTab == Tab::Recent && selectorIndex == static_cast(recentBooks.size())) { + isSearchShortcut = true; + } else if (currentTab == Tab::Lists && selectorIndex == static_cast(lists.size())) { + isSearchShortcut = true; + } else if (currentTab == Tab::Bookmarks && selectorIndex == static_cast(bookmarkedBooks.size())) { + isSearchShortcut = true; + } else if (currentTab == Tab::Files && selectorIndex == static_cast(files.size())) { + isSearchShortcut = true; + } + + if (isSearchShortcut) { + // Switch to Search tab with character picker active + currentTab = Tab::Search; + selectorIndex = 0; + searchInResults = false; + inTabBar = false; + searchCharIndex = 0; + updateRequired = true; + return; + } + if (currentTab == Tab::Recent) { if (!recentBooks.empty() && selectorIndex < static_cast(recentBooks.size())) { onSelectBook(recentBooks[selectorIndex].path, currentTab); @@ -555,6 +1117,19 @@ void MyLibraryActivity::loop() { onSelectList(lists[selectorIndex]); } } + } else if (currentTab == Tab::Bookmarks) { + // Bookmarks tab - open BookmarkListActivity for the selected book + if (!bookmarkedBooks.empty() && selectorIndex < static_cast(bookmarkedBooks.size())) { + const auto& book = bookmarkedBooks[selectorIndex]; + if (onSelectBookmarkedBook) { + onSelectBookmarkedBook(book.path, book.title); + } + } + } else if (currentTab == Tab::Search) { + // Search tab - open selected result + if (!searchResults.empty() && selectorIndex < static_cast(searchResults.size())) { + onSelectBook(searchResults[selectorIndex].path, currentTab); + } } else { // Files tab if (!files.empty() && selectorIndex < static_cast(files.size())) { @@ -589,6 +1164,10 @@ void MyLibraryActivity::loop() { const std::string dirName = oldPath.substr(pos + 1) + "/"; selectorIndex = static_cast(findEntry(dirName)); + updateRequired = true; + } else if (currentTab == Tab::Search && searchInResults) { + // In Search tab viewing results, go back to character picker + searchInResults = false; updateRequired = true; } else { // Go home @@ -598,32 +1177,55 @@ void MyLibraryActivity::loop() { return; } - // Tab switching: Left/Right always control tabs (Recent <-> Lists <-> Files) - if (leftReleased) { - if (currentTab == Tab::Files) { - currentTab = Tab::Lists; - selectorIndex = 0; - updateRequired = true; - return; - } else if (currentTab == Tab::Lists) { - currentTab = Tab::Recent; - selectorIndex = 0; - updateRequired = true; - return; + // Tab switching: Left/Right control tabs with wrapping (except in Search tab where they navigate picker) + // Order: Recent <-> Lists <-> Bookmarks <-> Search <-> Files + if (leftReleased && currentTab != Tab::Search) { + switch (currentTab) { + case Tab::Recent: + currentTab = Tab::Files; // Wrap from first to last + break; + case Tab::Lists: + currentTab = Tab::Recent; + break; + case Tab::Bookmarks: + currentTab = Tab::Lists; + break; + case Tab::Search: + currentTab = Tab::Bookmarks; + break; + case Tab::Files: + currentTab = Tab::Search; + inTabBar = true; // Stay in tab bar mode when cycling to Search + break; } + selectorIndex = 0; + // Don't auto-activate keyboard when tab-switching - user can press Down to enter search + updateRequired = true; + return; } - if (rightReleased) { - if (currentTab == Tab::Recent) { - currentTab = Tab::Lists; - selectorIndex = 0; - updateRequired = true; - return; - } else if (currentTab == Tab::Lists) { - currentTab = Tab::Files; - selectorIndex = 0; - updateRequired = true; - return; + if (rightReleased && currentTab != Tab::Search) { + switch (currentTab) { + case Tab::Recent: + currentTab = Tab::Lists; + break; + case Tab::Lists: + currentTab = Tab::Bookmarks; + break; + case Tab::Bookmarks: + currentTab = Tab::Search; + inTabBar = true; // Stay in tab bar mode when cycling to Search + break; + case Tab::Search: + currentTab = Tab::Files; + break; + case Tab::Files: + currentTab = Tab::Recent; // Wrap from last to first + break; } + selectorIndex = 0; + // Don't auto-activate keyboard when tab-switching - user can press Down to enter search + updateRequired = true; + return; } // Navigation: Up/Down moves through items only @@ -632,9 +1234,14 @@ void MyLibraryActivity::loop() { if (prevReleased && itemCount > 0) { if (skipPage) { + // Long press - page up selectorIndex = ((selectorIndex / pageItems - 1) * pageItems + itemCount) % itemCount; + } else if (selectorIndex == 0) { + // At top of list, enter tab bar + inTabBar = true; } else { - selectorIndex = (selectorIndex + itemCount - 1) % itemCount; + // Normal up navigation + selectorIndex = selectorIndex - 1; } updateRequired = true; } else if (nextReleased && itemCount > 0) { @@ -711,15 +1318,22 @@ void MyLibraryActivity::render() const { // Normal state - draw library view // Draw tab bar std::vector tabs = {{"Recent", currentTab == Tab::Recent}, - {"Reading Lists", currentTab == Tab::Lists}, + {"Lists", currentTab == Tab::Lists}, + {"Bookmarks", currentTab == Tab::Bookmarks}, + {"Search", currentTab == Tab::Search}, {"Files", currentTab == Tab::Files}}; - ScreenComponents::drawTabBar(renderer, TAB_BAR_Y, tabs); + const int selectedTabIndex = static_cast(currentTab); + ScreenComponents::drawTabBar(renderer, TAB_BAR_Y, tabs, selectedTabIndex, inTabBar); // Draw content based on current tab if (currentTab == Tab::Recent) { renderRecentTab(); } else if (currentTab == Tab::Lists) { renderListsTab(); + } else if (currentTab == Tab::Bookmarks) { + renderBookmarksTab(); + } else if (currentTab == Tab::Search) { + renderSearchTab(); } else { renderFilesTab(); } @@ -733,8 +1347,22 @@ void MyLibraryActivity::render() const { // Note: text is rotated 90° CW, so ">" appears as "^" and "<" appears as "v" renderer.drawSideButtonHints(UI_10_FONT_ID, ">", "<"); - // Draw bottom button hints - const auto labels = mappedInput.mapLabels("« Back", "Open", "<", ">"); + // Draw bottom button hints - customize for Search tab states + std::string backLabel = "« Back"; + std::string confirmLabel = "Open"; + if (currentTab == Tab::Search) { + if (inTabBar) { + backLabel = "« Back"; + confirmLabel = ""; // No action in tab bar + } else if (!searchInResults) { + backLabel = "BKSP"; // Back = backspace (short), clear (long) + confirmLabel = "Select"; + } else { + backLabel = "« Back"; + confirmLabel = "Open"; + } + } + const auto labels = mappedInput.mapLabels(backLabel.c_str(), confirmLabel.c_str(), "<", ">"); renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); renderer.displayBuffer(); @@ -744,6 +1372,7 @@ void MyLibraryActivity::renderRecentTab() const { const auto pageWidth = renderer.getScreenWidth(); const int pageItems = getPageItems(); const int bookCount = static_cast(recentBooks.size()); + const int totalItems = bookCount + 1; // +1 for "Search..." shortcut // Calculate bezel-adjusted margins const int bezelTop = renderer.getBezelOffsetTop(); @@ -755,7 +1384,12 @@ void MyLibraryActivity::renderRecentTab() const { const int THUMB_RIGHT_MARGIN = BASE_THUMB_RIGHT_MARGIN + bezelRight; if (bookCount == 0) { - renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, CONTENT_START_Y, "No recent books"); + // Still show "Search..." even when empty + const bool searchSelected = (selectorIndex == 0); + if (searchSelected) { + renderer.fillRect(bezelLeft, CONTENT_START_Y - 2, pageWidth - RIGHT_MARGIN - bezelLeft, RECENTS_LINE_HEIGHT); + } + renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, CONTENT_START_Y, "Search...", !searchSelected); return; } @@ -907,12 +1541,24 @@ void MyLibraryActivity::renderRecentTab() const { renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, y + 32, truncatedAuthor.c_str(), !isSelected); } } + + // Draw "Search..." shortcut if it's on the current page + const int searchIndex = bookCount; // Last item + if (searchIndex >= pageStartIndex && searchIndex < pageStartIndex + pageItems) { + const int y = CONTENT_START_Y + (searchIndex % pageItems) * RECENTS_LINE_HEIGHT; + const bool isSelected = (selectorIndex == searchIndex); + if (isSelected) { + renderer.fillRect(bezelLeft, y - 2, pageWidth - RIGHT_MARGIN - bezelLeft, RECENTS_LINE_HEIGHT); + } + renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, y + 2, "Search...", !isSelected); + } } void MyLibraryActivity::renderListsTab() const { const auto pageWidth = renderer.getScreenWidth(); const int pageItems = getPageItems(); const int listCount = static_cast(lists.size()); + const int totalItems = listCount + 1; // +1 for "Search..." shortcut // Calculate bezel-adjusted margins const int bezelTop = renderer.getBezelOffsetTop(); @@ -923,8 +1569,12 @@ void MyLibraryActivity::renderListsTab() const { const int RIGHT_MARGIN = BASE_RIGHT_MARGIN + bezelRight; if (listCount == 0) { - renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, CONTENT_START_Y, "No lists found"); - renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, CONTENT_START_Y + LINE_HEIGHT, "Create lists in Companion App"); + // Still show "Search..." even when empty + const bool searchSelected = (selectorIndex == 0); + if (searchSelected) { + renderer.fillRect(bezelLeft, CONTENT_START_Y - 2, pageWidth - RIGHT_MARGIN - bezelLeft, LINE_HEIGHT); + } + renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, CONTENT_START_Y, "Search...", !searchSelected); return; } @@ -945,12 +1595,22 @@ void MyLibraryActivity::renderListsTab() const { renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, CONTENT_START_Y + (i % pageItems) * LINE_HEIGHT, item.c_str(), i != selectorIndex); } + + // Draw "Search..." shortcut if it's on the current page + const int searchIndex = listCount; // Last item + if (searchIndex >= pageStartIndex && searchIndex < pageStartIndex + pageItems) { + const int y = CONTENT_START_Y + (searchIndex % pageItems) * LINE_HEIGHT; + const bool isSelected = (selectorIndex == searchIndex); + // Selection highlight already drawn above, but need to handle if Search is selected + renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, y, "Search...", !isSelected); + } } void MyLibraryActivity::renderFilesTab() const { const auto pageWidth = renderer.getScreenWidth(); const int pageItems = getPageItems(); const int fileCount = static_cast(files.size()); + const int totalItems = fileCount + 1; // +1 for "Search..." shortcut // Calculate bezel-adjusted margins const int bezelTop = renderer.getBezelOffsetTop(); @@ -961,7 +1621,12 @@ void MyLibraryActivity::renderFilesTab() const { const int RIGHT_MARGIN = BASE_RIGHT_MARGIN + bezelRight; if (fileCount == 0) { - renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, CONTENT_START_Y, "No books found"); + // Still show "Search..." even when empty + const bool searchSelected = (selectorIndex == 0); + if (searchSelected) { + renderer.fillRect(bezelLeft, CONTENT_START_Y - 2, pageWidth - RIGHT_MARGIN - bezelLeft, LINE_HEIGHT); + } + renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, CONTENT_START_Y, "Search...", !searchSelected); return; } @@ -977,6 +1642,14 @@ void MyLibraryActivity::renderFilesTab() const { renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, CONTENT_START_Y + (i % pageItems) * LINE_HEIGHT, item.c_str(), i != selectorIndex); } + + // Draw "Search..." shortcut if it's on the current page + const int searchIndex = fileCount; // Last item + if (searchIndex >= pageStartIndex && searchIndex < pageStartIndex + pageItems) { + const int y = CONTENT_START_Y + (searchIndex % pageItems) * LINE_HEIGHT; + const bool isSelected = (selectorIndex == searchIndex); + renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, y, "Search...", !isSelected); + } } void MyLibraryActivity::renderActionMenu() const { @@ -1160,3 +1833,334 @@ void MyLibraryActivity::renderClearAllRecentsConfirmation() const { const auto labels = mappedInput.mapLabels("« Cancel", "Confirm", "", ""); renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); } + +void MyLibraryActivity::renderBookmarksTab() const { + const auto pageWidth = renderer.getScreenWidth(); + const int pageItems = getPageItems(); + const int bookCount = static_cast(bookmarkedBooks.size()); + const int totalItems = bookCount + 1; // +1 for "Search..." shortcut + + // Calculate bezel-adjusted margins + const int bezelTop = renderer.getBezelOffsetTop(); + const int bezelLeft = renderer.getBezelOffsetLeft(); + const int bezelRight = renderer.getBezelOffsetRight(); + const int CONTENT_START_Y = BASE_CONTENT_START_Y + bezelTop; + const int LEFT_MARGIN = BASE_LEFT_MARGIN + bezelLeft; + const int RIGHT_MARGIN = BASE_RIGHT_MARGIN + bezelRight; + + if (bookCount == 0) { + // Still show "Search..." even when empty + const bool searchSelected = (selectorIndex == 0); + if (searchSelected) { + renderer.fillRect(bezelLeft, CONTENT_START_Y - 2, pageWidth - RIGHT_MARGIN - bezelLeft, RECENTS_LINE_HEIGHT); + } + renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, CONTENT_START_Y, "No bookmarks saved", !searchSelected); + renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, CONTENT_START_Y + LINE_HEIGHT, "Search...", searchSelected); + return; + } + + const auto pageStartIndex = selectorIndex / pageItems * pageItems; + + // Draw selection highlight + renderer.fillRect(bezelLeft, CONTENT_START_Y + (selectorIndex % pageItems) * RECENTS_LINE_HEIGHT - 2, + pageWidth - RIGHT_MARGIN - bezelLeft, RECENTS_LINE_HEIGHT); + + // Draw items (similar to Recent tab but with bookmark count) + for (int i = pageStartIndex; i < bookCount && i < pageStartIndex + pageItems; i++) { + const auto& book = bookmarkedBooks[i]; + const int y = CONTENT_START_Y + (i % pageItems) * RECENTS_LINE_HEIGHT; + const bool isSelected = (i == selectorIndex); + + // Line 1: Title + std::string title = book.title; + if (title.empty()) { + title = book.path; + const size_t lastSlash = title.find_last_of('/'); + if (lastSlash != std::string::npos) { + title = title.substr(lastSlash + 1); + } + } + auto truncatedTitle = renderer.truncatedText(UI_12_FONT_ID, title.c_str(), pageWidth - LEFT_MARGIN - RIGHT_MARGIN); + renderer.drawText(UI_12_FONT_ID, LEFT_MARGIN, y + 2, truncatedTitle.c_str(), !isSelected); + + // Line 2: Bookmark count + std::string countText = std::to_string(book.bookmarkCount) + " bookmark" + (book.bookmarkCount != 1 ? "s" : ""); + renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, y + 32, countText.c_str(), !isSelected); + } + + // Draw "Search..." shortcut if it's on the current page + const int searchIndex = bookCount; // Last item + if (searchIndex >= pageStartIndex && searchIndex < pageStartIndex + pageItems) { + const int y = CONTENT_START_Y + (searchIndex % pageItems) * RECENTS_LINE_HEIGHT; + const bool isSelected = (selectorIndex == searchIndex); + renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, y + 2, "Search...", !isSelected); + } +} + +void MyLibraryActivity::renderSearchTab() const { + const auto pageWidth = renderer.getScreenWidth(); + const int pageItems = getPageItems(); + const int resultCount = static_cast(searchResults.size()); + + // Calculate bezel-adjusted margins + const int bezelTop = renderer.getBezelOffsetTop(); + const int bezelLeft = renderer.getBezelOffsetLeft(); + const int bezelRight = renderer.getBezelOffsetRight(); + const int CONTENT_START_Y = BASE_CONTENT_START_Y + bezelTop; + const int LEFT_MARGIN = BASE_LEFT_MARGIN + bezelLeft; + const int RIGHT_MARGIN = BASE_RIGHT_MARGIN + bezelRight; + + // Layout: Character picker -> Query -> Results + // Character picker height: ~30px + // Query line height: ~25px + constexpr int PICKER_HEIGHT = 30; + constexpr int QUERY_HEIGHT = 25; + + // Draw character picker at top + const int pickerY = CONTENT_START_Y; + renderCharacterPicker(pickerY); + + // Draw query string below picker + const int queryY = pickerY + PICKER_HEIGHT; + std::string displayQuery = searchQuery.empty() ? "(select characters above)" : searchQuery; + if (!searchInResults) { + displayQuery = searchQuery + "_"; // Show cursor when in picker + } + auto truncatedQuery = renderer.truncatedText(UI_10_FONT_ID, displayQuery.c_str(), pageWidth - LEFT_MARGIN - RIGHT_MARGIN); + renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, queryY, truncatedQuery.c_str()); + + // Draw results below query + const int resultsStartY = queryY + QUERY_HEIGHT; + + // Draw results section + if (resultCount == 0) { + if (searchQuery.empty()) { + renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, resultsStartY, "Select characters to search"); + } else { + renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, resultsStartY, "No results found"); + } + return; + } + + const auto pageStartIndex = selectorIndex / pageItems * pageItems; + + // Draw items - only show selection when in results mode + for (int i = pageStartIndex; i < resultCount && i < pageStartIndex + pageItems; i++) { + const auto& result = searchResults[i]; + const int y = resultsStartY + (i % pageItems) * RECENTS_LINE_HEIGHT; + const bool isSelected = searchInResults && (i == selectorIndex); + + // Draw selection highlight only when in results + if (isSelected) { + renderer.fillRect(bezelLeft, y - 2, pageWidth - RIGHT_MARGIN - bezelLeft, RECENTS_LINE_HEIGHT); + } + + // Calculate available text width + const int baseAvailableWidth = pageWidth - LEFT_MARGIN - RIGHT_MARGIN; + + // Extract tags for badges (only when NOT selected) + constexpr int badgeSpacing = 4; + constexpr int badgePadding = 10; + constexpr int badgeToEdgeGap = 8; + int totalBadgeWidth = 0; + BookTags tags; + + if (!isSelected) { + tags = StringUtils::extractBookTags(result.path); + if (!tags.extensionTag.empty()) { + totalBadgeWidth += renderer.getTextWidth(SMALL_FONT_ID, tags.extensionTag.c_str()) + badgePadding; + } + if (!tags.suffixTag.empty()) { + if (totalBadgeWidth > 0) { + totalBadgeWidth += badgeSpacing; + } + totalBadgeWidth += renderer.getTextWidth(SMALL_FONT_ID, tags.suffixTag.c_str()) + badgePadding; + } + } + + // Reserve space for badges when not selected + const int badgeReservedWidth = totalBadgeWidth > 0 ? (totalBadgeWidth + badgeSpacing + badgeToEdgeGap) : 0; + const int availableWidth = isSelected ? baseAvailableWidth : (baseAvailableWidth - badgeReservedWidth); + + // Line 1: Title + auto truncatedTitle = renderer.truncatedText(UI_12_FONT_ID, result.title.c_str(), availableWidth); + renderer.drawText(UI_12_FONT_ID, LEFT_MARGIN, y + 2, truncatedTitle.c_str(), !isSelected); + + // Draw badges right-aligned - only when NOT selected + if (!isSelected && totalBadgeWidth > 0) { + const int badgeAreaRight = LEFT_MARGIN + baseAvailableWidth - badgeToEdgeGap; + int badgeX = badgeAreaRight - totalBadgeWidth; + + const int titleLineHeight = renderer.getLineHeight(UI_12_FONT_ID); + const int badgeLineHeight = renderer.getLineHeight(SMALL_FONT_ID); + constexpr int badgeVerticalPadding = 4; + const int badgeHeight = badgeLineHeight + badgeVerticalPadding; + const int badgeY = y + 2 + (titleLineHeight - badgeHeight) / 2; + + if (!tags.extensionTag.empty()) { + int badgeWidth = ScreenComponents::drawPillBadge(renderer, badgeX, badgeY, tags.extensionTag.c_str(), + SMALL_FONT_ID, false); + badgeX += badgeWidth + badgeSpacing; + } + if (!tags.suffixTag.empty()) { + ScreenComponents::drawPillBadge(renderer, badgeX, badgeY, tags.suffixTag.c_str(), SMALL_FONT_ID, false); + } + } + + // Line 2: Author or path + std::string secondLine = result.author.empty() ? result.path : result.author; + auto truncatedSecond = renderer.truncatedText(UI_10_FONT_ID, secondLine.c_str(), baseAvailableWidth); + renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, y + 32, truncatedSecond.c_str(), !isSelected); + } +} + +void MyLibraryActivity::renderCharacterPicker(int y) const { + const auto pageWidth = renderer.getScreenWidth(); + const int bezelLeft = renderer.getBezelOffsetLeft(); + const int bezelRight = renderer.getBezelOffsetRight(); + + constexpr int charSpacing = 6; // Spacing between characters + constexpr int specialKeyPadding = 8; // Extra padding around special keys + constexpr int overflowIndicatorWidth = 16; // Space reserved for < > indicators + + // Calculate total width needed + const int charCount = static_cast(searchCharacters.size()); + const int totalItems = charCount + 3; // +3 for SPC, <-, CLR + + // Calculate character widths + int totalWidth = 0; + for (char c : searchCharacters) { + std::string label(1, c); + totalWidth += renderer.getTextWidth(UI_10_FONT_ID, label.c_str()) + charSpacing; + } + // Add special keys width + totalWidth += renderer.getTextWidth(UI_10_FONT_ID, "SPC") + specialKeyPadding; + totalWidth += renderer.getTextWidth(UI_10_FONT_ID, "<-") + specialKeyPadding; + totalWidth += renderer.getTextWidth(UI_10_FONT_ID, "CLR") + specialKeyPadding; + + // Calculate visible window - we'll scroll the character row + const int availableWidth = pageWidth - bezelLeft - bezelRight - 40; // 40 for margins (20 each side) + + // Determine scroll offset to keep selected character visible + int scrollOffset = 0; + int selectedX = 0; + int currentX = 0; + + // Calculate position of selected item + for (int i = 0; i < totalItems; i++) { + int itemWidth; + if (i < charCount) { + std::string label(1, searchCharacters[i]); + itemWidth = renderer.getTextWidth(UI_10_FONT_ID, label.c_str()) + charSpacing; + } else if (i == charCount) { + itemWidth = renderer.getTextWidth(UI_10_FONT_ID, "SPC") + specialKeyPadding; + } else if (i == charCount + 1) { + itemWidth = renderer.getTextWidth(UI_10_FONT_ID, "<-") + specialKeyPadding; + } else { + itemWidth = renderer.getTextWidth(UI_10_FONT_ID, "CLR") + specialKeyPadding; + } + + if (i == searchCharIndex) { + selectedX = currentX; + // Center the selected item in the visible area + scrollOffset = selectedX - availableWidth / 2 + itemWidth / 2; + if (scrollOffset < 0) scrollOffset = 0; + if (scrollOffset > totalWidth - availableWidth) { + scrollOffset = std::max(0, totalWidth - availableWidth); + } + break; + } + currentX += itemWidth; + } + + // Draw separator line + renderer.drawLine(bezelLeft + 20, y + 22, pageWidth - bezelRight - 20, y + 22); + + // Calculate visible area boundaries (leave room for overflow indicators) + const bool hasLeftOverflow = scrollOffset > 0; + const bool hasRightOverflow = totalWidth > availableWidth && scrollOffset < totalWidth - availableWidth; + const int visibleLeft = bezelLeft + 20 + (hasLeftOverflow ? overflowIndicatorWidth : 0); + const int visibleRight = pageWidth - bezelRight - 20 - (hasRightOverflow ? overflowIndicatorWidth : 0); + + // Draw characters + const int startX = bezelLeft + 20 - scrollOffset; + currentX = startX; + const bool showSelection = !searchInResults && !inTabBar; // Only show selection when in picker (not tab bar or results) + + for (int i = 0; i < totalItems; i++) { + std::string label; + int itemWidth; + bool isSpecial = false; + + if (i < charCount) { + label = std::string(1, searchCharacters[i]); + itemWidth = renderer.getTextWidth(UI_10_FONT_ID, label.c_str()); + } else if (i == charCount) { + label = "SPC"; + itemWidth = renderer.getTextWidth(UI_10_FONT_ID, label.c_str()); + isSpecial = true; + } else if (i == charCount + 1) { + label = "<-"; + itemWidth = renderer.getTextWidth(UI_10_FONT_ID, label.c_str()); + isSpecial = true; + } else { + label = "CLR"; + itemWidth = renderer.getTextWidth(UI_10_FONT_ID, label.c_str()); + isSpecial = true; + } + + // Only draw if visible (accounting for overflow indicator space) + const int drawX = currentX + (isSpecial ? specialKeyPadding / 2 : 0); + if (drawX + itemWidth > visibleLeft && drawX < visibleRight) { + const bool isSelected = showSelection && (i == searchCharIndex); + + if (isSelected) { + // Draw inverted background for selection + constexpr int padding = 2; + const int lineHeight = renderer.getLineHeight(UI_10_FONT_ID); + renderer.fillRect(drawX - padding, y - 2, itemWidth + padding * 2, lineHeight + 2); + // Draw text inverted (white on black) + renderer.drawText(UI_10_FONT_ID, drawX, y, label.c_str(), false); + } else { + renderer.drawText(UI_10_FONT_ID, drawX, y, label.c_str()); + } + } + + currentX += itemWidth + (isSpecial ? specialKeyPadding : charSpacing); + } + + // Draw overflow indicators if content extends beyond visible area + if (totalWidth > availableWidth) { + constexpr int triangleHeight = 12; // Height of the triangle (vertical) + constexpr int triangleWidth = 6; // Width of the triangle (horizontal) - thin/elongated + const int pickerLineHeight = renderer.getLineHeight(UI_10_FONT_ID); + const int triangleCenterY = y + pickerLineHeight / 2; + + // Left overflow indicator (more content to the left) - thin triangle pointing left + if (hasLeftOverflow) { + // Clear background behind indicator to hide any overlapping text + renderer.fillRect(bezelLeft, y - 2, overflowIndicatorWidth + 4, pickerLineHeight + 4, false); + // Draw left-pointing triangle: point on left, base on right + const int tipX = bezelLeft + 2; + for (int i = 0; i < triangleWidth; ++i) { + // Scale height based on position (0 at tip, full height at base) + const int lineHalfHeight = (triangleHeight * i) / (triangleWidth * 2); + renderer.drawLine(tipX + i, triangleCenterY - lineHalfHeight, + tipX + i, triangleCenterY + lineHalfHeight); + } + } + // Right overflow indicator (more content to the right) - thin triangle pointing right + if (hasRightOverflow) { + // Clear background behind indicator to hide any overlapping text + renderer.fillRect(pageWidth - bezelRight - overflowIndicatorWidth - 4, y - 2, overflowIndicatorWidth + 4, pickerLineHeight + 4, false); + // Draw right-pointing triangle: base on left, point on right + const int baseX = pageWidth - bezelRight - 2 - triangleWidth; + for (int i = 0; i < triangleWidth; ++i) { + // Scale height based on position (full height at base, 0 at tip) + const int lineHalfHeight = (triangleHeight * (triangleWidth - 1 - i)) / (triangleWidth * 2); + renderer.drawLine(baseX + i, triangleCenterY - lineHalfHeight, + baseX + i, triangleCenterY + lineHalfHeight); + } + } + } +} diff --git a/src/activities/home/MyLibraryActivity.h b/src/activities/home/MyLibraryActivity.h index 3d29d7b..c9fd48e 100644 --- a/src/activities/home/MyLibraryActivity.h +++ b/src/activities/home/MyLibraryActivity.h @@ -19,9 +19,25 @@ struct ThumbExistsCache { bool exists = false; // Whether thumbnail exists }; +// Search result for the Search tab +struct SearchResult { + std::string path; + std::string title; + std::string author; + int matchScore = 0; // Higher = better match +}; + +// Book with bookmarks info for the Bookmarks tab +struct BookmarkedBook { + std::string path; + std::string title; + std::string author; + int bookmarkCount = 0; +}; + class MyLibraryActivity final : public Activity { public: - enum class Tab { Recent, Lists, Files }; + enum class Tab { Recent, Lists, Bookmarks, Search, Files }; enum class UIState { Normal, ActionMenu, Confirming, ListActionMenu, ListConfirmingDelete, ClearAllRecentsConfirming }; enum class ActionType { Archive, Delete, RemoveFromRecents, ClearAllRecents }; @@ -32,6 +48,7 @@ class MyLibraryActivity final : public Activity { Tab currentTab = Tab::Recent; int selectorIndex = 0; bool updateRequired = false; + bool inTabBar = false; // true = focus on tab bar for switching tabs (all tabs) // Action menu state UIState uiState = UIState::Normal; @@ -61,6 +78,17 @@ class MyLibraryActivity final : public Activity { int listMenuSelection = 0; // 0 = Pin/Unpin, 1 = Delete std::string listActionTargetName; + // Bookmarks tab state + std::vector bookmarkedBooks; + + // Search tab state + std::string searchQuery; + std::vector searchResults; + std::vector allBooks; // Cached index of all books + std::vector searchCharacters; // Dynamic character set from library + int searchCharIndex = 0; // Current position in character picker + bool searchInResults = false; // true = navigating results, false = in character picker + // Files tab state (from FileSelectionActivity) std::string basepath = "/"; std::vector files; @@ -69,6 +97,7 @@ class MyLibraryActivity final : public Activity { const std::function onGoHome; const std::function onSelectBook; const std::function onSelectList; + const std::function onSelectBookmarkedBook; // Number of items that fit on a page int getPageItems() const; @@ -79,6 +108,9 @@ class MyLibraryActivity final : public Activity { // Data loading void loadRecentBooks(); void loadLists(); + void loadBookmarkedBooks(); + void loadAllBooks(); + void updateSearchResults(); void loadFiles(); size_t findEntry(const std::string& name) const; @@ -88,10 +120,16 @@ class MyLibraryActivity final : public Activity { void render() const; void renderRecentTab() const; void renderListsTab() const; + void renderBookmarksTab() const; + void renderSearchTab() const; void renderFilesTab() const; void renderActionMenu() const; void renderConfirmation() const; + // Search character picker helpers + void buildSearchCharacters(); + void renderCharacterPicker(int y) const; + // Action handling void openActionMenu(); void executeAction(); @@ -114,13 +152,15 @@ class MyLibraryActivity final : public Activity { const std::function& onGoHome, const std::function& onSelectBook, const std::function& onSelectList, + const std::function& onSelectBookmarkedBook = nullptr, Tab initialTab = Tab::Recent, std::string initialPath = "/") : Activity("MyLibrary", renderer, mappedInput), currentTab(initialTab), basepath(initialPath.empty() ? "/" : std::move(initialPath)), onGoHome(onGoHome), onSelectBook(onSelectBook), - onSelectList(onSelectList) {} + onSelectList(onSelectList), + onSelectBookmarkedBook(onSelectBookmarkedBook) {} void onEnter() override; void onExit() override; void loop() override; From 82165c102296defd9046f77d9bc1593cc4b9a0c5 Mon Sep 17 00:00:00 2001 From: cottongin Date: Wed, 28 Jan 2026 02:20:58 -0500 Subject: [PATCH 7/7] feat: Wire up bookmark and quick menu features in main app Integrates BookmarkStore initialization, QuickMenuActivity, and BookmarkListActivity into the main application flow. --- src/CrossPointSettings.h | 2 +- src/activities/settings/SettingsActivity.cpp | 2 +- src/main.cpp | 36 +++++++++++++++++--- 3 files changed, 33 insertions(+), 7 deletions(-) diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index 21ece46..1ebf254 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -86,7 +86,7 @@ class CrossPointSettings { }; // Short power button press actions - enum SHORT_PWRBTN { IGNORE = 0, SLEEP = 1, PAGE_TURN = 2, DICTIONARY = 3, SHORT_PWRBTN_COUNT }; + enum SHORT_PWRBTN { IGNORE = 0, SLEEP = 1, PAGE_TURN = 2, DICTIONARY = 3, QUICK_MENU = 4, SHORT_PWRBTN_COUNT }; // Hide battery percentage enum HIDE_BATTERY_PERCENTAGE { HIDE_NEVER = 0, HIDE_READER = 1, HIDE_ALWAYS = 2, HIDE_BATTERY_PERCENTAGE_COUNT }; diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index e5d2817..dd81491 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -84,7 +84,7 @@ const SettingInfo controlsSettings[controlsSettingsCount] = { {"Prev, Next", "Next, Prev"}), SettingInfo::Toggle("Long-press Chapter Skip", &CrossPointSettings::longPressChapterSkip), SettingInfo::Enum("Short Power Button Click", &CrossPointSettings::shortPwrBtn, - {"Ignore", "Sleep", "Page Turn", "Dictionary"})}; + {"Ignore", "Sleep", "Page Turn", "Dictionary", "Quick Menu"})}; constexpr int systemSettingsCount = 4; const SettingInfo systemSettings[systemSettingsCount] = { diff --git a/src/main.cpp b/src/main.cpp index 47f12fb..73d9a2e 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -21,11 +21,13 @@ #include "activities/boot_sleep/BootActivity.h" #include "activities/boot_sleep/SleepActivity.h" #include "activities/browser/OpdsBookBrowserActivity.h" +#include "activities/home/BookmarkListActivity.h" #include "activities/home/HomeActivity.h" #include "activities/home/ListViewActivity.h" #include "activities/home/MyLibraryActivity.h" #include "activities/network/CrossPointWebServerActivity.h" #include "activities/reader/ReaderActivity.h" +#include "activities/settings/ClearCacheActivity.h" #include "activities/settings/SettingsActivity.h" #include "activities/util/FullScreenMessageActivity.h" #include "fontIds.h" @@ -336,10 +338,13 @@ void enterDeepSleep() { void onGoHome(); void onGoToMyLibrary(); void onGoToMyLibraryWithTab(const std::string& path, MyLibraryActivity::Tab tab); +void onGoToClearCache(); +void onGoToSettings(); void onGoToReader(const std::string& initialEpubPath, MyLibraryActivity::Tab fromTab) { exitActivity(); enterNewActivity( - new ReaderActivity(renderer, mappedInputManager, initialEpubPath, fromTab, onGoHome, onGoToMyLibraryWithTab)); + new ReaderActivity(renderer, mappedInputManager, initialEpubPath, fromTab, onGoHome, onGoToMyLibraryWithTab, + onGoToClearCache, onGoToSettings)); } void onContinueReading() { onGoToReader(APP_STATE.openEpubPath, MyLibraryActivity::Tab::Recent); } @@ -348,7 +353,7 @@ void onGoToReaderFromList(const std::string& bookPath) { exitActivity(); // When opening from a list, treat it like opening from Recent (will return to list view via back) enterNewActivity(new ReaderActivity(renderer, mappedInputManager, bookPath, MyLibraryActivity::Tab::Recent, onGoHome, - onGoToMyLibraryWithTab)); + onGoToMyLibraryWithTab, onGoToClearCache, onGoToSettings)); } // View a specific list @@ -358,6 +363,22 @@ void onGoToListView(const std::string& listName) { new ListViewActivity(renderer, mappedInputManager, listName, onGoToMyLibrary, onGoToReaderFromList)); } +// View bookmarks for a specific book +void onGoToBookmarkList(const std::string& bookPath, const std::string& bookTitle) { + exitActivity(); + enterNewActivity(new BookmarkListActivity( + renderer, mappedInputManager, bookPath, bookTitle, + onGoToMyLibrary, // On back, return to library + [bookPath](uint16_t spineIndex, uint32_t contentOffset) { + // Navigate to bookmark location in the book + // For now, just open the book (TODO: pass bookmark location to reader) + exitActivity(); + enterNewActivity(new ReaderActivity(renderer, mappedInputManager, bookPath, + MyLibraryActivity::Tab::Bookmarks, onGoHome, onGoToMyLibraryWithTab, + onGoToClearCache, onGoToSettings)); + })); +} + // Go to pinned list (if exists) or Lists tab void onGoToListsOrPinned() { exitActivity(); @@ -368,7 +389,7 @@ void onGoToListsOrPinned() { } else { // Go to Lists tab in My Library enterNewActivity(new MyLibraryActivity(renderer, mappedInputManager, onGoHome, onGoToReader, onGoToListView, - MyLibraryActivity::Tab::Lists)); + onGoToBookmarkList, MyLibraryActivity::Tab::Lists)); } } @@ -382,14 +403,19 @@ void onGoToSettings() { enterNewActivity(new SettingsActivity(renderer, mappedInputManager, onGoHome)); } +void onGoToClearCache() { + exitActivity(); + enterNewActivity(new ClearCacheActivity(renderer, mappedInputManager, onGoHome)); +} + void onGoToMyLibrary() { exitActivity(); - enterNewActivity(new MyLibraryActivity(renderer, mappedInputManager, onGoHome, onGoToReader, onGoToListView)); + enterNewActivity(new MyLibraryActivity(renderer, mappedInputManager, onGoHome, onGoToReader, onGoToListView, onGoToBookmarkList)); } void onGoToMyLibraryWithTab(const std::string& path, MyLibraryActivity::Tab tab) { exitActivity(); - enterNewActivity(new MyLibraryActivity(renderer, mappedInputManager, onGoHome, onGoToReader, onGoToListView, tab, path)); + enterNewActivity(new MyLibraryActivity(renderer, mappedInputManager, onGoHome, onGoToReader, onGoToListView, onGoToBookmarkList, tab, path)); } void onGoToBrowser() {