19 Commits

Author SHA1 Message Date
cottongin
fbe7d2feb4 release: ef-1.0.5 - stability, memory, and upstream merges
All checks were successful
CI / build (push) Successful in 4m51s
Compile Release / build-release (push) Successful in 1m18s
Webserver: JSON batching, removed MD5 blocking, simplified flow control
Memory: QR code caching, WiFi scan optimization, cover buffer leak fix
EPUB: Fixed errant underlining before styled inline elements
Flash screen: Version string overflow fix, half refresh for cleaner display

Upstream merges:
- PR #522: HAL abstraction layer (HalDisplay, HalGPIO)
- PR #603: Sunlight fading fix toggle in Display settings
2026-01-30 23:20:23 -05:00
cottongin
520a0cb124 feat: implement sunlight fading fix (PR #603)
Add user-toggleable setting to turn off display between refreshes,
which helps mitigate the sunlight fading issue on e-ink displays.

Changes:
- Add turnOffScreen parameter to HalDisplay methods
- Add fadingFix member and setFadingFix() to GfxRenderer
- Add fadingFix setting to CrossPointSettings with persistence
- Add "Sunlight Fading Fix" toggle in Display settings
- Update SDK submodule with turnOffScreen support
2026-01-30 23:02:29 -05:00
cottongin
be8b02efd6 feat: merge PR #522 - add HalDisplay and HalGPIO abstraction layer
Cherry-picked upstream PR #522 (da4d3b5) with conflict resolution:
- Added new lib/hal/ files (HalDisplay, HalGPIO)
- Updated GfxRenderer to use HalDisplay, preserving base viewable margins
- Adopted PR #522's MappedInputManager lookup table implementation
- Updated main.cpp to use HAL while preserving custom Serial initialization
- Updated all EInkDisplay::RefreshMode references to HalDisplay::RefreshMode

This introduces a Hardware Abstraction Layer for display and GPIO,
enabling easier emulation and testing.
2026-01-30 22:49:52 -05:00
cottongin
448ce55bb4 Add individual book cache clearing with preserve progress option
- Add "Clear Cache" option to book action menu in MyLibrary (both Recent and Files tabs)
- Prompt user to preserve reading progress when clearing cache
- Always preserve bookmarks when clearing individual book cache
- Add preserve progress option to system-level Clear Cache in Settings
- Implement BookManager::clearBookCache() for selective cache clearing
2026-01-30 22:22:22 -05:00
cottongin
5464d9de3a fix: webserver stability, memory leaks, epub underlining, flash screen
Webserver:
- Remove MD5 hash computation from file listings (caused EAGAIN errors)
- Implement JSON batching (2KB) with pacing for file listings
- Simplify sendContentSafe() flow control

Memory:
- Cache QR codes on server start instead of regenerating per render
- Optimize WiFi scan: vector deduplication, 20 network limit, early scanDelete()
- Fix 48KB cover buffer leak when navigating Home -> File Transfer

EPUB Reader:
- Fix errant underlining by flushing partWordBuffer before style changes
- Text before styled inline elements (e.g., <a> with CSS underline) no longer
  incorrectly receives the element's styling

Flash Screen:
- Fix version string buffer overflow (30 -> 50 char limit)
- Use half refresh for cleaner display
- Adjust pre_flash.py timing for half refresh completion
2026-01-30 22:00:15 -05:00
cottongin
48267ad848 release: ef-1.0.4
All checks were successful
CI / build (push) Successful in 4m11s
Compile Release / build-release (push) Successful in 1m14s
New Features:
- End-of-book "Start Over" option to wrap to first page

EPUB Rendering:
- CSS margin-left/padding-left parsing for block indentation
- Vertical bar and italic styling for blockquotes
- Left margin indentation for list items
- Fix ordered lists showing bullets instead of numbers
- Fix nested <p> inside <li> marker placement

Bug Fixes:
- Webserver: flow control and connection checking for file listing
- Webserver: memory optimization for File Transfer mode
- Dictionary: allocation order fix for zip dictionary buffer
2026-01-29 19:59:17 -05:00
cottongin
dd630dcf72 Improve EPUB rendering and add end-of-book Start Over
EPUB rendering improvements:
- Add margin-left/padding-left CSS parsing for block indentation
- Add vertical bar and italic styling for blockquotes
- Add left margin indentation for list items (ol/ul)
- Fix ordered lists showing bullets instead of numbers
- Fix nested <p> inside <li> causing marker on separate line

End-of-book improvements:
- Add "Start Over" option to wrap to first page when pressing next
- Show "Start Over" button hint on finished book prompt
2026-01-29 19:45:58 -05:00
cottongin
ef705d3ac6 Fix zip dictionary allocation 2026-01-29 18:54:01 -05:00
cottongin
bab374a675 fixes webserver uploads and general stability 2026-01-29 17:57:56 -05:00
cottongin
c171813045 release: ef-1.0.3
All checks were successful
CI / build (push) Successful in 2m47s
Compile Release / build-release (push) Successful in 1m16s
- Fixed cppcheck CI failure: removed unused screenWidth variable
- Version bump to 0.15.ef-1.0.3
2026-01-29 13:24:21 -05:00
cottongin
d5e42b9e40 Merge staging: ef-1.0.1 and ef-1.0.2
Some checks failed
Compile Release / build-release (push) Successful in 1m22s
CI / build (push) Failing after 1m38s
ef-1.0.2 - Quick Menu Enhancements
- Screen rotation toggle (Portrait/Landscape CCW)
- Customizable menu order with pick-and-place reordering
- Navigation button hints on quick menu

ef-1.0.1 - Dictionary Stability & UX
- Fixed dictionary crashes from heap fragmentation
- Refactored TextBlock/ParsedText to std::vector (~12x fewer allocations)
- Uncompressed .dict support, chunked HTML parsing
- Orientation-aware button hints on dictionary screens
2026-01-29 13:09:57 -05:00
cottongin
168c8fdb69 staging: 1.0.2 2026-01-29 13:01:59 -05:00
cottongin
492cf976f5 feat(quickmenu): comprehensive quick menu enhancements
Quick Menu UI Improvements:
- Add navigation button hints (prev/next on front buttons, up/down on side buttons)
- Fix orientation-aware margins for button hint areas in landscape modes

Screen Rotation Toggle:
- Add "Rotate Screen" option to toggle between Portrait and Landscape CCW
- Force section reindex when orientation changes to properly reflow content
- Position is automatically restored via content offset after reindex

Customizable Menu Order:
- Add "Edit List Order" option (fixed at bottom of menu)
- Pick-and-place reordering: select item to move, navigate to destination, place
- Visual feedback: filled highlight for cursor, outlined box for item being moved
- Menu order persists in settings (quickMenuOrder array in CrossPointSettings)
- New default order: Bookmark, Dictionary, Rotate Screen, Settings, Clear Cache

Files changed:
- CrossPointSettings.h: Add quickMenuOrder[5] setting
- QuickMenuActivity.h/cpp: Edit mode, order rendering, pick-and-place logic
- EpubReaderActivity.cpp: Handle TOGGLE_ORIENTATION action with section reset
2026-01-29 12:57:37 -05:00
cottongin
25e255af50 staging area for 1.0.1 release 2026-01-29 11:58:28 -05:00
cottongin
a4adbb9dfe fix(dictionary): comprehensive dictionary fixes for stability and UX
This commit completes a series of fixes addressing dictionary crashes,
memory issues, and UI/UX improvements.

Memory & Stability (from previous checkpoints):
- Add uncompressed dictionary (.dict) support to avoid decompression
  memory issues with large dictzip chunks (58KB -> direct read)
- Implement chunked on-demand HTML parsing for large definitions,
  parsing pages as user navigates rather than all at once
- Refactor TextBlock/ParsedText from std::list to std::vector,
  reducing heap allocations by ~12x per TextBlock and eliminating
  crashes from repeated page navigation due to heap fragmentation
- Limit cached pages to MAX_CACHED_PAGES (4) with re-parse capability
  for backward navigation beyond the cache window

UI/Layout Fixes (this commit):
- Restore DictionaryMargins.h for proper orientation-aware button
  hint space (front buttons: 45px, side buttons: 50px)
- Add side button hints to definition screen with proper "<" / ">"
  labels for page navigation
- Add side button hints to word selection screen ("UP"/"DOWN" labels,
  borderless, small font, 2px edge margin)
- Add side button hints to dictionary menu ("< Prev", "Next >")
- Fix double-button press bug when loading new chunks by checking
  forward navigation availability after parsing instead of page count
- Add drawSideButtonHints() drawBorder parameter for minimal hints
- Add drawTextRotated90CCW() for LandscapeCCW text orientation
- Move page indicator up to avoid bezel cutoff
2026-01-29 11:39:49 -05:00
cottongin
6ceba56620 checkpoint: refactor TextBlock/ParsedText from std::list to std::vector
Reduces heap fragmentation by ~12x fewer allocations per TextBlock.
This fixes crashes when repeatedly navigating dictionary pages.

- Replace std::list with std::vector in TextBlock members
- Replace splice() with move+erase in ParsedText::extractLine()
- Use index-based access in hyphenateWordAtIndex()
2026-01-29 09:52:30 -05:00
cottongin
62643ae933 checkpoint: pre list-to-vector refactor, fixes dictionary crash, mostly
- Add uncompressed dictionary (.dict) file support to avoid decompression memory issues
- Implement chunked on-demand parsing for large definitions
- Add backward navigation with re-parse capability
- Limit cached pages to MAX_CACHED_PAGES (4) to prevent memory exhaustion
- Add helper script for extracting/recompressing dictzip files
2026-01-29 09:33:40 -05:00
cottongin
8b41dccfb9 adjust .gitignore 2026-01-28 19:10:25 -05:00
cottongin
3204fa0339 fixes crash 2026-01-28 19:07:21 -05:00
56 changed files with 2651 additions and 623 deletions

3
.gitignore vendored
View File

@@ -13,6 +13,9 @@ test/epubs/
CrossPoint-ef.md
Serial_print.code-search
# Gitea Release note drafts
release-notes-*.md
# Gitea Actions runner config (contains credentials)
.runner
.runner.*

195
ef-CHANGELOG.md Normal file
View File

@@ -0,0 +1,195 @@
# crosspoint-ef Changelog
All notable changes to the crosspoint-ef fork are documented here.
Base: CrossPoint Reader 0.15.0
---
## ef-1.0.5
**Stability & Memory Improvements**
### Bug Fixes - Webserver
- **File Transfer Stability**: Removed blocking MD5 hash computation from file listings that caused EAGAIN errors and connection stalls
- **JSON Batching**: Implemented 2KB batch streaming for file listings with pacing to prevent TCP buffer overflow
- **Simplified Flow Control**: Removed unnecessary yield/delay logic from content streaming
### Bug Fixes - Memory
- **QR Code Caching**: Generate QR codes once on server start instead of regenerating on each screen render
- **WiFi Scan Optimization**: Replaced memory-heavy `std::map` deduplication with in-place vector search, limited results to 20 networks, earlier `WiFi.scanDelete()` for faster memory recovery
- **Cover Buffer Leak**: Fixed 48KB memory leak when navigating from Home to File Transfer (cover buffer now explicitly freed)
### Bug Fixes - EPUB Reader
- **Errant Underlining**: Fixed words before styled inline elements (like `<a>` tags with CSS underline) incorrectly receiving the element's style by flushing the text buffer before style changes
### Bug Fixes - Flashing Screen
- **Version String Overflow**: Fixed flash notification parsing failing on longer version strings (buffer limit increased from 30 to 50 characters)
- **Display Quality**: Changed flashing screen to half refresh for cleaner appearance
- **Timing**: Adjusted pre-flash script timing for half refresh completion
### Upstream Merges
- **PR #522 - HAL Abstraction Layer**: Merged hardware abstraction layer refactor introducing `HalDisplay` and `HalGPIO` classes, decoupling application code from direct hardware access
- **PR #603 - Sunlight Fading Fix**: Added user-toggleable setting to turn off display between refreshes, mitigating the sunlight fading issue on e-ink displays
- New "Sunlight Fading Fix" toggle in Display settings (OFF/ON)
- Passes `turnOffScreen` parameter through display stack when enabled
### Files Changed
- `src/main.cpp` - flash screen fixes, cover buffer free on File Transfer entry, fading fix integration
- `scripts/pre_flash.py` - timing adjustments for full refresh
- `src/network/CrossPointWebServer.cpp` - JSON batching, removed MD5 from listings
- `src/network/CrossPointWebServer.h` - removed md5 from FileInfo, simplified sendContentSafe
- `src/activities/network/CrossPointWebServerActivity.cpp` - QR code caching
- `src/activities/network/CrossPointWebServerActivity.h` - QR code cache members
- `src/activities/network/WifiSelectionActivity.cpp` - WiFi scan memory optimization
- `lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp` - flush buffer before style changes
- `lib/hal/HalDisplay.h` - new HAL abstraction for display (PR #522), turnOffScreen parameter (PR #603)
- `lib/hal/HalDisplay.cpp` - HAL display implementation with fading fix passthrough
- `lib/hal/HalGPIO.h` - new HAL abstraction for GPIO (PR #522)
- `lib/hal/HalGPIO.cpp` - HAL GPIO implementation
- `lib/GfxRenderer/GfxRenderer.h` - updated for HAL layer, added fadingFix member
- `lib/GfxRenderer/GfxRenderer.cpp` - updated for HAL layer, passes fadingFix to display
- `src/CrossPointSettings.h` - added fadingFix setting
- `src/CrossPointSettings.cpp` - fadingFix persistence
- `src/activities/settings/SettingsActivity.cpp` - added Sunlight Fading Fix toggle
- `open-x4-sdk` - updated submodule with turnOffScreen support in EInkDisplay
---
## ef-1.0.4
**EPUB Rendering & Stability**
### New Features
- **End-of-Book "Start Over"**: Press next at end of book to wrap to first page
### EPUB Rendering Improvements
- CSS `margin-left`/`padding-left` parsing for block indentation
- Vertical bar and italic styling for blockquotes
- Left margin indentation for list items (`<ol>`/`<ul>`)
- Fixed ordered lists showing bullets instead of numbers
- Fixed nested `<p>` inside `<li>` causing marker on separate line
### Bug Fixes
- **Webserver**: Fixed file listing disconnection issues with flow control
- **Webserver**: Memory optimization for File Transfer mode (frees heap before starting)
- **Dictionary**: Fixed zip dictionary allocation order for better memory allocation success
---
## ef-1.0.3
**Maintenance Release**
### Bug Fixes
- Fixed cppcheck CI failure: removed unused `screenWidth` variable in word selection activity
---
## ef-1.0.2
**Quick Menu Enhancements**
### New Features
- **Screen Rotation Toggle**: Quick toggle between Portrait and Landscape CCW directly from the quick menu
- Automatically reindexes content for new screen dimensions
- Preserves reading position via content offset restoration
- **Customizable Menu Order**: Reorder quick menu items to your preference
- New "Edit List Order" option at bottom of menu
- Pick-and-place reordering: select item, navigate to destination, place
- Order persists across sessions
### UI Improvements
- Added navigation button hints to quick menu (prev/next on front buttons, up/down on side buttons)
- Fixed orientation-aware margins for button hint areas in landscape modes
- New default menu order: Bookmark, Dictionary, Rotate Screen, Settings, Clear Cache
---
## ef-1.0.1
**Dictionary Stability & UX Improvements**
### Bug Fixes - Stability
- Fixed dictionary crashes caused by heap fragmentation from repeated page navigation
- Refactored TextBlock/ParsedText from `std::list` to `std::vector`, reducing heap allocations by ~12x per TextBlock
- Affects EPUB reader page rendering, dictionary definition display, and word selection
- Contiguous memory improves cache locality during text layout and reduces heap fragmentation on the memory-constrained ESP32
- Added uncompressed dictionary (`.dict`) support to avoid decompression memory issues with large dictzip chunks (58KB chunks -> direct read)
- Implemented chunked on-demand HTML parsing for large definitions, parsing pages as user navigates rather than all at once
- Limited cached pages to 4 with re-parse capability for backward navigation beyond cache window
- Fixed double-button press bug when loading new dictionary chunks
### Bug Fixes - UI/Layout
- Restored proper orientation-aware button hint spacing (front: 45px, side: 50px)
- Added side button hints to definition screen with "<" / ">" labels for page navigation
- Added side button hints to word selection screen ("UP"/"DOWN" labels, borderless, small font)
- Added side button hints to dictionary menu ("< Prev", "Next >")
- Moved page indicator up to avoid bezel cutoff in landscape orientations
---
## ef-1.0.0
**First Official Release** (previously ef-0.15.99)
First milestone release of the crosspoint-ef fork, building on CrossPoint Reader 0.15.0 with 14+ major new features and enhancements.
### New Features
- **Dictionary Support**: Offline StarDict dictionary with word selection from reader, fast prefix-indexed search, rich HTML formatting, and multi-page pagination
- **Bookmark System**: Per-book bookmarks with visual folded-corner indicators, dedicated management interface, and auto-generated bookmark names
- **Quick Menu**: In-reader quick access menu for common actions (Dictionary, Bookmark, Clear Cache, Settings) via short power button press
- **Library Search**: Search across all books by title, author, or filename with dynamic character picker and weighted relevance scoring
- **CSS Support**: Parse and apply CSS styles from EPUB stylesheets (text-align, font-style, font-weight, text-decoration, margins, padding)
- **Inline Image Support**: PNG and Baseline JPEG rendering within EPUB content with 2-bit grayscale dithering and caching
- **Custom Fonts**: Atkinson Hyperlegible Next (low-vision readers) and Fern Micro (small screens)
- **Enhanced Web Server**: File management (upload, download, delete, rename, copy, move, mkdir), companion app API, WebSocket uploads, mDNS discovery at `crosspoint.local`
- **Reading Lists**: Create, manage, and pin custom book lists with web API support (CSV format)
- **Enhanced Tab Bar**: Unified tab bar with horizontal scrolling and overflow indicators (Recent, Lists, Bookmarks, Search, Files)
- **Progress Bar Status**: Additional status bar option showing visual reading progress
- **OPDS Browser Enhancements**: Navigation history, page skipping (hold Up/Down), error retry, HTTP Basic Auth support
### Display Enhancements
- **High Contrast Mode**: System-wide contrast adjustment
- **Bezel Compensation**: Configurable margin (0-10px) for physical screen edge defects
- **Sleep Screen Improvements**: Edge-aware color filling for seamless letterbox appearance
### Bug Fixes
- Fixed device hanging when booted without USB connected (Serial.available()/Serial.read() called without Serial.begin())
- Fixed grayscale state corruption causing ghosting artifacts when anti-aliasing enabled under memory pressure
- Memory optimization with graceful degradation when memory is low
### Development Tools
- `pre_flash.py`: Displays "Flashing firmware..." screen during upload
- `debugging_monitor.py`: Enhanced serial monitor with memory graphs
- `pio_helper.py`: Interactive PlatformIO workflow helper
---
## Differences from Upstream 0.16.0
This fork is based on upstream 0.15.0. The following 0.16.0 features are not included:
- KOReader sync support
- Non-English hyphenation patterns (Spanish, German, French, Russian)
- XTC/XTCH file format support
See [crosspoint-ef-features.md](docs/crosspoint-ef-features.md) for complete feature documentation.

View File

@@ -68,7 +68,9 @@ void ParsedText::layoutAndExtractLines(const GfxRenderer& renderer, const int fo
// Apply fixed transforms before any per-line layout work.
applyParagraphIndent();
const int pageWidth = viewportWidth;
// Apply horizontal margin (for blockquotes, nested content, etc.)
const int leftMargin = blockStyle.marginLeft;
const int pageWidth = viewportWidth - leftMargin;
const int spaceWidth = renderer.getSpaceWidth(fontId);
auto wordWidths = calculateWordWidths(renderer, fontId);
std::vector<size_t> lineBreakIndices;
@@ -81,7 +83,7 @@ void ParsedText::layoutAndExtractLines(const GfxRenderer& renderer, const int fo
const size_t lineCount = includeLastLine ? lineBreakIndices.size() : lineBreakIndices.size() - 1;
for (size_t i = 0; i < lineCount; ++i) {
extractLine(i, pageWidth, spaceWidth, wordWidths, lineBreakIndices, processLine);
extractLine(i, pageWidth, spaceWidth, leftMargin, wordWidths, lineBreakIndices, processLine);
}
}
@@ -281,14 +283,9 @@ bool ParsedText::hyphenateWordAtIndex(const size_t wordIndex, const int availabl
return false;
}
// Get iterators to target word and style.
auto wordIt = words.begin();
auto styleIt = wordStyles.begin();
std::advance(wordIt, wordIndex);
std::advance(styleIt, wordIndex);
const std::string& word = *wordIt;
const auto style = *styleIt;
// Direct index access for vectors (more efficient than iterator + advance)
const std::string& word = words[wordIndex];
const auto wordStyle = wordStyles[wordIndex];
// Collect candidate breakpoints (byte offsets and hyphen requirements).
auto breakInfos = Hyphenator::breakOffsets(word, allowFallbackBreaks);
@@ -308,7 +305,7 @@ bool ParsedText::hyphenateWordAtIndex(const size_t wordIndex, const int availabl
}
const bool needsHyphen = info.requiresInsertedHyphen;
const int prefixWidth = measureWordWidth(renderer, fontId, word.substr(0, offset), style, needsHyphen);
const int prefixWidth = measureWordWidth(renderer, fontId, word.substr(0, offset), wordStyle, needsHyphen);
if (prefixWidth > availableWidth || prefixWidth <= chosenWidth) {
continue; // Skip if too wide or not an improvement
}
@@ -325,25 +322,23 @@ bool ParsedText::hyphenateWordAtIndex(const size_t wordIndex, const int availabl
// Split the word at the selected breakpoint and append a hyphen if required.
std::string remainder = word.substr(chosenOffset);
wordIt->resize(chosenOffset);
words[wordIndex].resize(chosenOffset);
if (chosenNeedsHyphen) {
wordIt->push_back('-');
words[wordIndex].push_back('-');
}
// Insert the remainder word (with matching style) directly after the prefix.
auto insertWordIt = std::next(wordIt);
auto insertStyleIt = std::next(styleIt);
words.insert(insertWordIt, remainder);
wordStyles.insert(insertStyleIt, style);
words.insert(words.begin() + wordIndex + 1, remainder);
wordStyles.insert(wordStyles.begin() + wordIndex + 1, wordStyle);
// Update cached widths to reflect the new prefix/remainder pairing.
wordWidths[wordIndex] = static_cast<uint16_t>(chosenWidth);
const uint16_t remainderWidth = measureWordWidth(renderer, fontId, remainder, style);
const uint16_t remainderWidth = measureWordWidth(renderer, fontId, remainder, wordStyle);
wordWidths.insert(wordWidths.begin() + wordIndex + 1, remainderWidth);
return true;
}
void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const int spaceWidth,
void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const int spaceWidth, const int leftMargin,
const std::vector<uint16_t>& wordWidths, const std::vector<size_t>& lineBreakIndices,
const std::function<void(std::shared_ptr<TextBlock>)>& processLine) {
const size_t lineBreak = lineBreakIndices[breakIndex];
@@ -366,37 +361,35 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const
spacing = spareSpace / (lineWordCount - 1);
}
// Calculate initial x position
uint16_t xpos = 0;
// Calculate initial x position (offset by left margin for blockquotes, etc.)
uint16_t xpos = static_cast<uint16_t>(leftMargin);
if (style == TextBlock::RIGHT_ALIGN) {
xpos = spareSpace - (lineWordCount - 1) * spaceWidth;
xpos += spareSpace - (lineWordCount - 1) * spaceWidth;
} else if (style == TextBlock::CENTER_ALIGN) {
xpos = (spareSpace - (lineWordCount - 1) * spaceWidth) / 2;
xpos += (spareSpace - (lineWordCount - 1) * spaceWidth) / 2;
}
// Pre-calculate X positions for words
std::list<uint16_t> lineXPos;
std::vector<uint16_t> lineXPos;
lineXPos.reserve(lineWordCount);
for (size_t i = lastBreakAt; i < lineBreak; i++) {
const uint16_t currentWordWidth = wordWidths[i];
lineXPos.push_back(xpos);
xpos += currentWordWidth + spacing;
}
// Iterators always start at the beginning as we are moving content with splice below
auto wordEndIt = words.begin();
auto wordStyleEndIt = wordStyles.begin();
auto wordUnderlineEndIt = wordUnderlines.begin();
std::advance(wordEndIt, lineWordCount);
std::advance(wordStyleEndIt, lineWordCount);
std::advance(wordUnderlineEndIt, lineWordCount);
// *** CRITICAL STEP: CONSUME DATA USING MOVE + ERASE ***
// Move first lineWordCount elements from words into lineWords
std::vector<std::string> lineWords(std::make_move_iterator(words.begin()),
std::make_move_iterator(words.begin() + lineWordCount));
words.erase(words.begin(), words.begin() + lineWordCount);
// *** CRITICAL STEP: CONSUME DATA USING SPLICE ***
std::list<std::string> lineWords;
lineWords.splice(lineWords.begin(), words, words.begin(), wordEndIt);
std::list<EpdFontFamily::Style> lineWordStyles;
lineWordStyles.splice(lineWordStyles.begin(), wordStyles, wordStyles.begin(), wordStyleEndIt);
std::list<bool> lineWordUnderlines;
lineWordUnderlines.splice(lineWordUnderlines.begin(), wordUnderlines, wordUnderlines.begin(), wordUnderlineEndIt);
std::vector<EpdFontFamily::Style> lineWordStyles(std::make_move_iterator(wordStyles.begin()),
std::make_move_iterator(wordStyles.begin() + lineWordCount));
wordStyles.erase(wordStyles.begin(), wordStyles.begin() + lineWordCount);
std::vector<bool> lineWordUnderlines(wordUnderlines.begin(), wordUnderlines.begin() + lineWordCount);
wordUnderlines.erase(wordUnderlines.begin(), wordUnderlines.begin() + lineWordCount);
for (auto& word : lineWords) {
if (containsSoftHyphen(word)) {

View File

@@ -3,7 +3,6 @@
#include <EpdFontFamily.h>
#include <functional>
#include <list>
#include <memory>
#include <string>
#include <vector>
@@ -14,9 +13,9 @@
class GfxRenderer;
class ParsedText {
std::list<std::string> words;
std::list<EpdFontFamily::Style> wordStyles;
std::list<bool> wordUnderlines; // Track underline per word
std::vector<std::string> words;
std::vector<EpdFontFamily::Style> wordStyles;
std::vector<bool> wordUnderlines; // Track underline per word
TextBlock::Style style;
BlockStyle blockStyle;
bool extraParagraphSpacing;
@@ -29,8 +28,8 @@ class ParsedText {
int spaceWidth, std::vector<uint16_t>& wordWidths);
bool hyphenateWordAtIndex(size_t wordIndex, int availableWidth, const GfxRenderer& renderer, int fontId,
std::vector<uint16_t>& wordWidths, bool allowFallbackBreaks);
void extractLine(size_t breakIndex, int pageWidth, int spaceWidth, const std::vector<uint16_t>& wordWidths,
const std::vector<size_t>& lineBreakIndices,
void extractLine(size_t breakIndex, int pageWidth, int spaceWidth, int leftMargin,
const std::vector<uint16_t>& wordWidths, const std::vector<size_t>& lineBreakIndices,
const std::function<void(std::shared_ptr<TextBlock>)>& processLine);
std::vector<uint16_t> calculateWordWidths(const GfxRenderer& renderer, int fontId);

View File

@@ -8,8 +8,8 @@
#include "parsers/ChapterHtmlSlimParser.h"
namespace {
// Version 12: Added content offsets to LUT for position restoration after re-indexing
constexpr uint8_t SECTION_FILE_VERSION = 12;
// Version 13: Added marginLeft and hasLeftBorder to BlockStyle serialization
constexpr uint8_t SECTION_FILE_VERSION = 13;
constexpr uint32_t HEADER_SIZE = sizeof(uint8_t) + sizeof(int) + sizeof(float) + sizeof(bool) + sizeof(uint8_t) +
sizeof(uint16_t) + sizeof(uint16_t) + sizeof(uint16_t) + sizeof(bool) +
sizeof(uint32_t);

View File

@@ -9,9 +9,11 @@
* Padding is treated similarly to margins for rendering purposes.
*/
struct BlockStyle {
int8_t marginTop = 0; // 0-2 lines
int8_t marginBottom = 0; // 0-2 lines
int8_t paddingTop = 0; // 0-2 lines (treated same as margin)
int8_t paddingBottom = 0; // 0-2 lines (treated same as margin)
int16_t textIndent = 0; // pixels
int8_t marginTop = 0; // 0-2 lines
int8_t marginBottom = 0; // 0-2 lines
int8_t paddingTop = 0; // 0-2 lines (treated same as margin)
int8_t paddingBottom = 0; // 0-2 lines (treated same as margin)
int16_t textIndent = 0; // pixels (first line indent)
int16_t marginLeft = 0; // pixels (horizontal indent for entire block)
bool hasLeftBorder = false; // draw vertical bar in left margin (for blockquotes)
};

View File

@@ -11,6 +11,17 @@ void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int
return;
}
// Draw left border (vertical bar) for blockquotes
if (blockStyle.hasLeftBorder && blockStyle.marginLeft > 0) {
const int lineHeight = renderer.getLineHeight(fontId);
const int barX = x + 4; // Small offset from left edge
const int barTop = y;
const int barBottom = y + lineHeight;
// Draw a 2-pixel wide vertical bar
renderer.drawLine(barX, barTop, barX, barBottom, true);
renderer.drawLine(barX + 1, barTop, barX + 1, barBottom, true);
}
auto wordIt = words.begin();
auto wordStylesIt = wordStyles.begin();
auto wordXposIt = wordXpos.begin();
@@ -92,29 +103,31 @@ bool TextBlock::serialize(FsFile& file) const {
serialization::writePod(file, blockStyle.paddingTop);
serialization::writePod(file, blockStyle.paddingBottom);
serialization::writePod(file, blockStyle.textIndent);
serialization::writePod(file, blockStyle.marginLeft);
serialization::writePod(file, blockStyle.hasLeftBorder);
return true;
}
std::unique_ptr<TextBlock> TextBlock::deserialize(FsFile& file) {
uint16_t wc;
std::list<std::string> words;
std::list<uint16_t> wordXpos;
std::list<EpdFontFamily::Style> wordStyles;
std::list<bool> wordUnderlines;
std::vector<std::string> words;
std::vector<uint16_t> wordXpos;
std::vector<EpdFontFamily::Style> wordStyles;
std::vector<bool> wordUnderlines;
Style style;
BlockStyle blockStyle;
// Word count
serialization::readPod(file, wc);
// Sanity check: prevent allocation of unreasonably large lists (max 10000 words per block)
// Sanity check: prevent allocation of unreasonably large vectors (max 10000 words per block)
if (wc > 10000) {
Serial.printf("[%lu] [TXB] Deserialization failed: word count %u exceeds maximum\n", millis(), wc);
return nullptr;
}
// Word data
// Word data - reserve capacity then resize
words.resize(wc);
wordXpos.resize(wc);
wordStyles.resize(wc);
@@ -124,14 +137,14 @@ std::unique_ptr<TextBlock> TextBlock::deserialize(FsFile& file) {
// Underline flags (packed as bytes, 8 words per byte)
wordUnderlines.resize(wc, false);
auto underlineIt = wordUnderlines.begin();
size_t underlineIdx = 0;
const int bytesNeeded = (wc + 7) / 8;
for (int byteIdx = 0; byteIdx < bytesNeeded; byteIdx++) {
uint8_t underlineByte;
serialization::readPod(file, underlineByte);
for (int bit = 0; bit < 8 && underlineIt != wordUnderlines.end(); bit++) {
*underlineIt = (underlineByte & 1 << bit) != 0;
++underlineIt;
for (int bit = 0; bit < 8 && underlineIdx < wc; bit++) {
wordUnderlines[underlineIdx] = (underlineByte & (1 << bit)) != 0;
++underlineIdx;
}
}
@@ -144,6 +157,8 @@ std::unique_ptr<TextBlock> TextBlock::deserialize(FsFile& file) {
serialization::readPod(file, blockStyle.paddingTop);
serialization::readPod(file, blockStyle.paddingBottom);
serialization::readPod(file, blockStyle.textIndent);
serialization::readPod(file, blockStyle.marginLeft);
serialization::readPod(file, blockStyle.hasLeftBorder);
return std::unique_ptr<TextBlock>(new TextBlock(std::move(words), std::move(wordXpos), std::move(wordStyles), style,
blockStyle, std::move(wordUnderlines)));

View File

@@ -2,9 +2,9 @@
#include <EpdFontFamily.h>
#include <SdFat.h>
#include <list>
#include <memory>
#include <string>
#include <vector>
#include "Block.h"
#include "BlockStyle.h"
@@ -20,17 +20,18 @@ class TextBlock final : public Block {
};
private:
std::list<std::string> words;
std::list<uint16_t> wordXpos;
std::list<EpdFontFamily::Style> wordStyles;
std::list<bool> wordUnderlines; // Track underline per word
std::vector<std::string> words;
std::vector<uint16_t> wordXpos;
std::vector<EpdFontFamily::Style> wordStyles;
std::vector<bool> wordUnderlines; // Track underline per word
Style style;
BlockStyle blockStyle;
public:
explicit TextBlock(std::list<std::string> words, std::list<uint16_t> word_xpos,
std::list<EpdFontFamily::Style> word_styles, const Style style,
const BlockStyle& blockStyle = BlockStyle(), std::list<bool> word_underlines = std::list<bool>())
explicit TextBlock(std::vector<std::string> words, std::vector<uint16_t> word_xpos,
std::vector<EpdFontFamily::Style> word_styles, const Style style,
const BlockStyle& blockStyle = BlockStyle(),
std::vector<bool> word_underlines = std::vector<bool>())
: words(std::move(words)),
wordXpos(std::move(word_xpos)),
wordStyles(std::move(word_styles)),
@@ -50,9 +51,9 @@ class TextBlock final : public Block {
bool isEmpty() override { return words.empty(); }
// Getters for word selection support
const std::list<std::string>& getWords() const { return words; }
const std::list<uint16_t>& getWordXPositions() const { return wordXpos; }
const std::list<EpdFontFamily::Style>& getWordStyles() const { return wordStyles; }
const std::vector<std::string>& getWords() const { return words; }
const std::vector<uint16_t>& getWordXPositions() const { return wordXpos; }
const std::vector<EpdFontFamily::Style>& getWordStyles() const { return wordStyles; }
size_t getWordCount() const { return words.size(); }
void layout(GfxRenderer& renderer) override {};
// given a renderer works out where to break the words into lines

View File

@@ -393,6 +393,32 @@ CssStyle CssParser::parseDeclarations(const std::string& declBlock) {
style.paddingBottom = spacing;
style.defined.paddingBottom = 1;
}
} else if (propName == "margin-left" || propName == "padding-left") {
// Horizontal indentation for blockquotes and nested content
const float pixels = interpretLength(propValue);
if (pixels > 0) {
style.marginLeft += pixels; // Accumulate margin-left and padding-left
style.defined.marginLeft = 1;
}
} else if (propName == "margin") {
// Shorthand: margin: top right bottom left OR margin: vertical horizontal
const auto values = splitWhitespace(propValue);
if (values.size() >= 2) {
// At least 2 values: first is vertical (top/bottom), second is horizontal (left/right)
const float horizontal = interpretLength(values[1]);
if (horizontal > 0) {
style.marginLeft = horizontal;
style.defined.marginLeft = 1;
}
}
if (values.size() == 4) {
// 4 values: top right bottom left - use the 4th value for left
const float left = interpretLength(values[3]);
if (left > 0) {
style.marginLeft = left;
style.defined.marginLeft = 1;
}
}
}
}

View File

@@ -25,7 +25,8 @@ struct CssPropertyFlags {
uint16_t marginBottom : 1;
uint16_t paddingTop : 1;
uint16_t paddingBottom : 1;
uint16_t reserved : 7;
uint16_t marginLeft : 1;
uint16_t reserved : 6;
CssPropertyFlags()
: alignment(0),
@@ -37,16 +38,17 @@ struct CssPropertyFlags {
marginBottom(0),
paddingTop(0),
paddingBottom(0),
marginLeft(0),
reserved(0) {}
[[nodiscard]] bool anySet() const {
return alignment || fontStyle || fontWeight || decoration || indent || marginTop || marginBottom || paddingTop ||
paddingBottom;
paddingBottom || marginLeft;
}
void clearAll() {
alignment = fontStyle = fontWeight = decoration = indent = 0;
marginTop = marginBottom = paddingTop = paddingBottom = 0;
marginTop = marginBottom = paddingTop = paddingBottom = marginLeft = 0;
}
};
@@ -63,6 +65,7 @@ struct CssStyle {
int8_t marginBottom = 0; // Vertical spacing after block (in lines, 0-2)
int8_t paddingTop = 0; // Padding before (in lines, 0-2)
int8_t paddingBottom = 0; // Padding after (in lines, 0-2)
float marginLeft = 0.0f; // Horizontal indent in pixels (for blockquotes, etc.)
CssPropertyFlags defined; // Tracks which properties were explicitly set
@@ -105,6 +108,10 @@ struct CssStyle {
paddingBottom = base.paddingBottom;
defined.paddingBottom = 1;
}
if (base.defined.marginLeft) {
marginLeft = base.marginLeft;
defined.marginLeft = 1;
}
}
// Compatibility accessors for existing code that uses hasX pattern
@@ -117,6 +124,7 @@ struct CssStyle {
[[nodiscard]] bool hasMarginBottom() const { return defined.marginBottom; }
[[nodiscard]] bool hasPaddingTop() const { return defined.paddingTop; }
[[nodiscard]] bool hasPaddingBottom() const { return defined.paddingBottom; }
[[nodiscard]] bool hasMarginLeft() const { return defined.marginLeft; }
// Merge another style (alias for applyOver for compatibility)
void merge(const CssStyle& other) { applyOver(other); }
@@ -128,6 +136,7 @@ struct CssStyle {
decoration = CssTextDecoration::None;
indentPixels = 0.0f;
marginTop = marginBottom = paddingTop = paddingBottom = 0;
marginLeft = 0.0f;
defined.clearAll();
}
};

View File

@@ -19,6 +19,9 @@ constexpr size_t MIN_SIZE_FOR_PROGRESS = 50 * 1024; // 50KB
const char* BLOCK_TAGS[] = {"p", "li", "div", "br", "blockquote"};
constexpr int NUM_BLOCK_TAGS = sizeof(BLOCK_TAGS) / sizeof(BLOCK_TAGS[0]);
const char* LIST_TAGS[] = {"ol", "ul"};
constexpr int NUM_LIST_TAGS = sizeof(LIST_TAGS) / sizeof(LIST_TAGS[0]);
const char* BOLD_TAGS[] = {"b", "strong"};
constexpr int NUM_BOLD_TAGS = sizeof(BOLD_TAGS) / sizeof(BOLD_TAGS[0]);
@@ -55,6 +58,7 @@ BlockStyle createBlockStyleFromCss(const CssStyle& cssStyle) {
blockStyle.paddingTop = cssStyle.paddingTop;
blockStyle.paddingBottom = cssStyle.paddingBottom;
blockStyle.textIndent = static_cast<int16_t>(cssStyle.indentPixels);
blockStyle.marginLeft = static_cast<int16_t>(cssStyle.marginLeft);
return blockStyle;
}
@@ -320,6 +324,18 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
// Determine if this is a block element
bool isBlockElement = matches(name, HEADER_TAGS, NUM_HEADER_TAGS) || matches(name, BLOCK_TAGS, NUM_BLOCK_TAGS);
bool isListTag = matches(name, LIST_TAGS, NUM_LIST_TAGS);
// Handle list container tags (ol, ul)
if (isListTag) {
ListContext ctx;
ctx.isOrdered = strcmp(name, "ol") == 0;
ctx.counter = 0;
ctx.depth = self->depth;
self->listStack.push_back(ctx);
self->depth += 1;
return; // Lists themselves don't create text blocks
}
// Compute CSS style for this element
CssStyle cssStyle;
@@ -365,6 +381,20 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
// This fixes issue where <br/> incorrectly wrapped the preceding word to a new line
self->flushPartWordBuffer();
self->startNewTextBlock(self->currentTextBlock->getStyle());
} else if (strcmp(name, "li") == 0) {
// For list items, DON'T create a text block yet - wait for the first content element
// This prevents the marker from being on its own line when <li><p>content</p></li>
self->insideListItem = true;
self->listItemDepth = self->depth;
self->listItemHasContent = false;
// Increment counter now (so nested lists work correctly)
if (!self->listStack.empty()) {
self->listStack.back().counter++;
}
// Don't create text block or add marker yet - will be done when first content arrives
self->depth += 1;
return;
} else {
// Determine alignment from CSS or default
auto alignment = static_cast<TextBlock::Style>(self->paragraphAlignment);
@@ -387,15 +417,77 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
}
}
// Apply default styling for blockquote if no CSS margin is specified
const bool isBlockquote = strcmp(name, "blockquote") == 0;
if (isBlockquote) {
if (!cssStyle.hasMarginLeft()) {
// Default left indent for blockquotes (~1.5em at 16px base = 24px)
cssStyle.marginLeft = 24.0f;
cssStyle.defined.marginLeft = 1;
}
// Also make blockquotes italic by default if not specified
if (!cssStyle.hasFontStyle()) {
cssStyle.fontStyle = CssFontStyle::Italic;
cssStyle.defined.fontStyle = 1;
}
// Track blockquote context for child elements
self->insideBlockquote = true;
self->blockquoteDepth = self->depth;
self->blockquoteMarginLeft = cssStyle.marginLeft;
}
// Apply blockquote styling to child block elements
if (self->insideBlockquote && !isBlockquote) {
// Inherit margin and border from parent blockquote
if (!cssStyle.hasMarginLeft()) {
cssStyle.marginLeft = self->blockquoteMarginLeft;
cssStyle.defined.marginLeft = 1;
}
}
// Apply left margin to list items (indent the whole block)
if (self->insideListItem && !cssStyle.hasMarginLeft()) {
// Default left indent for list items (~1.5em at 16px base = 24px)
cssStyle.marginLeft = 24.0f;
cssStyle.defined.marginLeft = 1;
}
self->currentBlockStyle = cssStyle;
self->startNewTextBlock(alignment, createBlockStyleFromCss(cssStyle));
BlockStyle blockStyleForElement = createBlockStyleFromCss(cssStyle);
if (isBlockquote || self->insideBlockquote) {
blockStyleForElement.hasLeftBorder = true; // Draw vertical bar for blockquotes
}
self->startNewTextBlock(alignment, blockStyleForElement);
self->updateEffectiveInlineStyle();
if (strcmp(name, "li") == 0) {
self->currentTextBlock->addWord("\xe2\x80\xa2", EpdFontFamily::REGULAR);
// If this is a blockquote, apply italic styling
if (isBlockquote && cssStyle.hasFontStyle() && cssStyle.fontStyle == CssFontStyle::Italic) {
self->italicUntilDepth = std::min(self->italicUntilDepth, self->depth);
}
// If this is the first block element inside a list item, add the marker
if (self->insideListItem && !self->listItemHasContent) {
if (!self->listStack.empty()) {
const ListContext& ctx = self->listStack.back();
if (ctx.isOrdered) {
// Ordered list: use number (counter was already incremented)
std::string marker = std::to_string(ctx.counter) + ". ";
self->currentTextBlock->addWord(marker, EpdFontFamily::REGULAR);
} else {
// Unordered list: use bullet
self->currentTextBlock->addWord("\xe2\x80\xa2", EpdFontFamily::REGULAR);
}
} else {
// No list context (orphan li), use bullet as fallback
self->currentTextBlock->addWord("\xe2\x80\xa2", EpdFontFamily::REGULAR);
}
self->listItemHasContent = true;
}
}
} else if (matches(name, UNDERLINE_TAGS, NUM_UNDERLINE_TAGS)) {
// Flush buffer with CURRENT style before changing effective style
self->flushPartWordBuffer();
self->underlineUntilDepth = std::min(self->underlineUntilDepth, self->depth);
// Push inline style entry for underline tag
StyleStackEntry entry;
@@ -413,6 +505,9 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
self->inlineStyleStack.push_back(entry);
self->updateEffectiveInlineStyle();
} else if (matches(name, BOLD_TAGS, NUM_BOLD_TAGS)) {
// Flush buffer with CURRENT style before changing effective style
self->flushPartWordBuffer();
self->boldUntilDepth = std::min(self->boldUntilDepth, self->depth);
// Push inline style entry for bold tag
StyleStackEntry entry;
@@ -430,6 +525,9 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
self->inlineStyleStack.push_back(entry);
self->updateEffectiveInlineStyle();
} else if (matches(name, ITALIC_TAGS, NUM_ITALIC_TAGS)) {
// Flush buffer with CURRENT style before changing effective style
self->flushPartWordBuffer();
self->italicUntilDepth = std::min(self->italicUntilDepth, self->depth);
// Push inline style entry for italic tag
StyleStackEntry entry;
@@ -449,6 +547,10 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
} else if (strcmp(name, "span") == 0 || !isBlockElement) {
// Handle span and other inline elements for CSS styling
if (cssStyle.hasFontWeight() || cssStyle.hasFontStyle() || cssStyle.hasTextDecoration()) {
// Flush buffer with CURRENT style before changing effective style
// This prevents text accumulated before this element from getting the new style
self->flushPartWordBuffer();
StyleStackEntry entry;
entry.depth = self->depth; // Track depth for matching pop
if (cssStyle.hasFontWeight()) {
@@ -484,6 +586,33 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char
self->lastCharDataOffset = XML_GetCurrentByteIndex(self->xmlParser);
}
// If we're inside an <li> but no text block was created yet (direct text without inner <p>),
// create a text block and add the list marker now
if (self->insideListItem && !self->listItemHasContent) {
// Apply left margin for list items
CssStyle cssStyle;
cssStyle.marginLeft = 24.0f; // Default indent (~1.5em at 16px base)
cssStyle.defined.marginLeft = 1;
BlockStyle blockStyle = createBlockStyleFromCss(cssStyle);
self->startNewTextBlock(static_cast<TextBlock::Style>(self->paragraphAlignment), blockStyle);
// Add the list marker
if (!self->listStack.empty()) {
const ListContext& ctx = self->listStack.back();
if (ctx.isOrdered) {
std::string marker = std::to_string(ctx.counter) + ". ";
self->currentTextBlock->addWord(marker, EpdFontFamily::REGULAR);
} else {
self->currentTextBlock->addWord("\xe2\x80\xa2", EpdFontFamily::REGULAR);
}
} else {
// No list context (orphan li), use bullet as fallback
self->currentTextBlock->addWord("\xe2\x80\xa2", EpdFontFamily::REGULAR);
}
self->listItemHasContent = true;
}
// Determine font style from depth-based tracking and CSS effective style
const bool isBold = self->boldUntilDepth < self->depth || self->effectiveBold;
const bool isItalic = self->italicUntilDepth < self->depth || self->effectiveItalic;
@@ -566,7 +695,8 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n
const bool shouldFlush = styleWillChange || matches(name, BLOCK_TAGS, NUM_BLOCK_TAGS) ||
matches(name, HEADER_TAGS, NUM_HEADER_TAGS) || matches(name, BOLD_TAGS, NUM_BOLD_TAGS) ||
matches(name, ITALIC_TAGS, NUM_ITALIC_TAGS) ||
matches(name, UNDERLINE_TAGS, NUM_UNDERLINE_TAGS) || self->depth == 1;
matches(name, UNDERLINE_TAGS, NUM_UNDERLINE_TAGS) ||
matches(name, LIST_TAGS, NUM_LIST_TAGS) || self->depth == 1;
if (shouldFlush) {
// Use combined depth-based and CSS-based style
@@ -596,6 +726,27 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n
self->skipUntilDepth = INT_MAX;
}
// Leaving list container (ol, ul)
if (matches(name, LIST_TAGS, NUM_LIST_TAGS)) {
if (!self->listStack.empty() && self->listStack.back().depth == self->depth) {
self->listStack.pop_back();
}
}
// Leaving list item (li)
if (strcmp(name, "li") == 0 && self->listItemDepth == self->depth) {
self->insideListItem = false;
self->listItemDepth = INT_MAX;
self->listItemHasContent = false;
}
// Leaving blockquote
if (strcmp(name, "blockquote") == 0 && self->blockquoteDepth == self->depth) {
self->insideBlockquote = false;
self->blockquoteDepth = INT_MAX;
self->blockquoteMarginLeft = 0.0f;
}
// Leaving bold tag
if (self->boldUntilDepth == self->depth) {
self->boldUntilDepth = INT_MAX;

View File

@@ -59,6 +59,22 @@ class ChapterHtmlSlimParser {
bool effectiveItalic = false;
bool effectiveUnderline = false;
// List context tracking for ordered/unordered lists
struct ListContext {
bool isOrdered = false; // true for <ol>, false for <ul>
int counter = 0; // Current item number (for ordered lists)
int depth = 0; // Depth at which list was opened
};
std::vector<ListContext> listStack;
bool insideListItem = false; // True when we're inside an <li> element
int listItemDepth = INT_MAX; // Depth at which <li> was opened
bool listItemHasContent = false; // True if we've added content to the current list item
// Blockquote context tracking (for left border on child elements)
bool insideBlockquote = false;
int blockquoteDepth = INT_MAX;
float blockquoteMarginLeft = 0.0f; // Inherit margin from blockquote to child elements
// Byte offset tracking for position restoration after re-indexing
XML_Parser xmlParser = nullptr; // Store parser for getting current byte index
size_t currentPageStartOffset = 0; // Byte offset when current page was started

View File

@@ -10,19 +10,19 @@ void GfxRenderer::rotateCoordinates(const int x, const int y, int* rotatedX, int
// Logical portrait (480x800) → panel (800x480)
// Rotation: 90 degrees clockwise
*rotatedX = y;
*rotatedY = EInkDisplay::DISPLAY_HEIGHT - 1 - x;
*rotatedY = HalDisplay::DISPLAY_HEIGHT - 1 - x;
break;
}
case LandscapeClockwise: {
// Logical landscape (800x480) rotated 180 degrees (swap top/bottom and left/right)
*rotatedX = EInkDisplay::DISPLAY_WIDTH - 1 - x;
*rotatedY = EInkDisplay::DISPLAY_HEIGHT - 1 - y;
*rotatedX = HalDisplay::DISPLAY_WIDTH - 1 - x;
*rotatedY = HalDisplay::DISPLAY_HEIGHT - 1 - y;
break;
}
case PortraitInverted: {
// Logical portrait (480x800) → panel (800x480)
// Rotation: 90 degrees counter-clockwise
*rotatedX = EInkDisplay::DISPLAY_WIDTH - 1 - y;
*rotatedX = HalDisplay::DISPLAY_WIDTH - 1 - y;
*rotatedY = x;
break;
}
@@ -36,7 +36,7 @@ void GfxRenderer::rotateCoordinates(const int x, const int y, int* rotatedX, int
}
void GfxRenderer::drawPixel(const int x, const int y, const bool state) const {
uint8_t* frameBuffer = einkDisplay.getFrameBuffer();
uint8_t* frameBuffer = display.getFrameBuffer();
// Early return if no framebuffer is set
if (!frameBuffer) {
@@ -49,14 +49,13 @@ void GfxRenderer::drawPixel(const int x, const int y, const bool state) const {
rotateCoordinates(x, y, &rotatedX, &rotatedY);
// Bounds checking against physical panel dimensions
if (rotatedX < 0 || rotatedX >= EInkDisplay::DISPLAY_WIDTH || rotatedY < 0 ||
rotatedY >= EInkDisplay::DISPLAY_HEIGHT) {
if (rotatedX < 0 || rotatedX >= HalDisplay::DISPLAY_WIDTH || rotatedY < 0 || rotatedY >= HalDisplay::DISPLAY_HEIGHT) {
Serial.printf("[%lu] [GFX] !! Outside range (%d, %d) -> (%d, %d)\n", millis(), x, y, rotatedX, rotatedY);
return;
}
// Calculate byte position and bit position
const uint16_t byteIndex = rotatedY * EInkDisplay::DISPLAY_WIDTH_BYTES + (rotatedX / 8);
const uint16_t byteIndex = rotatedY * HalDisplay::DISPLAY_WIDTH_BYTES + (rotatedX / 8);
const uint8_t bitPosition = 7 - (rotatedX % 8); // MSB first
if (state) {
@@ -202,7 +201,7 @@ void GfxRenderer::drawImage(const uint8_t bitmap[], const int x, const int y, co
break;
}
// TODO: Rotate bits
einkDisplay.drawImage(bitmap, rotatedX, rotatedY, width, height);
display.drawImage(bitmap, rotatedX, rotatedY, width, height);
}
void GfxRenderer::drawImageRotated(const uint8_t bitmap[], const int x, const int y, const int width, const int height,
@@ -519,21 +518,21 @@ void GfxRenderer::fillPolygon(const int* xPoints, const int* yPoints, int numPoi
free(nodeX);
}
void GfxRenderer::clearScreen(const uint8_t color) const { einkDisplay.clearScreen(color); }
void GfxRenderer::clearScreen(const uint8_t color) const { display.clearScreen(color); }
void GfxRenderer::invertScreen() const {
uint8_t* buffer = einkDisplay.getFrameBuffer();
uint8_t* buffer = display.getFrameBuffer();
if (!buffer) {
Serial.printf("[%lu] [GFX] !! No framebuffer in invertScreen\n", millis());
return;
}
for (int i = 0; i < EInkDisplay::BUFFER_SIZE; i++) {
for (int i = 0; i < HalDisplay::BUFFER_SIZE; i++) {
buffer[i] = ~buffer[i];
}
}
void GfxRenderer::displayBuffer(const EInkDisplay::RefreshMode refreshMode) const {
einkDisplay.displayBuffer(refreshMode);
void GfxRenderer::displayBuffer(const HalDisplay::RefreshMode refreshMode) const {
display.displayBuffer(refreshMode, fadingFix);
}
std::string GfxRenderer::truncatedText(const int fontId, const char* text, const int maxWidth,
@@ -553,13 +552,13 @@ int GfxRenderer::getScreenWidth() const {
case Portrait:
case PortraitInverted:
// 480px wide in portrait logical coordinates
return EInkDisplay::DISPLAY_HEIGHT;
return HalDisplay::DISPLAY_HEIGHT;
case LandscapeClockwise:
case LandscapeCounterClockwise:
// 800px wide in landscape logical coordinates
return EInkDisplay::DISPLAY_WIDTH;
return HalDisplay::DISPLAY_WIDTH;
}
return EInkDisplay::DISPLAY_HEIGHT;
return HalDisplay::DISPLAY_HEIGHT;
}
int GfxRenderer::getScreenHeight() const {
@@ -567,13 +566,13 @@ int GfxRenderer::getScreenHeight() const {
case Portrait:
case PortraitInverted:
// 800px tall in portrait logical coordinates
return EInkDisplay::DISPLAY_WIDTH;
return HalDisplay::DISPLAY_WIDTH;
case LandscapeClockwise:
case LandscapeCounterClockwise:
// 480px tall in landscape logical coordinates
return EInkDisplay::DISPLAY_HEIGHT;
return HalDisplay::DISPLAY_HEIGHT;
}
return EInkDisplay::DISPLAY_WIDTH;
return HalDisplay::DISPLAY_WIDTH;
}
int GfxRenderer::getSpaceWidth(const int fontId) const {
@@ -650,7 +649,8 @@ void GfxRenderer::drawButtonHints(const int fontId, const char* btn1, const char
setOrientation(orig_orientation);
}
void GfxRenderer::drawSideButtonHints(const int fontId, const char* topBtn, const char* bottomBtn) {
void GfxRenderer::drawSideButtonHints(const int fontId, const char* topBtn, const char* bottomBtn,
const bool drawBorder) {
const Orientation orig_orientation = getOrientation();
setOrientation(Orientation::Portrait);
@@ -671,27 +671,32 @@ void GfxRenderer::drawSideButtonHints(const int fontId, const char* topBtn, cons
// Draw the shared border for both buttons as one unit
const int x = screenWidth - buttonX - buttonWidth;
// Draw top button outline (3 sides, bottom open)
if (topBtn != nullptr && topBtn[0] != '\0') {
drawLine(x, topButtonY, x + buttonWidth - 1, topButtonY); // Top
drawLine(x, topButtonY, x, topButtonY + buttonHeight - 1); // Left
drawLine(x + buttonWidth - 1, topButtonY, x + buttonWidth - 1, topButtonY + buttonHeight - 1); // Right
}
if (drawBorder) {
// Draw top button outline (3 sides, bottom open)
if (topBtn != nullptr && topBtn[0] != '\0') {
drawLine(x, topButtonY, x + buttonWidth - 1, topButtonY); // Top
drawLine(x, topButtonY, x, topButtonY + buttonHeight - 1); // Left
drawLine(x + buttonWidth - 1, topButtonY, x + buttonWidth - 1, topButtonY + buttonHeight - 1); // Right
}
// Draw shared middle border
if ((topBtn != nullptr && topBtn[0] != '\0') || (bottomBtn != nullptr && bottomBtn[0] != '\0')) {
drawLine(x, topButtonY + buttonHeight, x + buttonWidth - 1, topButtonY + buttonHeight); // Shared border
}
// Draw shared middle border
if ((topBtn != nullptr && topBtn[0] != '\0') || (bottomBtn != nullptr && bottomBtn[0] != '\0')) {
drawLine(x, topButtonY + buttonHeight, x + buttonWidth - 1, topButtonY + buttonHeight); // Shared border
}
// Draw bottom button outline (3 sides, top is shared)
if (bottomBtn != nullptr && bottomBtn[0] != '\0') {
drawLine(x, topButtonY + buttonHeight, x, topButtonY + 2 * buttonHeight - 1); // Left
drawLine(x + buttonWidth - 1, topButtonY + buttonHeight, x + buttonWidth - 1,
topButtonY + 2 * buttonHeight - 1); // Right
drawLine(x, topButtonY + 2 * buttonHeight - 1, x + buttonWidth - 1, topButtonY + 2 * buttonHeight - 1); // Bottom
// Draw bottom button outline (3 sides, top is shared)
if (bottomBtn != nullptr && bottomBtn[0] != '\0') {
drawLine(x, topButtonY + buttonHeight, x, topButtonY + 2 * buttonHeight - 1); // Left
drawLine(x + buttonWidth - 1, topButtonY + buttonHeight, x + buttonWidth - 1,
topButtonY + 2 * buttonHeight - 1); // Right
drawLine(x, topButtonY + 2 * buttonHeight - 1, x + buttonWidth - 1, topButtonY + 2 * buttonHeight - 1); // Bottom
}
}
// Draw text for each button
// Use CCW rotation for LandscapeCCW so text reads in same direction as screen content
const bool useCCW = (orig_orientation == Orientation::LandscapeCounterClockwise);
for (int i = 0; i < 2; i++) {
if (labels[i] != nullptr && labels[i][0] != '\0') {
const int y = topButtonY + i * buttonHeight;
@@ -700,11 +705,22 @@ void GfxRenderer::drawSideButtonHints(const int fontId, const char* topBtn, cons
const int textWidth = getTextWidth(fontId, labels[i]);
const int textHeight = getTextHeight(fontId);
// Center the rotated text in the button
const int textX = x + (buttonWidth - textHeight) / 2;
const int textY = y + (buttonHeight + textWidth) / 2;
int textX, textY;
if (drawBorder) {
// Center the rotated text in the button
textX = x + (buttonWidth - textHeight) / 2;
textY = useCCW ? y + (buttonHeight - textWidth) / 2 : y + (buttonHeight + textWidth) / 2;
} else {
// Position at edge with 2px margin (no border mode)
textX = screenWidth - bezelRight - textHeight - 2;
textY = useCCW ? y + (buttonHeight - textWidth) / 2 : y + (buttonHeight + textWidth) / 2;
}
drawTextRotated90CW(fontId, textX, textY, labels[i]);
if (useCCW) {
drawTextRotated90CCW(fontId, textX, textY, labels[i]);
} else {
drawTextRotated90CW(fontId, textX, textY, labels[i]);
}
}
}
@@ -802,17 +818,101 @@ void GfxRenderer::drawTextRotated90CW(const int fontId, const int x, const int y
}
}
uint8_t* GfxRenderer::getFrameBuffer() const { return einkDisplay.getFrameBuffer(); }
void GfxRenderer::drawTextRotated90CCW(const int fontId, const int x, const int y, const char* text, const bool black,
const EpdFontFamily::Style style) const {
// Cannot draw a NULL / empty string
if (text == nullptr || *text == '\0') {
return;
}
size_t GfxRenderer::getBufferSize() { return EInkDisplay::BUFFER_SIZE; }
if (fontMap.count(fontId) == 0) {
Serial.printf("[%lu] [GFX] Font %d not found\n", millis(), fontId);
return;
}
const auto font = fontMap.at(fontId);
void GfxRenderer::grayscaleRevert() const { einkDisplay.grayscaleRevert(); }
// No printable characters
if (!font.hasPrintableChars(text, style)) {
return;
}
void GfxRenderer::copyGrayscaleLsbBuffers() const { einkDisplay.copyGrayscaleLsbBuffers(einkDisplay.getFrameBuffer()); }
// For 90° counter-clockwise rotation:
// Original (glyphX, glyphY) -> Rotated (-glyphY, glyphX)
// Text reads from top to bottom
void GfxRenderer::copyGrayscaleMsbBuffers() const { einkDisplay.copyGrayscaleMsbBuffers(einkDisplay.getFrameBuffer()); }
int yPos = y; // Current Y position (increases as we draw characters)
void GfxRenderer::displayGrayBuffer() const { einkDisplay.displayGrayBuffer(); }
uint32_t cp;
while ((cp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&text)))) {
const EpdGlyph* glyph = font.getGlyph(cp, style);
if (!glyph) {
glyph = font.getGlyph(REPLACEMENT_GLYPH, style);
}
if (!glyph) {
continue;
}
const int is2Bit = font.getData(style)->is2Bit;
const uint32_t offset = glyph->dataOffset;
const uint8_t width = glyph->width;
const uint8_t height = glyph->height;
const int left = glyph->left;
const int top = glyph->top;
const uint8_t* bitmap = &font.getData(style)->bitmap[offset];
if (bitmap != nullptr) {
for (int glyphY = 0; glyphY < height; glyphY++) {
for (int glyphX = 0; glyphX < width; glyphX++) {
const int pixelPosition = glyphY * width + glyphX;
// 90° counter-clockwise rotation transformation:
// screenX = x + (top - glyphY)
// screenY = yPos + (left + glyphX)
const int screenX = x + (top - glyphY);
const int screenY = yPos + left + glyphX;
if (is2Bit) {
const uint8_t byte = bitmap[pixelPosition / 4];
const uint8_t bit_index = (3 - pixelPosition % 4) * 2;
const uint8_t bmpVal = 3 - (byte >> bit_index) & 0x3;
if (renderMode == BW && bmpVal < 3) {
drawPixel(screenX, screenY, black);
} else if (renderMode == GRAYSCALE_MSB && (bmpVal == 1 || bmpVal == 2)) {
drawPixel(screenX, screenY, false);
} else if (renderMode == GRAYSCALE_LSB && bmpVal == 1) {
drawPixel(screenX, screenY, false);
}
} else {
const uint8_t byte = bitmap[pixelPosition / 8];
const uint8_t bit_index = 7 - (pixelPosition % 8);
if ((byte >> bit_index) & 1) {
drawPixel(screenX, screenY, black);
}
}
}
}
}
// Move to next character position (going down, so increase Y)
yPos += glyph->advanceX;
}
}
uint8_t* GfxRenderer::getFrameBuffer() const { return display.getFrameBuffer(); }
size_t GfxRenderer::getBufferSize() { return HalDisplay::BUFFER_SIZE; }
// unused
// void GfxRenderer::grayscaleRevert() const { display.grayscaleRevert(); }
void GfxRenderer::copyGrayscaleLsbBuffers() const { display.copyGrayscaleLsbBuffers(display.getFrameBuffer()); }
void GfxRenderer::copyGrayscaleMsbBuffers() const { display.copyGrayscaleMsbBuffers(display.getFrameBuffer()); }
void GfxRenderer::displayGrayBuffer() const { display.displayGrayBuffer(fadingFix); }
void GfxRenderer::freeBwBufferChunks() {
for (auto& bwBufferChunk : bwBufferChunks) {
@@ -830,7 +930,7 @@ void GfxRenderer::freeBwBufferChunks() {
* Returns true if buffer was stored successfully, false if allocation failed.
*/
bool GfxRenderer::storeBwBuffer() {
const uint8_t* frameBuffer = einkDisplay.getFrameBuffer();
const uint8_t* frameBuffer = display.getFrameBuffer();
if (!frameBuffer) {
Serial.printf("[%lu] [GFX] !! No framebuffer in storeBwBuffer\n", millis());
return false;
@@ -885,14 +985,14 @@ void GfxRenderer::restoreBwBuffer() {
// CRITICAL: Even if restore fails, we must clean up the grayscale state
// to prevent grayscaleRevert() from being called with corrupted RAM state
// Use the current framebuffer content (which may not be ideal but prevents worse issues)
uint8_t* frameBuffer = einkDisplay.getFrameBuffer();
uint8_t* frameBuffer = display.getFrameBuffer();
if (frameBuffer) {
einkDisplay.cleanupGrayscaleBuffers(frameBuffer);
display.cleanupGrayscaleBuffers(frameBuffer);
}
return;
}
uint8_t* frameBuffer = einkDisplay.getFrameBuffer();
uint8_t* frameBuffer = display.getFrameBuffer();
if (!frameBuffer) {
Serial.printf("[%lu] [GFX] !! No framebuffer in restoreBwBuffer\n", millis());
freeBwBufferChunks();
@@ -905,7 +1005,7 @@ void GfxRenderer::restoreBwBuffer() {
Serial.printf("[%lu] [GFX] !! BW buffer chunks not stored - this is likely a bug\n", millis());
freeBwBufferChunks();
// CRITICAL: Clean up grayscale state even on mid-restore failure
einkDisplay.cleanupGrayscaleBuffers(frameBuffer);
display.cleanupGrayscaleBuffers(frameBuffer);
return;
}
@@ -913,7 +1013,7 @@ void GfxRenderer::restoreBwBuffer() {
memcpy(frameBuffer + offset, bwBufferChunks[i], BW_BUFFER_CHUNK_SIZE);
}
einkDisplay.cleanupGrayscaleBuffers(frameBuffer);
display.cleanupGrayscaleBuffers(frameBuffer);
freeBwBufferChunks();
Serial.printf("[%lu] [GFX] Restored and freed BW buffer chunks\n", millis());
@@ -924,9 +1024,9 @@ void GfxRenderer::restoreBwBuffer() {
* Use this when BW buffer was re-rendered instead of stored/restored.
*/
void GfxRenderer::cleanupGrayscaleWithFrameBuffer() const {
uint8_t* frameBuffer = einkDisplay.getFrameBuffer();
uint8_t* frameBuffer = display.getFrameBuffer();
if (frameBuffer) {
einkDisplay.cleanupGrayscaleBuffers(frameBuffer);
display.cleanupGrayscaleBuffers(frameBuffer);
}
}

View File

@@ -1,7 +1,7 @@
#pragma once
#include <EInkDisplay.h>
#include <EpdFontFamily.h>
#include <HalDisplay.h>
#include <map>
@@ -24,8 +24,8 @@ class GfxRenderer {
private:
static constexpr size_t BW_BUFFER_CHUNK_SIZE = 8000; // 8KB chunks to allow for non-contiguous memory
static constexpr size_t BW_BUFFER_NUM_CHUNKS = EInkDisplay::BUFFER_SIZE / BW_BUFFER_CHUNK_SIZE;
static_assert(BW_BUFFER_CHUNK_SIZE * BW_BUFFER_NUM_CHUNKS == EInkDisplay::BUFFER_SIZE,
static constexpr size_t BW_BUFFER_NUM_CHUNKS = HalDisplay::BUFFER_SIZE / BW_BUFFER_CHUNK_SIZE;
static_assert(BW_BUFFER_CHUNK_SIZE * BW_BUFFER_NUM_CHUNKS == HalDisplay::BUFFER_SIZE,
"BW buffer chunking does not line up with display buffer size");
// Base viewable margins (hardware-specific, before bezel compensation)
@@ -34,9 +34,10 @@ class GfxRenderer {
static constexpr int BASE_VIEWABLE_MARGIN_BOTTOM = 3;
static constexpr int BASE_VIEWABLE_MARGIN_LEFT = 3;
EInkDisplay& einkDisplay;
HalDisplay& display;
RenderMode renderMode;
Orientation orientation;
bool fadingFix = false; // Sunlight fading fix - turn off screen after refresh
int bezelCompensation = 0; // Pixels to add for bezel defect compensation
int bezelEdge = 0; // Which physical edge (0=bottom, 1=top, 2=left, 3=right in portrait)
uint8_t* bwBufferChunks[BW_BUFFER_NUM_CHUNKS] = {nullptr};
@@ -47,7 +48,7 @@ class GfxRenderer {
void rotateCoordinates(int x, int y, int* rotatedX, int* rotatedY) const;
public:
explicit GfxRenderer(EInkDisplay& einkDisplay) : einkDisplay(einkDisplay), renderMode(BW), orientation(Portrait) {}
explicit GfxRenderer(HalDisplay& halDisplay) : display(halDisplay), renderMode(BW), orientation(Portrait) {}
~GfxRenderer() { freeBwBufferChunks(); }
// Viewable margins (includes bezel compensation applied to the configured edge)
@@ -76,10 +77,13 @@ class GfxRenderer {
void setOrientation(const Orientation o) { orientation = o; }
Orientation getOrientation() const { return orientation; }
// Fading fix control
void setFadingFix(const bool enabled) { fadingFix = enabled; }
// Screen ops
int getScreenWidth() const;
int getScreenHeight() const;
void displayBuffer(EInkDisplay::RefreshMode refreshMode = EInkDisplay::FAST_REFRESH) const;
void displayBuffer(HalDisplay::RefreshMode refreshMode = HalDisplay::FAST_REFRESH) const;
// EXPERIMENTAL: Windowed update - display only a rectangular region
void displayWindow(int x, int y, int width, int height) const;
void invertScreen() const;
@@ -116,12 +120,15 @@ class GfxRenderer {
// UI Components
void drawButtonHints(int fontId, const char* btn1, const char* btn2, const char* btn3, const char* btn4);
void drawSideButtonHints(int fontId, const char* topBtn, const char* bottomBtn);
void drawSideButtonHints(int fontId, const char* topBtn, const char* bottomBtn, bool drawBorder = true);
private:
// Helper for drawing rotated text (90 degrees clockwise, for side buttons)
void drawTextRotated90CW(int fontId, int x, int y, const char* text, bool black = true,
EpdFontFamily::Style style = EpdFontFamily::REGULAR) const;
// Helper for drawing rotated text (90 degrees counter-clockwise, for LandscapeCCW orientation)
void drawTextRotated90CCW(int fontId, int x, int y, const char* text, bool black = true,
EpdFontFamily::Style style = EpdFontFamily::REGULAR) const;
int getTextHeight(int fontId) const;
public:

View File

@@ -205,6 +205,19 @@ bool StarDict::loadDictzipHeader() {
bool StarDict::begin() {
if (!loadInfo()) return false;
// Try uncompressed .dict file first (preferred - no memory overhead)
const std::string dictPath = basePath + ".dict";
FsFile testFile;
if (SdMan.openFileForRead("DICT", dictPath, testFile)) {
testFile.close();
useUncompressed = true;
Serial.printf("[%lu] [DICT] Using uncompressed .dict file (no decompression needed)\n", millis());
return true;
}
// Fall back to compressed .dict.dz
useUncompressed = false;
if (!loadDictzipHeader()) return false;
return true;
}
@@ -238,12 +251,46 @@ bool StarDict::readWordAtPosition(FsFile& idxFile, uint32_t& position, std::stri
return true;
}
bool StarDict::readDefinitionDirect(uint32_t offset, uint32_t size, std::string& definition) {
// Read directly from uncompressed .dict file - no decompression needed!
const std::string dictPath = basePath + ".dict";
FsFile file;
if (!SdMan.openFileForRead("DICT", dictPath, file)) {
Serial.printf("[DICT-DBG] Failed to open .dict file\n");
return false;
}
// Seek to the definition offset
if (!file.seek(offset)) {
Serial.printf("[DICT-DBG] Failed to seek to offset %lu\n", offset);
file.close();
return false;
}
// Read the definition directly into the string
definition.resize(size);
const int bytesRead = file.read(&definition[0], size);
file.close();
if (bytesRead != static_cast<int>(size)) {
Serial.printf("[DICT-DBG] Read %d bytes, expected %lu\n", bytesRead, size);
definition.clear();
return false;
}
return true;
}
bool StarDict::decompressDefinition(uint32_t offset, uint32_t size, std::string& definition) {
if (!dzInfo.loaded) return false;
if (!dzInfo.loaded) {
Serial.printf("[DICT-DBG] dzInfo not loaded!\n");
return false;
}
const std::string dzPath = basePath + ".dict.dz";
FsFile file;
if (!SdMan.openFileForRead("DICT", dzPath, file)) {
Serial.printf("[DICT-DBG] Failed to open dict.dz file\n");
return false;
}
@@ -252,7 +299,10 @@ bool StarDict::decompressDefinition(uint32_t offset, uint32_t size, std::string&
const uint32_t endChunk = (offset + size - 1) / dzInfo.chunkLength;
const uint32_t startOffsetInChunk = offset % dzInfo.chunkLength;
Serial.printf("[DICT-DBG] Chunks: start=%lu, end=%lu, total=%u\n", startChunk, endChunk, dzInfo.chunkCount);
if (endChunk >= dzInfo.chunkCount) {
Serial.printf("[DICT-DBG] endChunk %lu >= chunkCount %u\n", endChunk, dzInfo.chunkCount);
file.close();
return false;
}
@@ -263,13 +313,38 @@ bool StarDict::decompressDefinition(uint32_t offset, uint32_t size, std::string&
fileOffset += dzInfo.chunkSizes[i];
}
// Allocate buffers
const uint32_t maxCompressedSize = 65536; // Max compressed chunk size
// Calculate actual max compressed size needed for the chunks we'll process
uint32_t maxCompressedSize = 0;
for (uint32_t i = startChunk; i <= endChunk; i++) {
if (dzInfo.chunkSizes[i] > maxCompressedSize) {
maxCompressedSize = dzInfo.chunkSizes[i];
}
}
// Allocate buffers - allocate inflator FIRST (smallest) to reduce fragmentation impact
// tinfl_decompressor is ~11KB, so total allocations are ~85KB
Serial.printf("[DICT-DBG] Allocating inflator=%u, comp=%lu, decomp=%u bytes\n", sizeof(tinfl_decompressor),
maxCompressedSize, dzInfo.chunkLength);
auto* inflator = static_cast<tinfl_decompressor*>(malloc(sizeof(tinfl_decompressor)));
if (!inflator) {
Serial.printf("[DICT-DBG] inflator alloc failed! (need %u bytes)\n", sizeof(tinfl_decompressor));
file.close();
return false;
}
auto* compressedBuf = static_cast<uint8_t*>(malloc(maxCompressedSize));
if (!compressedBuf) {
Serial.printf("[DICT-DBG] compressedBuf alloc failed!\n");
free(inflator);
file.close();
return false;
}
auto* decompressedBuf = static_cast<uint8_t*>(malloc(dzInfo.chunkLength));
if (!compressedBuf || !decompressedBuf) {
if (!decompressedBuf) {
Serial.printf("[DICT-DBG] decompressedBuf alloc failed!\n");
free(inflator);
free(compressedBuf);
free(decompressedBuf);
file.close();
return false;
}
@@ -277,13 +352,15 @@ bool StarDict::decompressDefinition(uint32_t offset, uint32_t size, std::string&
definition.clear();
definition.reserve(size);
// Process each needed chunk
// Process each needed chunk (reusing inflator allocation)
for (uint32_t chunk = startChunk; chunk <= endChunk; chunk++) {
const uint16_t compressedSize = dzInfo.chunkSizes[chunk];
// Seek and read compressed data
file.seek(fileOffset);
if (file.read(compressedBuf, compressedSize) != compressedSize) {
Serial.printf("[DICT-DBG] File read failed at offset %lu, size %u\n", fileOffset, compressedSize);
free(inflator);
free(compressedBuf);
free(decompressedBuf);
file.close();
@@ -291,13 +368,6 @@ bool StarDict::decompressDefinition(uint32_t offset, uint32_t size, std::string&
}
// Decompress using raw inflate (no zlib header)
auto* inflator = static_cast<tinfl_decompressor*>(malloc(sizeof(tinfl_decompressor)));
if (!inflator) {
free(compressedBuf);
free(decompressedBuf);
file.close();
return false;
}
tinfl_init(inflator);
size_t inBytes = compressedSize;
@@ -306,19 +376,13 @@ bool StarDict::decompressDefinition(uint32_t offset, uint32_t size, std::string&
tinfl_decompress(inflator, compressedBuf, &inBytes, decompressedBuf, decompressedBuf, &outBytes,
TINFL_FLAG_USING_NON_WRAPPING_OUTPUT_BUF | TINFL_FLAG_PARSE_ZLIB_HEADER);
free(inflator);
if (status != TINFL_STATUS_DONE && status != TINFL_STATUS_HAS_MORE_OUTPUT) {
// Try without zlib header flag
inflator = static_cast<tinfl_decompressor*>(malloc(sizeof(tinfl_decompressor)));
if (inflator) {
tinfl_init(inflator);
inBytes = compressedSize;
outBytes = dzInfo.chunkLength;
tinfl_decompress(inflator, compressedBuf, &inBytes, decompressedBuf, decompressedBuf, &outBytes,
TINFL_FLAG_USING_NON_WRAPPING_OUTPUT_BUF);
free(inflator);
}
tinfl_init(inflator);
inBytes = compressedSize;
outBytes = dzInfo.chunkLength;
tinfl_decompress(inflator, compressedBuf, &inBytes, decompressedBuf, decompressedBuf, &outBytes,
TINFL_FLAG_USING_NON_WRAPPING_OUTPUT_BUF);
}
// Extract the portion we need from this chunk
@@ -342,6 +406,7 @@ bool StarDict::decompressDefinition(uint32_t offset, uint32_t size, std::string&
fileOffset += compressedSize;
}
free(inflator);
free(compressedBuf);
free(decompressedBuf);
file.close();
@@ -349,9 +414,9 @@ bool StarDict::decompressDefinition(uint32_t offset, uint32_t size, std::string&
return true;
}
// StarDict comparison function: case-insensitive first, then case-sensitive as tiebreaker
// StarDict comparison function: case-insensitive matching
int StarDict::stardictStrcmp(const std::string& a, const std::string& b) {
// First: case-insensitive comparison (like g_ascii_strcasecmp)
// Case-insensitive comparison (like g_ascii_strcasecmp)
size_t i = 0;
while (i < a.length() && i < b.length()) {
const int ca = std::tolower(static_cast<unsigned char>(a[i]));
@@ -362,8 +427,8 @@ int StarDict::stardictStrcmp(const std::string& a, const std::string& b) {
if (a.length() != b.length()) {
return static_cast<int>(a.length()) - static_cast<int>(b.length());
}
// If case-insensitive equal, use case-sensitive as tiebreaker
return a.compare(b);
// Case-insensitive match found
return 0;
}
std::string StarDict::normalizeWord(const std::string& word) {
@@ -403,6 +468,8 @@ StarDict::LookupResult StarDict::lookup(const std::string& word) {
return result;
}
Serial.printf("[DICT-DBG] Searching for: '%s' (normalized: '%s')\n", word.c_str(), normalizedSearch.c_str());
// First try .idx (main entries) - use prefix jump table for fast lookup
const std::string idxPath = basePath + ".idx";
FsFile idxFile;
@@ -418,7 +485,10 @@ StarDict::LookupResult StarDict::lookup(const std::string& word) {
const uint16_t prefixIdx = DictPrefixIndex::prefixToIndex(normalizedSearch[0], normalizedSearch[1]);
position = DictPrefixIndex::dictPrefixOffsets[prefixIdx];
}
Serial.printf("[DICT-DBG] Starting at position %lu (prefix: %c%c)\n", position, normalizedSearch[0],
normalizedSearch[1]);
bool found = false;
uint32_t wordCount = 0;
while (position < info.idxfilesize) {
std::string currentWord;
@@ -427,13 +497,23 @@ StarDict::LookupResult StarDict::lookup(const std::string& word) {
if (!readWordAtPosition(idxFile, position, currentWord, dictOffset, dictSize)) {
break;
}
wordCount++;
if (wordCount % 50000 == 0) {
Serial.printf("[DICT-DBG] Progress: %lu words scanned, pos=%lu, current='%s'\n", wordCount, position,
currentWord.c_str());
}
// Use stardictStrcmp for case-insensitive matching
const int cmp = stardictStrcmp(normalizedSearch, currentWord);
if (cmp == 0) {
Serial.printf("[DICT-DBG] MATCH: '%s' == '%s' (offset=%lu, size=%lu)\n", normalizedSearch.c_str(),
currentWord.c_str(), dictOffset, dictSize);
std::string definition;
if (decompressDefinition(dictOffset, dictSize, definition)) {
const bool loaded = useUncompressed ? readDefinitionDirect(dictOffset, dictSize, definition)
: decompressDefinition(dictOffset, dictSize, definition);
if (loaded) {
Serial.printf("[DICT-DBG] Definition loaded, %u bytes\n", definition.length());
if (!found) {
result.word = currentWord;
result.definition = definition;
@@ -442,14 +522,19 @@ StarDict::LookupResult StarDict::lookup(const std::string& word) {
} else {
result.definition += "</html>" + definition;
}
} else {
Serial.printf("[DICT-DBG] Definition load FAILED!\n");
}
// Continue scanning for additional matches (same word, different case)
} else if (cmp < 0) {
// Passed where target would be (file is sorted)
} else if (found) {
// We had matches but now moved past them - safe to stop
break;
}
// Note: Cannot use early-break before first match because prefix index
// may not land exactly at target position
}
Serial.printf("[DICT-DBG] Search complete: %lu words scanned, found=%s\n", wordCount, found ? "YES" : "NO");
idxFile.close();
// If not found in main index, try synonym file with prefix jump
@@ -502,7 +587,9 @@ StarDict::LookupResult StarDict::lookup(const std::string& word) {
uint32_t dictOffset, dictSize;
if (readWordAtPosition(idxFile2, pos, mainWord, dictOffset, dictSize)) {
std::string definition;
if (decompressDefinition(dictOffset, dictSize, definition)) {
const bool loaded = useUncompressed ? readDefinitionDirect(dictOffset, dictSize, definition)
: decompressDefinition(dictOffset, dictSize, definition);
if (loaded) {
result.word = synWord;
result.definition = definition;
result.found = true;
@@ -513,10 +600,9 @@ StarDict::LookupResult StarDict::lookup(const std::string& word) {
idxFile2.close();
}
break; // Found a match, stop searching
} else if (cmp < 0) {
// Passed where it would be (file is sorted)
break;
}
// Note: Cannot use early-break optimization here because prefix index
// may not land exactly at target position
}
synFile.close();
}

View File

@@ -6,7 +6,7 @@
#include <string>
// StarDict dictionary lookup library
// Supports .ifo/.idx/.dict.dz format with linear scan lookup
// Supports .ifo/.idx/.dict (uncompressed) and .ifo/.idx/.dict.dz (compressed) formats
class StarDict {
public:
struct DictInfo {
@@ -38,16 +38,22 @@ class StarDict {
};
DictzipInfo dzInfo;
// Whether to use uncompressed .dict file (preferred) or compressed .dict.dz
bool useUncompressed = false;
// Parse .ifo file
bool loadInfo();
// Load dictzip header for random access
// Load dictzip header for random access (only if using compressed)
bool loadDictzipHeader();
// Read word at given index file position, returns word and advances position
bool readWordAtPosition(FsFile& idxFile, uint32_t& position, std::string& word, uint32_t& dictOffset,
uint32_t& dictSize);
// Read definition directly from uncompressed .dict file (no decompression needed)
bool readDefinitionDirect(uint32_t offset, uint32_t size, std::string& definition);
// Decompress a portion of the .dict.dz file
bool decompressDefinition(uint32_t offset, uint32_t size, std::string& definition);

View File

@@ -529,10 +529,24 @@ bool ZipFile::readFileToStream(const char* filename, Print& out, const size_t ch
}
if (fileStat.method == MZ_DEFLATED) {
// Setup inflator
// Allocate largest buffer first to maximize chance of finding contiguous block
// Dictionary buffer (32KB) - needed for DEFLATE sliding window
const auto outputBuffer = static_cast<uint8_t*>(malloc(TINFL_LZ_DICT_SIZE));
if (!outputBuffer) {
Serial.printf("[%lu] [ZIP] Failed to allocate memory for dictionary (need %d bytes)\n", millis(),
TINFL_LZ_DICT_SIZE);
if (!wasOpen) {
close();
}
return false;
}
memset(outputBuffer, 0, TINFL_LZ_DICT_SIZE);
// Setup inflator (~11KB)
const auto inflator = static_cast<tinfl_decompressor*>(malloc(sizeof(tinfl_decompressor)));
if (!inflator) {
Serial.printf("[%lu] [ZIP] Failed to allocate memory for inflator\n", millis());
free(outputBuffer);
if (!wasOpen) {
close();
}
@@ -541,29 +555,18 @@ bool ZipFile::readFileToStream(const char* filename, Print& out, const size_t ch
memset(inflator, 0, sizeof(tinfl_decompressor));
tinfl_init(inflator);
// Setup file read buffer
// Setup file read buffer (smallest allocation last)
const auto fileReadBuffer = static_cast<uint8_t*>(malloc(chunkSize));
if (!fileReadBuffer) {
Serial.printf("[%lu] [ZIP] Failed to allocate memory for zip file read buffer\n", millis());
free(inflator);
free(outputBuffer);
if (!wasOpen) {
close();
}
return false;
}
const auto outputBuffer = static_cast<uint8_t*>(malloc(TINFL_LZ_DICT_SIZE));
if (!outputBuffer) {
Serial.printf("[%lu] [ZIP] Failed to allocate memory for dictionary\n", millis());
free(inflator);
free(fileReadBuffer);
if (!wasOpen) {
close();
}
return false;
}
memset(outputBuffer, 0, TINFL_LZ_DICT_SIZE);
size_t fileRemainingBytes = deflatedDataSize;
size_t processedOutputBytes = 0;
size_t fileReadBufferFilledBytes = 0;

53
lib/hal/HalDisplay.cpp Normal file
View File

@@ -0,0 +1,53 @@
#include <HalDisplay.h>
#include <HalGPIO.h>
#define SD_SPI_MISO 7
HalDisplay::HalDisplay() : einkDisplay(EPD_SCLK, EPD_MOSI, EPD_CS, EPD_DC, EPD_RST, EPD_BUSY) {}
HalDisplay::~HalDisplay() {}
void HalDisplay::begin() { einkDisplay.begin(); }
void HalDisplay::clearScreen(uint8_t color) const { einkDisplay.clearScreen(color); }
void HalDisplay::drawImage(const uint8_t* imageData, uint16_t x, uint16_t y, uint16_t w, uint16_t h,
bool fromProgmem) const {
einkDisplay.drawImage(imageData, x, y, w, h, fromProgmem);
}
EInkDisplay::RefreshMode convertRefreshMode(HalDisplay::RefreshMode mode) {
switch (mode) {
case HalDisplay::FULL_REFRESH:
return EInkDisplay::FULL_REFRESH;
case HalDisplay::HALF_REFRESH:
return EInkDisplay::HALF_REFRESH;
case HalDisplay::FAST_REFRESH:
default:
return EInkDisplay::FAST_REFRESH;
}
}
void HalDisplay::displayBuffer(HalDisplay::RefreshMode mode, bool turnOffScreen) {
einkDisplay.displayBuffer(convertRefreshMode(mode), turnOffScreen);
}
void HalDisplay::refreshDisplay(HalDisplay::RefreshMode mode, bool turnOffScreen) {
einkDisplay.refreshDisplay(convertRefreshMode(mode), turnOffScreen);
}
void HalDisplay::deepSleep() { einkDisplay.deepSleep(); }
uint8_t* HalDisplay::getFrameBuffer() const { return einkDisplay.getFrameBuffer(); }
void HalDisplay::copyGrayscaleBuffers(const uint8_t* lsbBuffer, const uint8_t* msbBuffer) {
einkDisplay.copyGrayscaleBuffers(lsbBuffer, msbBuffer);
}
void HalDisplay::copyGrayscaleLsbBuffers(const uint8_t* lsbBuffer) { einkDisplay.copyGrayscaleLsbBuffers(lsbBuffer); }
void HalDisplay::copyGrayscaleMsbBuffers(const uint8_t* msbBuffer) { einkDisplay.copyGrayscaleMsbBuffers(msbBuffer); }
void HalDisplay::cleanupGrayscaleBuffers(const uint8_t* bwBuffer) { einkDisplay.cleanupGrayscaleBuffers(bwBuffer); }
void HalDisplay::displayGrayBuffer(bool turnOffScreen) { einkDisplay.displayGrayBuffer(turnOffScreen); }

52
lib/hal/HalDisplay.h Normal file
View File

@@ -0,0 +1,52 @@
#pragma once
#include <Arduino.h>
#include <EInkDisplay.h>
class HalDisplay {
public:
// Constructor with pin configuration
HalDisplay();
// Destructor
~HalDisplay();
// Refresh modes
enum RefreshMode {
FULL_REFRESH, // Full refresh with complete waveform
HALF_REFRESH, // Half refresh (1720ms) - balanced quality and speed
FAST_REFRESH // Fast refresh using custom LUT
};
// Initialize the display hardware and driver
void begin();
// Display dimensions
static constexpr uint16_t DISPLAY_WIDTH = EInkDisplay::DISPLAY_WIDTH;
static constexpr uint16_t DISPLAY_HEIGHT = EInkDisplay::DISPLAY_HEIGHT;
static constexpr uint16_t DISPLAY_WIDTH_BYTES = DISPLAY_WIDTH / 8;
static constexpr uint32_t BUFFER_SIZE = DISPLAY_WIDTH_BYTES * DISPLAY_HEIGHT;
// Frame buffer operations
void clearScreen(uint8_t color = 0xFF) const;
void drawImage(const uint8_t* imageData, uint16_t x, uint16_t y, uint16_t w, uint16_t h,
bool fromProgmem = false) const;
void displayBuffer(RefreshMode mode = RefreshMode::FAST_REFRESH, bool turnOffScreen = false);
void refreshDisplay(RefreshMode mode = RefreshMode::FAST_REFRESH, bool turnOffScreen = false);
// Power management
void deepSleep();
// Access to frame buffer
uint8_t* getFrameBuffer() const;
void copyGrayscaleBuffers(const uint8_t* lsbBuffer, const uint8_t* msbBuffer);
void copyGrayscaleLsbBuffers(const uint8_t* lsbBuffer);
void copyGrayscaleMsbBuffers(const uint8_t* msbBuffer);
void cleanupGrayscaleBuffers(const uint8_t* bwBuffer);
void displayGrayBuffer(bool turnOffScreen = false);
private:
EInkDisplay einkDisplay;
};

55
lib/hal/HalGPIO.cpp Normal file
View File

@@ -0,0 +1,55 @@
#include <HalGPIO.h>
#include <SPI.h>
#include <esp_sleep.h>
void HalGPIO::begin() {
inputMgr.begin();
SPI.begin(EPD_SCLK, SPI_MISO, EPD_MOSI, EPD_CS);
pinMode(BAT_GPIO0, INPUT);
pinMode(UART0_RXD, INPUT);
}
void HalGPIO::update() { inputMgr.update(); }
bool HalGPIO::isPressed(uint8_t buttonIndex) const { return inputMgr.isPressed(buttonIndex); }
bool HalGPIO::wasPressed(uint8_t buttonIndex) const { return inputMgr.wasPressed(buttonIndex); }
bool HalGPIO::wasAnyPressed() const { return inputMgr.wasAnyPressed(); }
bool HalGPIO::wasReleased(uint8_t buttonIndex) const { return inputMgr.wasReleased(buttonIndex); }
bool HalGPIO::wasAnyReleased() const { return inputMgr.wasAnyReleased(); }
unsigned long HalGPIO::getHeldTime() const { return inputMgr.getHeldTime(); }
void HalGPIO::startDeepSleep() {
esp_deep_sleep_enable_gpio_wakeup(1ULL << InputManager::POWER_BUTTON_PIN, ESP_GPIO_WAKEUP_GPIO_LOW);
// Ensure that the power button has been released to avoid immediately turning back on if you're holding it
while (inputMgr.isPressed(BTN_POWER)) {
delay(50);
inputMgr.update();
}
// Enter Deep Sleep
esp_deep_sleep_start();
}
int HalGPIO::getBatteryPercentage() const {
static const BatteryMonitor battery = BatteryMonitor(BAT_GPIO0);
return battery.readPercentage();
}
bool HalGPIO::isUsbConnected() const {
// U0RXD/GPIO20 reads HIGH when USB is connected
return digitalRead(UART0_RXD) == HIGH;
}
bool HalGPIO::isWakeupByPowerButton() const {
const auto wakeupCause = esp_sleep_get_wakeup_cause();
const auto resetReason = esp_reset_reason();
if (isUsbConnected()) {
return wakeupCause == ESP_SLEEP_WAKEUP_GPIO;
} else {
return (wakeupCause == ESP_SLEEP_WAKEUP_UNDEFINED) && (resetReason == ESP_RST_POWERON);
}
}

61
lib/hal/HalGPIO.h Normal file
View File

@@ -0,0 +1,61 @@
#pragma once
#include <Arduino.h>
#include <BatteryMonitor.h>
#include <InputManager.h>
// Display SPI pins (custom pins for XteinkX4, not hardware SPI defaults)
#define EPD_SCLK 8 // SPI Clock
#define EPD_MOSI 10 // SPI MOSI (Master Out Slave In)
#define EPD_CS 21 // Chip Select
#define EPD_DC 4 // Data/Command
#define EPD_RST 5 // Reset
#define EPD_BUSY 6 // Busy
#define SPI_MISO 7 // SPI MISO, shared between SD card and display (Master In Slave Out)
#define BAT_GPIO0 0 // Battery voltage
#define UART0_RXD 20 // Used for USB connection detection
class HalGPIO {
#if CROSSPOINT_EMULATED == 0
InputManager inputMgr;
#endif
public:
HalGPIO() = default;
// Start button GPIO and setup SPI for screen and SD card
void begin();
// Button input methods
void update();
bool isPressed(uint8_t buttonIndex) const;
bool wasPressed(uint8_t buttonIndex) const;
bool wasAnyPressed() const;
bool wasReleased(uint8_t buttonIndex) const;
bool wasAnyReleased() const;
unsigned long getHeldTime() const;
// Setup wake up GPIO and enter deep sleep
void startDeepSleep();
// Get battery percentage (range 0-100)
int getBatteryPercentage() const;
// Check if USB is connected
bool isUsbConnected() const;
// Check if wakeup was caused by power button press
bool isWakeupByPowerButton() const;
// Button indices
static constexpr uint8_t BTN_BACK = 0;
static constexpr uint8_t BTN_CONFIRM = 1;
static constexpr uint8_t BTN_LEFT = 2;
static constexpr uint8_t BTN_RIGHT = 3;
static constexpr uint8_t BTN_UP = 4;
static constexpr uint8_t BTN_DOWN = 5;
static constexpr uint8_t BTN_POWER = 6;
};

View File

@@ -2,7 +2,8 @@
default_envs = default
[crosspoint]
version = ef-0.15.99
# 0.15.0 CrossPoint base, ef-1.0.0 is the first release of the ef branch
version = 0.15.ef-1.0.5
[base]
platform = espressif32 @ 6.12.0

View File

@@ -5,7 +5,11 @@ This allows the firmware to display "Flashing firmware..." on the e-ink display
before the actual flash begins. The e-ink retains this message throughout the
flash process since it doesn't require power to maintain the display.
Protocol: Sends "FLASH:version\n" where version is read from platformio.ini
Protocol (Plan A - Simple timing):
1. Host opens serial port and sends "FLASH:version"
2. Host keeps port open briefly for device to receive and process
3. Device displays flash screen when it receives the command
4. Host proceeds with flash
"""
Import("env")
@@ -15,7 +19,7 @@ from version_utils import get_version
def before_upload(source, target, env):
"""Send FLASH command with version to device before upload begins."""
"""Send FLASH command to device before uploading firmware."""
port = env.GetProjectOption("upload_port", None)
if not port:
@@ -29,19 +33,20 @@ def before_upload(source, target, env):
]
port = ports[0] if ports else None
if port:
try:
version = get_version(env)
ser = serial.Serial(port, 115200, timeout=1)
ser.write(f"FLASH:{version}\n".encode())
ser.flush()
ser.close()
time.sleep(0.8) # Wait for e-ink fast refresh (~500ms) plus margin
print(f"[pre_flash] Flash notification sent to {port} (version {version})")
except Exception as e:
print(f"[pre_flash] Notification skipped: {e}")
else:
if not port:
print("[pre_flash] No serial port found, skipping notification")
return
try:
version = get_version(env)
ser = serial.Serial(port, 115200, timeout=1)
ser.write(f"FLASH:{version}\n".encode())
ser.flush()
time.sleep(4.0) # Keep port open for device to receive and complete full refresh (~2-3s)
ser.close()
print(f"[pre_flash] Flash notification sent to {port} (version {version})")
except Exception as e:
print(f"[pre_flash] Notification skipped: {e}")
env.AddPreAction("upload", before_upload)

View File

@@ -0,0 +1,335 @@
#!/usr/bin/env python3
"""
Recompress a dictzip file with a custom chunk size.
Dictzip is a gzip-compatible format that allows random access by compressing
data in independent chunks. The standard dictzip uses ~58KB chunks, but this
can cause memory issues on embedded devices like ESP32.
This script recompresses dictionary files with smaller chunks (default 16KB)
to reduce memory requirements during decompression.
Usage:
# From uncompressed .dict file:
python recompress_dictzip.py reader.dict reader.dict.dz --chunk-size 16384
# From existing .dict.dz file (will decompress first):
python recompress_dictzip.py reader.dict.dz reader_small.dict.dz --chunk-size 16384
"""
import argparse
import gzip
import struct
import sys
import time
import zlib
from pathlib import Path
def read_input_file(input_path: Path) -> bytes:
"""Read input file, decompressing if it's a .dz or .gz file."""
suffix = input_path.suffix.lower()
if suffix in ('.dz', '.gz'):
print(f"Decompressing {input_path}...")
with gzip.open(input_path, 'rb') as f:
data = f.read()
print(f" Decompressed size: {len(data):,} bytes")
return data
else:
print(f"Reading {input_path}...")
with open(input_path, 'rb') as f:
data = f.read()
print(f" Size: {len(data):,} bytes")
return data
def compress_chunk(data: bytes, level: int = 9) -> bytes:
"""Compress a single chunk using raw deflate (no zlib header)."""
# Use raw deflate (-15 for raw, 15 for window size)
compressor = zlib.compressobj(level, zlib.DEFLATED, -15)
compressed = compressor.compress(data)
compressed += compressor.flush()
return compressed
def create_dictzip(data: bytes, output_path: Path, chunk_size: int = 16384,
compression_level: int = 9) -> None:
"""
Create a dictzip file from uncompressed data.
Dictzip format:
- Standard gzip header with FEXTRA flag
- Extra field containing 'RA' subfield with chunk info
- Compressed chunks (raw deflate, no headers)
- Standard gzip trailer (CRC32 + ISIZE)
"""
# Validate chunk size (must fit in 16-bit field)
if chunk_size > 65535:
raise ValueError(f"Chunk size {chunk_size} exceeds maximum of 65535")
if chunk_size < 1024:
raise ValueError(f"Chunk size {chunk_size} is too small (minimum 1024)")
# Calculate number of chunks
num_chunks = (len(data) + chunk_size - 1) // chunk_size
# Check if we can fit all chunk sizes in the extra field
# Extra field max is 65535 bytes, each chunk size takes 2 bytes, plus 6 bytes header
max_chunks = (65535 - 6) // 2
if num_chunks > max_chunks:
raise ValueError(f"Too many chunks ({num_chunks}) for dictzip format (max {max_chunks})")
print(f"Compressing into {num_chunks} chunks of {chunk_size} bytes...")
# Compress each chunk and collect sizes
compressed_chunks = []
chunk_sizes = []
for i in range(num_chunks):
start = i * chunk_size
end = min(start + chunk_size, len(data))
chunk_data = data[start:end]
compressed = compress_chunk(chunk_data, compression_level)
compressed_chunks.append(compressed)
chunk_sizes.append(len(compressed))
if (i + 1) % 500 == 0 or i == num_chunks - 1:
print(f" Compressed chunk {i + 1}/{num_chunks}")
# Calculate CRC32 and size for gzip trailer
crc32 = zlib.crc32(data) & 0xffffffff
isize = len(data) & 0xffffffff
# Build the extra field
# RA subfield: VER(2) + CHLEN(2) + CHCNT(2) + sizes[CHCNT](2 each)
ra_subfield_len = 6 + 2 * num_chunks
extra_field = bytearray()
extra_field.extend(b'RA') # SI1, SI2
extra_field.extend(struct.pack('<H', ra_subfield_len)) # LEN
extra_field.extend(struct.pack('<H', 1)) # VER
extra_field.extend(struct.pack('<H', chunk_size)) # CHLEN
extra_field.extend(struct.pack('<H', num_chunks)) # CHCNT
for size in chunk_sizes:
if size > 65535:
raise ValueError(f"Compressed chunk size {size} exceeds 65535 bytes")
extra_field.extend(struct.pack('<H', size))
xlen = len(extra_field)
# Build gzip header
# Flags: FEXTRA (0x04)
timestamp = int(time.time())
xfl = 2 if compression_level == 9 else (4 if compression_level == 1 else 0)
header = bytearray()
header.extend(b'\x1f\x8b') # Magic number
header.append(0x08) # Compression method (deflate)
header.append(0x04) # Flags: FEXTRA
header.extend(struct.pack('<I', timestamp)) # MTIME
header.append(xfl) # XFL
header.append(0xff) # OS (unknown)
header.extend(struct.pack('<H', xlen)) # XLEN
header.extend(extra_field)
# Write output file
print(f"Writing {output_path}...")
with open(output_path, 'wb') as f:
f.write(header)
for chunk in compressed_chunks:
f.write(chunk)
f.write(struct.pack('<I', crc32))
f.write(struct.pack('<I', isize))
# Report stats
output_size = output_path.stat().st_size
ratio = (1 - output_size / len(data)) * 100
print(f" Output size: {output_size:,} bytes ({ratio:.1f}% compression)")
print(f" Chunk size: {chunk_size} bytes")
print(f" Number of chunks: {num_chunks}")
def verify_dictzip(path: Path) -> bool:
"""Verify a dictzip file by reading its header and decompressing chunk by chunk."""
print(f"Verifying {path}...")
with open(path, 'rb') as f:
# Read gzip header
magic = f.read(2)
if magic != b'\x1f\x8b':
print(f" ERROR: Invalid gzip magic number")
return False
method = f.read(1)[0]
if method != 8:
print(f" ERROR: Unknown compression method: {method}")
return False
flags = f.read(1)[0]
if not (flags & 0x04):
print(f" ERROR: FEXTRA flag not set - not a dictzip file")
return False
f.read(4) # MTIME
f.read(1) # XFL
f.read(1) # OS
# Read extra field
xlen = struct.unpack('<H', f.read(2))[0]
extra = f.read(xlen)
# Parse extra field for RA subfield
pos = 0
found_ra = False
chlen = 0
chcnt = 0
chunk_sizes = []
while pos < len(extra):
si1 = extra[pos]
si2 = extra[pos + 1]
slen = struct.unpack('<H', extra[pos + 2:pos + 4])[0]
if si1 == ord('R') and si2 == ord('A'):
found_ra = True
ra_data = extra[pos + 4:pos + 4 + slen]
ver = struct.unpack('<H', ra_data[0:2])[0]
chlen = struct.unpack('<H', ra_data[2:4])[0]
chcnt = struct.unpack('<H', ra_data[4:6])[0]
print(f" Version: {ver}")
print(f" Chunk size: {chlen} bytes")
print(f" Chunk count: {chcnt}")
# Verify chunk sizes array
if len(ra_data) != 6 + 2 * chcnt:
print(f" ERROR: Chunk sizes array length mismatch")
return False
for i in range(chcnt):
size = struct.unpack('<H', ra_data[6 + 2*i:8 + 2*i])[0]
chunk_sizes.append(size)
print(f" Total compressed data: {sum(chunk_sizes):,} bytes")
break
pos += 4 + slen
if not found_ra:
print(f" ERROR: RA subfield not found - not a dictzip file")
return False
# Decompress chunk by chunk (like the firmware does)
data_start = f.tell()
decompressed_data = bytearray()
try:
for i, comp_size in enumerate(chunk_sizes):
f.seek(data_start + sum(chunk_sizes[:i]))
compressed_chunk = f.read(comp_size)
# Decompress using raw inflate (no zlib header)
decompressor = zlib.decompressobj(-15)
decompressed_chunk = decompressor.decompress(compressed_chunk)
decompressed_chunk += decompressor.flush()
decompressed_data.extend(decompressed_chunk)
print(f" Decompressed size: {len(decompressed_data):,} bytes")
# Verify CRC32 from trailer
f.seek(-8, 2) # Seek to 8 bytes before end
expected_crc = struct.unpack('<I', f.read(4))[0]
expected_size = struct.unpack('<I', f.read(4))[0]
actual_crc = zlib.crc32(bytes(decompressed_data)) & 0xffffffff
actual_size = len(decompressed_data) & 0xffffffff
if actual_crc != expected_crc:
print(f" ERROR: CRC mismatch: expected {expected_crc:08x}, got {actual_crc:08x}")
return False
if actual_size != expected_size:
print(f" ERROR: Size mismatch: expected {expected_size}, got {actual_size}")
return False
print(f" CRC32: {actual_crc:08x} (verified)")
print(f" Verification: PASSED")
return True
except Exception as e:
print(f" ERROR: Decompression failed: {e}")
return False
def main():
parser = argparse.ArgumentParser(
description='Recompress a dictzip file with a custom chunk size.',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Recompress with 16KB chunks (recommended for ESP32):
%(prog)s reader.dict reader.dict.dz --chunk-size 16384
# Recompress from existing .dz file:
%(prog)s reader.dict.dz reader_small.dict.dz --chunk-size 16384
# Verify a dictzip file:
%(prog)s --verify reader.dict.dz
""")
parser.add_argument('input', nargs='?', help='Input .dict or .dict.dz file')
parser.add_argument('output', nargs='?', help='Output .dict.dz file')
parser.add_argument('--chunk-size', '-c', type=int, default=16384,
help='Chunk size in bytes (default: 16384, i.e., 16KB)')
parser.add_argument('--compression-level', '-l', type=int, default=9,
choices=range(1, 10), metavar='1-9',
help='Compression level 1-9 (default: 9)')
parser.add_argument('--verify', '-v', action='store_true',
help='Verify a dictzip file instead of compressing')
args = parser.parse_args()
if args.verify:
if not args.input:
parser.error("Input file required for verification")
input_path = Path(args.input)
if not input_path.exists():
print(f"Error: File not found: {input_path}")
sys.exit(1)
success = verify_dictzip(input_path)
sys.exit(0 if success else 1)
if not args.input or not args.output:
parser.error("Both input and output files are required")
input_path = Path(args.input)
output_path = Path(args.output)
if not input_path.exists():
print(f"Error: Input file not found: {input_path}")
sys.exit(1)
if output_path.exists():
response = input(f"Output file {output_path} exists. Overwrite? [y/N] ")
if response.lower() != 'y':
print("Aborted.")
sys.exit(1)
# Read and decompress input if needed
data = read_input_file(input_path)
# Create new dictzip with specified chunk size
create_dictzip(data, output_path, args.chunk_size, args.compression_level)
# Verify the output
print()
if verify_dictzip(output_path):
print(f"\nSuccess! Created {output_path} with {args.chunk_size}-byte chunks.")
else:
print(f"\nError: Verification failed!")
sys.exit(1)
if __name__ == '__main__':
main()

View File

@@ -303,6 +303,86 @@ bool BookManager::deleteBook(const std::string& bookPath, bool isArchived) {
return true;
}
bool BookManager::clearBookCache(const std::string& bookPath, bool preserveProgress) {
Serial.printf("[%lu] [%s] Clearing cache for: %s (preserveProgress=%d)\n", millis(), LOG_TAG, bookPath.c_str(),
preserveProgress);
const std::string cacheDir = getCacheDir(bookPath);
if (cacheDir.empty()) {
Serial.printf("[%lu] [%s] No cache directory for unsupported format\n", millis(), LOG_TAG);
return true; // Nothing to clear, not an error
}
if (!SdMan.exists(cacheDir.c_str())) {
Serial.printf("[%lu] [%s] Cache directory doesn't exist: %s\n", millis(), LOG_TAG, cacheDir.c_str());
return true; // Nothing to clear, not an error
}
FsFile dir = SdMan.open(cacheDir.c_str());
if (!dir || !dir.isDirectory()) {
Serial.printf("[%lu] [%s] Failed to open cache directory\n", millis(), LOG_TAG);
if (dir) dir.close();
return false;
}
// Files to preserve (always keep bookmarks, optionally keep progress)
const auto shouldPreserve = [preserveProgress](const char* name) {
// Always preserve bookmarks
if (strcmp(name, "bookmarks.bin") == 0) return true;
// Optionally preserve progress
if (preserveProgress && strcmp(name, "progress.bin") == 0) return true;
return false;
};
int deletedCount = 0;
int failedCount = 0;
char name[128];
// First pass: delete files (not directories)
for (FsFile entry = dir.openNextFile(); entry; entry = dir.openNextFile()) {
entry.getName(name, sizeof(name));
const bool isDir = entry.isDirectory();
entry.close();
if (!isDir && !shouldPreserve(name)) {
std::string fullPath = cacheDir + "/" + name;
if (SdMan.remove(fullPath.c_str())) {
deletedCount++;
} else {
Serial.printf("[%lu] [%s] Failed to delete: %s\n", millis(), LOG_TAG, fullPath.c_str());
failedCount++;
}
}
}
dir.close();
// Second pass: delete subdirectories (like "sections/")
dir = SdMan.open(cacheDir.c_str());
if (dir && dir.isDirectory()) {
for (FsFile entry = dir.openNextFile(); entry; entry = dir.openNextFile()) {
entry.getName(name, sizeof(name));
const bool isDir = entry.isDirectory();
entry.close();
if (isDir) {
std::string fullPath = cacheDir + "/" + name;
if (SdMan.removeDir(fullPath.c_str())) {
deletedCount++;
Serial.printf("[%lu] [%s] Deleted subdirectory: %s\n", millis(), LOG_TAG, fullPath.c_str());
} else {
Serial.printf("[%lu] [%s] Failed to delete subdirectory: %s\n", millis(), LOG_TAG, fullPath.c_str());
failedCount++;
}
}
}
dir.close();
}
Serial.printf("[%lu] [%s] Cache cleared: %d items deleted, %d failed\n", millis(), LOG_TAG, deletedCount,
failedCount);
return failedCount == 0;
}
std::vector<std::string> BookManager::listArchivedBooks() {
std::vector<std::string> archivedBooks;

View File

@@ -57,6 +57,14 @@ class BookManager {
*/
static std::string getCacheDir(const std::string& bookPath);
/**
* Clear cache for a single book, optionally preserving reading progress
* @param bookPath Full path to the book file
* @param preserveProgress If true, keeps progress.bin and bookmarks.bin
* @return true if successful (or if no cache exists)
*/
static bool clearBookCache(const std::string& bookPath, bool preserveProgress);
private:
// Extract filename from a full path
static std::string getFilename(const std::string& path);

View File

@@ -23,7 +23,7 @@ void readAndValidate(FsFile& file, uint8_t& member, const uint8_t maxValue) {
namespace {
constexpr uint8_t SETTINGS_FILE_VERSION = 1;
// Increment this when adding new persisted settings fields
constexpr uint8_t SETTINGS_COUNT = 29; // 28 + bezelCompensationEdge
constexpr uint8_t SETTINGS_COUNT = 30; // 29 + fadingFix
constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin";
} // namespace
@@ -72,6 +72,8 @@ bool CrossPointSettings::saveToFile() const {
serialization::writePod(outputFile, bezelCompensation);
// Which physical edge needs bezel compensation
serialization::writePod(outputFile, bezelCompensationEdge);
// Sunlight fading fix
serialization::writePod(outputFile, fadingFix);
// New fields added at end for backward compatibility
outputFile.close();
@@ -182,6 +184,9 @@ bool CrossPointSettings::loadFromFile() {
// Which physical edge needs bezel compensation
readAndValidate(inputFile, bezelCompensationEdge, BEZEL_EDGE_COUNT);
if (++settingsRead >= fileSettingsCount) break;
// Sunlight fading fix
serialization::readPod(inputFile, fadingFix);
if (++settingsRead >= fileSettingsCount) break;
// New fields added at end for backward compatibility
} while (false);

View File

@@ -144,6 +144,8 @@ class CrossPointSettings {
uint8_t hideBatteryPercentage = HIDE_NEVER;
// Long-press chapter skip on side buttons
uint8_t longPressChapterSkip = 1;
// Sunlight fading compensation (0 = off, 1 = on)
uint8_t fadingFix = 0;
// System-wide display contrast (0 = normal, 1 = high)
uint8_t displayContrast = 0;
// Bezel compensation - extra margin for physical screen edge defects (0-10px)
@@ -155,6 +157,11 @@ class CrossPointSettings {
// Pinned list name (empty = none pinned)
char pinnedListName[64] = "";
// Quick menu item order (indices 0-4 representing the 5 menu items)
// Maps to QuickMenuAction enum: 0=Dictionary, 1=Bookmark, 2=ClearCache, 3=Orientation, 4=Settings
// Default order: Bookmark(1), Dictionary(0), Orientation(3), Settings(4), ClearCache(2)
uint8_t quickMenuOrder[5] = {1, 0, 3, 4, 2};
~CrossPointSettings() = default;
// Get singleton instance

View File

@@ -2,103 +2,77 @@
#include "CrossPointSettings.h"
decltype(InputManager::BTN_BACK) MappedInputManager::mapButton(const Button button) const {
namespace {
using ButtonIndex = uint8_t;
struct FrontLayoutMap {
ButtonIndex back;
ButtonIndex confirm;
ButtonIndex left;
ButtonIndex right;
};
struct SideLayoutMap {
ButtonIndex pageBack;
ButtonIndex pageForward;
};
// Order matches CrossPointSettings::FRONT_BUTTON_LAYOUT.
constexpr FrontLayoutMap kFrontLayouts[] = {
{HalGPIO::BTN_BACK, HalGPIO::BTN_CONFIRM, HalGPIO::BTN_LEFT, HalGPIO::BTN_RIGHT},
{HalGPIO::BTN_LEFT, HalGPIO::BTN_RIGHT, HalGPIO::BTN_BACK, HalGPIO::BTN_CONFIRM},
{HalGPIO::BTN_CONFIRM, HalGPIO::BTN_LEFT, HalGPIO::BTN_BACK, HalGPIO::BTN_RIGHT},
{HalGPIO::BTN_BACK, HalGPIO::BTN_CONFIRM, HalGPIO::BTN_RIGHT, HalGPIO::BTN_LEFT},
};
// Order matches CrossPointSettings::SIDE_BUTTON_LAYOUT.
constexpr SideLayoutMap kSideLayouts[] = {
{HalGPIO::BTN_UP, HalGPIO::BTN_DOWN},
{HalGPIO::BTN_DOWN, HalGPIO::BTN_UP},
};
} // namespace
bool MappedInputManager::mapButton(const Button button, bool (HalGPIO::*fn)(uint8_t) const) const {
const auto frontLayout = static_cast<CrossPointSettings::FRONT_BUTTON_LAYOUT>(SETTINGS.frontButtonLayout);
const auto sideLayout = static_cast<CrossPointSettings::SIDE_BUTTON_LAYOUT>(SETTINGS.sideButtonLayout);
const auto& front = kFrontLayouts[frontLayout];
const auto& side = kSideLayouts[sideLayout];
switch (button) {
case Button::Back:
switch (frontLayout) {
case CrossPointSettings::LEFT_RIGHT_BACK_CONFIRM:
return InputManager::BTN_LEFT;
case CrossPointSettings::LEFT_BACK_CONFIRM_RIGHT:
return InputManager::BTN_CONFIRM;
case CrossPointSettings::BACK_CONFIRM_LEFT_RIGHT:
/* fall through */
case CrossPointSettings::BACK_CONFIRM_RIGHT_LEFT:
/* fall through */
default:
return InputManager::BTN_BACK;
}
return (gpio.*fn)(front.back);
case Button::Confirm:
switch (frontLayout) {
case CrossPointSettings::LEFT_RIGHT_BACK_CONFIRM:
return InputManager::BTN_RIGHT;
case CrossPointSettings::LEFT_BACK_CONFIRM_RIGHT:
return InputManager::BTN_LEFT;
case CrossPointSettings::BACK_CONFIRM_LEFT_RIGHT:
/* fall through */
case CrossPointSettings::BACK_CONFIRM_RIGHT_LEFT:
/* fall through */
default:
return InputManager::BTN_CONFIRM;
}
return (gpio.*fn)(front.confirm);
case Button::Left:
switch (frontLayout) {
case CrossPointSettings::LEFT_RIGHT_BACK_CONFIRM:
/* fall through */
case CrossPointSettings::LEFT_BACK_CONFIRM_RIGHT:
return InputManager::BTN_BACK;
case CrossPointSettings::BACK_CONFIRM_RIGHT_LEFT:
return InputManager::BTN_RIGHT;
case CrossPointSettings::BACK_CONFIRM_LEFT_RIGHT:
/* fall through */
default:
return InputManager::BTN_LEFT;
}
return (gpio.*fn)(front.left);
case Button::Right:
switch (frontLayout) {
case CrossPointSettings::LEFT_RIGHT_BACK_CONFIRM:
return InputManager::BTN_CONFIRM;
case CrossPointSettings::BACK_CONFIRM_RIGHT_LEFT:
return InputManager::BTN_LEFT;
case CrossPointSettings::BACK_CONFIRM_LEFT_RIGHT:
/* fall through */
case CrossPointSettings::LEFT_BACK_CONFIRM_RIGHT:
/* fall through */
default:
return InputManager::BTN_RIGHT;
}
return (gpio.*fn)(front.right);
case Button::Up:
return InputManager::BTN_UP;
return (gpio.*fn)(HalGPIO::BTN_UP);
case Button::Down:
return InputManager::BTN_DOWN;
return (gpio.*fn)(HalGPIO::BTN_DOWN);
case Button::Power:
return InputManager::BTN_POWER;
return (gpio.*fn)(HalGPIO::BTN_POWER);
case Button::PageBack:
switch (sideLayout) {
case CrossPointSettings::NEXT_PREV:
return InputManager::BTN_DOWN;
case CrossPointSettings::PREV_NEXT:
/* fall through */
default:
return InputManager::BTN_UP;
}
return (gpio.*fn)(side.pageBack);
case Button::PageForward:
switch (sideLayout) {
case CrossPointSettings::NEXT_PREV:
return InputManager::BTN_UP;
case CrossPointSettings::PREV_NEXT:
/* fall through */
default:
return InputManager::BTN_DOWN;
}
return (gpio.*fn)(side.pageForward);
}
return InputManager::BTN_BACK;
return false;
}
bool MappedInputManager::wasPressed(const Button button) const { return inputManager.wasPressed(mapButton(button)); }
bool MappedInputManager::wasPressed(const Button button) const { return mapButton(button, &HalGPIO::wasPressed); }
bool MappedInputManager::wasReleased(const Button button) const { return inputManager.wasReleased(mapButton(button)); }
bool MappedInputManager::wasReleased(const Button button) const { return mapButton(button, &HalGPIO::wasReleased); }
bool MappedInputManager::isPressed(const Button button) const { return inputManager.isPressed(mapButton(button)); }
bool MappedInputManager::isPressed(const Button button) const { return mapButton(button, &HalGPIO::isPressed); }
bool MappedInputManager::wasAnyPressed() const { return inputManager.wasAnyPressed(); }
bool MappedInputManager::wasAnyPressed() const { return gpio.wasAnyPressed(); }
bool MappedInputManager::wasAnyReleased() const { return inputManager.wasAnyReleased(); }
bool MappedInputManager::wasAnyReleased() const { return gpio.wasAnyReleased(); }
unsigned long MappedInputManager::getHeldTime() const { return inputManager.getHeldTime(); }
unsigned long MappedInputManager::getHeldTime() const { return gpio.getHeldTime(); }
MappedInputManager::Labels MappedInputManager::mapLabels(const char* back, const char* confirm, const char* previous,
const char* next) const {

View File

@@ -1,6 +1,6 @@
#pragma once
#include <InputManager.h>
#include <HalGPIO.h>
class MappedInputManager {
public:
@@ -13,7 +13,7 @@ class MappedInputManager {
const char* btn4;
};
explicit MappedInputManager(InputManager& inputManager) : inputManager(inputManager) {}
explicit MappedInputManager(HalGPIO& gpio) : gpio(gpio) {}
bool wasPressed(Button button) const;
bool wasReleased(Button button) const;
@@ -24,6 +24,7 @@ class MappedInputManager {
Labels mapLabels(const char* back, const char* confirm, const char* previous, const char* next) const;
private:
InputManager& inputManager;
decltype(InputManager::BTN_BACK) mapButton(Button button) const;
HalGPIO& gpio;
bool mapButton(Button button, bool (HalGPIO::*fn)(uint8_t) const) const;
};

View File

@@ -52,6 +52,13 @@ void RecentBooksStore::clearAll() {
Serial.printf("[%lu] [RBS] Cleared all recent books\n", millis());
}
void RecentBooksStore::clearFromMemory() {
const size_t count = recentBooks.size();
recentBooks.clear();
recentBooks.shrink_to_fit(); // Actually free the vector capacity
Serial.printf("[%lu] [RBS] Cleared %d recent books from memory (not saved)\n", millis(), count);
}
bool RecentBooksStore::saveToFile() const {
// Make sure the directory exists
SdMan.mkdir("/.crosspoint");

View File

@@ -29,9 +29,14 @@ class RecentBooksStore {
// Returns true if the book was found and removed
bool removeBook(const std::string& path);
// Clear all recent books from the list
// Clear all recent books from the list (and save to file)
void clearAll();
// Clear recent books from memory without saving to file
// Used to free memory when entering modes that don't need this data (e.g., File Transfer)
// Call loadFromFile() to restore the data when needed again
void clearFromMemory();
// Get the list of recent books (most recent first)
const std::vector<RecentBook>& getBooks() const { return recentBooks; }

View File

@@ -154,7 +154,7 @@ void SleepActivity::renderDefaultSleepScreen() const {
renderer.invertScreen();
}
renderer.displayBuffer(EInkDisplay::HALF_REFRESH);
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
}
void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap, const std::string& bmpPath) const {
@@ -269,7 +269,7 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap, const std::str
renderer.invertScreen();
}
renderer.displayBuffer(EInkDisplay::HALF_REFRESH);
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
if (hasGreyscale) {
// Grayscale LSB pass
@@ -400,7 +400,7 @@ void SleepActivity::renderCoverSleepScreen() const {
void SleepActivity::renderBlankSleepScreen() const {
renderer.clearScreen();
renderer.displayBuffer(EInkDisplay::HALF_REFRESH);
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
}
std::string SleepActivity::getEdgeCachePath(const std::string& bmpPath) {

View File

@@ -39,12 +39,14 @@ void DictionaryMenuActivity::onEnter() {
void DictionaryMenuActivity::onExit() {
Activity::onExit();
// Wait until not rendering to delete task
// Take mutex to ensure task isn't in render()
xSemaphoreTake(renderingMutex, portMAX_DELAY);
if (displayTaskHandle) {
// Task is definitely not in render() because we hold the mutex.
// Delete the task - it will never run again.
vTaskDelete(displayTaskHandle);
displayTaskHandle = nullptr;
vTaskDelay(10 / portTICK_PERIOD_MS); // Let idle task free stack
vTaskDelay(10 / portTICK_PERIOD_MS); // Let idle task free the task's stack
}
vSemaphoreDelete(renderingMutex);
renderingMutex = nullptr;
@@ -56,7 +58,10 @@ void DictionaryMenuActivity::loop() {
// Handle back button - cancel
// Use wasReleased to consume the full button event
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
onCancel();
// Copy callback before invoking - the callback may destroy this object
// (and thus the original std::function) while still executing
auto callback = onCancel;
callback();
return;
}
@@ -64,7 +69,9 @@ void DictionaryMenuActivity::loop() {
// Use wasReleased to consume the full button event
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
const DictionaryMode mode = (selectedIndex == 0) ? DictionaryMode::SELECT_FROM_SCREEN : DictionaryMode::ENTER_WORD;
onModeSelected(mode);
// Copy callback before invoking - the callback may destroy this object
auto callback = onModeSelected;
callback(mode);
return;
}
@@ -100,7 +107,7 @@ void DictionaryMenuActivity::displayTaskLoop() {
void DictionaryMenuActivity::render() const {
renderer.clearScreen();
// Get margins using same pattern as reader + button hint space
// Get margins with button hint space for all orientations
int marginTop, marginRight, marginBottom, marginLeft;
getDictionaryContentMargins(renderer, &marginTop, &marginRight, &marginBottom, &marginLeft);
@@ -112,11 +119,11 @@ void DictionaryMenuActivity::render() const {
const int contentWidth = pageWidth - marginLeft - marginRight;
const int contentHeight = pageHeight - marginTop - marginBottom;
// Draw header with top margin
renderer.drawCenteredText(UI_12_FONT_ID, marginTop + 15, "Dictionary", true, EpdFontFamily::BOLD);
// Draw header
renderer.drawCenteredText(UI_12_FONT_ID, marginTop + 5, "Dictionary", true, EpdFontFamily::BOLD);
// Draw subtitle
renderer.drawCenteredText(UI_10_FONT_ID, marginTop + 50, "Look up a word");
renderer.drawCenteredText(UI_10_FONT_ID, marginTop + 30, "Look up a word");
// Draw menu items centered in content area
constexpr int itemHeight = 50; // Height for each menu item (including description)
@@ -137,9 +144,13 @@ void DictionaryMenuActivity::render() const {
renderer.drawText(SMALL_FONT_ID, marginLeft + 20, itemY + 22, MENU_DESCRIPTIONS[i], /*black=*/!isSelected);
}
// Draw help text at bottom
const auto labels = mappedInput.mapLabels("\xc2\xab Back", "Select", "", "");
// Draw front button hints (Prev/Next for list navigation)
const auto labels = mappedInput.mapLabels("\xc2\xab Back", "Select", "< Prev", "Next >");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
// Draw side button hints for up/down navigation (standard style with borders, always shown since list wraps)
// Top button = up (prev), Bottom button = down (next)
renderer.drawSideButtonHints(UI_10_FONT_ID, "<", ">");
renderer.displayBuffer();
}

View File

@@ -3,6 +3,10 @@
#include <DictHtmlParser.h>
#include <GfxRenderer.h>
#include <algorithm>
#include <cctype>
#include <cstring>
#include "DictionaryMargins.h"
#include "MappedInputManager.h"
#include "fontIds.h"
@@ -15,22 +19,28 @@ void DictionaryResultActivity::taskTrampoline(void* param) {
void DictionaryResultActivity::onEnter() {
Activity::onEnter();
Serial.printf("[DICT-DBG] DictionaryResult onEnter, defLen=%u\n", rawDefinition.length());
renderingMutex = xSemaphoreCreateMutex();
currentPage = 0;
// Process definition for display
if (!notFound) {
Serial.printf("[DICT-DBG] Starting paginateDefinition...\n");
paginateDefinition();
Serial.printf("[DICT-DBG] Pagination done, %u pages\n", pages.size());
}
updateRequired = true;
Serial.printf("[DICT-DBG] Creating display task...\n");
xTaskCreate(&DictionaryResultActivity::taskTrampoline, "DictResultTask",
4096, // Stack size
this, // Parameters
1, // Priority
&displayTaskHandle // Task handle
);
Serial.printf("[DICT-DBG] Task created\n");
}
void DictionaryResultActivity::onExit() {
@@ -61,31 +71,58 @@ void DictionaryResultActivity::loop() {
}
// Handle page navigation - use orientation-aware PageBack/PageForward buttons
if (!notFound && pages.size() > 1) {
if (!notFound && !pages.empty()) {
const bool prevPressed = mappedInput.wasPressed(MappedInputManager::Button::PageBack) ||
mappedInput.wasPressed(MappedInputManager::Button::Left);
const bool nextPressed = mappedInput.wasPressed(MappedInputManager::Button::PageForward) ||
mappedInput.wasPressed(MappedInputManager::Button::Right);
if (prevPressed && currentPage > 0) {
currentPage--;
updateRequired = true;
} else if (nextPressed && currentPage < static_cast<int>(pages.size()) - 1) {
currentPage++;
updateRequired = true;
if (prevPressed) {
if (currentPage > 0) {
// Navigate within cached pages
currentPage--;
updateRequired = true;
} else if (firstPageNumber > 1) {
// At first cached page but earlier pages exist - re-parse to get them
const int targetPage = firstPageNumber - 1; // Go to the page before current first
Serial.printf("[DICT-DBG] Re-parsing to reach page %d\n", targetPage);
reparseToPage(targetPage);
updateRequired = true;
}
} else if (nextPressed) {
// Check if we can navigate to existing cached page
if (currentPage < static_cast<int>(pages.size()) - 1) {
currentPage++;
updateRequired = true;
} else if (hasMoreContent) {
// At end of cached pages but more content available - parse next chunk
Serial.printf("[DICT-DBG] Parsing next chunk on navigation (page %d)\n", currentPage);
parseNextChunk();
// After parsing (and possible page trimming), check if we can advance
// Note: Don't compare page counts - trimming may keep size the same while adding new content
if (currentPage < static_cast<int>(pages.size()) - 1) {
currentPage++;
updateRequired = true;
}
}
// else: at true end of content, do nothing
}
}
}
void DictionaryResultActivity::paginateDefinition() {
pages.clear();
parsePosition = 0;
hasMoreContent = false;
firstPageNumber = 1;
if (rawDefinition.empty()) {
notFound = true;
return;
}
// Get margins using same pattern as reader + button hint space
// Get margins with button hint space for all orientations
int marginTop, marginRight, marginBottom, marginLeft;
getDictionaryContentMargins(renderer, &marginTop, &marginRight, &marginBottom, &marginLeft);
@@ -93,20 +130,59 @@ void DictionaryResultActivity::paginateDefinition() {
const auto pageHeight = renderer.getScreenHeight();
// Calculate available area for text (must match render() layout)
constexpr int headerHeight = 80; // Space for word and header (relative to marginTop)
constexpr int footerHeight = 30; // Space for page indicator
constexpr int headerHeight = 55; // Space for "Dictionary" + lookup word
constexpr int footerHeight = 20; // Space for page indicator
const int textMargin = marginLeft + 10;
const int textWidth = pageWidth - textMargin - marginRight - 10;
const int textHeight = pageHeight - marginTop - marginBottom - headerHeight - footerHeight;
const int lineHeight = renderer.getLineHeight(UI_10_FONT_ID);
const int linesPerPage = textHeight / lineHeight;
// Collect all TextBlocks from the HTML parser
// For chunked parsing, we estimate how much HTML to parse at a time
// Roughly: each line is ~40-60 chars, so one page ≈ linesPerPage * 60 bytes of text
// With HTML overhead, multiply by ~2, plus buffer for finding break points
constexpr size_t CHUNK_SIZE_BASE = 1500; // Base chunk size
const size_t chunkSize = std::max(CHUNK_SIZE_BASE, static_cast<size_t>(linesPerPage * 120));
Serial.printf("[DICT-DBG] Chunked parsing: defLen=%u, chunkSize=%u, linesPerPage=%d\n", rawDefinition.length(),
chunkSize, linesPerPage);
// Determine how much to parse for first page
size_t parseEnd;
if (rawDefinition.length() <= chunkSize) {
// Small definition - parse it all
parseEnd = rawDefinition.length();
hasMoreContent = false;
} else {
// Large definition - find a good break point
parseEnd = findHtmlBreakPoint(rawDefinition, chunkSize / 2, chunkSize);
hasMoreContent = (parseEnd < rawDefinition.length());
}
// Extract the chunk to parse
std::string chunk = rawDefinition.substr(0, parseEnd);
parsePosition = parseEnd;
Serial.printf("[DICT-DBG] Parsing first chunk: 0-%u of %u, hasMore=%d\n", parseEnd, rawDefinition.length(),
hasMoreContent);
// Parse this chunk into TextBlocks
std::vector<std::shared_ptr<TextBlock>> allBlocks;
DictHtmlParser::parse(rawDefinition, UI_10_FONT_ID, renderer, textWidth,
DictHtmlParser::parse(chunk, UI_10_FONT_ID, renderer, textWidth,
[&allBlocks](std::shared_ptr<TextBlock> block) { allBlocks.push_back(block); });
Serial.printf("[DICT-DBG] First chunk parsed, %u TextBlocks\n", allBlocks.size());
if (allBlocks.empty()) {
notFound = true;
// Check if there's more to parse - maybe first chunk had no displayable content
if (hasMoreContent) {
// Try parsing more
parseNextChunk();
if (pages.empty()) {
notFound = true;
}
} else {
notFound = true;
}
return;
}
@@ -131,6 +207,185 @@ void DictionaryResultActivity::paginateDefinition() {
if (!currentPageBlocks.empty()) {
pages.push_back(currentPageBlocks);
}
Serial.printf("[DICT-DBG] Initial pagination: %u pages\n", pages.size());
}
size_t DictionaryResultActivity::findHtmlBreakPoint(const std::string& html, size_t searchStart, size_t maxPos) {
// Search backwards from maxPos for good HTML break points
// Priority: </li>, </p>, </ol>, </ul>, </div> then any '>' then whitespace
if (maxPos >= html.length()) {
return html.length();
}
// Clamp searchStart to not exceed maxPos
if (searchStart > maxPos) {
searchStart = maxPos;
}
// Search for closing block tags (best break points)
const char* closingTags[] = {"</li>", "</p>", "</ol>", "</ul>", "</div>", "</dd>", "</dt>"};
size_t bestBreak = std::string::npos;
for (const char* tag : closingTags) {
size_t pos = html.rfind(tag, maxPos);
if (pos != std::string::npos && pos >= searchStart) {
// Found a closing tag - break after it
size_t breakAfter = pos + strlen(tag);
if (bestBreak == std::string::npos || breakAfter > bestBreak) {
bestBreak = breakAfter;
}
}
}
if (bestBreak != std::string::npos) {
return bestBreak;
}
// Fallback: search for any '>' (end of tag)
size_t tagEnd = html.rfind('>', maxPos);
if (tagEnd != std::string::npos && tagEnd >= searchStart) {
return tagEnd + 1;
}
// Last resort: search for whitespace
for (size_t i = maxPos; i >= searchStart && i != std::string::npos; i--) {
if (std::isspace(static_cast<unsigned char>(html[i]))) {
return i + 1;
}
if (i == 0) break;
}
// No good break point found - use maxPos
return maxPos;
}
void DictionaryResultActivity::parseNextChunk() {
if (!hasMoreContent || parsePosition >= rawDefinition.length()) {
hasMoreContent = false;
return;
}
Serial.printf("[DICT-DBG] parseNextChunk starting at position %u of %u\n", parsePosition, rawDefinition.length());
// Get margins with button hint space for all orientations
int marginTop, marginRight, marginBottom, marginLeft;
getDictionaryContentMargins(renderer, &marginTop, &marginRight, &marginBottom, &marginLeft);
const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = renderer.getScreenHeight();
// Calculate text area dimensions (must match paginateDefinition and render)
constexpr int headerHeight = 55; // Space for "Dictionary" + lookup word
constexpr int footerHeight = 20; // Space for page indicator
const int textMargin = marginLeft + 10;
const int textWidth = pageWidth - textMargin - marginRight - 10;
const int textHeight = pageHeight - marginTop - marginBottom - headerHeight - footerHeight;
const int lineHeight = renderer.getLineHeight(UI_10_FONT_ID);
const int linesPerPage = textHeight / lineHeight;
// Chunk size estimation (same as paginateDefinition)
constexpr size_t CHUNK_SIZE_BASE = 1500;
const size_t chunkSize = std::max(CHUNK_SIZE_BASE, static_cast<size_t>(linesPerPage * 120));
// Determine parse range for this chunk
size_t parseStart = parsePosition;
size_t parseEnd;
if (parsePosition + chunkSize >= rawDefinition.length()) {
// This will be the last chunk
parseEnd = rawDefinition.length();
hasMoreContent = false;
} else {
// Find a good break point
parseEnd = findHtmlBreakPoint(rawDefinition, parsePosition + chunkSize / 2, parsePosition + chunkSize);
hasMoreContent = (parseEnd < rawDefinition.length());
}
// Extract the chunk to parse
std::string chunk = rawDefinition.substr(parseStart, parseEnd - parseStart);
parsePosition = parseEnd;
Serial.printf("[DICT-DBG] Parsing chunk %u-%u, hasMore=%d\n", parseStart, parseEnd, hasMoreContent);
// Parse this chunk into TextBlocks
std::vector<std::shared_ptr<TextBlock>> allBlocks;
DictHtmlParser::parse(chunk, UI_10_FONT_ID, renderer, textWidth,
[&allBlocks](std::shared_ptr<TextBlock> block) { allBlocks.push_back(block); });
Serial.printf("[DICT-DBG] Chunk parsed, %u TextBlocks\n", allBlocks.size());
if (allBlocks.empty()) {
// No content in this chunk - try parsing more if available
if (hasMoreContent) {
parseNextChunk();
}
return;
}
// Paginate: group TextBlocks into pages based on available height
std::vector<std::shared_ptr<TextBlock>> currentPageBlocks;
int currentY = 0;
for (const auto& block : allBlocks) {
if (currentY + lineHeight > textHeight && !currentPageBlocks.empty()) {
// Page is full, start new page
pages.push_back(currentPageBlocks);
currentPageBlocks.clear();
currentY = 0;
}
currentPageBlocks.push_back(block);
currentY += lineHeight;
}
// Add remaining blocks as last page
if (!currentPageBlocks.empty()) {
pages.push_back(currentPageBlocks);
}
// Trim old pages if we exceed the limit to prevent memory exhaustion
while (static_cast<int>(pages.size()) > MAX_CACHED_PAGES && currentPage > 0) {
// Remove the oldest page and adjust indices
pages.erase(pages.begin());
currentPage--;
firstPageNumber++;
Serial.printf("[DICT-DBG] Trimmed old page, firstPageNumber now %d\n", firstPageNumber);
}
Serial.printf("[DICT-DBG] After chunk: %u cached pages (pages %d-%d)\n", pages.size(), firstPageNumber,
firstPageNumber + static_cast<int>(pages.size()) - 1);
}
void DictionaryResultActivity::reparseToPage(int targetPageNumber) {
// Re-parse from the beginning to reach an earlier page that was trimmed
// This allows backward navigation through the entire definition
Serial.printf("[DICT-DBG] reparseToPage: target=%d, clearing and re-parsing\n", targetPageNumber);
// Clear current state and start fresh
pages.clear();
parsePosition = 0;
firstPageNumber = 1;
hasMoreContent = !rawDefinition.empty();
// Parse chunks until we have the target page
while (hasMoreContent && firstPageNumber + static_cast<int>(pages.size()) - 1 < targetPageNumber) {
parseNextChunk();
}
// Now position currentPage to show the target page
if (targetPageNumber >= firstPageNumber && targetPageNumber < firstPageNumber + static_cast<int>(pages.size())) {
currentPage = targetPageNumber - firstPageNumber;
} else {
// Target page doesn't exist (definition is shorter than expected)
currentPage = static_cast<int>(pages.size()) - 1;
if (currentPage < 0) currentPage = 0;
}
Serial.printf("[DICT-DBG] reparseToPage done: currentPage=%d, firstPageNumber=%d, pages=%u\n", currentPage,
firstPageNumber, pages.size());
}
void DictionaryResultActivity::displayTaskLoop() {
@@ -148,17 +403,15 @@ void DictionaryResultActivity::displayTaskLoop() {
void DictionaryResultActivity::render() const {
renderer.clearScreen();
// Get margins using same pattern as reader + button hint space
// Get margins with button hint space for all orientations
int marginTop, marginRight, marginBottom, marginLeft;
getDictionaryContentMargins(renderer, &marginTop, &marginRight, &marginBottom, &marginLeft);
const auto pageHeight = renderer.getScreenHeight();
// Draw header with top margin
renderer.drawCenteredText(UI_12_FONT_ID, marginTop + 15, "Dictionary", true, EpdFontFamily::BOLD);
// Draw word being looked up (bold)
renderer.drawCenteredText(UI_12_FONT_ID, marginTop + 50, lookupWord.c_str(), true, EpdFontFamily::BOLD);
// Draw header - "Dictionary" title and lookup word
renderer.drawCenteredText(UI_12_FONT_ID, marginTop + 5, "Dictionary", true, EpdFontFamily::BOLD);
renderer.drawCenteredText(UI_12_FONT_ID, marginTop + 30, lookupWord.c_str(), true, EpdFontFamily::BOLD);
if (notFound) {
// Show not found message (centered in content area)
@@ -166,10 +419,12 @@ void DictionaryResultActivity::render() const {
renderer.drawCenteredText(UI_10_FONT_ID, centerY, "Word not found");
} else if (!pages.empty()) {
// Draw definition text using TextBlocks with rich formatting
const int textStartY = marginTop + 80;
constexpr int headerHeight = 55; // Space for "Dictionary" + lookup word
constexpr int footerHeight = 20; // Space for page indicator
const int textStartY = marginTop + headerHeight;
const int textMargin = marginLeft + 10;
const int lineHeight = renderer.getLineHeight(UI_10_FONT_ID);
const int bottomLimit = pageHeight - marginBottom - 25; // Leave space for page indicator
const int bottomLimit = pageHeight - marginBottom - footerHeight;
const auto& pageBlocks = pages[currentPage];
int y = textStartY;
@@ -181,19 +436,36 @@ void DictionaryResultActivity::render() const {
y += lineHeight;
}
// Draw page indicator if multiple pages
if (pages.size() > 1) {
char pageIndicator[32];
snprintf(pageIndicator, sizeof(pageIndicator), "Page %d of %d", currentPage + 1, static_cast<int>(pages.size()));
renderer.drawCenteredText(SMALL_FONT_ID, pageHeight - marginBottom - 5, pageIndicator);
// Draw page indicator if multiple pages or more content available
const bool hasMultiplePages = pages.size() > 1 || hasMoreContent || firstPageNumber > 1;
if (hasMultiplePages) {
char pageIndicator[48];
const int displayPageNum = firstPageNumber + currentPage;
const int lastKnownPage = firstPageNumber + static_cast<int>(pages.size()) - 1;
if (hasMoreContent) {
snprintf(pageIndicator, sizeof(pageIndicator), "Page %d of %d+", displayPageNum, lastKnownPage);
} else {
snprintf(pageIndicator, sizeof(pageIndicator), "Page %d of %d", displayPageNum, lastKnownPage);
}
renderer.drawCenteredText(SMALL_FONT_ID, pageHeight - marginBottom - 15, pageIndicator);
}
}
// Draw button hints
const char* leftHint = (pages.size() > 1 && currentPage > 0) ? "< Prev" : "";
const char* rightHint = (pages.size() > 1 && currentPage < static_cast<int>(pages.size()) - 1) ? "Next >" : "";
// Show navigation hints when there are multiple pages or more content to load
// canGoBack is true if we have previous cached pages OR if earlier pages were trimmed
const bool canGoBack = currentPage > 0 || firstPageNumber > 1;
const bool canGoForward = currentPage < static_cast<int>(pages.size()) - 1 || hasMoreContent;
const char* leftHint = canGoBack ? "< Prev" : "";
const char* rightHint = canGoForward ? "Next >" : "";
const auto labels = mappedInput.mapLabels("\xc2\xab Back", "Search", leftHint, rightHint);
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
// Draw side button hints for page navigation (rotated 90° CW: ">" appears as "^", "<" as "v")
// Top physical button = PageBack (prev), Bottom physical button = PageForward (next)
const char* sideTopHint = canGoBack ? "<" : "";
const char* sideBottomHint = canGoForward ? ">" : "";
renderer.drawSideButtonHints(UI_10_FONT_ID, sideTopHint, sideBottomHint);
renderer.displayBuffer();
}

View File

@@ -26,14 +26,24 @@ class DictionaryResultActivity final : public Activity {
const std::function<void()> onSearchAnother;
// Pagination - each page contains TextBlocks with styled text
// We limit cached pages to prevent memory exhaustion on long definitions
static constexpr int MAX_CACHED_PAGES = 4;
std::vector<std::vector<std::shared_ptr<TextBlock>>> pages;
int currentPage = 0;
int currentPage = 0; // Index into pages vector
int firstPageNumber = 1; // The page number of pages[0] (1-based for display)
bool notFound = false;
// Chunked parsing state - parse definition on-demand as user navigates
size_t parsePosition = 0; // Current position in rawDefinition HTML
bool hasMoreContent = false; // True if more HTML remains to parse
static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop();
void render() const;
void paginateDefinition();
void parseNextChunk();
void reparseToPage(int targetPageNumber); // Re-parse from beginning to reach earlier page
static size_t findHtmlBreakPoint(const std::string& html, size_t searchStart, size_t maxPos);
public:
/**

View File

@@ -235,14 +235,14 @@ void DictionarySearchActivity::displayTaskLoop() {
void DictionarySearchActivity::render() const {
renderer.clearScreen();
// Get margins using same pattern as reader + button hint space
// Get margins with button hint space for all orientations
int marginTop, marginRight, marginBottom, marginLeft;
getDictionaryContentMargins(renderer, &marginTop, &marginRight, &marginBottom, &marginLeft);
const auto pageHeight = renderer.getScreenHeight();
// Draw header with top margin
renderer.drawCenteredText(UI_12_FONT_ID, marginTop + 15, "Dictionary", true, EpdFontFamily::BOLD);
// Draw header
renderer.drawCenteredText(UI_12_FONT_ID, marginTop + 5, "Dictionary", true, EpdFontFamily::BOLD);
if (isSearching) {
// Show searching status with word and animated ellipsis

View File

@@ -1,7 +1,7 @@
#include "EpubWordSelectionActivity.h"
#include <EInkDisplay.h>
#include <GfxRenderer.h>
#include <HalDisplay.h>
#include <algorithm>
#include <cctype>
@@ -223,10 +223,12 @@ void EpubWordSelectionActivity::displayTaskLoop() {
void EpubWordSelectionActivity::render() const {
renderer.clearScreen();
// Get margins using same pattern as reader + button hint space
// Get margins with button hint space for all orientations
int marginTop, marginRight, marginBottom, marginLeft;
getDictionaryContentMargins(renderer, &marginTop, &marginRight, &marginBottom, &marginLeft);
const auto screenHeight = renderer.getScreenHeight();
// Draw the page content (uses pre-calculated offsets from reader)
// The page already has proper offsets, so render as-is
if (page) {
@@ -246,14 +248,20 @@ void EpubWordSelectionActivity::render() const {
renderer.drawText(fontId, selected.x, selected.y, selected.text.c_str(), false, selected.style);
}
// Draw instruction text - position it just above the front button area
const auto screenHeight = renderer.getScreenHeight();
// Draw instruction text - always show, positioned just above the front button area
renderer.drawCenteredText(SMALL_FONT_ID, screenHeight - marginBottom - 10,
"Navigate with arrows, select with confirm");
// Draw button hints
const auto labels = mappedInput.mapLabels("\xc2\xab Cancel", "Select", "< >", "");
// Draw button hints with proper left/right navigation labels
const auto labels = mappedInput.mapLabels("\xc2\xab Cancel", "Select", "< Prev", "Next >");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer(EInkDisplay::FAST_REFRESH);
// Draw side button hints for up/down line navigation (no border, small font)
// Top physical button = Up (prev line), Bottom physical button = Down (next line)
const int lastLine = findLineForWordIndex(static_cast<int>(allWords.size()) - 1);
const char* sideTopHint = (currentLineIndex > 0) ? "UP" : "";
const char* sideBottomHint = (currentLineIndex < lastLine) ? "DOWN" : "";
renderer.drawSideButtonHints(SMALL_FONT_ID, sideTopHint, sideBottomHint, false); // No border
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
}

View File

@@ -503,22 +503,29 @@ void MyLibraryActivity::executeAction() {
} else if (selectedAction == ActionType::RemoveFromRecents) {
// Just remove from recents list, don't touch the file
success = RECENT_BOOKS.removeBook(actionTargetPath);
} else if (selectedAction == ActionType::ClearCache) {
// Clear cache for this book, optionally preserving progress
success = BookManager::clearBookCache(actionTargetPath, clearCachePreserveProgress);
// Also clear thumbnail existence cache since thumbnails may have been deleted
clearThumbExistsCache();
}
// Note: ClearAllRecents is handled directly in loop() via ClearAllRecentsConfirming state
if (success) {
// Reload data
loadRecentBooks();
if (selectedAction != ActionType::RemoveFromRecents) {
loadFiles(); // Only reload files for Archive/Delete
if (selectedAction != ActionType::RemoveFromRecents && selectedAction != ActionType::ClearCache) {
loadFiles(); // Only reload files for Archive/Delete (not needed for cache clear)
}
// Adjust selector if needed
const int itemCount = getCurrentItemCount();
if (selectorIndex >= itemCount && itemCount > 0) {
selectorIndex = itemCount - 1;
} else if (itemCount == 0) {
selectorIndex = 0;
// Adjust selector if needed (not needed for ClearCache since item count doesn't change)
if (selectedAction != ActionType::ClearCache) {
const int itemCount = getCurrentItemCount();
if (selectorIndex >= itemCount && itemCount > 0) {
selectorIndex = itemCount - 1;
} else if (itemCount == 0) {
selectorIndex = 0;
}
}
}
@@ -577,8 +584,8 @@ void MyLibraryActivity::executeListAction() {
void MyLibraryActivity::loop() {
// Handle action menu state
if (uiState == UIState::ActionMenu) {
// Menu has 4 options in Recent tab, 2 options in Files tab
const int maxMenuSelection = (currentTab == Tab::Recent) ? 3 : 1;
// Menu has 5 options in Recent tab, 3 options in Files tab
const int maxMenuSelection = (currentTab == Tab::Recent) ? 4 : 2;
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
uiState = UIState::Normal;
@@ -608,7 +615,7 @@ void MyLibraryActivity::loop() {
// Map menu selection to action type
if (currentTab == Tab::Recent) {
// Recent tab: Archive(0), Delete(1), Remove from Recents(2), Clear All Recents(3)
// Recent tab: Archive(0), Delete(1), Clear Cache(2), Remove from Recents(3), Clear All Recents(4)
switch (menuSelection) {
case 0:
selectedAction = ActionType::Archive;
@@ -617,20 +624,37 @@ void MyLibraryActivity::loop() {
selectedAction = ActionType::Delete;
break;
case 2:
selectedAction = ActionType::RemoveFromRecents;
selectedAction = ActionType::ClearCache;
break;
case 3:
selectedAction = ActionType::RemoveFromRecents;
break;
case 4:
selectedAction = ActionType::ClearAllRecents;
break;
}
} else {
// Files tab: Archive(0), Delete(1)
selectedAction = (menuSelection == 0) ? ActionType::Archive : ActionType::Delete;
// Files tab: Archive(0), Delete(1), Clear Cache(2)
switch (menuSelection) {
case 0:
selectedAction = ActionType::Archive;
break;
case 1:
selectedAction = ActionType::Delete;
break;
case 2:
selectedAction = ActionType::ClearCache;
break;
}
}
// Clear All Recents needs its own confirmation dialog
if (selectedAction == ActionType::ClearAllRecents) {
uiState = UIState::ClearAllRecentsConfirming;
} else if (selectedAction == ActionType::ClearCache) {
// Clear Cache shows options dialog first
clearCachePreserveProgress = true; // Default to preserving progress
uiState = UIState::ClearCacheOptionsConfirming;
} else {
uiState = UIState::Confirming;
}
@@ -735,6 +759,30 @@ void MyLibraryActivity::loop() {
return;
}
// Handle clear cache options confirmation state
if (uiState == UIState::ClearCacheOptionsConfirming) {
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
uiState = UIState::ActionMenu;
updateRequired = true;
return;
}
// Up/Down toggle between Yes/No for preserve progress
if (mappedInput.wasReleased(MappedInputManager::Button::Up) ||
mappedInput.wasReleased(MappedInputManager::Button::Down)) {
clearCachePreserveProgress = !clearCachePreserveProgress;
updateRequired = true;
return;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
executeAction();
return;
}
return;
}
// Normal state handling
const int itemCount = getCurrentItemCount();
const int pageItems = getPageItems();
@@ -1303,6 +1351,12 @@ void MyLibraryActivity::render() const {
return;
}
if (uiState == UIState::ClearCacheOptionsConfirming) {
renderClearCacheOptionsConfirmation();
renderer.displayBuffer();
return;
}
// Calculate bezel-adjusted margins
const int bezelTop = renderer.getBezelOffsetTop();
const int bezelBottom = renderer.getBezelOffsetBottom();
@@ -1661,40 +1715,46 @@ void MyLibraryActivity::renderActionMenu() const {
renderer.truncatedText(UI_10_FONT_ID, actionTargetName.c_str(), pageWidth - 40 - bezelLeft - bezelRight);
renderer.drawCenteredText(UI_10_FONT_ID, filenameY, truncatedName.c_str());
// Menu options - 4 for Recent tab, 2 for Files tab
// Menu options - 5 for Recent tab, 3 for Files tab
const bool isRecentTab = (currentTab == Tab::Recent);
const int menuItemCount = isRecentTab ? 4 : 2;
const int menuItemCount = isRecentTab ? 5 : 3;
constexpr int menuLineHeight = 35;
constexpr int menuItemWidth = 160;
const int menuX = (pageWidth - menuItemWidth) / 2;
const int menuStartY = pageHeight / 2 - (menuItemCount * menuLineHeight) / 2;
// Archive option
// Archive option (index 0)
if (menuSelection == 0) {
renderer.fillRect(menuX - 10, menuStartY - 5, menuItemWidth + 20, menuLineHeight);
}
renderer.drawCenteredText(UI_10_FONT_ID, menuStartY, "Archive", menuSelection != 0);
// Delete option
// Delete option (index 1)
if (menuSelection == 1) {
renderer.fillRect(menuX - 10, menuStartY + menuLineHeight - 5, menuItemWidth + 20, menuLineHeight);
}
renderer.drawCenteredText(UI_10_FONT_ID, menuStartY + menuLineHeight, "Delete", menuSelection != 1);
// Clear Cache option (index 2) - available in both tabs
if (menuSelection == 2) {
renderer.fillRect(menuX - 10, menuStartY + menuLineHeight * 2 - 5, menuItemWidth + 20, menuLineHeight);
}
renderer.drawCenteredText(UI_10_FONT_ID, menuStartY + menuLineHeight * 2, "Clear Cache", menuSelection != 2);
// Recent tab only: Remove from Recents and Clear All Recents
if (isRecentTab) {
// Remove from Recents option
if (menuSelection == 2) {
renderer.fillRect(menuX - 10, menuStartY + menuLineHeight * 2 - 5, menuItemWidth + 20, menuLineHeight);
}
renderer.drawCenteredText(UI_10_FONT_ID, menuStartY + menuLineHeight * 2, "Remove from Recents",
menuSelection != 2);
// Clear All Recents option
// Remove from Recents option (index 3)
if (menuSelection == 3) {
renderer.fillRect(menuX - 10, menuStartY + menuLineHeight * 3 - 5, menuItemWidth + 20, menuLineHeight);
}
renderer.drawCenteredText(UI_10_FONT_ID, menuStartY + menuLineHeight * 3, "Clear All Recents", menuSelection != 3);
renderer.drawCenteredText(UI_10_FONT_ID, menuStartY + menuLineHeight * 3, "Remove from Recents",
menuSelection != 3);
// Clear All Recents option (index 4)
if (menuSelection == 4) {
renderer.fillRect(menuX - 10, menuStartY + menuLineHeight * 4 - 5, menuItemWidth + 20, menuLineHeight);
}
renderer.drawCenteredText(UI_10_FONT_ID, menuStartY + menuLineHeight * 4, "Clear All Recents", menuSelection != 4);
}
// Draw side button hints (up/down navigation)
@@ -1828,6 +1888,54 @@ void MyLibraryActivity::renderClearAllRecentsConfirmation() const {
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
}
void MyLibraryActivity::renderClearCacheOptionsConfirmation() const {
const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = renderer.getScreenHeight();
// Bezel compensation
const int bezelTop = renderer.getBezelOffsetTop();
// Title
renderer.drawCenteredText(UI_12_FONT_ID, 20 + bezelTop, "Clear Book Cache", true, EpdFontFamily::BOLD);
// Show filename
const int filenameY = 60 + bezelTop;
const int bezelLeft = renderer.getBezelOffsetLeft();
const int bezelRight = renderer.getBezelOffsetRight();
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());
// Question text
const int questionY = pageHeight / 2 - 50;
renderer.drawCenteredText(UI_10_FONT_ID, questionY, "Preserve reading progress?");
// Yes/No options
constexpr int optionLineHeight = 35;
constexpr int optionWidth = 100;
const int optionX = (pageWidth - optionWidth) / 2;
const int optionStartY = questionY + 40;
// Yes option
if (clearCachePreserveProgress) {
renderer.fillRect(optionX - 10, optionStartY - 5, optionWidth + 20, optionLineHeight);
}
renderer.drawCenteredText(UI_10_FONT_ID, optionStartY, "Yes", !clearCachePreserveProgress);
// No option
if (!clearCachePreserveProgress) {
renderer.fillRect(optionX - 10, optionStartY + optionLineHeight - 5, optionWidth + 20, optionLineHeight);
}
renderer.drawCenteredText(UI_10_FONT_ID, optionStartY + optionLineHeight, "No", clearCachePreserveProgress);
// Draw side button hints (up/down navigation)
renderer.drawSideButtonHints(UI_10_FONT_ID, ">", "<");
// Draw bottom button hints
const auto labels = mappedInput.mapLabels("« Cancel", "Clear", "", "");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
}
void MyLibraryActivity::renderBookmarksTab() const {
const auto pageWidth = renderer.getScreenWidth();
const int pageItems = getPageItems();

View File

@@ -44,9 +44,10 @@ class MyLibraryActivity final : public Activity {
Confirming,
ListActionMenu,
ListConfirmingDelete,
ClearAllRecentsConfirming
ClearAllRecentsConfirming,
ClearCacheOptionsConfirming
};
enum class ActionType { Archive, Delete, RemoveFromRecents, ClearAllRecents };
enum class ActionType { Archive, Delete, RemoveFromRecents, ClearCache, ClearAllRecents };
private:
TaskHandle_t displayTaskHandle = nullptr;
@@ -62,8 +63,9 @@ class MyLibraryActivity final : public Activity {
ActionType selectedAction = ActionType::Archive;
std::string actionTargetPath;
std::string actionTargetName;
int menuSelection = 0; // 0 = Archive, 1 = Delete
bool ignoreNextConfirmRelease = false; // Prevents immediate selection after long-press opens menu
int menuSelection = 0; // 0 = Archive, 1 = Delete
bool ignoreNextConfirmRelease = false; // Prevents immediate selection after long-press opens menu
bool clearCachePreserveProgress = true; // For Clear Cache: whether to preserve reading progress
// Recent tab state
std::vector<RecentBook> recentBooks;
@@ -153,6 +155,9 @@ class MyLibraryActivity final : public Activity {
// Clear all recents confirmation
void renderClearAllRecentsConfirmation() const;
// Clear cache options confirmation
void renderClearCacheOptionsConfirmation() const;
public:
explicit MyLibraryActivity(
GfxRenderer& renderer, MappedInputManager& mappedInput, const std::function<void()>& onGoHome,

View File

@@ -300,6 +300,36 @@ void CrossPointWebServerActivity::startAccessPoint() {
startWebServer();
}
void CrossPointWebServerActivity::generateQRCodes() {
Serial.printf("[%lu] [WEBACT] Generating QR codes (cached)...\n", millis());
const unsigned long startTime = millis();
// Web browser URL QR code
std::string webUrl = "http://" + connectedIP + "/";
qrcode_initText(&qrWebBrowser, qrWebBrowserBuffer, 4, ECC_LOW, webUrl.c_str());
Serial.printf("[%lu] [WEBACT] QR cached: %s\n", millis(), webUrl.c_str());
// Companion App (Files) deep link QR code
std::string filesUrl = getCompanionAppUrl();
qrcode_initText(&qrCompanionApp, qrCompanionAppBuffer, 4, ECC_LOW, filesUrl.c_str());
Serial.printf("[%lu] [WEBACT] QR cached: %s\n", millis(), filesUrl.c_str());
// Companion App (Library) deep link QR code
std::string libraryUrl = getCompanionAppLibraryUrl();
qrcode_initText(&qrCompanionAppLibrary, qrCompanionAppLibraryBuffer, 4, ECC_LOW, libraryUrl.c_str());
Serial.printf("[%lu] [WEBACT] QR cached: %s\n", millis(), libraryUrl.c_str());
// WiFi config QR code (for AP mode)
if (isApMode) {
std::string wifiConfig = std::string("WIFI:S:") + connectedSSID + ";;";
qrcode_initText(&qrWifiConfig, qrWifiConfigBuffer, 4, ECC_LOW, wifiConfig.c_str());
Serial.printf("[%lu] [WEBACT] QR cached: %s\n", millis(), wifiConfig.c_str());
}
qrCacheValid = true;
Serial.printf("[%lu] [WEBACT] QR codes cached in %lu ms\n", millis(), millis() - startTime);
}
void CrossPointWebServerActivity::startWebServer() {
Serial.printf("[%lu] [WEBACT] Starting web server...\n", millis());
@@ -311,6 +341,9 @@ void CrossPointWebServerActivity::startWebServer() {
state = WebServerActivityState::SERVER_RUNNING;
Serial.printf("[%lu] [WEBACT] Web server started successfully\n", millis());
// Generate and cache QR codes now that we have IP and server ports
generateQRCodes();
// Force an immediate render since we're transitioning from a subactivity
// that had its own rendering task. We need to make sure our display is shown.
xSemaphoreTake(renderingMutex, portMAX_DELAY);
@@ -468,23 +501,18 @@ void CrossPointWebServerActivity::render() const {
}
}
// Draw QR code at specified position with configurable pixel size per module
// Draw QR code from pre-computed QRCode data at specified position
// Returns the size of the QR code in pixels (width = height = size * pixelsPerModule)
int drawQRCode(const GfxRenderer& renderer, const int x, const int y, const std::string& data,
const uint8_t pixelsPerModule = 7) {
QRCode qrcode;
uint8_t qrcodeBytes[qrcode_getBufferSize(4)];
Serial.printf("[%lu] [WEBACT] QR Code (%lu): %s\n", millis(), data.length(), data.c_str());
qrcode_initText(&qrcode, qrcodeBytes, 4, ECC_LOW, data.c_str());
for (uint8_t cy = 0; cy < qrcode.size; cy++) {
for (uint8_t cx = 0; cx < qrcode.size; cx++) {
if (qrcode_getModule(&qrcode, cx, cy)) {
int drawQRCodeCached(const GfxRenderer& renderer, const int x, const int y, QRCode* qrcode,
const uint8_t pixelsPerModule = 7) {
for (uint8_t cy = 0; cy < qrcode->size; cy++) {
for (uint8_t cx = 0; cx < qrcode->size; cx++) {
if (qrcode_getModule(qrcode, cx, cy)) {
renderer.fillRect(x + pixelsPerModule * cx, y + pixelsPerModule * cy, pixelsPerModule, pixelsPerModule, true);
}
}
}
return qrcode.size * pixelsPerModule;
return qrcode->size * pixelsPerModule;
}
// Helper to format bytes into human-readable sizes
@@ -612,8 +640,7 @@ void CrossPointWebServerActivity::renderWebBrowserScreen() const {
if (isApMode) {
// AP mode: Show WiFi QR code on left, connection info on right
const std::string wifiConfig = std::string("WIFI:S:") + connectedSSID + ";;";
drawQRCode(renderer, QR_X, QR_Y, wifiConfig, QR_PX);
drawQRCodeCached(renderer, QR_X, QR_Y, &qrWifiConfig, QR_PX);
std::string ssidInfo = "Network: " + connectedSSID;
renderer.drawText(NOTOSANS_12_FONT_ID, TEXT_X, textY, ssidInfo.c_str());
@@ -635,8 +662,7 @@ void CrossPointWebServerActivity::renderWebBrowserScreen() const {
renderer.drawText(UI_12_FONT_ID, TEXT_X, textY, hostnameUrl.c_str());
} else {
// STA mode: Show URL QR code on left, connection info on right
std::string webUrl = "http://" + connectedIP + "/";
drawQRCode(renderer, QR_X, QR_Y, webUrl, QR_PX);
drawQRCodeCached(renderer, QR_X, QR_Y, &qrWebBrowser, QR_PX);
std::string ssidInfo = "Network: " + connectedSSID;
if (ssidInfo.length() > 35) {
@@ -650,6 +676,7 @@ void CrossPointWebServerActivity::renderWebBrowserScreen() const {
renderer.drawText(NOTOSANS_12_FONT_ID, TEXT_X, textY, ipInfo.c_str());
textY += LINE_SPACING + 8;
std::string webUrl = "http://" + connectedIP + "/";
renderer.drawText(UI_12_FONT_ID, TEXT_X, textY, webUrl.c_str(), true, EpdFontFamily::BOLD);
textY += LINE_SPACING - 4;
@@ -704,12 +731,12 @@ void CrossPointWebServerActivity::renderCompanionAppScreen() const {
std::string webUrl = "http://" + connectedIP + "/files";
renderer.drawText(UI_12_FONT_ID, TEXT_X, textY, webUrl.c_str());
// Draw QR code on left
const std::string appUrl = getCompanionAppUrl();
drawQRCode(renderer, QR_X, QR_Y, appUrl, QR_PX);
// Draw cached QR code on left
drawQRCodeCached(renderer, QR_X, QR_Y, &qrCompanionApp, QR_PX);
// Show deep link URL below QR code
const int urlY = QR_Y + QR_SIZE + 10;
const std::string appUrl = getCompanionAppUrl();
renderer.drawText(UI_12_FONT_ID, QR_X, urlY, appUrl.c_str(), true, EpdFontFamily::BOLD);
}
@@ -754,11 +781,11 @@ void CrossPointWebServerActivity::renderCompanionAppLibraryScreen() const {
std::string webUrl = "http://" + connectedIP + "/";
renderer.drawText(UI_12_FONT_ID, TEXT_X, textY, webUrl.c_str());
// Draw QR code on left
const std::string appUrl = getCompanionAppLibraryUrl();
drawQRCode(renderer, QR_X, QR_Y, appUrl, QR_PX);
// Draw cached QR code on left
drawQRCodeCached(renderer, QR_X, QR_Y, &qrCompanionAppLibrary, QR_PX);
// Show deep link URL below QR code
const int urlY = QR_Y + QR_SIZE + 10;
const std::string appUrl = getCompanionAppLibraryUrl();
renderer.drawText(UI_12_FONT_ID, QR_X, urlY, appUrl.c_str(), true, EpdFontFamily::BOLD);
}

View File

@@ -2,6 +2,7 @@
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <freertos/task.h>
#include <qrcode.h>
#include <functional>
#include <memory>
@@ -11,6 +12,10 @@
#include "activities/ActivityWithSubactivity.h"
#include "network/CrossPointWebServer.h"
// QR code cache - version 4 QR codes (33x33 modules)
// Buffer size for version 4: qrcode_getBufferSize(4) ≈ 185 bytes
constexpr size_t QR_BUFFER_SIZE = 185;
// Web server activity states
enum class WebServerActivityState {
MODE_SELECTION, // Choosing between Join Network and Create Hotspot
@@ -62,6 +67,19 @@ class CrossPointWebServerActivity final : public ActivityWithSubactivity {
FileTransferScreen currentScreen = FileTransferScreen::COMPANION_APP_LIBRARY;
unsigned long lastStatsRefresh = 0;
// Cached QR codes - generated once when server starts
// Avoids recomputing QR data on every render (every 30s stats refresh)
// Marked mutable since QR drawing doesn't modify logical state but qrcode_getModule takes non-const
bool qrCacheValid = false;
mutable QRCode qrWebBrowser = {};
mutable QRCode qrCompanionApp = {};
mutable QRCode qrCompanionAppLibrary = {};
mutable QRCode qrWifiConfig = {}; // For AP mode WiFi connection QR
uint8_t qrWebBrowserBuffer[QR_BUFFER_SIZE] = {};
uint8_t qrCompanionAppBuffer[QR_BUFFER_SIZE] = {};
uint8_t qrCompanionAppLibraryBuffer[QR_BUFFER_SIZE] = {};
uint8_t qrWifiConfigBuffer[QR_BUFFER_SIZE] = {};
static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop();
void render() const;
@@ -78,6 +96,7 @@ class CrossPointWebServerActivity final : public ActivityWithSubactivity {
void startAccessPoint();
void startWebServer();
void stopWebServer();
void generateQRCodes();
public:
explicit CrossPointWebServerActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,

View File

@@ -3,7 +3,7 @@
#include <GfxRenderer.h>
#include <WiFi.h>
#include <map>
#include <algorithm>
#include "MappedInputManager.h"
#include "WifiCredentialStore.h"
@@ -124,48 +124,55 @@ void WifiSelectionActivity::processWifiScanResults() {
}
// Scan complete, process results
// Use a map to deduplicate networks by SSID, keeping the strongest signal
std::map<std::string, WifiNetworkInfo> uniqueNetworks;
// Deduplicate directly into the networks vector (avoids std::map overhead)
networks.clear();
networks.reserve(std::min(scanResult, static_cast<int16_t>(20))); // Limit to 20 networks max
for (int i = 0; i < scanResult; i++) {
std::string ssid = WiFi.SSID(i).c_str();
String ssidStr = WiFi.SSID(i);
const int32_t rssi = WiFi.RSSI(i);
// Skip hidden networks (empty SSID)
if (ssid.empty()) {
if (ssidStr.isEmpty()) {
continue;
}
// Check if we've already seen this SSID
auto it = uniqueNetworks.find(ssid);
if (it == uniqueNetworks.end() || rssi > it->second.rssi) {
// New network or stronger signal than existing entry
std::string ssid = ssidStr.c_str();
// Check if we've already seen this SSID (linear search is fine for small lists)
auto existing = std::find_if(networks.begin(), networks.end(),
[&ssid](const WifiNetworkInfo& net) { return net.ssid == ssid; });
if (existing != networks.end()) {
// Update if stronger signal
if (rssi > existing->rssi) {
existing->rssi = rssi;
existing->isEncrypted = (WiFi.encryptionType(i) != WIFI_AUTH_OPEN);
}
} else if (networks.size() < 20) {
// New network - only add if under limit
WifiNetworkInfo network;
network.ssid = ssid;
network.ssid = std::move(ssid);
network.rssi = rssi;
network.isEncrypted = (WiFi.encryptionType(i) != WIFI_AUTH_OPEN);
network.hasSavedPassword = WIFI_STORE.hasSavedCredential(network.ssid);
uniqueNetworks[ssid] = network;
networks.push_back(std::move(network));
}
}
// Convert map to vector
networks.clear();
for (const auto& pair : uniqueNetworks) {
// cppcheck-suppress useStlAlgorithm
networks.push_back(pair.second);
}
// Free WiFi scan memory immediately (before sorting)
WiFi.scanDelete();
// Sort by signal strength (strongest first)
std::sort(networks.begin(), networks.end(),
[](const WifiNetworkInfo& a, const WifiNetworkInfo& b) { return a.rssi > b.rssi; });
// Show networks with PW first
// Sort by signal strength (strongest first), then by saved password
std::sort(networks.begin(), networks.end(), [](const WifiNetworkInfo& a, const WifiNetworkInfo& b) {
return a.hasSavedPassword && !b.hasSavedPassword;
// Primary: saved passwords first
if (a.hasSavedPassword != b.hasSavedPassword) {
return a.hasSavedPassword;
}
// Secondary: strongest signal first
return a.rssi > b.rssi;
});
WiFi.scanDelete();
state = WifiSelectionState::NETWORK_LIST;
selectedNetworkIndex = 0;
updateRequired = true;

View File

@@ -89,7 +89,7 @@ void EpubReaderActivity::onEnter() {
renderer.fillRect(boxX, boxY, boxWidth, boxHeight, false);
renderer.drawRect(boxX + 5, boxY + 5, boxWidth - 10, boxHeight - 10);
renderer.drawText(UI_12_FONT_ID, boxX + boxMargin, boxY + boxMargin, "Preparing book... [0%]");
renderer.displayBuffer(EInkDisplay::FAST_REFRESH);
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
// Generate covers with progress callback
epub->generateAllCovers([&](int percent) {
@@ -103,7 +103,7 @@ void EpubReaderActivity::onEnter() {
char progressStr[32];
snprintf(progressStr, sizeof(progressStr), "Preparing book... [%d%%]", percent);
renderer.drawText(UI_12_FONT_ID, boxX + boxMargin, boxY + boxMargin, progressStr);
renderer.displayBuffer(EInkDisplay::FAST_REFRESH);
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
}
});
}
@@ -138,7 +138,7 @@ void EpubReaderActivity::onEnter() {
nextPageNumber = data[2] + (data[3] << 8);
hasContentOffset = false;
Serial.printf("[%lu] [ERS] Loaded legacy progress (unknown version %d): spine %d, page %d\n", millis(),
version, currentSpineIndex, nextPageNumber);
version, currentSpineIndex, nextPageNumber);
}
}
} else if (fileSize >= 4) {
@@ -236,6 +236,15 @@ void EpubReaderActivity::loop() {
updateRequired = true;
return;
}
if (mappedInput.wasReleased(MappedInputManager::Button::PageForward) ||
mappedInput.wasReleased(MappedInputManager::Button::Right)) {
// Start over from beginning
currentSpineIndex = 0;
nextPageNumber = 0;
showingEndOfBookPrompt = false;
updateRequired = true;
return;
}
return;
}
@@ -496,6 +505,28 @@ void EpubReaderActivity::loop() {
self->onGoToClearCache();
return;
}
self->updateRequired = true;
} else if (action == QuickMenuAction::TOGGLE_ORIENTATION) {
// Toggle between Portrait and Landscape CCW
if (SETTINGS.orientation == CrossPointSettings::ORIENTATION::PORTRAIT) {
SETTINGS.orientation = CrossPointSettings::ORIENTATION::LANDSCAPE_CCW;
} else {
SETTINGS.orientation = CrossPointSettings::ORIENTATION::PORTRAIT;
}
SETTINGS.saveToFile();
// Apply new orientation to renderer
if (SETTINGS.orientation == CrossPointSettings::ORIENTATION::PORTRAIT) {
self->renderer.setOrientation(GfxRenderer::Orientation::Portrait);
} else {
self->renderer.setOrientation(GfxRenderer::Orientation::LandscapeCounterClockwise);
}
// Force section reload with new orientation's viewport dimensions
xSemaphoreTake(cachedMutex, portMAX_DELAY);
self->section.reset();
xSemaphoreGive(cachedMutex);
self->updateRequired = true;
} else if (action == QuickMenuAction::GO_TO_SETTINGS) {
// Navigate to Settings activity
@@ -686,7 +717,7 @@ void EpubReaderActivity::renderScreen() {
auto progressCallback = [this, barX, barY, barWidth, barHeight](int progress) {
const int fillWidth = (barWidth - 2) * progress / 100;
renderer.fillRect(barX + 1, barY + 1, fillWidth, barHeight - 2, true);
renderer.displayBuffer(EInkDisplay::FAST_REFRESH);
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
};
if (!section->createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
@@ -730,8 +761,7 @@ void EpubReaderActivity::renderScreen() {
}
if (section->currentPage < 0 || section->currentPage >= section->pageCount) {
Serial.printf("[%lu] [ERS] Page out of bounds: %d (max %d)\n", millis(), section->currentPage,
section->pageCount);
Serial.printf("[%lu] [ERS] Page out of bounds: %d (max %d)\n", millis(), section->currentPage, section->pageCount);
renderer.drawCenteredText(UI_12_FONT_ID, 300, "Out of bounds", true, EpdFontFamily::BOLD);
renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
renderer.displayBuffer();
@@ -804,7 +834,7 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
if (pagesUntilFullRefresh <= 1) {
renderer.displayBuffer(EInkDisplay::HALF_REFRESH);
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
pagesUntilFullRefresh = SETTINGS.getRefreshFrequency();
} else {
renderer.displayBuffer();
@@ -966,7 +996,7 @@ void EpubReaderActivity::renderEndOfBookPrompt() {
}
// Button hints
const auto labels = mappedInput.mapLabels("« Back", "Select", "", "");
const auto labels = mappedInput.mapLabels("« Back", "Select", "", "Start Over");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer();

View File

@@ -85,7 +85,7 @@ void TxtReaderActivity::onEnter() {
renderer.fillRect(boxX, boxY, boxWidth, boxHeight, false);
renderer.drawRect(boxX + 5, boxY + 5, boxWidth - 10, boxHeight - 10);
renderer.drawText(UI_12_FONT_ID, boxX + boxMargin, boxY + boxMargin, "Preparing book... [0%]");
renderer.displayBuffer(EInkDisplay::FAST_REFRESH);
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
// Generate covers with progress callback
(void)txt->generateAllCovers([&](int percent) {
@@ -99,7 +99,7 @@ void TxtReaderActivity::onEnter() {
char progressStr[32];
snprintf(progressStr, sizeof(progressStr), "Preparing book... [%d%%]", percent);
renderer.drawText(UI_12_FONT_ID, boxX + boxMargin, boxY + boxMargin, progressStr);
renderer.displayBuffer(EInkDisplay::FAST_REFRESH);
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
}
});
}
@@ -171,6 +171,14 @@ void TxtReaderActivity::loop() {
updateRequired = true;
return;
}
if (mappedInput.wasReleased(MappedInputManager::Button::PageForward) ||
mappedInput.wasReleased(MappedInputManager::Button::Right)) {
// Start over from beginning
currentPage = 0;
showingEndOfBookPrompt = false;
updateRequired = true;
return;
}
return;
}
@@ -331,7 +339,7 @@ void TxtReaderActivity::buildPageIndex() {
// Fill progress bar
const int fillWidth = (barWidth - 2) * progressPercent / 100;
renderer.fillRect(barX + 1, barY + 1, fillWidth, barHeight - 2, true);
renderer.displayBuffer(EInkDisplay::FAST_REFRESH);
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
}
// Yield to other tasks periodically
@@ -563,7 +571,7 @@ void TxtReaderActivity::renderPage() {
renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
if (pagesUntilFullRefresh <= 1) {
renderer.displayBuffer(EInkDisplay::HALF_REFRESH);
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
pagesUntilFullRefresh = SETTINGS.getRefreshFrequency();
} else {
renderer.displayBuffer();
@@ -909,7 +917,7 @@ void TxtReaderActivity::renderEndOfBookPrompt() {
}
// Button hints
const auto labels = mappedInput.mapLabels("« Back", "Select", "", "");
const auto labels = mappedInput.mapLabels("« Back", "Select", "", "Start Over");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer();

View File

@@ -4,6 +4,9 @@
#include <HardwareSerial.h>
#include <SDCardManager.h>
#include <string>
#include <vector>
#include "MappedInputManager.h"
#include "activities/home/HomeActivity.h"
#include "activities/home/MyLibraryActivity.h"
@@ -19,6 +22,7 @@ void ClearCacheActivity::onEnter() {
renderingMutex = xSemaphoreCreateMutex();
state = WARNING;
preserveProgress = true; // Default to preserving progress
updateRequired = true;
xTaskCreate(&ClearCacheActivity::taskTrampoline, "ClearCacheActivityTask",
@@ -56,6 +60,7 @@ void ClearCacheActivity::displayTaskLoop() {
}
void ClearCacheActivity::render() {
const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = renderer.getScreenHeight();
// Bezel compensation
@@ -67,11 +72,32 @@ void ClearCacheActivity::render() {
renderer.drawCenteredText(UI_12_FONT_ID, 15 + bezelTop, "Clear Cache", true, EpdFontFamily::BOLD);
if (state == WARNING) {
renderer.drawCenteredText(UI_10_FONT_ID, centerY - 60, "This will clear all cached book data.", true);
renderer.drawCenteredText(UI_10_FONT_ID, centerY - 30, "All reading progress will be lost!", true,
EpdFontFamily::BOLD);
renderer.drawCenteredText(UI_10_FONT_ID, centerY + 10, "Books will need to be re-indexed", true);
renderer.drawCenteredText(UI_10_FONT_ID, centerY + 30, "when opened again.", true);
renderer.drawCenteredText(UI_10_FONT_ID, centerY - 70, "This will clear all cached book data.", true);
renderer.drawCenteredText(UI_10_FONT_ID, centerY - 45, "Books will need to be re-indexed.", true);
// Preserve progress option
renderer.drawCenteredText(UI_10_FONT_ID, centerY - 5, "Preserve reading progress?");
// Yes/No options
constexpr int optionLineHeight = 30;
constexpr int optionWidth = 80;
const int optionX = (pageWidth - optionWidth) / 2;
const int optionStartY = centerY + 25;
// Yes option
if (preserveProgress) {
renderer.fillRect(optionX - 10, optionStartY - 5, optionWidth + 20, optionLineHeight);
}
renderer.drawCenteredText(UI_10_FONT_ID, optionStartY, "Yes", !preserveProgress);
// No option
if (!preserveProgress) {
renderer.fillRect(optionX - 10, optionStartY + optionLineHeight - 5, optionWidth + 20, optionLineHeight);
}
renderer.drawCenteredText(UI_10_FONT_ID, optionStartY + optionLineHeight, "No", preserveProgress);
// Draw side button hints (up/down navigation)
renderer.drawSideButtonHints(UI_10_FONT_ID, ">", "<");
const auto labels = mappedInput.mapLabels("« Cancel", "Clear", "", "");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
@@ -110,8 +136,68 @@ void ClearCacheActivity::render() {
}
}
void ClearCacheActivity::clearCacheDirectory(const char* dirPath) {
// Helper to check if a file should be preserved
const auto shouldPreserve = [this](const char* name) {
if (!preserveProgress) return false;
// Preserve progress and bookmarks when preserveProgress is enabled
return (strcmp(name, "progress.bin") == 0 || strcmp(name, "bookmarks.bin") == 0);
};
FsFile dir = SdMan.open(dirPath);
if (!dir || !dir.isDirectory()) {
if (dir) dir.close();
failedCount++;
return;
}
char name[128];
std::vector<std::string> filesToDelete;
std::vector<std::string> dirsToDelete;
// First pass: collect files and directories to delete
for (FsFile entry = dir.openNextFile(); entry; entry = dir.openNextFile()) {
entry.getName(name, sizeof(name));
const bool isDir = entry.isDirectory();
entry.close();
std::string fullPath = std::string(dirPath) + "/" + name;
if (isDir) {
dirsToDelete.push_back(fullPath);
} else if (!shouldPreserve(name)) {
filesToDelete.push_back(fullPath);
}
}
dir.close();
// Delete files
for (const auto& path : filesToDelete) {
if (SdMan.remove(path.c_str())) {
clearedCount++;
} else {
Serial.printf("[%lu] [CLEAR_CACHE] Failed to delete file: %s\n", millis(), path.c_str());
failedCount++;
}
}
// Delete subdirectories (like "sections/")
for (const auto& path : dirsToDelete) {
if (SdMan.removeDir(path.c_str())) {
clearedCount++;
} else {
Serial.printf("[%lu] [CLEAR_CACHE] Failed to delete dir: %s\n", millis(), path.c_str());
failedCount++;
}
}
// If not preserving progress, try to remove the now-empty directory
if (!preserveProgress) {
SdMan.rmdir(dirPath); // This will fail if directory is not empty, which is fine
}
}
void ClearCacheActivity::clearCache() {
Serial.printf("[%lu] [CLEAR_CACHE] Clearing cache...\n", millis());
Serial.printf("[%lu] [CLEAR_CACHE] Clearing cache (preserveProgress=%d)...\n", millis(), preserveProgress);
// Open .crosspoint directory
auto root = SdMan.open("/.crosspoint");
@@ -127,35 +213,32 @@ void ClearCacheActivity::clearCache() {
failedCount = 0;
char name[128];
// Iterate through all entries in the directory
// Collect all book cache directories first
std::vector<std::string> cacheDirs;
for (auto file = root.openNextFile(); file; file = root.openNextFile()) {
file.getName(name, sizeof(name));
String itemName(name);
// Only delete directories starting with epub_ or txt_
// Only process directories starting with epub_ or txt_
if (file.isDirectory() && (itemName.startsWith("epub_") || itemName.startsWith("txt_"))) {
String fullPath = "/.crosspoint/" + itemName;
Serial.printf("[%lu] [CLEAR_CACHE] Removing cache: %s\n", millis(), fullPath.c_str());
file.close(); // Close before attempting to delete
if (SdMan.removeDir(fullPath.c_str())) {
clearedCount++;
} else {
Serial.printf("[%lu] [CLEAR_CACHE] Failed to remove: %s\n", millis(), fullPath.c_str());
failedCount++;
}
} else {
file.close();
cacheDirs.push_back("/.crosspoint/" + std::string(name));
}
file.close();
}
root.close();
// Now clear each cache directory
for (const auto& cacheDir : cacheDirs) {
Serial.printf("[%lu] [CLEAR_CACHE] Clearing: %s\n", millis(), cacheDir.c_str());
clearCacheDirectory(cacheDir.c_str());
}
// Also clear in-memory caches since disk cache is gone
HomeActivity::freeCoverBufferIfAllocated();
MyLibraryActivity::clearThumbExistsCache();
Serial.printf("[%lu] [CLEAR_CACHE] Cache cleared: %d removed, %d failed\n", millis(), clearedCount, failedCount);
Serial.printf("[%lu] [CLEAR_CACHE] Cache cleared: %d items removed, %d failed\n", millis(), clearedCount,
failedCount);
state = SUCCESS;
updateRequired = true;
@@ -163,8 +246,17 @@ void ClearCacheActivity::clearCache() {
void ClearCacheActivity::loop() {
if (state == WARNING) {
// Up/Down toggle preserve progress option
if (mappedInput.wasPressed(MappedInputManager::Button::Up) ||
mappedInput.wasPressed(MappedInputManager::Button::Down)) {
preserveProgress = !preserveProgress;
updateRequired = true;
return;
}
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
Serial.printf("[%lu] [CLEAR_CACHE] User confirmed, starting cache clear\n", millis());
Serial.printf("[%lu] [CLEAR_CACHE] User confirmed (preserveProgress=%d), starting cache clear\n", millis(),
preserveProgress);
xSemaphoreTake(renderingMutex, portMAX_DELAY);
state = CLEARING;
xSemaphoreGive(renderingMutex);

View File

@@ -29,9 +29,11 @@ class ClearCacheActivity final : public ActivityWithSubactivity {
int clearedCount = 0;
int failedCount = 0;
bool preserveProgress = true; // Whether to keep progress.bin and bookmarks.bin
static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop();
void render();
void clearCache();
void clearCacheDirectory(const char* dirPath); // Helper to clear a single book's cache
};

View File

@@ -15,7 +15,7 @@ namespace {
// Visibility condition for bezel edge setting (only show when compensation > 0)
bool isBezelCompensationEnabled() { return SETTINGS.bezelCompensation > 0; }
constexpr int displaySettingsCount = 9;
constexpr int displaySettingsCount = 10;
const SettingInfo displaySettings[displaySettingsCount] = {
// Should match with SLEEP_SCREEN_MODE
SettingInfo::Enum("Sleep Screen", &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover", "None"}),
@@ -28,6 +28,7 @@ const SettingInfo displaySettings[displaySettingsCount] = {
SettingInfo::Enum("Hide Battery %", &CrossPointSettings::hideBatteryPercentage, {"Never", "In Reader", "Always"}),
SettingInfo::Enum("Refresh Frequency", &CrossPointSettings::refreshFrequency,
{"1 page", "5 pages", "10 pages", "15 pages", "30 pages"}),
SettingInfo::Enum("Sunlight Fading Fix", &CrossPointSettings::fadingFix, {"OFF", "ON"}),
SettingInfo::Value("Bezel Compensation", &CrossPointSettings::bezelCompensation, {0, 10, 1}),
SettingInfo::Enum("Bezel Edge", &CrossPointSettings::bezelCompensationEdge, {"Bottom", "Top", "Left", "Right"},
isBezelCompensationEnabled)};

View File

@@ -1,6 +1,6 @@
#pragma once
#include <EInkDisplay.h>
#include <EpdFontFamily.h>
#include <HalDisplay.h>
#include <string>
#include <utility>
@@ -10,12 +10,12 @@
class FullScreenMessageActivity final : public Activity {
std::string text;
EpdFontFamily::Style style;
EInkDisplay::RefreshMode refreshMode;
HalDisplay::RefreshMode refreshMode;
public:
explicit FullScreenMessageActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string text,
const EpdFontFamily::Style style = EpdFontFamily::REGULAR,
const EInkDisplay::RefreshMode refreshMode = EInkDisplay::FAST_REFRESH)
const HalDisplay::RefreshMode refreshMode = HalDisplay::FAST_REFRESH)
: Activity("FullScreenMessage", renderer, mappedInput),
text(std::move(text)),
style(style),

View File

@@ -249,7 +249,7 @@ void KeyboardEntryActivity::loop() {
}
void KeyboardEntryActivity::render() const {
// Get margins using same pattern as reader + button hint space
// Get margins with button hint space for all orientations
int marginTop, marginRight, marginBottom, marginLeft;
getDictionaryContentMargins(renderer, &marginTop, &marginRight, &marginBottom, &marginLeft);

View File

@@ -2,16 +2,25 @@
#include <GfxRenderer.h>
#include "CrossPointSettings.h"
#include "MappedInputManager.h"
#include "fontIds.h"
namespace {
constexpr int MENU_ITEM_COUNT = 4;
const char* MENU_ITEMS[MENU_ITEM_COUNT] = {"Dictionary", "Bookmark", "Clear Cache", "Settings"};
const char* MENU_DESCRIPTIONS_ADD[MENU_ITEM_COUNT] = {"Look up a word", "Add bookmark to this page",
"Free up storage space", "Open settings menu"};
const char* MENU_DESCRIPTIONS_REMOVE[MENU_ITEM_COUNT] = {"Look up a word", "Remove bookmark from this page",
"Free up storage space", "Open settings menu"};
// Base menu item count (reorderable items)
constexpr int BASE_MENU_ITEM_COUNT = 5;
// Total display count including "Edit List Order"
constexpr int DISPLAY_ITEM_COUNT = 6;
// Menu items indexed by QuickMenuAction enum value
// 0=Dictionary, 1=Bookmark, 2=ClearCache, 3=Orientation, 4=Settings
const char* MENU_ITEMS[BASE_MENU_ITEM_COUNT] = {"Dictionary", "Bookmark", "Clear Cache", "Rotate Screen", "Settings"};
const char* MENU_DESCRIPTIONS_ADD[BASE_MENU_ITEM_COUNT] = {"Look up a word", "Add bookmark to this page",
"Free up storage space", "Toggle screen orientation",
"Open settings menu"};
const char* MENU_DESCRIPTIONS_REMOVE[BASE_MENU_ITEM_COUNT] = {"Look up a word", "Remove bookmark from this page",
"Free up storage space", "Toggle screen orientation",
"Open settings menu"};
} // namespace
void QuickMenuActivity::taskTrampoline(void* param) {
@@ -53,6 +62,16 @@ void QuickMenuActivity::onExit() {
}
void QuickMenuActivity::loop() {
if (editMode) {
// Edit mode logic
handleEditMode();
} else {
// Normal mode logic
handleNormalMode();
}
}
void QuickMenuActivity::handleNormalMode() {
// Handle back button - cancel
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
onCancel();
@@ -61,8 +80,22 @@ void QuickMenuActivity::loop() {
// Handle confirm button - select current option
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
// Last item is "Edit List Order"
if (selectedIndex == DISPLAY_ITEM_COUNT - 1) {
// Enter edit mode - copy current order to local buffer
for (int i = 0; i < BASE_MENU_ITEM_COUNT; i++) {
localOrder[i] = SETTINGS.quickMenuOrder[i];
}
editMode = true;
selectedIndex = 0; // Start at first item in edit mode
updateRequired = true;
return;
}
// Get the action from the order array
const int actionIndex = SETTINGS.quickMenuOrder[selectedIndex];
QuickMenuAction action;
switch (selectedIndex) {
switch (actionIndex) {
case 0:
action = QuickMenuAction::DICTIONARY;
break;
@@ -73,6 +106,9 @@ void QuickMenuActivity::loop() {
action = QuickMenuAction::CLEAR_CACHE;
break;
case 3:
action = QuickMenuAction::TOGGLE_ORIENTATION;
break;
case 4:
default:
action = QuickMenuAction::GO_TO_SETTINGS;
break;
@@ -88,10 +124,69 @@ void QuickMenuActivity::loop() {
mappedInput.wasPressed(MappedInputManager::Button::Right);
if (prevPressed) {
selectedIndex = (selectedIndex + MENU_ITEM_COUNT - 1) % MENU_ITEM_COUNT;
selectedIndex = (selectedIndex + DISPLAY_ITEM_COUNT - 1) % DISPLAY_ITEM_COUNT;
updateRequired = true;
} else if (nextPressed) {
selectedIndex = (selectedIndex + 1) % MENU_ITEM_COUNT;
selectedIndex = (selectedIndex + 1) % DISPLAY_ITEM_COUNT;
updateRequired = true;
}
}
void QuickMenuActivity::handleEditMode() {
// Handle back button - save and exit edit mode
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
// Save the local order to settings
for (int i = 0; i < BASE_MENU_ITEM_COUNT; i++) {
SETTINGS.quickMenuOrder[i] = localOrder[i];
}
SETTINGS.saveToFile();
editMode = false;
movingIndex = -1;
selectedIndex = DISPLAY_ITEM_COUNT - 1; // Select "Edit List Order" when exiting
updateRequired = true;
return;
}
// Handle confirm button - pick or place item
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (movingIndex < 0) {
// No item selected yet - pick up the current item
movingIndex = selectedIndex;
} else {
// Item is being moved - place it at the current position
if (movingIndex != selectedIndex) {
// Remove item from old position and insert at new position
const uint8_t movingItem = localOrder[movingIndex];
if (movingIndex < selectedIndex) {
// Moving down - shift items up
for (int i = movingIndex; i < selectedIndex; i++) {
localOrder[i] = localOrder[i + 1];
}
} else {
// Moving up - shift items down
for (int i = movingIndex; i > selectedIndex; i--) {
localOrder[i] = localOrder[i - 1];
}
}
localOrder[selectedIndex] = movingItem;
}
movingIndex = -1; // Deselect
}
updateRequired = true;
return;
}
// Handle navigation - just move cursor
const bool prevPressed = mappedInput.wasPressed(MappedInputManager::Button::Up) ||
mappedInput.wasPressed(MappedInputManager::Button::Left);
const bool nextPressed = mappedInput.wasPressed(MappedInputManager::Button::Down) ||
mappedInput.wasPressed(MappedInputManager::Button::Right);
if (prevPressed && selectedIndex > 0) {
selectedIndex--;
updateRequired = true;
} else if (nextPressed && selectedIndex < BASE_MENU_ITEM_COUNT - 1) {
selectedIndex++;
updateRequired = true;
}
}
@@ -120,46 +215,110 @@ void QuickMenuActivity::render() const {
const int bezelRight = renderer.getBezelOffsetRight();
const int bezelBottom = renderer.getBezelOffsetBottom();
// Calculate usable content area
const int marginLeft = 20 + bezelLeft;
const int marginRight = 20 + bezelRight;
const int marginTop = 15 + bezelTop;
const int contentWidth = pageWidth - marginLeft - marginRight;
const int contentHeight = pageHeight - marginTop - 60 - bezelBottom; // 60 for button hints
// Button hint space constants
constexpr int FRONT_BUTTON_SPACE = 45; // 40px button height + 5px padding
constexpr int SIDE_BUTTON_SPACE = 50; // 45px button area + 5px padding
// Draw header
renderer.drawCenteredText(UI_12_FONT_ID, marginTop, "Quick Menu", true, EpdFontFamily::BOLD);
// Calculate button hint margins based on orientation
// Physical button locations (fixed on device):
// - Front buttons: physical bottom in portrait
// - Side buttons: physical right in portrait
// These map to different logical edges depending on orientation
int frontBtnMarginTop = 0, frontBtnMarginBottom = 0, frontBtnMarginLeft = 0, frontBtnMarginRight = 0;
int sideBtnMarginTop = 0, sideBtnMarginBottom = 0, sideBtnMarginLeft = 0, sideBtnMarginRight = 0;
switch (renderer.getOrientation()) {
case GfxRenderer::Portrait:
// Front buttons at logical BOTTOM, Side buttons at logical RIGHT
frontBtnMarginBottom = FRONT_BUTTON_SPACE;
sideBtnMarginRight = SIDE_BUTTON_SPACE;
break;
case GfxRenderer::LandscapeClockwise:
// Front buttons at logical LEFT, Side buttons at logical BOTTOM
frontBtnMarginLeft = FRONT_BUTTON_SPACE;
sideBtnMarginBottom = SIDE_BUTTON_SPACE;
break;
case GfxRenderer::PortraitInverted:
// Front buttons at logical TOP, Side buttons at logical LEFT
frontBtnMarginTop = FRONT_BUTTON_SPACE;
sideBtnMarginLeft = SIDE_BUTTON_SPACE;
break;
case GfxRenderer::LandscapeCounterClockwise:
// Front buttons at logical RIGHT, Side buttons at logical TOP
frontBtnMarginRight = FRONT_BUTTON_SPACE;
sideBtnMarginTop = SIDE_BUTTON_SPACE;
break;
}
// Calculate usable content area with bezel and button hint margins
const int marginLeft = 20 + bezelLeft + frontBtnMarginLeft + sideBtnMarginLeft;
const int marginRight = 20 + bezelRight + frontBtnMarginRight + sideBtnMarginRight;
const int marginTop = 15 + bezelTop + frontBtnMarginTop + sideBtnMarginTop;
const int marginBottom = 15 + bezelBottom + frontBtnMarginBottom + sideBtnMarginBottom;
const int contentWidth = pageWidth - marginLeft - marginRight;
const int contentHeight = pageHeight - marginTop - marginBottom;
// Draw header - different text in edit mode
const char* headerText = editMode ? "Edit Menu Order" : "Quick Menu";
renderer.drawCenteredText(UI_12_FONT_ID, marginTop, headerText, true, EpdFontFamily::BOLD);
// Select descriptions based on bookmark state
const char* const* descriptions = isPageBookmarked ? MENU_DESCRIPTIONS_REMOVE : MENU_DESCRIPTIONS_ADD;
// Get the order array to use (local copy in edit mode, settings otherwise)
const uint8_t* order = editMode ? localOrder : SETTINGS.quickMenuOrder;
// Draw menu items centered in content area
constexpr int itemHeight = 50; // Height for each menu item (including description)
const int startY = marginTop + (contentHeight - (MENU_ITEM_COUNT * itemHeight)) / 2;
const int startY = marginTop + (contentHeight - (DISPLAY_ITEM_COUNT * itemHeight)) / 2;
for (int i = 0; i < MENU_ITEM_COUNT; i++) {
for (int i = 0; i < DISPLAY_ITEM_COUNT; i++) {
const int itemY = startY + i * itemHeight;
const bool isSelected = (i == selectedIndex);
const bool isBeingMoved = (editMode && i == movingIndex);
// Draw selection highlight (black fill) for selected item
if (isSelected) {
renderer.fillRect(marginLeft + 10, itemY - 2, contentWidth - 20, itemHeight - 6);
}
// Draw menu item text
const char* itemText = MENU_ITEMS[i];
// For bookmark item, show different text based on state
if (i == 1) {
itemText = isPageBookmarked ? "Remove Bookmark" : "Add Bookmark";
// Draw outline for item being moved (when cursor is elsewhere)
if (isBeingMoved && !isSelected) {
renderer.drawRect(marginLeft + 10, itemY - 2, contentWidth - 20, itemHeight - 6);
}
renderer.drawText(UI_10_FONT_ID, marginLeft + 20, itemY, itemText, !isSelected);
renderer.drawText(SMALL_FONT_ID, marginLeft + 20, itemY + 22, descriptions[i], !isSelected);
// Last item is always "Edit List Order" (fixed, not in the order array)
if (i == DISPLAY_ITEM_COUNT - 1) {
renderer.drawText(UI_10_FONT_ID, marginLeft + 20, itemY, "- Edit List Order -", !isSelected);
renderer.drawText(SMALL_FONT_ID, marginLeft + 20, itemY + 22, "Customize menu order", !isSelected);
} else {
// Get the action index from the order array
const int actionIndex = order[i];
// Draw menu item text - add indicator for item being moved
const char* itemText = MENU_ITEMS[actionIndex];
// For bookmark item (action index 1), show different text based on state
if (actionIndex == 1) {
itemText = isPageBookmarked ? "Remove Bookmark" : "Add Bookmark";
}
renderer.drawText(UI_10_FONT_ID, marginLeft + 20, itemY, itemText, !isSelected);
renderer.drawText(SMALL_FONT_ID, marginLeft + 20, itemY + 22, descriptions[actionIndex], !isSelected);
}
}
// Draw help text at bottom
const auto labels = mappedInput.mapLabels("\xc2\xab Back", "Select", "", "");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
// Draw help text at bottom - different hints for edit mode
if (editMode) {
const char* confirmLabel = (movingIndex < 0) ? "Pick" : "Place";
const auto labels = mappedInput.mapLabels("\xc2\xab Done", confirmLabel, "", "");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
// Side button hints for navigation
renderer.drawSideButtonHints(UI_10_FONT_ID, "<", ">");
} else {
const auto labels = mappedInput.mapLabels("\xc2\xab Back", "Select", "< Prev", "Next >");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
// Side button hints for up/down navigation
renderer.drawSideButtonHints(UI_10_FONT_ID, "<", ">");
}
renderer.displayBuffer();
}

View File

@@ -8,7 +8,7 @@
#include "../Activity.h"
// Enum for quick menu selection
enum class QuickMenuAction { DICTIONARY, ADD_BOOKMARK, CLEAR_CACHE, GO_TO_SETTINGS };
enum class QuickMenuAction { DICTIONARY, ADD_BOOKMARK, CLEAR_CACHE, TOGGLE_ORIENTATION, GO_TO_SETTINGS };
/**
* QuickMenuActivity presents a quick access menu triggered by short power button press.
@@ -28,9 +28,16 @@ class QuickMenuActivity final : public Activity {
const std::function<void()> onCancel;
const bool isPageBookmarked; // True if current page already has a bookmark
// Edit mode state
bool editMode = false; // True when in edit mode
int movingIndex = -1; // Index of item being moved (-1 if none)
uint8_t localOrder[5] = {0}; // Local copy of order for editing
static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop();
void render() const;
void handleNormalMode();
void handleEditMode();
public:
explicit QuickMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,

View File

@@ -1,9 +1,9 @@
#include <Arduino.h>
#include <BitmapHelpers.h>
#include <EInkDisplay.h>
#include <Epub.h>
#include <GfxRenderer.h>
#include <InputManager.h>
#include <HalDisplay.h>
#include <HalGPIO.h>
#include <SDCardManager.h>
#include <SPI.h>
#include <builtinFonts/all.h>
@@ -32,23 +32,10 @@
#include "fontIds.h"
#include "images/LockIcon.h"
#define SPI_FQ 40000000
// Display SPI pins (custom pins for XteinkX4, not hardware SPI defaults)
#define EPD_SCLK 8 // SPI Clock
#define EPD_MOSI 10 // SPI MOSI (Master Out Slave In)
#define EPD_CS 21 // Chip Select
#define EPD_DC 4 // Data/Command
#define EPD_RST 5 // Reset
#define EPD_BUSY 6 // Busy
#define UART0_RXD 20 // Used for USB connection detection
#define SD_SPI_MISO 7
EInkDisplay einkDisplay(EPD_SCLK, EPD_MOSI, EPD_CS, EPD_DC, EPD_RST, EPD_BUSY);
InputManager inputManager;
MappedInputManager mappedInputManager(inputManager);
GfxRenderer renderer(einkDisplay);
HalDisplay display;
HalGPIO gpio;
MappedInputManager mappedInputManager(gpio);
GfxRenderer renderer(display);
Activity* currentActivity;
// Fonts
@@ -130,11 +117,14 @@ void logMemoryState(const char* tag, const char* context) {
#define logMemoryState(tag, context) ((void)0)
#endif
// Flash command detection - receives "FLASH\n" from pre_flash.py script
// Flash command detection - receives "FLASH:version\n" from pre_flash.py script
// Plan A: Simple polling - host sends command, device checks when Serial is connected
static String flashCmdBuffer;
void checkForFlashCommand() {
if (!Serial) return; // Early exit if Serial not initialized
// Only check when Serial is connected (host has port open)
if (!Serial) return;
while (Serial.available()) {
char c = Serial.read();
if (c == '\n') {
@@ -165,56 +155,51 @@ void checkForFlashCommand() {
const int screenH = renderer.getScreenHeight();
// Show current version in bottom-left corner (orientation-aware)
// "Bottom-left" is relative to the current orientation
constexpr int versionMargin = 10;
const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, CROSSPOINT_VERSION);
int versionX, versionY;
switch (renderer.getOrientation()) {
case GfxRenderer::Portrait: // Bottom-left is actual bottom-left
case GfxRenderer::Portrait:
versionX = versionMargin;
versionY = screenH - 30;
break;
case GfxRenderer::PortraitInverted: // Bottom-left is actual top-right
case GfxRenderer::PortraitInverted:
versionX = screenW - textWidth - versionMargin;
versionY = 20;
break;
case GfxRenderer::LandscapeClockwise: // Bottom-left is actual bottom-right
case GfxRenderer::LandscapeClockwise:
versionX = screenW - textWidth - versionMargin;
versionY = screenH - 30;
break;
case GfxRenderer::LandscapeCounterClockwise: // Bottom-left is actual bottom-left
case GfxRenderer::LandscapeCounterClockwise:
versionX = versionMargin;
versionY = screenH - 30;
break;
}
renderer.drawText(SMALL_FONT_ID, versionX, versionY, CROSSPOINT_VERSION, false);
// Position and rotate lock icon based on current orientation (USB port location)
// USB port locations: Portrait=bottom-left, PortraitInverted=top-right,
// LandscapeCW=top-left, LandscapeCCW=bottom-right
// Position offsets: edge margin + half-width offset to center on USB port
constexpr int edgeMargin = 28; // Distance from screen edge
constexpr int halfWidth = LOCK_ICON_WIDTH / 2; // 16px offset for centering
// Position and rotate lock icon based on current orientation
constexpr int edgeMargin = 28;
constexpr int halfWidth = LOCK_ICON_WIDTH / 2;
int iconX, iconY;
GfxRenderer::ImageRotation rotation;
// Note: 90/270 rotation swaps output dimensions (W<->H)
switch (renderer.getOrientation()) {
case GfxRenderer::Portrait: // USB at bottom-left, shackle points right
case GfxRenderer::Portrait:
rotation = GfxRenderer::ROTATE_90;
iconX = edgeMargin;
iconY = screenH - LOCK_ICON_WIDTH - edgeMargin - halfWidth;
break;
case GfxRenderer::PortraitInverted: // USB at top-right, shackle points left
case GfxRenderer::PortraitInverted:
rotation = GfxRenderer::ROTATE_270;
iconX = screenW - LOCK_ICON_HEIGHT - edgeMargin;
iconY = edgeMargin + halfWidth;
break;
case GfxRenderer::LandscapeClockwise: // USB at top-left, shackle points down
case GfxRenderer::LandscapeClockwise:
rotation = GfxRenderer::ROTATE_180;
iconX = edgeMargin + halfWidth;
iconY = edgeMargin;
break;
case GfxRenderer::LandscapeCounterClockwise: // USB at bottom-right, shackle points up
case GfxRenderer::LandscapeCounterClockwise:
rotation = GfxRenderer::ROTATE_0;
iconX = screenW - LOCK_ICON_WIDTH - edgeMargin - halfWidth;
iconY = screenH - LOCK_ICON_HEIGHT - edgeMargin;
@@ -222,13 +207,13 @@ void checkForFlashCommand() {
}
renderer.drawImageRotated(LockIcon, iconX, iconY, LOCK_ICON_WIDTH, LOCK_ICON_HEIGHT, rotation);
renderer.displayBuffer(EInkDisplay::FAST_REFRESH);
// Use full refresh for clean display before flash overwrites firmware
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
}
flashCmdBuffer = "";
} else if (c != '\r') {
flashCmdBuffer += c;
// Prevent buffer overflow from random serial data (increased for version info)
if (flashCmdBuffer.length() > 30) {
if (flashCmdBuffer.length() > 50) {
flashCmdBuffer = "";
}
}
@@ -274,21 +259,20 @@ void verifyPowerButtonDuration() {
const uint16_t calibratedPressDuration =
(calibration < SETTINGS.getPowerButtonDuration()) ? SETTINGS.getPowerButtonDuration() - calibration : 1;
inputManager.update();
// Verify the user has actually pressed
gpio.update();
// Needed because inputManager.isPressed() may take up to ~500ms to return the correct state
while (!inputManager.isPressed(InputManager::BTN_POWER) && millis() - start < 1000) {
while (!gpio.isPressed(HalGPIO::BTN_POWER) && millis() - start < 1000) {
delay(10); // only wait 10ms each iteration to not delay too much in case of short configured duration.
inputManager.update();
gpio.update();
}
t2 = millis();
if (inputManager.isPressed(InputManager::BTN_POWER)) {
if (gpio.isPressed(HalGPIO::BTN_POWER)) {
do {
delay(10);
inputManager.update();
} while (inputManager.isPressed(InputManager::BTN_POWER) && inputManager.getHeldTime() < calibratedPressDuration);
abort = inputManager.getHeldTime() < calibratedPressDuration;
gpio.update();
} while (gpio.isPressed(HalGPIO::BTN_POWER) && gpio.getHeldTime() < calibratedPressDuration);
abort = gpio.getHeldTime() < calibratedPressDuration;
} else {
abort = true;
}
@@ -296,16 +280,15 @@ void verifyPowerButtonDuration() {
if (abort) {
// Button released too early. Returning to sleep.
// IMPORTANT: Re-arm the wakeup trigger before sleeping again
esp_deep_sleep_enable_gpio_wakeup(1ULL << InputManager::POWER_BUTTON_PIN, ESP_GPIO_WAKEUP_GPIO_LOW);
esp_deep_sleep_start();
gpio.startDeepSleep();
}
}
void waitForPowerRelease() {
inputManager.update();
while (inputManager.isPressed(InputManager::BTN_POWER)) {
gpio.update();
while (gpio.isPressed(HalGPIO::BTN_POWER)) {
delay(50);
inputManager.update();
gpio.update();
}
}
@@ -314,14 +297,11 @@ void enterDeepSleep() {
exitActivity();
enterNewActivity(new SleepActivity(renderer, mappedInputManager));
einkDisplay.deepSleep();
display.deepSleep();
Serial.printf("[%lu] [ ] Power button press calibration value: %lu ms\n", millis(), t2 - t1);
Serial.printf("[%lu] [ ] Entering deep sleep.\n", millis());
esp_deep_sleep_enable_gpio_wakeup(1ULL << InputManager::POWER_BUTTON_PIN, ESP_GPIO_WAKEUP_GPIO_LOW);
// Ensure that the power button has been released to avoid immediately turning back on if you're holding it
waitForPowerRelease();
// Enter Deep Sleep
esp_deep_sleep_start();
gpio.startDeepSleep();
}
void onGoHome();
@@ -381,6 +361,16 @@ void onGoToListsOrPinned() {
void onGoToFileTransfer() {
exitActivity();
// Free memory not needed during file transfer to maximize heap for webserver
RECENT_BOOKS.clearFromMemory();
APP_STATE.openBookTitle.clear();
APP_STATE.openBookTitle.shrink_to_fit();
APP_STATE.openBookAuthor.clear();
APP_STATE.openBookAuthor.shrink_to_fit();
HomeActivity::freeCoverBufferIfAllocated(); // Free 48KB cover buffer
Serial.printf("[%lu] [FT] Cleared non-essential memory before File Transfer\n", millis());
enterNewActivity(new CrossPointWebServerActivity(renderer, mappedInputManager, onGoHome));
}
@@ -396,12 +386,24 @@ void onGoToClearCache() {
void onGoToMyLibrary() {
exitActivity();
// Reload recent books if they were cleared (e.g., when exiting File Transfer mode)
if (RECENT_BOOKS.getCount() == 0) {
RECENT_BOOKS.loadFromFile();
}
enterNewActivity(
new MyLibraryActivity(renderer, mappedInputManager, onGoHome, onGoToReader, onGoToListView, onGoToBookmarkList));
}
void onGoToMyLibraryWithTab(const std::string& path, MyLibraryActivity::Tab tab) {
exitActivity();
// Reload recent books if they were cleared (e.g., when exiting File Transfer mode)
if (RECENT_BOOKS.getCount() == 0) {
RECENT_BOOKS.loadFromFile();
}
enterNewActivity(new MyLibraryActivity(renderer, mappedInputManager, onGoHome, onGoToReader, onGoToListView,
onGoToBookmarkList, tab, path));
}
@@ -413,12 +415,18 @@ void onGoToBrowser() {
void onGoHome() {
exitActivity();
// Reload recent books if they were cleared (e.g., when exiting File Transfer mode)
if (RECENT_BOOKS.getCount() == 0) {
RECENT_BOOKS.loadFromFile();
}
enterNewActivity(new HomeActivity(renderer, mappedInputManager, onContinueReading, onGoToListsOrPinned,
onGoToMyLibrary, onGoToSettings, onGoToFileTransfer, onGoToBrowser));
}
void setupDisplayAndFonts() {
einkDisplay.begin();
display.begin();
Serial.printf("[%lu] [ ] Display initialized\n", millis());
renderer.insertFont(BOOKERLY_14_FONT_ID, bookerly14FontFamily);
#ifndef OMIT_FONTS
@@ -440,42 +448,24 @@ void setupDisplayAndFonts() {
Serial.printf("[%lu] [ ] Fonts setup\n", millis());
}
bool isUsbConnected() {
// U0RXD/GPIO20 reads HIGH when USB is connected
return digitalRead(UART0_RXD) == HIGH;
}
bool isWakeupByPowerButton() {
const auto wakeupCause = esp_sleep_get_wakeup_cause();
const auto resetReason = esp_reset_reason();
if (isUsbConnected()) {
return wakeupCause == ESP_SLEEP_WAKEUP_GPIO;
} else {
return (wakeupCause == ESP_SLEEP_WAKEUP_UNDEFINED) && (resetReason == ESP_RST_POWERON);
}
}
void setup() {
t1 = millis();
// Only start serial if USB connected
pinMode(UART0_RXD, INPUT);
if (isUsbConnected()) {
Serial.begin(115200);
// Wait up to 3 seconds for Serial to be ready to catch early logs
gpio.begin();
// Always initialize Serial - safe on ESP32-C3 USB CDC even without USB connected
// (the peripheral just remains idle).
Serial.begin(115200);
// Only wait for terminal connection if USB is physically connected
// This allows catching early debug logs when a serial monitor is attached
if (gpio.isUsbConnected()) {
unsigned long start = millis();
while (!Serial && (millis() - start) < 3000) {
delay(10);
}
}
inputManager.begin();
// Initialize pins
pinMode(BAT_GPIO0, INPUT);
// Initialize SPI with custom pins
SPI.begin(EPD_SCLK, SD_SPI_MISO, EPD_MOSI, EPD_CS);
// SD Card Initialization
// We need 6 open files concurrently when parsing a new chapter
if (!SdMan.begin()) {
@@ -492,7 +482,7 @@ void setup() {
// Apply bezel compensation from settings
renderer.setBezelCompensation(SETTINGS.bezelCompensation, SETTINGS.bezelCompensationEdge);
if (isWakeupByPowerButton()) {
if (gpio.isWakeupByPowerButton()) {
// For normal wakeups, verify power button press duration
Serial.printf("[%lu] [ ] Verifying power button press duration\n", millis());
verifyPowerButtonDuration();
@@ -532,7 +522,9 @@ void loop() {
const unsigned long loopStartTime = millis();
static unsigned long lastMemPrint = 0;
inputManager.update();
gpio.update();
renderer.setFadingFix(SETTINGS.fadingFix);
if (Serial && millis() - lastMemPrint >= 10000) {
// Basic heap info
@@ -550,8 +542,7 @@ void loop() {
// Check for any user activity (button press or release) or active background work
static unsigned long lastActivityTime = millis();
if (inputManager.wasAnyPressed() || inputManager.wasAnyReleased() ||
(currentActivity && currentActivity->preventAutoSleep())) {
if (gpio.wasAnyPressed() || gpio.wasAnyReleased() || (currentActivity && currentActivity->preventAutoSleep())) {
lastActivityTime = millis(); // Reset inactivity timer
}
@@ -563,8 +554,7 @@ void loop() {
return;
}
if (inputManager.isPressed(InputManager::BTN_POWER) &&
inputManager.getHeldTime() > SETTINGS.getPowerButtonDuration()) {
if (gpio.isPressed(HalGPIO::BTN_POWER) && gpio.getHeldTime() > SETTINGS.getPowerButtonDuration()) {
enterDeepSleep();
// This should never be hit as `enterDeepSleep` calls esp_deep_sleep_start
return;

View File

@@ -371,27 +371,10 @@ void CrossPointWebServer::scanFiles(const char* path, const std::function<void(F
if (info.isDirectory) {
info.size = 0;
info.isEpub = false;
// md5 remains empty for directories
} else {
info.size = file.size();
info.isEpub = isEpubFile(info.name);
// For EPUBs, try to get cached MD5 hash
if (info.isEpub) {
// Build full file path
String fullPath = String(path);
if (!fullPath.endsWith("/")) {
fullPath += "/";
}
fullPath += fileName;
const std::string cachedMd5 =
Md5Utils::getCachedMd5(fullPath.c_str(), BookManager::CROSSPOINT_DIR, info.size);
if (!cachedMd5.empty()) {
info.md5 = String(cachedMd5.c_str());
}
// If not cached, md5 remains empty (companion app can request via /api/hash)
}
// MD5 not included in listing - clients can request via /api/hash endpoint
}
callback(info);
@@ -435,55 +418,69 @@ void CrossPointWebServer::handleFileListData() const {
}
}
// Check if we should show hidden files
bool showHidden = false;
if (server->hasArg("showHidden")) {
showHidden = server->arg("showHidden") == "true";
}
// Check if we should show hidden files (fork addition)
bool showHidden = server->hasArg("showHidden") && server->arg("showHidden") == "true";
server->setContentLength(CONTENT_LENGTH_UNKNOWN);
server->send(200, "application/json", "");
server->sendContent("[");
char output[512];
// Batch JSON entries to reduce number of sendContent calls
// This helps prevent TCP buffer overflow on memory-constrained systems
constexpr size_t BATCH_SIZE = 2048;
char batch[BATCH_SIZE];
size_t batchPos = 0;
batch[batchPos++] = '[';
char output[256]; // Single entry buffer (reduced from 512)
constexpr size_t outputSize = sizeof(output);
bool seenFirst = false;
JsonDocument doc;
scanFiles(
currentPath.c_str(),
[this, &output, &doc, seenFirst](const FileInfo& info) mutable {
[this, &batch, &batchPos, &output, &doc, &seenFirst](const FileInfo& info) mutable {
doc.clear();
doc["name"] = info.name;
doc["size"] = info.size;
doc["isDirectory"] = info.isDirectory;
doc["isEpub"] = info.isEpub;
// Include md5 field for EPUBs (null if not cached, hash string if available)
if (info.isEpub) {
if (info.md5.isEmpty()) {
doc["md5"] = nullptr; // JSON null
} else {
doc["md5"] = info.md5;
}
}
const size_t written = serializeJson(doc, output, outputSize);
if (written >= outputSize) {
// JSON output truncated; skip this entry to avoid sending malformed JSON
Serial.printf("[%lu] [WEB] Skipping file entry with oversized JSON for name: %s\n", millis(),
info.name.c_str());
return;
}
// Calculate space needed: comma (if not first) + entry
const size_t needed = (seenFirst ? 1 : 0) + written;
// If batch would overflow, send it first
if (batchPos + needed >= BATCH_SIZE - 1) {
batch[batchPos] = '\0';
server->sendContent(batch);
delay(5); // Brief delay between batch sends
batchPos = 0;
}
// Add comma separator if not first entry
if (seenFirst) {
server->sendContent(",");
batch[batchPos++] = ',';
} else {
seenFirst = true;
}
server->sendContent(output);
// Copy entry to batch
memcpy(batch + batchPos, output, written);
batchPos += written;
},
showHidden);
server->sendContent("]");
// Send remaining batch with closing bracket
batch[batchPos++] = ']';
batch[batchPos] = '\0';
server->sendContent(batch);
// End of streamed response, empty chunk to signal client
server->sendContent("");
Serial.printf("[%lu] [WEB] Served file listing page for path: %s\n", millis(), currentPath.c_str());
@@ -1264,6 +1261,16 @@ void CrossPointWebServer::handleRename() const {
}
}
bool CrossPointWebServer::sendContentSafe(const char* content) const {
if (!server || !server->client().connected()) {
return false;
}
server->sendContent(content);
return server->client().connected();
}
bool CrossPointWebServer::sendContentSafe(const String& content) const { return sendContentSafe(content.c_str()); }
bool CrossPointWebServer::copyFile(const String& srcPath, const String& destPath) const {
FsFile srcFile;
FsFile destFile;

View File

@@ -14,7 +14,6 @@ struct FileInfo {
size_t size;
bool isEpub;
bool isDirectory;
String md5; // MD5 hash for EPUBs (empty if not cached/available)
};
class CrossPointWebServer {
@@ -108,6 +107,11 @@ class CrossPointWebServer {
bool copyFile(const String& srcPath, const String& destPath) const;
bool copyFolder(const String& srcPath, const String& destPath) const;
// Helper for safe content sending with connection check
// Returns false if client disconnected, true otherwise
bool sendContentSafe(const char* content) const;
bool sendContentSafe(const String& content) const;
// List management handlers
void handleListGet() const;
void handleListPost() const;