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__/ **/__pycache__/
test/epubs/ test/epubs/
CrossPoint-ef.md CrossPoint-ef.md
Serial_print.code-search
# Gitea Actions runner config (contains credentials) # Gitea Actions runner config (contains credentials)
.runner .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()); SdMan.remove(tmpCssPath.c_str());
} }
Serial.printf("[%lu] [EBP] Loaded %zu CSS style rules from %zu files (~%zu bytes)\n", millis(), cssParser->ruleCount(), Serial.printf("[%lu] [EBP] Loaded %zu CSS style rules from %zu files (~%zu bytes)\n", millis(),
cssFiles.size(), cssParser->estimateMemoryUsage()); cssParser->ruleCount(), cssFiles.size(), cssParser->estimateMemoryUsage());
return true; return true;
} }
@ -757,8 +757,8 @@ bool Epub::generateAllCovers(const std::function<void(int)>& progressCallback) c
SdMan.openFileForWrite("EBP", getCoverBmpPath(false), coverBmp)) { SdMan.openFileForWrite("EBP", getCoverBmpPath(false), coverBmp)) {
const int targetWidth = 480; const int targetWidth = 480;
const int targetHeight = (480 * jpegHeight) / jpegWidth; const int targetHeight = (480 * jpegHeight) / jpegWidth;
const bool success = const bool success = JpegToBmpConverter::jpegFileToBmpStreamWithSize(coverJpg, coverBmp, targetWidth,
JpegToBmpConverter::jpegFileToBmpStreamWithSize(coverJpg, coverBmp, targetWidth, targetHeight, makeSubProgress(50, 75)); targetHeight, makeSubProgress(50, 75));
coverJpg.close(); coverJpg.close();
coverBmp.close(); coverBmp.close();
if (!success) { if (!success) {
@ -776,8 +776,8 @@ bool Epub::generateAllCovers(const std::function<void(int)>& progressCallback) c
SdMan.openFileForWrite("EBP", getCoverBmpPath(true), coverBmp)) { SdMan.openFileForWrite("EBP", getCoverBmpPath(true), coverBmp)) {
const int targetHeight = 800; const int targetHeight = 800;
const int targetWidth = (800 * jpegWidth) / jpegHeight; const int targetWidth = (800 * jpegWidth) / jpegHeight;
const bool success = const bool success = JpegToBmpConverter::jpegFileToBmpStreamWithSize(coverJpg, coverBmp, targetWidth,
JpegToBmpConverter::jpegFileToBmpStreamWithSize(coverJpg, coverBmp, targetWidth, targetHeight, makeSubProgress(75, 100)); targetHeight, makeSubProgress(75, 100));
coverJpg.close(); coverJpg.close();
coverBmp.close(); coverBmp.close();
if (!success) { if (!success) {

View File

@ -57,12 +57,12 @@ class Page {
public: public:
// the list of block index and line numbers on this page // the list of block index and line numbers on this page
std::vector<std::shared_ptr<PageElement>> elements; std::vector<std::shared_ptr<PageElement>> elements;
// Byte offset in source HTML where this page's content begins // Byte offset in source HTML where this page's content begins
// Used for restoring reading position after re-indexing due to font/setting changes // 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 // This is stored in the Section file's LUT, not in Page serialization
uint32_t firstContentOffset = 0; uint32_t firstContentOffset = 0;
void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) const; void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) const;
bool serialize(FsFile& file) const; bool serialize(FsFile& file) const;
static std::unique_ptr<Page> deserialize(FsFile& file); 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, writeSectionFileHeader(fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth,
viewportHeight, hyphenationEnabled); viewportHeight, hyphenationEnabled);
// LUT entries: { filePosition, contentOffset } pairs // LUT entries: { filePosition, contentOffset } pairs
struct LutEntry { struct LutEntry {
uint32_t filePos; 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 contentOffset = page->firstContentOffset;
const uint32_t filePos = this->onPageComplete(std::move(page)); const uint32_t filePos = this->onPageComplete(std::move(page));
lut.push_back({filePos, contentOffset}); lut.push_back({filePos, contentOffset});
}, progressFn, },
epub->getCssParser()); progressFn, epub->getCssParser());
Hyphenator::setPreferredLanguage(epub->getLanguage()); Hyphenator::setPreferredLanguage(epub->getLanguage());
success = visitor.parseAndBuildPages(); success = visitor.parseAndBuildPages();
@ -217,7 +217,7 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
// Add placeholder to LUT // Add placeholder to LUT
const uint32_t filePos = this->onPageComplete(std::move(placeholderPage)); const uint32_t filePos = this->onPageComplete(std::move(placeholderPage));
lut.push_back({filePos, 0}); lut.push_back({filePos, 0});
// If we still have no pages, the placeholder creation failed // If we still have no pages, the placeholder creation failed
if (pageCount == 0) { if (pageCount == 0) {
Serial.printf("[%lu] [SCT] Failed to create placeholder page\n", millis()); 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)); file.seek(HEADER_SIZE - sizeof(uint32_t));
uint32_t lutOffset; uint32_t lutOffset;
serialization::readPod(file, lutOffset); serialization::readPod(file, lutOffset);
// LUT entries are now 8 bytes each: { filePos (4), contentOffset (4) } // LUT entries are now 8 bytes each: { filePos (4), contentOffset (4) }
file.seek(lutOffset + LUT_ENTRY_SIZE * currentPage); file.seek(lutOffset + LUT_ENTRY_SIZE * currentPage);
uint32_t pagePos; uint32_t pagePos;
serialization::readPod(file, pagePos); serialization::readPod(file, pagePos);
// Skip contentOffset for now - we don't need it when just loading the page // Skip contentOffset for now - we don't need it when just loading the page
file.seek(pagePos); file.seek(pagePos);
auto page = Page::deserialize(file); auto page = Page::deserialize(file);
@ -300,15 +300,15 @@ int Section::findPageForContentOffset(uint32_t targetOffset) const {
while (left <= right) { while (left <= right) {
const int mid = left + (right - left) / 2; const int mid = left + (right - left) / 2;
// Read content offset for page 'mid' // Read content offset for page 'mid'
// LUT entry format: { filePos (4), contentOffset (4) } // LUT entry format: { filePos (4), contentOffset (4) }
f.seek(lutOffset + LUT_ENTRY_SIZE * mid + sizeof(uint32_t)); // Skip filePos f.seek(lutOffset + LUT_ENTRY_SIZE * mid + sizeof(uint32_t)); // Skip filePos
uint32_t midOffset; uint32_t midOffset;
serialization::readPod(f, midOffset); serialization::readPod(f, midOffset);
if (midOffset <= targetOffset) { 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 left = mid + 1; // Look for a later page that might also qualify
} else { } else {
right = mid - 1; // Look for an earlier page 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)); f.seek(lutOffset + LUT_ENTRY_SIZE * result + sizeof(uint32_t));
uint32_t resultOffset; uint32_t resultOffset;
serialization::readPod(f, resultOffset); serialization::readPod(f, resultOffset);
while (result > 0) { while (result > 0) {
f.seek(lutOffset + LUT_ENTRY_SIZE * (result - 1) + sizeof(uint32_t)); f.seek(lutOffset + LUT_ENTRY_SIZE * (result - 1) + sizeof(uint32_t));
uint32_t prevOffset; uint32_t prevOffset;

View File

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

View File

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

View File

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

View File

@ -5,13 +5,9 @@
// Global high contrast mode flag // Global high contrast mode flag
static bool g_highContrastMode = false; static bool g_highContrastMode = false;
void setHighContrastMode(bool enabled) { void setHighContrastMode(bool enabled) { g_highContrastMode = enabled; }
g_highContrastMode = enabled;
}
bool isHighContrastMode() { bool isHighContrastMode() { return g_highContrastMode; }
return g_highContrastMode;
}
// Brightness/Contrast adjustments: // Brightness/Contrast adjustments:
constexpr bool USE_BRIGHTNESS = false; // true: apply brightness/gamma 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 // Calculate screen Y position
const int screenYStart = y + static_cast<int>(std::floor(logicalY * scale)); const int screenYStart = y + static_cast<int>(std::floor(logicalY * scale));
// For upscaling, calculate the end position for this source row // 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) // Draw to all Y positions this source row maps to (for upscaling, this fills gaps)
for (int screenY = screenYStart; screenY < screenYEnd; screenY++) { 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 // Calculate screen X position
const int screenXStart = x + static_cast<int>(std::floor(srcX * scale)); const int screenXStart = x + static_cast<int>(std::floor(srcX * scale));
// For upscaling, calculate the end position for this source pixel // 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; 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 // Calculate screen Y position
const int screenYStart = y + static_cast<int>(std::floor(logicalY * scale)); const int screenYStart = y + static_cast<int>(std::floor(logicalY * scale));
// For upscaling, calculate the end position for this source row // 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) // Draw to all Y positions this source row maps to (for upscaling, this fills gaps)
for (int screenY = screenYStart; screenY < screenYEnd; screenY++) { 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 // Calculate screen X position
const int screenXStart = x + static_cast<int>(std::floor(bmpX * scale)); const int screenXStart = x + static_cast<int>(std::floor(bmpX * scale));
// For upscaling, calculate the end position for this source pixel // 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) // Get 2-bit value (result of readNextRow quantization)
const uint8_t val = outputRow[bmpX / 4] >> (6 - ((bmpX * 2) % 8)) & 0x3; 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; return bezelEdge;
case GfxRenderer::LandscapeClockwise: case GfxRenderer::LandscapeClockwise:
switch (bezelEdge) { switch (bezelEdge) {
case 0: return 2; // Physical bottom -> logical left case 0:
case 1: return 3; // Physical top -> logical right return 2; // Physical bottom -> logical left
case 2: return 1; // Physical left -> logical top case 1:
case 3: return 0; // Physical right -> logical bottom return 3; // Physical top -> logical right
case 2:
return 1; // Physical left -> logical top
case 3:
return 0; // Physical right -> logical bottom
} }
break; break;
case GfxRenderer::PortraitInverted: case GfxRenderer::PortraitInverted:
switch (bezelEdge) { switch (bezelEdge) {
case 0: return 1; // Physical bottom -> logical top case 0:
case 1: return 0; // Physical top -> logical bottom return 1; // Physical bottom -> logical top
case 2: return 3; // Physical left -> logical right case 1:
case 3: return 2; // Physical right -> logical left return 0; // Physical top -> logical bottom
case 2:
return 3; // Physical left -> logical right
case 3:
return 2; // Physical right -> logical left
} }
break; break;
case GfxRenderer::LandscapeCounterClockwise: case GfxRenderer::LandscapeCounterClockwise:
switch (bezelEdge) { switch (bezelEdge) {
case 0: return 3; // Physical bottom -> logical right case 0:
case 1: return 2; // Physical top -> logical left return 3; // Physical bottom -> logical right
case 2: return 0; // Physical left -> logical bottom case 1:
case 3: return 1; // Physical right -> logical top return 2; // Physical top -> logical left
case 2:
return 0; // Physical left -> logical bottom
case 3:
return 1; // Physical right -> logical top
} }
break; break;
} }
@ -1074,23 +1090,34 @@ void GfxRenderer::getOrientedViewableTRBL(int* outTop, int* outRight, int* outBo
*outLeft = getViewableMarginLeft(); *outLeft = getViewableMarginLeft();
break; break;
case LandscapeClockwise: case LandscapeClockwise:
*outTop = BASE_VIEWABLE_MARGIN_LEFT + (mapPhysicalToLogicalEdge(bezelEdge, orientation) == 1 ? bezelCompensation : 0); *outTop =
*outRight = BASE_VIEWABLE_MARGIN_TOP + (mapPhysicalToLogicalEdge(bezelEdge, orientation) == 3 ? bezelCompensation : 0); BASE_VIEWABLE_MARGIN_LEFT + (mapPhysicalToLogicalEdge(bezelEdge, orientation) == 1 ? bezelCompensation : 0);
*outBottom = BASE_VIEWABLE_MARGIN_RIGHT + (mapPhysicalToLogicalEdge(bezelEdge, orientation) == 0 ? bezelCompensation : 0); *outRight =
*outLeft = BASE_VIEWABLE_MARGIN_BOTTOM + (mapPhysicalToLogicalEdge(bezelEdge, orientation) == 2 ? bezelCompensation : 0); 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; break;
case PortraitInverted: case PortraitInverted:
*outTop = BASE_VIEWABLE_MARGIN_BOTTOM + (mapPhysicalToLogicalEdge(bezelEdge, orientation) == 1 ? bezelCompensation : 0); *outTop =
*outRight = BASE_VIEWABLE_MARGIN_LEFT + (mapPhysicalToLogicalEdge(bezelEdge, orientation) == 3 ? bezelCompensation : 0); BASE_VIEWABLE_MARGIN_BOTTOM + (mapPhysicalToLogicalEdge(bezelEdge, orientation) == 1 ? bezelCompensation : 0);
*outBottom = BASE_VIEWABLE_MARGIN_TOP + (mapPhysicalToLogicalEdge(bezelEdge, orientation) == 0 ? bezelCompensation : 0); *outRight =
*outLeft = BASE_VIEWABLE_MARGIN_RIGHT + (mapPhysicalToLogicalEdge(bezelEdge, orientation) == 2 ? bezelCompensation : 0); 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; break;
case LandscapeCounterClockwise: case LandscapeCounterClockwise:
*outTop = BASE_VIEWABLE_MARGIN_RIGHT + (mapPhysicalToLogicalEdge(bezelEdge, orientation) == 1 ? bezelCompensation : 0); *outTop =
*outRight = BASE_VIEWABLE_MARGIN_BOTTOM + (mapPhysicalToLogicalEdge(bezelEdge, orientation) == 3 ? bezelCompensation : 0); BASE_VIEWABLE_MARGIN_RIGHT + (mapPhysicalToLogicalEdge(bezelEdge, orientation) == 1 ? bezelCompensation : 0);
*outBottom = BASE_VIEWABLE_MARGIN_LEFT + (mapPhysicalToLogicalEdge(bezelEdge, orientation) == 0 ? bezelCompensation : 0); *outRight =
*outLeft = BASE_VIEWABLE_MARGIN_TOP + (mapPhysicalToLogicalEdge(bezelEdge, orientation) == 2 ? bezelCompensation : 0); 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; break;
} }
} }

View File

@ -94,10 +94,10 @@ class GfxRenderer {
// Handles current render mode (BW, GRAYSCALE_MSB, GRAYSCALE_LSB) // Handles current render mode (BW, GRAYSCALE_MSB, GRAYSCALE_LSB)
void fillRectGray(int x, int y, int width, int height, uint8_t grayLevel) const; 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 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, void drawImageRotated(const uint8_t bitmap[], int x, int y, int width, int height, ImageRotation rotation,
ImageRotation rotation, bool invert = false) const; bool invert = false) const;
void drawBitmap(const Bitmap& bitmap, int x, int y, int maxWidth, int maxHeight, float cropX = 0, void drawBitmap(const Bitmap& bitmap, int x, int y, int maxWidth, int maxHeight, float cropX = 0, float cropY = 0,
float cropY = 0, bool invert = false) const; bool invert = false) const;
void drawBitmap1Bit(const Bitmap& bitmap, int x, int y, int maxWidth, int maxHeight, 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; 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); std::string tagName = html.substr(nameStart, pos - nameStart);
// Convert to lowercase // Convert to lowercase
std::transform(tagName.begin(), tagName.end(), tagName.begin(), std::transform(tagName.begin(), tagName.end(), tagName.begin(), [](unsigned char c) { return std::tolower(c); });
[](unsigned char c) { return std::tolower(c); });
return tagName; return tagName;
} }
@ -160,17 +159,11 @@ bool DictHtmlParser::isBlockTag(const std::string& tagName) {
tagName == "ol" || tagName == "ul" || tagName == "dt" || tagName == "dd" || tagName == "html"; tagName == "ol" || tagName == "ul" || tagName == "dt" || tagName == "dd" || tagName == "html";
} }
bool DictHtmlParser::isBoldTag(const std::string& tagName) { bool DictHtmlParser::isBoldTag(const std::string& tagName) { return tagName == "b" || tagName == "strong"; }
return tagName == "b" || tagName == "strong";
}
bool DictHtmlParser::isItalicTag(const std::string& tagName) { bool DictHtmlParser::isItalicTag(const std::string& tagName) { return tagName == "i" || tagName == "em"; }
return tagName == "i" || tagName == "em";
}
bool DictHtmlParser::isUnderlineTag(const std::string& tagName) { bool DictHtmlParser::isUnderlineTag(const std::string& tagName) { return tagName == "u" || tagName == "ins"; }
return tagName == "u" || tagName == "ins";
}
bool DictHtmlParser::isSuperscriptTag(const std::string& tagName) { return tagName == "sup"; } 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. * DictHtmlParser parses HTML dictionary definitions into ParsedText.
* *
* Supports: * Supports:
* - Bold: <b>, <strong> * - Bold: <b>, <strong>
* - Italic: <i>, <em> * - Italic: <i>, <em>
@ -25,7 +25,7 @@ class DictHtmlParser {
/** /**
* Parse HTML definition and populate ParsedText with styled words. * Parse HTML definition and populate ParsedText with styled words.
* Each paragraph/block creates a separate ParsedText via the callback. * Each paragraph/block creates a separate ParsedText via the callback.
* *
* @param html The HTML definition text * @param html The HTML definition text
* @param fontId Font ID for text width calculations * @param fontId Font ID for text width calculations
* @param renderer Reference to renderer for layout * @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; const char* replacement;
}; };
static const EntityMapping entities[] = { static const EntityMapping entities[] = {
{"&nbsp;", " "}, {"&lt;", "<"}, {"&gt;", ">"}, {"&nbsp;", " "},
{"&amp;", "&"}, {"&quot;", "\""}, {"&apos;", "'"}, {"&lt;", "<"},
{"&gt;", ">"},
{"&amp;", "&"},
{"&quot;", "\""},
{"&apos;", "'"},
{"&mdash;", "\xe2\x80\x94"}, // — {"&mdash;", "\xe2\x80\x94"}, // —
{"&ndash;", "\xe2\x80\x93"}, // {"&ndash;", "\xe2\x80\x93"}, //
{"&hellip;", "\xe2\x80\xa6"}, // … {"&hellip;", "\xe2\x80\xa6"}, // …
@ -688,8 +692,8 @@ std::string StarDict::stripHtml(const std::string& html) {
// Extract tag name // Extract tag name
size_t tagEnd = tagStart; size_t tagEnd = tagStart;
while (tagEnd < html.length() && !std::isspace(static_cast<unsigned char>(html[tagEnd])) && while (tagEnd < html.length() && !std::isspace(static_cast<unsigned char>(html[tagEnd])) && html[tagEnd] != '>' &&
html[tagEnd] != '>' && html[tagEnd] != '/') { html[tagEnd] != '/') {
tagEnd++; tagEnd++;
} }

View File

@ -32,7 +32,7 @@ class StarDict {
struct DictzipInfo { struct DictzipInfo {
uint32_t chunkLength = 0; // Uncompressed chunk size (usually 58315) uint32_t chunkLength = 0; // Uncompressed chunk size (usually 58315)
uint16_t chunkCount = 0; 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 uint16_t* chunkSizes = nullptr; // Array of compressed chunk sizes
bool loaded = false; bool loaded = false;
}; };

View File

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

View File

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

View File

@ -219,9 +219,7 @@ bool BookListStore::listExists(const std::string& name) {
return SdMan.exists(path.c_str()); return SdMan.exists(path.c_str());
} }
std::string BookListStore::getListPath(const std::string& name) { std::string BookListStore::getListPath(const std::string& name) { return std::string(LISTS_DIR) + "/" + name + ".bin"; }
return std::string(LISTS_DIR) + "/" + name + ".bin";
}
int BookListStore::getBookCount(const std::string& name) { int BookListStore::getBookCount(const std::string& name) {
const std::string path = getListPath(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 * @return Book count, or -1 if list doesn't exist
*/ */
static int getBookCount(const std::string& name); static int getBookCount(const std::string& name);
}; };

