#include "HomeActivity.h" #include #include #include #include #include #include #include #include #include "Battery.h" #include "CrossPointSettings.h" #include "CrossPointState.h" #include "MappedInputManager.h" #include "ScreenComponents.h" #include "fontIds.h" #include "util/StringUtils.h" void HomeActivity::taskTrampoline(void* param) { auto* self = static_cast(param); self->displayTaskLoop(); } int HomeActivity::getMenuItemCount() const { int count = 4; // My Library, File transfer, Apps, Settings if (hasContinueReading) count++; if (hasOpdsUrl) count++; return count; } void HomeActivity::onEnter() { Activity::onEnter(); renderingMutex = xSemaphoreCreateMutex(); // Check if we have a book to continue reading hasContinueReading = !APP_STATE.openEpubPath.empty() && SdMan.exists(APP_STATE.openEpubPath.c_str()); // Check if OPDS browser URL is configured hasOpdsUrl = strlen(SETTINGS.opdsServerUrl) > 0; if (hasContinueReading) { // Extract filename from path for display lastBookTitle = APP_STATE.openEpubPath; const size_t lastSlash = lastBookTitle.find_last_of('/'); if (lastSlash != std::string::npos) { lastBookTitle = lastBookTitle.substr(lastSlash + 1); } // If epub, try to load the metadata for title/author and cover if (StringUtils::checkFileExtension(lastBookTitle, ".epub")) { Epub epub(APP_STATE.openEpubPath, "/.crosspoint"); epub.load(false); if (!epub.getTitle().empty()) { lastBookTitle = std::string(epub.getTitle()); } if (!epub.getAuthor().empty()) { lastBookAuthor = std::string(epub.getAuthor()); } // Try to generate thumbnail image for Continue Reading card if (epub.generateThumbBmp()) { coverBmpPath = epub.getThumbBmpPath(); hasCoverImage = true; } } else if (StringUtils::checkFileExtension(lastBookTitle, ".xtch") || StringUtils::checkFileExtension(lastBookTitle, ".xtc")) { // Handle XTC file Xtc xtc(APP_STATE.openEpubPath, "/.crosspoint"); if (xtc.load()) { if (!xtc.getTitle().empty()) { lastBookTitle = std::string(xtc.getTitle()); } if (!xtc.getAuthor().empty()) { lastBookAuthor = std::string(xtc.getAuthor()); } // Try to generate thumbnail image for Continue Reading card if (xtc.generateThumbBmp()) { coverBmpPath = xtc.getThumbBmpPath(); hasCoverImage = true; } } // Remove extension from title if we don't have metadata if (StringUtils::checkFileExtension(lastBookTitle, ".xtch")) { lastBookTitle.resize(lastBookTitle.length() - 5); } else if (StringUtils::checkFileExtension(lastBookTitle, ".xtc")) { lastBookTitle.resize(lastBookTitle.length() - 4); } } } selectorIndex = 0; // Trigger first update updateRequired = true; xTaskCreate(&HomeActivity::taskTrampoline, "HomeActivityTask", 4096, // Stack size (increased for cover image rendering) this, // Parameters 1, // Priority &displayTaskHandle // Task handle ); } void HomeActivity::onExit() { Activity::onExit(); // Wait until not rendering to delete task to avoid killing mid-instruction to EPD xSemaphoreTake(renderingMutex, portMAX_DELAY); if (displayTaskHandle) { vTaskDelete(displayTaskHandle); displayTaskHandle = nullptr; } vSemaphoreDelete(renderingMutex); renderingMutex = nullptr; // Free the stored cover buffer if any freeCoverBuffer(); } bool HomeActivity::storeCoverBuffer() { uint8_t* frameBuffer = renderer.getFrameBuffer(); if (!frameBuffer) { return false; } // Free any existing buffer first freeCoverBuffer(); const size_t bufferSize = GfxRenderer::getBufferSize(); coverBuffer = static_cast(malloc(bufferSize)); if (!coverBuffer) { return false; } memcpy(coverBuffer, frameBuffer, bufferSize); return true; } bool HomeActivity::restoreCoverBuffer() { if (!coverBuffer) { return false; } uint8_t* frameBuffer = renderer.getFrameBuffer(); if (!frameBuffer) { return false; } const size_t bufferSize = GfxRenderer::getBufferSize(); memcpy(frameBuffer, coverBuffer, bufferSize); return true; } void HomeActivity::freeCoverBuffer() { if (coverBuffer) { free(coverBuffer); coverBuffer = nullptr; } coverBufferStored = false; } void HomeActivity::loop() { 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); const int menuCount = getMenuItemCount(); if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { // Calculate dynamic indices based on which options are available int idx = 0; const int continueIdx = hasContinueReading ? idx++ : -1; const int myLibraryIdx = idx++; const int opdsLibraryIdx = hasOpdsUrl ? idx++ : -1; const int fileTransferIdx = idx++; const int appsIdx = idx++; const int settingsIdx = idx; if (selectorIndex == continueIdx) { onContinueReading(); } else if (selectorIndex == myLibraryIdx) { onMyLibraryOpen(); } else if (selectorIndex == opdsLibraryIdx) { onOpdsBrowserOpen(); } else if (selectorIndex == fileTransferIdx) { onFileTransferOpen(); } else if (selectorIndex == appsIdx) { onAppsOpen(); } else if (selectorIndex == settingsIdx) { onSettingsOpen(); } } else if (prevPressed) { selectorIndex = (selectorIndex + menuCount - 1) % menuCount; updateRequired = true; } else if (nextPressed) { selectorIndex = (selectorIndex + 1) % menuCount; updateRequired = true; } } void HomeActivity::displayTaskLoop() { while (true) { if (updateRequired) { updateRequired = false; xSemaphoreTake(renderingMutex, portMAX_DELAY); render(); xSemaphoreGive(renderingMutex); } vTaskDelay(10 / portTICK_PERIOD_MS); } } void HomeActivity::render() { // If we have a stored cover buffer, restore it instead of clearing const bool bufferRestored = coverBufferStored && restoreCoverBuffer(); if (!bufferRestored) { renderer.clearScreen(); } const auto pageWidth = renderer.getScreenWidth(); const auto pageHeight = renderer.getScreenHeight(); constexpr int margin = 20; constexpr int bottomMargin = 60; // --- Top "book" card for the current title (selectorIndex == 0) --- const int bookWidth = pageWidth / 2; const int bookHeight = pageHeight / 2; const int bookX = (pageWidth - bookWidth) / 2; constexpr int bookY = 30; const bool bookSelected = hasContinueReading && selectorIndex == 0; // Bookmark dimensions (used in multiple places) const int bookmarkWidth = bookWidth / 8; const int bookmarkHeight = bookHeight / 5; const int bookmarkX = bookX + bookWidth - bookmarkWidth - 10; const int bookmarkY = bookY + 5; // Draw book card regardless, fill with message based on `hasContinueReading` { // Draw cover image as background if available (inside the box) // Only load from SD on first render, then use stored buffer if (hasContinueReading && hasCoverImage && !coverBmpPath.empty() && !coverRendered) { // First time: load cover from SD and render FsFile file; if (SdMan.openFileForRead("HOME", coverBmpPath, file)) { Bitmap bitmap(file); if (bitmap.parseHeaders() == BmpReaderError::Ok) { // Calculate position to center image within the book card int coverX, coverY; if (bitmap.getWidth() > bookWidth || bitmap.getHeight() > bookHeight) { const float imgRatio = static_cast(bitmap.getWidth()) / static_cast(bitmap.getHeight()); const float boxRatio = static_cast(bookWidth) / static_cast(bookHeight); if (imgRatio > boxRatio) { coverX = bookX; coverY = bookY + (bookHeight - static_cast(bookWidth / imgRatio)) / 2; } else { coverX = bookX + (bookWidth - static_cast(bookHeight * imgRatio)) / 2; coverY = bookY; } } else { coverX = bookX + (bookWidth - bitmap.getWidth()) / 2; coverY = bookY + (bookHeight - bitmap.getHeight()) / 2; } // Draw the cover image centered within the book card renderer.drawBitmap(bitmap, coverX, coverY, bookWidth, bookHeight); // Draw border around the card renderer.drawRect(bookX, bookY, bookWidth, bookHeight); // No bookmark ribbon when cover is shown - it would just cover the art // Store the buffer with cover image for fast navigation coverBufferStored = storeCoverBuffer(); coverRendered = true; // First render: if selected, draw selection indicators now if (bookSelected) { renderer.drawRect(bookX + 1, bookY + 1, bookWidth - 2, bookHeight - 2); renderer.drawRect(bookX + 2, bookY + 2, bookWidth - 4, bookHeight - 4); } } file.close(); } } else if (!bufferRestored && !coverRendered) { // No cover image: draw border or fill, plus bookmark as visual flair if (bookSelected) { renderer.fillRect(bookX, bookY, bookWidth, bookHeight); } else { renderer.drawRect(bookX, bookY, bookWidth, bookHeight); } // Draw bookmark ribbon when no cover image (visual decoration) if (hasContinueReading) { const int notchDepth = bookmarkHeight / 3; const int centerX = bookmarkX + bookmarkWidth / 2; const int xPoints[5] = { bookmarkX, // top-left bookmarkX + bookmarkWidth, // top-right bookmarkX + bookmarkWidth, // bottom-right centerX, // center notch point bookmarkX // bottom-left }; const int yPoints[5] = { bookmarkY, // top-left bookmarkY, // top-right bookmarkY + bookmarkHeight, // bottom-right bookmarkY + bookmarkHeight - notchDepth, // center notch point bookmarkY + bookmarkHeight // bottom-left }; // Draw bookmark ribbon (inverted if selected) renderer.fillPolygon(xPoints, yPoints, 5, !bookSelected); } } // If buffer was restored, draw selection indicators if needed if (bufferRestored && bookSelected && coverRendered) { // Draw selection border (no bookmark inversion needed since cover has no bookmark) renderer.drawRect(bookX + 1, bookY + 1, bookWidth - 2, bookHeight - 2); renderer.drawRect(bookX + 2, bookY + 2, bookWidth - 4, bookHeight - 4); } else if (!coverRendered && !bufferRestored) { // Selection border already handled above in the no-cover case } } if (hasContinueReading) { // Invert text colors based on selection state: // - With cover: selected = white text on black box, unselected = black text on white box // - Without cover: selected = white text on black card, unselected = black text on white card // Split into words (avoid stringstream to keep this light on the MCU) std::vector words; words.reserve(8); size_t pos = 0; while (pos < lastBookTitle.size()) { while (pos < lastBookTitle.size() && lastBookTitle[pos] == ' ') { ++pos; } if (pos >= lastBookTitle.size()) { break; } const size_t start = pos; while (pos < lastBookTitle.size() && lastBookTitle[pos] != ' ') { ++pos; } words.emplace_back(lastBookTitle.substr(start, pos - start)); } std::vector lines; std::string currentLine; // Extra padding inside the card so text doesn't hug the border const int maxLineWidth = bookWidth - 40; const int spaceWidth = renderer.getSpaceWidth(UI_12_FONT_ID); for (auto& i : words) { // If we just hit the line limit (3), stop processing words if (lines.size() >= 3) { // Limit to 3 lines // Still have words left, so add ellipsis to last line lines.back().append("..."); while (!lines.back().empty() && renderer.getTextWidth(UI_12_FONT_ID, lines.back().c_str()) > maxLineWidth) { // Remove "..." first, then remove one UTF-8 char, then add "..." back lines.back().resize(lines.back().size() - 3); // Remove "..." utf8RemoveLastChar(lines.back()); lines.back().append("..."); } break; } int wordWidth = renderer.getTextWidth(UI_12_FONT_ID, i.c_str()); while (wordWidth > maxLineWidth && !i.empty()) { // Word itself is too long, trim it (UTF-8 safe) utf8RemoveLastChar(i); // Check if we have room for ellipsis std::string withEllipsis = i + "..."; wordWidth = renderer.getTextWidth(UI_12_FONT_ID, withEllipsis.c_str()); if (wordWidth <= maxLineWidth) { i = withEllipsis; break; } } int newLineWidth = renderer.getTextWidth(UI_12_FONT_ID, currentLine.c_str()); if (newLineWidth > 0) { newLineWidth += spaceWidth; } newLineWidth += wordWidth; if (newLineWidth > maxLineWidth && !currentLine.empty()) { // New line too long, push old line lines.push_back(currentLine); currentLine = i; } else { currentLine.append(" ").append(i); } } // If lower than the line limit, push remaining words if (!currentLine.empty() && lines.size() < 3) { lines.push_back(currentLine); } // Book title text int totalTextHeight = renderer.getLineHeight(UI_12_FONT_ID) * static_cast(lines.size()); if (!lastBookAuthor.empty()) { totalTextHeight += renderer.getLineHeight(UI_10_FONT_ID) * 3 / 2; } // Vertically center the title block within the card int titleYStart = bookY + (bookHeight - totalTextHeight) / 2; // If cover image was rendered, draw box behind title and author if (coverRendered) { constexpr int boxPadding = 8; // Calculate the max text width for the box int maxTextWidth = 0; for (const auto& line : lines) { const int lineWidth = renderer.getTextWidth(UI_12_FONT_ID, line.c_str()); if (lineWidth > maxTextWidth) { maxTextWidth = lineWidth; } } if (!lastBookAuthor.empty()) { std::string trimmedAuthor = lastBookAuthor; while (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) > maxLineWidth && !trimmedAuthor.empty()) { utf8RemoveLastChar(trimmedAuthor); } if (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) < renderer.getTextWidth(UI_10_FONT_ID, lastBookAuthor.c_str())) { trimmedAuthor.append("..."); } const int authorWidth = renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()); if (authorWidth > maxTextWidth) { maxTextWidth = authorWidth; } } const int boxWidth = maxTextWidth + boxPadding * 2; const int boxHeight = totalTextHeight + boxPadding * 2; const int boxX = (pageWidth - boxWidth) / 2; const int boxY = titleYStart - boxPadding; // Draw box (inverted when selected: black box instead of white) renderer.fillRect(boxX, boxY, boxWidth, boxHeight, bookSelected); // Draw border around the box (inverted when selected: white border instead of black) renderer.drawRect(boxX, boxY, boxWidth, boxHeight, !bookSelected); } for (const auto& line : lines) { renderer.drawCenteredText(UI_12_FONT_ID, titleYStart, line.c_str(), !bookSelected); titleYStart += renderer.getLineHeight(UI_12_FONT_ID); } if (!lastBookAuthor.empty()) { titleYStart += renderer.getLineHeight(UI_10_FONT_ID) / 2; std::string trimmedAuthor = lastBookAuthor; // Trim author if too long (UTF-8 safe) bool wasTrimmed = false; while (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) > maxLineWidth && !trimmedAuthor.empty()) { utf8RemoveLastChar(trimmedAuthor); wasTrimmed = true; } if (wasTrimmed && !trimmedAuthor.empty()) { // Make room for ellipsis while (renderer.getTextWidth(UI_10_FONT_ID, (trimmedAuthor + "...").c_str()) > maxLineWidth && !trimmedAuthor.empty()) { utf8RemoveLastChar(trimmedAuthor); } trimmedAuthor.append("..."); } renderer.drawCenteredText(UI_10_FONT_ID, titleYStart, trimmedAuthor.c_str(), !bookSelected); } // "Continue Reading" label at the bottom const int continueY = bookY + bookHeight - renderer.getLineHeight(UI_10_FONT_ID) * 3 / 2; if (coverRendered) { // Draw box behind "Continue Reading" text (inverted when selected: black box instead of white) const char* continueText = "Continue Reading"; const int continueTextWidth = renderer.getTextWidth(UI_10_FONT_ID, continueText); constexpr int continuePadding = 6; const int continueBoxWidth = continueTextWidth + continuePadding * 2; const int continueBoxHeight = renderer.getLineHeight(UI_10_FONT_ID) + continuePadding; const int continueBoxX = (pageWidth - continueBoxWidth) / 2; const int continueBoxY = continueY - continuePadding / 2; renderer.fillRect(continueBoxX, continueBoxY, continueBoxWidth, continueBoxHeight, bookSelected); renderer.drawRect(continueBoxX, continueBoxY, continueBoxWidth, continueBoxHeight, !bookSelected); renderer.drawCenteredText(UI_10_FONT_ID, continueY, continueText, !bookSelected); } else { renderer.drawCenteredText(UI_10_FONT_ID, continueY, "Continue Reading", !bookSelected); } } else { // No book to continue reading const int y = bookY + (bookHeight - renderer.getLineHeight(UI_12_FONT_ID) - renderer.getLineHeight(UI_10_FONT_ID)) / 2; renderer.drawCenteredText(UI_12_FONT_ID, y, "No open book"); renderer.drawCenteredText(UI_10_FONT_ID, y + renderer.getLineHeight(UI_12_FONT_ID), "Start reading below"); } // --- Bottom menu tiles --- // Build menu items dynamically // Keep this list in sync with getMenuItemCount() and loop() index mapping. std::vector menuItems = {"My Library", "File Transfer", "Apps", "Settings"}; if (hasOpdsUrl) { // Insert OPDS Browser after My Library menuItems.insert(menuItems.begin() + 1, "OPDS Browser"); } const int menuTileWidth = pageWidth - 2 * margin; constexpr int menuTileHeight = 45; constexpr int menuSpacing = 8; const int totalMenuHeight = static_cast(menuItems.size()) * menuTileHeight + (static_cast(menuItems.size()) - 1) * menuSpacing; int menuStartY = bookY + bookHeight + 15; // Ensure we don't collide with the bottom button legend const int maxMenuStartY = pageHeight - bottomMargin - totalMenuHeight - margin; if (menuStartY > maxMenuStartY) { menuStartY = maxMenuStartY; } for (size_t i = 0; i < menuItems.size(); ++i) { const int overallIndex = static_cast(i) + (hasContinueReading ? 1 : 0); constexpr int tileX = margin; const int tileY = menuStartY + static_cast(i) * (menuTileHeight + menuSpacing); const bool selected = selectorIndex == overallIndex; if (selected) { renderer.fillRect(tileX, tileY, menuTileWidth, menuTileHeight); } else { renderer.drawRect(tileX, tileY, menuTileWidth, menuTileHeight); } const char* label = menuItems[i]; const int textWidth = renderer.getTextWidth(UI_10_FONT_ID, label); const int textX = tileX + (menuTileWidth - textWidth) / 2; const int lineHeight = renderer.getLineHeight(UI_10_FONT_ID); const int textY = tileY + (menuTileHeight - lineHeight) / 2; // vertically centered assuming y is top of text // Invert text when the tile is selected, to contrast with the filled background renderer.drawText(UI_10_FONT_ID, textX, textY, label, !selected); } const auto labels = mappedInput.mapLabels("", "Select", "Up", "Down"); renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); const bool showBatteryPercentage = SETTINGS.hideBatteryPercentage != CrossPointSettings::HIDE_BATTERY_PERCENTAGE::HIDE_ALWAYS; // get percentage so we can align text properly const uint16_t percentage = battery.readPercentage(); const auto percentageText = showBatteryPercentage ? std::to_string(percentage) + "%" : ""; const auto batteryX = pageWidth - 25 - renderer.getTextWidth(SMALL_FONT_ID, percentageText.c_str()); ScreenComponents::drawBattery(renderer, batteryX, 10, showBatteryPercentage); renderer.displayBuffer(); }