From 9eadb0a3fe0c5c2121bf0604cf38e9a9d61fa395 Mon Sep 17 00:00:00 2001 From: pelgraine <140762863+pelgraine@users.noreply.github.com> Date: Sat, 7 Feb 2026 17:41:11 +1100 Subject: [PATCH] First functioning text reader and guide added --- Guide.md | 171 ++++ examples/companion_radio/main.cpp | 85 +- .../companion_radio/ui-new/Textreaderscreen.h | 823 ++++++++++++++++++ examples/companion_radio/ui-new/UITask.cpp | 15 + examples/companion_radio/ui-new/UITask.h | 4 + 5 files changed, 1087 insertions(+), 11 deletions(-) create mode 100644 Guide.md create mode 100644 examples/companion_radio/ui-new/Textreaderscreen.h diff --git a/Guide.md b/Guide.md new file mode 100644 index 00000000..4c6cdf88 --- /dev/null +++ b/Guide.md @@ -0,0 +1,171 @@ +# Text Reader Integration for Meck Firmware + +## Overview + +This adds a text reader accessible via the **R** key from the home screen. + +**Features:** +- Browse `.txt` files from `/books/` folder on SD card +- Word-wrapped text rendering using tiny font (maximum text density) +- Page navigation with W/S/A/D keys +- Automatic reading position resume (persisted to SD card) +- Index files cached to SD for instant re-opens +- Bookmark indicator (`*`) on files with saved positions +- Compose mode (`C`) still accessible from within reader + +**Key Mapping:** +| Context | Key | Action | +|---------|-----|--------| +| Home screen | R | Open text reader | +| File list | W/S | Navigate up/down | +| File list | Enter | Open selected file | +| File list | Q | Back to home screen | +| Reading | W/A | Previous page | +| Reading | S/D/Space/Enter | Next page | +| Reading | Q | Close book → file list | +| Reading | C | Enter compose mode | + +--- + +## Files to Create + +### 1. `TextReaderScreen.h` +New file — place alongside `ChannelScreen.h` in your UITask include path. + +--- + +## Files to Modify + +### 2. `UITask.h` — 4 additions + +**a)** Add screen pointer (after `channel_screen` declaration): +```cpp +UIScreen* text_reader; // Text reader screen +``` + +**b)** Add navigation method (in public section): +```cpp +void gotoTextReader(); +bool isOnTextReader() const { return curr == text_reader; } +``` + +**c)** Add accessor (in public section): +```cpp +UIScreen* getTextReaderScreen() const { return text_reader; } +``` + +### 3. `UITask.cpp` — 3 additions + +**a)** Add include (after `#include "ChannelScreen.h"`): +```cpp +#include "TextReaderScreen.h" +``` + +**b)** In `begin()`, after `channel_screen = new ChannelScreen(this, &rtc_clock);`: +```cpp +text_reader = new TextReaderScreen(this); +``` + +**c)** Add new method (after `gotoChannelScreen()`): +```cpp +void UITask::gotoTextReader() { + TextReaderScreen* reader = (TextReaderScreen*)text_reader; + if (_display != NULL) { + reader->enter(*_display); + } + setCurrScreen(text_reader); + if (_display != NULL && !_display->isOn()) { + _display->turnOn(); + } + _auto_off = millis() + AUTO_OFF_MILLIS; + _next_refresh = 100; +} +``` + +### 4. `main.cpp` — 5 changes + +**a)** Add includes (near top, within the T-Deck Pro section): +```cpp +#include +#include "TextReaderScreen.h" +extern SPIClass displaySpi; // From GxEPDDisplay.cpp, shared SPI bus +static bool readerMode = false; +``` + +**b)** SD card init in `setup()` — add after `initKeyboard()`: +```cpp +#if defined(LilyGo_TDeck_Pro) && defined(HAS_SDCARD) +{ + pinMode(SDCARD_CS, OUTPUT); + digitalWrite(SDCARD_CS, HIGH); + if (SD.begin(SDCARD_CS, displaySpi, 4000000)) { + MESH_DEBUG_PRINTLN("setup() - SD card initialized"); + TextReaderScreen* reader = (TextReaderScreen*)ui_task.getTextReaderScreen(); + if (reader) reader->setSDReady(true); + } else { + MESH_DEBUG_PRINTLN("setup() - SD card init failed!"); + } +} +#endif +``` + +**c)** In `loop()`, track reader state — add after the `ui_task.loop()` block: +```cpp +readerMode = ui_task.isOnTextReader(); +``` + +**d)** In `handleKeyboardInput()`, add reader-mode handler **before** the normal-mode `switch(key)`: +```cpp +// Text reader mode - route keys to reader +if (readerMode) { + TextReaderScreen* reader = (TextReaderScreen*)ui_task.getTextReaderScreen(); + if (key == 'q' || key == 'Q') { + if (reader->isReading()) { + ui_task.injectKey('q'); // Close book → file list + } else { + reader->exitReader(); + ui_task.gotoHomeScreen(); // Exit reader → home + } + return; + } + if (key == 'c' || key == 'C') { + composeMode = true; + composeBuffer[0] = '\0'; + composePos = 0; + drawComposeScreen(); + return; + } + ui_task.injectKey(key); // All other keys → reader + return; +} +``` + +**e)** In the normal-mode `switch(key)`, add R key: +```cpp +case 'r': +case 'R': + Serial.println("Opening text reader"); + ui_task.gotoTextReader(); + break; +``` + +--- + +## SD Card Setup + +Place `.txt` files in a `/books/` folder on the SD card root. The reader will: +- Auto-create `/books/` if it doesn't exist +- Auto-create `/.indexes/` for page index cache files +- Skip macOS hidden files (`._*`, `.DS_Store`) +- Support up to 50 files + +**Index format** is compatible with the standalone reader (version 3), so if you've used the standalone reader previously, bookmarks and indexes will carry over. + +--- + +## Architecture Notes + +- The reader renders through the standard `UIScreen::render()` framework, so no special bypass is needed in the main loop (unlike compose mode) +- SD card uses the same HSPI bus as e-ink display and LoRa radio — CS pin management handles contention +- Page content is pre-read from SD into a memory buffer during `handleInput()`, then rendered from buffer during `render()` — this avoids SPI bus conflicts during display refresh +- Layout metrics (chars per line, lines per page) are calculated dynamically from the display driver's font metrics on first entry \ No newline at end of file diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 85448bba..d620c314 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -7,6 +7,10 @@ // T-Deck Pro Keyboard support #if defined(LilyGo_TDeck_Pro) #include "TCA8418Keyboard.h" + #include + #include "TextReaderScreen.h" + extern SPIClass displaySpi; // From GxEPDDisplay.cpp, shared SPI bus + TCA8418Keyboard keyboard(I2C_ADDR_KEYBOARD, &Wire); // Compose mode state @@ -18,6 +22,9 @@ static bool composeNeedsRefresh = false; #define COMPOSE_REFRESH_INTERVAL 600 // ms between e-ink refreshes while typing (refresh takes ~650ms) + // Text reader mode state + static bool readerMode = false; + void initKeyboard(); void handleKeyboardInput(); void drawComposeScreen(); @@ -328,6 +335,25 @@ void setup() { initKeyboard(); #endif + // Initialize SD card for text reader + #if defined(LilyGo_TDeck_Pro) && defined(HAS_SDCARD) + { + pinMode(SDCARD_CS, OUTPUT); + digitalWrite(SDCARD_CS, HIGH); // Deselect SD initially + + if (SD.begin(SDCARD_CS, displaySpi, 4000000)) { + MESH_DEBUG_PRINTLN("setup() - SD card initialized"); + // Tell the text reader that SD is ready + TextReaderScreen* reader = (TextReaderScreen*)ui_task.getTextReaderScreen(); + if (reader) { + reader->setSDReady(true); + } + } else { + MESH_DEBUG_PRINTLN("setup() - SD card initialization failed!"); + } + } + #endif + // Enable GPS by default on T-Deck Pro #if HAS_GPS // Set GPS enabled in both sensor manager and node prefs @@ -356,6 +382,8 @@ void loop() { composeNeedsRefresh = false; } } + // Track reader mode state for key routing + readerMode = ui_task.isOnTextReader(); #else ui_task.loop(); #endif @@ -477,6 +505,40 @@ void handleKeyboardInput() { return; } + // *** TEXT READER MODE *** + if (readerMode) { + TextReaderScreen* reader = (TextReaderScreen*)ui_task.getTextReaderScreen(); + + // Q key: if reading, reader handles it (close book -> file list) + // if on file list, exit reader entirely + if (key == 'q' || key == 'Q') { + if (reader->isReading()) { + // Let the reader handle Q (close book, go to file list) + ui_task.injectKey('q'); + } else { + // On file list - exit reader, go home + reader->exitReader(); + Serial.println("Exiting text reader"); + ui_task.gotoHomeScreen(); + } + return; + } + + // C key: allow entering compose mode from reader + if (key == 'c' || key == 'C') { + composeMode = true; + composeBuffer[0] = '\0'; + composePos = 0; + Serial.printf("Entering compose mode from reader, channel %d\n", composeChannelIdx); + drawComposeScreen(); + return; + } + + // All other keys pass through to the reader screen + ui_task.injectKey(key); + return; + } + // Normal mode - not composing switch (key) { case 'c': @@ -500,6 +562,13 @@ void handleKeyboardInput() { ui_task.gotoChannelScreen(); break; + case 'r': + case 'R': + // Open text reader + Serial.println("Opening text reader"); + ui_task.gotoTextReader(); + break; + case 'w': case 'W': // Navigate up/previous (scroll on channel screen) @@ -573,7 +642,7 @@ void handleKeyboardInput() { void drawComposeScreen() { #ifdef DISPLAY_CLASS display.startFrame(); - display.setTextSize(1); // Header stays normal size + display.setTextSize(1); display.setColor(DisplayDriver::GREEN); display.setCursor(0, 0); @@ -590,19 +659,16 @@ void drawComposeScreen() { display.setColor(DisplayDriver::LIGHT); display.drawRect(0, 11, display.width(), 1); - // Switch to tiny font for compose body - display.setTextSize(0); display.setCursor(0, 14); display.setColor(DisplayDriver::LIGHT); // Word wrap the compose buffer - calculate chars per line based on actual font width int x = 0; int y = 14; - int lineHeight = 9; // 8px font + 1px spacing uint16_t testWidth = display.getTextWidth("MMMMMMMMMM"); // 10 wide chars - int charsPerLine = (testWidth > 0) ? (display.width() * 10) / testWidth : 35; - if (charsPerLine < 20) charsPerLine = 20; - if (charsPerLine > 60) charsPerLine = 60; + int charsPerLine = (testWidth > 0) ? (display.width() * 10) / testWidth : 20; + if (charsPerLine < 12) charsPerLine = 12; + if (charsPerLine > 40) charsPerLine = 40; char charStr[2] = {0, 0}; // Buffer for single character as string for (int i = 0; i < composePos; i++) { @@ -611,7 +677,7 @@ void drawComposeScreen() { x++; if (x >= charsPerLine) { x = 0; - y += lineHeight; + y += 11; display.setCursor(0, y); } } @@ -619,9 +685,6 @@ void drawComposeScreen() { // Show cursor display.print("_"); - // Switch back to normal font for status bar - display.setTextSize(1); - // Status bar int statusY = display.height() - 12; display.setColor(DisplayDriver::LIGHT); diff --git a/examples/companion_radio/ui-new/Textreaderscreen.h b/examples/companion_radio/ui-new/Textreaderscreen.h new file mode 100644 index 00000000..a5669a57 --- /dev/null +++ b/examples/companion_radio/ui-new/Textreaderscreen.h @@ -0,0 +1,823 @@ +#pragma once + +#include +#include +#include +#include + +// Forward declarations +class UITask; + +// ============================================================================ +// Configuration +// ============================================================================ +#define BOOKS_FOLDER "/books" +#define INDEX_FOLDER "/.indexes" +#define INDEX_VERSION 4 +#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) { + 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; +} + +// ============================================================================ +// Page Indexer (word-wrap aware, matches display rendering) +// ============================================================================ +inline int indexPagesWordWrap(File& file, long startPos, + std::vector& pagePositions, + int linesPerPage, int charsPerLine, + int maxPages) { + const int BUF_SIZE = 2048; + char buffer[BUF_SIZE]; + + 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 = findLineBreak(buffer, bufLen, pos, charsPerLine); + 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; + } + + return pagesAdded; +} + +// ============================================================================ +// TextReaderScreen +// ============================================================================ + +class TextReaderScreen : public UIScreen { +public: + enum Mode { FILE_LIST, READING }; + + // File cache entry (index + resume position) + struct FileCache { + String filename; + std::vector pagePositions; + unsigned long fileSize; + bool fullyIndexed; + int lastReadPage; + }; + +private: + UITask* _task; + Mode _mode; + bool _sdReady; + bool _initialized; // Layout metrics calculated + + // Display layout (calculated once from display metrics) + int _charsPerLine; + int _linesPerPage; + int _lineHeight; // virtual coord units per text line + int _headerHeight; + int _footerHeight; + + // File list state + std::vector _fileList; + std::vector _fileCache; + int _selectedFile; + + // Reading state + File _file; + String _currentFile; + bool _fileOpen; + int _currentPage; + int _totalPages; + std::vector _pagePositions; + + // Page content buffer (pre-read from SD before render) + char _pageBuf[READER_BUF_SIZE]; + int _pageBufLen; + bool _contentDirty; // Need to re-read from SD + + // ---- 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; + } + + 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 + String fullPath = String(BOOKS_FOLDER) + "/" + filename; + File txtFile = SD.open(fullPath.c_str(), FILE_READ); + if (!txtFile) { idxFile.close(); return false; } + unsigned long curSize = txtFile.size(); + txtFile.close(); + + if (savedSize != curSize) { + idxFile.close(); + SD.remove(idxPath.c_str()); + return false; + } + + cache.filename = filename; + cache.fileSize = savedSize; + cache.fullyIndexed = (fullyFlag == 1); + cache.lastReadPage = lastRead; + cache.pagePositions.clear(); + + for (unsigned long i = 0; i < pageCount; i++) { + long pos = 0; + idxFile.read((uint8_t*)&pos, 4); + cache.pagePositions.push_back(pos); + } + + idxFile.close(); + return true; + } + + bool saveIndex(const String& filename, const std::vector& pages, + unsigned long fileSize, bool fullyIndexed, int lastReadPage) { + if (!SD.exists(INDEX_FOLDER)) SD.mkdir(INDEX_FOLDER); + + String idxPath = getIndexPath(filename); + if (SD.exists(idxPath.c_str())) SD.remove(idxPath.c_str()); + + File idxFile = SD.open(idxPath.c_str(), FILE_WRITE); + if (!idxFile) return false; + + uint8_t version = INDEX_VERSION; + unsigned long pageCount = pages.size(); + uint8_t fullyFlag = fullyIndexed ? 1 : 0; + + idxFile.write(&version, 1); + idxFile.write((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) + fileSize(4) + pageCount(4) + fullyIndexed(1) + idxFile.seek(1 + 4 + 4 + 1); + idxFile.write((uint8_t*)&page, 4); + idxFile.close(); + return true; + } + + // ---- File Scanning ---- + + void scanFiles() { + _fileList.clear(); + if (!SD.exists(BOOKS_FOLDER)) { + SD.mkdir(BOOKS_FOLDER); + Serial.printf("TextReader: Created %s\n", BOOKS_FOLDER); + } + + File root = SD.open(BOOKS_FOLDER); + if (!root || !root.isDirectory()) return; + + File f = root.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.startsWith(".") && + (name.endsWith(".txt") || name.endsWith(".TXT"))) { + _fileList.push_back(name); + } + } + f = root.openNextFile(); + } + root.close(); + Serial.printf("TextReader: Found %d files\n", _fileList.size()); + } + + void buildIndexes() { + _fileCache.clear(); + for (int i = 0; i < (int)_fileList.size(); i++) { + FileCache cache; + if (loadIndex(_fileList[i], cache)) { + Serial.printf("TextReader: %s - loaded %d pages (resume pg %d)\n", + _fileList[i].c_str(), cache.pagePositions.size(), + cache.lastReadPage + 1); + _fileCache.push_back(cache); + continue; + } + + // Build new index + String fullPath = String(BOOKS_FOLDER) + "/" + _fileList[i]; + File file = SD.open(fullPath.c_str(), FILE_READ); + if (!file) continue; + + cache.filename = _fileList[i]; + cache.fileSize = file.size(); + cache.fullyIndexed = false; + cache.lastReadPage = 0; + cache.pagePositions.push_back(0); + + int added = indexPagesWordWrap(file, 0, cache.pagePositions, + _linesPerPage, _charsPerLine, + PREINDEX_PAGES - 1); + cache.fullyIndexed = !file.available(); + file.close(); + + saveIndex(cache.filename, cache.pagePositions, cache.fileSize, + cache.fullyIndexed, 0); + _fileCache.push_back(cache); + + Serial.printf("TextReader: %s - indexed %d pages%s\n", + _fileList[i].c_str(), (int)cache.pagePositions.size(), + cache.fullyIndexed ? " (complete)" : ""); + } + } + + // ---- Book Open/Close ---- + + void openBook(const String& filename) { + if (_fileOpen) closeBook(); + + // Find cached index + FileCache* cache = nullptr; + for (int i = 0; i < (int)_fileCache.size(); i++) { + if (_fileCache[i].filename == filename) { + cache = &_fileCache[i]; + break; + } + } + + String fullPath = String(BOOKS_FOLDER) + "/" + filename; + _file = SD.open(fullPath.c_str(), FILE_READ); + if (!_file) { + Serial.printf("TextReader: Failed to open %s\n", filename.c_str()); + return; + } + + _currentFile = filename; + _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; + } + + if (cache->fullyIndexed) { + _totalPages = _pagePositions.size(); + _mode = READING; + loadPageContent(); + return; + } + + // Continue indexing from cache + long lastPos = cache->pagePositions.back(); + indexPagesWordWrap(_file, lastPos, _pagePositions, + _linesPerPage, _charsPerLine, 0); + } else { + _pagePositions.push_back(0); + indexPagesWordWrap(_file, 0, _pagePositions, + _linesPerPage, _charsPerLine, 0); + } + + _totalPages = _pagePositions.size(); + saveIndex(filename, _pagePositions, _file.size(), true, _currentPage); + + // Update cache entry + for (int i = 0; i < (int)_fileCache.size(); i++) { + if (_fileCache[i].filename == filename) { + _fileCache[i].pagePositions = _pagePositions; + _fileCache[i].fullyIndexed = true; + break; + } + } + + // Deselect SD to free SPI bus + digitalWrite(SDCARD_CS, HIGH); + + _mode = READING; + loadPageContent(); + Serial.printf("TextReader: Opened %s, %d pages, resume pg %d\n", + filename.c_str(), _totalPages, _currentPage + 1); + } + + 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 ---- + // FIX: Read exact span between indexed page positions instead of guessing. + // This ensures the renderer gets exactly the bytes the indexer counted. + + 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; + + Serial.printf("TextReader: Page %d/%d, filePos=%ld, span=%ld, read=%d bytes\n", + _currentPage + 1, _totalPages, pageStart, pageSpan, _pageBufLen); + + // 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); + display.print("Text Reader"); + + sprintf(tmp, "[%d]", (int)_fileList.size()); + display.setCursor(display.width() - display.getTextWidth(tmp) - 2, 0); + display.print(tmp); + + display.drawRect(0, 11, display.width(), 1); + + if (_fileList.size() == 0) { + display.setCursor(0, 18); + display.setColor(DisplayDriver::LIGHT); + display.print("No .txt files found"); + display.setCursor(0, 30); + display.print("Add files to /books/"); + display.setCursor(0, 42); + display.print("on SD card"); + } else { + display.setTextSize(0); // Tiny font for file list + int listLineH = 8; // Approximate tiny font line height in virtual coords + 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, + (int)_fileList.size() - maxVisible)); + int endIdx = min((int)_fileList.size(), startIdx + maxVisible); + + int y = startY; + for (int i = startIdx; i < endIdx; i++) { + bool selected = (i == _selectedFile); + + if (selected) { + display.setColor(DisplayDriver::LIGHT); + // FIX: setCursor adds +5 to y internally, but fillRect does not. + // So we offset fillRect by +5 to align the highlight bar with the text. + display.fillRect(0, y + 5, display.width(), listLineH); + display.setColor(DisplayDriver::DARK); + } else { + display.setColor(DisplayDriver::LIGHT); + } + + // Set cursor AFTER fillRect so text draws on top of highlight + display.setCursor(0, y); + + // Build display string: "> filename.txt *" (asterisk if has bookmark) + String line = selected ? "> " : " "; + String name = _fileList[i]; + + // Check for resume indicator + String suffix = ""; + for (int j = 0; j < (int)_fileCache.size(); j++) { + if (_fileCache[j].filename == name && _fileCache[j].lastReadPage > 0) { + suffix = " *"; + break; + } + } + + // Truncate if needed + int maxLen = _charsPerLine - 4 - suffix.length(); + if ((int)name.length() > maxLen) { + name = name.substring(0, maxLen - 3) + "..."; + } + line += name + suffix; + display.print(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.setCursor(0, footerY); + display.setColor(DisplayDriver::YELLOW); + display.print("Q:Back W/S:Nav"); + + const char* right = "Ent:Open"; + display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY); + display.print(right); + } + + void renderPage(DisplayDriver& display) { + // Use tiny font for maximum text density + display.setTextSize(0); + display.setColor(DisplayDriver::LIGHT); + + int y = 0; + int lineCount = 0; + int pos = 0; + int maxY = display.height() - _footerHeight - _lineHeight; + + // 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. + while (pos < _pageBufLen && lineCount < _linesPerPage && y <= maxY) { + int oldPos = pos; + WrapResult wrap = findLineBreak(_pageBuf, _pageBufLen, pos, _charsPerLine); + + // Safety: stop if findLineBreak made no progress (stuck at end of buffer) + if (wrap.nextStart <= oldPos && wrap.lineEnd >= _pageBufLen) break; + + display.setCursor(0, y); + // Print line character by character (only printable) + char charStr[2] = {0, 0}; + for (int j = pos; j < wrap.lineEnd && j < _pageBufLen; j++) { + if (_pageBuf[j] >= 32) { + charStr[0] = _pageBuf[j]; + display.print(charStr); + } + } + + y += _lineHeight; + 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; + sprintf(status, "%d/%d %d%%", _currentPage + 1, _totalPages, pct); + display.setCursor(0, footerY); + display.print(status); + + const char* right = "W/S:Nav Q:Back"; + display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY); + display.print(right); + } + +public: + TextReaderScreen(UITask* task) + : _task(task), _mode(FILE_LIST), _sdReady(false), _initialized(false), + _charsPerLine(38), _linesPerPage(22), _lineHeight(5), + _headerHeight(14), _footerHeight(14), + _selectedFile(0), _fileOpen(false), _currentPage(0), _totalPages(0), + _pageBufLen(0), _contentDirty(true) { + } + + // Call once after display is available to calculate layout metrics + void initLayout(DisplayDriver& display) { + if (_initialized) return; + + // Measure tiny font metrics using the display driver + display.setTextSize(0); + + // Measure character width: use 10 M's to get accurate average + uint16_t tenCharsW = display.getTextWidth("MMMMMMMMMM"); + if (tenCharsW > 0) { + _charsPerLine = (display.width() * 10) / tenCharsW; + } + if (_charsPerLine < 15) _charsPerLine = 15; + if (_charsPerLine > 60) _charsPerLine = 60; + + // 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 need inter-line spacing, so use measured char width to estimate: + // Built-in font: 6px wide, 8px tall → height ≈ width * 8/6 + // Then add ~20% for spacing + uint16_t mWidth = display.getTextWidth("M"); + if (mWidth > 0) { + // Use a 1.2x multiplier on estimated character height for line spacing + _lineHeight = max(3, (int)((mWidth * 7 * 12) / (6 * 10))); + } else { + _lineHeight = 5; // Safe fallback + } + + _headerHeight = 0; // No header in reading mode (maximize text area) + _footerHeight = 14; + int 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 (display %dx%d)\n", + _charsPerLine, _linesPerPage, _lineHeight, display.width(), display.height()); + } + + // Initialize SD card access. Call from main.cpp setup(). + // Returns true if SD is ready. + void setSDReady(bool ready) { _sdReady = ready; } + bool isSDReady() const { return _sdReady; } + + // Called when entering the reader screen + void enter(DisplayDriver& display) { + initLayout(display); + if (_sdReady && !_fileOpen) { + scanFiles(); + buildIndexes(); + // Deselect SD to free SPI bus for display rendering + digitalWrite(SDCARD_CS, HIGH); + _selectedFile = 0; + _mode = FILE_LIST; + } else if (_fileOpen) { + _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; } + + 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) { + return handleReadingInput(c); + } + return false; + } + + bool handleFileListInput(char c) { + // W - scroll up + if (c == 'w' || c == 'W' || c == 0xF2) { + if (_selectedFile > 0) { + _selectedFile--; + return true; + } + return false; + } + + // S - scroll down + if (c == 's' || c == 'S' || c == 0xF1) { + if (_selectedFile < (int)_fileList.size() - 1) { + _selectedFile++; + return true; + } + return false; + } + + // Enter - open selected file + if (c == '\r' || c == 13) { + if (_fileList.size() > 0 && _selectedFile < (int)_fileList.size()) { + openBook(_fileList[_selectedFile]); + return true; + } + return false; + } + + return false; + } + + 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/Enter - next page + if (c == 's' || c == 'S' || c == 'd' || c == 'D' || + c == ' ' || c == '\r' || c == 13 || c == 0xF1) { + if (_currentPage < _totalPages - 1) { + _currentPage++; + loadPageContent(); + return true; + } + return false; + } + + // Q - close book, back to file list + if (c == 'q' || c == 'Q') { + closeBook(); + _mode = FILE_LIST; + return true; + } + + return false; + } + + // External close (called when leaving reader screen entirely) + void exitReader() { + if (_fileOpen) closeBook(); + _mode = FILE_LIST; + } +}; \ No newline at end of file diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index d32c5ab7..ee419f58 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -31,6 +31,7 @@ #include "icons.h" #include "ChannelScreen.h" +#include "TextReaderScreen.h" class SplashScreen : public UIScreen { UITask* _task; @@ -606,6 +607,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no home = new HomeScreen(this, &rtc_clock, sensors, node_prefs); msg_preview = new MsgPreviewScreen(this, &rtc_clock); channel_screen = new ChannelScreen(this, &rtc_clock); + text_reader = new TextReaderScreen(this); setCurrScreen(splash); } @@ -997,6 +999,19 @@ void UITask::gotoChannelScreen() { _next_refresh = 100; } +void UITask::gotoTextReader() { + TextReaderScreen* reader = (TextReaderScreen*)text_reader; + if (_display != NULL) { + reader->enter(*_display); + } + setCurrScreen(text_reader); + if (_display != NULL && !_display->isOn()) { + _display->turnOn(); + } + _auto_off = millis() + AUTO_OFF_MILLIS; + _next_refresh = 100; +} + uint8_t UITask::getChannelScreenViewIdx() const { return ((ChannelScreen *) channel_screen)->getViewChannelIdx(); } diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 0c354eb1..39c6ae99 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -52,6 +52,7 @@ class UITask : public AbstractUITask { UIScreen* home; UIScreen* msg_preview; UIScreen* channel_screen; // Channel message history screen + UIScreen* text_reader; // *** NEW: Text reader screen *** UIScreen* curr; void userLedHandler(); @@ -75,11 +76,13 @@ public: void gotoHomeScreen() { setCurrScreen(home); } void gotoChannelScreen(); // Navigate to channel message screen + void gotoTextReader(); // *** NEW: Navigate to text reader *** void showAlert(const char* text, int duration_millis); int getMsgCount() const { return _msgcount; } bool hasDisplay() const { return _display != NULL; } bool isButtonPressed() const; bool isOnChannelScreen() const { return curr == channel_screen; } + bool isOnTextReader() const { return curr == text_reader; } // *** NEW *** uint8_t getChannelScreenViewIdx() const; void toggleBuzzer(); @@ -95,6 +98,7 @@ public: // Get current screen for checking state UIScreen* getCurrentScreen() const { return curr; } UIScreen* getMsgPreviewScreen() const { return msg_preview; } + UIScreen* getTextReaderScreen() const { return text_reader; } // *** NEW *** // from AbstractUITask void msgRead(int msgcount) override;