11 Commits

Author SHA1 Message Date
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
cottongin
bc6dc357eb release: ef-0.15.99
All checks were successful
CI / build (push) Successful in 2m22s
Compile Release / build-release (push) Successful in 1m20s
First milestone release of the crosspoint-ef fork.

Key features:
- 14+ major enhancements over upstream 0.16.0
- Comprehensive documentation (crosspoint-ef-features.md, user guide)
- Fixed USB Serial blocking when device booted without USB connected
- Non-blocking Serial handling for ESP32-C3 USB CDC

See docs/crosspoint-ef-features.md for the complete feature list.
2026-01-28 17:49:20 -05:00
cottongin
ffe2aebd7e fix: guard Serial input calls when USB not connected at boot
Fixes device hanging when booted without USB connected. The root cause
was calling Serial.available() and Serial.read() in checkForFlashCommand()
when Serial.begin() was never called (USB not connected at boot).

Changes:
- Add if (!Serial) return guard to checkForFlashCommand()
- Restore upstream while (!Serial) wait loop with 3s timeout
- Remove Serial.setTxTimeoutMs(0) (not in upstream, may cause issues)
- Remove unnecessary if (Serial) guards from EpubReaderActivity.cpp
  (Serial.printf is safe without guards, only input calls need them)

Key insight: Serial.printf() is safe without guards (returns 0 when not
initialized), but Serial.available()/Serial.read() cause undefined
behavior on ESP32-C3 USB CDC when called without Serial.begin().

See: claude_notes/usb-serial-blocking-fix-2026-01-28.md
2026-01-28 17:45:00 -05:00
24 changed files with 1494 additions and 235 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.*

View File

@@ -0,0 +1,132 @@
# USB Serial Blocking Issue - Root Cause and Fix
**Date:** 2026-01-28
**Issue:** Device blocking/hanging when USB is not connected at boot
---
## Problem Description
The device would hang or behave unpredictably when booted without USB connected. This was traced to improper Serial handling on ESP32-C3 with USB CDC.
## Root Cause Analysis
### Factor A: `checkForFlashCommand()` Called Without Serial Initialization
The most critical issue was in `checkForFlashCommand()`, which is called at the start of every `loop()` iteration:
```cpp
void loop() {
checkForFlashCommand(); // Called EVERY loop iteration
// ...
}
void checkForFlashCommand() {
while (Serial.available()) { // Called even when Serial.begin() was never called!
char c = Serial.read();
// ...
}
}
```
When USB is not connected at boot, `Serial.begin()` is never called. Then in `loop()`, `checkForFlashCommand()` calls `Serial.available()` and `Serial.read()` on an uninitialized Serial object. On ESP32-C3 with USB CDC, this causes undefined behavior or blocking.
### Factor B: Removed `while (!Serial)` Wait Loop
The upstream 0.16.0 code included a 3-second wait loop after `Serial.begin()`:
```cpp
if (isUsbConnected()) {
Serial.begin(115200);
unsigned long start = millis();
while (!Serial && (millis() - start) < 3000) {
delay(10);
}
}
```
This wait loop was removed in an earlier attempt to fix boot delays, but it may be necessary for proper USB CDC initialization.
### Factor C: `Serial.setTxTimeoutMs(0)` Added Too Early
`Serial.setTxTimeoutMs(0)` was added immediately after `Serial.begin()` to make TX non-blocking. However, calling this before the Serial connection is fully established may interfere with USB CDC initialization.
---
## The Fix
### 1. Guard `checkForFlashCommand()` with Serial Check
```cpp
void checkForFlashCommand() {
if (!Serial) return; // Early exit if Serial not initialized
while (Serial.available()) {
// ... rest unchanged
}
}
```
### 2. Restore Upstream Serial Initialization Pattern
```cpp
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
unsigned long start = millis();
while (!Serial && (millis() - start) < 3000) {
delay(10);
}
}
// ... rest of setup
}
```
### 3. Remove `Serial.setTxTimeoutMs(0)`
This call was removed entirely as it's not present in upstream and may cause issues.
### 4. Remove Unnecessary `if (Serial)` Guards
The 15 `if (Serial)` guards added to `EpubReaderActivity.cpp` were removed. `Serial.printf()` is safe to call when Serial isn't initialized (it simply returns 0), so guards around output calls are unnecessary.
**Key distinction:**
- `Serial.printf()` / `Serial.println()` - Safe without guards (no-op when not initialized)
- `Serial.available()` / `Serial.read()` - **MUST** be guarded (undefined behavior when not initialized)
---
## Files Changed
| File | Change |
|------|--------|
| `src/main.cpp` | Removed `Serial.setTxTimeoutMs(0)`, restored `while (!Serial)` wait, added guard to `checkForFlashCommand()` |
| `src/activities/reader/EpubReaderActivity.cpp` | Removed all 15 `if (Serial)` guards |
---
## Testing Checklist
After applying fixes, verify:
1. ✅ Boot with USB connected, serial monitor open - should work
2. ✅ Boot with USB connected, NO serial monitor - should work (3s delay then continue)
3. ✅ Boot without USB - should work immediately (no blocking)
4. ✅ Sleep without USB, plug in USB during sleep, wake - should work
5. ✅ Sleep with USB, unplug during sleep, wake - should work
---
## Lessons Learned
1. **Always guard Serial input operations**: `Serial.available()` and `Serial.read()` must be guarded with `if (Serial)` or `if (!Serial) return` when Serial initialization is conditional.
2. **Serial output is safe without guards**: `Serial.printf()` and similar output functions are safe to call even when Serial is not initialized - they simply return 0.
3. **Don't remove initialization waits without understanding why they exist**: The `while (!Serial)` wait loop exists for proper USB CDC initialization and shouldn't be removed without careful testing.
4. **Upstream patterns exist for a reason**: When diverging from upstream behavior, especially around low-level hardware initialization, be extra cautious and test thoroughly.

