#include "TxtReaderActivity.h" #include #include #include #include #include "CrossPointSettings.h" #include "CrossPointState.h" #include "MappedInputManager.h" #include "RecentBooksStore.h" #include "ScreenComponents.h" #include "fontIds.h" namespace { constexpr unsigned long goHomeMs = 1000; constexpr int statusBarMargin = 25; constexpr int progressBarMarginTop = 1; constexpr size_t CHUNK_SIZE = 8 * 1024; // 8KB chunk for reading // Cache file magic and version constexpr uint32_t CACHE_MAGIC = 0x54585449; // "TXTI" constexpr uint8_t CACHE_VERSION = 2; // Increment when cache format changes } // namespace void TxtReaderActivity::taskTrampoline(void* param) { auto* self = static_cast(param); self->displayTaskLoop(); } void TxtReaderActivity::onEnter() { ActivityWithSubactivity::onEnter(); if (!txt) { return; } // Configure screen orientation based on settings switch (SETTINGS.orientation) { case CrossPointSettings::ORIENTATION::PORTRAIT: renderer.setOrientation(GfxRenderer::Orientation::Portrait); break; case CrossPointSettings::ORIENTATION::LANDSCAPE_CW: renderer.setOrientation(GfxRenderer::Orientation::LandscapeClockwise); break; case CrossPointSettings::ORIENTATION::INVERTED: renderer.setOrientation(GfxRenderer::Orientation::PortraitInverted); break; case CrossPointSettings::ORIENTATION::LANDSCAPE_CCW: renderer.setOrientation(GfxRenderer::Orientation::LandscapeCounterClockwise); break; default: break; } renderingMutex = xSemaphoreCreateMutex(); txt->setupCacheDir(); // Save current txt as last opened file and add to recent books APP_STATE.openEpubPath = txt->getPath(); APP_STATE.saveToFile(); RECENT_BOOKS.addBook(txt->getPath()); // Trigger first update updateRequired = true; xTaskCreate(&TxtReaderActivity::taskTrampoline, "TxtReaderActivityTask", 6144, // Stack size this, // Parameters 1, // Priority &displayTaskHandle // Task handle ); } void TxtReaderActivity::onExit() { ActivityWithSubactivity::onExit(); // Reset orientation back to portrait for the rest of the UI renderer.setOrientation(GfxRenderer::Orientation::Portrait); // Wait until not rendering to delete task xSemaphoreTake(renderingMutex, portMAX_DELAY); if (displayTaskHandle) { vTaskDelete(displayTaskHandle); displayTaskHandle = nullptr; } vSemaphoreDelete(renderingMutex); renderingMutex = nullptr; pageOffsets.clear(); currentPageLines.clear(); txt.reset(); } void TxtReaderActivity::loop() { if (subActivity) { subActivity->loop(); return; } // Long press BACK (1s+) goes directly to home if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= goHomeMs) { onGoHome(); return; } // Short press BACK goes to file selection if (mappedInput.wasReleased(MappedInputManager::Button::Back) && mappedInput.getHeldTime() < goHomeMs) { onGoBack(); return; } const bool prevReleased = mappedInput.wasReleased(MappedInputManager::Button::PageBack) || mappedInput.wasReleased(MappedInputManager::Button::Left); const bool nextReleased = mappedInput.wasReleased(MappedInputManager::Button::PageForward) || (SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::PAGE_TURN && mappedInput.wasReleased(MappedInputManager::Button::Power)) || mappedInput.wasReleased(MappedInputManager::Button::Right); if (!prevReleased && !nextReleased) { return; } if (prevReleased && currentPage > 0) { currentPage--; updateRequired = true; } else if (nextReleased && currentPage < totalPages - 1) { currentPage++; updateRequired = true; } } void TxtReaderActivity::displayTaskLoop() { while (true) { if (updateRequired) { updateRequired = false; xSemaphoreTake(renderingMutex, portMAX_DELAY); renderScreen(); xSemaphoreGive(renderingMutex); } vTaskDelay(10 / portTICK_PERIOD_MS); } } void TxtReaderActivity::initializeReader() { if (initialized) { return; } // Store current settings for cache validation cachedFontId = SETTINGS.getReaderFontId(); cachedScreenMargin = SETTINGS.screenMargin; cachedParagraphAlignment = SETTINGS.paragraphAlignment; // Calculate viewport dimensions int orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft; renderer.getOrientedViewableTRBL(&orientedMarginTop, &orientedMarginRight, &orientedMarginBottom, &orientedMarginLeft); orientedMarginTop += cachedScreenMargin; orientedMarginLeft += cachedScreenMargin; orientedMarginRight += cachedScreenMargin; orientedMarginBottom += cachedScreenMargin; // Add status bar margin if (SETTINGS.statusBar != CrossPointSettings::STATUS_BAR_MODE::NONE) { // Add additional margin for status bar if progress bar is shown const bool showProgressBar = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL_WITH_PROGRESS_BAR || SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::ONLY_PROGRESS_BAR; orientedMarginBottom += statusBarMargin - cachedScreenMargin + (showProgressBar ? (ScreenComponents::BOOK_PROGRESS_BAR_HEIGHT + progressBarMarginTop) : 0); } viewportWidth = renderer.getScreenWidth() - orientedMarginLeft - orientedMarginRight; const int viewportHeight = renderer.getScreenHeight() - orientedMarginTop - orientedMarginBottom; const int lineHeight = renderer.getLineHeight(cachedFontId); linesPerPage = viewportHeight / lineHeight; if (linesPerPage < 1) linesPerPage = 1; Serial.printf("[%lu] [TRS] Viewport: %dx%d, lines per page: %d\n", millis(), viewportWidth, viewportHeight, linesPerPage); // Try to load cached page index first if (!loadPageIndexCache()) { // Cache not found, build page index buildPageIndex(); // Save to cache for next time savePageIndexCache(); } // Load saved progress loadProgress(); initialized = true; } void TxtReaderActivity::buildPageIndex() { pageOffsets.clear(); pageOffsets.push_back(0); // First page starts at offset 0 size_t offset = 0; const size_t fileSize = txt->getFileSize(); int lastProgressPercent = -1; Serial.printf("[%lu] [TRS] Building page index for %zu bytes...\n", millis(), fileSize); // Progress bar dimensions (matching EpubReaderActivity style) constexpr int barWidth = 200; constexpr int barHeight = 10; constexpr int boxMargin = 20; const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, "Indexing..."); const int boxWidth = (barWidth > textWidth ? barWidth : textWidth) + boxMargin * 2; const int boxHeight = renderer.getLineHeight(UI_12_FONT_ID) + barHeight + boxMargin * 3; const int boxX = (renderer.getScreenWidth() - boxWidth) / 2; constexpr int boxY = 50; const int barX = boxX + (boxWidth - barWidth) / 2; const int barY = boxY + renderer.getLineHeight(UI_12_FONT_ID) + boxMargin * 2; // Draw initial progress box renderer.fillRect(boxX, boxY, boxWidth, boxHeight, false); renderer.drawText(UI_12_FONT_ID, boxX + boxMargin, boxY + boxMargin, "Indexing..."); renderer.drawRect(boxX + 5, boxY + 5, boxWidth - 10, boxHeight - 10); renderer.drawRect(barX, barY, barWidth, barHeight); renderer.displayBuffer(); while (offset < fileSize) { std::vector tempLines; size_t nextOffset = offset; if (!loadPageAtOffset(offset, tempLines, nextOffset)) { break; } if (nextOffset <= offset) { // No progress made, avoid infinite loop break; } offset = nextOffset; if (offset < fileSize) { pageOffsets.push_back(offset); } // Update progress bar every 10% (matching EpubReaderActivity logic) int progressPercent = (offset * 100) / fileSize; if (lastProgressPercent / 10 != progressPercent / 10) { lastProgressPercent = progressPercent; // Fill progress bar const int fillWidth = (barWidth - 2) * progressPercent / 100; renderer.fillRect(barX + 1, barY + 1, fillWidth, barHeight - 2, true); renderer.displayBuffer(EInkDisplay::FAST_REFRESH); } // Yield to other tasks periodically if (pageOffsets.size() % 20 == 0) { vTaskDelay(1); } } totalPages = pageOffsets.size(); Serial.printf("[%lu] [TRS] Built page index: %d pages\n", millis(), totalPages); } bool TxtReaderActivity::loadPageAtOffset(size_t offset, std::vector& outLines, size_t& nextOffset) { outLines.clear(); const size_t fileSize = txt->getFileSize(); if (offset >= fileSize) { return false; } // Read a chunk from file size_t chunkSize = std::min(CHUNK_SIZE, fileSize - offset); auto* buffer = static_cast(malloc(chunkSize + 1)); if (!buffer) { Serial.printf("[%lu] [TRS] Failed to allocate %zu bytes\n", millis(), chunkSize); return false; } if (!txt->readContent(buffer, offset, chunkSize)) { free(buffer); return false; } buffer[chunkSize] = '\0'; // Parse lines from buffer size_t pos = 0; while (pos < chunkSize && static_cast(outLines.size()) < linesPerPage) { // Find end of line size_t lineEnd = pos; while (lineEnd < chunkSize && buffer[lineEnd] != '\n') { lineEnd++; } // Check if we have a complete line bool lineComplete = (lineEnd < chunkSize) || (offset + lineEnd >= fileSize); if (!lineComplete && static_cast(outLines.size()) > 0) { // Incomplete line and we already have some lines, stop here break; } // Calculate the actual length of line content in the buffer (excluding newline) size_t lineContentLen = lineEnd - pos; // Check for carriage return bool hasCR = (lineContentLen > 0 && buffer[pos + lineContentLen - 1] == '\r'); size_t displayLen = hasCR ? lineContentLen - 1 : lineContentLen; // Extract line content for display (without CR/LF) std::string line(reinterpret_cast(buffer + pos), displayLen); // Track position within this source line (in bytes from pos) size_t lineBytePos = 0; // Word wrap if needed while (!line.empty() && static_cast(outLines.size()) < linesPerPage) { int lineWidth = renderer.getTextWidth(cachedFontId, line.c_str()); if (lineWidth <= viewportWidth) { outLines.push_back(line); lineBytePos = displayLen; // Consumed entire display content line.clear(); break; } // Find break point size_t breakPos = line.length(); while (breakPos > 0 && renderer.getTextWidth(cachedFontId, line.substr(0, breakPos).c_str()) > viewportWidth) { // Try to break at space size_t spacePos = line.rfind(' ', breakPos - 1); if (spacePos != std::string::npos && spacePos > 0) { breakPos = spacePos; } else { // Break at character boundary for UTF-8 breakPos--; // Make sure we don't break in the middle of a UTF-8 sequence while (breakPos > 0 && (line[breakPos] & 0xC0) == 0x80) { breakPos--; } } } if (breakPos == 0) { breakPos = 1; } outLines.push_back(line.substr(0, breakPos)); // Skip space at break point size_t skipChars = breakPos; if (breakPos < line.length() && line[breakPos] == ' ') { skipChars++; } lineBytePos += skipChars; line = line.substr(skipChars); } // Determine how much of the source buffer we consumed if (line.empty()) { // Fully consumed this source line, move past the newline pos = lineEnd + 1; } else { // Partially consumed - page is full mid-line // Move pos to where we stopped in the line (NOT past the line) pos = pos + lineBytePos; break; } } // Ensure we make progress even if calculations go wrong if (pos == 0 && !outLines.empty()) { // Fallback: at minimum, consume something to avoid infinite loop pos = 1; } nextOffset = offset + pos; // Make sure we don't go past the file if (nextOffset > fileSize) { nextOffset = fileSize; } free(buffer); return !outLines.empty(); } void TxtReaderActivity::renderScreen() { if (!txt) { return; } // Initialize reader if not done if (!initialized) { renderer.clearScreen(); renderer.drawCenteredText(UI_12_FONT_ID, 300, "Indexing...", true, EpdFontFamily::BOLD); renderer.displayBuffer(); initializeReader(); } if (pageOffsets.empty()) { renderer.clearScreen(); renderer.drawCenteredText(UI_12_FONT_ID, 300, "Empty file", true, EpdFontFamily::BOLD); renderer.displayBuffer(); return; } // Bounds check if (currentPage < 0) currentPage = 0; if (currentPage >= totalPages) currentPage = totalPages - 1; // Load current page content size_t offset = pageOffsets[currentPage]; size_t nextOffset; currentPageLines.clear(); loadPageAtOffset(offset, currentPageLines, nextOffset); renderer.clearScreen(); renderPage(); // Save progress saveProgress(); } void TxtReaderActivity::renderPage() { int orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft; renderer.getOrientedViewableTRBL(&orientedMarginTop, &orientedMarginRight, &orientedMarginBottom, &orientedMarginLeft); orientedMarginTop += cachedScreenMargin; orientedMarginLeft += cachedScreenMargin; orientedMarginRight += cachedScreenMargin; orientedMarginBottom += statusBarMargin; const int lineHeight = renderer.getLineHeight(cachedFontId); const int contentWidth = viewportWidth; // Render text lines with alignment auto renderLines = [&]() { int y = orientedMarginTop; for (const auto& line : currentPageLines) { if (!line.empty()) { int x = orientedMarginLeft; // Apply text alignment switch (cachedParagraphAlignment) { case CrossPointSettings::LEFT_ALIGN: default: // x already set to left margin break; case CrossPointSettings::CENTER_ALIGN: { int textWidth = renderer.getTextWidth(cachedFontId, line.c_str()); x = orientedMarginLeft + (contentWidth - textWidth) / 2; break; } case CrossPointSettings::RIGHT_ALIGN: { int textWidth = renderer.getTextWidth(cachedFontId, line.c_str()); x = orientedMarginLeft + contentWidth - textWidth; break; } case CrossPointSettings::JUSTIFIED: // For plain text, justified is treated as left-aligned // (true justification would require word spacing adjustments) break; } renderer.drawText(cachedFontId, x, y, line.c_str()); } y += lineHeight; } }; // First pass: BW rendering renderLines(); renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft); if (pagesUntilFullRefresh <= 1) { renderer.displayBuffer(EInkDisplay::HALF_REFRESH); pagesUntilFullRefresh = SETTINGS.getRefreshFrequency(); } else { renderer.displayBuffer(); pagesUntilFullRefresh--; } // Grayscale rendering pass (for anti-aliased fonts) if (SETTINGS.textAntiAliasing) { // Save BW buffer for restoration after grayscale pass renderer.storeBwBuffer(); renderer.clearScreen(0x00); renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB); renderLines(); renderer.copyGrayscaleLsbBuffers(); renderer.clearScreen(0x00); renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB); renderLines(); renderer.copyGrayscaleMsbBuffers(); renderer.displayGrayBuffer(); renderer.setRenderMode(GfxRenderer::BW); // Restore BW buffer renderer.restoreBwBuffer(); } } void TxtReaderActivity::renderStatusBar(const int orientedMarginRight, const int orientedMarginBottom, const int orientedMarginLeft) const { const bool showProgressPercentage = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL; const bool showProgressBar = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL_WITH_PROGRESS_BAR || SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::ONLY_PROGRESS_BAR; const bool showProgressText = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL || SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL_WITH_PROGRESS_BAR; const bool showBattery = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::NO_PROGRESS || SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL || SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL_WITH_PROGRESS_BAR; const bool showTitle = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::NO_PROGRESS || SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL || SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL_WITH_PROGRESS_BAR; const bool showBatteryPercentage = SETTINGS.hideBatteryPercentage == CrossPointSettings::HIDE_BATTERY_PERCENTAGE::HIDE_NEVER; const auto screenHeight = renderer.getScreenHeight(); const auto textY = screenHeight - orientedMarginBottom - 4; int progressTextWidth = 0; const float progress = totalPages > 0 ? (currentPage + 1) * 100.0f / totalPages : 0; if (showProgressText || showProgressPercentage) { char progressStr[32]; if (showProgressPercentage) { snprintf(progressStr, sizeof(progressStr), "%d/%d %.0f%%", currentPage + 1, totalPages, progress); } else { snprintf(progressStr, sizeof(progressStr), "%d/%d", currentPage + 1, totalPages); } progressTextWidth = renderer.getTextWidth(SMALL_FONT_ID, progressStr); renderer.drawText(SMALL_FONT_ID, renderer.getScreenWidth() - orientedMarginRight - progressTextWidth, textY, progressStr); } if (showProgressBar) { // Draw progress bar at the very bottom of the screen, from edge to edge of viewable area ScreenComponents::drawBookProgressBar(renderer, static_cast(progress)); } if (showBattery) { ScreenComponents::drawBattery(renderer, orientedMarginLeft, textY, showBatteryPercentage); } if (showTitle) { const int titleMarginLeft = 50 + 30 + orientedMarginLeft; const int titleMarginRight = progressTextWidth + 30 + orientedMarginRight; const int availableTextWidth = renderer.getScreenWidth() - titleMarginLeft - titleMarginRight; std::string title = txt->getTitle(); int titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str()); while (titleWidth > availableTextWidth && title.length() > 11) { title.replace(title.length() - 8, 8, "..."); titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str()); } renderer.drawText(SMALL_FONT_ID, titleMarginLeft + (availableTextWidth - titleWidth) / 2, textY, title.c_str()); } } void TxtReaderActivity::saveProgress() const { FsFile f; if (SdMan.openFileForWrite("TRS", txt->getCachePath() + "/progress.bin", f)) { uint8_t data[4]; data[0] = currentPage & 0xFF; data[1] = (currentPage >> 8) & 0xFF; data[2] = 0; data[3] = 0; f.write(data, 4); f.close(); } } void TxtReaderActivity::loadProgress() { FsFile f; if (SdMan.openFileForRead("TRS", txt->getCachePath() + "/progress.bin", f)) { uint8_t data[4]; if (f.read(data, 4) == 4) { currentPage = data[0] + (data[1] << 8); if (currentPage >= totalPages) { currentPage = totalPages - 1; } if (currentPage < 0) { currentPage = 0; } Serial.printf("[%lu] [TRS] Loaded progress: page %d/%d\n", millis(), currentPage, totalPages); } f.close(); } } bool TxtReaderActivity::loadPageIndexCache() { // Cache file format (using serialization module): // - uint32_t: magic "TXTI" // - uint8_t: cache version // - uint32_t: file size (to validate cache) // - int32_t: viewport width // - int32_t: lines per page // - int32_t: font ID (to invalidate cache on font change) // - int32_t: screen margin (to invalidate cache on margin change) // - uint8_t: paragraph alignment (to invalidate cache on alignment change) // - uint32_t: total pages count // - N * uint32_t: page offsets std::string cachePath = txt->getCachePath() + "/index.bin"; FsFile f; if (!SdMan.openFileForRead("TRS", cachePath, f)) { Serial.printf("[%lu] [TRS] No page index cache found\n", millis()); return false; } // Read and validate header using serialization module uint32_t magic; serialization::readPod(f, magic); if (magic != CACHE_MAGIC) { Serial.printf("[%lu] [TRS] Cache magic mismatch, rebuilding\n", millis()); f.close(); return false; } uint8_t version; serialization::readPod(f, version); if (version != CACHE_VERSION) { Serial.printf("[%lu] [TRS] Cache version mismatch (%d != %d), rebuilding\n", millis(), version, CACHE_VERSION); f.close(); return false; } uint32_t fileSize; serialization::readPod(f, fileSize); if (fileSize != txt->getFileSize()) { Serial.printf("[%lu] [TRS] Cache file size mismatch, rebuilding\n", millis()); f.close(); return false; } int32_t cachedWidth; serialization::readPod(f, cachedWidth); if (cachedWidth != viewportWidth) { Serial.printf("[%lu] [TRS] Cache viewport width mismatch, rebuilding\n", millis()); f.close(); return false; } int32_t cachedLines; serialization::readPod(f, cachedLines); if (cachedLines != linesPerPage) { Serial.printf("[%lu] [TRS] Cache lines per page mismatch, rebuilding\n", millis()); f.close(); return false; } int32_t fontId; serialization::readPod(f, fontId); if (fontId != cachedFontId) { Serial.printf("[%lu] [TRS] Cache font ID mismatch (%d != %d), rebuilding\n", millis(), fontId, cachedFontId); f.close(); return false; } int32_t margin; serialization::readPod(f, margin); if (margin != cachedScreenMargin) { Serial.printf("[%lu] [TRS] Cache screen margin mismatch, rebuilding\n", millis()); f.close(); return false; } uint8_t alignment; serialization::readPod(f, alignment); if (alignment != cachedParagraphAlignment) { Serial.printf("[%lu] [TRS] Cache paragraph alignment mismatch, rebuilding\n", millis()); f.close(); return false; } uint32_t numPages; serialization::readPod(f, numPages); // Read page offsets pageOffsets.clear(); pageOffsets.reserve(numPages); for (uint32_t i = 0; i < numPages; i++) { uint32_t offset; serialization::readPod(f, offset); pageOffsets.push_back(offset); } f.close(); totalPages = pageOffsets.size(); Serial.printf("[%lu] [TRS] Loaded page index cache: %d pages\n", millis(), totalPages); return true; } void TxtReaderActivity::savePageIndexCache() const { std::string cachePath = txt->getCachePath() + "/index.bin"; FsFile f; if (!SdMan.openFileForWrite("TRS", cachePath, f)) { Serial.printf("[%lu] [TRS] Failed to save page index cache\n", millis()); return; } // Write header using serialization module serialization::writePod(f, CACHE_MAGIC); serialization::writePod(f, CACHE_VERSION); serialization::writePod(f, static_cast(txt->getFileSize())); serialization::writePod(f, static_cast(viewportWidth)); serialization::writePod(f, static_cast(linesPerPage)); serialization::writePod(f, static_cast(cachedFontId)); serialization::writePod(f, static_cast(cachedScreenMargin)); serialization::writePod(f, cachedParagraphAlignment); serialization::writePod(f, static_cast(pageOffsets.size())); // Write page offsets for (size_t offset : pageOffsets) { serialization::writePod(f, static_cast(offset)); } f.close(); Serial.printf("[%lu] [TRS] Saved page index cache: %d pages\n", millis(), totalPages); }