View File

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

View File

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

View File

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

View File

@ -26,7 +26,14 @@ class CrossPointSettings {
}; };
// Status bar display type enum // 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 { enum ORIENTATION {
PORTRAIT = 0, // 480x800 logical coordinates (current default) PORTRAIT = 0, // 480x800 logical coordinates (current default)
@ -116,7 +123,7 @@ class CrossPointSettings {
uint8_t sideButtonLayout = PREV_NEXT; uint8_t sideButtonLayout = PREV_NEXT;
// Reader font settings // Reader font settings
uint8_t fontFamily = BOOKERLY; 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 fallbackFontFamily = BOOKERLY; // Fallback for missing glyphs/weights in custom fonts
uint8_t fontSize = MEDIUM; uint8_t fontSize = MEDIUM;
uint8_t lineSpacing = NORMAL; 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); 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) { int ScreenComponents::drawTabBar(const GfxRenderer& renderer, const int y, const std::vector<TabInfo>& tabs,
constexpr int tabPadding = 20; // Horizontal padding between tabs int selectedIndex, bool showCursor) {
constexpr int leftMargin = 20; // Left margin for first tab constexpr int tabPadding = 20; // Horizontal padding between tabs
constexpr int rightMargin = 20; // Right margin constexpr int leftMargin = 20; // Left margin for first tab
constexpr int underlineHeight = 2; // Height of selection underline constexpr int rightMargin = 20; // Right margin
constexpr int underlineGap = 4; // Gap between text and underline constexpr int underlineHeight = 2; // Height of selection underline
constexpr int cursorPadding = 4; // Space between bullet cursor and tab text 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 constexpr int overflowIndicatorWidth = 16; // Space reserved for < > indicators
const int lineHeight = renderer.getLineHeight(UI_12_FONT_ID); 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; std::vector<int> tabWidths;
int totalWidth = 0; int totalWidth = 0;
for (const auto& tab : tabs) { 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); tabWidths.push_back(textWidth);
totalWidth += textWidth; totalWidth += textWidth;
} }
@ -151,13 +153,13 @@ int ScreenComponents::drawTabBar(const GfxRenderer& renderer, const int y, const
// Bullet cursor settings // Bullet cursor settings
constexpr int bulletRadius = 3; constexpr int bulletRadius = 3;
const int bulletCenterY = y + lineHeight / 2; const int bulletCenterY = y + lineHeight / 2;
// Calculate visible area boundaries (leave room for overflow indicators) // Calculate visible area boundaries (leave room for overflow indicators)
const bool hasLeftOverflow = scrollOffset > 0; const bool hasLeftOverflow = scrollOffset > 0;
const bool hasRightOverflow = totalWidth > availableWidth && scrollOffset < totalWidth - availableWidth; const bool hasRightOverflow = totalWidth > availableWidth && scrollOffset < totalWidth - availableWidth;
const int visibleLeft = bezelLeft + (hasLeftOverflow ? overflowIndicatorWidth : 0); const int visibleLeft = bezelLeft + (hasLeftOverflow ? overflowIndicatorWidth : 0);
const int visibleRight = screenWidth - bezelRight - (hasRightOverflow ? overflowIndicatorWidth : 0); const int visibleRight = screenWidth - bezelRight - (hasRightOverflow ? overflowIndicatorWidth : 0);
for (size_t i = 0; i < tabs.size(); i++) { for (size_t i = 0; i < tabs.size(); i++) {
const auto& tab = tabs[i]; const auto& tab = tabs[i];
const int textWidth = tabWidths[i]; const int textWidth = tabWidths[i];
@ -177,7 +179,7 @@ int ScreenComponents::drawTabBar(const GfxRenderer& renderer, const int y, const
} }
} }
} }
// Draw tab label // Draw tab label
renderer.drawText(UI_12_FONT_ID, currentX, y, tab.label, true, renderer.drawText(UI_12_FONT_ID, currentX, y, tab.label, true,
tab.selected ? EpdFontFamily::BOLD : EpdFontFamily::REGULAR); 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 // Draw underline for selected tab
if (tab.selected) { if (tab.selected) {
renderer.fillRect(currentX, y + lineHeight + underlineGap, textWidth, underlineHeight); 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 triangleHeight = 12; // Height of the triangle (vertical)
constexpr int triangleWidth = 6; // Width of the triangle (horizontal) - thin/elongated constexpr int triangleWidth = 6; // Width of the triangle (horizontal) - thin/elongated
const int triangleCenterY = y + lineHeight / 2; const int triangleCenterY = y + lineHeight / 2;
// Left overflow indicator (more content to the left) - thin triangle pointing left // Left overflow indicator (more content to the left) - thin triangle pointing left
if (scrollOffset > 0) { if (scrollOffset > 0) {
// Clear background behind indicator to hide any overlapping text // 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) { for (int i = 0; i < triangleWidth; ++i) {
// Scale height based on position (0 at tip, full height at base) // Scale height based on position (0 at tip, full height at base)
const int lineHalfHeight = (triangleHeight * i) / (triangleWidth * 2); const int lineHalfHeight = (triangleHeight * i) / (triangleWidth * 2);
renderer.drawLine(tipX + i, triangleCenterY - lineHalfHeight, renderer.drawLine(tipX + i, triangleCenterY - lineHalfHeight, tipX + i, triangleCenterY + lineHalfHeight);
tipX + i, triangleCenterY + lineHalfHeight);
} }
} }
// Right overflow indicator (more content to the right) - thin triangle pointing right // Right overflow indicator (more content to the right) - thin triangle pointing right
if (scrollOffset < totalWidth - availableWidth) { if (scrollOffset < totalWidth - availableWidth) {
// Clear background behind indicator to hide any overlapping text // 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 // Draw right-pointing triangle: base on left, point on right
const int baseX = screenWidth - bezelRight - 2 - triangleWidth; const int baseX = screenWidth - bezelRight - 2 - triangleWidth;
for (int i = 0; i < triangleWidth; ++i) { for (int i = 0; i < triangleWidth; ++i) {
// Scale height based on position (full height at base, 0 at tip) // Scale height based on position (full height at base, 0 at tip)
const int lineHalfHeight = (triangleHeight * (triangleWidth - 1 - i)) / (triangleWidth * 2); const int lineHalfHeight = (triangleHeight * (triangleWidth - 1 - i)) / (triangleWidth * 2);
renderer.drawLine(baseX + i, triangleCenterY - lineHalfHeight, renderer.drawLine(baseX + i, triangleCenterY - lineHalfHeight, 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) // Returns the height of the tab bar (for positioning content below)
// When selectedIndex is provided, tabs scroll so the selected tab is visible // When selectedIndex is provided, tabs scroll so the selected tab is visible
// When showCursor is true, bullet indicators are drawn around the selected tab // 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 // Draw a scroll/page indicator on the right side of the screen
// Shows up/down arrows and current page fraction (e.g., "1/3") // 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 // Helper macro to log stack high-water mark for a task
// Usage: LOG_STACK_WATERMARK("ActivityName", taskHandle); // Usage: LOG_STACK_WATERMARK("ActivityName", taskHandle);
#define LOG_STACK_WATERMARK(name, handle) \ #define LOG_STACK_WATERMARK(name, handle) \
do { \ do { \
if (handle) { \ if (handle) { \
UBaseType_t remaining = uxTaskGetStackHighWaterMark(handle); \ UBaseType_t remaining = uxTaskGetStackHighWaterMark(handle); \
Serial.printf("[%lu] [STACK] %s: %u bytes remaining\n", millis(), name, remaining * sizeof(StackType_t)); \ Serial.printf("[%lu] [STACK] %s: %u bytes remaining\n", millis(), name, remaining * sizeof(StackType_t)); \
} \ } \
} while(0) } while (0)
class Activity { class Activity {
protected: protected:

View File

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

View File

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

View File

@ -236,7 +236,8 @@ void OpdsBookBrowserActivity::render() const {
} }
const auto pageStartIndex = selectorIndex / PAGE_ITEMS * PAGE_ITEMS; 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++) { for (size_t i = pageStartIndex; i < entries.size() && i < static_cast<size_t>(pageStartIndex + PAGE_ITEMS); i++) {
const auto& entry = entries[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(), renderer.drawText(UI_10_FONT_ID, 20 + bezelLeft, 60 + bezelTop + (i % PAGE_ITEMS) * 30, item.c_str(),
i != static_cast<size_t>(selectorIndex)); i != static_cast<size_t>(selectorIndex));
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -13,10 +13,10 @@
// Cached thumbnail existence info for Recent tab // Cached thumbnail existence info for Recent tab
struct ThumbExistsCache { struct ThumbExistsCache {
std::string bookPath; // Book path this cache entry belongs to std::string bookPath; // Book path this cache entry belongs to
std::string thumbPath; // Path to micro-thumbnail (if exists) std::string thumbPath; // Path to micro-thumbnail (if exists)
bool checked = false; // Whether we've checked for this book bool checked = false; // Whether we've checked for this book
bool exists = false; // Whether thumbnail exists bool exists = false; // Whether thumbnail exists
}; };
// Search result for the Search tab // Search result for the Search tab
@ -38,7 +38,14 @@ struct BookmarkedBook {
class MyLibraryActivity final : public Activity { class MyLibraryActivity final : public Activity {
public: public:
enum class Tab { Recent, Lists, Bookmarks, Search, Files }; 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 }; enum class ActionType { Archive, Delete, RemoveFromRecents, ClearAllRecents };
private: private:
@ -55,7 +62,7 @@ class MyLibraryActivity final : public Activity {
ActionType selectedAction = ActionType::Archive; ActionType selectedAction = ActionType::Archive;
std::string actionTargetPath; std::string actionTargetPath;
std::string actionTargetName; 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 bool ignoreNextConfirmRelease = false; // Prevents immediate selection after long-press opens menu
// Recent tab state // Recent tab state
@ -64,13 +71,12 @@ class MyLibraryActivity final : public Activity {
// Static thumbnail existence cache - persists across activity enter/exit // Static thumbnail existence cache - persists across activity enter/exit
static constexpr int MAX_THUMB_CACHE = 10; static constexpr int MAX_THUMB_CACHE = 10;
static ThumbExistsCache thumbExistsCache[MAX_THUMB_CACHE]; static ThumbExistsCache thumbExistsCache[MAX_THUMB_CACHE];
public: public:
// Clear the thumbnail existence cache (call when disk cache is cleared) // Clear the thumbnail existence cache (call when disk cache is cleared)
static void clearThumbExistsCache(); static void clearThumbExistsCache();
private:
private:
// Lists tab state // Lists tab state
std::vector<std::string> lists; std::vector<std::string> lists;
@ -148,12 +154,12 @@ class MyLibraryActivity final : public Activity {
void renderClearAllRecentsConfirmation() const; void renderClearAllRecentsConfirmation() const;
public: public:
explicit MyLibraryActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, explicit MyLibraryActivity(
const std::function<void()>& onGoHome, 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& path, Tab fromTab)>& onSelectBook,
const std::function<void(const std::string& listName)>& onSelectList, const std::function<void(const std::string& listName)>& onSelectList,
const std::function<void(const std::string& path, const std::string& title)>& onSelectBookmarkedBook = nullptr, const std::function<void(const std::string& path, const std::string& title)>& onSelectBookmarkedBook = nullptr,
Tab initialTab = Tab::Recent, std::string initialPath = "/") Tab initialTab = Tab::Recent, std::string initialPath = "/")
: Activity("MyLibrary", renderer, mappedInput), : Activity("MyLibrary", renderer, mappedInput),
currentTab(initialTab), currentTab(initialTab),
basepath(initialPath.empty() ? "/" : std::move(initialPath)), 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 // Landscape layout (800x480): QR on left, text on right
constexpr int QR_X = 15; constexpr int QR_X = 15;
constexpr int QR_Y = 15; constexpr int QR_Y = 15;
constexpr int QR_PX = 7; // pixels per QR module constexpr int QR_PX = 7; // pixels per QR module
constexpr int QR_SIZE = 33 * QR_PX; // QR version 4 = 33 modules = 231px 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 TEXT_X = QR_X + QR_SIZE + 35; // Text starts after QR + margin
constexpr int LINE_SPACING = 32; constexpr int LINE_SPACING = 32;
// Draw title on right side // Draw title on right side
@ -667,9 +667,9 @@ void CrossPointWebServerActivity::renderCompanionAppScreen() const {
// Landscape layout (800x480): QR on left, text on right // Landscape layout (800x480): QR on left, text on right
constexpr int QR_X = 15; constexpr int QR_X = 15;
constexpr int QR_Y = 15; constexpr int QR_Y = 15;
constexpr int QR_PX = 7; // pixels per QR module constexpr int QR_PX = 7; // pixels per QR module
constexpr int QR_SIZE = 33 * QR_PX; // QR version 4 = 33 modules = 231px 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 TEXT_X = QR_X + QR_SIZE + 35; // Text starts after QR + margin
constexpr int LINE_SPACING = 32; constexpr int LINE_SPACING = 32;
// Draw title on right side // Draw title on right side
@ -717,9 +717,9 @@ void CrossPointWebServerActivity::renderCompanionAppLibraryScreen() const {
// Landscape layout (800x480): QR on left, text on right // Landscape layout (800x480): QR on left, text on right
constexpr int QR_X = 15; constexpr int QR_X = 15;
constexpr int QR_Y = 15; constexpr int QR_Y = 15;
constexpr int QR_PX = 7; // pixels per QR module constexpr int QR_PX = 7; // pixels per QR module
constexpr int QR_SIZE = 33 * QR_PX; // QR version 4 = 33 modules = 231px 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 TEXT_X = QR_X + QR_SIZE + 35; // Text starts after QR + margin
constexpr int LINE_SPACING = 32; constexpr int LINE_SPACING = 32;
// Draw title on right side // Draw title on right side

View File

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

View File

@ -24,7 +24,7 @@ class EpubReaderActivity final : public ActivityWithSubactivity {
// End-of-book prompt state // End-of-book prompt state
bool showingEndOfBookPrompt = false; bool showingEndOfBookPrompt = false;
int endOfBookSelection = 2; // 0=Archive, 1=Delete, 2=Keep (default to safe option) int endOfBookSelection = 2; // 0=Archive, 1=Delete, 2=Keep (default to safe option)
// Content offset for position restoration after re-indexing // Content offset for position restoration after re-indexing
uint32_t savedContentOffset = 0; uint32_t savedContentOffset = 0;
bool hasContentOffset = false; // True if we have a valid content offset to use 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 bezelLeft = renderer.getBezelOffsetLeft();
const int bezelRight = renderer.getBezelOffsetRight(); const int bezelRight = renderer.getBezelOffsetRight();
const std::string title = const std::string title = renderer.truncatedText(UI_12_FONT_ID, epub->getTitle().c_str(),
renderer.truncatedText(UI_12_FONT_ID, epub->getTitle().c_str(), pageWidth - 40 - bezelLeft - bezelRight, EpdFontFamily::BOLD); pageWidth - 40 - bezelLeft - bezelRight, EpdFontFamily::BOLD);
renderer.drawCenteredText(UI_12_FONT_ID, 15 + bezelTop, title.c_str(), true, EpdFontFamily::BOLD); renderer.drawCenteredText(UI_12_FONT_ID, 15 + bezelTop, title.c_str(), true, EpdFontFamily::BOLD);
const auto pageStartIndex = selectorIndex / pageItems * pageItems; 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++) { for (int itemIndex = pageStartIndex; itemIndex < totalItems && itemIndex < pageStartIndex + pageItems; itemIndex++) {
const int displayY = 60 + bezelTop + (itemIndex % pageItems) * 30; const int displayY = 60 + bezelTop + (itemIndex % pageItems) * 30;
@ -175,8 +176,8 @@ void EpubReaderChapterSelectionActivity::renderScreen() {
const int tocIndex = tocIndexFromItemIndex(itemIndex); const int tocIndex = tocIndexFromItemIndex(itemIndex);
auto item = epub->getTocItem(tocIndex); auto item = epub->getTocItem(tocIndex);
const int indentSize = 20 + bezelLeft + (item.level - 1) * 15; const int indentSize = 20 + bezelLeft + (item.level - 1) * 15;
const std::string chapterName = const std::string chapterName = renderer.truncatedText(
renderer.truncatedText(UI_10_FONT_ID, item.title.c_str(), pageWidth - 40 - bezelLeft - bezelRight - indentSize + 20 + bezelLeft); 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); 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; currentBookPath = epubPath;
exitActivity(); exitActivity();
enterNewActivity(new EpubReaderActivity( enterNewActivity(new EpubReaderActivity(
renderer, mappedInput, std::move(epub), renderer, mappedInput, std::move(epub), [this, epubPath] { goToLibrary(epubPath); }, [this] { onGoBack(); },
[this, epubPath] { goToLibrary(epubPath); }, onGoToClearCache, onGoToSettings));
[this] { onGoBack(); },
onGoToClearCache,
onGoToSettings));
} }
void ReaderActivity::onGoToTxtReader(std::unique_ptr<Txt> txt) { 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)) { if (SdMan.openFileForWrite("TRS", txt->getCachePath() + "/progress.bin", f)) {
// New format: version + byte offset + page number (for backwards compatibility debugging) // New format: version + byte offset + page number (for backwards compatibility debugging)
serialization::writePod(f, PROGRESS_VERSION); serialization::writePod(f, PROGRESS_VERSION);
// Store byte offset - this is stable across font/setting changes // Store byte offset - this is stable across font/setting changes
const size_t byteOffset = (currentPage >= 0 && currentPage < static_cast<int>(pageOffsets.size())) const size_t byteOffset =
? pageOffsets[currentPage] : 0; (currentPage >= 0 && currentPage < static_cast<int>(pageOffsets.size())) ? pageOffsets[currentPage] : 0;
serialization::writePod(f, static_cast<uint32_t>(byteOffset)); serialization::writePod(f, static_cast<uint32_t>(byteOffset));
// Also store page number for debugging/logging purposes // Also store page number for debugging/logging purposes
serialization::writePod(f, static_cast<uint16_t>(currentPage)); serialization::writePod(f, static_cast<uint16_t>(currentPage));
f.close(); f.close();
Serial.printf("[%lu] [TRS] Saved progress: page %d, offset %zu\n", millis(), currentPage, byteOffset); 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)) { if (SdMan.openFileForRead("TRS", txt->getCachePath() + "/progress.bin", f)) {
// Check file size to determine format // Check file size to determine format
const size_t fileSize = f.size(); const size_t fileSize = f.size();
if (fileSize >= 7) { if (fileSize >= 7) {
// New format: version (1) + byte offset (4) + page number (2) = 7 bytes // New format: version (1) + byte offset (4) + page number (2) = 7 bytes
uint8_t version; uint8_t version;
serialization::readPod(f, version); serialization::readPod(f, version);
if (version == PROGRESS_VERSION) { if (version == PROGRESS_VERSION) {
uint32_t savedOffset; uint32_t savedOffset;
serialization::readPod(f, savedOffset); serialization::readPod(f, savedOffset);
uint16_t savedPage; uint16_t savedPage;
serialization::readPod(f, savedPage); serialization::readPod(f, savedPage);
// Use byte offset to find the correct page (works even if re-indexed) // Use byte offset to find the correct page (works even if re-indexed)
currentPage = findPageForOffset(savedOffset); currentPage = findPageForOffset(savedOffset);
Serial.printf("[%lu] [TRS] Loaded progress: offset %u -> page %d/%d (was page %d)\n", Serial.printf("[%lu] [TRS] Loaded progress: offset %u -> page %d/%d (was page %d)\n", millis(), savedOffset,
millis(), savedOffset, currentPage, totalPages, savedPage); currentPage, totalPages, savedPage);
} else { } else {
// Unknown version, fall back to legacy behavior // Unknown version, fall back to legacy behavior
Serial.printf("[%lu] [TRS] Unknown progress version %d, ignoring\n", millis(), version); 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); Serial.printf("[%lu] [TRS] Loaded legacy progress: page %d/%d\n", millis(), currentPage, totalPages);
} }
} }
// Bounds check // Bounds check
if (currentPage >= totalPages) { if (currentPage >= totalPages) {
currentPage = totalPages - 1; currentPage = totalPages - 1;
@ -716,7 +716,7 @@ void TxtReaderActivity::loadProgress() {
if (currentPage < 0) { if (currentPage < 0) {
currentPage = 0; currentPage = 0;
} }
f.close(); f.close();
} }
} }
@ -725,16 +725,16 @@ int TxtReaderActivity::findPageForOffset(size_t targetOffset) const {
if (pageOffsets.empty()) { if (pageOffsets.empty()) {
return 0; return 0;
} }
// Binary search: find the largest offset that is <= targetOffset // Binary search: find the largest offset that is <= targetOffset
// This finds the page that contains or starts at the target offset // This finds the page that contains or starts at the target offset
auto it = std::upper_bound(pageOffsets.begin(), pageOffsets.end(), targetOffset); auto it = std::upper_bound(pageOffsets.begin(), pageOffsets.end(), targetOffset);
if (it == pageOffsets.begin()) { if (it == pageOffsets.begin()) {
// Target is before the first page, return page 0 // Target is before the first page, return page 0
return 0; return 0;
} }
// upper_bound returns iterator to first element > targetOffset // upper_bound returns iterator to first element > targetOffset
// So we need the element before it (which is <= targetOffset) // So we need the element before it (which is <= targetOffset)
return static_cast<int>(std::distance(pageOffsets.begin(), it) - 1); 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); renderer.drawCenteredText(UI_12_FONT_ID, 15 + bezelTop, categoryName, true, EpdFontFamily::BOLD);
// Draw selection highlight // 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 // Draw only visible settings
int visibleIndex = 0; int visibleIndex = 0;
@ -237,7 +238,8 @@ void CategorySettingsActivity::render() const {
visibleIndex++; 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); pageHeight - 60 - bezelBottom, CROSSPOINT_VERSION);
const auto labels = mappedInput.mapLabels("« Back", "Toggle", "", ""); const auto labels = mappedInput.mapLabels("« Back", "Toggle", "", "");

View File

@ -147,7 +147,8 @@ void OtaUpdateActivity::render() {
if (state == WAITING_CONFIRMATION) { if (state == WAITING_CONFIRMATION) {
renderer.drawCenteredText(UI_10_FONT_ID, centerY - 100, "New update available!", true, EpdFontFamily::BOLD); 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 - 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", "", ""); const auto labels = mappedInput.mapLabels("Cancel", "Update", "", "");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 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) { if (state == UPDATE_IN_PROGRESS) {
renderer.drawCenteredText(UI_10_FONT_ID, centerY - 40, "Updating...", true, EpdFontFamily::BOLD); renderer.drawCenteredText(UI_10_FONT_ID, centerY - 40, "Updating...", true, EpdFontFamily::BOLD);
renderer.drawRect(20 + bezelLeft, centerY, pageWidth - 40 - bezelLeft - bezelRight, 50); 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, renderer.drawCenteredText(UI_10_FONT_ID, centerY + 70,
(std::to_string(static_cast<int>(updaterProgress * 100)) + "%").c_str()); (std::to_string(static_cast<int>(updaterProgress * 100)) + "%").c_str());
renderer.drawCenteredText( renderer.drawCenteredText(

View File

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

View File

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

View File

@ -8,18 +8,10 @@
namespace { namespace {
constexpr int MENU_ITEM_COUNT = 4; constexpr int MENU_ITEM_COUNT = 4;
const char* MENU_ITEMS[MENU_ITEM_COUNT] = {"Dictionary", "Bookmark", "Clear Cache", "Settings"}; const char* MENU_ITEMS[MENU_ITEM_COUNT] = {"Dictionary", "Bookmark", "Clear Cache", "Settings"};
const char* MENU_DESCRIPTIONS_ADD[MENU_ITEM_COUNT] = { const char* MENU_DESCRIPTIONS_ADD[MENU_ITEM_COUNT] = {"Look up a word", "Add bookmark to this page",
"Look up a word", "Free up storage space", "Open settings menu"};
"Add bookmark to this page", const char* MENU_DESCRIPTIONS_REMOVE[MENU_ITEM_COUNT] = {"Look up a word", "Remove bookmark from this page",
"Free up storage space", "Free up storage space", "Open settings menu"};
"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 } // namespace
void QuickMenuActivity::taskTrampoline(void* param) { void QuickMenuActivity::taskTrampoline(void* param) {
@ -121,7 +113,7 @@ void QuickMenuActivity::render() const {
const auto pageWidth = renderer.getScreenWidth(); const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = renderer.getScreenHeight(); const auto pageHeight = renderer.getScreenHeight();
// Get bezel offsets // Get bezel offsets
const int bezelTop = renderer.getBezelOffsetTop(); const int bezelTop = renderer.getBezelOffsetTop();
const int bezelLeft = renderer.getBezelOffsetLeft(); const int bezelLeft = renderer.getBezelOffsetLeft();
@ -160,7 +152,7 @@ void QuickMenuActivity::render() const {
if (i == 1) { if (i == 1) {
itemText = isPageBookmarked ? "Remove Bookmark" : "Add Bookmark"; itemText = isPageBookmarked ? "Remove Bookmark" : "Add Bookmark";
} }
renderer.drawText(UI_10_FONT_ID, marginLeft + 20, itemY, itemText, !isSelected); renderer.drawText(UI_10_FONT_ID, marginLeft + 20, itemY, itemText, !isSelected);
renderer.drawText(SMALL_FONT_ID, marginLeft + 20, itemY + 22, descriptions[i], !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 * Generated by convert-builtin-fonts.sh
* Custom font definitions * Custom font definitions
*/ */
#include <builtinFonts/custom/customFonts.h>
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <builtinFonts/custom/customFonts.h>
#include "fontIds.h" #include "fontIds.h"
// EpdFont definitions for custom fonts // EpdFont definitions for custom fonts
@ -41,14 +42,30 @@ EpdFont fernmicro18BoldFont(&fernmicro_18_bold);
EpdFont fernmicro18BoldItalicFont(&fernmicro_18_bolditalic); EpdFont fernmicro18BoldItalicFont(&fernmicro_18_bolditalic);
// EpdFontFamily definitions for custom fonts // EpdFontFamily definitions for custom fonts
EpdFontFamily atkinsonhyperlegiblenext12FontFamily(&atkinsonhyperlegiblenext12RegularFont, &atkinsonhyperlegiblenext12BoldFont, &atkinsonhyperlegiblenext12ItalicFont, &atkinsonhyperlegiblenext12BoldItalicFont); EpdFontFamily atkinsonhyperlegiblenext12FontFamily(&atkinsonhyperlegiblenext12RegularFont,
EpdFontFamily atkinsonhyperlegiblenext14FontFamily(&atkinsonhyperlegiblenext14RegularFont, &atkinsonhyperlegiblenext14BoldFont, &atkinsonhyperlegiblenext14ItalicFont, &atkinsonhyperlegiblenext14BoldItalicFont); &atkinsonhyperlegiblenext12BoldFont,
EpdFontFamily atkinsonhyperlegiblenext16FontFamily(&atkinsonhyperlegiblenext16RegularFont, &atkinsonhyperlegiblenext16BoldFont, &atkinsonhyperlegiblenext16ItalicFont, &atkinsonhyperlegiblenext16BoldItalicFont); &atkinsonhyperlegiblenext12ItalicFont,
EpdFontFamily atkinsonhyperlegiblenext18FontFamily(&atkinsonhyperlegiblenext18RegularFont, &atkinsonhyperlegiblenext18BoldFont, &atkinsonhyperlegiblenext18ItalicFont, &atkinsonhyperlegiblenext18BoldItalicFont); &atkinsonhyperlegiblenext12BoldItalicFont);
EpdFontFamily fernmicro12FontFamily(&fernmicro12RegularFont, &fernmicro12BoldFont, &fernmicro12ItalicFont, &fernmicro12BoldItalicFont); EpdFontFamily atkinsonhyperlegiblenext14FontFamily(&atkinsonhyperlegiblenext14RegularFont,
EpdFontFamily fernmicro14FontFamily(&fernmicro14RegularFont, &fernmicro14BoldFont, &fernmicro14ItalicFont, &fernmicro14BoldItalicFont); &atkinsonhyperlegiblenext14BoldFont,
EpdFontFamily fernmicro16FontFamily(&fernmicro16RegularFont, &fernmicro16BoldFont, &fernmicro16ItalicFont, &fernmicro16BoldItalicFont); &atkinsonhyperlegiblenext14ItalicFont,
EpdFontFamily fernmicro18FontFamily(&fernmicro18RegularFont, &fernmicro18BoldFont, &fernmicro18ItalicFont, &fernmicro18BoldItalicFont); &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) { void registerCustomFonts(GfxRenderer& renderer) {
#if CUSTOM_FONT_COUNT > 0 #if CUSTOM_FONT_COUNT > 0
@ -64,4 +81,3 @@ void registerCustomFonts(GfxRenderer& renderer) {
(void)renderer; // Suppress unused parameter warning (void)renderer; // Suppress unused parameter warning
#endif #endif
} }

View File

@ -30,6 +30,7 @@
// Custom font ID lookup array: CUSTOM_FONT_IDS[fontIndex][sizeIndex] // Custom font ID lookup array: CUSTOM_FONT_IDS[fontIndex][sizeIndex]
// Size indices: 0=12pt, 1=14pt, 2=16pt, 3=18pt // Size indices: 0=12pt, 1=14pt, 2=16pt, 3=18pt
static const int CUSTOM_FONT_IDS[][4] = { static const int CUSTOM_FONT_IDS[][4] = {
{ATKINSONHYPERLEGIBLENEXT_12_FONT_ID, ATKINSONHYPERLEGIBLENEXT_14_FONT_ID, ATKINSONHYPERLEGIBLENEXT_16_FONT_ID, ATKINSONHYPERLEGIBLENEXT_18_FONT_ID}, {ATKINSONHYPERLEGIBLENEXT_12_FONT_ID, ATKINSONHYPERLEGIBLENEXT_14_FONT_ID, ATKINSONHYPERLEGIBLENEXT_16_FONT_ID,
{FERNMICRO_12_FONT_ID, FERNMICRO_14_FONT_ID, FERNMICRO_16_FONT_ID, FERNMICRO_18_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 // Use drawImageRotated() to rotate as needed for different screen orientations
static const uint8_t LockIcon[] = { static const uint8_t LockIcon[] = {
// Row 0-1: Empty space above shackle // 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 // Row 2-3: Shackle top curve
0x00, 0x0F, 0xF0, 0x00, // ....####.... 0x00,
0x00, 0x3F, 0xFC, 0x00, // ..########.. 0x0F,
0xF0,
0x00, // ....####....
0x00,
0x3F,
0xFC,
0x00, // ..########..
// Row 4-5: Shackle upper sides // Row 4-5: Shackle upper sides
0x00, 0x78, 0x1E, 0x00, // .####..####. 0x00,
0x00, 0xE0, 0x07, 0x00, // ###......### 0x78,
0x1E,
0x00, // .####..####.
0x00,
0xE0,
0x07,
0x00, // ###......###
// Row 6-9: Extended shackle legs (longer for better visual) // Row 6-9: Extended shackle legs (longer for better visual)
0x00, 0xC0, 0x03, 0x00, // ##........## 0x00,
0x01, 0xC0, 0x03, 0x80, // ###......### 0xC0,
0x01, 0x80, 0x01, 0x80, // ##........## 0x03,
0x01, 0x80, 0x01, 0x80, // ##........## 0x00, // ##........##
0x01,
0xC0,
0x03,
0x80, // ###......###
0x01,
0x80,
0x01,
0x80, // ##........##
0x01,
0x80,
0x01,
0x80, // ##........##
// Row 10-13: Shackle legs continue into body // Row 10-13: Shackle legs continue into body
0x01, 0x80, 0x01, 0x80, // ##........## 0x01,
0x01, 0x80, 0x01, 0x80, // ##........## 0x80,
0x01, 0x80, 0x01, 0x80, // ##........## 0x01,
0x01, 0x80, 0x01, 0x80, // ##........## 0x80, // ##........##
0x01,
0x80,
0x01,
0x80, // ##........##
0x01,
0x80,
0x01,
0x80, // ##........##
0x01,
0x80,
0x01,
0x80, // ##........##
// Row 14-15: Body top // Row 14-15: Body top
0x0F, 0xFF, 0xFF, 0xF0, // ############ 0x0F,
0x1F, 0xFF, 0xFF, 0xF8, // ############## 0xFF,
0xFF,
0xF0, // ############
0x1F,
0xFF,
0xFF,
0xF8, // ##############
// Row 16-17: Body top edge // Row 16-17: Body top edge
0x3F, 0xFF, 0xFF, 0xFC, // ################ 0x3F,
0x3F, 0xFF, 0xFF, 0xFC, // ################ 0xFF,
0xFF,
0xFC, // ################
0x3F,
0xFF,
0xFF,
0xFC, // ################
// Row 18-29: Solid body (no keyhole) // Row 18-29: Solid body (no keyhole)
0x3F, 0xFF, 0xFF, 0xFC, 0x3F,
0x3F, 0xFF, 0xFF, 0xFC, 0xFF,
0x3F, 0xFF, 0xFF, 0xFC, 0xFF,
0x3F, 0xFF, 0xFF, 0xFC, 0xFC,
0x3F, 0xFF, 0xFF, 0xFC, 0x3F,
0x3F, 0xFF, 0xFF, 0xFC, 0xFF,
0x3F, 0xFF, 0xFF, 0xFC, 0xFF,
0x3F, 0xFF, 0xFF, 0xFC, 0xFC,
0x3F, 0xFF, 0xFF, 0xFC, 0x3F,
0x3F, 0xFF, 0xFF, 0xFC, 0xFF,
0x3F, 0xFF, 0xFF, 0xFC, 0xFF,
0x3F, 0xFF, 0xFF, 0xFC, 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 // Row 30-33: Body lower section
0x3F, 0xFF, 0xFF, 0xFC, 0x3F,
0x3F, 0xFF, 0xFF, 0xFC, 0xFF,
0x3F, 0xFF, 0xFF, 0xFC, 0xFF,
0x3F, 0xFF, 0xFF, 0xFC, 0xFC,
0x3F,
0xFF,
0xFF,
0xFC,
0x3F,
0xFF,
0xFF,
0xFC,
0x3F,
0xFF,
0xFF,
0xFC,
// Row 34-35: Body bottom edge // Row 34-35: Body bottom edge
0x3F, 0xFF, 0xFF, 0xFC, 0x3F,
0x1F, 0xFF, 0xFF, 0xF8, 0xFF,
0xFF,
0xFC,
0x1F,
0xFF,
0xFF,
0xF8,
// Row 36-37: Body bottom // Row 36-37: Body bottom
0x0F, 0xFF, 0xFF, 0xF0, 0x0F,
0x00, 0x00, 0x00, 0x00, 0xFF,
0xFF,
0xF0,
0x00,
0x00,
0x00,
0x00,
// Row 38-39: Empty space below // 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 <Arduino.h>
#include <BitmapHelpers.h>
#include <EInkDisplay.h> #include <EInkDisplay.h>
#include <Epub.h> #include <Epub.h>
#include <GfxRenderer.h> #include <GfxRenderer.h>
@ -10,8 +11,6 @@
#include <cstring> #include <cstring>
#include <BitmapHelpers.h>
#include "Battery.h" #include "Battery.h"
#include "BookListStore.h" #include "BookListStore.h"
#include "CrossPointSettings.h" #include "CrossPointSettings.h"
@ -123,11 +122,8 @@ unsigned long t2 = 0;
// Memory debugging helper - logs heap state for tracking leaks // Memory debugging helper - logs heap state for tracking leaks
#ifdef DEBUG_MEMORY #ifdef DEBUG_MEMORY
void logMemoryState(const char* tag, const char* context) { void logMemoryState(const char* tag, const char* context) {
Serial.printf("[%lu] [%s] [MEM] %s - Free: %d, Largest: %d, MinFree: %d\n", Serial.printf("[%lu] [%s] [MEM] %s - Free: %d, Largest: %d, MinFree: %d\n", millis(), tag, context, ESP.getFreeHeap(),
millis(), tag, context, ESP.getMaxAllocHeap(), ESP.getMinFreeHeap());
ESP.getFreeHeap(),
ESP.getMaxAllocHeap(),
ESP.getMinFreeHeap());
} }
#else #else
// No-op when not in debug mode // No-op when not in debug mode
@ -196,7 +192,7 @@ void checkForFlashCommand() {
// USB port locations: Portrait=bottom-left, PortraitInverted=top-right, // USB port locations: Portrait=bottom-left, PortraitInverted=top-right,
// LandscapeCW=top-left, LandscapeCCW=bottom-right // LandscapeCW=top-left, LandscapeCCW=bottom-right
// Position offsets: edge margin + half-width offset to center on USB port // 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 constexpr int halfWidth = LOCK_ICON_WIDTH / 2; // 16px offset for centering
int iconX, iconY; int iconX, iconY;
GfxRenderer::ImageRotation rotation; GfxRenderer::ImageRotation rotation;
@ -334,9 +330,8 @@ void onGoToClearCache();
void onGoToSettings(); void onGoToSettings();
void onGoToReader(const std::string& initialEpubPath, MyLibraryActivity::Tab fromTab) { void onGoToReader(const std::string& initialEpubPath, MyLibraryActivity::Tab fromTab) {
exitActivity(); exitActivity();
enterNewActivity( enterNewActivity(new ReaderActivity(renderer, mappedInputManager, initialEpubPath, fromTab, onGoHome,
new ReaderActivity(renderer, mappedInputManager, initialEpubPath, fromTab, onGoHome, onGoToMyLibraryWithTab, onGoToMyLibraryWithTab, onGoToClearCache, onGoToSettings));
onGoToClearCache, onGoToSettings));
} }
void onContinueReading() { onGoToReader(APP_STATE.openEpubPath, MyLibraryActivity::Tab::Recent); } void onContinueReading() { onGoToReader(APP_STATE.openEpubPath, MyLibraryActivity::Tab::Recent); }
@ -351,8 +346,7 @@ void onGoToReaderFromList(const std::string& bookPath) {
// View a specific list // View a specific list
void onGoToListView(const std::string& listName) { void onGoToListView(const std::string& listName) {
exitActivity(); exitActivity();
enterNewActivity( enterNewActivity(new ListViewActivity(renderer, mappedInputManager, listName, onGoToMyLibrary, onGoToReaderFromList));
new ListViewActivity(renderer, mappedInputManager, listName, onGoToMyLibrary, onGoToReaderFromList));
} }
// View bookmarks for a specific book // 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 // Navigate to bookmark location in the book
// For now, just open the book (TODO: pass bookmark location to reader) // For now, just open the book (TODO: pass bookmark location to reader)
exitActivity(); exitActivity();
enterNewActivity(new ReaderActivity(renderer, mappedInputManager, bookPath, enterNewActivity(new ReaderActivity(renderer, mappedInputManager, bookPath, MyLibraryActivity::Tab::Bookmarks,
MyLibraryActivity::Tab::Bookmarks, onGoHome, onGoToMyLibraryWithTab, onGoHome, onGoToMyLibraryWithTab, onGoToClearCache, onGoToSettings));
onGoToClearCache, onGoToSettings));
})); }));
} }
@ -402,12 +395,14 @@ void onGoToClearCache() {
void onGoToMyLibrary() { void onGoToMyLibrary() {
exitActivity(); 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) { void onGoToMyLibraryWithTab(const std::string& path, MyLibraryActivity::Tab tab) {
exitActivity(); 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() { void onGoToBrowser() {
@ -462,10 +457,14 @@ bool isWakeupByPowerButton() {
void setup() { void setup() {
t1 = millis(); 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); pinMode(UART0_RXD, INPUT);
if (isUsbConnected()) { if (isUsbConnected()) {
Serial.begin(115200);
// Wait up to 3 seconds for Serial to be ready to catch early logs // Wait up to 3 seconds for Serial to be ready to catch early logs
unsigned long start = millis(); unsigned long start = millis();
while (!Serial && (millis() - start) < 3000) { while (!Serial && (millis() - start) < 3000) {
@ -542,14 +541,13 @@ void loop() {
// Basic heap info // Basic heap info
Serial.printf("[%lu] [MEM] Free: %d bytes, Total: %d bytes, Min Free: %d bytes\n", millis(), ESP.getFreeHeap(), Serial.printf("[%lu] [MEM] Free: %d bytes, Total: %d bytes, Min Free: %d bytes\n", millis(), ESP.getFreeHeap(),
ESP.getHeapSize(), ESP.getMinFreeHeap()); ESP.getHeapSize(), ESP.getMinFreeHeap());
// Detailed fragmentation info using ESP-IDF heap caps API // Detailed fragmentation info using ESP-IDF heap caps API
multi_heap_info_t info; multi_heap_info_t info;
heap_caps_get_info(&info, MALLOC_CAP_8BIT); heap_caps_get_info(&info, MALLOC_CAP_8BIT);
Serial.printf("[%lu] [HEAP] Largest: %d, Allocated: %d, Blocks: %d, Free blocks: %d\n", millis(), Serial.printf("[%lu] [HEAP] Largest: %d, Allocated: %d, Blocks: %d, Free blocks: %d\n", millis(),
info.largest_free_block, info.total_allocated_bytes, info.largest_free_block, info.total_allocated_bytes, info.allocated_blocks, info.free_blocks);
info.allocated_blocks, info.free_blocks);
lastMemPrint = millis(); lastMemPrint = millis();
} }
@ -562,7 +560,8 @@ void loop() {
const unsigned long sleepTimeoutMs = SETTINGS.getSleepTimeoutMs(); const unsigned long sleepTimeoutMs = SETTINGS.getSleepTimeoutMs();
if (millis() - lastActivityTime >= sleepTimeoutMs) { 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(); enterDeepSleep();
// This should never be hit as `enterDeepSleep` calls esp_deep_sleep_start // This should never be hit as `enterDeepSleep` calls esp_deep_sleep_start
return; return;
@ -584,7 +583,7 @@ void loop() {
const unsigned long loopDuration = millis() - loopStartTime; const unsigned long loopDuration = millis() - loopStartTime;
if (loopDuration > maxLoopDuration) { if (loopDuration > maxLoopDuration) {
maxLoopDuration = loopDuration; 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, Serial.printf("[%lu] [LOOP] New max loop duration: %lu ms (activity: %lu ms)\n", millis(), maxLoopDuration,
activityDuration); activityDuration);
} }