105
ef-CHANGELOG.md Normal file
View File

@@ -0,0 +1,105 @@
# crosspoint-ef Changelog
All notable changes to the crosspoint-ef fork are documented here.
Base: CrossPoint Reader 0.15.0
---
## 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

@@ -281,14 +281,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 +303,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,20 +320,18 @@ 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;
}
@@ -375,28 +368,30 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const
}
// 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;

View File

@@ -98,23 +98,23 @@ bool TextBlock::serialize(FsFile& file) const {
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 +124,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;
}
}

View File

@@ -2,7 +2,7 @@
#include <EpdFontFamily.h>
#include <SdFat.h>
#include <list>
#include <vector>
#include <memory>
#include <string>
@@ -20,17 +20,17 @@ 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 +50,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

@@ -650,7 +650,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 +672,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 +706,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,6 +819,89 @@ void GfxRenderer::drawTextRotated90CW(const int fontId, const int x, const int y
}
}
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;
}
if (fontMap.count(fontId) == 0) {
Serial.printf("[%lu] [GFX] Font %d not found\n", millis(), fontId);
return;
}
const auto font = fontMap.at(fontId);
// No printable characters
if (!font.hasPrintableChars(text, style)) {
return;
}
// For 90° counter-clockwise rotation:
// Original (glyphX, glyphY) -> Rotated (-glyphY, glyphX)
// Text reads from top to bottom
int yPos = y; // Current Y position (increases as we draw characters)
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 einkDisplay.getFrameBuffer(); }
size_t GfxRenderer::getBufferSize() { return EInkDisplay::BUFFER_SIZE; }

View File

@@ -116,12 +116,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,11 @@ 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 +314,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 +353,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 +369,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 +377,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 +407,7 @@ bool StarDict::decompressDefinition(uint32_t offset, uint32_t size, std::string&
fileOffset += compressedSize;
}
free(inflator);
free(compressedBuf);
free(decompressedBuf);
file.close();
@@ -349,9 +415,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 +428,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 +469,9 @@ 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 +487,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 +499,24 @@ 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 +525,20 @@ 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 +591,10 @@ 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 +605,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

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

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

@@ -155,6 +155,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

@@ -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,61 @@ 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,
[&allBlocks](std::shared_ptr<TextBlock> block) { allBlocks.push_back(block); });
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 +209,189 @@ 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 +409,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 +425,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 +442,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

@@ -223,10 +223,13 @@ 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 screenWidth = renderer.getScreenWidth();
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 +249,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);
// 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(EInkDisplay::FAST_REFRESH);
}

View File

