mirror of
https://github.com/pelgraine/Meck.git
synced 2026-05-16 14:25:51 +02:00
2007 lines
67 KiB
C++
2007 lines
67 KiB
C++
#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;
|
||
}
|
||
}; |