Merge branch 'daveallie:master' into feature/show-img-alt-text

This commit is contained in:
Jonas Diemer 2025-12-30 14:18:31 +01:00 committed by GitHub
commit 61b2622206
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 391 additions and 97 deletions

View File

@ -2,8 +2,7 @@
#include <Utf8.h>
inline int min(const int a, const int b) { return a < b ? a : b; }
inline int max(const int a, const int b) { return a < b ? b : a; }
#include <algorithm>
void EpdFont::getTextBounds(const char* string, const int startX, const int startY, int* minX, int* minY, int* maxX,
int* maxY) const {
@ -32,10 +31,10 @@ void EpdFont::getTextBounds(const char* string, const int startX, const int star
continue;
}
*minX = min(*minX, cursorX + glyph->left);
*maxX = max(*maxX, cursorX + glyph->left + glyph->width);
*minY = min(*minY, cursorY + glyph->top - glyph->height);
*maxY = max(*maxY, cursorY + glyph->top);
*minX = std::min(*minX, cursorX + glyph->left);
*maxX = std::max(*maxX, cursorX + glyph->left + glyph->width);
*minY = std::min(*minY, cursorY + glyph->top - glyph->height);
*maxY = std::max(*maxY, cursorY + glyph->top);
cursorX += glyph->advanceX;
}
}

View File