@@ -127,9 +127,8 @@ void EpubReaderActivity::onEnter() {
nextPageNumber = pageNumber;
hasContentOffset = true;
if (Serial)
Serial.printf("[%lu] [ERS] Loaded progress v1: spine %d, page %d, offset %u\n", millis(), currentSpineIndex,
nextPageNumber, savedContentOffset);
Serial.printf("[%lu] [ERS] Loaded progress v1: spine %d, page %d, offset %u\n", millis(), currentSpineIndex,
nextPageNumber, savedContentOffset);
} else {
// Unknown version, try legacy format
f.seek(0);
@@ -138,9 +137,8 @@ void EpubReaderActivity::onEnter() {
currentSpineIndex = data[0] + (data[1] << 8);
nextPageNumber = data[2] + (data[3] << 8);
hasContentOffset = false;
if (Serial)
Serial.printf("[%lu] [ERS] Loaded legacy progress (unknown version %d): spine %d, page %d\n", millis(),
version, currentSpineIndex, nextPageNumber);
Serial.printf("[%lu] [ERS] Loaded legacy progress (unknown version %d): spine %d, page %d\n", millis(),
version, currentSpineIndex, nextPageNumber);
}
}
} else if (fileSize >= 4) {
@@ -150,9 +148,8 @@ void EpubReaderActivity::onEnter() {
currentSpineIndex = data[0] + (data[1] << 8);
nextPageNumber = data[2] + (data[3] << 8);
hasContentOffset = false;
if (Serial)
Serial.printf("[%lu] [ERS] Loaded legacy progress: spine %d, page %d\n", millis(), currentSpineIndex,
nextPageNumber);
Serial.printf("[%lu] [ERS] Loaded legacy progress: spine %d, page %d\n", millis(), currentSpineIndex,
nextPageNumber);
}
}
f.close();
@@ -164,9 +161,8 @@ void EpubReaderActivity::onEnter() {
int textSpineIndex = epub->getSpineIndexForTextReference();
if (textSpineIndex != 0) {
currentSpineIndex = textSpineIndex;
if (Serial)
Serial.printf("[%lu] [ERS] Opened for first time, navigating to text reference at index %d\n", millis(),
textSpineIndex);
Serial.printf("[%lu] [ERS] Opened for first time, navigating to text reference at index %d\n", millis(),
textSpineIndex);
}
}
@@ -500,6 +496,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
@@ -639,8 +657,7 @@ void EpubReaderActivity::renderScreen() {
if (!section) {
const auto filepath = epub->getSpineItem(currentSpineIndex).href;
if (Serial)
Serial.printf("[%lu] [ERS] Loading file: %s, index: %d\n", millis(), filepath.c_str(), currentSpineIndex);
Serial.printf("[%lu] [ERS] Loading file: %s, index: %d\n", millis(), filepath.c_str(), currentSpineIndex);
section = std::unique_ptr<Section>(new Section(epub, currentSpineIndex, renderer));
const uint16_t viewportWidth = renderer.getScreenWidth() - orientedMarginLeft - orientedMarginRight;
@@ -651,7 +668,7 @@ void EpubReaderActivity::renderScreen() {
if (!section->loadSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
viewportHeight, SETTINGS.hyphenationEnabled)) {
if (Serial) Serial.printf("[%lu] [ERS] Cache not found, building...\n", millis());
Serial.printf("[%lu] [ERS] Cache not found, building...\n", millis());
sectionWasReIndexed = true;
// Progress bar dimensions
@@ -697,12 +714,12 @@ void EpubReaderActivity::renderScreen() {
if (!section->createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
viewportHeight, SETTINGS.hyphenationEnabled, progressSetup, progressCallback)) {
if (Serial) Serial.printf("[%lu] [ERS] Failed to persist page data to SD\n", millis());
Serial.printf("[%lu] [ERS] Failed to persist page data to SD\n", millis());
section.reset();
return;
}
} else {
if (Serial) Serial.printf("[%lu] [ERS] Cache found, skipping build...\n", millis());
Serial.printf("[%lu] [ERS] Cache found, skipping build...\n", millis());
}
// Determine the correct page to display
@@ -714,9 +731,8 @@ void EpubReaderActivity::renderScreen() {
// Use the offset to find the correct page
const int restoredPage = section->findPageForContentOffset(savedContentOffset);
section->currentPage = restoredPage;
if (Serial)
Serial.printf("[%lu] [ERS] Restored position via offset: %u -> page %d (was page %d)\n", millis(),
savedContentOffset, restoredPage, nextPageNumber);
Serial.printf("[%lu] [ERS] Restored position via offset: %u -> page %d (was page %d)\n", millis(),
savedContentOffset, restoredPage, nextPageNumber);
// Clear the offset flag since we've used it
hasContentOffset = false;
} else {
@@ -728,7 +744,7 @@ void EpubReaderActivity::renderScreen() {
renderer.clearScreen();
if (section->pageCount == 0) {
if (Serial) Serial.printf("[%lu] [ERS] No pages to render\n", millis());
Serial.printf("[%lu] [ERS] No pages to render\n", millis());
renderer.drawCenteredText(UI_12_FONT_ID, 300, "Empty chapter", true, EpdFontFamily::BOLD);
renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
renderer.displayBuffer();
@@ -736,9 +752,8 @@ void EpubReaderActivity::renderScreen() {
}
if (section->currentPage < 0 || section->currentPage >= section->pageCount) {
if (Serial)
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();
@@ -748,7 +763,7 @@ void EpubReaderActivity::renderScreen() {
{
auto p = section->loadPageFromSectionFile();
if (!p) {
if (Serial) Serial.printf("[%lu] [ERS] Failed to load page from SD - clearing section cache\n", millis());
Serial.printf("[%lu] [ERS] Failed to load page from SD - clearing section cache\n", millis());
section->clearCache();
section.reset();
return renderScreen();
@@ -756,7 +771,7 @@ void EpubReaderActivity::renderScreen() {
// Handle empty pages (e.g., from malformed chapters that couldn't be parsed)
if (p->elements.empty()) {
if (Serial) Serial.printf("[%lu] [ERS] Page has no content (possibly malformed chapter)\n", millis());
Serial.printf("[%lu] [ERS] Page has no content (possibly malformed chapter)\n", millis());
renderer.drawCenteredText(UI_12_FONT_ID, 280, "Chapter content unavailable", true, EpdFontFamily::BOLD);
renderer.drawCenteredText(UI_10_FONT_ID, 320, "(File may be malformed)");
renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
@@ -766,7 +781,7 @@ void EpubReaderActivity::renderScreen() {
const auto start = millis();
renderContents(std::move(p), orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
if (Serial) Serial.printf("[%lu] [ERS] Rendered page in %dms\n", millis(), millis() - start);
Serial.printf("[%lu] [ERS] Rendered page in %dms\n", millis(), millis() - start);
}
// Save progress with content offset for position restoration after re-indexing
@@ -782,9 +797,8 @@ void EpubReaderActivity::renderScreen() {
serialization::writePod(f, contentOffset);
f.close();
if (Serial)
Serial.printf("[%lu] [ERS] Saved progress: spine %d, page %d, offset %u\n", millis(), currentSpineIndex,
section->currentPage, contentOffset);
Serial.printf("[%lu] [ERS] Saved progress: spine %d, page %d, offset %u\n", millis(), currentSpineIndex,
section->currentPage, contentOffset);
}
}

View File

@@ -1,7 +1,7 @@
#include "KeyboardEntryActivity.h"
#include "MappedInputManager.h"
#include "activities/dictionary/DictionaryMargins.h"
#include "MappedInputManager.h"
#include "fontIds.h"
// Keyboard layouts - lowercase
@@ -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

@@ -134,6 +134,7 @@ void logMemoryState(const char* tag, const char* context) {
static String flashCmdBuffer;
void checkForFlashCommand() {
if (!Serial) return; // Early exit if Serial not initialized
while (Serial.available()) {
char c = Serial.read();
if (c == '\n') {
@@ -457,14 +458,10 @@ bool isWakeupByPowerButton() {
void setup() {
t1 = millis();
// Always initialize Serial but make it non-blocking
// This prevents Serial.printf from blocking when USB is disconnected
Serial.begin(115200);
Serial.setTxTimeoutMs(0); // Non-blocking TX - critical for USB disconnect handling
// Only wait for Serial to be ready if USB is connected
// 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
unsigned long start = millis();
while (!Serial && (millis() - start) < 3000) {
@@ -560,8 +557,7 @@ void loop() {
const unsigned long sleepTimeoutMs = SETTINGS.getSleepTimeoutMs();
if (millis() - lastActivityTime >= sleepTimeoutMs) {
if (Serial)
Serial.printf("[%lu] [SLP] Auto-sleep triggered after %lu ms of inactivity\n", millis(), sleepTimeoutMs);
Serial.printf("[%lu] [SLP] Auto-sleep triggered after %lu ms of inactivity\n", millis(), sleepTimeoutMs);
enterDeepSleep();
// This should never be hit as `enterDeepSleep` calls esp_deep_sleep_start
return;