Files

2007 lines
67 KiB
C++
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#pragma once
#include <helpers/ui/UIScreen.h>
#include <helpers/ui/DisplayDriver.h>
#include <SD.h>
#include <vector>
#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 13 // v13: font key in header — auto-invalidate on font/style change
#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<long>& 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<long>& 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<long> 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<String> _fileList;
std::vector<String> _dirList; // Subdirectories at current path
std::vector<FileCache> _fileCache;
int _selectedFile;
String _currentPath; // Current browsed directory
// Reading state
File _file;
String _currentFile;
bool _fileOpen;
int _currentPage;
int _totalPages;
std::vector<long> _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<long>& 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 || _prefs->ui_font_style > 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 || _prefs->ui_font_style > 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("Q: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 Q:Cancel" : "Entr:Pg# Q: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<String> 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 || _prefs->ui_font_style > 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 || _prefs->ui_font_style > 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<String> savedFileList = _fileList;
std::vector<String> savedDirList = _dirList;
std::vector<FileCache> 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 || _prefs->ui_font_style > 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 || _prefs->ui_font_style > 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;
}
// Q - close book, back to file list
if (c == 'q' || c == 'Q') {
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;
}
// Q or Escape — cancel
if (c == 'q' || c == 'Q' || c == 0x1B) {
_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;
}
};