Add TXT file reader support (#240)
## Summary * **What is the goal of this PR?** Add support for reading plain text (.txt) files, enabling users to browse, read, and track progress in TXT documents alongside existing EPUB and XTC formats. * **What changes are included?** - New Txt library for loading and parsing plain text files - New TxtReaderActivity with streaming page rendering using 8KB chunks to handle large files without memory issues on ESP32-C3 - Page index caching system (index.bin) for instant re-open after sleep or app restart - Progress bar UI during initial file indexing (matching EPUB style) - Word wrapping with proper UTF-8 support - Cover image support for TXT files: - Primary: image with same filename as TXT (e.g., book.jpg for book.txt) - Fallback: cover.bmp/jpg/jpeg in the same folder - JPG to BMP conversion using existing converter - Sleep screen cover mode now works with TXT files - File browser now shows .txt files ## Additional Context * Add any other information that might be helpful for the reviewer * Memory constraints: The streaming approach was necessary because ESP32-C3 only has 320KB RAM. A 700KB TXT file cannot be loaded entirely into memory, so we read 8KB chunks and build a page offset index instead. * Cache invalidation: The page index cache automatically invalidates when file size, viewport width, or lines per page changes (e.g., font size or orientation change). * Performance: First open requires indexing (with progress bar), subsequent opens load from cache instantly. * Cover image format: PNG is detected but not supported for conversion (no PNG decoder available). Only BMP and JPG/JPEG work.
This commit is contained in:
191
lib/Txt/Txt.cpp
Normal file
191
lib/Txt/Txt.cpp
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
#include "Txt.h"
|
||||||
|
|
||||||
|
#include <FsHelpers.h>
|
||||||
|
#include <JpegToBmpConverter.h>
|
||||||
|
|
||||||
|
Txt::Txt(std::string path, std::string cacheBasePath)
|
||||||
|
: filepath(std::move(path)), cacheBasePath(std::move(cacheBasePath)) {
|
||||||
|
// Generate cache path from file path hash
|
||||||
|
const size_t hash = std::hash<std::string>{}(filepath);
|
||||||
|
cachePath = this->cacheBasePath + "/txt_" + std::to_string(hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Txt::load() {
|
||||||
|
if (loaded) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!SdMan.exists(filepath.c_str())) {
|
||||||
|
Serial.printf("[%lu] [TXT] File does not exist: %s\n", millis(), filepath.c_str());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
FsFile file;
|
||||||
|
if (!SdMan.openFileForRead("TXT", filepath, file)) {
|
||||||
|
Serial.printf("[%lu] [TXT] Failed to open file: %s\n", millis(), filepath.c_str());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
fileSize = file.size();
|
||||||
|
file.close();
|
||||||
|
|
||||||
|
loaded = true;
|
||||||
|
Serial.printf("[%lu] [TXT] Loaded TXT file: %s (%zu bytes)\n", millis(), filepath.c_str(), fileSize);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string Txt::getTitle() const {
|
||||||
|
// Extract filename without path and extension
|
||||||
|
size_t lastSlash = filepath.find_last_of('/');
|
||||||
|
std::string filename = (lastSlash != std::string::npos) ? filepath.substr(lastSlash + 1) : filepath;
|
||||||
|
|
||||||
|
// Remove .txt extension
|
||||||
|
if (filename.length() >= 4 && filename.substr(filename.length() - 4) == ".txt") {
|
||||||
|
filename = filename.substr(0, filename.length() - 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
return filename;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Txt::setupCacheDir() const {
|
||||||
|
if (!SdMan.exists(cacheBasePath.c_str())) {
|
||||||
|
SdMan.mkdir(cacheBasePath.c_str());
|
||||||
|
}
|
||||||
|
if (!SdMan.exists(cachePath.c_str())) {
|
||||||
|
SdMan.mkdir(cachePath.c_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string Txt::findCoverImage() const {
|
||||||
|
// Get the folder containing the txt file
|
||||||
|
size_t lastSlash = filepath.find_last_of('/');
|
||||||
|
std::string folder = (lastSlash != std::string::npos) ? filepath.substr(0, lastSlash) : "";
|
||||||
|
if (folder.empty()) {
|
||||||
|
folder = "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the base filename without extension (e.g., "mybook" from "/books/mybook.txt")
|
||||||
|
std::string baseName = getTitle();
|
||||||
|
|
||||||
|
// Image extensions to try
|
||||||
|
const char* extensions[] = {".bmp", ".jpg", ".jpeg", ".png", ".BMP", ".JPG", ".JPEG", ".PNG"};
|
||||||
|
|
||||||
|
// First priority: look for image with same name as txt file (e.g., mybook.jpg)
|
||||||
|
for (const auto& ext : extensions) {
|
||||||
|
std::string coverPath = folder + "/" + baseName + ext;
|
||||||
|
if (SdMan.exists(coverPath.c_str())) {
|
||||||
|
Serial.printf("[%lu] [TXT] Found matching cover image: %s\n", millis(), coverPath.c_str());
|
||||||
|
return coverPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: look for cover image files
|
||||||
|
const char* coverNames[] = {"cover", "Cover", "COVER"};
|
||||||
|
for (const auto& name : coverNames) {
|
||||||
|
for (const auto& ext : extensions) {
|
||||||
|
std::string coverPath = folder + "/" + std::string(name) + ext;
|
||||||
|
if (SdMan.exists(coverPath.c_str())) {
|
||||||
|
Serial.printf("[%lu] [TXT] Found fallback cover image: %s\n", millis(), coverPath.c_str());
|
||||||
|
return coverPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string Txt::getCoverBmpPath() const { return cachePath + "/cover.bmp"; }
|
||||||
|
|
||||||
|
bool Txt::generateCoverBmp() const {
|
||||||
|
// Already generated, return true
|
||||||
|
if (SdMan.exists(getCoverBmpPath().c_str())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string coverImagePath = findCoverImage();
|
||||||
|
if (coverImagePath.empty()) {
|
||||||
|
Serial.printf("[%lu] [TXT] No cover image found for TXT file\n", millis());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup cache directory
|
||||||
|
setupCacheDir();
|
||||||
|
|
||||||
|
// Get file extension
|
||||||
|
const size_t len = coverImagePath.length();
|
||||||
|
const bool isJpg =
|
||||||
|
(len >= 4 && (coverImagePath.substr(len - 4) == ".jpg" || coverImagePath.substr(len - 4) == ".JPG")) ||
|
||||||
|
(len >= 5 && (coverImagePath.substr(len - 5) == ".jpeg" || coverImagePath.substr(len - 5) == ".JPEG"));
|
||||||
|
const bool isBmp = len >= 4 && (coverImagePath.substr(len - 4) == ".bmp" || coverImagePath.substr(len - 4) == ".BMP");
|
||||||
|
|
||||||
|
if (isBmp) {
|
||||||
|
// Copy BMP file to cache
|
||||||
|
Serial.printf("[%lu] [TXT] Copying BMP cover image to cache\n", millis());
|
||||||
|
FsFile src, dst;
|
||||||
|
if (!SdMan.openFileForRead("TXT", coverImagePath, src)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!SdMan.openFileForWrite("TXT", getCoverBmpPath(), dst)) {
|
||||||
|
src.close();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
uint8_t buffer[1024];
|
||||||
|
while (src.available()) {
|
||||||
|
size_t bytesRead = src.read(buffer, sizeof(buffer));
|
||||||
|
dst.write(buffer, bytesRead);
|
||||||
|
}
|
||||||
|
src.close();
|
||||||
|
dst.close();
|
||||||
|
Serial.printf("[%lu] [TXT] Copied BMP cover to cache\n", millis());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isJpg) {
|
||||||
|
// Convert JPG/JPEG to BMP (same approach as Epub)
|
||||||
|
Serial.printf("[%lu] [TXT] Generating BMP from JPG cover image\n", millis());
|
||||||
|
FsFile coverJpg, coverBmp;
|
||||||
|
if (!SdMan.openFileForRead("TXT", coverImagePath, coverJpg)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!SdMan.openFileForWrite("TXT", getCoverBmpPath(), coverBmp)) {
|
||||||
|
coverJpg.close();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const bool success = JpegToBmpConverter::jpegFileToBmpStream(coverJpg, coverBmp);
|
||||||
|
coverJpg.close();
|
||||||
|
coverBmp.close();
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
Serial.printf("[%lu] [TXT] Failed to generate BMP from JPG cover image\n", millis());
|
||||||
|
SdMan.remove(getCoverBmpPath().c_str());
|
||||||
|
} else {
|
||||||
|
Serial.printf("[%lu] [TXT] Generated BMP from JPG cover image\n", millis());
|
||||||
|
}
|
||||||
|
return success;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PNG files are not supported (would need a PNG decoder)
|
||||||
|
Serial.printf("[%lu] [TXT] Cover image format not supported (only BMP/JPG/JPEG)\n", millis());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Txt::readContent(uint8_t* buffer, size_t offset, size_t length) const {
|
||||||
|
if (!loaded) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
FsFile file;
|
||||||
|
if (!SdMan.openFileForRead("TXT", filepath, file)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!file.seek(offset)) {
|
||||||
|
file.close();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t bytesRead = file.read(buffer, length);
|
||||||
|
file.close();
|
||||||
|
|
||||||
|
return bytesRead > 0;
|
||||||
|
}
|
||||||
33
lib/Txt/Txt.h
Normal file
33
lib/Txt/Txt.h
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <SDCardManager.h>
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
class Txt {
|
||||||
|
std::string filepath;
|
||||||
|
std::string cacheBasePath;
|
||||||
|
std::string cachePath;
|
||||||
|
bool loaded = false;
|
||||||
|
size_t fileSize = 0;
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit Txt(std::string path, std::string cacheBasePath);
|
||||||
|
|
||||||
|
bool load();
|
||||||
|
[[nodiscard]] const std::string& getPath() const { return filepath; }
|
||||||
|
[[nodiscard]] const std::string& getCachePath() const { return cachePath; }
|
||||||
|
[[nodiscard]] std::string getTitle() const;
|
||||||
|
[[nodiscard]] size_t getFileSize() const { return fileSize; }
|
||||||
|
|
||||||
|
void setupCacheDir() const;
|
||||||
|
|
||||||
|
// Cover image support - looks for cover.bmp/jpg/jpeg/png in same folder as txt file
|
||||||
|
[[nodiscard]] std::string getCoverBmpPath() const;
|
||||||
|
[[nodiscard]] bool generateCoverBmp() const;
|
||||||
|
[[nodiscard]] std::string findCoverImage() const;
|
||||||
|
|
||||||
|
// Read content from file
|
||||||
|
[[nodiscard]] bool readContent(uint8_t* buffer, size_t offset, size_t length) const;
|
||||||
|
};
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
#include <Epub.h>
|
#include <Epub.h>
|
||||||
#include <GfxRenderer.h>
|
#include <GfxRenderer.h>
|
||||||
#include <SDCardManager.h>
|
#include <SDCardManager.h>
|
||||||
|
#include <Txt.h>
|
||||||
#include <Xtc.h>
|
#include <Xtc.h>
|
||||||
|
|
||||||
#include "CrossPointSettings.h"
|
#include "CrossPointSettings.h"
|
||||||
@@ -207,6 +208,7 @@ void SleepActivity::renderCoverSleepScreen() const {
|
|||||||
std::string coverBmpPath;
|
std::string coverBmpPath;
|
||||||
bool cropped = SETTINGS.sleepScreenCoverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::CROP;
|
bool cropped = SETTINGS.sleepScreenCoverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::CROP;
|
||||||
|
|
||||||
|
// Check if the current book is XTC, TXT, or EPUB
|
||||||
if (StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".xtc") ||
|
if (StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".xtc") ||
|
||||||
StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".xtch")) {
|
StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".xtch")) {
|
||||||
// Handle XTC file
|
// Handle XTC file
|
||||||
@@ -222,6 +224,20 @@ void SleepActivity::renderCoverSleepScreen() const {
|
|||||||
}
|
}
|
||||||
|
|
||||||
coverBmpPath = lastXtc.getCoverBmpPath();
|
coverBmpPath = lastXtc.getCoverBmpPath();
|
||||||
|
} else if (StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".txt")) {
|
||||||
|
// Handle TXT file - looks for cover image in the same folder
|
||||||
|
Txt lastTxt(APP_STATE.openEpubPath, "/.crosspoint");
|
||||||
|
if (!lastTxt.load()) {
|
||||||
|
Serial.println("[SLP] Failed to load last TXT");
|
||||||
|
return renderDefaultSleepScreen();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!lastTxt.generateCoverBmp()) {
|
||||||
|
Serial.println("[SLP] No cover image found for TXT file");
|
||||||
|
return renderDefaultSleepScreen();
|
||||||
|
}
|
||||||
|
|
||||||
|
coverBmpPath = lastTxt.getCoverBmpPath();
|
||||||
} else if (StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".epub")) {
|
} else if (StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".epub")) {
|
||||||
// Handle EPUB file
|
// Handle EPUB file
|
||||||
Epub lastEpub(APP_STATE.openEpubPath, "/.crosspoint");
|
Epub lastEpub(APP_STATE.openEpubPath, "/.crosspoint");
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ void FileSelectionActivity::loadFiles() {
|
|||||||
} else {
|
} else {
|
||||||
auto filename = std::string(name);
|
auto filename = std::string(name);
|
||||||
if (StringUtils::checkFileExtension(filename, ".epub") || StringUtils::checkFileExtension(filename, ".xtch") ||
|
if (StringUtils::checkFileExtension(filename, ".epub") || StringUtils::checkFileExtension(filename, ".xtch") ||
|
||||||
StringUtils::checkFileExtension(filename, ".xtc")) {
|
StringUtils::checkFileExtension(filename, ".xtc") || StringUtils::checkFileExtension(filename, ".txt")) {
|
||||||
files.emplace_back(filename);
|
files.emplace_back(filename);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
#include "Epub.h"
|
#include "Epub.h"
|
||||||
#include "EpubReaderActivity.h"
|
#include "EpubReaderActivity.h"
|
||||||
#include "FileSelectionActivity.h"
|
#include "FileSelectionActivity.h"
|
||||||
|
#include "Txt.h"
|
||||||
|
#include "TxtReaderActivity.h"
|
||||||
#include "Xtc.h"
|
#include "Xtc.h"
|
||||||
#include "XtcReaderActivity.h"
|
#include "XtcReaderActivity.h"
|
||||||
#include "activities/util/FullScreenMessageActivity.h"
|
#include "activities/util/FullScreenMessageActivity.h"
|
||||||
@@ -20,6 +22,12 @@ bool ReaderActivity::isXtcFile(const std::string& path) {
|
|||||||
return StringUtils::checkFileExtension(path, ".xtc") || StringUtils::checkFileExtension(path, ".xtch");
|
return StringUtils::checkFileExtension(path, ".xtc") || StringUtils::checkFileExtension(path, ".xtch");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool ReaderActivity::isTxtFile(const std::string& path) {
|
||||||
|
if (path.length() < 4) return false;
|
||||||
|
std::string ext4 = path.substr(path.length() - 4);
|
||||||
|
return ext4 == ".txt" || ext4 == ".TXT";
|
||||||
|
}
|
||||||
|
|
||||||
std::unique_ptr<Epub> ReaderActivity::loadEpub(const std::string& path) {
|
std::unique_ptr<Epub> ReaderActivity::loadEpub(const std::string& path) {
|
||||||
if (!SdMan.exists(path.c_str())) {
|
if (!SdMan.exists(path.c_str())) {
|
||||||
Serial.printf("[%lu] [ ] File does not exist: %s\n", millis(), path.c_str());
|
Serial.printf("[%lu] [ ] File does not exist: %s\n", millis(), path.c_str());
|
||||||
@@ -50,6 +58,21 @@ std::unique_ptr<Xtc> ReaderActivity::loadXtc(const std::string& path) {
|
|||||||
return nullptr;
|
return nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::unique_ptr<Txt> ReaderActivity::loadTxt(const std::string& path) {
|
||||||
|
if (!SdMan.exists(path.c_str())) {
|
||||||
|
Serial.printf("[%lu] [ ] File does not exist: %s\n", millis(), path.c_str());
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto txt = std::unique_ptr<Txt>(new Txt(path, "/.crosspoint"));
|
||||||
|
if (txt->load()) {
|
||||||
|
return txt;
|
||||||
|
}
|
||||||
|
|
||||||
|
Serial.printf("[%lu] [ ] Failed to load TXT\n", millis());
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
void ReaderActivity::onSelectBookFile(const std::string& path) {
|
void ReaderActivity::onSelectBookFile(const std::string& path) {
|
||||||
currentBookPath = path; // Track current book path
|
currentBookPath = path; // Track current book path
|
||||||
exitActivity();
|
exitActivity();
|
||||||
@@ -67,6 +90,18 @@ void ReaderActivity::onSelectBookFile(const std::string& path) {
|
|||||||
delay(2000);
|
delay(2000);
|
||||||
onGoToFileSelection();
|
onGoToFileSelection();
|
||||||
}
|
}
|
||||||
|
} else if (isTxtFile(path)) {
|
||||||
|
// Load TXT file
|
||||||
|
auto txt = loadTxt(path);
|
||||||
|
if (txt) {
|
||||||
|
onGoToTxtReader(std::move(txt));
|
||||||
|
} else {
|
||||||
|
exitActivity();
|
||||||
|
enterNewActivity(new FullScreenMessageActivity(renderer, mappedInput, "Failed to load TXT",
|
||||||
|
EpdFontFamily::REGULAR, EInkDisplay::HALF_REFRESH));
|
||||||
|
delay(2000);
|
||||||
|
onGoToFileSelection();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Load EPUB file
|
// Load EPUB file
|
||||||
auto epub = loadEpub(path);
|
auto epub = loadEpub(path);
|
||||||
@@ -108,6 +143,15 @@ void ReaderActivity::onGoToXtcReader(std::unique_ptr<Xtc> xtc) {
|
|||||||
[this] { onGoBack(); }));
|
[this] { onGoBack(); }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void ReaderActivity::onGoToTxtReader(std::unique_ptr<Txt> txt) {
|
||||||
|
const auto txtPath = txt->getPath();
|
||||||
|
currentBookPath = txtPath;
|
||||||
|
exitActivity();
|
||||||
|
enterNewActivity(new TxtReaderActivity(
|
||||||
|
renderer, mappedInput, std::move(txt), [this, txtPath] { onGoToFileSelection(txtPath); },
|
||||||
|
[this] { onGoBack(); }));
|
||||||
|
}
|
||||||
|
|
||||||
void ReaderActivity::onEnter() {
|
void ReaderActivity::onEnter() {
|
||||||
ActivityWithSubactivity::onEnter();
|
ActivityWithSubactivity::onEnter();
|
||||||
|
|
||||||
@@ -125,6 +169,13 @@ void ReaderActivity::onEnter() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onGoToXtcReader(std::move(xtc));
|
onGoToXtcReader(std::move(xtc));
|
||||||
|
} else if (isTxtFile(initialBookPath)) {
|
||||||
|
auto txt = loadTxt(initialBookPath);
|
||||||
|
if (!txt) {
|
||||||
|
onGoBack();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onGoToTxtReader(std::move(txt));
|
||||||
} else {
|
} else {
|
||||||
auto epub = loadEpub(initialBookPath);
|
auto epub = loadEpub(initialBookPath);
|
||||||
if (!epub) {
|
if (!epub) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
class Epub;
|
class Epub;
|
||||||
class Xtc;
|
class Xtc;
|
||||||
|
class Txt;
|
||||||
|
|
||||||
class ReaderActivity final : public ActivityWithSubactivity {
|
class ReaderActivity final : public ActivityWithSubactivity {
|
||||||
std::string initialBookPath;
|
std::string initialBookPath;
|
||||||
@@ -12,13 +13,16 @@ class ReaderActivity final : public ActivityWithSubactivity {
|
|||||||
const std::function<void()> onGoBack;
|
const std::function<void()> onGoBack;
|
||||||
static std::unique_ptr<Epub> loadEpub(const std::string& path);
|
static std::unique_ptr<Epub> loadEpub(const std::string& path);
|
||||||
static std::unique_ptr<Xtc> loadXtc(const std::string& path);
|
static std::unique_ptr<Xtc> loadXtc(const std::string& path);
|
||||||
|
static std::unique_ptr<Txt> loadTxt(const std::string& path);
|
||||||
static bool isXtcFile(const std::string& path);
|
static bool isXtcFile(const std::string& path);
|
||||||
|
static bool isTxtFile(const std::string& path);
|
||||||
|
|
||||||
static std::string extractFolderPath(const std::string& filePath);
|
static std::string extractFolderPath(const std::string& filePath);
|
||||||
void onSelectBookFile(const std::string& path);
|
void onSelectBookFile(const std::string& path);
|
||||||
void onGoToFileSelection(const std::string& fromBookPath = "");
|
void onGoToFileSelection(const std::string& fromBookPath = "");
|
||||||
void onGoToEpubReader(std::unique_ptr<Epub> epub);
|
void onGoToEpubReader(std::unique_ptr<Epub> epub);
|
||||||
void onGoToXtcReader(std::unique_ptr<Xtc> xtc);
|
void onGoToXtcReader(std::unique_ptr<Xtc> xtc);
|
||||||
|
void onGoToTxtReader(std::unique_ptr<Txt> txt);
|
||||||
|
|
||||||
public:
|
public:
|
||||||
explicit ReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string initialBookPath,
|
explicit ReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string initialBookPath,
|
||||||
|
|||||||
700
src/activities/reader/TxtReaderActivity.cpp
Normal file
700
src/activities/reader/TxtReaderActivity.cpp
Normal file
@@ -0,0 +1,700 @@
|
|||||||
|
#include "TxtReaderActivity.h"
|
||||||
|
|
||||||
|
#include <GfxRenderer.h>
|
||||||
|
#include <SDCardManager.h>
|
||||||
|
#include <Serialization.h>
|
||||||
|
#include <Utf8.h>
|
||||||
|
|
||||||
|
#include "CrossPointSettings.h"
|
||||||
|
#include "CrossPointState.h"
|
||||||
|
#include "MappedInputManager.h"
|
||||||
|
#include "ScreenComponents.h"
|
||||||
|
#include "fontIds.h"
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
constexpr unsigned long goHomeMs = 1000;
|
||||||
|
constexpr int statusBarMargin = 25;
|
||||||
|
constexpr size_t CHUNK_SIZE = 8 * 1024; // 8KB chunk for reading
|
||||||
|
|
||||||
|
// Cache file magic and version
|
||||||
|
constexpr uint32_t CACHE_MAGIC = 0x54585449; // "TXTI"
|
||||||
|
constexpr uint8_t CACHE_VERSION = 2; // Increment when cache format changes
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void TxtReaderActivity::taskTrampoline(void* param) {
|
||||||
|
auto* self = static_cast<TxtReaderActivity*>(param);
|
||||||
|
self->displayTaskLoop();
|
||||||
|
}
|
||||||
|
|
||||||
|
void TxtReaderActivity::onEnter() {
|
||||||
|
ActivityWithSubactivity::onEnter();
|
||||||
|
|
||||||
|
if (!txt) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure screen orientation based on settings
|
||||||
|
switch (SETTINGS.orientation) {
|
||||||
|
case CrossPointSettings::ORIENTATION::PORTRAIT:
|
||||||
|
renderer.setOrientation(GfxRenderer::Orientation::Portrait);
|
||||||
|
break;
|
||||||
|
case CrossPointSettings::ORIENTATION::LANDSCAPE_CW:
|
||||||
|
renderer.setOrientation(GfxRenderer::Orientation::LandscapeClockwise);
|
||||||
|
break;
|
||||||
|
case CrossPointSettings::ORIENTATION::INVERTED:
|
||||||
|
renderer.setOrientation(GfxRenderer::Orientation::PortraitInverted);
|
||||||
|
break;
|
||||||
|
case CrossPointSettings::ORIENTATION::LANDSCAPE_CCW:
|
||||||
|
renderer.setOrientation(GfxRenderer::Orientation::LandscapeCounterClockwise);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderingMutex = xSemaphoreCreateMutex();
|
||||||
|
|
||||||
|
txt->setupCacheDir();
|
||||||
|
|
||||||
|
// Save current txt as last opened file
|
||||||
|
APP_STATE.openEpubPath = txt->getPath();
|
||||||
|
APP_STATE.saveToFile();
|
||||||
|
|
||||||
|
// Trigger first update
|
||||||
|
updateRequired = true;
|
||||||
|
|
||||||
|
xTaskCreate(&TxtReaderActivity::taskTrampoline, "TxtReaderActivityTask",
|
||||||
|
6144, // Stack size
|
||||||
|
this, // Parameters
|
||||||
|
1, // Priority
|
||||||
|
&displayTaskHandle // Task handle
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void TxtReaderActivity::onExit() {
|
||||||
|
ActivityWithSubactivity::onExit();
|
||||||
|
|
||||||
|
// Reset orientation back to portrait for the rest of the UI
|
||||||
|
renderer.setOrientation(GfxRenderer::Orientation::Portrait);
|
||||||
|
|
||||||
|
// Wait until not rendering to delete task
|
||||||
|
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||||
|
if (displayTaskHandle) {
|
||||||
|
vTaskDelete(displayTaskHandle);
|
||||||
|
displayTaskHandle = nullptr;
|
||||||
|
}
|
||||||
|
vSemaphoreDelete(renderingMutex);
|
||||||
|
renderingMutex = nullptr;
|
||||||
|
pageOffsets.clear();
|
||||||
|
currentPageLines.clear();
|
||||||
|
txt.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
void TxtReaderActivity::loop() {
|
||||||
|
if (subActivity) {
|
||||||
|
subActivity->loop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Long press BACK (1s+) goes directly to home
|
||||||
|
if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= goHomeMs) {
|
||||||
|
onGoHome();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Short press BACK goes to file selection
|
||||||
|
if (mappedInput.wasReleased(MappedInputManager::Button::Back) && mappedInput.getHeldTime() < goHomeMs) {
|
||||||
|
onGoBack();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bool prevReleased = mappedInput.wasReleased(MappedInputManager::Button::PageBack) ||
|
||||||
|
mappedInput.wasReleased(MappedInputManager::Button::Left);
|
||||||
|
const bool nextReleased = mappedInput.wasReleased(MappedInputManager::Button::PageForward) ||
|
||||||
|
(SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::PAGE_TURN &&
|
||||||
|
mappedInput.wasReleased(MappedInputManager::Button::Power)) ||
|
||||||
|
mappedInput.wasReleased(MappedInputManager::Button::Right);
|
||||||
|
|
||||||
|
if (!prevReleased && !nextReleased) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prevReleased && currentPage > 0) {
|
||||||
|
currentPage--;
|
||||||
|
updateRequired = true;
|
||||||
|
} else if (nextReleased && currentPage < totalPages - 1) {
|
||||||
|
currentPage++;
|
||||||
|
updateRequired = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void TxtReaderActivity::displayTaskLoop() {
|
||||||
|
while (true) {
|
||||||
|
if (updateRequired) {
|
||||||
|
updateRequired = false;
|
||||||
|
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||||
|
renderScreen();
|
||||||
|
xSemaphoreGive(renderingMutex);
|
||||||
|
}
|
||||||
|
vTaskDelay(10 / portTICK_PERIOD_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void TxtReaderActivity::initializeReader() {
|
||||||
|
if (initialized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store current settings for cache validation
|
||||||
|
cachedFontId = SETTINGS.getReaderFontId();
|
||||||
|
cachedScreenMargin = SETTINGS.screenMargin;
|
||||||
|
cachedParagraphAlignment = SETTINGS.paragraphAlignment;
|
||||||
|
|
||||||
|
// Calculate viewport dimensions
|
||||||
|
int orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft;
|
||||||
|
renderer.getOrientedViewableTRBL(&orientedMarginTop, &orientedMarginRight, &orientedMarginBottom,
|
||||||
|
&orientedMarginLeft);
|
||||||
|
orientedMarginTop += cachedScreenMargin;
|
||||||
|
orientedMarginLeft += cachedScreenMargin;
|
||||||
|
orientedMarginRight += cachedScreenMargin;
|
||||||
|
orientedMarginBottom += statusBarMargin;
|
||||||
|
|
||||||
|
viewportWidth = renderer.getScreenWidth() - orientedMarginLeft - orientedMarginRight;
|
||||||
|
const int viewportHeight = renderer.getScreenHeight() - orientedMarginTop - orientedMarginBottom;
|
||||||
|
const int lineHeight = renderer.getLineHeight(cachedFontId);
|
||||||
|
|
||||||
|
linesPerPage = viewportHeight / lineHeight;
|
||||||
|
if (linesPerPage < 1) linesPerPage = 1;
|
||||||
|
|
||||||
|
Serial.printf("[%lu] [TRS] Viewport: %dx%d, lines per page: %d\n", millis(), viewportWidth, viewportHeight,
|
||||||
|
linesPerPage);
|
||||||
|
|
||||||
|
// Try to load cached page index first
|
||||||
|
if (!loadPageIndexCache()) {
|
||||||
|
// Cache not found, build page index
|
||||||
|
buildPageIndex();
|
||||||
|
// Save to cache for next time
|
||||||
|
savePageIndexCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load saved progress
|
||||||
|
loadProgress();
|
||||||
|
|
||||||
|
initialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void TxtReaderActivity::buildPageIndex() {
|
||||||
|
pageOffsets.clear();
|
||||||
|
pageOffsets.push_back(0); // First page starts at offset 0
|
||||||
|
|
||||||
|
size_t offset = 0;
|
||||||
|
const size_t fileSize = txt->getFileSize();
|
||||||
|
int lastProgressPercent = -1;
|
||||||
|
|
||||||
|
Serial.printf("[%lu] [TRS] Building page index for %zu bytes...\n", millis(), fileSize);
|
||||||
|
|
||||||
|
// Progress bar dimensions (matching EpubReaderActivity style)
|
||||||
|
constexpr int barWidth = 200;
|
||||||
|
constexpr int barHeight = 10;
|
||||||
|
constexpr int boxMargin = 20;
|
||||||
|
const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, "Indexing...");
|
||||||
|
const int boxWidth = (barWidth > textWidth ? barWidth : textWidth) + boxMargin * 2;
|
||||||
|
const int boxHeight = renderer.getLineHeight(UI_12_FONT_ID) + barHeight + boxMargin * 3;
|
||||||
|
const int boxX = (renderer.getScreenWidth() - boxWidth) / 2;
|
||||||
|
constexpr int boxY = 50;
|
||||||
|
const int barX = boxX + (boxWidth - barWidth) / 2;
|
||||||
|
const int barY = boxY + renderer.getLineHeight(UI_12_FONT_ID) + boxMargin * 2;
|
||||||
|
|
||||||
|
// Draw initial progress box
|
||||||
|
renderer.fillRect(boxX, boxY, boxWidth, boxHeight, false);
|
||||||
|
renderer.drawText(UI_12_FONT_ID, boxX + boxMargin, boxY + boxMargin, "Indexing...");
|
||||||
|
renderer.drawRect(boxX + 5, boxY + 5, boxWidth - 10, boxHeight - 10);
|
||||||
|
renderer.drawRect(barX, barY, barWidth, barHeight);
|
||||||
|
renderer.displayBuffer();
|
||||||
|
|
||||||
|
while (offset < fileSize) {
|
||||||
|
std::vector<std::string> tempLines;
|
||||||
|
size_t nextOffset = offset;
|
||||||
|
|
||||||
|
if (!loadPageAtOffset(offset, tempLines, nextOffset)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextOffset <= offset) {
|
||||||
|
// No progress made, avoid infinite loop
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
offset = nextOffset;
|
||||||
|
if (offset < fileSize) {
|
||||||
|
pageOffsets.push_back(offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update progress bar every 10% (matching EpubReaderActivity logic)
|
||||||
|
int progressPercent = (offset * 100) / fileSize;
|
||||||
|
if (lastProgressPercent / 10 != progressPercent / 10) {
|
||||||
|
lastProgressPercent = progressPercent;
|
||||||
|
|
||||||
|
// Fill progress bar
|
||||||
|
const int fillWidth = (barWidth - 2) * progressPercent / 100;
|
||||||
|
renderer.fillRect(barX + 1, barY + 1, fillWidth, barHeight - 2, true);
|
||||||
|
renderer.displayBuffer(EInkDisplay::FAST_REFRESH);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Yield to other tasks periodically
|
||||||
|
if (pageOffsets.size() % 20 == 0) {
|
||||||
|
vTaskDelay(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
totalPages = pageOffsets.size();
|
||||||
|
Serial.printf("[%lu] [TRS] Built page index: %d pages\n", millis(), totalPages);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool TxtReaderActivity::loadPageAtOffset(size_t offset, std::vector<std::string>& outLines, size_t& nextOffset) {
|
||||||
|
outLines.clear();
|
||||||
|
const size_t fileSize = txt->getFileSize();
|
||||||
|
|
||||||
|
if (offset >= fileSize) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read a chunk from file
|
||||||
|
size_t chunkSize = std::min(CHUNK_SIZE, fileSize - offset);
|
||||||
|
auto* buffer = static_cast<uint8_t*>(malloc(chunkSize + 1));
|
||||||
|
if (!buffer) {
|
||||||
|
Serial.printf("[%lu] [TRS] Failed to allocate %zu bytes\n", millis(), chunkSize);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!txt->readContent(buffer, offset, chunkSize)) {
|
||||||
|
free(buffer);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
buffer[chunkSize] = '\0';
|
||||||
|
|
||||||
|
// Parse lines from buffer
|
||||||
|
size_t pos = 0;
|
||||||
|
|
||||||
|
while (pos < chunkSize && static_cast<int>(outLines.size()) < linesPerPage) {
|
||||||
|
// Find end of line
|
||||||
|
size_t lineEnd = pos;
|
||||||
|
while (lineEnd < chunkSize && buffer[lineEnd] != '\n') {
|
||||||
|
lineEnd++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we have a complete line
|
||||||
|
bool lineComplete = (lineEnd < chunkSize) || (offset + lineEnd >= fileSize);
|
||||||
|
|
||||||
|
if (!lineComplete && static_cast<int>(outLines.size()) > 0) {
|
||||||
|
// Incomplete line and we already have some lines, stop here
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate the actual length of line content in the buffer (excluding newline)
|
||||||
|
size_t lineContentLen = lineEnd - pos;
|
||||||
|
|
||||||
|
// Check for carriage return
|
||||||
|
bool hasCR = (lineContentLen > 0 && buffer[pos + lineContentLen - 1] == '\r');
|
||||||
|
size_t displayLen = hasCR ? lineContentLen - 1 : lineContentLen;
|
||||||
|
|
||||||
|
// Extract line content for display (without CR/LF)
|
||||||
|
std::string line(reinterpret_cast<char*>(buffer + pos), displayLen);
|
||||||
|
|
||||||
|
// Track position within this source line (in bytes from pos)
|
||||||
|
size_t lineBytePos = 0;
|
||||||
|
|
||||||
|
// Word wrap if needed
|
||||||
|
while (!line.empty() && static_cast<int>(outLines.size()) < linesPerPage) {
|
||||||
|
int lineWidth = renderer.getTextWidth(cachedFontId, line.c_str());
|
||||||
|
|
||||||
|
if (lineWidth <= viewportWidth) {
|
||||||
|
outLines.push_back(line);
|
||||||
|
lineBytePos = displayLen; // Consumed entire display content
|
||||||
|
line.clear();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find break point
|
||||||
|
size_t breakPos = line.length();
|
||||||
|
while (breakPos > 0 && renderer.getTextWidth(cachedFontId, line.substr(0, breakPos).c_str()) > viewportWidth) {
|
||||||
|
// Try to break at space
|
||||||
|
size_t spacePos = line.rfind(' ', breakPos - 1);
|
||||||
|
if (spacePos != std::string::npos && spacePos > 0) {
|
||||||
|
breakPos = spacePos;
|
||||||
|
} else {
|
||||||
|
// Break at character boundary for UTF-8
|
||||||
|
breakPos--;
|
||||||
|
// Make sure we don't break in the middle of a UTF-8 sequence
|
||||||
|
while (breakPos > 0 && (line[breakPos] & 0xC0) == 0x80) {
|
||||||
|
breakPos--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (breakPos == 0) {
|
||||||
|
breakPos = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
outLines.push_back(line.substr(0, breakPos));
|
||||||
|
|
||||||
|
// Skip space at break point
|
||||||
|
size_t skipChars = breakPos;
|
||||||
|
if (breakPos < line.length() && line[breakPos] == ' ') {
|
||||||
|
skipChars++;
|
||||||
|
}
|
||||||
|
lineBytePos += skipChars;
|
||||||
|
line = line.substr(skipChars);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine how much of the source buffer we consumed
|
||||||
|
if (line.empty()) {
|
||||||
|
// Fully consumed this source line, move past the newline
|
||||||
|
pos = lineEnd + 1;
|
||||||
|
} else {
|
||||||
|
// Partially consumed - page is full mid-line
|
||||||
|
// Move pos to where we stopped in the line (NOT past the line)
|
||||||
|
pos = pos + lineBytePos;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure we make progress even if calculations go wrong
|
||||||
|
if (pos == 0 && !outLines.empty()) {
|
||||||
|
// Fallback: at minimum, consume something to avoid infinite loop
|
||||||
|
pos = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
nextOffset = offset + pos;
|
||||||
|
|
||||||
|
// Make sure we don't go past the file
|
||||||
|
if (nextOffset > fileSize) {
|
||||||
|
nextOffset = fileSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
free(buffer);
|
||||||
|
|
||||||
|
return !outLines.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
void TxtReaderActivity::renderScreen() {
|
||||||
|
if (!txt) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize reader if not done
|
||||||
|
if (!initialized) {
|
||||||
|
renderer.clearScreen();
|
||||||
|
renderer.drawCenteredText(UI_12_FONT_ID, 300, "Indexing...", true, EpdFontFamily::BOLD);
|
||||||
|
renderer.displayBuffer();
|
||||||
|
initializeReader();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pageOffsets.empty()) {
|
||||||
|
renderer.clearScreen();
|
||||||
|
renderer.drawCenteredText(UI_12_FONT_ID, 300, "Empty file", true, EpdFontFamily::BOLD);
|
||||||
|
renderer.displayBuffer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bounds check
|
||||||
|
if (currentPage < 0) currentPage = 0;
|
||||||
|
if (currentPage >= totalPages) currentPage = totalPages - 1;
|
||||||
|
|
||||||
|
// Load current page content
|
||||||
|
size_t offset = pageOffsets[currentPage];
|
||||||
|
size_t nextOffset;
|
||||||
|
currentPageLines.clear();
|
||||||
|
loadPageAtOffset(offset, currentPageLines, nextOffset);
|
||||||
|
|
||||||
|
renderer.clearScreen();
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
// Save progress
|
||||||
|
saveProgress();
|
||||||
|
}
|
||||||
|
|
||||||
|
void TxtReaderActivity::renderPage() {
|
||||||
|
int orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft;
|
||||||
|
renderer.getOrientedViewableTRBL(&orientedMarginTop, &orientedMarginRight, &orientedMarginBottom,
|
||||||
|
&orientedMarginLeft);
|
||||||
|
orientedMarginTop += cachedScreenMargin;
|
||||||
|
orientedMarginLeft += cachedScreenMargin;
|
||||||
|
orientedMarginRight += cachedScreenMargin;
|
||||||
|
orientedMarginBottom += statusBarMargin;
|
||||||
|
|
||||||
|
const int lineHeight = renderer.getLineHeight(cachedFontId);
|
||||||
|
const int contentWidth = viewportWidth;
|
||||||
|
|
||||||
|
// Render text lines with alignment
|
||||||
|
auto renderLines = [&]() {
|
||||||
|
int y = orientedMarginTop;
|
||||||
|
for (const auto& line : currentPageLines) {
|
||||||
|
if (!line.empty()) {
|
||||||
|
int x = orientedMarginLeft;
|
||||||
|
|
||||||
|
// Apply text alignment
|
||||||
|
switch (cachedParagraphAlignment) {
|
||||||
|
case CrossPointSettings::LEFT_ALIGN:
|
||||||
|
default:
|
||||||
|
// x already set to left margin
|
||||||
|
break;
|
||||||
|
case CrossPointSettings::CENTER_ALIGN: {
|
||||||
|
int textWidth = renderer.getTextWidth(cachedFontId, line.c_str());
|
||||||
|
x = orientedMarginLeft + (contentWidth - textWidth) / 2;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case CrossPointSettings::RIGHT_ALIGN: {
|
||||||
|
int textWidth = renderer.getTextWidth(cachedFontId, line.c_str());
|
||||||
|
x = orientedMarginLeft + contentWidth - textWidth;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case CrossPointSettings::JUSTIFIED:
|
||||||
|
// For plain text, justified is treated as left-aligned
|
||||||
|
// (true justification would require word spacing adjustments)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderer.drawText(cachedFontId, x, y, line.c_str());
|
||||||
|
}
|
||||||
|
y += lineHeight;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// First pass: BW rendering
|
||||||
|
renderLines();
|
||||||
|
renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
|
||||||
|
|
||||||
|
if (pagesUntilFullRefresh <= 1) {
|
||||||
|
renderer.displayBuffer(EInkDisplay::HALF_REFRESH);
|
||||||
|
pagesUntilFullRefresh = SETTINGS.getRefreshFrequency();
|
||||||
|
} else {
|
||||||
|
renderer.displayBuffer();
|
||||||
|
pagesUntilFullRefresh--;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grayscale rendering pass (for anti-aliased fonts)
|
||||||
|
if (SETTINGS.textAntiAliasing) {
|
||||||
|
// Save BW buffer for restoration after grayscale pass
|
||||||
|
renderer.storeBwBuffer();
|
||||||
|
|
||||||
|
renderer.clearScreen(0x00);
|
||||||
|
renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB);
|
||||||
|
renderLines();
|
||||||
|
renderer.copyGrayscaleLsbBuffers();
|
||||||
|
|
||||||
|
renderer.clearScreen(0x00);
|
||||||
|
renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB);
|
||||||
|
renderLines();
|
||||||
|
renderer.copyGrayscaleMsbBuffers();
|
||||||
|
|
||||||
|
renderer.displayGrayBuffer();
|
||||||
|
renderer.setRenderMode(GfxRenderer::BW);
|
||||||
|
|
||||||
|
// Restore BW buffer
|
||||||
|
renderer.restoreBwBuffer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void TxtReaderActivity::renderStatusBar(const int orientedMarginRight, const int orientedMarginBottom,
|
||||||
|
const int orientedMarginLeft) const {
|
||||||
|
const bool showProgress = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL;
|
||||||
|
const bool showBattery = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::NO_PROGRESS ||
|
||||||
|
SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL;
|
||||||
|
const bool showTitle = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::NO_PROGRESS ||
|
||||||
|
SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL;
|
||||||
|
|
||||||
|
const auto screenHeight = renderer.getScreenHeight();
|
||||||
|
const auto textY = screenHeight - orientedMarginBottom - 4;
|
||||||
|
int progressTextWidth = 0;
|
||||||
|
|
||||||
|
if (showProgress) {
|
||||||
|
const int progress = totalPages > 0 ? (currentPage + 1) * 100 / totalPages : 0;
|
||||||
|
const std::string progressStr =
|
||||||
|
std::to_string(currentPage + 1) + "/" + std::to_string(totalPages) + " " + std::to_string(progress) + "%";
|
||||||
|
progressTextWidth = renderer.getTextWidth(SMALL_FONT_ID, progressStr.c_str());
|
||||||
|
renderer.drawText(SMALL_FONT_ID, renderer.getScreenWidth() - orientedMarginRight - progressTextWidth, textY,
|
||||||
|
progressStr.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showBattery) {
|
||||||
|
ScreenComponents::drawBattery(renderer, orientedMarginLeft, textY);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showTitle) {
|
||||||
|
const int titleMarginLeft = 50 + 30 + orientedMarginLeft;
|
||||||
|
const int titleMarginRight = progressTextWidth + 30 + orientedMarginRight;
|
||||||
|
const int availableTextWidth = renderer.getScreenWidth() - titleMarginLeft - titleMarginRight;
|
||||||
|
|
||||||
|
std::string title = txt->getTitle();
|
||||||
|
int titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str());
|
||||||
|
while (titleWidth > availableTextWidth && title.length() > 11) {
|
||||||
|
title.replace(title.length() - 8, 8, "...");
|
||||||
|
titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
renderer.drawText(SMALL_FONT_ID, titleMarginLeft + (availableTextWidth - titleWidth) / 2, textY, title.c_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void TxtReaderActivity::saveProgress() const {
|
||||||
|
FsFile f;
|
||||||
|
if (SdMan.openFileForWrite("TRS", txt->getCachePath() + "/progress.bin", f)) {
|
||||||
|
uint8_t data[4];
|
||||||
|
data[0] = currentPage & 0xFF;
|
||||||
|
data[1] = (currentPage >> 8) & 0xFF;
|
||||||
|
data[2] = 0;
|
||||||
|
data[3] = 0;
|
||||||
|
f.write(data, 4);
|
||||||
|
f.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void TxtReaderActivity::loadProgress() {
|
||||||
|
FsFile f;
|
||||||
|
if (SdMan.openFileForRead("TRS", txt->getCachePath() + "/progress.bin", f)) {
|
||||||
|
uint8_t data[4];
|
||||||
|
if (f.read(data, 4) == 4) {
|
||||||
|
currentPage = data[0] + (data[1] << 8);
|
||||||
|
if (currentPage >= totalPages) {
|
||||||
|
currentPage = totalPages - 1;
|
||||||
|
}
|
||||||
|
if (currentPage < 0) {
|
||||||
|
currentPage = 0;
|
||||||
|
}
|
||||||
|
Serial.printf("[%lu] [TRS] Loaded progress: page %d/%d\n", millis(), currentPage, totalPages);
|
||||||
|
}
|
||||||
|
f.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool TxtReaderActivity::loadPageIndexCache() {
|
||||||
|
// Cache file format (using serialization module):
|
||||||
|
// - uint32_t: magic "TXTI"
|
||||||
|
// - uint8_t: cache version
|
||||||
|
// - uint32_t: file size (to validate cache)
|
||||||
|
// - int32_t: viewport width
|
||||||
|
// - int32_t: lines per page
|
||||||
|
// - int32_t: font ID (to invalidate cache on font change)
|
||||||
|
// - int32_t: screen margin (to invalidate cache on margin change)
|
||||||
|
// - uint8_t: paragraph alignment (to invalidate cache on alignment change)
|
||||||
|
// - uint32_t: total pages count
|
||||||
|
// - N * uint32_t: page offsets
|
||||||
|
|
||||||
|
std::string cachePath = txt->getCachePath() + "/index.bin";
|
||||||
|
FsFile f;
|
||||||
|
if (!SdMan.openFileForRead("TRS", cachePath, f)) {
|
||||||
|
Serial.printf("[%lu] [TRS] No page index cache found\n", millis());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read and validate header using serialization module
|
||||||
|
uint32_t magic;
|
||||||
|
serialization::readPod(f, magic);
|
||||||
|
if (magic != CACHE_MAGIC) {
|
||||||
|
Serial.printf("[%lu] [TRS] Cache magic mismatch, rebuilding\n", millis());
|
||||||
|
f.close();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t version;
|
||||||
|
serialization::readPod(f, version);
|
||||||
|
if (version != CACHE_VERSION) {
|
||||||
|
Serial.printf("[%lu] [TRS] Cache version mismatch (%d != %d), rebuilding\n", millis(), version, CACHE_VERSION);
|
||||||
|
f.close();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint32_t fileSize;
|
||||||
|
serialization::readPod(f, fileSize);
|
||||||
|
if (fileSize != txt->getFileSize()) {
|
||||||
|
Serial.printf("[%lu] [TRS] Cache file size mismatch, rebuilding\n", millis());
|
||||||
|
f.close();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int32_t cachedWidth;
|
||||||
|
serialization::readPod(f, cachedWidth);
|
||||||
|
if (cachedWidth != viewportWidth) {
|
||||||
|
Serial.printf("[%lu] [TRS] Cache viewport width mismatch, rebuilding\n", millis());
|
||||||
|
f.close();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int32_t cachedLines;
|
||||||
|
serialization::readPod(f, cachedLines);
|
||||||
|
if (cachedLines != linesPerPage) {
|
||||||
|
Serial.printf("[%lu] [TRS] Cache lines per page mismatch, rebuilding\n", millis());
|
||||||
|
f.close();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int32_t fontId;
|
||||||
|
serialization::readPod(f, fontId);
|
||||||
|
if (fontId != cachedFontId) {
|
||||||
|
Serial.printf("[%lu] [TRS] Cache font ID mismatch (%d != %d), rebuilding\n", millis(), fontId, cachedFontId);
|
||||||
|
f.close();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int32_t margin;
|
||||||
|
serialization::readPod(f, margin);
|
||||||
|
if (margin != cachedScreenMargin) {
|
||||||
|
Serial.printf("[%lu] [TRS] Cache screen margin mismatch, rebuilding\n", millis());
|
||||||
|
f.close();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t alignment;
|
||||||
|
serialization::readPod(f, alignment);
|
||||||
|
if (alignment != cachedParagraphAlignment) {
|
||||||
|
Serial.printf("[%lu] [TRS] Cache paragraph alignment mismatch, rebuilding\n", millis());
|
||||||
|
f.close();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint32_t numPages;
|
||||||
|
serialization::readPod(f, numPages);
|
||||||
|
|
||||||
|
// Read page offsets
|
||||||
|
pageOffsets.clear();
|
||||||
|
pageOffsets.reserve(numPages);
|
||||||
|
|
||||||
|
for (uint32_t i = 0; i < numPages; i++) {
|
||||||
|
uint32_t offset;
|
||||||
|
serialization::readPod(f, offset);
|
||||||
|
pageOffsets.push_back(offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
f.close();
|
||||||
|
totalPages = pageOffsets.size();
|
||||||
|
Serial.printf("[%lu] [TRS] Loaded page index cache: %d pages\n", millis(), totalPages);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void TxtReaderActivity::savePageIndexCache() const {
|
||||||
|
std::string cachePath = txt->getCachePath() + "/index.bin";
|
||||||
|
FsFile f;
|
||||||
|
if (!SdMan.openFileForWrite("TRS", cachePath, f)) {
|
||||||
|
Serial.printf("[%lu] [TRS] Failed to save page index cache\n", millis());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write header using serialization module
|
||||||
|
serialization::writePod(f, CACHE_MAGIC);
|
||||||
|
serialization::writePod(f, CACHE_VERSION);
|
||||||
|
serialization::writePod(f, static_cast<uint32_t>(txt->getFileSize()));
|
||||||
|
serialization::writePod(f, static_cast<int32_t>(viewportWidth));
|
||||||
|
serialization::writePod(f, static_cast<int32_t>(linesPerPage));
|
||||||
|
serialization::writePod(f, static_cast<int32_t>(cachedFontId));
|
||||||
|
serialization::writePod(f, static_cast<int32_t>(cachedScreenMargin));
|
||||||
|
serialization::writePod(f, cachedParagraphAlignment);
|
||||||
|
serialization::writePod(f, static_cast<uint32_t>(pageOffsets.size()));
|
||||||
|
|
||||||
|
// Write page offsets
|
||||||
|
for (size_t offset : pageOffsets) {
|
||||||
|
serialization::writePod(f, static_cast<uint32_t>(offset));
|
||||||
|
}
|
||||||
|
|
||||||
|
f.close();
|
||||||
|
Serial.printf("[%lu] [TRS] Saved page index cache: %d pages\n", millis(), totalPages);
|
||||||
|
}
|
||||||
60
src/activities/reader/TxtReaderActivity.h
Normal file
60
src/activities/reader/TxtReaderActivity.h
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <Txt.h>
|
||||||
|
#include <freertos/FreeRTOS.h>
|
||||||
|
#include <freertos/semphr.h>
|
||||||
|
#include <freertos/task.h>
|
||||||
|
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "CrossPointSettings.h"
|
||||||
|
#include "activities/ActivityWithSubactivity.h"
|
||||||
|
|
||||||
|
class TxtReaderActivity final : public ActivityWithSubactivity {
|
||||||
|
std::unique_ptr<Txt> txt;
|
||||||
|
TaskHandle_t displayTaskHandle = nullptr;
|
||||||
|
SemaphoreHandle_t renderingMutex = nullptr;
|
||||||
|
int currentPage = 0;
|
||||||
|
int totalPages = 1;
|
||||||
|
int pagesUntilFullRefresh = 0;
|
||||||
|
bool updateRequired = false;
|
||||||
|
const std::function<void()> onGoBack;
|
||||||
|
const std::function<void()> onGoHome;
|
||||||
|
|
||||||
|
// Streaming text reader - stores file offsets for each page
|
||||||
|
std::vector<size_t> pageOffsets; // File offset for start of each page
|
||||||
|
std::vector<std::string> currentPageLines;
|
||||||
|
int linesPerPage = 0;
|
||||||
|
int viewportWidth = 0;
|
||||||
|
bool initialized = false;
|
||||||
|
|
||||||
|
// Cached settings for cache validation (different fonts/margins require re-indexing)
|
||||||
|
int cachedFontId = 0;
|
||||||
|
int cachedScreenMargin = 0;
|
||||||
|
uint8_t cachedParagraphAlignment = CrossPointSettings::LEFT_ALIGN;
|
||||||
|
|
||||||
|
static void taskTrampoline(void* param);
|
||||||
|
[[noreturn]] void displayTaskLoop();
|
||||||
|
void renderScreen();
|
||||||
|
void renderPage();
|
||||||
|
void renderStatusBar(int orientedMarginRight, int orientedMarginBottom, int orientedMarginLeft) const;
|
||||||
|
|
||||||
|
void initializeReader();
|
||||||
|
bool loadPageAtOffset(size_t offset, std::vector<std::string>& outLines, size_t& nextOffset);
|
||||||
|
void buildPageIndex();
|
||||||
|
bool loadPageIndexCache();
|
||||||
|
void savePageIndexCache() const;
|
||||||
|
void saveProgress() const;
|
||||||
|
void loadProgress();
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit TxtReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::unique_ptr<Txt> txt,
|
||||||
|
const std::function<void()>& onGoBack, const std::function<void()>& onGoHome)
|
||||||
|
: ActivityWithSubactivity("TxtReader", renderer, mappedInput),
|
||||||
|
txt(std::move(txt)),
|
||||||
|
onGoBack(onGoBack),
|
||||||
|
onGoHome(onGoHome) {}
|
||||||
|
void onEnter() override;
|
||||||
|
void onExit() override;
|
||||||
|
void loop() override;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user