fix: prevent Serial.printf from blocking when USB disconnected
Some checks failed
CI / build (push) Failing after 2m16s

On ESP32-C3 with USB CDC, Serial.printf() blocks indefinitely when USB
is not connected. This caused device freezes when booted without USB.

Solution: Call Serial.setTxTimeoutMs(0) after Serial.begin() to make
all Serial output non-blocking.

Also added if (Serial) guards to high-traffic logging paths in
EpubReaderActivity as belt-and-suspenders protection.

Includes documentation of the debugging process and Serial call inventory.

Also applies clang-format to fix pre-existing formatting issues.
This commit is contained in:
cottongin 2026-01-28 15:57:31 -05:00
parent f3075002c1
commit 8fa01bc83a
No known key found for this signature in database
GPG Key ID: 0ECC91FE4655C262
57 changed files with 981 additions and 621 deletions

1
.gitignore vendored
View File

@ -11,6 +11,7 @@ build
**/__pycache__/
test/epubs/
CrossPoint-ef.md
Serial_print.code-search
# Gitea Actions runner config (contains credentials)
.runner

View File

@ -0,0 +1,70 @@
# Serial.printf Calls Without `if (Serial)` Guards
**Date:** 2026-01-28
**Status:** Informational (not blocking issues)
## Summary
The codebase contains **408 Serial print calls** across 27 files in `src/`. Of these, only **16 calls** (in 2 files) have explicit `if (Serial)` guards.
**This is not a problem** because `Serial.setTxTimeoutMs(0)` is called in `setup()` before any activity code runs, making all Serial output non-blocking globally.
## Protection Mechanism
In `src/main.cpp` (lines 467-468):
```cpp
Serial.begin(115200);
Serial.setTxTimeoutMs(0); // Non-blocking TX - critical for USB disconnect handling
```
This ensures that even without `if (Serial)` guards, Serial.printf calls will return immediately when USB is disconnected instead of blocking indefinitely.
## Files with `if (Serial)` Guards (16 calls)
| File | Protected Calls |
|------|-----------------|
| `src/activities/reader/EpubReaderActivity.cpp` | 15 |
| `src/main.cpp` | 1 |
## Files Without Guards (392 calls)
These calls are protected by `Serial.setTxTimeoutMs(0)` but don't have explicit guards:
| File | Unguarded Calls |
|------|-----------------|
| `src/network/CrossPointWebServer.cpp` | 106 |
| `src/activities/network/CrossPointWebServerActivity.cpp` | 49 |
| `src/activities/boot_sleep/SleepActivity.cpp` | 33 |
| `src/BookManager.cpp` | 25 |
| `src/activities/reader/TxtReaderActivity.cpp` | 20 |
| `src/activities/home/HomeActivity.cpp` | 16 |
| `src/network/OtaUpdater.cpp` | 16 |
| `src/util/Md5Utils.cpp` | 15 |
| `src/main.cpp` | 13 (plus 1 guarded) |
| `src/WifiCredentialStore.cpp` | 12 |
| `src/network/HttpDownloader.cpp` | 12 |
| `src/BookListStore.cpp` | 11 |
| `src/activities/network/WifiSelectionActivity.cpp` | 11 |
| `src/activities/settings/OtaUpdateActivity.cpp` | 9 |
| `src/activities/browser/OpdsBookBrowserActivity.cpp` | 9 |
| `src/activities/settings/ClearCacheActivity.cpp` | 7 |
| `src/BookmarkStore.cpp` | 6 |
| `src/RecentBooksStore.cpp` | 5 |
| `src/activities/reader/ReaderActivity.cpp` | 4 |
| `src/activities/Activity.h` | 3 |
| `src/CrossPointSettings.cpp` | 3 |
| `src/activities/network/CalibreConnectActivity.cpp` | 2 |
| `src/activities/home/ListViewActivity.cpp` | 2 |
| `src/activities/home/MyLibraryActivity.cpp` | 1 |
| `src/activities/dictionary/DictionarySearchActivity.cpp` | 1 |
| `src/CrossPointState.cpp` | 1 |
## Recommendation
No immediate action required. The global `Serial.setTxTimeoutMs(0)` protection is sufficient.
If desired, `if (Serial)` guards could be added to high-frequency logging paths for minor performance optimization (skipping format string processing), but this is low priority.
## Note on open-x4-sdk
The `open-x4-sdk` submodule also contains Serial calls (in `EInkDisplay.cpp`, `SDCardManager.cpp`). These are also protected by the global timeout setting since `Serial.begin()` and `setTxTimeoutMs()` are called before any SDK code executes.

View File

@ -0,0 +1,125 @@
# Serial Blocking Debug Session Summary
**Date:** 2026-01-28
**Issue:** Device freezes when booted without USB connected
**Resolution:** `Serial.setTxTimeoutMs(0)` - make Serial TX non-blocking
## Problem Description
During release preparation for ef-0.15.9, the device was discovered to freeze completely when:
1. Unplugged from USB
2. Powered on via power button
3. Book page displays, then device becomes unresponsive
4. No button presses register
The device worked perfectly when USB was connected.
## Investigation Process
### Initial Hypotheses Tested
Multiple hypotheses were systematically investigated:
1. **Hypothesis A-D:** Display/rendering mutex issues
- Added mutex logging to SD card
- Mutex operations completed successfully
- Ruled out as root cause
2. **Hypothesis E:** FreeRTOS task creation issues
- Task created and ran successfully
- First render completed normally
- Ruled out
3. **Hypothesis F-G:** Main loop execution
- Added loop counter logging to SD card
- **Key finding:** Main loop never started logging
- Setup() completed but loop() never executed meaningful work
4. **Hypothesis H-J:** Various timing and initialization issues
- Tested different delays and initialization orders
- No improvement
### Root Cause Discovery
The breakthrough came from analyzing the boot sequence:
1. `setup()` completes successfully
2. `EpubReaderActivity::onEnter()` runs and calls `Serial.printf()` to log progress
3. **Device hangs at Serial.printf() call**
On ESP32-C3 with USB CDC (USB serial), `Serial.printf()` blocks indefinitely waiting for the TX buffer to drain when USB is not connected. The default behavior expects a host to read the data.
### Evidence
- When USB connected: `Serial.printf()` returns immediately (data sent to host)
- When USB disconnected: `Serial.printf()` blocks forever waiting for TX buffer space
- The hang occurred specifically in `EpubReaderActivity.cpp` during progress logging
## Solution
### Primary Fix
Configure Serial to be non-blocking in `src/main.cpp`:
```cpp
// Always initialize Serial but make it non-blocking
Serial.begin(115200);
Serial.setTxTimeoutMs(0); // Non-blocking TX - critical for USB disconnect handling
```
`Serial.setTxTimeoutMs(0)` tells the ESP32 Arduino core to return immediately from Serial write operations if the buffer is full, rather than blocking.
### Secondary Protection (Belt and Suspenders)
Added `if (Serial)` guards to high-traffic Serial calls in `EpubReaderActivity.cpp`:
```cpp
if (Serial) Serial.printf("[%lu] [ERS] Loaded progress...\n", millis());
```
This provides an additional check before attempting to print, though it's not strictly necessary with the timeout set to 0.
## Files Changed
| File | Change |
|------|--------|
| `src/main.cpp` | Added `Serial.setTxTimeoutMs(0)` after `Serial.begin()` |
| `src/main.cpp` | Added `if (Serial)` guard to auto-sleep log |
| `src/main.cpp` | Added `if (Serial)` guard to max loop duration log |
| `src/activities/reader/EpubReaderActivity.cpp` | Added 16 `if (Serial)` guards |
## Verification
After applying the fix:
1. Device boots successfully when unplugged from USB
2. Book pages render correctly
3. Button presses register normally
4. Sleep/wake cycle works
5. No functionality lost when USB is connected
## Lessons Learned
1. **ESP32-C3 USB CDC behavior:** Serial output can block indefinitely without a connected host
2. **Always set non-blocking:** `Serial.setTxTimeoutMs(0)` should be standard for battery-powered devices
3. **Debug logging location matters:** When debugging hangs, SD card logging proved essential since Serial was the problem
4. **Systematic hypothesis testing:** Ruled out many red herrings (mutex, task, rendering) before finding the true cause
## Technical Details
### Why This Affects ESP32-C3 Specifically
The ESP32-C3 uses native USB CDC for serial communication (no external USB-UART chip). The Arduino core's default behavior is to wait for TX buffer space, which requires an active USB host connection.
### Alternative Approaches Considered
1. **Only initialize Serial when USB connected:** Partially implemented, but insufficient because USB can be disconnected after boot
2. **Add `if (Serial)` guards everywhere:** Too invasive (400+ calls)
3. **Disable Serial entirely:** Would lose debug output when USB connected
The chosen solution (`setTxTimeoutMs(0)`) provides the best balance: debug output works when USB is connected, device operates normally when disconnected.
## References
- ESP32 Arduino Core Serial documentation
- ESP-IDF USB CDC documentation
- FreeRTOS queue behavior (initial red herring investigation)

View File

