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 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/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/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") 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; +}; 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; 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; 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/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; +}; 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() {