#pragma once #include #include #include #include #include "Utf8CP437.h" #include "EpubProcessor.h" #include "../NodePrefs.h" // Forward declarations class UITask; // ============================================================================ // Configuration // ============================================================================ #define BOOKS_FOLDER "/books" #define INDEX_FOLDER "/.indexes" #define INDEX_VERSION 14 // v14: indexer wrap choice keyed to getFontStyle() (matches renderer); invalidates v13 caches #define PREINDEX_PAGES 100 #define READER_MAX_FILES 50 #define READER_BUF_SIZE 4096 // ============================================================================ // Word Wrap Helper (same algorithm as standalone reader) // ============================================================================ struct WrapResult { int lineEnd; int nextStart; }; inline WrapResult findLineBreak(const char* buffer, int bufLen, int lineStart, int maxChars) { WrapResult result; result.lineEnd = lineStart; result.nextStart = lineStart; if (lineStart >= bufLen) return result; int charCount = 0; int lastBreakPoint = -1; bool inWord = false; for (int i = lineStart; i < bufLen; i++) { char c = buffer[i]; if (c == '\n') { result.lineEnd = i; result.nextStart = i + 1; if (result.nextStart < bufLen && buffer[result.nextStart] == '\r') result.nextStart++; return result; } if (c == '\r') { result.lineEnd = i; result.nextStart = i + 1; if (result.nextStart < bufLen && buffer[result.nextStart] == '\n') result.nextStart++; return result; } if (c >= 32) { // Skip UTF-8 continuation bytes (0x80-0xBF) - the lead byte already // counted as one display character, so don't double-count these. if ((uint8_t)c >= 0x80 && (uint8_t)c < 0xC0) continue; charCount++; if (c == ' ' || c == '\t') { if (inWord) { lastBreakPoint = i; inWord = false; } } else if (c == '-') { if (inWord) { lastBreakPoint = i + 1; } } else { inWord = true; } if (charCount >= maxChars) { if (lastBreakPoint > lineStart) { result.lineEnd = lastBreakPoint; result.nextStart = lastBreakPoint; while (result.nextStart < bufLen && (buffer[result.nextStart] == ' ' || buffer[result.nextStart] == '\t')) result.nextStart++; } else { result.lineEnd = i; result.nextStart = i; } return result; } } } result.lineEnd = bufLen; result.nextStart = bufLen; return result; } // ============================================================================ // Pixel-width line breaking for proportional fonts (T5S3) // // Measures actual rendered text width via DisplayDriver::getTextWidth() at // each word boundary. This gives correct line breaks regardless of character // width variation in proportional fonts like FreeSans12pt. // maxChars is a safety upper bound to prevent runaway on spaceless lines. // ============================================================================ inline WrapResult findLineBreakPixel(const char* buffer, int bufLen, int lineStart, DisplayDriver* display, int maxChars) { WrapResult result; result.lineEnd = lineStart; result.nextStart = lineStart; if (lineStart >= bufLen || !display) return result; #if defined(LilyGo_T5S3_EPaper_Pro) int rightMargin = 5; // Wider margin for T5S3 (portrait mode especially tight) #else int rightMargin = 3; #endif int displayW = display->width() - rightMargin; char measBuf[300]; // temp buffer for pixel measurement int measLen = 0; int lastBreakPoint = -1; int lastBreakMeasLen = 0; // measLen at lastBreakPoint (for mid-word fallback) bool inWord = false; int charCount = 0; for (int i = lineStart; i < bufLen; i++) { char c = buffer[i]; // Newline handling (identical to char-count version) if (c == '\n') { result.lineEnd = i; result.nextStart = i + 1; if (result.nextStart < bufLen && buffer[result.nextStart] == '\r') result.nextStart++; return result; } if (c == '\r') { result.lineEnd = i; result.nextStart = i + 1; if (result.nextStart < bufLen && buffer[result.nextStart] == '\n') result.nextStart++; return result; } if ((uint8_t)c >= 32) { // UTF-8 handling: decode multi-byte sequences to CP437 for accurate // width measurement. The renderer (renderPage) does this same conversion, // so the measurement must match or it underestimates line width. int charStartIdx = i; // buffer index where this character started if ((uint8_t)c >= 0xC0) { // UTF-8 lead byte — decode full sequence to CP437 int decPos = i; uint32_t cp = decodeUtf8Char(buffer, bufLen, &decPos); uint8_t glyph = unicodeToCP437(cp); if (glyph >= 32 && measLen < 298) { measBuf[measLen++] = (char)glyph; charCount++; } i = decPos - 1; // -1 because the for loop will i++ inWord = true; } else if ((uint8_t)c >= 0x80 && (uint8_t)c < 0xC0) { // Orphan continuation byte — treat as CP437 pass-through (same as renderer) if (measLen < 298) measBuf[measLen++] = c; charCount++; inWord = true; } else { // Plain ASCII charCount++; if (measLen < 298) measBuf[measLen++] = c; if (c == ' ' || c == '\t') { if (inWord) { lastBreakPoint = i; lastBreakMeasLen = measLen; inWord = false; } } else if (c == '-') { if (inWord) { lastBreakPoint = i + 1; lastBreakMeasLen = measLen; } inWord = true; } else { inWord = true; } } // Per-character pixel width check — catches long words that exceed // displayW without ever hitting a space/hyphen break point. // Only measure every 3 chars to avoid excessive getTextWidth() calls. if ((charCount & 3) == 0 || c == ' ' || c == '-') { measBuf[measLen] = '\0'; int pw = display->getTextWidth(measBuf); if (pw >= displayW) { if (lastBreakPoint > lineStart) { // Break at last word boundary result.lineEnd = lastBreakPoint; result.nextStart = lastBreakPoint; while (result.nextStart < bufLen && (buffer[result.nextStart] == ' ' || buffer[result.nextStart] == '\t')) result.nextStart++; } else { // No word boundary found — break mid-word before this character result.lineEnd = charStartIdx; result.nextStart = charStartIdx; } return result; } } // Safety: hard char limit (handles spaceless lines, URLs, etc.) if (charCount >= maxChars) { if (lastBreakPoint > lineStart) { result.lineEnd = lastBreakPoint; result.nextStart = lastBreakPoint; while (result.nextStart < bufLen && (buffer[result.nextStart] == ' ' || buffer[result.nextStart] == '\t')) result.nextStart++; } else { result.lineEnd = i; result.nextStart = i; } return result; } } } result.lineEnd = bufLen; result.nextStart = bufLen; return result; } // ============================================================================ // Page Indexer (word-wrap aware, matches display rendering) // When textAreaHeight and lineHeight are provided (both > 0), uses height-based // pagination that accounts for blank lines getting 40% height (matching renderer). // Otherwise falls back to simple line counting. // ============================================================================ inline int indexPagesWordWrap(File& file, long startPos, std::vector& pagePositions, int linesPerPage, int charsPerLine, int maxPages, int textAreaHeight = 0, int lineHeight = 0, DisplayDriver* pixelDisplay = nullptr) { const int BUF_SIZE = READER_BUF_SIZE; // Match page buffer to avoid chunk boundary wrap mismatches char buffer[BUF_SIZE]; bool heightAware = (textAreaHeight > 0 && lineHeight > 0); int blankLineH = heightAware ? max(2, lineHeight * 2 / 5) : 0; file.seek(startPos); int pagesAdded = 0; int lineCount = 0; int accHeight = 0; int leftover = 0; long chunkFileStart = startPos; while (file.available() && (maxPages <= 0 || pagesAdded < maxPages)) { int bytesRead = file.readBytes(buffer + leftover, BUF_SIZE - leftover); int bufLen = leftover + bytesRead; if (bufLen == 0) break; int pos = 0; while (pos < bufLen) { int lineStart = pos; // Pixel-based wrapping for proportional fonts; char-count for monospaced WrapResult wrap = pixelDisplay ? findLineBreakPixel(buffer, bufLen, pos, pixelDisplay, charsPerLine) : findLineBreak(buffer, bufLen, pos, charsPerLine); if (wrap.nextStart <= pos && wrap.lineEnd >= bufLen) break; // Blank line = newline at line start (no printable content before it) bool isBlankLine = (wrap.lineEnd == lineStart); bool pageBreak = false; if (heightAware) { int thisH = isBlankLine ? blankLineH : lineHeight; // Check BEFORE adding: does this line fit on the current page? // The renderer checks y <= maxY before rendering each line, // so we must break the page BEFORE a line that won't fit. if (accHeight > 0 && accHeight + thisH > textAreaHeight) { // This line doesn't fit — start new page at this line's position long pageFilePos = chunkFileStart + lineStart; pagePositions.push_back(pageFilePos); pagesAdded++; accHeight = 0; if (maxPages > 0 && pagesAdded >= maxPages) break; } accHeight += thisH; } else { lineCount++; if (lineCount >= linesPerPage) { pageBreak = true; lineCount = 0; } } pos = wrap.nextStart; if (pageBreak) { long pageFilePos = chunkFileStart + pos; pagePositions.push_back(pageFilePos); pagesAdded++; if (maxPages > 0 && pagesAdded >= maxPages) break; } if (pos >= bufLen) break; } leftover = bufLen - pos; if (leftover > 0 && leftover < BUF_SIZE) { memmove(buffer, buffer + pos, leftover); } else { leftover = 0; } chunkFileStart = file.position() - leftover; } return pagesAdded; } // ============================================================================ // Pixel-based Page Indexer (proportional font word wrap) // ============================================================================ inline int indexPagesWordWrapPixel(File& file, long startPos, std::vector& pagePositions, int linesPerPage, int maxChars, DisplayDriver* display, int maxPages, NodePrefs* prefs = nullptr) { const int BUF_SIZE = READER_BUF_SIZE; // Match page buffer to avoid chunk boundary wrap mismatches char buffer[BUF_SIZE]; // Ensure body font is active for pixel measurement display->setTextSize(prefs ? prefs->smallTextSize() : 0); file.seek(startPos); int pagesAdded = 0; int lineCount = 0; int leftover = 0; long chunkFileStart = startPos; while (file.available() && (maxPages <= 0 || pagesAdded < maxPages)) { int bytesRead = file.readBytes(buffer + leftover, BUF_SIZE - leftover); int bufLen = leftover + bytesRead; if (bufLen == 0) break; int pos = 0; while (pos < bufLen) { WrapResult wrap = findLineBreakPixel(buffer, bufLen, pos, display, maxChars); if (wrap.nextStart <= pos && wrap.lineEnd >= bufLen) break; lineCount++; pos = wrap.nextStart; if (lineCount >= linesPerPage) { long pageFilePos = chunkFileStart + pos; pagePositions.push_back(pageFilePos); pagesAdded++; lineCount = 0; if (maxPages > 0 && pagesAdded >= maxPages) break; } if (pos >= bufLen) break; } leftover = bufLen - pos; if (leftover > 0 && leftover < BUF_SIZE) { memmove(buffer, buffer + pos, leftover); } else { leftover = 0; } chunkFileStart = file.position() - leftover; } display->setTextSize(1); // Restore return pagesAdded; } // ============================================================================ // TextReaderScreen // ============================================================================ class TextReaderScreen : public UIScreen { public: enum Mode { FILE_LIST, READING }; // File cache entry (index + resume position) struct FileCache { String filename; std::vector pagePositions; unsigned long fileSize; bool fullyIndexed; int lastReadPage; }; private: UITask* _task; NodePrefs* _prefs; Mode _mode; bool _sdReady; bool _initialized; // Layout metrics calculated uint8_t _lastFontPref; // Font preference at last layout init (large_font | fontStyle<<4) uint8_t _fontKey; // Current font key stored in .idx files for cache invalidation bool _bootIndexed; // Boot-time pre-indexing done DisplayDriver* _display; // Stored reference for splash screens // Display layout (calculated once from display metrics) int _charsPerLine; int _linesPerPage; int _lineHeight; // virtual coord units per text line int _textAreaHeight; // usable height for text (excluding header/footer) int _headerHeight; int _footerHeight; // File list state std::vector _fileList; std::vector _dirList; // Subdirectories at current path std::vector _fileCache; int _selectedFile; String _currentPath; // Current browsed directory // Reading state File _file; String _currentFile; bool _fileOpen; int _currentPage; int _totalPages; std::vector _pagePositions; // Page content buffer (pre-read from SD before render) char _pageBuf[READER_BUF_SIZE]; int _pageBufLen; bool _contentDirty; // Need to re-read from SD // Go-to-page input mode (Enter in reading view) bool _gotoMode = false; char _gotoBuf[6]; // Up to 5 digits + null int _gotoBufLen = 0; // ---- Splash Screen Drawing ---- // Draw directly to display outside the normal render cycle. // Matches the style of the standalone text reader firmware splash. // Generic splash screen: title (large green), subtitle (normal), detail (normal) void drawSplash(const char* title, const char* subtitle, const char* detail) { if (!_display) return; _display->startFrame(); // Title in large text _display->setTextSize(2); _display->setColor(DisplayDriver::GREEN); _display->setCursor(10, 11); _display->print(title); _display->setTextSize(1); _display->setColor(DisplayDriver::LIGHT); int y = 35; // Subtitle if (subtitle && subtitle[0]) { _display->setCursor(10, y); _display->print(subtitle); y += 8; } // Detail line if (detail && detail[0]) { _display->setCursor(10, y); _display->print(detail); } _display->endFrame(); } // Word-wrapping splash for opening a large book. // Shows: "Indexing / Pages..." (large), word-wrapped filename, "Please wait. / Loading shortly..." void drawIndexingSplash(const String& filename) { if (!_display) return; _display->startFrame(); // "Indexing" / "Pages..." in large text // Original: textSize(2) at real (20, 40) and (20, 65) // Virtual: (10, 11) and (10, 21) _display->setTextSize(2); _display->setColor(DisplayDriver::GREEN); _display->setCursor(10, 11); _display->print("Indexing"); _display->setCursor(10, 21); _display->print("Pages..."); // Word-wrapped filename in normal text _display->setTextSize(1); _display->setColor(DisplayDriver::LIGHT); int y = 39; int leftMargin = 10; // Calculate max chars that fit: (display_width - margin) / char_width uint16_t charW = _display->getTextWidth("M"); int maxChars = charW > 0 ? (_display->width() - leftMargin) / charW : 20; if (maxChars < 10) maxChars = 10; if (maxChars > 40) maxChars = 40; String remaining = filename; while (remaining.length() > 0 && y < 80) { String line; if ((int)remaining.length() <= maxChars) { line = remaining; remaining = ""; } else { int breakPoint = maxChars; for (int i = maxChars; i > 0; i--) { if (remaining[i] == ' ' || remaining[i] == '-' || remaining[i] == '_') { breakPoint = i; break; } } line = remaining.substring(0, breakPoint); remaining = remaining.substring(breakPoint); remaining.trim(); } _display->setCursor(10, y); _display->print(line.c_str()); y += 5; } // "Please wait." / "Loading shortly..." // Original: textSize(1) at real (20, 230) and (20, 245) // Virtual: y=87 and y=93 _display->setColor(DisplayDriver::LIGHT); _display->setCursor(10, 87); _display->print("Please wait."); _display->setCursor(10, 93); _display->print("Loading shortly..."); _display->endFrame(); } // Boot-time progress splash with file counter. // Shows: "Indexing / Pages..." (large), "(2/10)", word-wrapped filename, "Please wait." // If current==0 and total==0, skips the progress counter (used for initial scan splash). void drawBootSplash(int current, int total, const String& filename) { if (!_display) return; _display->startFrame(); // "Indexing" / "Pages..." in large text _display->setTextSize(2); _display->setColor(DisplayDriver::GREEN); _display->setCursor(10, 11); _display->print("Indexing"); _display->setCursor(10, 21); _display->print("Pages..."); _display->setTextSize(1); _display->setColor(DisplayDriver::LIGHT); int y = 35; // Progress counter (skip if both zero) if (current > 0 || total > 0) { char progress[20]; sprintf(progress, "(%d/%d)", current, total); _display->setCursor(10, y); _display->print(progress); y += 8; } // Word-wrapped filename int leftMargin = 10; uint16_t charW = _display->getTextWidth("M"); int maxChars = charW > 0 ? (_display->width() - leftMargin) / charW : 20; if (maxChars < 10) maxChars = 10; if (maxChars > 40) maxChars = 40; String remaining = filename; while (remaining.length() > 0 && y < 80) { String line; if ((int)remaining.length() <= maxChars) { line = remaining; remaining = ""; } else { int breakPoint = maxChars; for (int i = maxChars; i > 0; i--) { if (remaining[i] == ' ' || remaining[i] == '-' || remaining[i] == '_') { breakPoint = i; break; } } line = remaining.substring(0, breakPoint); remaining = remaining.substring(breakPoint); remaining.trim(); } _display->setCursor(10, y); _display->print(line.c_str()); y += 5; } // "Please wait." _display->setCursor(10, 87); _display->print("Please wait."); _display->endFrame(); } // ---- SD Index I/O ---- String getIndexPath(const String& filename) { return String(INDEX_FOLDER) + "/" + filename + ".idx"; } bool loadIndex(const String& filename, FileCache& cache) { String idxPath = getIndexPath(filename); File idxFile = SD.open(idxPath.c_str(), FILE_READ); if (!idxFile) return false; uint8_t version = 0; unsigned long savedSize = 0, pageCount = 0; uint8_t fullyFlag = 0; int lastRead = 0; idxFile.read(&version, 1); if (version != INDEX_VERSION) { // Wrong version - discard and rebuild idxFile.close(); SD.remove(idxPath.c_str()); return false; } // Font key: page boundaries depend on font metrics — discard if font changed uint8_t savedFontKey = 0; idxFile.read(&savedFontKey, 1); if (savedFontKey != _fontKey) { idxFile.close(); SD.remove(idxPath.c_str()); return false; } idxFile.read((uint8_t*)&savedSize, 4); idxFile.read((uint8_t*)&pageCount, 4); idxFile.read(&fullyFlag, 1); idxFile.read((uint8_t*)&lastRead, 4); // Verify file hasn't changed - try current path first, then epub cache String fullPath = _currentPath + "/" + filename; File txtFile = SD.open(fullPath.c_str(), FILE_READ); if (!txtFile) { // Fallback: check epub cache directory String cachePath = String("/books/.epub_cache/") + filename; txtFile = SD.open(cachePath.c_str(), FILE_READ); } if (!txtFile) { idxFile.close(); return false; } unsigned long curSize = txtFile.size(); txtFile.close(); if (savedSize != curSize) { idxFile.close(); SD.remove(idxPath.c_str()); return false; } cache.filename = filename; cache.fileSize = savedSize; cache.fullyIndexed = (fullyFlag == 1); cache.lastReadPage = lastRead; cache.pagePositions.clear(); for (unsigned long i = 0; i < pageCount; i++) { long pos = 0; idxFile.read((uint8_t*)&pos, 4); cache.pagePositions.push_back(pos); } idxFile.close(); return true; } bool saveIndex(const String& filename, const std::vector& pages, unsigned long fileSize, bool fullyIndexed, int lastReadPage) { if (!SD.exists(INDEX_FOLDER)) SD.mkdir(INDEX_FOLDER); String idxPath = getIndexPath(filename); if (SD.exists(idxPath.c_str())) SD.remove(idxPath.c_str()); File idxFile = SD.open(idxPath.c_str(), FILE_WRITE); if (!idxFile) return false; uint8_t version = INDEX_VERSION; unsigned long pageCount = pages.size(); uint8_t fullyFlag = fullyIndexed ? 1 : 0; idxFile.write(&version, 1); idxFile.write(&_fontKey, 1); // Font key for cache invalidation idxFile.write((uint8_t*)&fileSize, 4); idxFile.write((uint8_t*)&pageCount, 4); idxFile.write(&fullyFlag, 1); idxFile.write((uint8_t*)&lastReadPage, 4); for (unsigned long i = 0; i < pageCount; i++) { long pos = pages[i]; idxFile.write((uint8_t*)&pos, 4); } idxFile.close(); return true; } bool saveReadingPosition(const String& filename, int page) { String idxPath = getIndexPath(filename); File idxFile = SD.open(idxPath.c_str(), "r+"); if (!idxFile) return false; uint8_t version = 0; idxFile.read(&version, 1); if (version != INDEX_VERSION) { idxFile.close(); for (int i = 0; i < (int)_fileCache.size(); i++) { if (_fileCache[i].filename == filename) { _fileCache[i].lastReadPage = page; return saveIndex(filename, _fileCache[i].pagePositions, _fileCache[i].fileSize, _fileCache[i].fullyIndexed, page); } } return false; } // Seek to lastReadPage field: version(1) + fontKey(1) + fileSize(4) + pageCount(4) + fullyIndexed(1) idxFile.seek(1 + 1 + 4 + 4 + 1); idxFile.write((uint8_t*)&page, 4); idxFile.close(); return true; } // ---- File Scanning ---- // ---- Folder Navigation Helpers ---- bool isAtBooksRoot() const { return _currentPath == String(BOOKS_FOLDER); } // Number of non-file entries at the start of the visual list int dirEntryCount() const { int count = _dirList.size(); if (!isAtBooksRoot()) count++; // ".." entry return count; } // Total items in the visual list (parent + dirs + files) int totalListItems() const { return dirEntryCount() + (int)_fileList.size(); } // What type of entry is at visual list index idx? // Returns: 0 = ".." parent, 1 = directory, 2 = file int itemTypeAt(int idx) const { bool hasParent = !isAtBooksRoot(); if (hasParent && idx == 0) return 0; // ".." int dirStart = hasParent ? 1 : 0; if (idx < dirStart + (int)_dirList.size()) return 1; // directory return 2; // file } // Get directory name for visual index (only valid when itemTypeAt == 1) const String& dirNameAt(int idx) const { int dirStart = isAtBooksRoot() ? 0 : 1; return _dirList[idx - dirStart]; } // Get file list index for visual index (only valid when itemTypeAt == 2) int fileIndexAt(int idx) const { return idx - dirEntryCount(); } void navigateToParent() { int lastSlash = _currentPath.lastIndexOf('/'); if (lastSlash > 0) { _currentPath = _currentPath.substring(0, lastSlash); } else { _currentPath = BOOKS_FOLDER; } } void navigateToChild(const String& dirName) { _currentPath = _currentPath + "/" + dirName; } // ---- File Scanning ---- void scanFiles() { _fileList.clear(); _dirList.clear(); if (!SD.exists(BOOKS_FOLDER)) { SD.mkdir(BOOKS_FOLDER); Serial.printf("TextReader: Created %s\n", BOOKS_FOLDER); } File root = SD.open(_currentPath.c_str()); if (!root || !root.isDirectory()) return; File f = root.openNextFile(); while (f && (_fileList.size() + _dirList.size()) < READER_MAX_FILES) { String name = String(f.name()); int slash = name.lastIndexOf('/'); if (slash >= 0) name = name.substring(slash + 1); // Skip hidden files/dirs if (name.startsWith(".")) { f = root.openNextFile(); continue; } if (f.isDirectory()) { _dirList.push_back(name); } else if (name.endsWith(".txt") || name.endsWith(".TXT") || name.endsWith(".epub") || name.endsWith(".EPUB")) { _fileList.push_back(name); } f = root.openNextFile(); } root.close(); Serial.printf("TextReader: %s — %d dirs, %d files\n", _currentPath.c_str(), (int)_dirList.size(), (int)_fileList.size()); } // ---- Book Open/Close ---- void openBook(const String& filename) { if (_fileOpen) closeBook(); // ---- EPUB auto-conversion ---- String actualFilename = filename; String actualFullPath = _currentPath + "/" + filename; bool isEpub = filename.endsWith(".epub") || filename.endsWith(".EPUB"); if (isEpub) { // Build cache path for this EPUB char cachePath[160]; EpubProcessor::buildCachePath(actualFullPath.c_str(), cachePath, sizeof(cachePath)); // Check if already converted digitalWrite(SDCARD_CS, LOW); bool cached = SD.exists(cachePath); digitalWrite(SDCARD_CS, HIGH); if (!cached) { // Show conversion splash on e-ink char shortName[28]; if (filename.length() > 24) { strncpy(shortName, filename.c_str(), 21); shortName[21] = '\0'; strcat(shortName, "..."); } else { strncpy(shortName, filename.c_str(), sizeof(shortName) - 1); shortName[sizeof(shortName) - 1] = '\0'; } drawSplash("Converting EPUB...", "Please wait", shortName); Serial.printf("TextReader: Converting EPUB '%s'\n", filename.c_str()); unsigned long t0 = millis(); digitalWrite(SDCARD_CS, LOW); bool ok = EpubProcessor::processToText(actualFullPath.c_str(), cachePath); digitalWrite(SDCARD_CS, HIGH); if (!ok) { Serial.println("TextReader: EPUB conversion failed!"); drawSplash("Convert failed!", "", shortName); delay(2000); return; // Stay in file list } Serial.printf("TextReader: EPUB converted in %lu ms\n", millis() - t0); } else { Serial.printf("TextReader: EPUB cache hit for '%s'\n", filename.c_str()); } // Redirect to the cached .txt actualFullPath = String(cachePath); const char* lastSlash = strrchr(cachePath, '/'); actualFilename = String(lastSlash ? lastSlash + 1 : cachePath); } // ---- End EPUB auto-conversion ---- // Find cached index for this file FileCache* cache = nullptr; for (int i = 0; i < (int)_fileCache.size(); i++) { if (_fileCache[i].filename == actualFilename) { cache = &_fileCache[i]; break; } } _file = SD.open(actualFullPath.c_str(), FILE_READ); // Fallback: try epub cache dir (for files discovered during boot scan) if (!_file && !isEpub) { String cacheFallback = String("/books/.epub_cache/") + actualFilename; _file = SD.open(cacheFallback.c_str(), FILE_READ); if (_file) { actualFullPath = cacheFallback; Serial.printf("TextReader: Opened from epub cache: %s\n", actualFilename.c_str()); } } if (!_file) { Serial.printf("TextReader: Failed to open %s\n", actualFilename.c_str()); return; } _currentFile = actualFilename; _fileOpen = true; _currentPage = 0; _pagePositions.clear(); if (cache) { for (int i = 0; i < (int)cache->pagePositions.size(); i++) { _pagePositions.push_back(cache->pagePositions[i]); } if (cache->lastReadPage > 0 && cache->lastReadPage < (int)cache->pagePositions.size()) { _currentPage = cache->lastReadPage; } // Already fully indexed — open immediately if (cache->fullyIndexed) { _totalPages = _pagePositions.size(); _mode = READING; loadPageContent(); Serial.printf("TextReader: Opened %s, %d pages, resume pg %d\n", actualFilename.c_str(), _totalPages, _currentPage + 1); return; } // Partially indexed — finish indexing with splash Serial.printf("TextReader: Finishing index for %s (have %d pages so far)\n", actualFilename.c_str(), (int)_pagePositions.size()); char shortName[28]; if (actualFilename.length() > 24) { strncpy(shortName, actualFilename.c_str(), 21); shortName[21] = '\0'; strcat(shortName, "..."); } else { strncpy(shortName, actualFilename.c_str(), sizeof(shortName) - 1); shortName[sizeof(shortName) - 1] = '\0'; } drawSplash("Indexing...", "Please wait", shortName); DisplayDriver* pxd = (_prefs->large_font || _display->getFontStyle() > 0) ? _display : nullptr; if (pxd) pxd->setTextSize(_prefs->smallTextSize()); if (_pagePositions.empty()) { // Cache had no pages (e.g. dummy entry) — full index from scratch _pagePositions.push_back(0); indexPagesWordWrap(_file, 0, _pagePositions, _linesPerPage, _charsPerLine, 0, _textAreaHeight, _lineHeight, pxd); } else { long lastPos = cache->pagePositions.back(); indexPagesWordWrap(_file, lastPos, _pagePositions, _linesPerPage, _charsPerLine, 0, _textAreaHeight, _lineHeight, pxd); } } else { // No cache — full index from scratch Serial.printf("TextReader: Full index for %s\n", actualFilename.c_str()); char shortName[28]; if (actualFilename.length() > 24) { strncpy(shortName, actualFilename.c_str(), 21); shortName[21] = '\0'; strcat(shortName, "..."); } else { strncpy(shortName, actualFilename.c_str(), sizeof(shortName) - 1); shortName[sizeof(shortName) - 1] = '\0'; } drawSplash("Indexing...", "Please wait", shortName); _pagePositions.push_back(0); DisplayDriver* pxd = (_prefs->large_font || _display->getFontStyle() > 0) ? _display : nullptr; if (pxd) pxd->setTextSize(_prefs->smallTextSize()); indexPagesWordWrap(_file, 0, _pagePositions, _linesPerPage, _charsPerLine, 0, _textAreaHeight, _lineHeight, pxd); } // Save complete index _totalPages = _pagePositions.size(); // Update or create cache entry bool foundCache = false; for (int i = 0; i < (int)_fileCache.size(); i++) { if (_fileCache[i].filename == actualFilename) { _fileCache[i].pagePositions = _pagePositions; _fileCache[i].fullyIndexed = true; _fileCache[i].fileSize = _file.size(); foundCache = true; break; } } if (!foundCache) { FileCache newCache; newCache.filename = actualFilename; newCache.fileSize = _file.size(); newCache.fullyIndexed = true; newCache.lastReadPage = _currentPage; newCache.pagePositions = _pagePositions; _fileCache.push_back(newCache); } saveIndex(actualFilename, _pagePositions, _file.size(), true, _currentPage); _mode = READING; loadPageContent(); Serial.printf("TextReader: Opened %s, %d pages\n", actualFilename.c_str(), _totalPages); } void closeBook() { if (!_fileOpen) return; saveReadingPosition(_currentFile, _currentPage); for (int i = 0; i < (int)_fileCache.size(); i++) { if (_fileCache[i].filename == _currentFile) { _fileCache[i].lastReadPage = _currentPage; break; } } _file.close(); _fileOpen = false; _pagePositions.clear(); _pagePositions.shrink_to_fit(); // Deselect SD to free SPI bus digitalWrite(SDCARD_CS, HIGH); Serial.printf("TextReader: Closed, saved at page %d\n", _currentPage + 1); } // ---- Page Content Loading ---- // Read exact span between indexed page positions so renderer gets // exactly the bytes the indexer counted for this page. void loadPageContent() { if (!_fileOpen || _currentPage >= _totalPages) { _pageBufLen = 0; return; } long pageStart = _pagePositions[_currentPage]; long pageEnd; if (_currentPage + 1 < _totalPages) { pageEnd = _pagePositions[_currentPage + 1]; } else { // Last page - read remaining file content pageEnd = _file.size(); } long pageSpan = pageEnd - pageStart; int toRead = (int)min((long)(READER_BUF_SIZE - 1), pageSpan); _file.seek(pageStart); _pageBufLen = _file.readBytes(_pageBuf, toRead); _pageBuf[_pageBufLen] = '\0'; _contentDirty = false; // Deselect SD to free SPI bus for display digitalWrite(SDCARD_CS, HIGH); } // ---- Rendering Helpers ---- void renderFileList(DisplayDriver& display) { char tmp[40]; // Header display.setCursor(0, 0); display.setTextSize(1); display.setColor(DisplayDriver::GREEN); if (isAtBooksRoot()) { display.print("Text Reader"); } else { // Show current subfolder name int lastSlash = _currentPath.lastIndexOf('/'); String folderName = (lastSlash >= 0) ? _currentPath.substring(lastSlash + 1) : _currentPath; char hdrBuf[20]; strncpy(hdrBuf, folderName.c_str(), 17); hdrBuf[17] = '\0'; display.print(hdrBuf); } int totalItems = totalListItems(); sprintf(tmp, "[%d]", totalItems); display.setCursor(display.width() - display.getTextWidth(tmp) - 2, 0); display.print(tmp); display.drawRect(0, 11, display.width(), 1); if (totalItems == 0) { display.setCursor(0, 18); display.setColor(DisplayDriver::LIGHT); display.print("No files found"); display.setCursor(0, 30); display.print("Add .txt or .epub to"); display.setCursor(0, 42); display.print("/books/ on SD card"); } else { display.setTextSize(_prefs->smallTextSize()); // Tiny font for file list int listLineH = _prefs->smallLineH(); int startY = 14; int maxVisible = (display.height() - startY - _footerHeight) / listLineH; if (maxVisible < 3) maxVisible = 3; if (maxVisible > 15) maxVisible = 15; int startIdx = max(0, min(_selectedFile - maxVisible / 2, totalItems - maxVisible)); int endIdx = min(totalItems, startIdx + maxVisible); int y = startY; for (int i = startIdx; i < endIdx; i++) { bool selected = (i == _selectedFile); if (selected) { display.setColor(DisplayDriver::LIGHT); #if defined(LilyGo_T5S3_EPaper_Pro) display.fillRect(0, y, display.width(), listLineH); #else // setCursor adds +5 to y internally, but fillRect does not. // NodePrefs::smallHighlightOff() returns the correct offset for all // font combinations (built-in, large_font, custom 7pt styles). display.fillRect(0, y + _prefs->smallHighlightOff(), display.width(), listLineH); #endif display.setColor(DisplayDriver::DARK); } else { display.setColor(DisplayDriver::LIGHT); } // Set cursor AFTER fillRect so text draws on top of highlight int type = itemTypeAt(i); String line = selected ? "> " : " "; if (type == 0) { // ".." parent directory line += ".. (up)"; } else if (type == 1) { // Subdirectory line += "/" + dirNameAt(i); } else { // File int fi = fileIndexAt(i); String name = _fileList[fi]; // Check for resume indicator String suffix = ""; if (fi < (int)_fileCache.size()) { if (_fileCache[fi].filename == name && _fileCache[fi].lastReadPage > 0) { suffix = " *"; } } line += name + suffix; } // Pixel-aware ellipsis — small margin prevents GxEPD edge wrapping display.drawTextEllipsized(0, y, display.width() - 4, line.c_str()); y += listLineH; } display.setTextSize(1); // Restore } // Footer display.setTextSize(1); int footerY = display.height() - 12; display.drawRect(0, footerY - 2, display.width(), 1); display.setColor(DisplayDriver::YELLOW); #if defined(LilyGo_T5S3_EPaper_Pro) display.setTextSize(_prefs->smallTextSize()); display.drawTextCentered(display.width() / 2, footerY, "Swipe: Scroll Tap: Open Boot: home"); #else display.setCursor(0, footerY); display.print("Sh+Del:Bk"); const char* right = "Tap/Ent:Open"; display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY); display.print(right); #endif } void renderPage(DisplayDriver& display) { // Use tiny font for maximum text density display.setTextSize(_prefs->smallTextSize()); display.setColor(DisplayDriver::LIGHT); int y = 0; int lineCount = 0; int pos = 0; int textArea = display.height() - _footerHeight; // total usable height (matches indexer's textAreaHeight) // Render all lines in the page buffer using word wrap. // The buffer contains exactly the bytes for this page (from indexed positions), // so we render everything in it. // Proportional fonts use pixel-based wrapping to match the indexer exactly. bool usePixelWrap = (_prefs->large_font || display.getFontStyle() > 0); while (pos < _pageBufLen) { int oldPos = pos; WrapResult wrap = usePixelWrap ? findLineBreakPixel(_pageBuf, _pageBufLen, pos, &display, _charsPerLine) : findLineBreak(_pageBuf, _pageBufLen, pos, _charsPerLine); // Safety: stop if wrap made no progress (stuck at end of buffer) if (wrap.nextStart <= oldPos && wrap.lineEnd >= _pageBufLen) break; // Height-aware stop check — must match the indexer exactly. // Blank lines (lineEnd == lineStart) get reduced height. // Check BEFORE rendering: does this line fit on the current page? bool isBlankLine = (wrap.lineEnd == pos); int thisH = isBlankLine ? max(2, _lineHeight * 2 / 5) : _lineHeight; if (y > 0 && y + thisH > textArea) break; display.setCursor(0, y); // Print line with UTF-8 decoding: multi-byte sequences are decoded // to Unicode codepoints, then mapped to CP437 for the built-in font. bool lineHasContent = false; char charStr[2] = {0, 0}; int j = pos; while (j < wrap.lineEnd && j < _pageBufLen) { uint8_t b = (uint8_t)_pageBuf[j]; if (b < 32) { // Control character — skip j++; continue; } if (b < 0x80) { // Plain ASCII — print directly charStr[0] = (char)b; display.print(charStr); lineHasContent = true; j++; } else if (b >= 0xC0) { // UTF-8 lead byte — decode full sequence and map to CP437 int savedJ = j; uint32_t cp = decodeUtf8Char(_pageBuf, wrap.lineEnd, &j); uint8_t glyph = unicodeToCP437(cp); if (glyph) { charStr[0] = (char)glyph; display.print(charStr); lineHasContent = true; } // If unmappable (glyph==0), just skip the character } else { // Standalone byte 0x80-0xBF: not a valid UTF-8 lead byte. // Treat as CP437 pass-through (e.g. from EPUB numeric entity decoding). charStr[0] = (char)b; display.print(charStr); lineHasContent = true; j++; } } // Blank lines (paragraph breaks) get reduced height for compact layout. // Full _lineHeight for blank lines wastes too much space — on T5S3 each // blank line is ~34px, making paragraph gaps 7-8× the normal line spacing. // Using 40% height gives a visible paragraph break without wasting space. if (lineHasContent) { y += _lineHeight; } else { y += max(2, _lineHeight * 2 / 5); // ~40% height for blank lines } lineCount++; pos = wrap.nextStart; if (pos >= _pageBufLen) break; } // Restore text size for footer display.setTextSize(1); // Footer: page info on left, navigation hints on right int footerY = display.height() - 12; display.drawRect(0, footerY - 2, display.width(), 1); display.setColor(DisplayDriver::YELLOW); char status[30]; int pct = _totalPages > 1 ? (_currentPage * 100) / (_totalPages - 1) : 100; if (_gotoMode) { // Go-to-page input mode — show typed digits in footer snprintf(status, sizeof(status), "Go to: %.*s_", _gotoBufLen, _gotoBuf); } else { sprintf(status, "%d/%d %d%%", _currentPage + 1, _totalPages, pct); } #if defined(LilyGo_T5S3_EPaper_Pro) display.setTextSize(_prefs->smallTextSize()); display.setCursor(0, footerY); display.print(status); const char* right = "Swipe:Page Tap:GoTo Hold:Close"; display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY); display.print(right); #else display.setCursor(0, footerY); display.print(status); const char* right = _gotoMode ? "Ent:Go Sh+Del:Cancel" : "Entr:Pg# Sh+Del:Bk"; display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY); display.print(right); #endif } public: TextReaderScreen(UITask* task, NodePrefs* prefs = nullptr) : _task(task), _prefs(prefs), _mode(FILE_LIST), _sdReady(false), _initialized(false), _lastFontPref(0), _fontKey(0), _bootIndexed(false), _display(nullptr), _charsPerLine(38), _linesPerPage(22), _lineHeight(5), _textAreaHeight(100), _headerHeight(14), _footerHeight(14), _selectedFile(0), _currentPath(BOOKS_FOLDER), _fileOpen(false), _currentPage(0), _totalPages(0), _pageBufLen(0), _contentDirty(true) { } // Reset layout so it recalculates on next render (orientation change). // If a book is open, forces full reindex with new layout params. void invalidateLayout() { _initialized = false; if (_fileOpen) { _pagePositions.clear(); _totalPages = 0; _currentPage = 0; _pageBufLen = 0; _contentDirty = true; Serial.println("TextReader: Layout invalidated, will reindex on next enter"); } } // Call once after display is available to calculate layout metrics void initLayout(DisplayDriver& display) { // Re-init if font preference OR font style changed since last layout uint8_t curFont = _prefs ? (_prefs->large_font | (display.getFontStyle() << 4)) : 0; if (_initialized && curFont != _lastFontPref) { _initialized = false; _fileCache.clear(); // Page positions are font-dependent — force re-index Serial.println("TextReader: font changed, recalculating layout"); } if (_initialized) return; _lastFontPref = curFont; _fontKey = curFont; // Stored in .idx files for SD cache invalidation // Store display reference for splash screens during openBook _display = &display; // Measure tiny font metrics using the display driver display.setTextSize(_prefs->smallTextSize()); // Measure character width: use 10 M's for monospace (T-Deck Pro tiny font). // Proportional fonts (T5S3 and T-Deck Pro large_font) override below with // average-width measurement since M is the widest glyph (~40% wider than average). uint16_t tenCharsW = display.getTextWidth("MMMMMMMMMM"); if (tenCharsW > 0) { _charsPerLine = (display.width() * 10) / tenCharsW; } #if defined(LilyGo_T5S3_EPaper_Pro) // T5S3 uses proportional font (FreeSans12pt) — measure average character // width from a representative English sample. M-based measurement is far // too conservative (M is the widest glyph), leaving half the line empty. { const char* sample = "the quick brown fox jumps over lazy dog"; uint16_t sampleW = display.getTextWidth(sample); int sampleLen = strlen(sample); if (sampleW > 0 && sampleLen > 0) { // 95% factor as small safety margin for slightly-wider-than-average text _charsPerLine = (display.width() * sampleLen * 95) / ((int)sampleW * 100); } } if (_charsPerLine < 15) _charsPerLine = 15; if (_charsPerLine > 80) _charsPerLine = 80; #else // T-Deck Pro: proportional font — measure average character width from // a sample sentence (M is widest glyph, ~40% wider than average). // Large font (9pt) uses 70% safety margin; custom tiny (7pt) uses 85%. if (_prefs && (_prefs->large_font || display.getFontStyle() > 0)) { const char* sample = "the quick brown fox jumps over lazy dog"; uint16_t sampleW = display.getTextWidth(sample); int sampleLen = strlen(sample); if (sampleW > 0 && sampleLen > 0) { int pct = _prefs->large_font ? 70 : 85; _charsPerLine = (display.width() * sampleLen * pct) / ((int)sampleW * 100); } } if (_charsPerLine < 15) _charsPerLine = 15; if (_charsPerLine > 60) _charsPerLine = 60; #endif // Line height for built-in 6x8 font: // setCursor adds +5 to y, so effective text top = (y+5)*scale_y // The font is ~8px tall in real coords. In virtual coords: 8/scale_y ~ 3.2 units // We derive from measured char width since we can't measure height directly. // Built-in font: 6px wide, 8px tall -> height ~ width * 7/6 // Then add ~20% for spacing uint16_t mWidth = display.getTextWidth("M"); if (mWidth > 0) { _lineHeight = max(3, (int)((mWidth * 7 * 12) / (6 * 10))); } else { _lineHeight = 5; // Safe fallback } #if defined(LilyGo_T5S3_EPaper_Pro) // T5S3 uses FreeSans12pt/FreeSerif12pt for size 0 (yAdvance=29px). { extern DISPLAY_CLASS display; _lineHeight = display.isPortraitMode() ? 5 : 8; } #else // T-Deck Pro large_font uses FreeSans9pt (yAdvance=22px at scale 1.5625×). // Custom proportional fonts (Noto Sans, Montserrat) at size 0 also need // a tuned line height — the 6x8 formula above uses a width:height ratio // that doesn't apply to GFX fonts, causing overlap or excessive spacing. if (_prefs && _prefs->large_font) { _lineHeight = _prefs->smallLineH(); // 11 — tested for FreeSans9pt } else if (display.getFontStyle() > 0) { _lineHeight = 7; // Custom 7pt fonts: 7 * 2.5 = 17.5px — fits ~16 lines/page } #endif _headerHeight = 0; // No header in reading mode (maximize text area) _footerHeight = 14; _textAreaHeight = display.height() - _headerHeight - _footerHeight; _linesPerPage = _textAreaHeight / _lineHeight; if (_linesPerPage < 5) _linesPerPage = 5; if (_linesPerPage > 40) _linesPerPage = 40; display.setTextSize(1); // Restore _initialized = true; Serial.printf("TextReader layout: %d chars/line, %d lines/page, lineH=%d, textH=%d (display %dx%d)\n", _charsPerLine, _linesPerPage, _lineHeight, _textAreaHeight, display.width(), display.height()); } // ---- Boot-time Indexing ---- // Called from setup() after SD card init. Scans files, pre-indexes first // 100 pages of each, and shows progress on the e-ink display. // Pre-index files inside one level of subdirectories so navigating // into them later is instant (idx files already on SD). void bootIndexSubfolders() { // Work from the root-level _dirList that scanFiles() already populated. // Copy it -- scanFiles() will overwrite _dirList when we scan each subfolder. std::vector subDirs = _dirList; if (subDirs.empty()) return; Serial.printf("TextReader: Pre-indexing %d subfolders\n", (int)subDirs.size()); int totalSubFiles = 0; int cachedSubFiles = 0; int indexedSubFiles = 0; for (int d = 0; d < (int)subDirs.size(); d++) { String subPath = String(BOOKS_FOLDER) + "/" + subDirs[d]; _currentPath = subPath; scanFiles(); // populates _fileList for this subfolder // Also pick up previously converted EPUB cache files for this subfolder String epubCachePath = subPath + "/.epub_cache"; if (SD.exists(epubCachePath.c_str())) { File cacheDir = SD.open(epubCachePath.c_str()); if (cacheDir && cacheDir.isDirectory()) { File cf = cacheDir.openNextFile(); while (cf && _fileList.size() < READER_MAX_FILES) { if (!cf.isDirectory()) { String cname = String(cf.name()); int cslash = cname.lastIndexOf('/'); if (cslash >= 0) cname = cname.substring(cslash + 1); if (cname.endsWith(".txt") || cname.endsWith(".TXT")) { bool dup = false; for (int k = 0; k < (int)_fileList.size(); k++) { if (_fileList[k] == cname) { dup = true; break; } } if (!dup) _fileList.push_back(cname); } } cf = cacheDir.openNextFile(); } cacheDir.close(); } } for (int i = 0; i < (int)_fileList.size(); i++) { totalSubFiles++; // Try loading existing .idx cache -- if hit, skip FileCache tempCache; if (loadIndex(_fileList[i], tempCache)) { cachedSubFiles++; continue; } // Skip .epub files (converted on first open) if (_fileList[i].endsWith(".epub") || _fileList[i].endsWith(".EPUB")) continue; // Index this .txt file String fullPath = _currentPath + "/" + _fileList[i]; File file = SD.open(fullPath.c_str(), FILE_READ); if (!file) { // Try epub cache fallback String cacheFallback = epubCachePath + "/" + _fileList[i]; file = SD.open(cacheFallback.c_str(), FILE_READ); } if (!file) continue; indexedSubFiles++; String displayName = subDirs[d] + "/" + _fileList[i]; drawBootSplash(indexedSubFiles, 0, displayName); FileCache cache; cache.filename = _fileList[i]; cache.fileSize = file.size(); cache.fullyIndexed = false; cache.lastReadPage = 0; cache.pagePositions.clear(); cache.pagePositions.push_back(0); DisplayDriver* pxd = (_prefs->large_font || _display->getFontStyle() > 0) ? _display : nullptr; if (pxd) pxd->setTextSize(_prefs->smallTextSize()); indexPagesWordWrap(file, 0, cache.pagePositions, _linesPerPage, _charsPerLine, PREINDEX_PAGES - 1, _textAreaHeight, _lineHeight, pxd); cache.fullyIndexed = !file.available(); file.close(); saveIndex(cache.filename, cache.pagePositions, cache.fileSize, cache.fullyIndexed, 0); Serial.printf("TextReader: %s/%s - indexed %d pages%s\n", subDirs[d].c_str(), _fileList[i].c_str(), (int)cache.pagePositions.size(), cache.fullyIndexed ? " (complete)" : ""); yield(); // Feed WDT between files } } Serial.printf("TextReader: Subfolder pre-index: %d files (%d cached, %d newly indexed)\n", totalSubFiles, cachedSubFiles, indexedSubFiles); } void bootIndex(DisplayDriver& display) { if (!_sdReady) return; // Calculate layout metrics first (needed for indexing) initLayout(display); // Show initial splash drawBootSplash(0, 0, "Scanning..."); Serial.println("TextReader: Boot indexing started"); // Scan for files (includes .txt and .epub) scanFiles(); // Also pick up previously converted EPUB cache files if (SD.exists("/books/.epub_cache")) { File cacheDir = SD.open("/books/.epub_cache"); if (cacheDir && cacheDir.isDirectory()) { File f = cacheDir.openNextFile(); while (f && _fileList.size() < READER_MAX_FILES) { if (!f.isDirectory()) { String name = String(f.name()); int slash = name.lastIndexOf('/'); if (slash >= 0) name = name.substring(slash + 1); if (name.endsWith(".txt") || name.endsWith(".TXT")) { // Avoid duplicates bool dup = false; for (int i = 0; i < (int)_fileList.size(); i++) { if (_fileList[i] == name) { dup = true; break; } } if (!dup) { _fileList.push_back(name); Serial.printf("TextReader: Found cached EPUB txt: %s\n", name.c_str()); } } } f = cacheDir.openNextFile(); } cacheDir.close(); } } if (_fileList.size() == 0 && _dirList.size() == 0) { Serial.println("TextReader: No files or folders to index"); _bootIndexed = true; return; } int cachedCount = 0; int needsIndexCount = 0; // --- Pass 1 & 2: Index root-level files --- if (_fileList.size() > 0) { // --- Pass 1: Fast cache load (no per-file splash screens) --- // Try to load existing .idx files from SD for every file. // This is just SD reads — no indexing, no e-ink refreshes. _fileCache.clear(); _fileCache.resize(_fileList.size()); // Pre-allocate slots to maintain alignment with _fileList for (int i = 0; i < (int)_fileList.size(); i++) { if (loadIndex(_fileList[i], _fileCache[i])) { Serial.printf("TextReader: %s - cached %d pages (resume pg %d)\n", _fileList[i].c_str(), _fileCache[i].pagePositions.size(), _fileCache[i].lastReadPage + 1); cachedCount++; } else { // Mark as needing indexing (filename will be empty) _fileCache[i].filename = ""; needsIndexCount++; } } Serial.printf("TextReader: %d cached, %d need indexing\n", cachedCount, needsIndexCount); // --- Pass 2: Index only new/changed files (with splash screens) --- if (needsIndexCount > 0) { int indexProgress = 0; for (int i = 0; i < (int)_fileList.size(); i++) { // Skip files that loaded from cache if (_fileCache[i].filename.length() > 0) continue; // Skip .epub files — they'll be converted on first open via openBook() if (_fileList[i].endsWith(".epub") || _fileList[i].endsWith(".EPUB")) { needsIndexCount--; // Don't count epubs in progress display continue; } indexProgress++; drawBootSplash(indexProgress, needsIndexCount, _fileList[i]); // Try current path first, then epub cache fallback String fullPath = _currentPath + "/" + _fileList[i]; File file = SD.open(fullPath.c_str(), FILE_READ); if (!file) { String cacheFallback = String("/books/.epub_cache/") + _fileList[i]; file = SD.open(cacheFallback.c_str(), FILE_READ); } if (!file) continue; FileCache& cache = _fileCache[i]; cache.filename = _fileList[i]; cache.fileSize = file.size(); cache.fullyIndexed = false; cache.lastReadPage = 0; cache.pagePositions.clear(); cache.pagePositions.push_back(0); DisplayDriver* pxd = (_prefs->large_font || _display->getFontStyle() > 0) ? _display : nullptr; if (pxd) pxd->setTextSize(_prefs->smallTextSize()); int added = indexPagesWordWrap(file, 0, cache.pagePositions, _linesPerPage, _charsPerLine, PREINDEX_PAGES - 1, _textAreaHeight, _lineHeight, pxd); cache.fullyIndexed = !file.available(); file.close(); saveIndex(cache.filename, cache.pagePositions, cache.fileSize, cache.fullyIndexed, 0); Serial.printf("TextReader: %s - indexed %d pages%s\n", _fileList[i].c_str(), (int)cache.pagePositions.size(), cache.fullyIndexed ? " (complete)" : ""); } } } // end if (_fileList.size() > 0) // --- Pass 3: Pre-index files inside subfolders (one level deep) --- // Save root state -- bootIndexSubfolders() will overwrite _fileList/_dirList // via scanFiles() as it iterates each subdirectory. if (_dirList.size() > 0) { std::vector savedFileList = _fileList; std::vector savedDirList = _dirList; std::vector savedFileCache = _fileCache; bootIndexSubfolders(); // Restore root state _currentPath = String(BOOKS_FOLDER); _fileList = savedFileList; _dirList = savedDirList; _fileCache = savedFileCache; } // Deselect SD to free SPI bus digitalWrite(SDCARD_CS, HIGH); _bootIndexed = true; Serial.printf("TextReader: Boot indexing complete, %d files (%d cached, %d newly indexed)\n", (int)_fileList.size(), cachedCount, needsIndexCount); } // ---- Public Interface ---- void setSDReady(bool ready) { _sdReady = ready; } bool isSDReady() const { return _sdReady; } // Called when entering the reader screen (press R). // If boot indexing already ran, this is lightweight. void enter(DisplayDriver& display) { initLayout(display); if (_sdReady && !_bootIndexed) { // Boot indexing didn't run (shouldn't happen, but safety fallback) bootIndex(display); } if (!_fileOpen) { _selectedFile = 0; _mode = FILE_LIST; } else if (_pagePositions.empty()) { // Layout was invalidated (orientation change) — reindex the open book Serial.println("TextReader: Reindexing after layout change"); _pagePositions.push_back(0); DisplayDriver* pxd = (_prefs->large_font || _display->getFontStyle() > 0) ? _display : nullptr; if (pxd) pxd->setTextSize(_prefs->smallTextSize()); indexPagesWordWrap(_file, 0, _pagePositions, _linesPerPage, _charsPerLine, 0, _textAreaHeight, _lineHeight, pxd); _totalPages = _pagePositions.size(); if (_currentPage >= _totalPages) _currentPage = 0; _mode = READING; loadPageContent(); } else { _mode = READING; loadPageContent(); } } // Are we currently reading a book? (for key routing in main.cpp) bool isReading() const { return _mode == READING; } bool isInFileList() const { return _mode == FILE_LIST; } // Jump to a specific page number (1-based for user-facing, converted to 0-based) void gotoPage(int pageNum) { if (!_fileOpen || _totalPages == 0) return; int target = pageNum - 1; // Convert 1-based input to 0-based if (target < 0) target = 0; if (target >= _totalPages) target = _totalPages - 1; _currentPage = target; loadPageContent(); Serial.printf("TextReader: Go to page %d/%d\n", _currentPage + 1, _totalPages); } int getTotalPages() const { return _totalPages; } int getCurrentPage() const { return _currentPage; } // Tap-to-select: given virtual Y, select file list row. // Returns: 0=miss, 1=moved, 2=tapped current row. int selectRowAtVY(int vy) { if (_mode != FILE_LIST) return 0; const int startY = 14, footerH = 14; const int listLineH = _prefs ? _prefs->smallLineH() : 9; #if defined(LilyGo_T5S3_EPaper_Pro) const int bodyTop = startY; #else const int bodyTop = startY + (_prefs ? _prefs->smallHighlightOff() : 5); #endif if (vy < bodyTop || vy >= 128 - footerH) return 0; int totalItems = totalListItems(); if (totalItems == 0) return 0; int maxVisible = (128 - startY - footerH) / listLineH; if (maxVisible < 3) maxVisible = 3; if (maxVisible > 15) maxVisible = 15; int startIdx = max(0, min(_selectedFile - maxVisible / 2, totalItems - maxVisible)); int tappedRow = startIdx + (vy - bodyTop) / listLineH; if (tappedRow < 0 || tappedRow >= totalItems) return 0; if (tappedRow == _selectedFile) return 2; _selectedFile = tappedRow; return 1; } int render(DisplayDriver& display) override { if (!_sdReady) { display.setCursor(0, 20); display.setTextSize(1); display.setColor(DisplayDriver::LIGHT); display.print("SD card not found"); display.setCursor(0, 35); display.print("Insert SD with /books/"); return 5000; } if (_mode == FILE_LIST) { renderFileList(display); } else if (_mode == READING) { renderPage(display); } return 5000; // E-ink refresh interval } bool handleInput(char c) override { if (_mode == FILE_LIST) { return handleFileListInput(c); } else if (_mode == READING) { if (_gotoMode) return handleGotoInput(c); return handleReadingInput(c); } return false; } bool handleFileListInput(char c) { int total = totalListItems(); // Shift+W: page up if (c == 'W') { int pageSize = (128 - 14 - 14) / _prefs->smallLineH(); if (pageSize < 3) pageSize = 3; _selectedFile = max(0, _selectedFile - pageSize); return true; } // W - scroll up if (c == 'w' || c == 0xF2) { if (_selectedFile > 0) { _selectedFile--; return true; } return false; } // Shift+S: page down if (c == 'S') { int pageSize = (128 - 14 - 14) / _prefs->smallLineH(); if (pageSize < 3) pageSize = 3; _selectedFile = min(total - 1, _selectedFile + pageSize); return true; } // S - scroll down if (c == 's' || c == 0xF1) { if (_selectedFile < total - 1) { _selectedFile++; return true; } return false; } // Enter - open selected item (directory or file) if (c == '\r' || c == 13) { if (total == 0 || _selectedFile >= total) return false; int type = itemTypeAt(_selectedFile); if (type == 0) { // ".." — navigate to parent navigateToParent(); rescanAndIndex(); return true; } else if (type == 1) { // Subdirectory — navigate into it navigateToChild(dirNameAt(_selectedFile)); rescanAndIndex(); return true; } else { // File — open it int fi = fileIndexAt(_selectedFile); if (fi >= 0 && fi < (int)_fileList.size()) { openBook(_fileList[fi]); return true; } } return false; } return false; } // Rescan current directory and re-index its files. // Called when navigating into or out of a subfolder. void rescanAndIndex() { scanFiles(); _selectedFile = 0; // Rebuild file cache for the new directory's files _fileCache.clear(); _fileCache.resize(_fileList.size()); for (int i = 0; i < (int)_fileList.size(); i++) { if (!loadIndex(_fileList[i], _fileCache[i])) { // Not cached — skip EPUB auto-indexing here (it happens on open) // For .txt files, index now if (!(_fileList[i].endsWith(".epub") || _fileList[i].endsWith(".EPUB"))) { String fullPath = _currentPath + "/" + _fileList[i]; File file = SD.open(fullPath.c_str(), FILE_READ); if (!file) { // Try epub cache fallback String cacheFallback = String("/books/.epub_cache/") + _fileList[i]; file = SD.open(cacheFallback.c_str(), FILE_READ); } if (file) { FileCache& cache = _fileCache[i]; cache.filename = _fileList[i]; cache.fileSize = file.size(); cache.fullyIndexed = false; cache.lastReadPage = 0; cache.pagePositions.clear(); cache.pagePositions.push_back(0); DisplayDriver* pxd = (_prefs->large_font || _display->getFontStyle() > 0) ? _display : nullptr; if (pxd) pxd->setTextSize(_prefs->smallTextSize()); indexPagesWordWrap(file, 0, cache.pagePositions, _linesPerPage, _charsPerLine, PREINDEX_PAGES - 1, _textAreaHeight, _lineHeight, pxd); cache.fullyIndexed = !file.available(); file.close(); saveIndex(cache.filename, cache.pagePositions, cache.fileSize, cache.fullyIndexed, 0); } } else { _fileCache[i].filename = ""; } } yield(); // Feed WDT between files } digitalWrite(SDCARD_CS, HIGH); } bool handleReadingInput(char c) { // W/A - previous page if (c == 'w' || c == 'W' || c == 'a' || c == 'A' || c == 0xF2) { if (_currentPage > 0) { _currentPage--; loadPageContent(); return true; } return false; } // S/D/Space - next page if (c == 's' || c == 'S' || c == 'd' || c == 'D' || c == ' ' || c == 0xF1) { if (_currentPage < _totalPages - 1) { _currentPage++; loadPageContent(); return true; } return false; } // Enter - go-to-page input mode if (c == '\r' || c == 13) { _gotoMode = true; _gotoBufLen = 0; _gotoBuf[0] = '\0'; return true; } // Shift+Del - close book, back to file list if (c == KEY_CANCEL) { closeBook(); _mode = FILE_LIST; return true; } return false; } bool handleGotoInput(char c) { // Enter — commit page number if (c == '\r' || c == 13) { if (_gotoBufLen > 0) { int pageNum = atoi(_gotoBuf); gotoPage(pageNum); } _gotoMode = false; return true; } // Shift+Del - cancel if (c == KEY_CANCEL) { _gotoMode = false; return true; } // Backspace — delete last digit if (c == '\b' || c == 0x7F) { if (_gotoBufLen > 0) { _gotoBufLen--; _gotoBuf[_gotoBufLen] = '\0'; } return true; } // Digit — append (max 5 digits) if (c >= '0' && c <= '9' && _gotoBufLen < 5) { _gotoBuf[_gotoBufLen++] = c; _gotoBuf[_gotoBufLen] = '\0'; return true; } return true; // Consume all other keys while in goto mode } // External close (called when leaving reader screen entirely) void exitReader() { if (_fileOpen) closeBook(); _mode = FILE_LIST; } };