@ -74,6 +74,7 @@ bool Epub::parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata) {
bookMetadata.title = opfParser.title;
bookMetadata.author = opfParser.author;
bookMetadata.coverItemHref = opfParser.coverItemHref;
bookMetadata.textReferenceHref = opfParser.textReferenceHref;
if (!opfParser.tocNcxPath.empty()) {
tocNcxItem = opfParser.tocNcxPath;
@ -108,17 +109,20 @@ bool Epub::parseTocNcxFile() const {
if (!ncxParser.setup()) {
Serial.printf("[%lu] [EBP] Could not setup toc ncx parser\n", millis());
tempNcxFile.close();
return false;
}
const auto ncxBuffer = static_cast<uint8_t*>(malloc(1024));
if (!ncxBuffer) {
Serial.printf("[%lu] [EBP] Could not allocate memory for toc ncx parser\n", millis());
tempNcxFile.close();
return false;
}
while (tempNcxFile.available()) {
const auto readSize = tempNcxFile.read(ncxBuffer, 1024);
if (readSize == 0) break;
const auto processedSize = ncxParser.write(ncxBuffer, readSize);
if (processedSize != readSize) {
@ -138,7 +142,7 @@ bool Epub::parseTocNcxFile() const {
}
// load in the meta data for the epub file
bool Epub::load() {
bool Epub::load(const bool buildIfMissing) {
Serial.printf("[%lu] [EBP] Loading ePub: %s\n", millis(), filepath.c_str());
// Initialize spine/TOC cache
@ -150,6 +154,11 @@ bool Epub::load() {
return true;
}
// If we didn't load from cache above and we aren't allowed to build, fail now
if (!buildIfMissing) {
return false;
}
// Cache doesn't exist or is invalid, build it
Serial.printf("[%lu] [EBP] Cache not found, building spine/TOC cache\n", millis());
setupCacheDir();
@ -426,6 +435,35 @@ size_t Epub::getBookSize() const {
return getCumulativeSpineItemSize(getSpineItemsCount() - 1);
}
int Epub::getSpineIndexForTextReference() const {
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
Serial.printf("[%lu] [EBP] getSpineIndexForTextReference called but cache not loaded\n", millis());
return 0;
}
Serial.printf("[%lu] [ERS] Core Metadata: cover(%d)=%s, textReference(%d)=%s\n", millis(),
bookMetadataCache->coreMetadata.coverItemHref.size(),
bookMetadataCache->coreMetadata.coverItemHref.c_str(),
bookMetadataCache->coreMetadata.textReferenceHref.size(),
bookMetadataCache->coreMetadata.textReferenceHref.c_str());
if (bookMetadataCache->coreMetadata.textReferenceHref.empty()) {
// there was no textReference in epub, so we return 0 (the first chapter)
return 0;
}
// loop through spine items to get the correct index matching the text href
for (size_t i = 0; i < getSpineItemsCount(); i++) {
if (getSpineItem(i).href == bookMetadataCache->coreMetadata.textReferenceHref) {
Serial.printf("[%lu] [ERS] Text reference %s found at index %d\n", millis(),
bookMetadataCache->coreMetadata.textReferenceHref.c_str(), i);
return i;
}
}
// This should not happen, as we checked for empty textReferenceHref earlier
Serial.printf("[%lu] [EBP] Section not found for text reference\n", millis());
return 0;
}
// Calculate progress in book
uint8_t Epub::calculateProgress(const int currentSpineIndex, const float currentSpineRead) const {
const size_t bookSize = getBookSize();

View File

@ -34,7 +34,7 @@ class Epub {
}
~Epub() = default;
std::string& getBasePath() { return contentBasePath; }
bool load();
bool load(bool buildIfMissing = true);
bool clearCache() const;
void setupCacheDir() const;
const std::string& getCachePath() const;
@ -54,6 +54,7 @@ class Epub {
int getSpineIndexForTocIndex(int tocIndex) const;
int getTocIndexForSpineIndex(int spineIndex) const;
size_t getCumulativeSpineItemSize(int spineIndex) const;
int getSpineIndexForTextReference() const;
size_t getBookSize() const;
uint8_t calculateProgress(int currentSpineIndex, float currentSpineRead) const;

View File

@ -9,7 +9,7 @@
#include "FsHelpers.h"
namespace {
constexpr uint8_t BOOK_CACHE_VERSION = 2;
constexpr uint8_t BOOK_CACHE_VERSION = 3;
constexpr char bookBinFile[] = "/book.bin";
constexpr char tmpSpineBinFile[] = "/spine.bin.tmp";
constexpr char tmpTocBinFile[] = "/toc.bin.tmp";
@ -87,8 +87,8 @@ bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMeta
constexpr uint32_t headerASize =
sizeof(BOOK_CACHE_VERSION) + /* LUT Offset */ sizeof(uint32_t) + sizeof(spineCount) + sizeof(tocCount);
const uint32_t metadataSize =
metadata.title.size() + metadata.author.size() + metadata.coverItemHref.size() + sizeof(uint32_t) * 3;
const uint32_t metadataSize = metadata.title.size() + metadata.author.size() + metadata.coverItemHref.size() +
metadata.textReferenceHref.size() + sizeof(uint32_t) * 4;
const uint32_t lutSize = sizeof(uint32_t) * spineCount + sizeof(uint32_t) * tocCount;
const uint32_t lutOffset = headerASize + metadataSize;
@ -101,6 +101,7 @@ bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMeta
serialization::writeString(bookFile, metadata.title);
serialization::writeString(bookFile, metadata.author);
serialization::writeString(bookFile, metadata.coverItemHref);
serialization::writeString(bookFile, metadata.textReferenceHref);
// Loop through spine entries, writing LUT positions
spineFile.seek(0);
@ -289,6 +290,7 @@ bool BookMetadataCache::load() {
serialization::readString(bookFile, coreMetadata.title);
serialization::readString(bookFile, coreMetadata.author);
serialization::readString(bookFile, coreMetadata.coverItemHref);
serialization::readString(bookFile, coreMetadata.textReferenceHref);
loaded = true;
Serial.printf("[%lu] [BMC] Loaded cache data: %d spine, %d TOC entries\n", millis(), spineCount, tocCount);

View File

@ -10,6 +10,7 @@ class BookMetadataCache {
std::string title;
std::string author;
std::string coverItemHref;
std::string textReferenceHref;
};
struct SpineEntry {

View File

@ -57,7 +57,6 @@ void ChapterHtmlSlimParser::startNewTextBlock(const TextBlock::BLOCK_STYLE style
void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* name, const XML_Char** atts) {
auto* self = static_cast<ChapterHtmlSlimParser*>(userData);
(void)atts;
// Middle of skip
if (self->skipUntilDepth < self->depth) {
@ -111,7 +110,7 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
if (matches(name, HEADER_TAGS, NUM_HEADER_TAGS)) {
self->startNewTextBlock(TextBlock::CENTER_ALIGN);
self->boldUntilDepth = min(self->boldUntilDepth, self->depth);
self->boldUntilDepth = std::min(self->boldUntilDepth, self->depth);
} else if (matches(name, BLOCK_TAGS, NUM_BLOCK_TAGS)) {
if (strcmp(name, "br") == 0) {
self->startNewTextBlock(self->currentTextBlock->getStyle());
@ -119,9 +118,9 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
self->startNewTextBlock(TextBlock::JUSTIFIED);
}
} else if (matches(name, BOLD_TAGS, NUM_BOLD_TAGS)) {
self->boldUntilDepth = min(self->boldUntilDepth, self->depth);
self->boldUntilDepth = std::min(self->boldUntilDepth, self->depth);
} else if (matches(name, ITALIC_TAGS, NUM_ITALIC_TAGS)) {
self->italicUntilDepth = min(self->italicUntilDepth, self->depth);
self->italicUntilDepth = std::min(self->italicUntilDepth, self->depth);
}
self->depth += 1;
@ -180,7 +179,6 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char
void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* name) {
auto* self = static_cast<ChapterHtmlSlimParser*>(userData);
(void)name;
if (self->partWordBufferIndex > 0) {
// Only flush out part word buffer if we're closing a block tag or are at the top of the HTML file.
@ -263,9 +261,9 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
return false;
}
const size_t len = file.read(static_cast<uint8_t*>(buf), 1024);
const size_t len = file.read(buf, 1024);
if (len == 0) {
if (len == 0 && file.available() > 0) {
Serial.printf("[%lu] [EHP] File read error\n", millis());
XML_StopParser(parser, XML_FALSE); // Stop any pending processing
XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks

View File

@ -127,6 +127,18 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name
return;
}
if (self->state == IN_PACKAGE && (strcmp(name, "guide") == 0 || strcmp(name, "opf:guide") == 0)) {
self->state = IN_GUIDE;
// TODO Remove print
Serial.printf("[%lu] [COF] Entering guide state.\n", millis());
if (!SdMan.openFileForRead("COF", self->cachePath + itemCacheFile, self->tempItemStore)) {
Serial.printf(
"[%lu] [COF] Couldn't open temp items file for reading. This is probably going to be a fatal error.\n",
millis());
}
return;
}
if (self->state == IN_METADATA && (strcmp(name, "meta") == 0 || strcmp(name, "opf:meta") == 0)) {
bool isCover = false;
std::string coverItemId;
@ -205,6 +217,29 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name
return;
}
}
// parse the guide
if (self->state == IN_GUIDE && (strcmp(name, "reference") == 0 || strcmp(name, "opf:reference") == 0)) {
std::string type;
std::string textHref;
for (int i = 0; atts[i]; i += 2) {
if (strcmp(atts[i], "type") == 0) {
type = atts[i + 1];
if (type == "text" || type == "start") {
continue;
} else {
Serial.printf("[%lu] [COF] Skipping non-text reference in guide: %s\n", millis(), type.c_str());
break;
}
} else if (strcmp(atts[i], "href") == 0) {
textHref = self->baseContentPath + atts[i + 1];
}
}
if ((type == "text" || (type == "start" && !self->textReferenceHref.empty())) && (textHref.length() > 0)) {
Serial.printf("[%lu] [COF] Found %s reference in guide: %s.\n", millis(), type.c_str(), textHref.c_str());
self->textReferenceHref = textHref;
}
return;
}
}
void XMLCALL ContentOpfParser::characterData(void* userData, const XML_Char* s, const int len) {
@ -231,6 +266,12 @@ void XMLCALL ContentOpfParser::endElement(void* userData, const XML_Char* name)
return;
}
if (self->state == IN_GUIDE && (strcmp(name, "guide") == 0 || strcmp(name, "opf:guide") == 0)) {
self->state = IN_PACKAGE;
self->tempItemStore.close();
return;
}
if (self->state == IN_MANIFEST && (strcmp(name, "manifest") == 0 || strcmp(name, "opf:manifest") == 0)) {
self->state = IN_PACKAGE;
self->tempItemStore.close();

View File

@ -15,6 +15,7 @@ class ContentOpfParser final : public Print {
IN_BOOK_AUTHOR,
IN_MANIFEST,
IN_SPINE,
IN_GUIDE,
};
const std::string& cachePath;
@ -35,6 +36,7 @@ class ContentOpfParser final : public Print {
std::string author;
std::string tocNcxPath;
std::string coverItemHref;
std::string textReferenceHref;
explicit ContentOpfParser(const std::string& cachePath, const std::string& baseContentPath, const size_t xmlSize,
BookMetadataCache* cache)

View File

@ -37,7 +37,7 @@ class GfxRenderer {
public:
explicit GfxRenderer(EInkDisplay& einkDisplay) : einkDisplay(einkDisplay), renderMode(BW), orientation(Portrait) {}
~GfxRenderer() = default;
~GfxRenderer() { freeBwBufferChunks(); }
static constexpr int VIEWABLE_MARGIN_TOP = 9;
static constexpr int VIEWABLE_MARGIN_RIGHT = 3;

View File

@ -1,5 +1,5 @@
[platformio]
crosspoint_version = 0.10.0
crosspoint_version = 0.11.0
default_envs = default
[base]

41
src/ScreenComponents.cpp Normal file
View File

@ -0,0 +1,41 @@
#include "ScreenComponents.h"
#include <GfxRenderer.h>
#include <string>
#include "Battery.h"
#include "fontIds.h"
void ScreenComponents::drawBattery(const GfxRenderer& renderer, const int left, const int top) {
// Left aligned battery icon and percentage
const uint16_t percentage = battery.readPercentage();
const auto percentageText = std::to_string(percentage) + "%";
renderer.drawText(SMALL_FONT_ID, left + 20, top, percentageText.c_str());
// 1 column on left, 2 columns on right, 5 columns of battery body
constexpr int batteryWidth = 15;
constexpr int batteryHeight = 10;
const int x = left;
const int y = top + 7;
// Top line
renderer.drawLine(x, y, x + batteryWidth - 4, y);
// Bottom line
renderer.drawLine(x, y + batteryHeight - 1, x + batteryWidth - 4, y + batteryHeight - 1);
// Left line
renderer.drawLine(x, y, x, y + batteryHeight - 1);
// Battery end
renderer.drawLine(x + batteryWidth - 4, y, x + batteryWidth - 4, y + batteryHeight - 1);
renderer.drawLine(x + batteryWidth - 3, y + 2, x + batteryWidth - 1, y + 2);
renderer.drawLine(x + batteryWidth - 3, y + batteryHeight - 3, x + batteryWidth - 1, y + batteryHeight - 3);
renderer.drawLine(x + batteryWidth - 1, y + 2, x + batteryWidth - 1, y + batteryHeight - 3);
// The +1 is to round up, so that we always fill at least one pixel
int filledWidth = percentage * (batteryWidth - 5) / 100 + 1;
if (filledWidth > batteryWidth - 5) {
filledWidth = batteryWidth - 5; // Ensure we don't overflow
}
renderer.fillRect(x + 1, y + 1, filledWidth, batteryHeight - 2);
}

8
src/ScreenComponents.h Normal file
View File

@ -0,0 +1,8 @@
#pragma once
class GfxRenderer;
class ScreenComponents {
public:
static void drawBattery(const GfxRenderer& renderer, int left, int top);
};

View File

@ -1,10 +1,12 @@
#include "HomeActivity.h"
#include <Epub.h>
#include <GfxRenderer.h>
#include <SDCardManager.h>
#include "CrossPointState.h"
#include "MappedInputManager.h"
#include "ScreenComponents.h"
#include "fontIds.h"
void HomeActivity::taskTrampoline(void* param) {
@ -22,6 +24,33 @@ void HomeActivity::onEnter() {
// Check if we have a book to continue reading
hasContinueReading = !APP_STATE.openEpubPath.empty() && SdMan.exists(APP_STATE.openEpubPath.c_str());
if (hasContinueReading) {
// Extract filename from path for display
lastBookTitle = APP_STATE.openEpubPath;
const size_t lastSlash = lastBookTitle.find_last_of('/');
if (lastSlash != std::string::npos) {
lastBookTitle = lastBookTitle.substr(lastSlash + 1);
}
const std::string ext4 = lastBookTitle.length() >= 4 ? lastBookTitle.substr(lastBookTitle.length() - 4) : "";
const std::string ext5 = lastBookTitle.length() >= 5 ? lastBookTitle.substr(lastBookTitle.length() - 5) : "";
// If epub, try to load the metadata for title/author
if (ext5 == ".epub") {
Epub epub(APP_STATE.openEpubPath, "/.crosspoint");
epub.load(false);
if (!epub.getTitle().empty()) {
lastBookTitle = std::string(epub.getTitle());
}
if (!epub.getAuthor().empty()) {
lastBookAuthor = std::string(epub.getAuthor());
}
} else if (ext5 == ".xtch") {
lastBookTitle.resize(lastBookTitle.length() - 5);
} else if (ext4 == ".xtc") {
lastBookTitle.resize(lastBookTitle.length() - 4);
}
}
selectorIndex = 0;
// Trigger first update
@ -103,52 +132,191 @@ void HomeActivity::render() const {
renderer.clearScreen();
const auto pageWidth = renderer.getScreenWidth();
renderer.drawCenteredText(UI_12_FONT_ID, 15, "CrossPoint Reader", true, BOLD);
const auto pageHeight = renderer.getScreenHeight();
// Draw selection
renderer.fillRect(0, 60 + selectorIndex * 30 - 2, pageWidth - 1, 30);
constexpr int margin = 20;
constexpr int bottomMargin = 60;
int menuY = 60;
int menuIndex = 0;
// --- Top "book" card for the current title (selectorIndex == 0) ---
const int bookWidth = pageWidth / 2;
const int bookHeight = pageHeight / 2;
const int bookX = (pageWidth - bookWidth) / 2;
constexpr int bookY = 30;
const bool bookSelected = hasContinueReading && selectorIndex == 0;
if (hasContinueReading) {
// Extract filename from path for display
std::string bookName = APP_STATE.openEpubPath;
const size_t lastSlash = bookName.find_last_of('/');
if (lastSlash != std::string::npos) {
bookName = bookName.substr(lastSlash + 1);
}
// Remove .epub extension
if (bookName.length() > 5 && bookName.substr(bookName.length() - 5) == ".epub") {
bookName.resize(bookName.length() - 5);
// Draw book card regardless, fill with message based on `hasContinueReading`
{
if (bookSelected) {
renderer.fillRect(bookX, bookY, bookWidth, bookHeight);
} else {
renderer.drawRect(bookX, bookY, bookWidth, bookHeight);
}
// Truncate if too long
std::string continueLabel = "Continue: " + bookName;
int itemWidth = renderer.getTextWidth(UI_10_FONT_ID, continueLabel.c_str());
while (itemWidth > renderer.getScreenWidth() - 40 && continueLabel.length() > 8) {
continueLabel.replace(continueLabel.length() - 5, 5, "...");
itemWidth = renderer.getTextWidth(UI_10_FONT_ID, continueLabel.c_str());
Serial.printf("[%lu] [HOM] width: %lu, pageWidth: %lu\n", millis(), itemWidth, pageWidth);
}
// Bookmark icon in the top-right corner of the card
const int bookmarkWidth = bookWidth / 8;
const int bookmarkHeight = bookHeight / 5;
const int bookmarkX = bookX + bookWidth - bookmarkWidth - 8;
constexpr int bookmarkY = bookY + 1;
renderer.drawText(UI_10_FONT_ID, 20, menuY, continueLabel.c_str(), selectorIndex != menuIndex);
menuY += 30;
menuIndex++;
// Main bookmark body (solid)
renderer.fillRect(bookmarkX, bookmarkY, bookmarkWidth, bookmarkHeight, !bookSelected);
// Carve out an inverted triangle notch at the bottom center to create angled points
const int notchHeight = bookmarkHeight / 2; // depth of the notch
for (int i = 0; i < notchHeight; ++i) {
const int y = bookmarkY + bookmarkHeight - 1 - i;
const int xStart = bookmarkX + i;
const int width = bookmarkWidth - 2 * i;
if (width <= 0) {
break;
}
// Draw a horizontal strip in the opposite color to "cut" the notch
renderer.fillRect(xStart, y, width, 1, bookSelected);
}
}
renderer.drawText(UI_10_FONT_ID, 20, menuY, "Browse", selectorIndex != menuIndex);
menuY += 30;
menuIndex++;
if (hasContinueReading) {
// Split into words (avoid stringstream to keep this light on the MCU)
std::vector<std::string> words;
words.reserve(8);
size_t pos = 0;
while (pos < lastBookTitle.size()) {
while (pos < lastBookTitle.size() && lastBookTitle[pos] == ' ') {
++pos;
}
if (pos >= lastBookTitle.size()) {
break;
}
const size_t start = pos;
while (pos < lastBookTitle.size() && lastBookTitle[pos] != ' ') {
++pos;
}
words.emplace_back(lastBookTitle.substr(start, pos - start));
}
renderer.drawText(UI_10_FONT_ID, 20, menuY, "File transfer", selectorIndex != menuIndex);
menuY += 30;
menuIndex++;
std::vector<std::string> lines;
std::string currentLine;
// Extra padding inside the card so text doesn't hug the border
const int maxLineWidth = bookWidth - 40;
const int spaceWidth = renderer.getSpaceWidth(UI_12_FONT_ID);
renderer.drawText(UI_10_FONT_ID, 20, menuY, "Settings", selectorIndex != menuIndex);
for (auto& i : words) {
// If we just hit the line limit (3), stop processing words
if (lines.size() >= 3) {
// Limit to 3 lines
// Still have words left, so add ellipsis to last line
lines.back().append("...");
const auto labels = mappedInput.mapLabels("Back", "Confirm", "Left", "Right");
while (!lines.back().empty() && renderer.getTextWidth(UI_12_FONT_ID, lines.back().c_str()) > maxLineWidth) {
lines.back().resize(lines.back().size() - 5);
lines.back().append("...");
}
break;
}
int wordWidth = renderer.getTextWidth(UI_12_FONT_ID, i.c_str());
while (wordWidth > maxLineWidth && i.size() > 5) {
// Word itself is too long, trim it
i.resize(i.size() - 5);
i.append("...");
wordWidth = renderer.getTextWidth(UI_12_FONT_ID, i.c_str());
}
int newLineWidth = renderer.getTextWidth(UI_12_FONT_ID, currentLine.c_str());
if (newLineWidth > 0) {
newLineWidth += spaceWidth;
}
newLineWidth += wordWidth;
if (newLineWidth > maxLineWidth && !currentLine.empty()) {
// New line too long, push old line
lines.push_back(currentLine);
currentLine = i;
} else {
currentLine.append(" ").append(i);
}
}
// If lower than the line limit, push remaining words
if (!currentLine.empty() && lines.size() < 3) {
lines.push_back(currentLine);
}
// Book title text
int totalTextHeight = renderer.getLineHeight(UI_12_FONT_ID) * static_cast<int>(lines.size());
if (!lastBookAuthor.empty()) {
totalTextHeight += renderer.getLineHeight(UI_10_FONT_ID) * 3 / 2;
}
// Vertically center the title block within the card
int titleYStart = bookY + (bookHeight - totalTextHeight) / 2;
for (const auto& line : lines) {
renderer.drawCenteredText(UI_12_FONT_ID, titleYStart, line.c_str(), !bookSelected);
titleYStart += renderer.getLineHeight(UI_12_FONT_ID);
}
if (!lastBookAuthor.empty()) {
titleYStart += renderer.getLineHeight(UI_10_FONT_ID) / 2;
std::string trimmedAuthor = lastBookAuthor;
// Trim author if too long
while (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) > maxLineWidth && !trimmedAuthor.empty()) {
trimmedAuthor.resize(trimmedAuthor.size() - 5);
trimmedAuthor.append("...");
}
renderer.drawCenteredText(UI_10_FONT_ID, titleYStart, trimmedAuthor.c_str(), !bookSelected);
}
renderer.drawCenteredText(UI_10_FONT_ID, bookY + bookHeight - renderer.getLineHeight(UI_10_FONT_ID) * 3 / 2,
"Continue Reading", !bookSelected);
} else {
// No book to continue reading
const int y =
bookY + (bookHeight - renderer.getLineHeight(UI_12_FONT_ID) - renderer.getLineHeight(UI_10_FONT_ID)) / 2;
renderer.drawCenteredText(UI_12_FONT_ID, y, "No open book");
renderer.drawCenteredText(UI_10_FONT_ID, y + renderer.getLineHeight(UI_12_FONT_ID), "Start reading below");
}
// --- Bottom menu tiles (indices 1-3) ---
const int menuTileWidth = pageWidth - 2 * margin;
constexpr int menuTileHeight = 50;
constexpr int menuSpacing = 10;
constexpr int totalMenuHeight = 3 * menuTileHeight + 2 * menuSpacing;
int menuStartY = bookY + bookHeight + 20;
// Ensure we don't collide with the bottom button legend
const int maxMenuStartY = pageHeight - bottomMargin - totalMenuHeight - margin;
if (menuStartY > maxMenuStartY) {
menuStartY = maxMenuStartY;
}
for (int i = 0; i < 3; ++i) {
constexpr const char* items[3] = {"Browse files", "File transfer", "Settings"};
const int overallIndex = i + (getMenuItemCount() - 3);
constexpr int tileX = margin;
const int tileY = menuStartY + i * (menuTileHeight + menuSpacing);
const bool selected = selectorIndex == overallIndex;
if (selected) {
renderer.fillRect(tileX, tileY, menuTileWidth, menuTileHeight);
} else {
renderer.drawRect(tileX, tileY, menuTileWidth, menuTileHeight);
}
const char* label = items[i];
const int textWidth = renderer.getTextWidth(UI_10_FONT_ID, label);
const int textX = tileX + (menuTileWidth - textWidth) / 2;
const int lineHeight = renderer.getLineHeight(UI_10_FONT_ID);
const int textY = tileY + (menuTileHeight - lineHeight) / 2; // vertically centered assuming y is top of text
// Invert text when the tile is selected, to contrast with the filled background
renderer.drawText(UI_10_FONT_ID, textX, textY, label, !selected);
}
const auto labels = mappedInput.mapLabels("", "Confirm", "Up", "Down");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
ScreenComponents::drawBattery(renderer, 20, pageHeight - 30);
renderer.displayBuffer();
}

View File

@ -13,6 +13,8 @@ class HomeActivity final : public Activity {
int selectorIndex = 0;
bool updateRequired = false;
bool hasContinueReading = false;
std::string lastBookTitle;
std::string lastBookAuthor;
const std::function<void()> onContinueReading;
const std::function<void()> onReaderOpen;
const std::function<void()> onSettingsOpen;

View File

@ -5,11 +5,11 @@
#include <GfxRenderer.h>
#include <SDCardManager.h>
#include "Battery.h"
#include "CrossPointSettings.h"
#include "CrossPointState.h"
#include "EpubReaderChapterSelectionActivity.h"
#include "MappedInputManager.h"
#include "ScreenComponents.h"
#include "fontIds.h"
namespace {
@ -65,6 +65,16 @@ void EpubReaderActivity::onEnter() {
}
f.close();
}
// We may want a better condition to detect if we are opening for the first time.
// This will trigger if the book is re-opened at Chapter 0.
if (currentSpineIndex == 0) {
int textSpineIndex = epub->getSpineIndexForTextReference();
if (textSpineIndex != 0) {
currentSpineIndex = textSpineIndex;
Serial.printf("[%lu] [ERS] Opened for first time, navigating to text reference at index %d\n", millis(),
textSpineIndex);
}
}
// Save current epub as last opened epub
APP_STATE.openEpubPath = epub->getPath();
@ -412,7 +422,6 @@ void EpubReaderActivity::renderStatusBar(const int orientedMarginRight, const in
// Position status bar near the bottom of the logical screen, regardless of orientation
const auto screenHeight = renderer.getScreenHeight();
const auto textY = screenHeight - orientedMarginBottom - 2;
int percentageTextWidth = 0;
int progressTextWidth = 0;
if (showProgress) {
@ -429,42 +438,13 @@ void EpubReaderActivity::renderStatusBar(const int orientedMarginRight, const in
}
if (showBattery) {
// Left aligned battery icon and percentage
const uint16_t percentage = battery.readPercentage();
const auto percentageText = std::to_string(percentage) + "%";
percentageTextWidth = renderer.getTextWidth(SMALL_FONT_ID, percentageText.c_str());
renderer.drawText(SMALL_FONT_ID, 20 + orientedMarginLeft, textY, percentageText.c_str());
// 1 column on left, 2 columns on right, 5 columns of battery body
constexpr int batteryWidth = 15;
constexpr int batteryHeight = 10;
const int x = orientedMarginLeft;
const int y = screenHeight - orientedMarginBottom + 5;
// Top line
renderer.drawLine(x, y, x + batteryWidth - 4, y);
// Bottom line
renderer.drawLine(x, y + batteryHeight - 1, x + batteryWidth - 4, y + batteryHeight - 1);
// Left line
renderer.drawLine(x, y, x, y + batteryHeight - 1);
// Battery end
renderer.drawLine(x + batteryWidth - 4, y, x + batteryWidth - 4, y + batteryHeight - 1);
renderer.drawLine(x + batteryWidth - 3, y + 2, x + batteryWidth - 1, y + 2);
renderer.drawLine(x + batteryWidth - 3, y + batteryHeight - 3, x + batteryWidth - 1, y + batteryHeight - 3);
renderer.drawLine(x + batteryWidth - 1, y + 2, x + batteryWidth - 1, y + batteryHeight - 3);
// The +1 is to round up, so that we always fill at least one pixel
int filledWidth = percentage * (batteryWidth - 5) / 100 + 1;
if (filledWidth > batteryWidth - 5) {
filledWidth = batteryWidth - 5; // Ensure we don't overflow
}
renderer.fillRect(x + 1, y + 1, filledWidth, batteryHeight - 2);
ScreenComponents::drawBattery(renderer, orientedMarginLeft, textY);
}
if (showChapterTitle) {
// Centered chatper title text
// Page width minus existing content with 30px padding on each side
const int titleMarginLeft = 20 + percentageTextWidth + 30 + orientedMarginLeft;
const int titleMarginLeft = 50 + 30 + orientedMarginLeft; // 50px for battery
const int titleMarginRight = progressTextWidth + 30 + orientedMarginRight;
const int availableTextWidth = renderer.getScreenWidth() - titleMarginLeft - titleMarginRight;
const int tocIndex = epub->getTocIndexForSpineIndex(currentSpineIndex);

View File

@ -50,6 +50,14 @@ void CrossPointWebServer::begin() {
Serial.printf("[%lu] [WEB] Creating web server on port %d...\n", millis(), port);
server.reset(new WebServer(port));
// Disable WiFi sleep to improve responsiveness and prevent 'unreachable' errors.
// This is critical for reliable web server operation on ESP32.
WiFi.setSleep(false);
// Note: WebServer class doesn't have setNoDelay() in the standard ESP32 library.
// We rely on disabling WiFi sleep for responsiveness.
Serial.printf("[%lu] [WEB] [MEM] Free heap after WebServer allocation: %d bytes\n", millis(), ESP.getFreeHeap());
if (!server) {
@ -157,15 +165,16 @@ void CrossPointWebServer::handleStatus() const {
// Get correct IP based on AP vs STA mode
const String ipAddr = apMode ? WiFi.softAPIP().toString() : WiFi.localIP().toString();
String json = "{";
json += "\"version\":\"" + String(CROSSPOINT_VERSION) + "\",";
json += "\"ip\":\"" + ipAddr + "\",";
json += "\"mode\":\"" + String(apMode ? "AP" : "STA") + "\",";
json += "\"rssi\":" + String(apMode ? 0 : WiFi.RSSI()) + ","; // RSSI not applicable in AP mode
json += "\"freeHeap\":" + String(ESP.getFreeHeap()) + ",";
json += "\"uptime\":" + String(millis() / 1000);
json += "}";
JsonDocument doc;
doc["version"] = CROSSPOINT_VERSION;
doc["ip"] = ipAddr;
doc["mode"] = apMode ? "AP" : "STA";
doc["rssi"] = apMode ? 0 : WiFi.RSSI();
doc["freeHeap"] = ESP.getFreeHeap();
doc["uptime"] = millis() / 1000;
String json;
serializeJson(doc, json);
server->send(200, "application/json", json);
}
@ -220,6 +229,7 @@ void CrossPointWebServer::scanFiles(const char* path, const std::function<void(F
}
file.close();
yield(); // Yield to allow WiFi and other tasks to process during long scans
file = root.openNextFile();
}
root.close();
@ -254,12 +264,15 @@ void CrossPointWebServer::handleFileListData() const {
char output[512];
constexpr size_t outputSize = sizeof(output);
bool seenFirst = false;
scanFiles(currentPath.c_str(), [this, &output, seenFirst](const FileInfo& info) mutable {
JsonDocument doc;
JsonDocument doc;
scanFiles(currentPath.c_str(), [this, &output, &doc, seenFirst](const FileInfo& info) mutable {
doc.clear();
doc["name"] = info.name;
doc["size"] = info.size;
doc["isDirectory"] = info.isDirectory;
doc["isEpub"] = info.isEpub;
const size_t written = serializeJson(doc, output, outputSize);
if (written >= outputSize) {
// JSON output truncated; skip this entry to avoid sending malformed JSON