@ -251,8 +251,8 @@ bool Epub::parseCssFiles() {
SdMan.remove(tmpCssPath.c_str());
}
Serial.printf("[%lu] [EBP] Loaded %zu CSS style rules from %zu files (~%zu bytes)\n", millis(), cssParser->ruleCount(),
cssFiles.size(), cssParser->estimateMemoryUsage());
Serial.printf("[%lu] [EBP] Loaded %zu CSS style rules from %zu files (~%zu bytes)\n", millis(),
cssParser->ruleCount(), cssFiles.size(), cssParser->estimateMemoryUsage());
return true;
}
@ -757,8 +757,8 @@ bool Epub::generateAllCovers(const std::function<void(int)>& progressCallback) c
SdMan.openFileForWrite("EBP", getCoverBmpPath(false), coverBmp)) {
const int targetWidth = 480;
const int targetHeight = (480 * jpegHeight) / jpegWidth;
const bool success =
JpegToBmpConverter::jpegFileToBmpStreamWithSize(coverJpg, coverBmp, targetWidth, targetHeight, makeSubProgress(50, 75));
const bool success = JpegToBmpConverter::jpegFileToBmpStreamWithSize(coverJpg, coverBmp, targetWidth,
targetHeight, makeSubProgress(50, 75));
coverJpg.close();
coverBmp.close();
if (!success) {
@ -776,8 +776,8 @@ bool Epub::generateAllCovers(const std::function<void(int)>& progressCallback) c
SdMan.openFileForWrite("EBP", getCoverBmpPath(true), coverBmp)) {
const int targetHeight = 800;
const int targetWidth = (800 * jpegWidth) / jpegHeight;
const bool success =
JpegToBmpConverter::jpegFileToBmpStreamWithSize(coverJpg, coverBmp, targetWidth, targetHeight, makeSubProgress(75, 100));
const bool success = JpegToBmpConverter::jpegFileToBmpStreamWithSize(coverJpg, coverBmp, targetWidth,
targetHeight, makeSubProgress(75, 100));
coverJpg.close();
coverBmp.close();
if (!success) {

View File

@ -57,12 +57,12 @@ class Page {
public:
// the list of block index and line numbers on this page
std::vector<std::shared_ptr<PageElement>> elements;
// Byte offset in source HTML where this page's content begins
// Used for restoring reading position after re-indexing due to font/setting changes
// This is stored in the Section file's LUT, not in Page serialization
uint32_t firstContentOffset = 0;
void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) const;
bool serialize(FsFile& file) const;
static std::unique_ptr<Page> deserialize(FsFile& file);

View File

@ -186,7 +186,7 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
}
writeSectionFileHeader(fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth,
viewportHeight, hyphenationEnabled);
// LUT entries: { filePosition, contentOffset } pairs
struct LutEntry {
uint32_t filePos;
@ -202,8 +202,8 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
const uint32_t contentOffset = page->firstContentOffset;
const uint32_t filePos = this->onPageComplete(std::move(page));
lut.push_back({filePos, contentOffset});
}, progressFn,
epub->getCssParser());
},
progressFn, epub->getCssParser());
Hyphenator::setPreferredLanguage(epub->getLanguage());
success = visitor.parseAndBuildPages();
@ -217,7 +217,7 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
// Add placeholder to LUT
const uint32_t filePos = this->onPageComplete(std::move(placeholderPage));
lut.push_back({filePos, 0});
// If we still have no pages, the placeholder creation failed
if (pageCount == 0) {
Serial.printf("[%lu] [SCT] Failed to create placeholder page\n", millis());
@ -262,13 +262,13 @@ std::unique_ptr<Page> Section::loadPageFromSectionFile() {
file.seek(HEADER_SIZE - sizeof(uint32_t));
uint32_t lutOffset;
serialization::readPod(file, lutOffset);
// LUT entries are now 8 bytes each: { filePos (4), contentOffset (4) }
file.seek(lutOffset + LUT_ENTRY_SIZE * currentPage);
uint32_t pagePos;
serialization::readPod(file, pagePos);
// Skip contentOffset for now - we don't need it when just loading the page
file.seek(pagePos);
auto page = Page::deserialize(file);
@ -300,15 +300,15 @@ int Section::findPageForContentOffset(uint32_t targetOffset) const {
while (left <= right) {
const int mid = left + (right - left) / 2;
// Read content offset for page 'mid'
// LUT entry format: { filePos (4), contentOffset (4) }
f.seek(lutOffset + LUT_ENTRY_SIZE * mid + sizeof(uint32_t)); // Skip filePos
uint32_t midOffset;
serialization::readPod(f, midOffset);
if (midOffset <= targetOffset) {
result = mid; // This page could be the answer
result = mid; // This page could be the answer
left = mid + 1; // Look for a later page that might also qualify
} else {
right = mid - 1; // Look for an earlier page
@ -322,7 +322,7 @@ int Section::findPageForContentOffset(uint32_t targetOffset) const {
f.seek(lutOffset + LUT_ENTRY_SIZE * result + sizeof(uint32_t));
uint32_t resultOffset;
serialization::readPod(f, resultOffset);
while (result > 0) {
f.seek(lutOffset + LUT_ENTRY_SIZE * (result - 1) + sizeof(uint32_t));
uint32_t prevOffset;

View File

@ -36,7 +36,7 @@ class Section {
const std::function<void()>& progressSetupFn = nullptr,
const std::function<void(int)>& progressFn = nullptr);
std::unique_ptr<Page> loadPageFromSectionFile();
// Methods for content offset-based position tracking
// Used to restore reading position after re-indexing due to font/setting changes
int findPageForContentOffset(uint32_t targetOffset) const;

View File

@ -83,7 +83,7 @@ void ChapterHtmlSlimParser::updateEffectiveInlineStyle() {
// Flush the contents of partWordBuffer to currentTextBlock
void ChapterHtmlSlimParser::flushPartWordBuffer() {
if (partWordBufferIndex == 0) return;
// Determine font style using effective styles
EpdFontFamily::Style fontStyle = EpdFontFamily::REGULAR;
if (effectiveBold && effectiveItalic) {
@ -93,7 +93,7 @@ void ChapterHtmlSlimParser::flushPartWordBuffer() {
} else if (effectiveItalic) {
fontStyle = EpdFontFamily::ITALIC;
}
// Flush the buffer
partWordBuffer[partWordBufferIndex] = '\0';
currentTextBlock->addWord(partWordBuffer, fontStyle, effectiveUnderline);
@ -290,7 +290,7 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
} else {
placeholder = "[Image unavailable]";
}
self->startNewTextBlock(TextBlock::CENTER_ALIGN);
self->italicUntilDepth = std::min(self->italicUntilDepth, self->depth);
self->depth += 1;
@ -478,7 +478,7 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char
if (self->skipUntilDepth < self->depth) {
return;
}
// Capture byte offset of this character data for page position tracking
if (self->xmlParser) {
self->lastCharDataOffset = XML_GetCurrentByteIndex(self->xmlParser);
@ -647,7 +647,7 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
const size_t totalSize = file.size();
size_t bytesRead = 0;
int lastProgress = -1;
// Initialize offset tracking - first page starts at offset 0
currentPageStartOffset = 0;
lastCharDataOffset = 0;
@ -739,7 +739,7 @@ void ChapterHtmlSlimParser::addLineToPage(std::shared_ptr<TextBlock> line) {
currentPage->firstContentOffset = static_cast<uint32_t>(currentPageStartOffset);
}
completePageFn(std::move(currentPage));
// Start new page - offset will be set when first content is added
currentPage.reset(new Page());
currentPageStartOffset = lastCharDataOffset; // Use offset from when content was parsed

View File

@ -58,11 +58,11 @@ class ChapterHtmlSlimParser {
bool effectiveBold = false;
bool effectiveItalic = false;
bool effectiveUnderline = false;
// Byte offset tracking for position restoration after re-indexing
XML_Parser xmlParser = nullptr; // Store parser for getting current byte index
size_t currentPageStartOffset = 0; // Byte offset when current page was started
size_t lastCharDataOffset = 0; // Byte offset of last character data (captured during parsing)
XML_Parser xmlParser = nullptr; // Store parser for getting current byte index
size_t currentPageStartOffset = 0; // Byte offset when current page was started
size_t lastCharDataOffset = 0; // Byte offset of last character data (captured during parsing)
void updateEffectiveInlineStyle();
void startNewTextBlock(TextBlock::Style style);

View File

@ -5,13 +5,9 @@
// Global high contrast mode flag
static bool g_highContrastMode = false;
void setHighContrastMode(bool enabled) {
g_highContrastMode = enabled;
}
void setHighContrastMode(bool enabled) { g_highContrastMode = enabled; }
bool isHighContrastMode() {
return g_highContrastMode;
}
bool isHighContrastMode() { return g_highContrastMode; }
// Brightness/Contrast adjustments:
constexpr bool USE_BRIGHTNESS = false; // true: apply brightness/gamma adjustments

View File

@ -327,7 +327,8 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con
// Calculate screen Y position
const int screenYStart = y + static_cast<int>(std::floor(logicalY * scale));
// For upscaling, calculate the end position for this source row
const int screenYEnd = isUpscaling ? (y + static_cast<int>(std::floor((logicalY + 1) * scale))) : (screenYStart + 1);
const int screenYEnd =
isUpscaling ? (y + static_cast<int>(std::floor((logicalY + 1) * scale))) : (screenYStart + 1);
// Draw to all Y positions this source row maps to (for upscaling, this fills gaps)
for (int screenY = screenYStart; screenY < screenYEnd; screenY++) {
@ -340,7 +341,8 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con
// Calculate screen X position
const int screenXStart = x + static_cast<int>(std::floor(srcX * scale));
// For upscaling, calculate the end position for this source pixel
const int screenXEnd = isUpscaling ? (x + static_cast<int>(std::floor((srcX + 1) * scale))) : (screenXStart + 1);
const int screenXEnd =
isUpscaling ? (x + static_cast<int>(std::floor((srcX + 1) * scale))) : (screenXStart + 1);
const uint8_t val = outputRow[bmpX / 4] >> (6 - ((bmpX * 2) % 8)) & 0x3;
@ -409,7 +411,8 @@ void GfxRenderer::drawBitmap1Bit(const Bitmap& bitmap, const int x, const int y,
// Calculate screen Y position
const int screenYStart = y + static_cast<int>(std::floor(logicalY * scale));
// For upscaling, calculate the end position for this source row
const int screenYEnd = isUpscaling ? (y + static_cast<int>(std::floor((logicalY + 1) * scale))) : (screenYStart + 1);
const int screenYEnd =
isUpscaling ? (y + static_cast<int>(std::floor((logicalY + 1) * scale))) : (screenYStart + 1);
// Draw to all Y positions this source row maps to (for upscaling, this fills gaps)
for (int screenY = screenYStart; screenY < screenYEnd; screenY++) {
@ -420,7 +423,8 @@ void GfxRenderer::drawBitmap1Bit(const Bitmap& bitmap, const int x, const int y,
// Calculate screen X position
const int screenXStart = x + static_cast<int>(std::floor(bmpX * scale));
// For upscaling, calculate the end position for this source pixel
const int screenXEnd = isUpscaling ? (x + static_cast<int>(std::floor((bmpX + 1) * scale))) : (screenXStart + 1);
const int screenXEnd =
isUpscaling ? (x + static_cast<int>(std::floor((bmpX + 1) * scale))) : (screenXStart + 1);
// Get 2-bit value (result of readNextRow quantization)
const uint8_t val = outputRow[bmpX / 4] >> (6 - ((bmpX * 2) % 8)) & 0x3;
@ -998,26 +1002,38 @@ int mapPhysicalToLogicalEdge(int bezelEdge, GfxRenderer::Orientation orientation
return bezelEdge;
case GfxRenderer::LandscapeClockwise:
switch (bezelEdge) {
case 0: return 2; // Physical bottom -> logical left
case 1: return 3; // Physical top -> logical right
case 2: return 1; // Physical left -> logical top
case 3: return 0; // Physical right -> logical bottom
case 0:
return 2; // Physical bottom -> logical left
case 1:
return 3; // Physical top -> logical right
case 2:
return 1; // Physical left -> logical top
case 3:
return 0; // Physical right -> logical bottom
}
break;
case GfxRenderer::PortraitInverted:
switch (bezelEdge) {
case 0: return 1; // Physical bottom -> logical top
case 1: return 0; // Physical top -> logical bottom
case 2: return 3; // Physical left -> logical right
case 3: return 2; // Physical right -> logical left
case 0:
return 1; // Physical bottom -> logical top
case 1:
return 0; // Physical top -> logical bottom
case 2:
return 3; // Physical left -> logical right
case 3:
return 2; // Physical right -> logical left
}
break;
case GfxRenderer::LandscapeCounterClockwise:
switch (bezelEdge) {
case 0: return 3; // Physical bottom -> logical right
case 1: return 2; // Physical top -> logical left
case 2: return 0; // Physical left -> logical bottom
case 3: return 1; // Physical right -> logical top
case 0:
return 3; // Physical bottom -> logical right
case 1:
return 2; // Physical top -> logical left
case 2:
return 0; // Physical left -> logical bottom
case 3:
return 1; // Physical right -> logical top
}
break;
}
@ -1074,23 +1090,34 @@ void GfxRenderer::getOrientedViewableTRBL(int* outTop, int* outRight, int* outBo
*outLeft = getViewableMarginLeft();
break;
case LandscapeClockwise:
*outTop = BASE_VIEWABLE_MARGIN_LEFT + (mapPhysicalToLogicalEdge(bezelEdge, orientation) == 1 ? bezelCompensation : 0);
*outRight = BASE_VIEWABLE_MARGIN_TOP + (mapPhysicalToLogicalEdge(bezelEdge, orientation) == 3 ? bezelCompensation : 0);
*outBottom = BASE_VIEWABLE_MARGIN_RIGHT + (mapPhysicalToLogicalEdge(bezelEdge, orientation) == 0 ? bezelCompensation : 0);
*outLeft = BASE_VIEWABLE_MARGIN_BOTTOM + (mapPhysicalToLogicalEdge(bezelEdge, orientation) == 2 ? bezelCompensation : 0);
*outTop =
BASE_VIEWABLE_MARGIN_LEFT + (mapPhysicalToLogicalEdge(bezelEdge, orientation) == 1 ? bezelCompensation : 0);
*outRight =
BASE_VIEWABLE_MARGIN_TOP + (mapPhysicalToLogicalEdge(bezelEdge, orientation) == 3 ? bezelCompensation : 0);
*outBottom =
BASE_VIEWABLE_MARGIN_RIGHT + (mapPhysicalToLogicalEdge(bezelEdge, orientation) == 0 ? bezelCompensation : 0);
*outLeft =
BASE_VIEWABLE_MARGIN_BOTTOM + (mapPhysicalToLogicalEdge(bezelEdge, orientation) == 2 ? bezelCompensation : 0);
break;
case PortraitInverted:
*outTop = BASE_VIEWABLE_MARGIN_BOTTOM + (mapPhysicalToLogicalEdge(bezelEdge, orientation) == 1 ? bezelCompensation : 0);
*outRight = BASE_VIEWABLE_MARGIN_LEFT + (mapPhysicalToLogicalEdge(bezelEdge, orientation) == 3 ? bezelCompensation : 0);
*outBottom = BASE_VIEWABLE_MARGIN_TOP + (mapPhysicalToLogicalEdge(bezelEdge, orientation) == 0 ? bezelCompensation : 0);
*outLeft = BASE_VIEWABLE_MARGIN_RIGHT + (mapPhysicalToLogicalEdge(bezelEdge, orientation) == 2 ? bezelCompensation : 0);
*outTop =
BASE_VIEWABLE_MARGIN_BOTTOM + (mapPhysicalToLogicalEdge(bezelEdge, orientation) == 1 ? bezelCompensation : 0);
*outRight =
BASE_VIEWABLE_MARGIN_LEFT + (mapPhysicalToLogicalEdge(bezelEdge, orientation) == 3 ? bezelCompensation : 0);
*outBottom =
BASE_VIEWABLE_MARGIN_TOP + (mapPhysicalToLogicalEdge(bezelEdge, orientation) == 0 ? bezelCompensation : 0);
*outLeft =
BASE_VIEWABLE_MARGIN_RIGHT + (mapPhysicalToLogicalEdge(bezelEdge, orientation) == 2 ? bezelCompensation : 0);
break;
case LandscapeCounterClockwise:
*outTop = BASE_VIEWABLE_MARGIN_RIGHT + (mapPhysicalToLogicalEdge(bezelEdge, orientation) == 1 ? bezelCompensation : 0);
*outRight = BASE_VIEWABLE_MARGIN_BOTTOM + (mapPhysicalToLogicalEdge(bezelEdge, orientation) == 3 ? bezelCompensation : 0);
*outBottom = BASE_VIEWABLE_MARGIN_LEFT + (mapPhysicalToLogicalEdge(bezelEdge, orientation) == 0 ? bezelCompensation : 0);
*outLeft = BASE_VIEWABLE_MARGIN_TOP + (mapPhysicalToLogicalEdge(bezelEdge, orientation) == 2 ? bezelCompensation : 0);
*outTop =
BASE_VIEWABLE_MARGIN_RIGHT + (mapPhysicalToLogicalEdge(bezelEdge, orientation) == 1 ? bezelCompensation : 0);
*outRight =
BASE_VIEWABLE_MARGIN_BOTTOM + (mapPhysicalToLogicalEdge(bezelEdge, orientation) == 3 ? bezelCompensation : 0);
*outBottom =
BASE_VIEWABLE_MARGIN_LEFT + (mapPhysicalToLogicalEdge(bezelEdge, orientation) == 0 ? bezelCompensation : 0);
*outLeft =
BASE_VIEWABLE_MARGIN_TOP + (mapPhysicalToLogicalEdge(bezelEdge, orientation) == 2 ? bezelCompensation : 0);
break;
}
}

View File

@ -94,10 +94,10 @@ class GfxRenderer {
// Handles current render mode (BW, GRAYSCALE_MSB, GRAYSCALE_LSB)
void fillRectGray(int x, int y, int width, int height, uint8_t grayLevel) const;
void drawImage(const uint8_t bitmap[], int x, int y, int width, int height) const;
void drawImageRotated(const uint8_t bitmap[], int x, int y, int width, int height,
ImageRotation rotation, bool invert = false) const;
void drawBitmap(const Bitmap& bitmap, int x, int y, int maxWidth, int maxHeight, float cropX = 0,
float cropY = 0, bool invert = false) const;
void drawImageRotated(const uint8_t bitmap[], int x, int y, int width, int height, ImageRotation rotation,
bool invert = false) const;
void drawBitmap(const Bitmap& bitmap, int x, int y, int maxWidth, int maxHeight, float cropX = 0, float cropY = 0,
bool invert = false) const;
void drawBitmap1Bit(const Bitmap& bitmap, int x, int y, int maxWidth, int maxHeight, bool invert = false) const;
void fillPolygon(const int* xPoints, const int* yPoints, int numPoints, bool state = true) const;

View File

@ -150,8 +150,7 @@ std::string DictHtmlParser::extractTagName(const std::string& html, size_t start
std::string tagName = html.substr(nameStart, pos - nameStart);
// Convert to lowercase
std::transform(tagName.begin(), tagName.end(), tagName.begin(),
[](unsigned char c) { return std::tolower(c); });
std::transform(tagName.begin(), tagName.end(), tagName.begin(), [](unsigned char c) { return std::tolower(c); });
return tagName;
}
@ -160,17 +159,11 @@ bool DictHtmlParser::isBlockTag(const std::string& tagName) {
tagName == "ol" || tagName == "ul" || tagName == "dt" || tagName == "dd" || tagName == "html";
}
bool DictHtmlParser::isBoldTag(const std::string& tagName) {
return tagName == "b" || tagName == "strong";
}
bool DictHtmlParser::isBoldTag(const std::string& tagName) { return tagName == "b" || tagName == "strong"; }
bool DictHtmlParser::isItalicTag(const std::string& tagName) {
return tagName == "i" || tagName == "em";
}
bool DictHtmlParser::isItalicTag(const std::string& tagName) { return tagName == "i" || tagName == "em"; }
bool DictHtmlParser::isUnderlineTag(const std::string& tagName) {
return tagName == "u" || tagName == "ins";
}
bool DictHtmlParser::isUnderlineTag(const std::string& tagName) { return tagName == "u" || tagName == "ins"; }
bool DictHtmlParser::isSuperscriptTag(const std::string& tagName) { return tagName == "sup"; }

View File

@ -10,7 +10,7 @@ class GfxRenderer;
/**
* DictHtmlParser parses HTML dictionary definitions into ParsedText.
*
*
* Supports:
* - Bold: <b>, <strong>
* - Italic: <i>, <em>
@ -25,7 +25,7 @@ class DictHtmlParser {
/**
* Parse HTML definition and populate ParsedText with styled words.
* Each paragraph/block creates a separate ParsedText via the callback.
*
*
* @param html The HTML definition text
* @param fontId Font ID for text width calculations
* @param renderer Reference to renderer for layout

View File

@ -588,8 +588,12 @@ static std::string decodeHtmlEntity(const std::string& html, size_t& i) {
const char* replacement;
};
static const EntityMapping entities[] = {
{"&nbsp;", " "}, {"&lt;", "<"}, {"&gt;", ">"},
{"&amp;", "&"}, {"&quot;", "\""}, {"&apos;", "'"},
{"&nbsp;", " "},
{"&lt;", "<"},
{"&gt;", ">"},
{"&amp;", "&"},
{"&quot;", "\""},
{"&apos;", "'"},
{"&mdash;", "\xe2\x80\x94"}, // —
{"&ndash;", "\xe2\x80\x93"}, //
{"&hellip;", "\xe2\x80\xa6"}, // …
@ -688,8 +692,8 @@ std::string StarDict::stripHtml(const std::string& html) {
// Extract tag name
size_t tagEnd = tagStart;
while (tagEnd < html.length() && !std::isspace(static_cast<unsigned char>(html[tagEnd])) &&
html[tagEnd] != '>' && html[tagEnd] != '/') {
while (tagEnd < html.length() && !std::isspace(static_cast<unsigned char>(html[tagEnd])) && html[tagEnd] != '>' &&
html[tagEnd] != '/') {
tagEnd++;
}

View File

@ -32,7 +32,7 @@ class StarDict {
struct DictzipInfo {
uint32_t chunkLength = 0; // Uncompressed chunk size (usually 58315)
uint16_t chunkCount = 0;
uint32_t headerSize = 0; // Total header size to skip
uint32_t headerSize = 0; // Total header size to skip
uint16_t* chunkSizes = nullptr; // Array of compressed chunk sizes
bool loaded = false;
};

View File

@ -205,8 +205,8 @@ bool Txt::generateThumbBmp() const {
}
constexpr int THUMB_TARGET_WIDTH = 240;
constexpr int THUMB_TARGET_HEIGHT = 400;
const bool success =
JpegToBmpConverter::jpegFileTo1BitBmpStreamWithSize(coverJpg, thumbBmp, THUMB_TARGET_WIDTH, THUMB_TARGET_HEIGHT);
const bool success = JpegToBmpConverter::jpegFileTo1BitBmpStreamWithSize(coverJpg, thumbBmp, THUMB_TARGET_WIDTH,
THUMB_TARGET_HEIGHT);
coverJpg.close();
thumbBmp.close();
@ -276,8 +276,8 @@ bool Txt::generateMicroThumbBmp() const {
}
constexpr int MICRO_THUMB_TARGET_WIDTH = 45;
constexpr int MICRO_THUMB_TARGET_HEIGHT = 60;
const bool success = JpegToBmpConverter::jpegFileTo1BitBmpStreamWithSize(coverJpg, microThumbBmp,
MICRO_THUMB_TARGET_WIDTH, MICRO_THUMB_TARGET_HEIGHT);
const bool success = JpegToBmpConverter::jpegFileTo1BitBmpStreamWithSize(
coverJpg, microThumbBmp, MICRO_THUMB_TARGET_WIDTH, MICRO_THUMB_TARGET_HEIGHT);
coverJpg.close();
microThumbBmp.close();

View File

@ -2,7 +2,7 @@
default_envs = default
[crosspoint]
version = 0.15.0
version = ef-0.15.9
[base]
platform = espressif32 @ 6.12.0

View File

@ -219,9 +219,7 @@ bool BookListStore::listExists(const std::string& name) {
return SdMan.exists(path.c_str());
}
std::string BookListStore::getListPath(const std::string& name) {
return std::string(LISTS_DIR) + "/" + name + ".bin";
}
std::string BookListStore::getListPath(const std::string& name) { return std::string(LISTS_DIR) + "/" + name + ".bin"; }
int BookListStore::getBookCount(const std::string& name) {
const std::string path = getListPath(name);

View File

@ -81,5 +81,4 @@ class BookListStore {
* @return Book count, or -1 if list doesn't exist
*/
static int getBookCount(const std::string& name);
};

View File

@ -35,9 +35,7 @@ std::string BookManager::getExtension(const std::string& path) {
return ext;
}
size_t BookManager::computePathHash(const std::string& path) {
return std::hash<std::string>{}(path);
}
size_t BookManager::computePathHash(const std::string& path) { return std::hash<std::string>{}(path); }
std::string BookManager::getCachePrefix(const std::string& path) {
const std::string ext = getExtension(path);

View File

@ -20,11 +20,10 @@ constexpr int MAX_BOOKMARKS_PER_BOOK = 100;
// Get cache directory path for a book (same logic as BookManager)
std::string getCacheDir(const std::string& bookPath) {
const size_t hash = std::hash<std::string>{}(bookPath);
if (StringUtils::checkFileExtension(bookPath, ".epub")) {
return "/.crosspoint/epub_" + std::to_string(hash);
} else if (StringUtils::checkFileExtension(bookPath, ".txt") ||
StringUtils::checkFileExtension(bookPath, ".TXT") ||
} else if (StringUtils::checkFileExtension(bookPath, ".txt") || StringUtils::checkFileExtension(bookPath, ".TXT") ||
StringUtils::checkFileExtension(bookPath, ".md")) {
return "/.crosspoint/txt_" + std::to_string(hash);
}
@ -47,21 +46,21 @@ std::vector<Bookmark> BookmarkStore::getBookmarks(const std::string& bookPath) {
bool BookmarkStore::addBookmark(const std::string& bookPath, const Bookmark& bookmark) {
std::vector<Bookmark> bookmarks;
loadBookmarks(bookPath, bookmarks);
// Check if bookmark already exists at this location
auto it = std::find_if(bookmarks.begin(), bookmarks.end(), [&](const Bookmark& b) {
return b.spineIndex == bookmark.spineIndex && b.contentOffset == bookmark.contentOffset;
});
if (it != bookmarks.end()) {
Serial.printf("[%lu] [BMS] Bookmark already exists at spine %u, offset %u\n",
millis(), bookmark.spineIndex, bookmark.contentOffset);
Serial.printf("[%lu] [BMS] Bookmark already exists at spine %u, offset %u\n", millis(), bookmark.spineIndex,
bookmark.contentOffset);
return false;
}
// Add new bookmark
bookmarks.push_back(bookmark);
// Trim to max size (remove oldest)
if (bookmarks.size() > MAX_BOOKMARKS_PER_BOOK) {
// Sort by timestamp and remove oldest
@ -70,100 +69,99 @@ bool BookmarkStore::addBookmark(const std::string& bookPath, const Bookmark& boo
});
bookmarks.resize(MAX_BOOKMARKS_PER_BOOK);
}
return saveBookmarks(bookPath, bookmarks);
}
bool BookmarkStore::removeBookmark(const std::string& bookPath, uint16_t spineIndex, uint32_t contentOffset) {
std::vector<Bookmark> bookmarks;
loadBookmarks(bookPath, bookmarks);
auto it = std::find_if(bookmarks.begin(), bookmarks.end(), [&](const Bookmark& b) {
return b.spineIndex == spineIndex && b.contentOffset == contentOffset;
});
if (it == bookmarks.end()) {
return false;
}
bookmarks.erase(it);
Serial.printf("[%lu] [BMS] Removed bookmark at spine %u, offset %u\n", millis(), spineIndex, contentOffset);
return saveBookmarks(bookPath, bookmarks);
}
bool BookmarkStore::isPageBookmarked(const std::string& bookPath, uint16_t spineIndex, uint32_t contentOffset) {
std::vector<Bookmark> bookmarks;
loadBookmarks(bookPath, bookmarks);
return std::any_of(bookmarks.begin(), bookmarks.end(), [&](const Bookmark& b) {
return b.spineIndex == spineIndex && b.contentOffset == contentOffset;
});
return std::any_of(bookmarks.begin(), bookmarks.end(),
[&](const Bookmark& b) { return b.spineIndex == spineIndex && b.contentOffset == contentOffset; });
}
int BookmarkStore::getBookmarkCount(const std::string& bookPath) {
const std::string filePath = getBookmarksFilePath(bookPath);
if (filePath.empty()) return 0;
FsFile inputFile;
if (!SdMan.openFileForRead("BMS", filePath, inputFile)) {
return 0;
}
uint8_t version;
serialization::readPod(inputFile, version);
if (version != BOOKMARKS_FILE_VERSION) {
inputFile.close();
return 0;
}
uint8_t count;
serialization::readPod(inputFile, count);
inputFile.close();
return count;
}
std::vector<BookmarkedBook> BookmarkStore::getBooksWithBookmarks() {
std::vector<BookmarkedBook> result;
// Scan /.crosspoint/ directory for cache folders with bookmarks
auto crosspoint = SdMan.open("/.crosspoint");
if (!crosspoint || !crosspoint.isDirectory()) {
if (crosspoint) crosspoint.close();
return result;
}
crosspoint.rewindDirectory();
char name[256];
for (auto entry = crosspoint.openNextFile(); entry; entry = crosspoint.openNextFile()) {
entry.getName(name, sizeof(name));
if (!entry.isDirectory()) {
entry.close();
continue;
}
// Check if this directory has a bookmarks file
std::string dirPath = "/.crosspoint/";
dirPath += name;
std::string bookmarksPath = dirPath + "/" + BOOKMARKS_FILENAME;
if (SdMan.exists(bookmarksPath.c_str())) {
// Read the bookmarks file to get count and book info
FsFile bookmarksFile;
if (SdMan.openFileForRead("BMS", bookmarksPath, bookmarksFile)) {
uint8_t version;
serialization::readPod(bookmarksFile, version);
if (version == BOOKMARKS_FILE_VERSION) {
uint8_t count;
serialization::readPod(bookmarksFile, count);
// Read book metadata (stored at end of file)
std::string bookPath, bookTitle, bookAuthor;
// Skip bookmark entries to get to metadata
for (uint8_t i = 0; i < count; i++) {
std::string tempName;
@ -176,12 +174,12 @@ std::vector<BookmarkedBook> BookmarkStore::getBooksWithBookmarks() {
serialization::readPod(bookmarksFile, tempPage);
serialization::readPod(bookmarksFile, tempTimestamp);
}
// Read book metadata
serialization::readString(bookmarksFile, bookPath);
serialization::readString(bookmarksFile, bookTitle);
serialization::readString(bookmarksFile, bookAuthor);
if (!bookPath.empty() && count > 0) {
BookmarkedBook book;
book.path = bookPath;
@ -197,19 +195,18 @@ std::vector<BookmarkedBook> BookmarkStore::getBooksWithBookmarks() {
entry.close();
}
crosspoint.close();
// Sort by title
std::sort(result.begin(), result.end(), [](const BookmarkedBook& a, const BookmarkedBook& b) {
return a.title < b.title;
});
std::sort(result.begin(), result.end(),
[](const BookmarkedBook& a, const BookmarkedBook& b) { return a.title < b.title; });
return result;
}
void BookmarkStore::clearBookmarks(const std::string& bookPath) {
const std::string filePath = getBookmarksFilePath(bookPath);
if (filePath.empty()) return;
SdMan.remove(filePath.c_str());
Serial.printf("[%lu] [BMS] Cleared all bookmarks for %s\n", millis(), bookPath.c_str());
}
@ -217,21 +214,21 @@ void BookmarkStore::clearBookmarks(const std::string& bookPath) {
bool BookmarkStore::saveBookmarks(const std::string& bookPath, const std::vector<Bookmark>& bookmarks) {
const std::string cacheDir = getCacheDir(bookPath);
if (cacheDir.empty()) return false;
// Make sure the directory exists
SdMan.mkdir(cacheDir.c_str());
const std::string filePath = cacheDir + "/" + BOOKMARKS_FILENAME;
FsFile outputFile;
if (!SdMan.openFileForWrite("BMS", filePath, outputFile)) {
return false;
}
serialization::writePod(outputFile, BOOKMARKS_FILE_VERSION);
const uint8_t count = static_cast<uint8_t>(std::min(bookmarks.size(), static_cast<size_t>(255)));
serialization::writePod(outputFile, count);
for (size_t i = 0; i < count; i++) {
const auto& bookmark = bookmarks[i];
serialization::writeString(outputFile, bookmark.name);
@ -240,7 +237,7 @@ bool BookmarkStore::saveBookmarks(const std::string& bookPath, const std::vector
serialization::writePod(outputFile, bookmark.pageNumber);
serialization::writePod(outputFile, bookmark.timestamp);
}
// Store book metadata at end (for getBooksWithBookmarks to read)
// Extract title from path if we don't have it
std::string title = bookPath;
@ -252,11 +249,11 @@ bool BookmarkStore::saveBookmarks(const std::string& bookPath, const std::vector
if (dot != std::string::npos) {
title.resize(dot);
}
serialization::writeString(outputFile, bookPath);
serialization::writeString(outputFile, title);
serialization::writeString(outputFile, ""); // Author (not always available)
outputFile.close();
Serial.printf("[%lu] [BMS] Bookmarks saved for %s (%d entries)\n", millis(), bookPath.c_str(), count);
return true;
@ -264,15 +261,15 @@ bool BookmarkStore::saveBookmarks(const std::string& bookPath, const std::vector
bool BookmarkStore::loadBookmarks(const std::string& bookPath, std::vector<Bookmark>& bookmarks) {
bookmarks.clear();
const std::string filePath = getBookmarksFilePath(bookPath);
if (filePath.empty()) return false;
FsFile inputFile;
if (!SdMan.openFileForRead("BMS", filePath, inputFile)) {
return false;
}
uint8_t version;
serialization::readPod(inputFile, version);
if (version != BOOKMARKS_FILE_VERSION) {
@ -280,11 +277,11 @@ bool BookmarkStore::loadBookmarks(const std::string& bookPath, std::vector<Bookm
inputFile.close();
return false;
}
uint8_t count;
serialization::readPod(inputFile, count);
bookmarks.reserve(count);
for (uint8_t i = 0; i < count; i++) {
Bookmark bookmark;
serialization::readString(inputFile, bookmark.name);
@ -294,7 +291,7 @@ bool BookmarkStore::loadBookmarks(const std::string& bookPath, std::vector<Bookm
serialization::readPod(inputFile, bookmark.timestamp);
bookmarks.push_back(bookmark);
}
inputFile.close();
Serial.printf("[%lu] [BMS] Bookmarks loaded for %s (%d entries)\n", millis(), bookPath.c_str(), count);
return true;

View File

@ -7,12 +7,12 @@ struct BookmarkedBook;
// A single bookmark within a book
struct Bookmark {
std::string name; // Display name (e.g., "Chapter 1 - Page 42")
uint16_t spineIndex = 0; // For EPUB: which spine item
uint32_t contentOffset = 0; // Content offset for stable positioning
uint16_t pageNumber = 0; // Page number at time of bookmark (for display)
uint32_t timestamp = 0; // Unix timestamp when created
std::string name; // Display name (e.g., "Chapter 1 - Page 42")
uint16_t spineIndex = 0; // For EPUB: which spine item
uint32_t contentOffset = 0; // Content offset for stable positioning
uint16_t pageNumber = 0; // Page number at time of bookmark (for display)
uint32_t timestamp = 0; // Unix timestamp when created
bool operator==(const Bookmark& other) const {
return spineIndex == other.spineIndex && contentOffset == other.contentOffset;
}
@ -22,7 +22,7 @@ struct Bookmark {
* BookmarkStore manages bookmarks for books.
* Bookmarks are stored per-book in the book's cache directory:
* /.crosspoint/{epub_|txt_}<hash>/bookmarks.bin
*
*
* This is a static utility class, not a singleton, since bookmarks
* are loaded/saved on demand for specific books.
*/
@ -30,34 +30,34 @@ class BookmarkStore {
public:
// Get all bookmarks for a book
static std::vector<Bookmark> getBookmarks(const std::string& bookPath);
// Add a bookmark to a book
// Returns true if added, false if bookmark already exists at that location
static bool addBookmark(const std::string& bookPath, const Bookmark& bookmark);
// Remove a bookmark from a book by content offset
// Returns true if removed, false if not found
static bool removeBookmark(const std::string& bookPath, uint16_t spineIndex, uint32_t contentOffset);
// Check if a specific page is bookmarked
static bool isPageBookmarked(const std::string& bookPath, uint16_t spineIndex, uint32_t contentOffset);
// Get count of bookmarks for a book (without loading all data)
static int getBookmarkCount(const std::string& bookPath);
// Get all books that have bookmarks (for Bookmarks tab)
static std::vector<BookmarkedBook> getBooksWithBookmarks();
// Delete all bookmarks for a book
static void clearBookmarks(const std::string& bookPath);
private:
// Get the bookmarks file path for a book
static std::string getBookmarksFilePath(const std::string& bookPath);
// Save bookmarks to file
static bool saveBookmarks(const std::string& bookPath, const std::vector<Bookmark>& bookmarks);
// Load bookmarks from file
static bool loadBookmarks(const std::string& bookPath, std::vector<Bookmark>& bookmarks);
};

View File

@ -193,7 +193,7 @@ bool CrossPointSettings::loadFromFile() {
float CrossPointSettings::getReaderLineCompression() const {
// For custom fonts, use the fallback font's line compression
const uint8_t effectiveFamily = (fontFamily == CUSTOM_FONT) ? fallbackFontFamily : fontFamily;
switch (effectiveFamily) {
case BOOKERLY:
default:

View File

@ -26,7 +26,14 @@ class CrossPointSettings {
};
// Status bar display type enum
enum STATUS_BAR_MODE { NONE = 0, NO_PROGRESS = 1, FULL = 2, FULL_WITH_PROGRESS_BAR = 3, ONLY_PROGRESS_BAR = 4, STATUS_BAR_MODE_COUNT };
enum STATUS_BAR_MODE {
NONE = 0,
NO_PROGRESS = 1,
FULL = 2,
FULL_WITH_PROGRESS_BAR = 3,
ONLY_PROGRESS_BAR = 4,
STATUS_BAR_MODE_COUNT
};
enum ORIENTATION {
PORTRAIT = 0, // 480x800 logical coordinates (current default)
@ -116,7 +123,7 @@ class CrossPointSettings {
uint8_t sideButtonLayout = PREV_NEXT;
// Reader font settings
uint8_t fontFamily = BOOKERLY;
uint8_t customFontIndex = 0; // Which custom font to use (0 to CUSTOM_FONT_COUNT-1)
uint8_t customFontIndex = 0; // Which custom font to use (0 to CUSTOM_FONT_COUNT-1)
uint8_t fallbackFontFamily = BOOKERLY; // Fallback for missing glyphs/weights in custom fonts
uint8_t fontSize = MEDIUM;
uint8_t lineSpacing = NORMAL;

View File

@ -90,13 +90,14 @@ void ScreenComponents::drawBookProgressBar(const GfxRenderer& renderer, const si
renderer.fillRect(vieweableMarginLeft, progressBarY, barWidth, BOOK_PROGRESS_BAR_HEIGHT, true);
}
int ScreenComponents::drawTabBar(const GfxRenderer& renderer, const int y, const std::vector<TabInfo>& tabs, int selectedIndex, bool showCursor) {
constexpr int tabPadding = 20; // Horizontal padding between tabs
constexpr int leftMargin = 20; // Left margin for first tab
constexpr int rightMargin = 20; // Right margin
constexpr int underlineHeight = 2; // Height of selection underline
constexpr int underlineGap = 4; // Gap between text and underline
constexpr int cursorPadding = 4; // Space between bullet cursor and tab text
int ScreenComponents::drawTabBar(const GfxRenderer& renderer, const int y, const std::vector<TabInfo>& tabs,
int selectedIndex, bool showCursor) {
constexpr int tabPadding = 20; // Horizontal padding between tabs
constexpr int leftMargin = 20; // Left margin for first tab
constexpr int rightMargin = 20; // Right margin
constexpr int underlineHeight = 2; // Height of selection underline
constexpr int underlineGap = 4; // Gap between text and underline
constexpr int cursorPadding = 4; // Space between bullet cursor and tab text
constexpr int overflowIndicatorWidth = 16; // Space reserved for < > indicators
const int lineHeight = renderer.getLineHeight(UI_12_FONT_ID);
@ -120,7 +121,8 @@ int ScreenComponents::drawTabBar(const GfxRenderer& renderer, const int y, const
std::vector<int> tabWidths;
int totalWidth = 0;
for (const auto& tab : tabs) {
const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, tab.label, tab.selected ? EpdFontFamily::BOLD : EpdFontFamily::REGULAR);
const int textWidth =
renderer.getTextWidth(UI_12_FONT_ID, tab.label, tab.selected ? EpdFontFamily::BOLD : EpdFontFamily::REGULAR);
tabWidths.push_back(textWidth);
totalWidth += textWidth;
}
@ -151,13 +153,13 @@ int ScreenComponents::drawTabBar(const GfxRenderer& renderer, const int y, const
// Bullet cursor settings
constexpr int bulletRadius = 3;
const int bulletCenterY = y + lineHeight / 2;
// Calculate visible area boundaries (leave room for overflow indicators)
const bool hasLeftOverflow = scrollOffset > 0;
const bool hasRightOverflow = totalWidth > availableWidth && scrollOffset < totalWidth - availableWidth;
const int visibleLeft = bezelLeft + (hasLeftOverflow ? overflowIndicatorWidth : 0);
const int visibleRight = screenWidth - bezelRight - (hasRightOverflow ? overflowIndicatorWidth : 0);
for (size_t i = 0; i < tabs.size(); i++) {
const auto& tab = tabs[i];
const int textWidth = tabWidths[i];
@ -177,7 +179,7 @@ int ScreenComponents::drawTabBar(const GfxRenderer& renderer, const int y, const
}
}
}
// Draw tab label
renderer.drawText(UI_12_FONT_ID, currentX, y, tab.label, true,
tab.selected ? EpdFontFamily::BOLD : EpdFontFamily::REGULAR);
@ -195,7 +197,7 @@ int ScreenComponents::drawTabBar(const GfxRenderer& renderer, const int y, const
}
}
}
// Draw underline for selected tab
if (tab.selected) {
renderer.fillRect(currentX, y + lineHeight + underlineGap, textWidth, underlineHeight);
@ -210,7 +212,7 @@ int ScreenComponents::drawTabBar(const GfxRenderer& renderer, const int y, const
constexpr int triangleHeight = 12; // Height of the triangle (vertical)
constexpr int triangleWidth = 6; // Width of the triangle (horizontal) - thin/elongated
const int triangleCenterY = y + lineHeight / 2;
// Left overflow indicator (more content to the left) - thin triangle pointing left
if (scrollOffset > 0) {
// Clear background behind indicator to hide any overlapping text
@ -220,21 +222,20 @@ int ScreenComponents::drawTabBar(const GfxRenderer& renderer, const int y, const
for (int i = 0; i < triangleWidth; ++i) {
// Scale height based on position (0 at tip, full height at base)
const int lineHalfHeight = (triangleHeight * i) / (triangleWidth * 2);
renderer.drawLine(tipX + i, triangleCenterY - lineHalfHeight,
tipX + i, triangleCenterY + lineHalfHeight);
renderer.drawLine(tipX + i, triangleCenterY - lineHalfHeight, tipX + i, triangleCenterY + lineHalfHeight);
}
}
// Right overflow indicator (more content to the right) - thin triangle pointing right
if (scrollOffset < totalWidth - availableWidth) {
// Clear background behind indicator to hide any overlapping text
renderer.fillRect(screenWidth - bezelRight - overflowIndicatorWidth, y - 2, overflowIndicatorWidth, lineHeight + 4, false);
renderer.fillRect(screenWidth - bezelRight - overflowIndicatorWidth, y - 2, overflowIndicatorWidth,
lineHeight + 4, false);
// Draw right-pointing triangle: base on left, point on right
const int baseX = screenWidth - bezelRight - 2 - triangleWidth;
for (int i = 0; i < triangleWidth; ++i) {
// Scale height based on position (full height at base, 0 at tip)
const int lineHalfHeight = (triangleHeight * (triangleWidth - 1 - i)) / (triangleWidth * 2);
renderer.drawLine(baseX + i, triangleCenterY - lineHalfHeight,
baseX + i, triangleCenterY + lineHalfHeight);
renderer.drawLine(baseX + i, triangleCenterY - lineHalfHeight, baseX + i, triangleCenterY + lineHalfHeight);
}
}
}

View File

@ -25,7 +25,8 @@ class ScreenComponents {
// Returns the height of the tab bar (for positioning content below)
// When selectedIndex is provided, tabs scroll so the selected tab is visible
// When showCursor is true, bullet indicators are drawn around the selected tab
static int drawTabBar(const GfxRenderer& renderer, int y, const std::vector<TabInfo>& tabs, int selectedIndex = -1, bool showCursor = false);
static int drawTabBar(const GfxRenderer& renderer, int y, const std::vector<TabInfo>& tabs, int selectedIndex = -1,
bool showCursor = false);
// Draw a scroll/page indicator on the right side of the screen
// Shows up/down arrows and current page fraction (e.g., "1/3")

View File

@ -12,13 +12,13 @@ class GfxRenderer;
// Helper macro to log stack high-water mark for a task
// Usage: LOG_STACK_WATERMARK("ActivityName", taskHandle);
#define LOG_STACK_WATERMARK(name, handle) \
do { \
if (handle) { \
UBaseType_t remaining = uxTaskGetStackHighWaterMark(handle); \
#define LOG_STACK_WATERMARK(name, handle) \
do { \
if (handle) { \
UBaseType_t remaining = uxTaskGetStackHighWaterMark(handle); \
Serial.printf("[%lu] [STACK] %s: %u bytes remaining\n", millis(), name, remaining * sizeof(StackType_t)); \
} \
} while(0)
} \
} while (0)
class Activity {
protected:

View File

@ -184,7 +184,8 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap, const std::str
drawHeight = 0;
fillWidth = static_cast<int>(bitmap.getWidth());
fillHeight = static_cast<int>(bitmap.getHeight());
Serial.printf("[%lu] [SLP] ACTUAL mode: centering at %d, %d (fill: %dx%d)\n", millis(), x, y, fillWidth, fillHeight);
Serial.printf("[%lu] [SLP] ACTUAL mode: centering at %d, %d (fill: %dx%d)\n", millis(), x, y, fillWidth,
fillHeight);
} else if (coverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::CROP) {
// CROP mode: Scale to fill screen completely (may crop edges)
// Calculate crop values to fill the screen while maintaining aspect ratio
@ -221,8 +222,8 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap, const std::str
// Center the scaled image
x = (pageWidth - fillWidth) / 2;
y = (pageHeight - fillHeight) / 2;
Serial.printf("[%lu] [SLP] FIT mode: scale %f, scaled size %d x %d, position %d, %d\n", millis(), scale,
fillWidth, fillHeight, x, y);
Serial.printf("[%lu] [SLP] FIT mode: scale %f, scaled size %d x %d, position %d, %d\n", millis(), scale, fillWidth,
fillHeight, x, y);
}
// Get edge luminance values (from cache or calculate)
@ -232,9 +233,8 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap, const std::str
const uint8_t leftGray = quantizeGray(edges.left);
const uint8_t rightGray = quantizeGray(edges.right);
Serial.printf("[%lu] [SLP] Edge luminance: T=%d B=%d L=%d R=%d -> gray levels T=%d B=%d L=%d R=%d\n",
millis(), edges.top, edges.bottom, edges.left, edges.right,
topGray, bottomGray, leftGray, rightGray);
Serial.printf("[%lu] [SLP] Edge luminance: T=%d B=%d L=%d R=%d -> gray levels T=%d B=%d L=%d R=%d\n", millis(),
edges.top, edges.bottom, edges.left, edges.right, topGray, bottomGray, leftGray, rightGray);
// Check if greyscale pass should be used (PR #476: skip if filter is applied)
const bool hasGreyscale = bitmap.hasGreyscale() &&
@ -418,10 +418,10 @@ std::string SleepActivity::getEdgeCachePath(const std::string& bmpPath) {
uint8_t SleepActivity::quantizeGray(uint8_t lum) {
// Quantize luminance (0-255) to 4-level grayscale (0-3)
// Thresholds tuned for X4 display gray levels
if (lum < 43) return 0; // black
if (lum < 128) return 1; // dark gray
if (lum < 213) return 2; // light gray
return 3; // white
if (lum < 43) return 0; // black
if (lum < 128) return 1; // dark gray
if (lum < 213) return 2; // light gray
return 3; // white
}
EdgeLuminance SleepActivity::getEdgeLuminance(const Bitmap& bitmap, const std::string& bmpPath) const {
@ -434,8 +434,7 @@ EdgeLuminance SleepActivity::getEdgeLuminance(const Bitmap& bitmap, const std::s
uint8_t cacheData[EDGE_CACHE_SIZE];
if (cacheFile.read(cacheData, EDGE_CACHE_SIZE) == EDGE_CACHE_SIZE) {
// Extract cached file size
const uint32_t cachedSize = static_cast<uint32_t>(cacheData[0]) |
(static_cast<uint32_t>(cacheData[1]) << 8) |
const uint32_t cachedSize = static_cast<uint32_t>(cacheData[0]) | (static_cast<uint32_t>(cacheData[1]) << 8) |
(static_cast<uint32_t>(cacheData[2]) << 16) |
(static_cast<uint32_t>(cacheData[3]) << 24);
@ -448,8 +447,8 @@ EdgeLuminance SleepActivity::getEdgeLuminance(const Bitmap& bitmap, const std::s
result.bottom = cacheData[5];
result.left = cacheData[6];
result.right = cacheData[7];
Serial.printf("[%lu] [SLP] Edge cache hit for %s: T=%d B=%d L=%d R=%d\n", millis(), bmpPath.c_str(),
result.top, result.bottom, result.left, result.right);
Serial.printf("[%lu] [SLP] Edge cache hit for %s: T=%d B=%d L=%d R=%d\n", millis(), bmpPath.c_str(), result.top,
result.bottom, result.left, result.right);
cacheFile.close();
return result;
}
@ -462,8 +461,8 @@ EdgeLuminance SleepActivity::getEdgeLuminance(const Bitmap& bitmap, const std::s
// Cache miss - calculate edge luminance
Serial.printf("[%lu] [SLP] Calculating edge luminance for %s\n", millis(), bmpPath.c_str());
result = bitmap.detectEdgeLuminance(2); // Sample 2 pixels deep for stability
Serial.printf("[%lu] [SLP] Edge luminance detected: T=%d B=%d L=%d R=%d\n", millis(),
result.top, result.bottom, result.left, result.right);
Serial.printf("[%lu] [SLP] Edge luminance detected: T=%d B=%d L=%d R=%d\n", millis(), result.top, result.bottom,
result.left, result.right);
// Get BMP file size from already-opened bitmap for cache
const uint32_t fileSize = bitmap.getFileSize();
@ -534,8 +533,7 @@ bool SleepActivity::tryRenderCachedCoverSleep(const std::string& bookPath, bool
cacheFile.close();
// Extract cached values
const uint32_t cachedBmpSize = static_cast<uint32_t>(cacheData[0]) |
(static_cast<uint32_t>(cacheData[1]) << 8) |
const uint32_t cachedBmpSize = static_cast<uint32_t>(cacheData[0]) | (static_cast<uint32_t>(cacheData[1]) << 8) |
(static_cast<uint32_t>(cacheData[2]) << 16) |
(static_cast<uint32_t>(cacheData[3]) << 24);
EdgeLuminance cachedEdges;
@ -548,8 +546,8 @@ bool SleepActivity::tryRenderCachedCoverSleep(const std::string& bookPath, bool
// Check if cover mode matches (for EPUB)
const uint8_t currentCoverMode = cropped ? 1 : 0;
if (StringUtils::checkFileExtension(bookPath, ".epub") && cachedCoverMode != currentCoverMode) {
Serial.printf("[SLP] Cover mode changed (cached=%d, current=%d), invalidating cache\n",
cachedCoverMode, currentCoverMode);
Serial.printf("[SLP] Cover mode changed (cached=%d, current=%d), invalidating cache\n", cachedCoverMode,
currentCoverMode);
return false;
}
@ -571,8 +569,8 @@ bool SleepActivity::tryRenderCachedCoverSleep(const std::string& bookPath, bool
// Check if BMP file size matches cache
const uint32_t currentBmpSize = bmpFile.size();
if (currentBmpSize != cachedBmpSize || currentBmpSize == 0) {
Serial.printf("[SLP] BMP size mismatch (cached=%lu, current=%lu)\n",
static_cast<unsigned long>(cachedBmpSize), static_cast<unsigned long>(currentBmpSize));
Serial.printf("[SLP] BMP size mismatch (cached=%lu, current=%lu)\n", static_cast<unsigned long>(cachedBmpSize),
static_cast<unsigned long>(currentBmpSize));
bmpFile.close();
return false;
}
@ -585,8 +583,8 @@ bool SleepActivity::tryRenderCachedCoverSleep(const std::string& bookPath, bool
return false;
}
Serial.printf("[%lu] [SLP] Using cached cover sleep: %s (T=%d B=%d L=%d R=%d)\n", millis(),
coverBmpPath.c_str(), cachedEdges.top, cachedEdges.bottom, cachedEdges.left, cachedEdges.right);
Serial.printf("[%lu] [SLP] Using cached cover sleep: %s (T=%d B=%d L=%d R=%d)\n", millis(), coverBmpPath.c_str(),
cachedEdges.top, cachedEdges.bottom, cachedEdges.left, cachedEdges.right);
// Render the bitmap with cached edge values
// We call renderBitmapSleepScreen which will use getEdgeLuminance internally,

View File

@ -1,10 +1,10 @@
#pragma once
#include "../Activity.h"
#include <Bitmap.h>
#include <string>
#include "../Activity.h"
class SleepActivity final : public Activity {
public:
explicit SleepActivity(GfxRenderer& renderer, MappedInputManager& mappedInput)

View File

@ -236,7 +236,8 @@ void OpdsBookBrowserActivity::render() const {
}
const auto pageStartIndex = selectorIndex / PAGE_ITEMS * PAGE_ITEMS;
renderer.fillRect(bezelLeft, 60 + bezelTop + (selectorIndex % PAGE_ITEMS) * 30 - 2, pageWidth - 1 - bezelLeft - bezelRight, 30);
renderer.fillRect(bezelLeft, 60 + bezelTop + (selectorIndex % PAGE_ITEMS) * 30 - 2,
pageWidth - 1 - bezelLeft - bezelRight, 30);
for (size_t i = pageStartIndex; i < entries.size() && i < static_cast<size_t>(pageStartIndex + PAGE_ITEMS); i++) {
const auto& entry = entries[i];
@ -253,7 +254,8 @@ void OpdsBookBrowserActivity::render() const {
}
}
auto item = renderer.truncatedText(UI_10_FONT_ID, displayText.c_str(), renderer.getScreenWidth() - 40 - bezelLeft - bezelRight);
auto item = renderer.truncatedText(UI_10_FONT_ID, displayText.c_str(),
renderer.getScreenWidth() - 40 - bezelLeft - bezelRight);
renderer.drawText(UI_10_FONT_ID, 20 + bezelLeft, 60 + bezelTop + (i % PAGE_ITEMS) * 30, item.c_str(),
i != static_cast<size_t>(selectorIndex));
}

View File

@ -9,8 +9,7 @@
namespace {
constexpr int MAX_MENU_ITEM_COUNT = 2;
const char* MENU_ITEMS[MAX_MENU_ITEM_COUNT] = {"Select from Screen", "Enter a Word"};
const char* MENU_DESCRIPTIONS[MAX_MENU_ITEM_COUNT] = {"Choose a word from the current page",
"Type a word to look up"};
const char* MENU_DESCRIPTIONS[MAX_MENU_ITEM_COUNT] = {"Choose a word from the current page", "Type a word to look up"};
} // namespace
void DictionaryMenuActivity::taskTrampoline(void* param) {
@ -64,8 +63,7 @@ void DictionaryMenuActivity::loop() {
// Handle confirm button - select current option
// Use wasReleased to consume the full button event
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
const DictionaryMode mode =
(selectedIndex == 0) ? DictionaryMode::SELECT_FROM_SCREEN : DictionaryMode::ENTER_WORD;
const DictionaryMode mode = (selectedIndex == 0) ? DictionaryMode::SELECT_FROM_SCREEN : DictionaryMode::ENTER_WORD;
onModeSelected(mode);
return;
}

View File

@ -93,8 +93,8 @@ void DictionaryResultActivity::paginateDefinition() {
const auto pageHeight = renderer.getScreenHeight();
// Calculate available area for text (must match render() layout)
constexpr int headerHeight = 80; // Space for word and header (relative to marginTop)
constexpr int footerHeight = 30; // Space for page indicator
constexpr int headerHeight = 80; // Space for word and header (relative to marginTop)
constexpr int footerHeight = 30; // Space for page indicator
const int textMargin = marginLeft + 10;
const int textWidth = pageWidth - textMargin - marginRight - 10;
const int textHeight = pageHeight - marginTop - marginBottom - headerHeight - footerHeight;

View File

@ -1,10 +1,9 @@
#pragma once
#include <Epub/blocks/TextBlock.h>
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <freertos/task.h>
#include <Epub/blocks/TextBlock.h>
#include <functional>
#include <memory>
#include <string>
@ -48,8 +47,7 @@ class DictionaryResultActivity final : public Activity {
*/
explicit DictionaryResultActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const std::string& wordToLookup, const std::string& definition,
const std::function<void()>& onBack,
const std::function<void()>& onSearchAnother)
const std::function<void()>& onBack, const std::function<void()>& onSearchAnother)
: Activity("DictionaryResult", renderer, mappedInput),
lookupWord(wordToLookup),
rawDefinition(definition),

View File

@ -77,9 +77,8 @@ void EpubWordSelectionActivity::buildWordList() {
while (wordIt != words.end() && xPosIt != xPositions.end() && styleIt != styles.end()) {
// Skip whitespace-only words
const std::string& wordText = *wordIt;
const bool hasAlpha = std::any_of(wordText.begin(), wordText.end(), [](char c) {
return std::isalpha(static_cast<unsigned char>(c));
});
const bool hasAlpha = std::any_of(wordText.begin(), wordText.end(),
[](char c) { return std::isalpha(static_cast<unsigned char>(c)); });
if (hasAlpha) {
WordInfo info;
@ -249,7 +248,8 @@ void EpubWordSelectionActivity::render() const {
// Draw instruction text - position it just above the front button area
const auto screenHeight = renderer.getScreenHeight();
renderer.drawCenteredText(SMALL_FONT_ID, screenHeight - marginBottom - 10, "Navigate with arrows, select with confirm");
renderer.drawCenteredText(SMALL_FONT_ID, screenHeight - marginBottom - 10,
"Navigate with arrows, select with confirm");
// Draw button hints
const auto labels = mappedInput.mapLabels("\xc2\xab Cancel", "Select", "< >", "");

View File

@ -39,9 +39,7 @@ int BookmarkListActivity::getCurrentPage() const {
return selectorIndex / pageItems + 1;
}
void BookmarkListActivity::loadBookmarks() {
bookmarks = BookmarkStore::getBookmarks(bookPath);
}
void BookmarkListActivity::loadBookmarks() { bookmarks = BookmarkStore::getBookmarks(bookPath); }
void BookmarkListActivity::taskTrampoline(void* param) {
auto* self = static_cast<BookmarkListActivity*>(param);
@ -95,7 +93,7 @@ void BookmarkListActivity::loop() {
const auto& bm = bookmarks[selectorIndex];
BookmarkStore::removeBookmark(bookPath, bm.spineIndex, bm.contentOffset);
loadBookmarks();
// Adjust selector if needed
if (selectorIndex >= static_cast<int>(bookmarks.size()) && !bookmarks.empty()) {
selectorIndex = static_cast<int>(bookmarks.size()) - 1;
@ -115,9 +113,8 @@ void BookmarkListActivity::loop() {
const int itemCount = static_cast<int>(bookmarks.size());
// Long press Confirm to delete bookmark
if (mappedInput.isPressed(MappedInputManager::Button::Confirm) &&
mappedInput.getHeldTime() >= ACTION_MENU_MS && !bookmarks.empty() &&
selectorIndex < itemCount) {
if (mappedInput.isPressed(MappedInputManager::Button::Confirm) && mappedInput.getHeldTime() >= ACTION_MENU_MS &&
!bookmarks.empty() && selectorIndex < itemCount) {
uiState = UIState::Confirming;
updateRequired = true;
return;
@ -128,7 +125,7 @@ void BookmarkListActivity::loop() {
if (mappedInput.getHeldTime() >= ACTION_MENU_MS) {
return; // Was a long press
}
if (!bookmarks.empty() && selectorIndex < itemCount) {
const auto& bm = bookmarks[selectorIndex];
onSelectBookmark(bm.spineIndex, bm.contentOffset);
@ -190,12 +187,14 @@ void BookmarkListActivity::render() const {
const int RIGHT_MARGIN = BASE_RIGHT_MARGIN + bezelRight;
// Draw title
auto truncatedTitle = renderer.truncatedText(UI_12_FONT_ID, bookTitle.c_str(), pageWidth - LEFT_MARGIN - RIGHT_MARGIN);
renderer.drawText(UI_12_FONT_ID, LEFT_MARGIN, BASE_TAB_BAR_Y + bezelTop, truncatedTitle.c_str(), true, EpdFontFamily::BOLD);
auto truncatedTitle =
renderer.truncatedText(UI_12_FONT_ID, bookTitle.c_str(), pageWidth - LEFT_MARGIN - RIGHT_MARGIN);
renderer.drawText(UI_12_FONT_ID, LEFT_MARGIN, BASE_TAB_BAR_Y + bezelTop, truncatedTitle.c_str(), true,
EpdFontFamily::BOLD);
if (itemCount == 0) {
renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, CONTENT_START_Y, "No bookmarks");
const auto labels = mappedInput.mapLabels("\xc2\xab Back", "", "", "");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer();

View File

@ -50,10 +50,10 @@ class BookmarkListActivity final : public Activity {
void renderConfirmation() const;
public:
explicit BookmarkListActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const std::string& bookPath, const std::string& bookTitle,
const std::function<void()>& onGoBack,
const std::function<void(uint16_t spineIndex, uint32_t contentOffset)>& onSelectBookmark)
explicit BookmarkListActivity(
GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& bookPath, const std::string& bookTitle,
const std::function<void()>& onGoBack,
const std::function<void(uint16_t spineIndex, uint32_t contentOffset)>& onSelectBookmark)
: Activity("BookmarkList", renderer, mappedInput),
bookPath(bookPath),
bookTitle(bookTitle),

View File

@ -157,7 +157,7 @@ bool HomeActivity::storeCoverBuffer() {
}
const size_t bufferSize = GfxRenderer::getBufferSize();
// Reuse existing buffer if already allocated (avoids fragmentation from free+malloc)
if (!coverBuffer) {
coverBuffer = static_cast<uint8_t*>(malloc(bufferSize));
@ -270,7 +270,7 @@ bool HomeActivity::preloadCoverBuffer() {
cachedCoverPath = thumbPath;
coverBufferStored = false; // Will be set true after actual render in HomeActivity
coverRendered = false; // Will trigger load from disk in render()
Serial.printf("[%lu] [HOME] [MEM] Cover buffer pre-allocated for: %s\n", millis(), thumbPath.c_str());
return true;
}
@ -374,8 +374,7 @@ void HomeActivity::render() {
constexpr int menuSpacing = 8;
const int halfTileWidth = (menuTileWidth - menuSpacing) / 2; // Account for spacing between halves
// 1 row for split buttons + full-width rows
const int totalMenuHeight =
menuTileHeight + static_cast<int>(fullWidthItems.size()) * (menuTileHeight + menuSpacing);
const int totalMenuHeight = menuTileHeight + static_cast<int>(fullWidthItems.size()) * (menuTileHeight + menuSpacing);
// Anchor menu to bottom of screen
const int menuStartY = pageHeight - bottomMargin - totalMenuHeight;
@ -581,8 +580,7 @@ void HomeActivity::render() {
// Still have words left, so add ellipsis to last line
lines.back().append("...");
while (!lines.back().empty() &&
renderer.getTextWidth(UI_12_FONT_ID, lines.back().c_str()) > maxLineWidth) {
while (!lines.back().empty() && renderer.getTextWidth(UI_12_FONT_ID, lines.back().c_str()) > maxLineWidth) {
// Remove "..." first, then remove one UTF-8 char, then add "..." back
lines.back().resize(lines.back().size() - 3); // Remove "..."
StringUtils::utf8RemoveLastChar(lines.back());
@ -690,7 +688,8 @@ void HomeActivity::render() {
// Truncate lists label if needed
std::string truncatedLabel = listsLabel;
const int maxLabelWidth = halfTileWidth - 16; // Padding
while (renderer.getTextWidth(UI_10_FONT_ID, truncatedLabel.c_str()) > maxLabelWidth && truncatedLabel.length() > 3) {
while (renderer.getTextWidth(UI_10_FONT_ID, truncatedLabel.c_str()) > maxLabelWidth &&
truncatedLabel.length() > 3) {
truncatedLabel.resize(truncatedLabel.length() - 4);
truncatedLabel += "...";
}
@ -750,7 +749,7 @@ void HomeActivity::render() {
// Draw battery in bottom-left where the back button hint would normally be
const bool showBatteryPercentage =
SETTINGS.hideBatteryPercentage != CrossPointSettings::HIDE_BATTERY_PERCENTAGE::HIDE_ALWAYS;
constexpr int batteryX = 25; // Align with first button hint position
constexpr int batteryX = 25; // Align with first button hint position
const int batteryY = pageHeight - 34; // Vertically centered in button hint area
ScreenComponents::drawBatteryLarge(renderer, batteryX, batteryY, showBatteryPercentage);

View File

@ -17,9 +17,9 @@ class HomeActivity final : public Activity {
bool hasCoverImage = false;
// Static cover buffer - persists across activity changes to avoid reloading from SD
static bool coverRendered; // Track if cover has been rendered once
static bool coverBufferStored; // Track if cover buffer is stored
static uint8_t* coverBuffer; // HomeActivity's own buffer for cover image
static bool coverRendered; // Track if cover has been rendered once
static bool coverBufferStored; // Track if cover buffer is stored
static uint8_t* coverBuffer; // HomeActivity's own buffer for cover image
static std::string cachedCoverPath; // Path of the cached cover (to detect book changes)
std::string lastBookTitle;
@ -43,15 +43,14 @@ class HomeActivity final : public Activity {
public:
// Free cover buffer from external activities (e.g., when entering reader to reclaim memory)
static void freeCoverBufferIfAllocated();
// Preload cover buffer from external activities (e.g., MyLibraryActivity) for instant Home screen
// Returns true if cover was successfully preloaded or already cached
static bool preloadCoverBuffer();
explicit HomeActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const std::function<void()>& onContinueReading, const std::function<void()>& onListsOpen,
const std::function<void()>& onMyLibraryOpen, const std::function<void()>& onSettingsOpen,
const std::function<void()>& onFileTransferOpen,
const std::function<void()>& onOpdsBrowserOpen)
const std::function<void()>& onFileTransferOpen, const std::function<void()>& onOpdsBrowserOpen)
: Activity("Home", renderer, mappedInput),
onContinueReading(onContinueReading),
onListsOpen(onListsOpen),

View File

@ -202,8 +202,8 @@ void ListViewActivity::render() const {
const auto pageStartIndex = selectorIndex / pageItems * pageItems;
// Draw selection highlight
renderer.fillRect(bezelLeft, CONTENT_START_Y + (selectorIndex % pageItems) * LINE_HEIGHT - 2, pageWidth - RIGHT_MARGIN - bezelLeft,
LINE_HEIGHT);
renderer.fillRect(bezelLeft, CONTENT_START_Y + (selectorIndex % pageItems) * LINE_HEIGHT - 2,
pageWidth - RIGHT_MARGIN - bezelLeft, LINE_HEIGHT);
// Calculate available text width
const int textMaxWidth = pageWidth - LEFT_MARGIN - RIGHT_MARGIN - MICRO_THUMB_WIDTH - 10;
@ -262,8 +262,8 @@ void ListViewActivity::render() const {
}
// Extract tags for badges (only if we'll show them - when NOT selected)
constexpr int badgeSpacing = 4; // Gap between badges
constexpr int badgePadding = 10; // Horizontal padding inside badge (5 each side)
constexpr int badgeSpacing = 4; // Gap between badges
constexpr int badgePadding = 10; // Horizontal padding inside badge (5 each side)
constexpr int badgeToThumbGap = 8; // Gap between rightmost badge and cover art
int totalBadgeWidth = 0;
BookTags tags;
@ -302,8 +302,8 @@ void ListViewActivity::render() const {
const int badgeY = y + 2 + (titleLineHeight - badgeHeight) / 2;
if (!tags.extensionTag.empty()) {
int badgeWidth = ScreenComponents::drawPillBadge(renderer, badgeX, badgeY, tags.extensionTag.c_str(),
SMALL_FONT_ID, false);
int badgeWidth =
ScreenComponents::drawPillBadge(renderer, badgeX, badgeY, tags.extensionTag.c_str(), SMALL_FONT_ID, false);
badgeX += badgeWidth + badgeSpacing;
}
if (!tags.suffixTag.empty()) {

View File

@ -85,22 +85,23 @@ int MyLibraryActivity::getPageItems() const {
const int bottomBarHeight = 60; // Space for button hints
const int bezelTop = renderer.getBezelOffsetTop();
const int bezelBottom = renderer.getBezelOffsetBottom();
// Search tab has compact layout: character picker (~30px) + query (~25px) + results
if (currentTab == Tab::Search) {
// Character picker: ~30px, Query: ~25px = 55px overhead
// Much more room for results than the old 5-row keyboard
constexpr int SEARCH_OVERHEAD = 55;
const int availableHeight = screenHeight - (BASE_CONTENT_START_Y + bezelTop) - bottomBarHeight - bezelBottom - SEARCH_OVERHEAD;
const int availableHeight =
screenHeight - (BASE_CONTENT_START_Y + bezelTop) - bottomBarHeight - bezelBottom - SEARCH_OVERHEAD;
int items = availableHeight / RECENTS_LINE_HEIGHT;
if (items < 1) items = 1;
return items;
}
const int availableHeight = screenHeight - (BASE_CONTENT_START_Y + bezelTop) - bottomBarHeight - bezelBottom;
// Recent and Bookmarks tabs use taller items (title + author), Lists and Files use single-line items
const int lineHeight = (currentTab == Tab::Recent || currentTab == Tab::Bookmarks)
? RECENTS_LINE_HEIGHT : LINE_HEIGHT;
const int lineHeight =
(currentTab == Tab::Recent || currentTab == Tab::Bookmarks) ? RECENTS_LINE_HEIGHT : LINE_HEIGHT;
int items = availableHeight / lineHeight;
if (items < 1) {
items = 1;
@ -152,7 +153,7 @@ void MyLibraryActivity::loadLists() { lists = BookListStore::listAllLists(); }
void MyLibraryActivity::loadBookmarkedBooks() {
bookmarkedBooks = BookmarkStore::getBooksWithBookmarks();
// Try to get better metadata from recent books
for (auto& book : bookmarkedBooks) {
auto it = std::find_if(recentBooks.begin(), recentBooks.end(),
@ -167,7 +168,7 @@ void MyLibraryActivity::loadBookmarkedBooks() {
void MyLibraryActivity::loadAllBooks() {
// Build index of all books on SD card for search
allBooks.clear();
// Helper lambda to recursively scan directories
std::function<void(const std::string&)> scanDirectory = [&](const std::string& path) {
auto dir = SdMan.open(path.c_str());
@ -175,37 +176,36 @@ void MyLibraryActivity::loadAllBooks() {
if (dir) dir.close();
return;
}
dir.rewindDirectory();
char name[500];
for (auto file = dir.openNextFile(); file; file = dir.openNextFile()) {
file.getName(name, sizeof(name));
if (name[0] == '.' || strcmp(name, "System Volume Information") == 0) {
file.close();
continue;
}
std::string fullPath = (path.back() == '/') ? path + name : path + "/" + name;
if (file.isDirectory()) {
file.close();
scanDirectory(fullPath);
} else {
auto filename = std::string(name);
if (StringUtils::checkFileExtension(filename, ".epub") ||
StringUtils::checkFileExtension(filename, ".txt") ||
if (StringUtils::checkFileExtension(filename, ".epub") || StringUtils::checkFileExtension(filename, ".txt") ||
StringUtils::checkFileExtension(filename, ".md")) {
SearchResult result;
result.path = fullPath;
// Extract title from filename (remove extension)
result.title = filename;
const size_t dot = result.title.find_last_of('.');
if (dot != std::string::npos) {
result.title.resize(dot);
}
// Try to get metadata from recent books if available
auto it = std::find_if(recentBooks.begin(), recentBooks.end(),
[&fullPath](const RecentBook& recent) { return recent.path == fullPath; });
@ -213,7 +213,7 @@ void MyLibraryActivity::loadAllBooks() {
if (!it->title.empty()) result.title = it->title;
if (!it->author.empty()) result.author = it->author;
}
allBooks.push_back(result);
}
file.close();
@ -221,16 +221,15 @@ void MyLibraryActivity::loadAllBooks() {
}
dir.close();
};
scanDirectory("/");
// Sort alphabetically by title
std::sort(allBooks.begin(), allBooks.end(), [](const SearchResult& a, const SearchResult& b) {
return lexicographical_compare(
a.title.begin(), a.title.end(), b.title.begin(), b.title.end(),
[](char c1, char c2) { return tolower(c1) < tolower(c2); });
return lexicographical_compare(a.title.begin(), a.title.end(), b.title.begin(), b.title.end(),
[](char c1, char c2) { return tolower(c1) < tolower(c2); });
});
// Build character set after loading books
buildSearchCharacters();
}
@ -238,7 +237,7 @@ void MyLibraryActivity::loadAllBooks() {
void MyLibraryActivity::buildSearchCharacters() {
// Build a set of unique characters from all book titles and authors
std::set<char> charSet;
for (const auto& book : allBooks) {
for (char c : book.title) {
// Convert to uppercase for display, store as uppercase
@ -262,29 +261,29 @@ void MyLibraryActivity::buildSearchCharacters() {
}
}
}
// Convert set to vector, sorted: A-Z, then 0-9, then symbols
searchCharacters.clear();
// Add letters A-Z
for (char c = 'A'; c <= 'Z'; c++) {
if (charSet.count(c)) {
searchCharacters.push_back(c);
}
}
// Add digits 0-9
for (char c = '0'; c <= '9'; c++) {
if (charSet.count(c)) {
searchCharacters.push_back(c);
}
}
// Add symbols (anything else in the set)
std::copy_if(charSet.begin(), charSet.end(), std::back_inserter(searchCharacters), [](char c) {
return !std::isalpha(static_cast<unsigned char>(c)) && !std::isdigit(static_cast<unsigned char>(c));
});
// Reset character index if it's out of bounds
if (searchCharIndex >= static_cast<int>(searchCharacters.size()) + 3) { // +3 for special keys
searchCharIndex = 0;
@ -293,17 +292,17 @@ void MyLibraryActivity::buildSearchCharacters() {
void MyLibraryActivity::updateSearchResults() {
searchResults.clear();
if (searchQuery.empty()) {
// Don't show any results when query is empty - user needs to type something
return;
}
// Convert query to lowercase for case-insensitive matching
std::string queryLower = searchQuery;
std::transform(queryLower.begin(), queryLower.end(), queryLower.begin(),
[](unsigned char c) { return std::tolower(c); });
for (const auto& book : allBooks) {
// Convert title, author, and path to lowercase
std::string titleLower = book.title;
@ -315,9 +314,9 @@ void MyLibraryActivity::updateSearchResults() {
[](unsigned char c) { return std::tolower(c); });
std::transform(pathLower.begin(), pathLower.end(), pathLower.begin(),
[](unsigned char c) { return std::tolower(c); });
int score = 0;
// Check for matches
if (titleLower.find(queryLower) != std::string::npos) {
score += 100;
@ -331,19 +330,17 @@ void MyLibraryActivity::updateSearchResults() {
if (pathLower.find(queryLower) != std::string::npos) {
score += 30;
}
if (score > 0) {
SearchResult result = book;
result.matchScore = score;
searchResults.push_back(result);
}
}
// Sort by match score (descending)
std::sort(searchResults.begin(), searchResults.end(),
[](const SearchResult& a, const SearchResult& b) {
return a.matchScore > b.matchScore;
});
std::sort(searchResults.begin(), searchResults.end(),
[](const SearchResult& a, const SearchResult& b) { return a.matchScore > b.matchScore; });
}
void MyLibraryActivity::loadFiles() {
@ -407,14 +404,14 @@ void MyLibraryActivity::onEnter() {
loadFiles();
selectorIndex = 0;
// If entering Search tab, start in character picker mode
if (currentTab == Tab::Search) {
searchInResults = false;
inTabBar = false;
searchCharIndex = 0;
}
updateRequired = true;
xTaskCreate(&MyLibraryActivity::taskTrampoline, "MyLibraryActivityTask",
@ -491,7 +488,7 @@ void MyLibraryActivity::openActionMenu() {
}
uiState = UIState::ActionMenu;
menuSelection = 0; // Default to Archive
menuSelection = 0; // Default to Archive
ignoreNextConfirmRelease = true; // Ignore the release from the long-press that opened this menu
updateRequired = true;
}
@ -766,7 +763,7 @@ void MyLibraryActivity::loop() {
updateRequired = true;
return;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Right)) {
switch (currentTab) {
case Tab::Recent:
@ -788,7 +785,7 @@ void MyLibraryActivity::loop() {
updateRequired = true;
return;
}
// Down exits tab bar, enters list at top
if (mappedInput.wasReleased(MappedInputManager::Button::Down)) {
inTabBar = false;
@ -796,7 +793,7 @@ void MyLibraryActivity::loop() {
updateRequired = true;
return;
}
// Up exits tab bar, jumps to bottom of list
if (mappedInput.wasReleased(MappedInputManager::Button::Up)) {
inTabBar = false;
@ -806,13 +803,13 @@ void MyLibraryActivity::loop() {
updateRequired = true;
return;
}
// Back goes home
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
onGoHome();
return;
}
return;
}
@ -820,7 +817,7 @@ void MyLibraryActivity::loop() {
if (currentTab == Tab::Search) {
const int charCount = static_cast<int>(searchCharacters.size());
const int totalPickerItems = charCount + 3; // +3 for SPC, <-, CLR
if (inTabBar) {
// In tab bar mode - Left/Right switch tabs, Down goes to picker
// Use wasReleased for consistency with other tab switching code
@ -830,21 +827,21 @@ void MyLibraryActivity::loop() {
updateRequired = true;
return;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Right)) {
currentTab = Tab::Files;
selectorIndex = 0;
updateRequired = true;
return;
}
// Down exits tab bar, goes to character picker
if (mappedInput.wasReleased(MappedInputManager::Button::Down)) {
inTabBar = false;
updateRequired = true;
return;
}
// Up exits tab bar, jumps to bottom of results (if any)
if (mappedInput.wasReleased(MappedInputManager::Button::Up)) {
inTabBar = false;
@ -855,33 +852,31 @@ void MyLibraryActivity::loop() {
updateRequired = true;
return;
}
// Back goes home
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
onGoHome();
return;
}
return;
} else if (!searchInResults) {
// In character picker mode
// Long press Left = jump to start
if (mappedInput.isPressed(MappedInputManager::Button::Left) &&
mappedInput.getHeldTime() >= 700) {
if (mappedInput.isPressed(MappedInputManager::Button::Left) && mappedInput.getHeldTime() >= 700) {
searchCharIndex = 0;
updateRequired = true;
return;
}
// Long press Right = jump to end
if (mappedInput.isPressed(MappedInputManager::Button::Right) &&
mappedInput.getHeldTime() >= 700) {
if (mappedInput.isPressed(MappedInputManager::Button::Right) && mappedInput.getHeldTime() >= 700) {
searchCharIndex = totalPickerItems - 1;
updateRequired = true;
return;
}
// Left/Right navigate through characters (with wrap)
if (mappedInput.wasPressed(MappedInputManager::Button::Left)) {
if (searchCharIndex > 0) {
@ -892,7 +887,7 @@ void MyLibraryActivity::loop() {
updateRequired = true;
return;
}
if (mappedInput.wasPressed(MappedInputManager::Button::Right)) {
if (searchCharIndex < totalPickerItems - 1) {
searchCharIndex++;
@ -902,7 +897,7 @@ void MyLibraryActivity::loop() {
updateRequired = true;
return;
}
// Down moves to results (if any exist)
if (mappedInput.wasPressed(MappedInputManager::Button::Down)) {
if (!searchResults.empty()) {
@ -912,14 +907,14 @@ void MyLibraryActivity::loop() {
}
return;
}
// Up moves to tab bar
if (mappedInput.wasReleased(MappedInputManager::Button::Up)) {
inTabBar = true;
updateRequired = true;
return;
}
// Confirm adds selected character or performs special action
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (searchCharIndex < charCount) {
@ -944,10 +939,9 @@ void MyLibraryActivity::loop() {
updateRequired = true;
return;
}
// Long press Back = clear entire query
if (mappedInput.isPressed(MappedInputManager::Button::Back) &&
mappedInput.getHeldTime() >= 700) {
if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= 700) {
if (!searchQuery.empty()) {
searchQuery.clear();
updateSearchResults();
@ -955,7 +949,7 @@ void MyLibraryActivity::loop() {
}
return;
}
// Short press Back = backspace (delete one char)
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
if (mappedInput.getHeldTime() >= 700) {
@ -972,29 +966,27 @@ void MyLibraryActivity::loop() {
}
return;
}
return; // Don't process other input while in picker
} else {
// In results mode
// Long press PageBack (side button) = jump to first result
if (mappedInput.isPressed(MappedInputManager::Button::PageBack) &&
mappedInput.getHeldTime() >= 700) {
if (mappedInput.isPressed(MappedInputManager::Button::PageBack) && mappedInput.getHeldTime() >= 700) {
selectorIndex = 0;
updateRequired = true;
return;
}
// Long press PageForward (side button) = jump to last result
if (mappedInput.isPressed(MappedInputManager::Button::PageForward) &&
mappedInput.getHeldTime() >= 700) {
if (mappedInput.isPressed(MappedInputManager::Button::PageForward) && mappedInput.getHeldTime() >= 700) {
if (!searchResults.empty()) {
selectorIndex = static_cast<int>(searchResults.size()) - 1;
}
updateRequired = true;
return;
}
// Up/Down navigate through results
if (mappedInput.wasPressed(MappedInputManager::Button::Up)) {
if (selectorIndex > 0) {
@ -1006,7 +998,7 @@ void MyLibraryActivity::loop() {
updateRequired = true;
return;
}
if (mappedInput.wasPressed(MappedInputManager::Button::Down)) {
if (selectorIndex < static_cast<int>(searchResults.size()) - 1) {
selectorIndex++;
@ -1017,13 +1009,13 @@ void MyLibraryActivity::loop() {
updateRequired = true;
return;
}
// Left/Right do nothing in results (or could page?)
if (mappedInput.wasPressed(MappedInputManager::Button::Left) ||
mappedInput.wasPressed(MappedInputManager::Button::Right)) {
return;
}
// Confirm opens the selected book
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (!searchResults.empty() && selectorIndex < static_cast<int>(searchResults.size())) {
@ -1031,14 +1023,14 @@ void MyLibraryActivity::loop() {
}
return;
}
// Back button - go back to character picker
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
searchInResults = false;
updateRequired = true;
return;
}
return; // Don't process other input
}
}
@ -1065,8 +1057,8 @@ void MyLibraryActivity::loop() {
}
// Long press Confirm to open action menu (only for files, not directories)
if (mappedInput.isPressed(MappedInputManager::Button::Confirm) &&
mappedInput.getHeldTime() >= ACTION_MENU_MS && isSelectedItemAFile()) {
if (mappedInput.isPressed(MappedInputManager::Button::Confirm) && mappedInput.getHeldTime() >= ACTION_MENU_MS &&
isSelectedItemAFile()) {
openActionMenu();
return;
}
@ -1096,7 +1088,7 @@ void MyLibraryActivity::loop() {
} else if (currentTab == Tab::Files && selectorIndex == static_cast<int>(files.size())) {
isSearchShortcut = true;
}
if (isSearchShortcut) {
// Switch to Search tab with character picker active
currentTab = Tab::Search;
@ -1107,7 +1099,7 @@ void MyLibraryActivity::loop() {
updateRequired = true;
return;
}
if (currentTab == Tab::Recent) {
if (!recentBooks.empty() && selectorIndex < static_cast<int>(recentBooks.size())) {
onSelectBook(recentBooks[selectorIndex].path, currentTab);
@ -1258,14 +1250,14 @@ void MyLibraryActivity::loop() {
void MyLibraryActivity::displayTaskLoop() {
bool coverPreloaded = false;
while (true) {
if (updateRequired) {
updateRequired = false;
xSemaphoreTake(renderingMutex, portMAX_DELAY);
render();
xSemaphoreGive(renderingMutex);
// After first render, pre-allocate cover buffer for Home screen
// This happens in background so Home screen loads faster when user navigates there
if (!coverPreloaded) {
@ -1487,8 +1479,8 @@ void MyLibraryActivity::renderRecentTab() const {
}
// Extract tags for badges (only if we'll show them - when NOT selected)
constexpr int badgeSpacing = 4; // Gap between badges
constexpr int badgePadding = 10; // Horizontal padding inside badge (5 each side)
constexpr int badgeSpacing = 4; // Gap between badges
constexpr int badgePadding = 10; // Horizontal padding inside badge (5 each side)
constexpr int badgeToThumbGap = 8; // Gap between rightmost badge and cover art
int totalBadgeWidth = 0;
BookTags tags;
@ -1527,8 +1519,8 @@ void MyLibraryActivity::renderRecentTab() const {
const int badgeY = y + 2 + (titleLineHeight - badgeHeight) / 2;
if (!tags.extensionTag.empty()) {
int badgeWidth = ScreenComponents::drawPillBadge(renderer, badgeX, badgeY, tags.extensionTag.c_str(),
SMALL_FONT_ID, false);
int badgeWidth =
ScreenComponents::drawPillBadge(renderer, badgeX, badgeY, tags.extensionTag.c_str(), SMALL_FONT_ID, false);
badgeX += badgeWidth + badgeSpacing;
}
if (!tags.suffixTag.empty()) {
@ -1542,7 +1534,7 @@ void MyLibraryActivity::renderRecentTab() const {
renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, y + 32, truncatedAuthor.c_str(), !isSelected);
}
}
// Draw "Search..." shortcut if it's on the current page
const int searchIndex = bookCount; // Last item
if (searchIndex >= pageStartIndex && searchIndex < pageStartIndex + pageItems) {
@ -1581,8 +1573,8 @@ void MyLibraryActivity::renderListsTab() const {
const auto pageStartIndex = selectorIndex / pageItems * pageItems;
// Draw selection highlight
renderer.fillRect(bezelLeft, CONTENT_START_Y + (selectorIndex % pageItems) * LINE_HEIGHT - 2, pageWidth - RIGHT_MARGIN - bezelLeft,
LINE_HEIGHT);
renderer.fillRect(bezelLeft, CONTENT_START_Y + (selectorIndex % pageItems) * LINE_HEIGHT - 2,
pageWidth - RIGHT_MARGIN - bezelLeft, LINE_HEIGHT);
// Draw items
for (int i = pageStartIndex; i < listCount && i < pageStartIndex + pageItems; i++) {
@ -1595,7 +1587,7 @@ void MyLibraryActivity::renderListsTab() const {
renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, CONTENT_START_Y + (i % pageItems) * LINE_HEIGHT, item.c_str(),
i != selectorIndex);
}
// Draw "Search..." shortcut if it's on the current page
const int searchIndex = listCount; // Last item
if (searchIndex >= pageStartIndex && searchIndex < pageStartIndex + pageItems) {
@ -1632,8 +1624,8 @@ void MyLibraryActivity::renderFilesTab() const {
const auto pageStartIndex = selectorIndex / pageItems * pageItems;
// Draw selection highlight
renderer.fillRect(bezelLeft, CONTENT_START_Y + (selectorIndex % pageItems) * LINE_HEIGHT - 2, pageWidth - RIGHT_MARGIN - bezelLeft,
LINE_HEIGHT);
renderer.fillRect(bezelLeft, CONTENT_START_Y + (selectorIndex % pageItems) * LINE_HEIGHT - 2,
pageWidth - RIGHT_MARGIN - bezelLeft, LINE_HEIGHT);
// Draw items
for (int i = pageStartIndex; i < fileCount && i < pageStartIndex + pageItems; i++) {
@ -1641,7 +1633,7 @@ void MyLibraryActivity::renderFilesTab() const {
renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, CONTENT_START_Y + (i % pageItems) * LINE_HEIGHT, item.c_str(),
i != selectorIndex);
}
// Draw "Search..." shortcut if it's on the current page
const int searchIndex = fileCount; // Last item
if (searchIndex >= pageStartIndex && searchIndex < pageStartIndex + pageItems) {
@ -1665,7 +1657,8 @@ void MyLibraryActivity::renderActionMenu() const {
// Show filename
const int filenameY = 70 + bezelTop;
auto truncatedName = renderer.truncatedText(UI_10_FONT_ID, actionTargetName.c_str(), pageWidth - 40 - bezelLeft - bezelRight);
auto truncatedName =
renderer.truncatedText(UI_10_FONT_ID, actionTargetName.c_str(), pageWidth - 40 - bezelLeft - bezelRight);
renderer.drawCenteredText(UI_10_FONT_ID, filenameY, truncatedName.c_str());
// Menu options - 4 for Recent tab, 2 for Files tab
@ -1694,7 +1687,8 @@ void MyLibraryActivity::renderActionMenu() const {
if (menuSelection == 2) {
renderer.fillRect(menuX - 10, menuStartY + menuLineHeight * 2 - 5, menuItemWidth + 20, menuLineHeight);
}
renderer.drawCenteredText(UI_10_FONT_ID, menuStartY + menuLineHeight * 2, "Remove from Recents", menuSelection != 2);
renderer.drawCenteredText(UI_10_FONT_ID, menuStartY + menuLineHeight * 2, "Remove from Recents",
menuSelection != 2);
// Clear All Recents option
if (menuSelection == 3) {
@ -1808,7 +1802,8 @@ void MyLibraryActivity::renderListDeleteConfirmation() const {
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 40, truncatedName.c_str());
// Warning text
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, "List will be permanently deleted!", true, EpdFontFamily::BOLD);
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, "List will be permanently deleted!", true,
EpdFontFamily::BOLD);
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 25, "This cannot be undone.");
// Draw bottom button hints
@ -1885,7 +1880,7 @@ void MyLibraryActivity::renderBookmarksTab() const {
std::string countText = std::to_string(book.bookmarkCount) + " bookmark" + (book.bookmarkCount != 1 ? "s" : "");
renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, y + 32, countText.c_str(), !isSelected);
}
// Draw "Search..." shortcut if it's on the current page
const int searchIndex = bookCount; // Last item
if (searchIndex >= pageStartIndex && searchIndex < pageStartIndex + pageItems) {
@ -1924,12 +1919,13 @@ void MyLibraryActivity::renderSearchTab() const {
if (!searchInResults) {
displayQuery = searchQuery + "_"; // Show cursor when in picker
}
auto truncatedQuery = renderer.truncatedText(UI_10_FONT_ID, displayQuery.c_str(), pageWidth - LEFT_MARGIN - RIGHT_MARGIN);
auto truncatedQuery =
renderer.truncatedText(UI_10_FONT_ID, displayQuery.c_str(), pageWidth - LEFT_MARGIN - RIGHT_MARGIN);
renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, queryY, truncatedQuery.c_str());
// Draw results below query
const int resultsStartY = queryY + QUERY_HEIGHT;
// Draw results section
if (resultCount == 0) {
if (searchQuery.empty()) {
@ -1996,8 +1992,8 @@ void MyLibraryActivity::renderSearchTab() const {
const int badgeY = y + 2 + (titleLineHeight - badgeHeight) / 2;
if (!tags.extensionTag.empty()) {
int badgeWidth = ScreenComponents::drawPillBadge(renderer, badgeX, badgeY, tags.extensionTag.c_str(),
SMALL_FONT_ID, false);
int badgeWidth =
ScreenComponents::drawPillBadge(renderer, badgeX, badgeY, tags.extensionTag.c_str(), SMALL_FONT_ID, false);
badgeX += badgeWidth + badgeSpacing;
}
if (!tags.suffixTag.empty()) {
@ -2016,15 +2012,15 @@ void MyLibraryActivity::renderCharacterPicker(int y) const {
const auto pageWidth = renderer.getScreenWidth();
const int bezelLeft = renderer.getBezelOffsetLeft();
const int bezelRight = renderer.getBezelOffsetRight();
constexpr int charSpacing = 6; // Spacing between characters
constexpr int specialKeyPadding = 8; // Extra padding around special keys
constexpr int charSpacing = 6; // Spacing between characters
constexpr int specialKeyPadding = 8; // Extra padding around special keys
constexpr int overflowIndicatorWidth = 16; // Space reserved for < > indicators
// Calculate total width needed
const int charCount = static_cast<int>(searchCharacters.size());
const int totalItems = charCount + 3; // +3 for SPC, <-, CLR
// Calculate character widths
int totalWidth = 0;
for (char c : searchCharacters) {
@ -2035,14 +2031,14 @@ void MyLibraryActivity::renderCharacterPicker(int y) const {
totalWidth += renderer.getTextWidth(UI_10_FONT_ID, "SPC") + specialKeyPadding;
totalWidth += renderer.getTextWidth(UI_10_FONT_ID, "<-") + specialKeyPadding;
totalWidth += renderer.getTextWidth(UI_10_FONT_ID, "CLR") + specialKeyPadding;
// Calculate visible window - we'll scroll the character row
const int availableWidth = pageWidth - bezelLeft - bezelRight - 40; // 40 for margins (20 each side)
// Determine scroll offset to keep selected character visible
int scrollOffset = 0;
int currentX = 0;
// Calculate position of selected item
for (int i = 0; i < totalItems; i++) {
int itemWidth;
@ -2056,7 +2052,7 @@ void MyLibraryActivity::renderCharacterPicker(int y) const {
} else {
itemWidth = renderer.getTextWidth(UI_10_FONT_ID, "CLR") + specialKeyPadding;
}
if (i == searchCharIndex) {
// Center the selected item in the visible area
scrollOffset = currentX - availableWidth / 2 + itemWidth / 2;
@ -2068,26 +2064,27 @@ void MyLibraryActivity::renderCharacterPicker(int y) const {
}
currentX += itemWidth;
}
// Draw separator line
renderer.drawLine(bezelLeft + 20, y + 22, pageWidth - bezelRight - 20, y + 22);
// Calculate visible area boundaries (leave room for overflow indicators)
const bool hasLeftOverflow = scrollOffset > 0;
const bool hasRightOverflow = totalWidth > availableWidth && scrollOffset < totalWidth - availableWidth;
const int visibleLeft = bezelLeft + 20 + (hasLeftOverflow ? overflowIndicatorWidth : 0);
const int visibleRight = pageWidth - bezelRight - 20 - (hasRightOverflow ? overflowIndicatorWidth : 0);
// Draw characters
const int startX = bezelLeft + 20 - scrollOffset;
currentX = startX;
const bool showSelection = !searchInResults && !inTabBar; // Only show selection when in picker (not tab bar or results)
const bool showSelection =
!searchInResults && !inTabBar; // Only show selection when in picker (not tab bar or results)
for (int i = 0; i < totalItems; i++) {
std::string label;
int itemWidth;
bool isSpecial = false;
if (i < charCount) {
label = std::string(1, searchCharacters[i]);
itemWidth = renderer.getTextWidth(UI_10_FONT_ID, label.c_str());
@ -2104,12 +2101,12 @@ void MyLibraryActivity::renderCharacterPicker(int y) const {
itemWidth = renderer.getTextWidth(UI_10_FONT_ID, label.c_str());
isSpecial = true;
}
// Only draw if visible (accounting for overflow indicator space)
const int drawX = currentX + (isSpecial ? specialKeyPadding / 2 : 0);
if (drawX + itemWidth > visibleLeft && drawX < visibleRight) {
const bool isSelected = showSelection && (i == searchCharIndex);
if (isSelected) {
// Draw inverted background for selection
constexpr int padding = 2;
@ -2121,17 +2118,17 @@ void MyLibraryActivity::renderCharacterPicker(int y) const {
renderer.drawText(UI_10_FONT_ID, drawX, y, label.c_str());
}
}
currentX += itemWidth + (isSpecial ? specialKeyPadding : charSpacing);
}
// Draw overflow indicators if content extends beyond visible area
if (totalWidth > availableWidth) {
constexpr int triangleHeight = 12; // Height of the triangle (vertical)
constexpr int triangleWidth = 6; // Width of the triangle (horizontal) - thin/elongated
const int pickerLineHeight = renderer.getLineHeight(UI_10_FONT_ID);
const int triangleCenterY = y + pickerLineHeight / 2;
// Left overflow indicator (more content to the left) - thin triangle pointing left
if (hasLeftOverflow) {
// Clear background behind indicator to hide any overlapping text
@ -2141,21 +2138,20 @@ void MyLibraryActivity::renderCharacterPicker(int y) const {
for (int i = 0; i < triangleWidth; ++i) {
// Scale height based on position (0 at tip, full height at base)
const int lineHalfHeight = (triangleHeight * i) / (triangleWidth * 2);
renderer.drawLine(tipX + i, triangleCenterY - lineHalfHeight,
tipX + i, triangleCenterY + lineHalfHeight);
renderer.drawLine(tipX + i, triangleCenterY - lineHalfHeight, tipX + i, triangleCenterY + lineHalfHeight);
}
}
// Right overflow indicator (more content to the right) - thin triangle pointing right
if (hasRightOverflow) {
// Clear background behind indicator to hide any overlapping text
renderer.fillRect(pageWidth - bezelRight - overflowIndicatorWidth - 4, y - 2, overflowIndicatorWidth + 4, pickerLineHeight + 4, false);
renderer.fillRect(pageWidth - bezelRight - overflowIndicatorWidth - 4, y - 2, overflowIndicatorWidth + 4,
pickerLineHeight + 4, false);
// Draw right-pointing triangle: base on left, point on right
const int baseX = pageWidth - bezelRight - 2 - triangleWidth;
for (int i = 0; i < triangleWidth; ++i) {
// Scale height based on position (full height at base, 0 at tip)
const int lineHalfHeight = (triangleHeight * (triangleWidth - 1 - i)) / (triangleWidth * 2);
renderer.drawLine(baseX + i, triangleCenterY - lineHalfHeight,
baseX + i, triangleCenterY + lineHalfHeight);
renderer.drawLine(baseX + i, triangleCenterY - lineHalfHeight, baseX + i, triangleCenterY + lineHalfHeight);
}
}
}

View File

@ -13,10 +13,10 @@
// Cached thumbnail existence info for Recent tab
struct ThumbExistsCache {
std::string bookPath; // Book path this cache entry belongs to
std::string thumbPath; // Path to micro-thumbnail (if exists)
bool checked = false; // Whether we've checked for this book
bool exists = false; // Whether thumbnail exists
std::string bookPath; // Book path this cache entry belongs to
std::string thumbPath; // Path to micro-thumbnail (if exists)
bool checked = false; // Whether we've checked for this book
bool exists = false; // Whether thumbnail exists
};
// Search result for the Search tab
@ -38,7 +38,14 @@ struct BookmarkedBook {
class MyLibraryActivity final : public Activity {
public:
enum class Tab { Recent, Lists, Bookmarks, Search, Files };
enum class UIState { Normal, ActionMenu, Confirming, ListActionMenu, ListConfirmingDelete, ClearAllRecentsConfirming };
enum class UIState {
Normal,
ActionMenu,
Confirming,
ListActionMenu,
ListConfirmingDelete,
ClearAllRecentsConfirming
};
enum class ActionType { Archive, Delete, RemoveFromRecents, ClearAllRecents };
private:
@ -55,7 +62,7 @@ class MyLibraryActivity final : public Activity {
ActionType selectedAction = ActionType::Archive;
std::string actionTargetPath;
std::string actionTargetName;
int menuSelection = 0; // 0 = Archive, 1 = Delete
int menuSelection = 0; // 0 = Archive, 1 = Delete
bool ignoreNextConfirmRelease = false; // Prevents immediate selection after long-press opens menu
// Recent tab state
@ -64,13 +71,12 @@ class MyLibraryActivity final : public Activity {
// Static thumbnail existence cache - persists across activity enter/exit
static constexpr int MAX_THUMB_CACHE = 10;
static ThumbExistsCache thumbExistsCache[MAX_THUMB_CACHE];
public:
// Clear the thumbnail existence cache (call when disk cache is cleared)
static void clearThumbExistsCache();
private:
private:
// Lists tab state
std::vector<std::string> lists;
@ -148,12 +154,12 @@ class MyLibraryActivity final : public Activity {
void renderClearAllRecentsConfirmation() const;
public:
explicit MyLibraryActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const std::function<void()>& onGoHome,
const std::function<void(const std::string& path, Tab fromTab)>& onSelectBook,
const std::function<void(const std::string& listName)>& onSelectList,
const std::function<void(const std::string& path, const std::string& title)>& onSelectBookmarkedBook = nullptr,
Tab initialTab = Tab::Recent, std::string initialPath = "/")
explicit MyLibraryActivity(
GfxRenderer& renderer, MappedInputManager& mappedInput, const std::function<void()>& onGoHome,
const std::function<void(const std::string& path, Tab fromTab)>& onSelectBook,
const std::function<void(const std::string& listName)>& onSelectList,
const std::function<void(const std::string& path, const std::string& title)>& onSelectBookmarkedBook = nullptr,
Tab initialTab = Tab::Recent, std::string initialPath = "/")
: Activity("MyLibrary", renderer, mappedInput),
currentTab(initialTab),
basepath(initialPath.empty() ? "/" : std::move(initialPath)),

View File

@ -600,9 +600,9 @@ void CrossPointWebServerActivity::renderWebBrowserScreen() const {
// Landscape layout (800x480): QR on left, text on right
constexpr int QR_X = 15;
constexpr int QR_Y = 15;
constexpr int QR_PX = 7; // pixels per QR module
constexpr int QR_SIZE = 33 * QR_PX; // QR version 4 = 33 modules = 231px
constexpr int TEXT_X = QR_X + QR_SIZE + 35; // Text starts after QR + margin
constexpr int QR_PX = 7; // pixels per QR module
constexpr int QR_SIZE = 33 * QR_PX; // QR version 4 = 33 modules = 231px
constexpr int TEXT_X = QR_X + QR_SIZE + 35; // Text starts after QR + margin
constexpr int LINE_SPACING = 32;
// Draw title on right side
@ -667,9 +667,9 @@ void CrossPointWebServerActivity::renderCompanionAppScreen() const {
// Landscape layout (800x480): QR on left, text on right
constexpr int QR_X = 15;
constexpr int QR_Y = 15;
constexpr int QR_PX = 7; // pixels per QR module
constexpr int QR_SIZE = 33 * QR_PX; // QR version 4 = 33 modules = 231px
constexpr int TEXT_X = QR_X + QR_SIZE + 35; // Text starts after QR + margin
constexpr int QR_PX = 7; // pixels per QR module
constexpr int QR_SIZE = 33 * QR_PX; // QR version 4 = 33 modules = 231px
constexpr int TEXT_X = QR_X + QR_SIZE + 35; // Text starts after QR + margin
constexpr int LINE_SPACING = 32;
// Draw title on right side
@ -717,9 +717,9 @@ void CrossPointWebServerActivity::renderCompanionAppLibraryScreen() const {
// Landscape layout (800x480): QR on left, text on right
constexpr int QR_X = 15;
constexpr int QR_Y = 15;
constexpr int QR_PX = 7; // pixels per QR module
constexpr int QR_SIZE = 33 * QR_PX; // QR version 4 = 33 modules = 231px
constexpr int TEXT_X = QR_X + QR_SIZE + 35; // Text starts after QR + margin
constexpr int QR_PX = 7; // pixels per QR module
constexpr int QR_SIZE = 33 * QR_PX; // QR version 4 = 33 modules = 231px
constexpr int TEXT_X = QR_X + QR_SIZE + 35; // Text starts after QR + margin
constexpr int LINE_SPACING = 32;
// Draw title on right side

View File

@ -111,24 +111,25 @@ void EpubReaderActivity::onEnter() {
FsFile f;
if (SdMan.openFileForRead("ERS", epub->getCachePath() + "/progress.bin", f)) {
const size_t fileSize = f.size();
if (fileSize >= 9) {
// New format: version (1) + spineIndex (2) + pageNumber (2) + contentOffset (4) = 9 bytes
uint8_t version;
serialization::readPod(f, version);
if (version == EPUB_PROGRESS_VERSION) {
uint16_t spineIndex, pageNumber;
serialization::readPod(f, spineIndex);
serialization::readPod(f, pageNumber);
serialization::readPod(f, savedContentOffset);
currentSpineIndex = spineIndex;
nextPageNumber = pageNumber;
hasContentOffset = true;
Serial.printf("[%lu] [ERS] Loaded progress v1: spine %d, page %d, offset %u\n",
millis(), currentSpineIndex, nextPageNumber, savedContentOffset);
if (Serial)
Serial.printf("[%lu] [ERS] Loaded progress v1: spine %d, page %d, offset %u\n", millis(), currentSpineIndex,
nextPageNumber, savedContentOffset);
} else {
// Unknown version, try legacy format
f.seek(0);
@ -137,8 +138,9 @@ void EpubReaderActivity::onEnter() {
currentSpineIndex = data[0] + (data[1] << 8);
nextPageNumber = data[2] + (data[3] << 8);
hasContentOffset = false;
Serial.printf("[%lu] [ERS] Loaded legacy progress (unknown version %d): spine %d, page %d\n",
millis(), version, currentSpineIndex, nextPageNumber);
if (Serial)
Serial.printf("[%lu] [ERS] Loaded legacy progress (unknown version %d): spine %d, page %d\n", millis(),
version, currentSpineIndex, nextPageNumber);
}
}
} else if (fileSize >= 4) {
@ -148,20 +150,23 @@ void EpubReaderActivity::onEnter() {
currentSpineIndex = data[0] + (data[1] << 8);
nextPageNumber = data[2] + (data[3] << 8);
hasContentOffset = false;
Serial.printf("[%lu] [ERS] Loaded legacy progress: spine %d, page %d\n",
millis(), currentSpineIndex, nextPageNumber);
if (Serial)
Serial.printf("[%lu] [ERS] Loaded legacy progress: spine %d, page %d\n", millis(), currentSpineIndex,
nextPageNumber);
}
}
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);
if (Serial)
Serial.printf("[%lu] [ERS] Opened for first time, navigating to text reference at index %d\n", millis(),
textSpineIndex);
}
}
@ -300,19 +305,20 @@ void EpubReaderActivity::loop() {
Section* cachedSection = section.get();
SemaphoreHandle_t cachedMutex = renderingMutex;
EpubReaderActivity* self = this;
// Handle dictionary mode selection - exitActivity deletes DictionaryMenuActivity
exitActivity();
if (mode == DictionaryMode::ENTER_WORD) {
// Enter word mode - show keyboard and search
self->enterNewActivity(new DictionarySearchActivity(cachedRenderer, cachedMappedInput,
[self]() {
// On back from dictionary
self->exitActivity();
self->updateRequired = true;
},
"")); // Empty string = show keyboard
self->enterNewActivity(new DictionarySearchActivity(
cachedRenderer, cachedMappedInput,
[self]() {
// On back from dictionary
self->exitActivity();
self->updateRequired = true;
},
"")); // Empty string = show keyboard
} else {
// Select from screen mode - show word selection on current page
if (cachedSection) {
@ -322,7 +328,7 @@ void EpubReaderActivity::loop() {
// Get margins for word selection positioning
int orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft;
cachedRenderer.getOrientedViewableTRBL(&orientedMarginTop, &orientedMarginRight, &orientedMarginBottom,
&orientedMarginLeft);
&orientedMarginLeft);
orientedMarginTop += SETTINGS.screenMargin;
orientedMarginLeft += SETTINGS.screenMargin;
@ -335,12 +341,13 @@ void EpubReaderActivity::loop() {
[self](const std::string& selectedWord) {
// Word selected - look it up
self->exitActivity();
self->enterNewActivity(new DictionarySearchActivity(self->renderer, self->mappedInput,
[self]() {
self->exitActivity();
self->updateRequired = true;
},
selectedWord));
self->enterNewActivity(new DictionarySearchActivity(
self->renderer, self->mappedInput,
[self]() {
self->exitActivity();
self->updateRequired = true;
},
selectedWord));
},
[self]() {
// Cancelled word selection
@ -372,14 +379,14 @@ void EpubReaderActivity::loop() {
if (SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::QUICK_MENU &&
mappedInput.wasReleased(MappedInputManager::Button::Power)) {
xSemaphoreTake(renderingMutex, portMAX_DELAY);
// Check if current page is bookmarked
bool isBookmarked = false;
if (section) {
const uint32_t contentOffset = section->getContentOffsetForPage(section->currentPage);
isBookmarked = BookmarkStore::isPageBookmarked(epub->getPath(), currentSpineIndex, contentOffset);
}
exitActivity();
enterNewActivity(new QuickMenuActivity(
renderer, mappedInput,
@ -387,9 +394,9 @@ void EpubReaderActivity::loop() {
// Cache values before exitActivity
EpubReaderActivity* self = this;
SemaphoreHandle_t cachedMutex = renderingMutex;
exitActivity();
if (action == QuickMenuAction::DICTIONARY) {
// Open dictionary menu - cache renderer/input for this scope
GfxRenderer& cachedRenderer = self->renderer;
@ -402,15 +409,17 @@ void EpubReaderActivity::loop() {
MappedInputManager& m = self->mappedInput;
Section* s = self->section.get();
SemaphoreHandle_t mtx = self->renderingMutex;
self->exitActivity();
if (mode == DictionaryMode::ENTER_WORD) {
self->enterNewActivity(new DictionarySearchActivity(r, m,
self->enterNewActivity(new DictionarySearchActivity(
r, m,
[self]() {
self->exitActivity();
self->updateRequired = true;
}, ""));
},
""));
} else if (s) {
xSemaphoreTake(mtx, portMAX_DELAY);
auto page = s->loadPageFromSectionFile();
@ -420,7 +429,7 @@ void EpubReaderActivity::loop() {
mt += SETTINGS.screenMargin;
ml += SETTINGS.screenMargin;
const int fontId = SETTINGS.getReaderFontId();
self->enterNewActivity(new EpubWordSelectionActivity(
r, m, std::move(page), fontId, ml, mt,
[self](const std::string& word) {
@ -430,7 +439,8 @@ void EpubReaderActivity::loop() {
[self]() {
self->exitActivity();
self->updateRequired = true;
}, word));
},
word));
},
[self]() {
self->exitActivity();
@ -455,7 +465,7 @@ void EpubReaderActivity::loop() {
if (self->section) {
const uint32_t contentOffset = self->section->getContentOffsetForPage(self->section->currentPage);
const std::string& bookPath = self->epub->getPath();
if (BookmarkStore::isPageBookmarked(bookPath, self->currentSpineIndex, contentOffset)) {
// Remove bookmark
BookmarkStore::removeBookmark(bookPath, self->currentSpineIndex, contentOffset);
@ -466,7 +476,7 @@ void EpubReaderActivity::loop() {
bm.contentOffset = contentOffset;
bm.pageNumber = self->section->currentPage;
bm.timestamp = millis() / 1000; // Approximate timestamp
// Generate name: "Chapter - Page X" or fallback
std::string chapterTitle;
const int tocIndex = self->epub->getTocIndexForSpineIndex(self->currentSpineIndex);
@ -478,7 +488,7 @@ void EpubReaderActivity::loop() {
} else {
bm.name = "Page " + std::to_string(self->section->currentPage + 1);
}
BookmarkStore::addBookmark(bookPath, bm);
}
}
@ -629,18 +639,19 @@ void EpubReaderActivity::renderScreen() {
if (!section) {
const auto filepath = epub->getSpineItem(currentSpineIndex).href;
Serial.printf("[%lu] [ERS] Loading file: %s, index: %d\n", millis(), filepath.c_str(), currentSpineIndex);
if (Serial)
Serial.printf("[%lu] [ERS] Loading file: %s, index: %d\n", millis(), filepath.c_str(), currentSpineIndex);
section = std::unique_ptr<Section>(new Section(epub, currentSpineIndex, renderer));
const uint16_t viewportWidth = renderer.getScreenWidth() - orientedMarginLeft - orientedMarginRight;
const uint16_t viewportHeight = renderer.getScreenHeight() - orientedMarginTop - orientedMarginBottom;
bool sectionWasReIndexed = false;
if (!section->loadSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
viewportHeight, SETTINGS.hyphenationEnabled)) {
Serial.printf("[%lu] [ERS] Cache not found, building...\n", millis());
if (Serial) Serial.printf("[%lu] [ERS] Cache not found, building...\n", millis());
sectionWasReIndexed = true;
// Progress bar dimensions
@ -683,15 +694,15 @@ void EpubReaderActivity::renderScreen() {
renderer.displayBuffer(EInkDisplay::FAST_REFRESH);
};
if (!section->createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
if (!section->createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
viewportHeight, SETTINGS.hyphenationEnabled, progressSetup, progressCallback)) {
Serial.printf("[%lu] [ERS] Failed to persist page data to SD\n", millis());
if (Serial) Serial.printf("[%lu] [ERS] Failed to persist page data to SD\n", millis());
section.reset();
return;
}
} else {
Serial.printf("[%lu] [ERS] Cache found, skipping build...\n", millis());
if (Serial) Serial.printf("[%lu] [ERS] Cache found, skipping build...\n", millis());
}
// Determine the correct page to display
@ -703,8 +714,9 @@ void EpubReaderActivity::renderScreen() {
// Use the offset to find the correct page
const int restoredPage = section->findPageForContentOffset(savedContentOffset);
section->currentPage = restoredPage;
Serial.printf("[%lu] [ERS] Restored position via offset: %u -> page %d (was page %d)\n",
millis(), savedContentOffset, restoredPage, nextPageNumber);
if (Serial)
Serial.printf("[%lu] [ERS] Restored position via offset: %u -> page %d (was page %d)\n", millis(),
savedContentOffset, restoredPage, nextPageNumber);
// Clear the offset flag since we've used it
hasContentOffset = false;
} else {
@ -716,7 +728,7 @@ void EpubReaderActivity::renderScreen() {
renderer.clearScreen();
if (section->pageCount == 0) {
Serial.printf("[%lu] [ERS] No pages to render\n", millis());
if (Serial) Serial.printf("[%lu] [ERS] No pages to render\n", millis());
renderer.drawCenteredText(UI_12_FONT_ID, 300, "Empty chapter", true, EpdFontFamily::BOLD);
renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
renderer.displayBuffer();
@ -724,7 +736,9 @@ void EpubReaderActivity::renderScreen() {
}
if (section->currentPage < 0 || section->currentPage >= section->pageCount) {
Serial.printf("[%lu] [ERS] Page out of bounds: %d (max %d)\n", millis(), section->currentPage, section->pageCount);
if (Serial)
Serial.printf("[%lu] [ERS] Page out of bounds: %d (max %d)\n", millis(), section->currentPage,
section->pageCount);
renderer.drawCenteredText(UI_12_FONT_ID, 300, "Out of bounds", true, EpdFontFamily::BOLD);
renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
renderer.displayBuffer();
@ -734,25 +748,25 @@ void EpubReaderActivity::renderScreen() {
{
auto p = section->loadPageFromSectionFile();
if (!p) {
Serial.printf("[%lu] [ERS] Failed to load page from SD - clearing section cache\n", millis());
if (Serial) Serial.printf("[%lu] [ERS] Failed to load page from SD - clearing section cache\n", millis());
section->clearCache();
section.reset();
return renderScreen();
}
// Handle empty pages (e.g., from malformed chapters that couldn't be parsed)
if (p->elements.empty()) {
Serial.printf("[%lu] [ERS] Page has no content (possibly malformed chapter)\n", millis());
if (Serial) Serial.printf("[%lu] [ERS] Page has no content (possibly malformed chapter)\n", millis());
renderer.drawCenteredText(UI_12_FONT_ID, 280, "Chapter content unavailable", true, EpdFontFamily::BOLD);
renderer.drawCenteredText(UI_10_FONT_ID, 320, "(File may be malformed)");
renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
renderer.displayBuffer();
return;
}
const auto start = millis();
renderContents(std::move(p), orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
Serial.printf("[%lu] [ERS] Rendered page in %dms\n", millis(), millis() - start);
if (Serial) Serial.printf("[%lu] [ERS] Rendered page in %dms\n", millis(), millis() - start);
}
// Save progress with content offset for position restoration after re-indexing
@ -760,16 +774,17 @@ void EpubReaderActivity::renderScreen() {
if (SdMan.openFileForWrite("ERS", epub->getCachePath() + "/progress.bin", f)) {
// Get content offset for current page
const uint32_t contentOffset = section->getContentOffsetForPage(section->currentPage);
// New format: version (1) + spineIndex (2) + pageNumber (2) + contentOffset (4) = 9 bytes
serialization::writePod(f, EPUB_PROGRESS_VERSION);
serialization::writePod(f, static_cast<uint16_t>(currentSpineIndex));
serialization::writePod(f, static_cast<uint16_t>(section->currentPage));
serialization::writePod(f, contentOffset);
f.close();
Serial.printf("[%lu] [ERS] Saved progress: spine %d, page %d, offset %u\n",
millis(), currentSpineIndex, section->currentPage, contentOffset);
if (Serial)
Serial.printf("[%lu] [ERS] Saved progress: spine %d, page %d, offset %u\n", millis(), currentSpineIndex,
section->currentPage, contentOffset);
}
}
@ -777,7 +792,7 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
const int orientedMarginRight, const int orientedMarginBottom,
const int orientedMarginLeft) {
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
// Draw bookmark indicator (folded corner) if this page is bookmarked
if (section) {
const uint32_t contentOffset = section->getContentOffsetForPage(section->currentPage);
@ -787,14 +802,14 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
constexpr int cornerSize = 20;
const int cornerX = screenWidth - orientedMarginRight - cornerSize;
const int cornerY = orientedMarginTop;
// Draw triangle (folded corner effect)
const int xPoints[3] = {cornerX, cornerX + cornerSize, cornerX + cornerSize};
const int yPoints[3] = {cornerY, cornerY, cornerY + cornerSize};
renderer.fillPolygon(xPoints, yPoints, 3, true); // Black triangle
}
}
renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
if (pagesUntilFullRefresh <= 1) {
renderer.displayBuffer(EInkDisplay::HALF_REFRESH);

View File

@ -24,7 +24,7 @@ class EpubReaderActivity final : public ActivityWithSubactivity {
// End-of-book prompt state
bool showingEndOfBookPrompt = false;
int endOfBookSelection = 2; // 0=Archive, 1=Delete, 2=Keep (default to safe option)
// Content offset for position restoration after re-indexing
uint32_t savedContentOffset = 0;
bool hasContentOffset = false; // True if we have a valid content offset to use

View File

@ -160,12 +160,13 @@ void EpubReaderChapterSelectionActivity::renderScreen() {
const int bezelLeft = renderer.getBezelOffsetLeft();
const int bezelRight = renderer.getBezelOffsetRight();
const std::string title =
renderer.truncatedText(UI_12_FONT_ID, epub->getTitle().c_str(), pageWidth - 40 - bezelLeft - bezelRight, EpdFontFamily::BOLD);
const std::string title = renderer.truncatedText(UI_12_FONT_ID, epub->getTitle().c_str(),
pageWidth - 40 - bezelLeft - bezelRight, EpdFontFamily::BOLD);
renderer.drawCenteredText(UI_12_FONT_ID, 15 + bezelTop, title.c_str(), true, EpdFontFamily::BOLD);
const auto pageStartIndex = selectorIndex / pageItems * pageItems;
renderer.fillRect(bezelLeft, 60 + bezelTop + (selectorIndex % pageItems) * 30 - 2, pageWidth - 1 - bezelLeft - bezelRight, 30);
renderer.fillRect(bezelLeft, 60 + bezelTop + (selectorIndex % pageItems) * 30 - 2,
pageWidth - 1 - bezelLeft - bezelRight, 30);
for (int itemIndex = pageStartIndex; itemIndex < totalItems && itemIndex < pageStartIndex + pageItems; itemIndex++) {
const int displayY = 60 + bezelTop + (itemIndex % pageItems) * 30;
@ -175,8 +176,8 @@ void EpubReaderChapterSelectionActivity::renderScreen() {
const int tocIndex = tocIndexFromItemIndex(itemIndex);
auto item = epub->getTocItem(tocIndex);
const int indentSize = 20 + bezelLeft + (item.level - 1) * 15;
const std::string chapterName =
renderer.truncatedText(UI_10_FONT_ID, item.title.c_str(), pageWidth - 40 - bezelLeft - bezelRight - indentSize + 20 + bezelLeft);
const std::string chapterName = renderer.truncatedText(
UI_10_FONT_ID, item.title.c_str(), pageWidth - 40 - bezelLeft - bezelRight - indentSize + 20 + bezelLeft);
renderer.drawText(UI_10_FONT_ID, indentSize, displayY, chapterName.c_str(), !isSelected);
}

View File

@ -62,11 +62,8 @@ void ReaderActivity::onGoToEpubReader(std::unique_ptr<Epub> epub) {
currentBookPath = epubPath;
exitActivity();
enterNewActivity(new EpubReaderActivity(
renderer, mappedInput, std::move(epub),
[this, epubPath] { goToLibrary(epubPath); },
[this] { onGoBack(); },
onGoToClearCache,
onGoToSettings));
renderer, mappedInput, std::move(epub), [this, epubPath] { goToLibrary(epubPath); }, [this] { onGoBack(); },
onGoToClearCache, onGoToSettings));
}
void ReaderActivity::onGoToTxtReader(std::unique_ptr<Txt> txt) {

View File

@ -658,15 +658,15 @@ void TxtReaderActivity::saveProgress() const {
if (SdMan.openFileForWrite("TRS", txt->getCachePath() + "/progress.bin", f)) {
// New format: version + byte offset + page number (for backwards compatibility debugging)
serialization::writePod(f, PROGRESS_VERSION);
// Store byte offset - this is stable across font/setting changes
const size_t byteOffset = (currentPage >= 0 && currentPage < static_cast<int>(pageOffsets.size()))
? pageOffsets[currentPage] : 0;
const size_t byteOffset =
(currentPage >= 0 && currentPage < static_cast<int>(pageOffsets.size())) ? pageOffsets[currentPage] : 0;
serialization::writePod(f, static_cast<uint32_t>(byteOffset));
// Also store page number for debugging/logging purposes
serialization::writePod(f, static_cast<uint16_t>(currentPage));
f.close();
Serial.printf("[%lu] [TRS] Saved progress: page %d, offset %zu\n", millis(), currentPage, byteOffset);
}
@ -677,24 +677,24 @@ void TxtReaderActivity::loadProgress() {
if (SdMan.openFileForRead("TRS", txt->getCachePath() + "/progress.bin", f)) {
// Check file size to determine format
const size_t fileSize = f.size();
if (fileSize >= 7) {
// New format: version (1) + byte offset (4) + page number (2) = 7 bytes
uint8_t version;
serialization::readPod(f, version);
if (version == PROGRESS_VERSION) {
uint32_t savedOffset;
serialization::readPod(f, savedOffset);
uint16_t savedPage;
serialization::readPod(f, savedPage);
// Use byte offset to find the correct page (works even if re-indexed)
currentPage = findPageForOffset(savedOffset);
Serial.printf("[%lu] [TRS] Loaded progress: offset %u -> page %d/%d (was page %d)\n",
millis(), savedOffset, currentPage, totalPages, savedPage);
Serial.printf("[%lu] [TRS] Loaded progress: offset %u -> page %d/%d (was page %d)\n", millis(), savedOffset,
currentPage, totalPages, savedPage);
} else {
// Unknown version, fall back to legacy behavior
Serial.printf("[%lu] [TRS] Unknown progress version %d, ignoring\n", millis(), version);
@ -708,7 +708,7 @@ void TxtReaderActivity::loadProgress() {
Serial.printf("[%lu] [TRS] Loaded legacy progress: page %d/%d\n", millis(), currentPage, totalPages);
}
}
// Bounds check
if (currentPage >= totalPages) {
currentPage = totalPages - 1;
@ -716,7 +716,7 @@ void TxtReaderActivity::loadProgress() {
if (currentPage < 0) {
currentPage = 0;
}
f.close();
}
}
@ -725,16 +725,16 @@ int TxtReaderActivity::findPageForOffset(size_t targetOffset) const {
if (pageOffsets.empty()) {
return 0;
}
// Binary search: find the largest offset that is <= targetOffset
// This finds the page that contains or starts at the target offset
auto it = std::upper_bound(pageOffsets.begin(), pageOffsets.end(), targetOffset);
if (it == pageOffsets.begin()) {
// Target is before the first page, return page 0
return 0;
}
// upper_bound returns iterator to first element > targetOffset
// So we need the element before it (which is <= targetOffset)
return static_cast<int>(std::distance(pageOffsets.begin(), it) - 1);

View File

@ -201,7 +201,8 @@ void CategorySettingsActivity::render() const {
renderer.drawCenteredText(UI_12_FONT_ID, 15 + bezelTop, categoryName, true, EpdFontFamily::BOLD);
// Draw selection highlight
renderer.fillRect(bezelLeft, 60 + bezelTop + selectedSettingIndex * 30 - 2, pageWidth - 1 - bezelLeft - bezelRight, 30);
renderer.fillRect(bezelLeft, 60 + bezelTop + selectedSettingIndex * 30 - 2, pageWidth - 1 - bezelLeft - bezelRight,
30);
// Draw only visible settings
int visibleIndex = 0;
@ -237,7 +238,8 @@ void CategorySettingsActivity::render() const {
visibleIndex++;
}
renderer.drawText(SMALL_FONT_ID, pageWidth - 20 - bezelRight - renderer.getTextWidth(SMALL_FONT_ID, CROSSPOINT_VERSION),
renderer.drawText(SMALL_FONT_ID,
pageWidth - 20 - bezelRight - renderer.getTextWidth(SMALL_FONT_ID, CROSSPOINT_VERSION),
pageHeight - 60 - bezelBottom, CROSSPOINT_VERSION);
const auto labels = mappedInput.mapLabels("« Back", "Toggle", "", "");

View File

@ -147,7 +147,8 @@ void OtaUpdateActivity::render() {
if (state == WAITING_CONFIRMATION) {
renderer.drawCenteredText(UI_10_FONT_ID, centerY - 100, "New update available!", true, EpdFontFamily::BOLD);
renderer.drawText(UI_10_FONT_ID, 20 + bezelLeft, centerY - 50, "Current Version: " CROSSPOINT_VERSION);
renderer.drawText(UI_10_FONT_ID, 20 + bezelLeft, centerY - 30, ("New Version: " + updater.getLatestVersion()).c_str());
renderer.drawText(UI_10_FONT_ID, 20 + bezelLeft, centerY - 30,
("New Version: " + updater.getLatestVersion()).c_str());
const auto labels = mappedInput.mapLabels("Cancel", "Update", "", "");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
@ -158,7 +159,9 @@ void OtaUpdateActivity::render() {
if (state == UPDATE_IN_PROGRESS) {
renderer.drawCenteredText(UI_10_FONT_ID, centerY - 40, "Updating...", true, EpdFontFamily::BOLD);
renderer.drawRect(20 + bezelLeft, centerY, pageWidth - 40 - bezelLeft - bezelRight, 50);
renderer.fillRect(24 + bezelLeft, centerY + 4, static_cast<int>(updaterProgress * static_cast<float>(pageWidth - 44 - bezelLeft - bezelRight)), 42);
renderer.fillRect(24 + bezelLeft, centerY + 4,
static_cast<int>(updaterProgress * static_cast<float>(pageWidth - 44 - bezelLeft - bezelRight)),
42);
renderer.drawCenteredText(UI_10_FONT_ID, centerY + 70,
(std::to_string(static_cast<int>(updaterProgress * 100)) + "%").c_str());
renderer.drawCenteredText(

View File

@ -29,8 +29,8 @@ const SettingInfo displaySettings[displaySettingsCount] = {
SettingInfo::Enum("Refresh Frequency", &CrossPointSettings::refreshFrequency,
{"1 page", "5 pages", "10 pages", "15 pages", "30 pages"}),
SettingInfo::Value("Bezel Compensation", &CrossPointSettings::bezelCompensation, {0, 10, 1}),
SettingInfo::Enum("Bezel Edge", &CrossPointSettings::bezelCompensationEdge,
{"Bottom", "Top", "Left", "Right"}, isBezelCompensationEnabled)};
SettingInfo::Enum("Bezel Edge", &CrossPointSettings::bezelCompensationEdge, {"Bottom", "Top", "Left", "Right"},
isBezelCompensationEnabled)};
// Helper to get custom font names as a vector
std::vector<std::string> getCustomFontNamesVector() {
@ -62,8 +62,8 @@ const SettingInfo readerSettings[readerSettingsCount] = {
SettingInfo::Enum("Font Family", &CrossPointSettings::fontFamily, getFontFamilyOptions()),
SettingInfo::Enum("Custom Font", &CrossPointSettings::customFontIndex, getCustomFontNamesVector(),
isCustomFontSelected),
SettingInfo::Enum("Fallback Font", &CrossPointSettings::fallbackFontFamily,
{"Bookerly", "Noto Sans"}, isCustomFontSelected),
SettingInfo::Enum("Fallback Font", &CrossPointSettings::fallbackFontFamily, {"Bookerly", "Noto Sans"},
isCustomFontSelected),
SettingInfo::Enum("Font Size", &CrossPointSettings::fontSize, {"Small", "Medium", "Large", "X Large"}),
SettingInfo::Enum("Line Spacing", &CrossPointSettings::lineSpacing, {"Tight", "Normal", "Wide"}),
SettingInfo::Value("Screen Margin", &CrossPointSettings::screenMargin, {5, 40, 5}),
@ -229,7 +229,8 @@ void SettingsActivity::render() const {
renderer.drawCenteredText(UI_12_FONT_ID, 15 + bezelTop, "Settings", true, EpdFontFamily::BOLD);
// Draw selection
renderer.fillRect(bezelLeft, 60 + bezelTop + selectedCategoryIndex * 30 - 2, pageWidth - 1 - bezelLeft - bezelRight, 30);
renderer.fillRect(bezelLeft, 60 + bezelTop + selectedCategoryIndex * 30 - 2, pageWidth - 1 - bezelLeft - bezelRight,
30);
// Draw all categories
for (int i = 0; i < categoryCount; i++) {
@ -240,7 +241,8 @@ void SettingsActivity::render() const {
}
// Draw version text above button hints
renderer.drawText(SMALL_FONT_ID, pageWidth - 20 - bezelRight - renderer.getTextWidth(SMALL_FONT_ID, CROSSPOINT_VERSION),
renderer.drawText(SMALL_FONT_ID,
pageWidth - 20 - bezelRight - renderer.getTextWidth(SMALL_FONT_ID, CROSSPOINT_VERSION),
pageHeight - 60 - bezelBottom, CROSSPOINT_VERSION);
// Draw help text

View File

@ -1,7 +1,7 @@
#include "KeyboardEntryActivity.h"
#include "activities/dictionary/DictionaryMargins.h"
#include "MappedInputManager.h"
#include "activities/dictionary/DictionaryMargins.h"
#include "fontIds.h"
// Keyboard layouts - lowercase

View File

@ -8,18 +8,10 @@
namespace {
constexpr int MENU_ITEM_COUNT = 4;
const char* MENU_ITEMS[MENU_ITEM_COUNT] = {"Dictionary", "Bookmark", "Clear Cache", "Settings"};
const char* MENU_DESCRIPTIONS_ADD[MENU_ITEM_COUNT] = {
"Look up a word",
"Add bookmark to this page",
"Free up storage space",
"Open settings menu"
};
const char* MENU_DESCRIPTIONS_REMOVE[MENU_ITEM_COUNT] = {
"Look up a word",
"Remove bookmark from this page",
"Free up storage space",
"Open settings menu"
};
const char* MENU_DESCRIPTIONS_ADD[MENU_ITEM_COUNT] = {"Look up a word", "Add bookmark to this page",
"Free up storage space", "Open settings menu"};
const char* MENU_DESCRIPTIONS_REMOVE[MENU_ITEM_COUNT] = {"Look up a word", "Remove bookmark from this page",
"Free up storage space", "Open settings menu"};
} // namespace
void QuickMenuActivity::taskTrampoline(void* param) {
@ -121,7 +113,7 @@ void QuickMenuActivity::render() const {
const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = renderer.getScreenHeight();
// Get bezel offsets
const int bezelTop = renderer.getBezelOffsetTop();
const int bezelLeft = renderer.getBezelOffsetLeft();
@ -160,7 +152,7 @@ void QuickMenuActivity::render() const {
if (i == 1) {
itemText = isPageBookmarked ? "Remove Bookmark" : "Add Bookmark";
}
renderer.drawText(UI_10_FONT_ID, marginLeft + 20, itemY, itemText, !isSelected);
renderer.drawText(SMALL_FONT_ID, marginLeft + 20, itemY + 22, descriptions[i], !isSelected);
}

View File

@ -2,8 +2,9 @@
* Generated by convert-builtin-fonts.sh
* Custom font definitions
*/
#include <builtinFonts/custom/customFonts.h>
#include <GfxRenderer.h>
#include <builtinFonts/custom/customFonts.h>
#include "fontIds.h"
// EpdFont definitions for custom fonts
@ -41,14 +42,30 @@ EpdFont fernmicro18BoldFont(&fernmicro_18_bold);
EpdFont fernmicro18BoldItalicFont(&fernmicro_18_bolditalic);
// EpdFontFamily definitions for custom fonts
EpdFontFamily atkinsonhyperlegiblenext12FontFamily(&atkinsonhyperlegiblenext12RegularFont, &atkinsonhyperlegiblenext12BoldFont, &atkinsonhyperlegiblenext12ItalicFont, &atkinsonhyperlegiblenext12BoldItalicFont);
EpdFontFamily atkinsonhyperlegiblenext14FontFamily(&atkinsonhyperlegiblenext14RegularFont, &atkinsonhyperlegiblenext14BoldFont, &atkinsonhyperlegiblenext14ItalicFont, &atkinsonhyperlegiblenext14BoldItalicFont);
EpdFontFamily atkinsonhyperlegiblenext16FontFamily(&atkinsonhyperlegiblenext16RegularFont, &atkinsonhyperlegiblenext16BoldFont, &atkinsonhyperlegiblenext16ItalicFont, &atkinsonhyperlegiblenext16BoldItalicFont);
EpdFontFamily atkinsonhyperlegiblenext18FontFamily(&atkinsonhyperlegiblenext18RegularFont, &atkinsonhyperlegiblenext18BoldFont, &atkinsonhyperlegiblenext18ItalicFont, &atkinsonhyperlegiblenext18BoldItalicFont);
EpdFontFamily fernmicro12FontFamily(&fernmicro12RegularFont, &fernmicro12BoldFont, &fernmicro12ItalicFont, &fernmicro12BoldItalicFont);
EpdFontFamily fernmicro14FontFamily(&fernmicro14RegularFont, &fernmicro14BoldFont, &fernmicro14ItalicFont, &fernmicro14BoldItalicFont);
EpdFontFamily fernmicro16FontFamily(&fernmicro16RegularFont, &fernmicro16BoldFont, &fernmicro16ItalicFont, &fernmicro16BoldItalicFont);
EpdFontFamily fernmicro18FontFamily(&fernmicro18RegularFont, &fernmicro18BoldFont, &fernmicro18ItalicFont, &fernmicro18BoldItalicFont);
EpdFontFamily atkinsonhyperlegiblenext12FontFamily(&atkinsonhyperlegiblenext12RegularFont,
&atkinsonhyperlegiblenext12BoldFont,
&atkinsonhyperlegiblenext12ItalicFont,
&atkinsonhyperlegiblenext12BoldItalicFont);
EpdFontFamily atkinsonhyperlegiblenext14FontFamily(&atkinsonhyperlegiblenext14RegularFont,
&atkinsonhyperlegiblenext14BoldFont,
&atkinsonhyperlegiblenext14ItalicFont,
&atkinsonhyperlegiblenext14BoldItalicFont);
EpdFontFamily atkinsonhyperlegiblenext16FontFamily(&atkinsonhyperlegiblenext16RegularFont,
&atkinsonhyperlegiblenext16BoldFont,
&atkinsonhyperlegiblenext16ItalicFont,
&atkinsonhyperlegiblenext16BoldItalicFont);
EpdFontFamily atkinsonhyperlegiblenext18FontFamily(&atkinsonhyperlegiblenext18RegularFont,
&atkinsonhyperlegiblenext18BoldFont,
&atkinsonhyperlegiblenext18ItalicFont,
&atkinsonhyperlegiblenext18BoldItalicFont);
EpdFontFamily fernmicro12FontFamily(&fernmicro12RegularFont, &fernmicro12BoldFont, &fernmicro12ItalicFont,
&fernmicro12BoldItalicFont);
EpdFontFamily fernmicro14FontFamily(&fernmicro14RegularFont, &fernmicro14BoldFont, &fernmicro14ItalicFont,
&fernmicro14BoldItalicFont);
EpdFontFamily fernmicro16FontFamily(&fernmicro16RegularFont, &fernmicro16BoldFont, &fernmicro16ItalicFont,
&fernmicro16BoldItalicFont);
EpdFontFamily fernmicro18FontFamily(&fernmicro18RegularFont, &fernmicro18BoldFont, &fernmicro18ItalicFont,
&fernmicro18BoldItalicFont);
void registerCustomFonts(GfxRenderer& renderer) {
#if CUSTOM_FONT_COUNT > 0
@ -64,4 +81,3 @@ void registerCustomFonts(GfxRenderer& renderer) {
(void)renderer; // Suppress unused parameter warning
#endif
}

View File

@ -30,6 +30,7 @@
// Custom font ID lookup array: CUSTOM_FONT_IDS[fontIndex][sizeIndex]
// Size indices: 0=12pt, 1=14pt, 2=16pt, 3=18pt
static const int CUSTOM_FONT_IDS[][4] = {
{ATKINSONHYPERLEGIBLENEXT_12_FONT_ID, ATKINSONHYPERLEGIBLENEXT_14_FONT_ID, ATKINSONHYPERLEGIBLENEXT_16_FONT_ID, ATKINSONHYPERLEGIBLENEXT_18_FONT_ID},
{FERNMICRO_12_FONT_ID, FERNMICRO_14_FONT_ID, FERNMICRO_16_FONT_ID, FERNMICRO_18_FONT_ID},
{ATKINSONHYPERLEGIBLENEXT_12_FONT_ID, ATKINSONHYPERLEGIBLENEXT_14_FONT_ID, ATKINSONHYPERLEGIBLENEXT_16_FONT_ID,
ATKINSONHYPERLEGIBLENEXT_18_FONT_ID},
{FERNMICRO_12_FONT_ID, FERNMICRO_14_FONT_ID, FERNMICRO_16_FONT_ID, FERNMICRO_18_FONT_ID},
};

View File

@ -11,55 +11,175 @@ static constexpr int LOCK_ICON_HEIGHT = 40;
// Use drawImageRotated() to rotate as needed for different screen orientations
static const uint8_t LockIcon[] = {
// Row 0-1: Empty space above shackle
0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
// Row 2-3: Shackle top curve
0x00, 0x0F, 0xF0, 0x00, // ....####....
0x00, 0x3F, 0xFC, 0x00, // ..########..
0x00,
0x0F,
0xF0,
0x00, // ....####....
0x00,
0x3F,
0xFC,
0x00, // ..########..
// Row 4-5: Shackle upper sides
0x00, 0x78, 0x1E, 0x00, // .####..####.
0x00, 0xE0, 0x07, 0x00, // ###......###
0x00,
0x78,
0x1E,
0x00, // .####..####.
0x00,
0xE0,
0x07,
0x00, // ###......###
// Row 6-9: Extended shackle legs (longer for better visual)
0x00, 0xC0, 0x03, 0x00, // ##........##
0x01, 0xC0, 0x03, 0x80, // ###......###
0x01, 0x80, 0x01, 0x80, // ##........##
0x01, 0x80, 0x01, 0x80, // ##........##
0x00,
0xC0,
0x03,
0x00, // ##........##
0x01,
0xC0,
0x03,
0x80, // ###......###
0x01,
0x80,
0x01,
0x80, // ##........##
0x01,
0x80,
0x01,
0x80, // ##........##
// Row 10-13: Shackle legs continue into body
0x01, 0x80, 0x01, 0x80, // ##........##
0x01, 0x80, 0x01, 0x80, // ##........##
0x01, 0x80, 0x01, 0x80, // ##........##
0x01, 0x80, 0x01, 0x80, // ##........##
0x01,
0x80,
0x01,
0x80, // ##........##
0x01,
0x80,
0x01,
0x80, // ##........##
0x01,
0x80,
0x01,
0x80, // ##........##
0x01,
0x80,
0x01,
0x80, // ##........##
// Row 14-15: Body top
0x0F, 0xFF, 0xFF, 0xF0, // ############
0x1F, 0xFF, 0xFF, 0xF8, // ##############
0x0F,
0xFF,
0xFF,
0xF0, // ############
0x1F,
0xFF,
0xFF,
0xF8, // ##############
// Row 16-17: Body top edge
0x3F, 0xFF, 0xFF, 0xFC, // ################
0x3F, 0xFF, 0xFF, 0xFC, // ################
0x3F,
0xFF,
0xFF,
0xFC, // ################
0x3F,
0xFF,
0xFF,
0xFC, // ################
// Row 18-29: Solid body (no keyhole)
0x3F, 0xFF, 0xFF, 0xFC,
0x3F, 0xFF, 0xFF, 0xFC,
0x3F, 0xFF, 0xFF, 0xFC,
0x3F, 0xFF, 0xFF, 0xFC,
0x3F, 0xFF, 0xFF, 0xFC,
0x3F, 0xFF, 0xFF, 0xFC,
0x3F, 0xFF, 0xFF, 0xFC,
0x3F, 0xFF, 0xFF, 0xFC,
0x3F, 0xFF, 0xFF, 0xFC,
0x3F, 0xFF, 0xFF, 0xFC,
0x3F, 0xFF, 0xFF, 0xFC,
0x3F, 0xFF, 0xFF, 0xFC,
0x3F,
0xFF,
0xFF,
0xFC,
0x3F,
0xFF,
0xFF,
0xFC,
0x3F,
0xFF,
0xFF,
0xFC,
0x3F,
0xFF,
0xFF,
0xFC,
0x3F,
0xFF,
0xFF,
0xFC,
0x3F,
0xFF,
0xFF,
0xFC,
0x3F,
0xFF,
0xFF,
0xFC,
0x3F,
0xFF,
0xFF,
0xFC,
0x3F,
0xFF,
0xFF,
0xFC,
0x3F,
0xFF,
0xFF,
0xFC,
0x3F,
0xFF,
0xFF,
0xFC,
0x3F,
0xFF,
0xFF,
0xFC,
// Row 30-33: Body lower section
0x3F, 0xFF, 0xFF, 0xFC,
0x3F, 0xFF, 0xFF, 0xFC,
0x3F, 0xFF, 0xFF, 0xFC,
0x3F, 0xFF, 0xFF, 0xFC,
0x3F,
0xFF,
0xFF,
0xFC,
0x3F,
0xFF,
0xFF,
0xFC,
0x3F,
0xFF,
0xFF,
0xFC,
0x3F,
0xFF,
0xFF,
0xFC,
// Row 34-35: Body bottom edge
0x3F, 0xFF, 0xFF, 0xFC,
0x1F, 0xFF, 0xFF, 0xF8,
0x3F,
0xFF,
0xFF,
0xFC,
0x1F,
0xFF,
0xFF,
0xF8,
// Row 36-37: Body bottom
0x0F, 0xFF, 0xFF, 0xF0,
0x00, 0x00, 0x00, 0x00,
0x0F,
0xFF,
0xFF,
0xF0,
0x00,
0x00,
0x00,
0x00,
// Row 38-39: Empty space below
0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
};

View File

@ -1,4 +1,5 @@
#include <Arduino.h>
#include <BitmapHelpers.h>
#include <EInkDisplay.h>
#include <Epub.h>
#include <GfxRenderer.h>
@ -10,8 +11,6 @@
#include <cstring>
#include <BitmapHelpers.h>
#include "Battery.h"
#include "BookListStore.h"
#include "CrossPointSettings.h"
@ -123,11 +122,8 @@ unsigned long t2 = 0;
// Memory debugging helper - logs heap state for tracking leaks
#ifdef DEBUG_MEMORY
void logMemoryState(const char* tag, const char* context) {
Serial.printf("[%lu] [%s] [MEM] %s - Free: %d, Largest: %d, MinFree: %d\n",
millis(), tag, context,
ESP.getFreeHeap(),
ESP.getMaxAllocHeap(),
ESP.getMinFreeHeap());
Serial.printf("[%lu] [%s] [MEM] %s - Free: %d, Largest: %d, MinFree: %d\n", millis(), tag, context, ESP.getFreeHeap(),
ESP.getMaxAllocHeap(), ESP.getMinFreeHeap());
}
#else
// No-op when not in debug mode
@ -196,7 +192,7 @@ void checkForFlashCommand() {
// USB port locations: Portrait=bottom-left, PortraitInverted=top-right,
// LandscapeCW=top-left, LandscapeCCW=bottom-right
// Position offsets: edge margin + half-width offset to center on USB port
constexpr int edgeMargin = 28; // Distance from screen edge
constexpr int edgeMargin = 28; // Distance from screen edge
constexpr int halfWidth = LOCK_ICON_WIDTH / 2; // 16px offset for centering
int iconX, iconY;
GfxRenderer::ImageRotation rotation;
@ -334,9 +330,8 @@ void onGoToClearCache();
void onGoToSettings();
void onGoToReader(const std::string& initialEpubPath, MyLibraryActivity::Tab fromTab) {
exitActivity();
enterNewActivity(
new ReaderActivity(renderer, mappedInputManager, initialEpubPath, fromTab, onGoHome, onGoToMyLibraryWithTab,
onGoToClearCache, onGoToSettings));
enterNewActivity(new ReaderActivity(renderer, mappedInputManager, initialEpubPath, fromTab, onGoHome,
onGoToMyLibraryWithTab, onGoToClearCache, onGoToSettings));
}
void onContinueReading() { onGoToReader(APP_STATE.openEpubPath, MyLibraryActivity::Tab::Recent); }
@ -351,8 +346,7 @@ void onGoToReaderFromList(const std::string& bookPath) {
// View a specific list
void onGoToListView(const std::string& listName) {
exitActivity();
enterNewActivity(
new ListViewActivity(renderer, mappedInputManager, listName, onGoToMyLibrary, onGoToReaderFromList));
enterNewActivity(new ListViewActivity(renderer, mappedInputManager, listName, onGoToMyLibrary, onGoToReaderFromList));
}
// View bookmarks for a specific book
@ -365,9 +359,8 @@ void onGoToBookmarkList(const std::string& bookPath, const std::string& bookTitl
// Navigate to bookmark location in the book
// For now, just open the book (TODO: pass bookmark location to reader)
exitActivity();
enterNewActivity(new ReaderActivity(renderer, mappedInputManager, bookPath,
MyLibraryActivity::Tab::Bookmarks, onGoHome, onGoToMyLibraryWithTab,
onGoToClearCache, onGoToSettings));
enterNewActivity(new ReaderActivity(renderer, mappedInputManager, bookPath, MyLibraryActivity::Tab::Bookmarks,
onGoHome, onGoToMyLibraryWithTab, onGoToClearCache, onGoToSettings));
}));
}
@ -402,12 +395,14 @@ void onGoToClearCache() {
void onGoToMyLibrary() {
exitActivity();
enterNewActivity(new MyLibraryActivity(renderer, mappedInputManager, onGoHome, onGoToReader, onGoToListView, onGoToBookmarkList));
enterNewActivity(
new MyLibraryActivity(renderer, mappedInputManager, onGoHome, onGoToReader, onGoToListView, onGoToBookmarkList));
}
void onGoToMyLibraryWithTab(const std::string& path, MyLibraryActivity::Tab tab) {
exitActivity();
enterNewActivity(new MyLibraryActivity(renderer, mappedInputManager, onGoHome, onGoToReader, onGoToListView, onGoToBookmarkList, tab, path));
enterNewActivity(new MyLibraryActivity(renderer, mappedInputManager, onGoHome, onGoToReader, onGoToListView,
onGoToBookmarkList, tab, path));
}
void onGoToBrowser() {
@ -462,10 +457,14 @@ bool isWakeupByPowerButton() {
void setup() {
t1 = millis();
// Only start serial if USB connected
// Always initialize Serial but make it non-blocking
// This prevents Serial.printf from blocking when USB is disconnected
Serial.begin(115200);
Serial.setTxTimeoutMs(0); // Non-blocking TX - critical for USB disconnect handling
// Only wait for Serial to be ready if USB is connected
pinMode(UART0_RXD, INPUT);
if (isUsbConnected()) {
Serial.begin(115200);
// Wait up to 3 seconds for Serial to be ready to catch early logs
unsigned long start = millis();
while (!Serial && (millis() - start) < 3000) {
@ -542,14 +541,13 @@ void loop() {
// Basic heap info
Serial.printf("[%lu] [MEM] Free: %d bytes, Total: %d bytes, Min Free: %d bytes\n", millis(), ESP.getFreeHeap(),
ESP.getHeapSize(), ESP.getMinFreeHeap());
// Detailed fragmentation info using ESP-IDF heap caps API
multi_heap_info_t info;
heap_caps_get_info(&info, MALLOC_CAP_8BIT);
Serial.printf("[%lu] [HEAP] Largest: %d, Allocated: %d, Blocks: %d, Free blocks: %d\n", millis(),
info.largest_free_block, info.total_allocated_bytes,
info.allocated_blocks, info.free_blocks);
info.largest_free_block, info.total_allocated_bytes, info.allocated_blocks, info.free_blocks);
lastMemPrint = millis();
}
@ -562,7 +560,8 @@ void loop() {
const unsigned long sleepTimeoutMs = SETTINGS.getSleepTimeoutMs();
if (millis() - lastActivityTime >= sleepTimeoutMs) {
Serial.printf("[%lu] [SLP] Auto-sleep triggered after %lu ms of inactivity\n", millis(), sleepTimeoutMs);
if (Serial)
Serial.printf("[%lu] [SLP] Auto-sleep triggered after %lu ms of inactivity\n", millis(), sleepTimeoutMs);
enterDeepSleep();
// This should never be hit as `enterDeepSleep` calls esp_deep_sleep_start
return;
@ -584,7 +583,7 @@ void loop() {
const unsigned long loopDuration = millis() - loopStartTime;
if (loopDuration > maxLoopDuration) {
maxLoopDuration = loopDuration;
if (maxLoopDuration > 50) {
if (Serial && maxLoopDuration > 50) {
Serial.printf("[%lu] [LOOP] New max loop duration: %lu ms (activity: %lu ms)\n", millis(), maxLoopDuration,
activityDuration);
}