26 Commits

Author SHA1 Message Date
cottongin
48267ad848 release: ef-1.0.4
All checks were successful
CI / build (push) Successful in 4m11s
Compile Release / build-release (push) Successful in 1m14s
New Features:
- End-of-book "Start Over" option to wrap to first page

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

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

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

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

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

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

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

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

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

- Replace std::list with std::vector in TextBlock members
- Replace splice() with move+erase in ParsedText::extractLine()
- Use index-based access in hyphenateWordAtIndex()
2026-01-29 09:52:30 -05:00
cottongin
62643ae933 checkpoint: pre list-to-vector refactor, fixes dictionary crash, mostly
- Add uncompressed dictionary (.dict) file support to avoid decompression memory issues
- Implement chunked on-demand parsing for large definitions
- Add backward navigation with re-parse capability
- Limit cached pages to MAX_CACHED_PAGES (4) to prevent memory exhaustion
- Add helper script for extracting/recompressing dictzip files
2026-01-29 09:33:40 -05:00
cottongin
8b41dccfb9 adjust .gitignore 2026-01-28 19:10:25 -05:00
cottongin
3204fa0339 fixes crash 2026-01-28 19:07:21 -05:00
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
cottongin
4965e63ad4 chore: remove chat-summaries from git tracking
All checks were successful
CI / build (push) Successful in 2m20s
These files are now gitignored as they contain session-specific notes.
2026-01-28 16:23:34 -05:00
cottongin
4db384edb6 fix: prevent Serial.printf from blocking when USB disconnected
All checks were successful
CI / build (push) Successful in 2m23s
On ESP32-C3 with USB CDC, Serial.printf() blocks indefinitely when USB
is not connected. This caused device freezes when booted without USB.

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

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

Includes documentation of the debugging process and Serial call inventory.

Also applies clang-format to fix pre-existing formatting issues.
2026-01-28 16:16:11 -05:00
cottongin
f3075002c1 style: reduce variable scope in EpubReaderActivity
Some checks failed
CI / build (push) Failing after 2m31s
Move cachedRenderer, cachedMappedInput, and cachedSection declarations
into the DICTIONARY action block where they're actually used, addressing
cppcheck variableScope warnings.
2026-01-28 10:22:01 -05:00
cottongin
3e3be8bd23 fix: correct type names and restore cached variables
Some checks failed
CI / build (push) Failing after 2m4s
2026-01-28 10:13:06 -05:00
cottongin
800b07a2e5 docs: add AI assistance disclaimer
Some checks failed
CI / build (push) Failing after 4m13s
2026-01-28 10:08:13 -05:00
cottongin
2a31559747 docs: add fork notice and documentation
Some checks failed
CI / build (push) Failing after 3m43s
- Add crosspoint-ef fork notice to README with links to upstream
- Include feature overview, user guide, and technical comparison docs
2026-01-28 10:03:45 -05:00
cottongin
c052512b1b chore: point SDK submodule to Gitea fork
Some checks failed
CI / build (push) Failing after 2m45s
Switch from upstream GitHub repo to our Gitea fork which includes:
- SDCardManager.rename() method for archive feature
- EInkDisplay grayscale improvements
2026-01-28 09:58:23 -05:00
cottongin
bd95bfd44d style: fix all cppcheck warnings
Some checks failed
CI / build (push) Failing after 3m20s
- Fix ignored return value in TxtReaderActivity
- Remove unused variables across multiple files
- Add const correctness to DictionaryMargins and EpubReaderActivity
- Replace inefficient substr patterns with resize+append
- Use STL algorithms (find_if, any_of, copy_if, transform) where applicable
- Remove dead sync placeholder code in EpubReaderChapterSelectionActivity
- Add cppcheck suppression for ValueFlow analysis limitation

Resolves all low, medium, and high severity cppcheck defects.
2026-01-28 09:45:42 -05:00
cottongin
fe446d4690 fix(ci): use system Python instead of setup-python action
Some checks failed
CI / build (push) Failing after 3m13s
The setup-python action has hardcoded paths that fail on self-hosted
macOS runners. Use system Python directly instead.

Also simplified clang-format step to use system version if available.
2026-01-28 05:20:19 -05:00
cottongin
23e73312b4 fix(ci): set RUNNER_TOOL_CACHE for setup-python action
Some checks failed
CI / build (push) Failing after 19s
The setup-python action defaults to /Users/runner which doesn't exist
on self-hosted runners. Set RUNNER_TOOL_CACHE to a writable temp path.
2026-01-28 05:17:54 -05:00
86 changed files with 4487 additions and 1763 deletions

View File

@@ -12,26 +12,30 @@ jobs:
with: with:
submodules: recursive submodules: recursive
- uses: actions/setup-python@v5 - name: Set up Python
with: run: |
python-version: '3.12' # Use system Python on self-hosted runner
python3 --version
python3 -m pip install --upgrade pip
- name: Install PlatformIO Core - name: Install PlatformIO Core
run: pip install --upgrade platformio run: python3 -m pip install --upgrade platformio
- name: Install clang-format-21
run: |
wget https://apt.llvm.org/llvm.sh
chmod +x llvm.sh
sudo ./llvm.sh 21
sudo apt-get update
sudo apt-get install -y clang-format-21
- name: Run cppcheck - name: Run cppcheck
run: pio check --fail-on-defect low --fail-on-defect medium --fail-on-defect high run: pio check --fail-on-defect low --fail-on-defect medium --fail-on-defect high
- name: Run clang-format - name: Run clang-format
run: PATH="/usr/lib/llvm-21/bin:$PATH" ./bin/clang-format-fix && git diff --exit-code || (echo "Please run 'bin/clang-format-fix' to fix formatting issues" && exit 1) run: |
# Use system clang-format if available, skip if not
if command -v clang-format &> /dev/null; then
./bin/clang-format-fix && git diff --exit-code || (echo "Please run 'bin/clang-format-fix' to fix formatting issues" && exit 1)
else
echo "clang-format not found, skipping format check"
fi
- name: Generate Dictionary Index
run: |
python3 scripts/generate_dict_index.py --zip dict-en-en.zip --output lib/StarDict/DictPrefixIndex.generated.h
- name: Build CrossPoint - name: Build CrossPoint
run: pio run run: pio run

View File

@@ -12,19 +12,18 @@ jobs:
with: with:
submodules: recursive submodules: recursive
- uses: actions/cache@v4 - name: Set up Python
with: run: |
path: | # Use system Python on self-hosted runner
~/.cache/pip python3 --version
~/.platformio/.cache python3 -m pip install --upgrade pip
key: ${{ runner.os }}-pio
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install PlatformIO Core - name: Install PlatformIO Core
run: pip install --upgrade platformio run: python3 -m pip install --upgrade platformio
- name: Generate Dictionary Index
run: |
python3 scripts/generate_dict_index.py --zip dict-en-en.zip --output lib/StarDict/DictPrefixIndex.generated.h
- name: Build CrossPoint - name: Build CrossPoint
run: pio run -e gh_release run: pio run -e gh_release

4
.gitignore vendored
View File

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

3
.gitmodules vendored
View File

@@ -1,4 +1,5 @@
[submodule "open-x4-sdk"] [submodule "open-x4-sdk"]
path = open-x4-sdk path = open-x4-sdk
url = https://github.com/open-x4-epaper/community-sdk.git url = https://code.cottongin.xyz/cottongin/community-sdk.git
branch = crosspoint-ef
ignore = dirty ignore = dirty

View File

@@ -1,4 +1,15 @@
# CrossPoint Reader # CrossPoint Reader (ef fork)
> **Note:** This is **crosspoint-ef**, a heavily customized fork of [CrossPoint Reader](https://github.com/crosspoint-reader/crosspoint-reader) with additional features, UI improvements, and bug fixes. It also uses a [forked community-sdk](https://code.cottongin.xyz/cottongin/community-sdk) with additional hardware support.
>
> **Documentation:**
> - [Feature Overview](./docs/crosspoint-ef-features.md) - What's new in this fork
> - [User Guide](./docs/crosspoint-ef-user-guide.md) - How to use the new features
> - [Technical Comparison](./docs/branch-comparison-summary.md) - Detailed diff from upstream
>
> **Disclaimer:** Much of the code in this fork was developed with assistance from [Claude](https://claude.ai), an AI assistant by Anthropic.
---
Firmware for the **Xteink X4** e-paper display reader (unaffiliated with Xteink). Firmware for the **Xteink X4** e-paper display reader (unaffiliated with Xteink).
Built using **PlatformIO** and targeting the **ESP32-C3** microcontroller. Built using **PlatformIO** and targeting the **ESP32-C3** microcontroller.

View File

@@ -1,537 +0,0 @@
# EPUB Reader Architectural Decisions
**Date:** 2026-01-23 19:47:23
**Status:** Active
**Based on:** EPUB 3.3 Compliance Audit
---
## Purpose
This document captures architectural decisions about which EPUB 3.3 features to implement, intentionally omit, or defer. Each decision includes rationale based on:
- Hardware constraints (ESP32-C3, 800x480 4-level grayscale e-ink)
- Memory limitations (~400KB SRAM, no PSRAM)
- User experience goals
- Implementation complexity
---
## ADR-001: Inline Image Support
**Status:** RECOMMENDED FOR IMPLEMENTATION
### Context
EPUBs frequently contain images for:
- Cover art
- Chapter illustrations
- Diagrams and figures
- Decorative elements
Current implementation displays `[Image: alt_text]` placeholder.
### Decision
**Implement inline image rendering** using existing infrastructure.
### Rationale
1. **Infrastructure Exists:**
- `Bitmap` class handles BMP parsing with grayscale conversion and dithering
- `JpegToBmpConverter` converts JPEG to BMP (most common EPUB image format)
- `GfxRenderer::drawBitmap()` already renders bitmaps to e-ink
- `ZipFile` can extract files from EPUB archive
- Home screen cover rendering demonstrates the pattern works
2. **Memory Management Pattern:**
- Convert and cache images to SD card (like thumbnail generation)
- Load one image at a time during page render
- Use streaming conversion to minimize RAM usage
3. **High User Impact:**
- Many EPUBs contain important visual content
- Technical books rely on diagrams
- Children's books heavily use illustrations
### Implementation Architecture
```
┌────────────────────────────────────────────────────────────────┐
│ Image Processing Pipeline │
├────────────────────────────────────────────────────────────────┤
│ │
│ EPUB ZIP ──► Extract Image ──► Convert to BMP ──► Cache to SD │
│ │ │ │
│ │ ├─ JPEG: JpegToBmpConverter│
│ │ └─ BMP: Direct copy │
│ │ │
│ └─► During page render: │
│ Load cached BMP ──► drawBitmap() │
│ │
└────────────────────────────────────────────────────────────────┘
```
### Page Element Structure
```cpp
// New PageImage element (alongside PageLine)
class PageImage final : public PageElement {
std::string cachedBmpPath; // Path to converted BMP on SD
int16_t width;
int16_t height;
public:
void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) override;
bool serialize(FsFile& file) override;
static std::unique_ptr<PageImage> deserialize(FsFile& file);
};
```
### Constraints
- **No PNG support** initially (would require adding `pngle` library)
- **Maximum image size:** Scale to viewport width, max 800x480
- **Memory budget:** ~10KB row buffer during conversion
---
## ADR-002: JavaScript/Scripting Support
**Status:** INTENTIONALLY OMITTED
### Context
EPUB 3.3 allows JavaScript in content documents for interactive features.
### Decision
**Do not implement JavaScript execution.**
### Rationale
1. **Security Risk:**
- Untrusted code execution on embedded device
- No sandboxing infrastructure
- Potential for malicious EPUBs
2. **Hardware Limitations:**
- E-ink display unsuitable for interactive content
- Limited RAM for JavaScript engine
- No benefit for static reading experience
3. **Minimal EPUB Use:**
- Most EPUBs don't use JavaScript
- Interactive textbooks target tablets, not e-readers
4. **Implementation Complexity:**
- Would require embedding V8/Duktape/QuickJS
- DOM manipulation engine
- Event handling system
### Alternative
`<script>` elements are silently ignored. Content remains readable.
---
## ADR-003: Fixed Layout (FXL) Support
**Status:** INTENTIONALLY OMITTED
### Context
EPUB Fixed Layout provides pixel-precise page positioning for:
- Comic books
- Children's picture books
- Magazines
- Technical drawings with precise layout
### Decision
**Do not implement Fixed Layout support.**
### Rationale
1. **Display Mismatch:**
- FXL designed for high-resolution color tablets
- 800x480 grayscale e-ink would require heavy downscaling
- Visual quality would be poor
2. **User Experience:**
- FXL EPUBs expect pan/zoom interaction
- E-ink refresh rate makes this impractical
- Text would be too small to read without zoom
3. **Implementation Complexity:**
- Requires full CSS positioning engine
- Viewport meta tag handling
- Coordinate transformation system
### Alternative
FXL EPUBs will open but may display incorrectly. Users should use reflowable EPUBs on this device.
---
## ADR-004: Audio/Video and Media Overlays
**Status:** HARDWARE LIMITED - CANNOT IMPLEMENT
### Context
EPUB 3.3 supports:
- `<audio>` and `<video>` elements
- Media Overlays (SMIL synchronization)
- Text-to-speech hints
### Decision
**Cannot implement due to hardware constraints.**
### Rationale
1. **No Audio Hardware:**
- Device has no speaker or audio DAC
- No audio output jack
2. **No Video Capability:**
- E-ink refresh rate (~1 Hz) incompatible with video
- No video decoding hardware
### Alternative
- Audio/video elements are ignored
- Alt text or fallback content displayed if available
- Media Overlays not processed
---
## ADR-005: Color CSS Properties
**Status:** HARDWARE LIMITED - SIMPLIFIED HANDLING
### Context
EPUBs use CSS colors for:
- Text color (`color`)
- Background color (`background-color`)
- Border colors
### Decision
**Ignore color properties; display in grayscale.**
### Rationale
1. **Hardware Constraint:**
- Display is 4-level grayscale only
- Cannot render colors
2. **Acceptable Degradation:**
- Text remains readable in black
- Background remains white
- Colored elements appear as gray variations
### Implementation
Color CSS properties are parsed but not applied. Default black text on white background used.
---
## ADR-006: Table Rendering
**Status:** DEFERRED - OPTIONAL IMPLEMENTATION
### Context
Tables appear in:
- Technical documentation
- Reference material
- Data presentations
Current implementation shows `[Table omitted]` placeholder.
### Decision
**Implement simple text-based table rendering as an optional enhancement.**
### Rationale
1. **Moderate Impact:**
- Some EPUBs use tables, but not majority
- Technical users would benefit most
2. **Complexity vs. Benefit:**
- Full table layout is complex (colspan, rowspan, sizing)
- Simple tables can be rendered as text columns
### Implementation Approach (if implemented)
```
┌──────────────────────────────────────┐
│ Text-Based Table Rendering │
├──────────────────────────────────────┤
│ │
│ Header1 │ Header2 │ Header3 │
│ ─────────────────────────────────────│
│ Data 1 │ Data 2 │ Data 3 │
│ Data 4 │ Data 5 │ Data 6 │
│ │
└──────────────────────────────────────┘
```
### Constraints
- Equal-width columns (no complex sizing)
- No colspan/rowspan support
- Truncate wide content
- Maximum 4-5 columns before overflow
---
## ADR-007: SVG and MathML
**Status:** INTENTIONALLY OMITTED
### Context
- SVG: Scalable Vector Graphics for illustrations
- MathML: Mathematical notation markup
### Decision
**Do not implement SVG or MathML rendering.**
### Rationale
1. **Implementation Complexity:**
- SVG requires full vector graphics engine
- MathML requires specialized math typesetting
2. **Limited Use:**
- Most EPUBs use raster images, not SVG
- MathML primarily in academic texts
3. **Alternative Exists:**
- Many EPUBs include fallback PNG for SVG
- MathML often has image fallback
### Alternative
Display alt text or fallback image if available.
---
## ADR-008: CSS Selector Support
**Status:** CURRENT IMPLEMENTATION SUFFICIENT
### Context
CSS selectors enable targeting elements for styling. Current support:
- Element selectors (`p`, `div`)
- Class selectors (`.classname`)
- Element.class selectors (`p.intro`)
### Decision
**Maintain current limited selector support; do not expand.**
### Rationale
1. **Sufficient for Most EPUBs:**
- 90%+ of EPUB styling uses simple selectors
- Complex selectors rarely affect core readability
2. **Implementation Complexity:**
- Descendant selectors require DOM tree
- Pseudo-selectors need state tracking
- Specificity calculation is complex
3. **Memory Constraints:**
- DOM tree would consume significant RAM
- Current streaming parser is memory-efficient
### Not Implemented
- Descendant selectors (`div p`)
- Child selectors (`ul > li`)
- Sibling selectors (`h1 + p`)
- Pseudo-classes (`:first-child`, `:hover`)
- Pseudo-elements (`::before`, `::after`)
- Attribute selectors (`[type="text"]`)
---
## ADR-009: Internal Link Navigation
**Status:** RECOMMENDED FOR IMPLEMENTATION (PHASE 2)
### Context
EPUBs use internal links for:
- Footnotes
- Cross-references
- Table of contents
- Index entries
### Decision
**Implement internal link navigation in Phase 2.**
### Rationale
1. **User Value:**
- Footnotes are common in non-fiction
- Reference navigation improves usability
2. **Complexity:**
- Requires anchor parsing and storage
- Needs selection UI for link activation
- Cross-chapter navigation adds complexity
### Implementation Architecture
```
Link Navigation Flow:
1. Parse <a href="#id"> during HTML parsing
2. Store link targets in PageLine metadata
3. Add link highlighting (underline or marker)
4. User selects link via UI
5. Resolve target: same chapter (anchor) or cross-chapter (spine + anchor)
6. Navigate to target page/position
```
### Deferred
- External links (http://) - no network navigation on e-reader
---
## ADR-010: DRM and Encryption
**Status:** INTENTIONALLY OMITTED
### Context
EPUB supports DRM through:
- `encryption.xml` in META-INF
- Adobe DRM
- Various proprietary schemes
### Decision
**Do not implement DRM support.**
### Rationale
1. **Licensing Complexity:**
- Adobe DRM requires licensing agreements
- Proprietary schemes have legal restrictions
2. **User Expectation:**
- Open-source e-reader users expect DRM-free content
- DRM conflicts with device modification philosophy
3. **Implementation Complexity:**
- Each DRM scheme is different
- Secure key storage required
- Regular updates needed for scheme changes
### Alternative
Users should remove DRM from purchased content using legal tools before loading to device.
---
## ADR-011: List Rendering Enhancements
**Status:** RECOMMENDED FOR IMPLEMENTATION (PHASE 1)
### Context
Current implementation:
- `<li>` renders bullet character `•`
- No numbered list support
- No nesting indentation
### Decision
**Enhance list rendering with ordered numbers and nesting.**
### Rationale
1. **Low Complexity:**
- Track list type (`<ol>` vs `<ul>`) in parser state
- Maintain counter for ordered lists
- Apply indentation based on nesting depth
2. **Clear User Benefit:**
- Numbered lists convey sequence
- Indentation shows hierarchy
### Implementation
```cpp
// Parser state additions
int listDepth = 0;
int orderedListCounter = 0;
bool isOrderedList = false;
// On <ol> start
isOrderedList = true;
orderedListCounter = 1;
listDepth++;
// On <li> in ordered list
addWord(std::to_string(orderedListCounter++) + ".", REGULAR);
// Apply indent
textIndent = listDepth * 20; // pixels per level
```
---
## Summary Table
| Feature | Decision | Rationale |
|---------|----------|-----------|
| Inline Images | IMPLEMENT | High impact, infrastructure ready |
| JavaScript | OMIT | Security risk, no benefit for e-ink |
| Fixed Layout | OMIT | Display mismatch, poor UX |
| Audio/Video | CANNOT | No hardware support |
| Color CSS | IGNORE | Grayscale display |
| Tables | DEFER | Moderate impact, high complexity |
| SVG/MathML | OMIT | High complexity, limited use |
| Complex CSS Selectors | OMIT | Memory constraints, limited benefit |
| Internal Links | IMPLEMENT (Phase 2) | User value for references |
| DRM | OMIT | Licensing, philosophy conflict |
| List Enhancements | IMPLEMENT (Phase 1) | Low complexity, clear benefit |
---
## Implementation Priority
### Phase 1 (Quick Wins)
- [x] Basic bullet rendering (already implemented)
- [ ] Ordered list numbering
- [ ] Nested list indentation
- [ ] Line-height CSS support
### Phase 2 (Image Support)
- [ ] Image extraction from EPUB
- [ ] JPEG to BMP conversion for inline images
- [ ] PageImage element integration
- [ ] Image scaling and layout
### Phase 3 (Navigation)
- [ ] Internal link parsing
- [ ] Link selection UI
- [ ] Anchor navigation
### Deferred/Not Planned
- PNG support (would need library addition)
- Table rendering
- Complex CSS selectors
- JavaScript, FXL, DRM

View File

@@ -1,262 +0,0 @@
# EPUB 3.3 Compliance Feature Prioritization
**Date:** 2026-01-23 19:46:02
**Based on:** EPUB 3.3 Compliance Audit
---
## Overview
This document reviews the audit findings from the CrossPoint Reader EPUB 3.3 compliance audit and prioritizes features for implementation based on:
1. **User Impact** - How much the feature improves the reading experience
2. **Implementation Complexity** - Level of effort required
3. **Existing Infrastructure** - Available code that can be leveraged
4. **Hardware Feasibility** - Whether the e-ink display can support it
---
## Priority 1: High Impact, Infrastructure Ready
These features have existing code that can be leveraged and provide significant user value.
### 1.1 Inline Image Rendering
**Current State:** Images show placeholder text `[Image: alt_text]`
**Infrastructure Available:**
- `Bitmap` class (`lib/GfxRenderer/Bitmap.h`) - BMP parsing with grayscale conversion and dithering
- `JpegToBmpConverter` (`lib/JpegToBmpConverter/`) - JPEG to BMP conversion with prescaling
- `GfxRenderer::drawBitmap()` - Already renders bitmaps to e-ink display
- `ZipFile` - Can extract images from EPUB archive
**Implementation Approach:**
1. Extend `ChapterHtmlSlimParser` to extract image `src` attributes
2. Create `PageImage` element (alongside existing `PageLine`)
3. Extract and cache images from EPUB ZIP to SD card as BMP
4. Integrate image blocks into page layout calculations
5. Render images inline with text during page display
**Complexity:** Medium
**User Impact:** High - Many EPUBs have important diagrams, illustrations, and decorative images
**Supported Formats:**
- JPEG (via `picojpeg`) - Most common in EPUBs
- BMP (native support)
- PNG - **Not currently supported** (would need `pngle` or similar library)
---
### 1.2 List Markers (Bullets and Numbers)
**Current State:** List items render without visual markers; `<li>` already adds bullet character
**Infrastructure Available:**
- `ChapterHtmlSlimParser` already handles `<li>` tags (line 248 adds bullet)
- Font system supports Unicode characters
**Implementation Approach:**
1. Track list type (`<ol>` vs `<ul>`) in parser state
2. For `<ol>`: maintain counter, render as "1.", "2.", etc.
3. For `<ul>`: use bullet character (already implemented: `\xe2\x80\xa2`)
4. Apply text indentation for list item content
**Current Code (already adds bullet):**
```cpp
if (strcmp(name, "li") == 0) {
self->currentTextBlock->addWord("\xe2\x80\xa2", EpdFontFamily::REGULAR);
}
```
**Complexity:** Low
**User Impact:** Medium - Improves readability of enumerated content
---
### 1.3 Nested List Indentation
**Current State:** Nested lists are flattened to same indentation level
**Implementation Approach:**
1. Track nesting depth in parser (`listDepth` counter)
2. Apply progressive `text-indent` based on depth (e.g., 20px per level)
3. Store depth in `BlockStyle` for rendering
**Complexity:** Low
**User Impact:** Medium - Important for technical documentation and outlines
---
## Priority 2: Medium Impact, Moderate Complexity
### 2.1 Basic Table Rendering
**Current State:** Tables show `[Table omitted]` placeholder
**Implementation Approach:**
1. Parse table structure (`<table>`, `<tr>`, `<td>`, `<th>`)
2. Calculate column widths based on content or proportional division
3. Render as text rows with column separators (e.g., `|` character)
4. Apply header styling (bold) for `<th>` elements
**Constraints:**
- Fixed-width font may be needed for alignment
- Complex tables (colspan, rowspan) would remain unsupported
- Wide tables may need horizontal truncation
**Complexity:** Medium-High
**User Impact:** Medium - Some EPUBs have data tables, but many don't
---
### 2.2 Internal Link Navigation
**Current State:** Links render as plain text, not interactive
**Implementation Approach:**
1. Parse `<a href="#id">` attributes during HTML parsing
2. Store link targets in page elements
3. Add UI for link selection (highlight/underline links)
4. Implement navigation to target anchor when activated
5. Handle cross-chapter links (resolve to spine + anchor)
**Complexity:** Medium-High (requires UI changes)
**User Impact:** Medium - Important for reference material, footnotes
---
### 2.3 Line Height CSS Support
**Current State:** Line height is fixed based on font metrics
**Implementation Approach:**
1. Add `line-height` property to `CssParser`
2. Store in `CssStyle` struct
3. Apply multiplier to `getLineHeight()` during layout
**Complexity:** Low
**User Impact:** Medium - Improves text density matching to publisher intent
---
## Priority 3: Lower Impact or Higher Complexity
### 3.1 Font Size CSS Support
**Current State:** Font size controlled by reader settings only
**Implementation Approach:**
1. Parse `font-size` in `CssParser` (relative units: em, rem, %)
2. Map to available font sizes (snap to nearest)
3. Apply during text rendering
**Constraints:**
- Limited font size options in bitmap fonts
- Large size changes may cause layout issues
**Complexity:** Medium
**User Impact:** Low-Medium - Most content uses default sizing
---
### 3.2 Page-List Navigation
**Current State:** Only TOC navigation supported
**Implementation Approach:**
1. Parse `<nav epub:type="page-list">` in navigation document
2. Store page reference mappings
3. Add UI to jump to specific page numbers
**Complexity:** Medium
**User Impact:** Low - Useful for academic/reference texts with page citations
---
### 3.3 Landmarks Navigation
**Current State:** Not implemented
**Implementation Approach:**
1. Parse `<nav epub:type="landmarks">`
2. Extract semantic markers (cover, toc, bodymatter, etc.)
3. Add quick-nav UI
**Complexity:** Low-Medium
**User Impact:** Low - Convenience feature
---
## Features NOT Recommended for Implementation
These features are either hardware-limited or provide minimal value for the target use case.
### Hardware Limited (Cannot Implement)
| Feature | Reason |
|---------|--------|
| Color images/CSS | Monochrome 4-level grayscale display |
| Audio playback | No audio hardware |
| Video playback | No video hardware, e-ink refresh too slow |
| Animations | E-ink refresh latency incompatible |
| Media Overlays | Requires audio hardware |
### Not Worth Implementing
| Feature | Reason |
|---------|--------|
| JavaScript | Security concerns, minimal EPUB use, high complexity |
| Fixed Layout (FXL) | Designed for tablets; poor e-ink experience |
| SVG | Complex parser needed, limited use in text EPUBs |
| MathML | Extremely complex, niche use case |
| CSS Grid/Flexbox | Overkill for text layout |
| @font-face | Memory constraints, limited benefit |
| DRM | Licensing complexity, user expectation of DRM-free |
| Forms | Interactive elements unsuitable for e-ink |
---
## Implementation Roadmap
### Phase 1: Quick Wins (Low Effort, High Value)
1. ~~List bullet rendering~~ (already implemented)
2. Ordered list numbering
3. Nested list indentation
4. Line-height CSS support
### Phase 2: Image Support (Medium Effort, High Value)
1. JPEG image extraction and caching
2. BMP image support
3. `PageImage` element integration
4. Image scaling to viewport width
5. (Optional) PNG support via external library
### Phase 3: Enhanced Navigation (Medium Effort, Medium Value)
1. Internal link parsing
2. Link highlighting/selection UI
3. Anchor navigation within chapters
4. Cross-chapter link navigation
### Phase 4: Table Support (High Effort, Medium Value)
1. Basic table parsing
2. Simple column layout
3. Text-based table rendering
4. Header styling
---
## Summary
| Priority | Feature | Complexity | Impact | Dependencies |
|----------|---------|------------|--------|--------------|
| 1.1 | Inline Images | Medium | High | Bitmap, JpegToBmpConverter |
| 1.2 | Ordered List Numbers | Low | Medium | None |
| 1.3 | Nested List Indent | Low | Medium | None |
| 2.1 | Basic Tables | Medium-High | Medium | None |
| 2.2 | Internal Links | Medium-High | Medium | UI changes |
| 2.3 | Line Height CSS | Low | Medium | CssParser |
| 3.1 | Font Size CSS | Medium | Low-Medium | Font system |
| 3.2 | Page-List Nav | Medium | Low | Navigation system |
| 3.3 | Landmarks Nav | Low-Medium | Low | Navigation system |
**Recommended First Implementation:** Start with Phase 1 quick wins (list improvements, line-height), then proceed to Image Support as it has the highest user-visible impact and leverages existing infrastructure.

View File

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

View File

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

View File

@@ -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.

View File

@@ -0,0 +1,422 @@
# Branch Comparison Summary: crosspoint-ef vs 0.16.0
This document provides a comprehensive comparison between the `crosspoint-ef` branch and the upstream `0.16.0` release for merge planning and implementation decisions.
## Branch History
| Branch | Base | Commits Since Base | Status |
|--------|------|-------------------|--------|
| `crosspoint-ef` | 0.15.0 | 90+ | Active development |
| `0.16.0` | 0.15.0 | 30 | Released |
Both branches diverged from `0.15.0` at commit `3ce11f14`.
---
## Feature Comparison Matrix
### Major Features
| Feature | crosspoint-ef | 0.16.0 | Notes |
|---------|:-------------:|:------:|-------|
| Dictionary Support | Yes | No | StarDict format with word selection |
| Bookmark System | Yes | No | Per-book bookmarks with visual indicator |
| Quick Menu | Yes | No | Power button quick access |
| Library Search | Yes | No | Character picker with weighted search |
| CSS Parsing | Yes | No | Element, class, inline styles |
| Inline Images (PNG/JPEG) | Yes | No | With caching and dithering |
| Custom Fonts | Yes | No | Atkinson Hyperlegible, Fern Micro |
| Enhanced Web Server | Yes | Partial | File ops, MD5 API, mDNS |
| Companion App API | Yes | No | Deep links, WebSocket uploads |
| Reading Lists | Yes | No | With pinning support |
| Tab Bar Enhancements | Yes | No | Scrolling, overflow indicators |
| High Contrast Mode | Yes | No | System-wide |
| Bezel Compensation | Yes | No | Edge defect compensation |
| Sleep Screen Edge Detection | Yes | No | Dominant color fill |
| Recents Improvements | Yes | No | Badges, removal, clearing |
| Progress Bar Status Bar | Yes | Yes | Same feature |
| Spanish Hyphenation | No | Yes | Missing in crosspoint-ef |
| XTC/XTCH Author Extraction | No | Yes | Missing in crosspoint-ef |
| OTA Rework | No | Yes | Different implementation |
| KOReader MD5 Binary Matching | No | Yes | Missing in crosspoint-ef |
| Relative Position on Settings Change | No | Yes | Missing in crosspoint-ef |
| Multi-line Keyboard Entry | No | Yes | Missing in crosspoint-ef |
| Italics on Image Alt | No | Yes | Missing in crosspoint-ef |
| Page Turn on Button Press (UX) | No | Yes | When chapter skip disabled |
### Bug Fixes
| Fix | crosspoint-ef | 0.16.0 | Notes |
|-----|:-------------:|:------:|-------|
| Large EPUB indexing O(n²)→O(n) | Yes | Yes | Same fix |
| Settings validation on read | Yes | Yes | Same fix |
| Line break fixes | Yes | Yes | Similar fixes |
| Rotate origin in drawImage | Yes | Yes | Same fix |
| Short-press power wakeup | Yes | Yes | Same fix |
| TXT books in recent tab | Yes | Yes | Same fix |
| B&W filters for covers | Yes | Yes | Same fix |
| Cover fit artifacts | Yes | Yes | Same fix |
| Grayscale state corruption | Yes | No | Unique to crosspoint-ef |
| Memory graceful degradation | Yes | No | Unique to crosspoint-ef |
| Chapter Selection UI (KOReader) | No | Yes | Missing in crosspoint-ef |
| Front layout in mapLabels() | No | Yes | Missing in crosspoint-ef |
---
## Files Changed Summary
### crosspoint-ef Unique Files (New)
| Category | Files |
|----------|-------|
| Dictionary | `src/activities/dictionary/` (8 files), `lib/StarDict/` (4 files) |
| Bookmarks | `src/BookmarkStore.cpp/.h`, `src/activities/home/BookmarkListActivity.cpp/.h` |
| Quick Menu | `src/activities/util/QuickMenuActivity.cpp/.h` |
| CSS | `lib/Epub/Epub/css/` (3 files) |
| Images | `lib/Epub/Epub/blocks/ImageBlock.cpp/.h`, `lib/Epub/Epub/converters/` (6 files) |
| Custom Fonts | `src/customFonts.cpp`, `src/fontIds.h`, `lib/EpdFont/builtinFonts/custom/` (50+ files) |
| Utils | `src/util/Md5Utils.cpp/.h`, `src/util/StringUtils.cpp/.h` |
| Lists | `src/BookListStore.cpp/.h` |
| Docs | `docs/webserver-api-reference.md`, `docs/companion-app-deep-link-API.md`, `docs/troubleshooting.md` |
### crosspoint-ef Modified Files (Significant Changes)
| File | Changes |
|------|---------|
| `src/network/CrossPointWebServer.cpp` | +1083 lines (file ops, API, WebSocket) |
| `src/activities/home/MyLibraryActivity.cpp` | +700 lines (tabs, search, badges) |
| `src/main.cpp` | +255 lines (feature integration) |
| `lib/GfxRenderer/GfxRenderer.cpp` | +439 lines (contrast, bezel) |
| `lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp` | +596 lines (CSS integration) |
| `src/CrossPointSettings.cpp/.h` | New settings fields |
| `src/activities/boot_sleep/SleepActivity.cpp` | Edge detection, caching |
| `src/RecentBooksStore.cpp` | Badges, removal, metadata |
| `src/ScreenComponents.cpp` | Tab bar enhancements |
### 0.16.0 Unique Files/Changes
| File | Description |
|------|-------------|
| `lib/Epub/Epub/hyphenation/generated/hyph-es.trie.h` | Spanish hyphenation (removed in ef) |
| `lib/KOReaderSync/` | KOReader credential handling (removed in ef) |
| `src/network/OtaUpdater.cpp` | OTA rework |
---
## Merge Strategy Recommendations
### Phase 1: Cherry-pick 0.16.0 Fixes into crosspoint-ef
**Low Risk - Recommended First:**
1. **Spanish hyphenation support** (#558)
- Add `hyph-es.trie.h` back
- Update `LanguageRegistry.cpp`
2. **Render keyboard entry over multiple lines** (#567)
- Update `KeyboardEntryActivity.cpp`
3. **Correctly render italics on image alt** (#569)
- Minimal change to text rendering
4. **Page turning on button pressed** (#451)
- UX improvement when chapter skip disabled
5. **Missing front layout in mapLabels()** (#564)
- Bug fix for button mapping
**Medium Risk:**
6. **KOReader document MD5 binary matching** (#529)
- May conflict with MD5Utils changes
7. **Chapter Selection UI bugs** (#501)
- Review for conflicts with tab bar changes
8. **Relative position on settings change** (#486)
- Reader state management change
**Higher Risk:**
9. **OTA feature rework** (#509)
- Compare implementations, may need reconciliation
- crosspoint-ef has different OTA changes
10. **Extract author from XTC/XTCH** (#563)
- XTC format was removed in crosspoint-ef
- Evaluate if needed
### Phase 2: Potential Upstream Contributions from crosspoint-ef
**High Value, Moderate Complexity:**
1. **Dictionary Support**
- Self-contained feature
- New files, minimal integration points
- Requires shipping dictionary data
2. **Bookmark System**
- Clean implementation
- New files with reader integration
3. **Quick Menu**
- Simple overlay feature
- Depends on bookmark and dictionary
4. **CSS Parsing**
- Significant EPUB improvement
- Well-isolated in `lib/Epub/Epub/css/`
**High Value, Higher Complexity:**
5. **Inline Image Support**
- Major EPUB enhancement
- Multiple new converters
- Memory management considerations
6. **Library Search**
- Integrated into MyLibraryActivity
- Tab bar changes included
7. **Enhanced Web Server**
- Large changes to CrossPointWebServer
- New API endpoints
- WebSocket uploads
**Medium Value:**
8. **Custom Fonts**
- Large binary additions (font headers)
- Clean integration
9. **Display Enhancements**
- High contrast, bezel compensation
- Settings additions
10. **Reading Lists**
- New feature with web API
---
## Potential Conflicts
### High Conflict Risk
| Area | crosspoint-ef | 0.16.0 | Resolution |
|------|---------------|--------|------------|
| `MyLibraryActivity.cpp` | Major restructure | Minor fixes | Manual merge required |
| `CrossPointWebServer.cpp` | Extensive additions | Minimal changes | crosspoint-ef likely compatible |
| `CrossPointSettings.h` | Many new fields | Few changes | Additive, low conflict |
| `main.cpp` | Feature integration | Minor changes | Review integration points |
| `OtaUpdater.cpp` | Modified | Reworked (#509) | Compare implementations |
### Low Conflict Risk
| Area | Notes |
|------|-------|
| Dictionary files | All new, no conflicts |
| Bookmark files | All new, no conflicts |
| CSS parser files | All new, no conflicts |
| Image converter files | All new, no conflicts |
| Custom font files | All new, no conflicts |
| StarDict library | All new, no conflicts |
---
## Testing Considerations
### Regression Testing Required
After any merge:
1. **EPUB Reading**
- Page navigation
- Chapter selection
- CSS styling
- Image rendering
- Bookmark indicators
2. **Library Functions**
- Tab navigation
- Search functionality
- Recent books display
- List management
3. **Dictionary**
- Word selection
- Lookup accuracy
- Definition display
4. **Web Server**
- File upload/download
- API endpoints
- WebSocket uploads
- mDNS discovery
5. **Settings**
- All new settings persist correctly
- Settings migration from older versions
6. **Display**
- High contrast mode
- Bezel compensation (all orientations)
- Sleep screen variations
### Memory Testing
crosspoint-ef includes memory optimization fixes. After merge:
1. Test with large EPUBs (2000+ chapters)
2. Test opening multiple books in sequence
3. Test anti-aliasing under memory pressure
4. Monitor for ghosting/artifacts
---
## Priority Recommendations
### Immediate (For crosspoint-ef Stability)
1. Cherry-pick Spanish hyphenation (#558)
2. Cherry-pick multi-line keyboard entry (#567)
3. Cherry-pick italics on image alt (#569)
4. Cherry-pick front layout fix (#564)
### Short-term (Feature Completeness)
5. Evaluate OTA rework (#509) - compare implementations
6. Cherry-pick page turn UX (#451)
7. Cherry-pick relative position fix (#486)
### Long-term (Upstream Contribution)
8. Prepare dictionary feature as PR
9. Prepare bookmark system as PR
10. Prepare CSS parsing as PR
11. Evaluate inline image support for upstream
---
## File Inventory for Merge
### Files to Add to 0.16.0 Base (for upstream contribution)
```
src/activities/dictionary/
DictionaryMargins.h
DictionaryMenuActivity.cpp
DictionaryMenuActivity.h
DictionaryResultActivity.cpp
DictionaryResultActivity.h
DictionarySearchActivity.cpp
DictionarySearchActivity.h
EpubWordSelectionActivity.cpp
EpubWordSelectionActivity.h
src/activities/util/
QuickMenuActivity.cpp
QuickMenuActivity.h
src/activities/home/
BookmarkListActivity.cpp
BookmarkListActivity.h
src/
BookmarkStore.cpp
BookmarkStore.h
BookListStore.cpp
BookListStore.h
customFonts.cpp
fontIds.h
BadgeConfig.h
src/util/
Md5Utils.cpp
Md5Utils.h
StringUtils.cpp
StringUtils.h
src/images/
LockIcon.h
lib/StarDict/
StarDict.cpp
StarDict.h
DictHtmlParser.cpp
DictHtmlParser.h
DictPrefixIndex.generated.h
lib/Epub/Epub/css/
CssParser.cpp
CssParser.h
CssStyle.h
lib/Epub/Epub/blocks/
ImageBlock.cpp
ImageBlock.h
BlockStyle.h
lib/Epub/Epub/converters/
FramebufferWriter.cpp
FramebufferWriter.h
ImageDecoderFactory.cpp
ImageDecoderFactory.h
ImageToFramebufferDecoder.cpp
ImageToFramebufferDecoder.h
JpegToFramebufferConverter.cpp
JpegToFramebufferConverter.h
PngToFramebufferConverter.cpp
PngToFramebufferConverter.h
lib/EpdFont/builtinFonts/custom/
[All font header files]
docs/
webserver-api-reference.md
companion-app-deep-link-API.md
troubleshooting.md
crosspoint-ef-features.md
crosspoint-ef-user-guide.md
```
### Files to Merge Carefully
```
src/main.cpp
src/CrossPointSettings.cpp
src/CrossPointSettings.h
src/network/CrossPointWebServer.cpp
src/network/CrossPointWebServer.h
src/network/OtaUpdater.cpp
src/network/OtaUpdater.h
src/activities/home/MyLibraryActivity.cpp
src/activities/home/MyLibraryActivity.h
src/activities/reader/EpubReaderActivity.cpp
src/activities/settings/SettingsActivity.cpp
src/activities/settings/CategorySettingsActivity.cpp
src/ScreenComponents.cpp
src/ScreenComponents.h
src/RecentBooksStore.cpp
src/RecentBooksStore.h
lib/GfxRenderer/GfxRenderer.cpp
lib/GfxRenderer/GfxRenderer.h
lib/GfxRenderer/BitmapHelpers.cpp
lib/GfxRenderer/BitmapHelpers.h
lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp
lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h
lib/Epub/Epub/Section.cpp
lib/Epub/Epub/Section.h
```
---
## Conclusion
The `crosspoint-ef` branch represents a significant enhancement over the `0.15.0` baseline with 14+ major features. Most features are cleanly isolated in new files, making selective upstream contribution feasible.
**Recommended approach:**
1. First, bring crosspoint-ef up to date with 0.16.0 bug fixes
2. Then, evaluate individual features for upstream PR submission
3. Prioritize dictionary, bookmarks, and CSS parsing as highest-value contributions
The grayscale state corruption fix in crosspoint-ef should also be submitted upstream as a critical bug fix, as it prevents display artifacts under memory pressure.

View File

@@ -0,0 +1,603 @@
# CrossPoint-EF Branch Features
This document describes the features and enhancements unique to the `crosspoint-ef` branch, which diverged from CrossPoint Reader at version `0.15.0`.
## Overview
The `crosspoint-ef` branch introduces significant new functionality including:
- **Dictionary Support** - Offline StarDict dictionary with word selection from reader
- **Bookmark System** - Per-book bookmarks with visual indicators and management
- **Quick Menu** - Fast access to common actions via power button
- **Library Search** - Search across all books by title, author, or filename
- **CSS Support** - Parse and apply CSS styles from EPUB files
- **Inline Images** - PNG and Baseline JPEG image rendering within EPUBs
- **Custom Fonts** - Atkinson Hyperlegible Next and Fern Micro accessibility fonts
- **Enhanced Web Server** - File management, companion app API, mDNS discovery
- **Reading Lists** - Create, manage, and pin custom book lists
- **Display Enhancements** - High contrast mode, bezel compensation, sleep screen improvements
---
## Major Features
### 1. Dictionary Support
Full offline dictionary lookup using the StarDict format with fast prefix-indexed search.
**Features:**
- Word selection directly from EPUB pages
- Manual word entry via on-screen keyboard
- Rich HTML formatting in definitions (bold, italic, lists)
- Multi-page definitions with pagination
- Synonym support
- Case-insensitive search with prefix optimization
**Access Methods:**
- **Quick Menu** → Dictionary
- **Power Button** (when configured to `Dictionary` action)
**Dictionary Format:**
- StarDict format with dictzip compression
- Files located at `/dictionaries/dict-data` on SD card:
- `dict-data.ifo` - Metadata
- `dict-data.idx` - Word index
- `dict-data.dict.dz` - Compressed definitions
- `dict-data.syn` - Synonyms (optional)
**Technical Implementation:**
- Files: `src/activities/dictionary/`, `lib/StarDict/`
- Prefix jump table for near-instant lookups
- On-demand chunk decompression using miniz
- HTML definition parsing with entity decoding
---
### 2. Bookmark System
Per-book bookmark storage with visual indicators and dedicated management interface.
**Features:**
- Add/remove bookmarks from current page
- Visual folded-corner indicator on bookmarked pages
- Bookmarks tab in library showing all books with bookmarks
- Long-press to delete bookmarks
- Auto-generated bookmark names ("Chapter Title - Page X")
- Maximum 100 bookmarks per book
**Storage:**
- Binary file per book: `/.crosspoint/{epub_|txt_}<hash>/bookmarks.bin`
- Stores: name, spine index, content offset, page number, timestamp
**Technical Implementation:**
- Files: `src/BookmarkStore.cpp/.h`, `src/activities/home/BookmarkListActivity.cpp/.h`
- Bookmark identification by `spineIndex + contentOffset` (stable across re-renders)
---
### 3. Quick Menu
In-reader quick access menu for common actions, triggered by short power button press.
**Menu Options:**
1. **Dictionary** - Look up a word
2. **Bookmark** - Add/Remove bookmark (state-aware text)
3. **Clear Cache** - Free up storage space
4. **Settings** - Open settings menu
**Configuration:**
- Settings → Controls → Short Power Button Click → `Quick Menu`
**Technical Implementation:**
- File: `src/activities/util/QuickMenuActivity.cpp/.h`
- Renders overlay with navigation and selection
---
### 4. Library Search with Character Picker
Search across all books using a dynamic character picker interface.
**Features:**
- Character picker with dynamically generated character set from library content
- Weighted search scoring:
- Title match: 100 points (+50 if at start)
- Author match: 80 points (+40 if at start)
- Path match: 30 points
- Results sorted by relevance score
- Special controls: SPC (space), ← (backspace), CLR (clear)
**Navigation:**
- Left/Right: Select character
- Confirm: Add character to query
- Up/Down: Switch between picker and results
**Technical Implementation:**
- Integrated in `src/activities/home/MyLibraryActivity.cpp`
- Search tab accessible from library tab bar
---
### 5. CSS Support for EPUBs
Parse and apply CSS styles from EPUB stylesheets.
**Supported Selectors:**
- Element selectors: `p`, `div`, `h1`, etc.
- Class selectors: `.classname`
- Combined selectors: `element.classname`
- Grouped selectors: `h1, h2, h3`
- Inline styles: `style="..."`
**Supported Properties:**
- `text-align` (left, center, right, justify)
- `font-style` (normal, italic)
- `font-weight` (normal, bold)
- `text-decoration` (underline)
- `text-indent`
- `margin-top`, `margin-bottom`
- `padding-top`, `padding-bottom`
**Cascade Order:**
1. Element styles
2. Class styles
3. Element.class styles
4. Inline styles
**Technical Implementation:**
- Files: `lib/Epub/Epub/css/CssParser.cpp/.h`, `CssStyle.h`
- CSS files parsed during EPUB loading
- Styles applied during HTML parsing via `ChapterHtmlSlimParser`
---
### 6. Inline Image Support (PNG/Baseline JPEG)
Render embedded images within EPUB content.
**Supported Formats:**
- Baseline JPEG (.jpg, .jpeg)
- PNG (.png)
**Features:**
- Images decoded to 2-bit grayscale with dithering
- Image caching as `.pxc` files (2 bits per pixel, packed format)
- Row-by-row rendering to minimize memory usage
- Automatic scaling to fit page width
**Technical Implementation:**
- Files: `lib/Epub/Epub/blocks/ImageBlock.cpp/.h`
- Converters: `JpegToFramebufferConverter`, `PngToFramebufferConverter`
- Factory: `ImageDecoderFactory` routes to appropriate decoder
---
### 7. Custom Fonts
Additional accessibility-focused fonts beyond the standard Bookerly and Noto Sans.
**Available Fonts:**
1. **Atkinson Hyperlegible Next** - Designed for low-vision readers
2. **Fern Micro** - Optimized for small screens
**Font Sizes:**
- 12pt, 14pt, 16pt, 18pt for each font
- Full style support: Regular, Italic, Bold, Bold Italic
**Configuration:**
- Settings → Reader → Font Family → Custom
- Settings → Reader → Custom Font → [Select font]
- Settings → Reader → Fallback Font → [Bookerly/Noto Sans]
**Technical Implementation:**
- Files: `src/customFonts.cpp`, `src/fontIds.h`
- Font headers: `lib/EpdFont/builtinFonts/custom/`
- Conversion scripts: `lib/EpdFont/scripts/convert-builtin-fonts.sh`
---
### 8. Enhanced Web Server
Extended web server with file management operations and companion app support.
**File Operations:**
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/files` | GET | List files with MD5 hashes for EPUBs |
| `/api/status` | GET | Device status (version, IP, heap, uptime) |
| `/api/archived` | GET | List archived books |
| `/api/hash` | GET | Compute/retrieve MD5 hash for sync |
| `/download` | GET | Download files |
| `/upload` | POST | Upload files (multipart) |
| `/delete` | POST | Delete files/folders |
| `/archive` | POST | Archive a book |
| `/unarchive` | POST | Restore archived book |
| `/rename` | POST | Rename files/folders |
| `/copy` | POST | Copy files/folders |
| `/move` | POST | Move files/folders |
| `/mkdir` | POST | Create folders |
| `/list` | GET/POST | Manage reading lists |
**WebSocket Upload (Port 81):**
- Fast binary uploads for large files
- Protocol: `START:<filename>:<size>:<path>``READY` → binary chunks → `DONE`
- Progress updates: `PROGRESS:<received>:<total>`
**mDNS Discovery:**
- Hostname: `crosspoint.local`
- Service: `_http._tcp` on port 80
- UDP discovery on port 8134
**Deep Link Support:**
- URL scheme: `crosspoint://<path>?host=<ip>&port=<port>&wsPort=<wsPort>`
- Paths: `files`, `library`, `lists`, `settings`
**Technical Implementation:**
- Files: `src/network/CrossPointWebServer.cpp/.h`
- MD5 Utils: `src/util/Md5Utils.cpp/.h`
- Docs: `docs/webserver-api-reference.md`, `docs/companion-app-deep-link-API.md`
---
### 9. Reading Lists with Pinning
Create and manage custom book lists with pinning support.
**Features:**
- Create, load, delete lists
- Pin a list to show on home screen
- List contents displayed with book metadata
- Web API for list upload/download (CSV format)
**Storage:**
- Lists stored in `/.lists/` as `.bin` files
- CSV format for API: `order,title,author,path`
**Configuration:**
- Library → Lists tab → Long-press → Pin/Unpin
- Pinned list name shown on home screen Lists button
**Technical Implementation:**
- Files: `src/BookListStore.cpp/.h`
- Pinned list stored in `SETTINGS.pinnedListName`
---
### 10. Display Enhancements
#### High Contrast Mode
System-wide display contrast adjustment for improved readability.
- **Normal mode:** Standard grayscale thresholds
- **High contrast mode:** Pushes mid-grays toward black/white
**Configuration:**
- Settings → Display → High Contrast → On/Off
#### Bezel Compensation
Compensate for physical screen edge defects with configurable margin.
- **Range:** 0-10 pixels
- **Edges:** Bottom, Top, Left, Right
- **Behavior:** Margin rotates with screen orientation
**Configuration:**
- Settings → Display → Bezel Compensation → [0-10]
- Settings → Display → Bezel Edge → [Bottom/Top/Left/Right]
#### Sleep Screen Improvements
Enhanced sleep screen with edge-aware color filling.
- **Edge luminance detection:** Samples edge pixels for dominant color
- **Letterbox fill:** Fills letterbox regions with edge colors for seamless appearance
- **Two-level caching:**
- Per-BMP cache: `.bmp.perim` files store edge luminance
- Book-level cache: `edge.bin` stores cover data
---
### 11. Recents View Improvements
Enhanced recent books display with metadata and management.
**Features:**
- **Badges:** Extension and suffix tags (e.g., "epub", "X4")
- **Metadata display:** Title and author from EPUB
- **Remove from recents:** Long-press → Remove from Recents
- **Clear all:** Long-press → Clear All Recents
**Badge Configuration:**
- Extension badges: `.epub` → "epub", `.txt` → "txt"
- Suffix badges: `-x4` → "X4", `-x4p` → "X4P"
**Technical Implementation:**
- Files: `src/RecentBooksStore.cpp/.h`
- Badge config: `src/BadgeConfig.h`
- String utils: `src/util/StringUtils.cpp/.h`
---
### 12. Enhanced Tab Bar
Unified tab bar with scrolling and overflow indicators.
**Features:**
- Horizontal scrolling when tabs exceed available width
- Overflow indicators (< >) when content extends beyond view
- Selected tab highlighting with underline
- Bullet cursors for focus mode
**Tabs:**
- Recent, Lists, Bookmarks, Search, Files
**Technical Implementation:**
- Files: `src/ScreenComponents.cpp/.h`
- Used in `MyLibraryActivity` for library navigation
---
### 13. Progress Bar Status Bar
Additional status bar option showing visual progress bar.
**Options:**
- `Full w/ Progress Bar` - Full status bar with progress bar
- `Progress Bar` - Only progress bar, no other status info
**Configuration:**
- Settings → Display → Status Bar
---
### 14. Additional Settings
New configuration options unique to crosspoint-ef:
| Setting | Options | Description |
|---------|---------|-------------|
| Short Power Button Click | Ignore, Sleep, Page Turn, Dictionary, Quick Menu | Map power button to action |
| Bezel Compensation | 0-10 pixels | Edge defect compensation |
| Bezel Edge | Bottom, Top, Left, Right | Which edge to compensate |
| High Contrast | Off, On | System-wide contrast boost |
| Custom Font | [Font list] | Select custom font |
| Fallback Font | Bookerly, Noto Sans | Fallback for custom fonts |
---
### 15. OPDS Browser Enhancements
Improved OPDS catalog browsing experience.
**Features:**
- **Navigation history stack** - Back button navigates through visited feeds
- **Page skipping** - Hold Up/Down for 700ms to skip 23 items at once
- **Error retry mechanism** - Retry button on error screens with WiFi status check
- **HTTP Basic Authentication** - Username/password support for protected OPDS servers
**Configuration:**
- Settings → System → Calibre Settings → OPDS Server URL
- Settings → System → Calibre Settings → Username/Password
**Technical Implementation:**
- Files: `src/activities/browser/OpdsBookBrowserActivity.cpp`
- Authentication: `src/network/HttpDownloader.cpp`
---
### 16. Development Tools
Scripts and utilities for firmware development.
**Scripts:**
| Script | Purpose |
|--------|---------|
| `pre_flash.py` | Displays "Flashing firmware..." screen during upload |
| `debugging_monitor.py` | Enhanced serial monitor with memory graphs and color output |
| `pio_helper.py` | Interactive PlatformIO workflow helper with presets |
| `version_hash.py` | Embeds git commit hash in dev builds |
| `build_html.py` | Minifies HTML files for web server |
**Firmware Flashing Screen:**
- Full-screen display during firmware upload
- Shows "Flashing firmware..." with version info
- Lock icon indicates USB port location
- Prevents accidental disconnection
**Debug/Memory Monitoring:**
- `DEBUG_MEMORY` build mode for heap tracking at activity transitions
- Periodic memory logging every 10 seconds (when Serial connected)
- Loop duration warnings when exceeding 50ms
- Detailed heap fragmentation info
**Technical Implementation:**
- Scripts: `scripts/pre_flash.py`, `scripts/debugging_monitor.py`, `scripts/pio_helper.py`
- Flash screen: `src/main.cpp` (lines 138-247)
- Memory monitoring: `src/main.cpp` (lines 549-562)
- Build config: `platformio.ini` (`debug_memory` environment)
---
### 17. Power Management Enhancements
Optimizations for battery life and responsiveness.
**Features:**
- **Auto-sleep prevention** - Background tasks (web server, OTA, downloads) prevent auto-sleep
- **USB connection detection** - Serial only starts when USB is connected (saves power)
- **Skip loop delay** - Activities can request faster loop execution for responsive HTTP handling
- **Power button release wait** - Prevents immediate wake if button is still held
**Technical Implementation:**
- Files: `src/main.cpp`, `src/activities/Activity.h`
- Methods: `preventAutoSleep()`, `skipLoopDelay()`
---
## Bug Fixes
### Unique to crosspoint-ef
1. **Grayscale state corruption fix** - Prevents ghosting and gray filter artifacts when anti-aliasing is enabled under memory pressure
2. **Memory optimization** - Graceful degradation when memory is low (skip anti-aliasing instead of corrupting display)
### Shared with 0.16.0
- Large EPUB indexing optimization (O(n²) → O(n))
- Settings validation on read
- Line break fixes (flush word before `<br/>`)
- Rotate origin in `drawImage()`
- Short-press power button to wakeup
- Add txt books to recent tab
- B&W filters for cover images
---
## Missing or Removed Features from 0.16.0
The following features are present in the upstream `0.16.0` release but are missing or were removed in `crosspoint-ef`:
### Removed Features
#### 1. KOReader Sync Support (Removed)
The entire KOReader sync functionality has been removed.
**What was removed:**
- `lib/KOReaderSync/` library (8 files deleted)
- Progress sync with KOReader sync server (`sync.koreader.rocks`)
- Document MD5 binary matching for progress synchronization
- KOReader credential storage
**Impact:**
- Cannot sync reading progress with KOReader app
- Chapter Selection UI fixes for KOReader sync (#501) not applicable
**Files deleted:**
- `KOReaderSyncClient.cpp/.h`
- `KOReaderCredentialStore.cpp/.h`
- `KOReaderDocumentId.cpp/.h`
- `ProgressMapper.cpp/.h`
---
#### 2. Non-English Hyphenation Patterns (Removed)
Hyphenation pattern files for non-English languages have been removed.
**What was removed:**
- `hyph-es.trie.h` - Spanish hyphenation
- `hyph-de.trie.h` - German hyphenation
- `hyph-fr.trie.h` - French hyphenation
- `hyph-ru.trie.h` - Russian hyphenation
**Impact:**
- Only English hyphenation patterns remain
- Non-English books will not hyphenate correctly
- Spanish hyphenation support (#558) not available
**Note:** These can be restored by copying the trie files from 0.16.0.
---
#### 3. XTC/XTCH File Support (Removed)
Support for the XTC/XTCH proprietary format has been removed.
**What was removed:**
- Author extraction from XTC/XTCH files (#563)
- XTC format handling in file browsers
**Impact:**
- XTC/XTCH files cannot be read
- Author metadata not extracted from these formats
---
### Missing Bug Fixes
The following bug fixes from 0.16.0 have not been applied to crosspoint-ef:
| PR | Description | Impact |
|----|-------------|--------|
| #567 | Multi-line keyboard entry | Long text input truncated with "..." instead of wrapping |
| #569 | Italics on image alt text | Image alt placeholders don't render in italics |
| #564 | Front layout in mapLabels() | Button mapping may be incorrect in some layouts |
| #486 | Relative position on settings change | Reader may jump to different location when settings change |
| #501 | Chapter Selection UI (KOReader) | N/A - KOReader sync removed |
| #529 | KOReader MD5 binary matching | N/A - KOReader sync removed |
---
### Missing UX Enhancements
| PR | Description | Impact |
|----|-------------|--------|
| #451 | Page turn on button press | When long-press chapter skip is disabled, 0.16.0 allows page turn on button press; crosspoint-ef does not |
---
### Different Implementation: OTA Updates
The OTA update mechanism uses a different implementation:
| Aspect | crosspoint-ef | 0.16.0 |
|--------|---------------|--------|
| HTTP Client | Arduino `HTTPClient` | ESP-IDF `esp_http_client` |
| OTA Library | Arduino `Update` | ESP-IDF `esp_https_ota` |
| Memory Management | Standard | Improved with custom buffer handling |
**Impact:**
- Both implementations work, but 0.16.0's ESP-IDF approach may be more memory-efficient
- Consider evaluating 0.16.0's OTA rework (#509) for potential adoption
---
### Recommendation for Missing Features
**High Priority to Cherry-pick:**
1. Multi-line keyboard entry (#567) - Improves UX for long inputs
2. Front layout fix (#564) - Bug fix for button mapping
3. Relative position on settings change (#486) - Improves reader UX
**Medium Priority:**
4. Restore hyphenation patterns for non-English languages
5. Italics on image alt (#569) - Minor visual improvement
6. Page turn on button press (#451) - UX enhancement
**Evaluate:**
7. OTA rework (#509) - Compare implementations for memory benefits
8. KOReader sync - Restore if sync functionality is desired
---
## File Summary
| Feature | Primary Files |
|---------|---------------|
| Dictionary | `src/activities/dictionary/`, `lib/StarDict/` |
| Bookmarks | `src/BookmarkStore.*`, `src/activities/home/BookmarkListActivity.*` |
| Quick Menu | `src/activities/util/QuickMenuActivity.*` |
| Search | `src/activities/home/MyLibraryActivity.cpp` |
| CSS | `lib/Epub/Epub/css/` |
| Images | `lib/Epub/Epub/blocks/ImageBlock.*`, `lib/Epub/Epub/converters/` |
| Custom Fonts | `src/customFonts.cpp`, `lib/EpdFont/builtinFonts/custom/` |
| Web Server | `src/network/CrossPointWebServer.*`, `src/util/Md5Utils.*` |
| Lists | `src/BookListStore.*` |
| Settings | `src/CrossPointSettings.*` |
| Tab Bar | `src/ScreenComponents.*` |
| Recents | `src/RecentBooksStore.*`, `src/BadgeConfig.h` |
| OPDS Browser | `src/activities/browser/OpdsBookBrowserActivity.*` |
| Dev Tools | `scripts/pre_flash.py`, `scripts/debugging_monitor.py`, `scripts/pio_helper.py` |
| Power Management | `src/main.cpp`, `src/activities/Activity.h` |
---
## Version Information
- **Base version:** 0.15.0
- **Branch:** crosspoint-ef
- **Commits since divergence:** 90+
- **Files changed:** 250+

View File

@@ -0,0 +1,555 @@
# CrossPoint-EF User Guide Supplement
This guide covers the additional features available in the `crosspoint-ef` branch. For basic operation, refer to the main [User Guide](../USER_GUIDE.md).
## Table of Contents
- [Dictionary](#dictionary)
- [Bookmarks](#bookmarks)
- [Quick Menu](#quick-menu)
- [Library Search](#library-search)
- [Reading Lists](#reading-lists)
- [Display Settings](#display-settings)
- [Web Server Features](#web-server-features)
- [Custom Fonts](#custom-fonts)
- [Additional Settings](#additional-settings)
---
## Dictionary
The dictionary feature provides offline word lookup while reading.
### Setup
1. Download a StarDict dictionary (English-English dictionary provided as `dict-en-en.zip`)
2. Extract the dictionary files to `/dictionaries/dict-data/` on your SD card
3. You should have these files:
- `dict-data.ifo`
- `dict-data.idx`
- `dict-data.dict.dz`
- `dict-data.syn` (optional, for synonyms)
### Using the Dictionary
#### Method 1: Quick Menu
1. While reading, press the **Power** button briefly (requires Quick Menu to be configured)
2. Select **Dictionary** from the menu
3. Choose **Select from Screen** or **Enter a Word**
#### Method 2: Direct Power Button Access
1. Go to **Settings → Controls → Short Power Button Click**
2. Set to **Dictionary**
3. While reading, press the **Power** button briefly to open the dictionary
### Selecting a Word from the Page
1. Choose **Select from Screen** from the dictionary menu
2. The current page will display with word selection enabled
3. Use **Left/Right** to move between words
4. Use **Up/Down** to jump between lines
5. Press **Confirm** to look up the selected word
6. Press **Back** to cancel
### Viewing Definitions
- Definitions display with rich formatting (bold, italic, lists)
- Use **Left/Right** or **Volume Up/Down** to navigate between pages if the definition is long
- Press **Confirm** to search for another word
- Press **Back** to return to your book
---
## Bookmarks
Create and manage bookmarks within your books.
### Adding a Bookmark
#### Method 1: Quick Menu
1. Press the **Power** button briefly (requires Quick Menu to be configured)
2. Select **Add Bookmark** (or **Remove Bookmark** if already bookmarked)
#### Method 2: Settings Configuration
1. Go to **Settings → Controls → Short Power Button Click**
2. Set to **Quick Menu**
3. Use Quick Menu to toggle bookmarks
### Bookmark Indicator
When a page is bookmarked, a small folded corner triangle appears in the top-right corner of the page.
### Viewing Bookmarks
1. Go to **Home → Library**
2. Select the **Bookmarks** tab
3. You'll see a list of books that have bookmarks
4. Select a book to view its bookmarks
5. Select a bookmark to jump to that location
### Deleting Bookmarks
1. Open a book's bookmark list (from Bookmarks tab)
2. Navigate to the bookmark you want to delete
3. **Long-press Confirm** (hold for about 1 second)
4. Confirm deletion when prompted
### Bookmark Naming
Bookmarks are automatically named based on:
- Chapter title and page number (e.g., "Chapter 3 - Page 42")
- Just page number if no chapter title (e.g., "Page 15")
---
## Quick Menu
Fast access to common actions while reading.
### Enabling Quick Menu
1. Go to **Settings → Controls → Short Power Button Click**
2. Select **Quick Menu**
### Using Quick Menu
1. While reading, press the **Power** button briefly
2. Navigate with **Up/Down** or **Left/Right**
3. Press **Confirm** to select an option
4. Press **Back** to close the menu
### Quick Menu Options
| Option | Description |
|--------|-------------|
| **Dictionary** | Look up a word |
| **Add/Remove Bookmark** | Toggle bookmark on current page |
| **Clear Cache** | Free up storage space |
| **Settings** | Open settings menu |
---
## Library Search
Search your library by title, author, or filename.
### Accessing Search
1. Go to **Home → Library**
2. Select the **Search** tab
3. Or from any tab, scroll to the bottom and select **Search...**
### Using the Character Picker
The search uses a character picker interface:
1. **Left/Right** - Move between characters
2. **Confirm** - Add character to search query
3. **SPC** - Add a space
4. **←** - Delete last character (backspace)
5. **CLR** - Clear entire query
### Navigating Results
1. After entering characters, results appear below
2. Press **Down** to move from character picker to results
3. **Left/Right** to navigate results
4. **Confirm** to open a book
5. **Up** to return to character picker
### Search Scoring
Results are ranked by relevance:
- Title matches rank highest
- Author matches rank second
- Filename matches rank lowest
- Matches at the start of a field rank higher
---
## Reading Lists
Create custom book lists for organizing your library.
### Viewing Lists
1. Go to **Home → Library**
2. Select the **Lists** tab
3. Available lists are displayed
### Opening a List
1. Navigate to a list name
2. Press **Confirm** to view the list contents
3. Select a book to start reading
### Pinning a List
Pin a list to quickly access it from the home screen:
1. In the Lists tab, navigate to a list
2. **Long-press Confirm** to open the action menu
3. Select **Pin List**
The pinned list name will appear on the Lists button on the home screen.
### Unpinning a List
1. Navigate to the pinned list
2. **Long-press Confirm**
3. Select **Unpin List**
### Deleting a List
1. Navigate to a list
2. **Long-press Confirm**
3. Select **Delete List**
4. Confirm deletion
### Creating Lists via Web Server
Lists can be created and uploaded via the web server API. See [Web Server Features](#web-server-features).
---
## Display Settings
### High Contrast Mode
Increases contrast across the entire UI for better readability.
1. Go to **Settings → Display → High Contrast**
2. Set to **On** or **Off**
When enabled, mid-gray tones are pushed toward black or white.
### Bezel Compensation
Compensate for physical screen edge defects (common on some devices).
1. Go to **Settings → Display → Bezel Compensation**
2. Set value from **0** (disabled) to **10** pixels
3. If compensation is enabled, select **Bezel Edge**:
- **Bottom** - Default, compensates bottom edge
- **Top** - Compensates top edge
- **Left** - Compensates left edge
- **Right** - Compensates right edge
The compensation margin automatically rotates with screen orientation.
### Status Bar Options
Additional status bar display options:
| Option | Description |
|--------|-------------|
| None | No status bar |
| No Progress | Status bar without reading progress |
| Full w/ Percentage | Status bar with percentage progress |
| Full w/ Progress Bar | Status bar with visual progress bar |
| Progress Bar | Only progress bar, no other info |
Configure at **Settings → Display → Status Bar**.
### Sleep Screen Cover Filter
When using book cover as sleep screen:
| Filter | Effect |
|--------|--------|
| None | Grayscale image as-is |
| Contrast | Black and white only (no grays) |
| Inverted | Inverted black and white |
Configure at **Settings → Display → Sleep Screen Cover Filter**.
---
## Web Server Features
The web server provides extended file management and companion app support.
### Starting the Web Server
1. Go to **Home → File Transfer**
2. Select a WiFi network or create a hotspot
3. The web server URL will be displayed
### File Management
Access the file manager at `http://<device-ip>/files`
**Available Operations:**
- **Upload** - Upload files via drag-and-drop or file picker
- **Download** - Download files to your computer
- **Delete** - Remove files and folders
- **Rename** - Rename files and folders
- **Create Folder** - Create new directories
- **Archive/Unarchive** - Archive books (preserves reading progress)
- **Copy/Move** - Copy or move files and folders
### API Access
The web server provides a JSON API for programmatic access:
| Endpoint | Description |
|----------|-------------|
| `GET /api/status` | Device status |
| `GET /api/files?path=/` | List files |
| `GET /api/archived` | List archived books |
| `GET /api/hash?path=/book.epub` | Get MD5 hash |
### mDNS Discovery
The device advertises itself as `crosspoint.local` on your network.
### Companion App Support
The web server supports the CrossPoint Companion Android app:
1. **QR Code** - Scan the QR code displayed on the web server screen
2. **Deep Links** - URLs like `crosspoint://files?host=192.168.1.100` open the app directly
### Managing Reading Lists via API
**Get all lists:**
```
GET /list
```
**Get specific list:**
```
GET /list?name=MyList
```
**Upload a list:**
```
POST /list?action=upload&name=MyList
Content-Type: text/plain
1,Book Title,Author Name,/path/to/book.epub
2,Another Book,Another Author,/path/to/another.epub
```
**Delete a list:**
```
POST /list?action=delete&name=MyList
```
---
## Custom Fonts
Two additional accessibility-focused fonts are available.
### Available Custom Fonts
1. **Atkinson Hyperlegible Next** - Designed for low-vision readers with high character differentiation
2. **Fern Micro** - Optimized for small screens
### Enabling Custom Fonts
1. Go to **Settings → Reader → Font Family**
2. Select **Custom**
3. Go to **Settings → Reader → Custom Font**
4. Select your preferred font
### Fallback Font
When using custom fonts, set a fallback for missing glyphs:
1. Go to **Settings → Reader → Fallback Font**
2. Choose **Bookerly** or **Noto Sans**
---
## Additional Settings
### Short Power Button Actions
Configure what happens when you briefly press the Power button:
| Option | Action |
|--------|--------|
| Ignore | No action (default) |
| Sleep | Put device to sleep |
| Page Turn | Turn to next page |
| Dictionary | Open dictionary |
| Quick Menu | Open quick menu |
Configure at **Settings → Controls → Short Power Button Click**.
### Long-press Chapter Skip
Control side button long-press behavior:
- **On** (default) - Long-press Volume buttons to skip chapters
- **Off** - Long-press scrolls a page instead
Configure at **Settings → Controls → Long-press Chapter Skip**.
### Hyphenation
Enable word hyphenation for justified text:
1. Go to **Settings → Reader → Hyphenation**
2. Set to **On**
Hyphenation patterns are available for multiple languages (English, German, French, Spanish, Russian, etc.).
---
## Recents View Enhancements
### Badges
Books in the Recent tab display badges showing:
- **File extension** (epub, txt, md)
- **Suffix tags** (X4, X4P for files with `-x4` or `-x4p` suffixes)
### Removing from Recents
1. Navigate to a book in the Recent tab
2. **Long-press Confirm**
3. Select **Remove from Recents**
### Clearing All Recents
1. Navigate to any book in the Recent tab
2. **Long-press Confirm**
3. Select **Clear All Recents**
4. Confirm the action
---
## Tab Navigation
The library uses a unified tab bar for navigation.
### Tabs Available
| Tab | Contents |
|-----|----------|
| Recent | Recently opened books |
| Lists | Custom reading lists |
| Bookmarks | Books with bookmarks |
| Search | Search all books |
| Files | File browser |
### Navigating Tabs
When the tab bar is focused:
- **Left/Right** - Switch between tabs
- **Down** - Enter the selected tab's content
- **Confirm** - Same as Down
### Tab Overflow
When tabs don't fit on screen:
- **<** indicator appears on left when more tabs exist to the left
- **>** indicator appears on right when more tabs exist to the right
- Scroll continues automatically when navigating past visible tabs
---
## Inline Images
EPUBs with embedded images now display them inline with text.
### Supported Formats
- JPEG (.jpg, .jpeg)
- PNG (.png)
### Image Display
- Images are automatically scaled to fit the page width
- Images are converted to 4-level grayscale with dithering
- First load may be slower as images are processed
- Subsequent loads use cached versions
### Image Cache
Processed images are cached as `.pxc` files in the book's cache directory for faster loading.
---
## Troubleshooting
### Dictionary Not Working
1. Verify dictionary files are in `/dictionaries/dict-data/`
2. Check that all required files exist (.ifo, .idx, .dict.dz)
3. File names must match exactly (case-sensitive)
### Bookmarks Not Saving
1. Ensure SD card is not write-protected
2. Check available storage space
3. Bookmarks are saved per-book in `/.crosspoint/`
### Search Not Finding Books
1. Search only indexes books in the library
2. Ensure books have proper EPUB metadata
3. Try searching by filename if metadata is missing
### Images Not Displaying
1. Only PNG and JPEG formats are supported
2. Very large images may fail to load due to memory constraints
3. Check for sufficient free memory (multiple large books open may exhaust memory)
### Web Server Connection Issues
1. Ensure device and computer are on the same network
2. Try accessing via IP address instead of `crosspoint.local`
3. Check that firewall isn't blocking port 80
---
## Keyboard Shortcuts Summary
### In Reader
| Button | Action |
|--------|--------|
| Left/Volume Up | Previous page |
| Right/Volume Down | Next page |
| Left (hold) | Previous chapter |
| Right (hold) | Next chapter |
| Back | Return to library |
| Back (hold) | Return to home |
| Confirm | Open chapter selection |
| Power (brief) | Configured action (Quick Menu/Dictionary/Sleep/Page Turn) |
### In Quick Menu
| Button | Action |
|--------|--------|
| Up/Down/Left/Right | Navigate options |
| Confirm | Select option |
| Back | Close menu |
### In Word Selection
| Button | Action |
|--------|--------|
| Left/Right | Move between words |
| Up/Down | Move between lines |
| Confirm | Look up word |
| Back | Cancel |
### In Library Tabs
| Button | Action |
|--------|--------|
| Left/Right | Switch tabs (when tab bar focused) |
| Up/Down | Navigate within tab |
| Confirm | Select item / Enter tab |
| Confirm (hold) | Action menu |
| Back | Go back / Exit to home |

139
ef-CHANGELOG.md Normal file
View File

@@ -0,0 +1,139 @@
# crosspoint-ef Changelog
All notable changes to the crosspoint-ef fork are documented here.
Base: CrossPoint Reader 0.15.0
---
## ef-1.0.4
**EPUB Rendering & Stability**
### New Features
- **End-of-Book "Start Over"**: Press next at end of book to wrap to first page
### EPUB Rendering Improvements
- CSS `margin-left`/`padding-left` parsing for block indentation
- Vertical bar and italic styling for blockquotes
- Left margin indentation for list items (`<ol>`/`<ul>`)
- Fixed ordered lists showing bullets instead of numbers
- Fixed nested `<p>` inside `<li>` causing marker on separate line
### Bug Fixes
- **Webserver**: Fixed file listing disconnection issues with flow control
- **Webserver**: Memory optimization for File Transfer mode (frees heap before starting)
- **Dictionary**: Fixed zip dictionary allocation order for better memory allocation success
---
## ef-1.0.3
**Maintenance Release**
### Bug Fixes
- Fixed cppcheck CI failure: removed unused `screenWidth` variable in word selection activity
---
## ef-1.0.2
**Quick Menu Enhancements**
### New Features
- **Screen Rotation Toggle**: Quick toggle between Portrait and Landscape CCW directly from the quick menu
- Automatically reindexes content for new screen dimensions
- Preserves reading position via content offset restoration
- **Customizable Menu Order**: Reorder quick menu items to your preference
- New "Edit List Order" option at bottom of menu
- Pick-and-place reordering: select item, navigate to destination, place
- Order persists across sessions
### UI Improvements
- Added navigation button hints to quick menu (prev/next on front buttons, up/down on side buttons)
- Fixed orientation-aware margins for button hint areas in landscape modes
- New default menu order: Bookmark, Dictionary, Rotate Screen, Settings, Clear Cache
---
## ef-1.0.1
**Dictionary Stability & UX Improvements**
### Bug Fixes - Stability
- Fixed dictionary crashes caused by heap fragmentation from repeated page navigation
- Refactored TextBlock/ParsedText from `std::list` to `std::vector`, reducing heap allocations by ~12x per TextBlock
- Affects EPUB reader page rendering, dictionary definition display, and word selection
- Contiguous memory improves cache locality during text layout and reduces heap fragmentation on the memory-constrained ESP32
- Added uncompressed dictionary (`.dict`) support to avoid decompression memory issues with large dictzip chunks (58KB chunks -> direct read)
- Implemented chunked on-demand HTML parsing for large definitions, parsing pages as user navigates rather than all at once
- Limited cached pages to 4 with re-parse capability for backward navigation beyond cache window
- Fixed double-button press bug when loading new dictionary chunks
### Bug Fixes - UI/Layout
- Restored proper orientation-aware button hint spacing (front: 45px, side: 50px)
- Added side button hints to definition screen with "<" / ">" labels for page navigation
- Added side button hints to word selection screen ("UP"/"DOWN" labels, borderless, small font)
- Added side button hints to dictionary menu ("< Prev", "Next >")
- Moved page indicator up to avoid bezel cutoff in landscape orientations
---
## ef-1.0.0
**First Official Release** (previously ef-0.15.99)
First milestone release of the crosspoint-ef fork, building on CrossPoint Reader 0.15.0 with 14+ major new features and enhancements.
### New Features
- **Dictionary Support**: Offline StarDict dictionary with word selection from reader, fast prefix-indexed search, rich HTML formatting, and multi-page pagination
- **Bookmark System**: Per-book bookmarks with visual folded-corner indicators, dedicated management interface, and auto-generated bookmark names
- **Quick Menu**: In-reader quick access menu for common actions (Dictionary, Bookmark, Clear Cache, Settings) via short power button press
- **Library Search**: Search across all books by title, author, or filename with dynamic character picker and weighted relevance scoring
- **CSS Support**: Parse and apply CSS styles from EPUB stylesheets (text-align, font-style, font-weight, text-decoration, margins, padding)
- **Inline Image Support**: PNG and Baseline JPEG rendering within EPUB content with 2-bit grayscale dithering and caching
- **Custom Fonts**: Atkinson Hyperlegible Next (low-vision readers) and Fern Micro (small screens)
- **Enhanced Web Server**: File management (upload, download, delete, rename, copy, move, mkdir), companion app API, WebSocket uploads, mDNS discovery at `crosspoint.local`
- **Reading Lists**: Create, manage, and pin custom book lists with web API support (CSV format)
- **Enhanced Tab Bar**: Unified tab bar with horizontal scrolling and overflow indicators (Recent, Lists, Bookmarks, Search, Files)
- **Progress Bar Status**: Additional status bar option showing visual reading progress
- **OPDS Browser Enhancements**: Navigation history, page skipping (hold Up/Down), error retry, HTTP Basic Auth support
### Display Enhancements
- **High Contrast Mode**: System-wide contrast adjustment
- **Bezel Compensation**: Configurable margin (0-10px) for physical screen edge defects
- **Sleep Screen Improvements**: Edge-aware color filling for seamless letterbox appearance
### Bug Fixes
- Fixed device hanging when booted without USB connected (Serial.available()/Serial.read() called without Serial.begin())
- Fixed grayscale state corruption causing ghosting artifacts when anti-aliasing enabled under memory pressure
- Memory optimization with graceful degradation when memory is low
### Development Tools
- `pre_flash.py`: Displays "Flashing firmware..." screen during upload
- `debugging_monitor.py`: Enhanced serial monitor with memory graphs
- `pio_helper.py`: Interactive PlatformIO workflow helper
---
## Differences from Upstream 0.16.0
This fork is based on upstream 0.15.0. The following 0.16.0 features are not included:
- KOReader sync support
- Non-English hyphenation patterns (Spanish, German, French, Russian)
- XTC/XTCH file format support
See [crosspoint-ef-features.md](docs/crosspoint-ef-features.md) for complete feature documentation.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,5 +13,7 @@ struct BlockStyle {
int8_t marginBottom = 0; // 0-2 lines int8_t marginBottom = 0; // 0-2 lines
int8_t paddingTop = 0; // 0-2 lines (treated same as margin) int8_t paddingTop = 0; // 0-2 lines (treated same as margin)
int8_t paddingBottom = 0; // 0-2 lines (treated same as margin) int8_t paddingBottom = 0; // 0-2 lines (treated same as margin)
int16_t textIndent = 0; // pixels int16_t textIndent = 0; // pixels (first line indent)
int16_t marginLeft = 0; // pixels (horizontal indent for entire block)
bool hasLeftBorder = false; // draw vertical bar in left margin (for blockquotes)
}; };

View File

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

View File

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

View File

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

View File

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

View File

@@ -19,6 +19,9 @@ constexpr size_t MIN_SIZE_FOR_PROGRESS = 50 * 1024; // 50KB
const char* BLOCK_TAGS[] = {"p", "li", "div", "br", "blockquote"}; const char* BLOCK_TAGS[] = {"p", "li", "div", "br", "blockquote"};
constexpr int NUM_BLOCK_TAGS = sizeof(BLOCK_TAGS) / sizeof(BLOCK_TAGS[0]); constexpr int NUM_BLOCK_TAGS = sizeof(BLOCK_TAGS) / sizeof(BLOCK_TAGS[0]);
const char* LIST_TAGS[] = {"ol", "ul"};
constexpr int NUM_LIST_TAGS = sizeof(LIST_TAGS) / sizeof(LIST_TAGS[0]);
const char* BOLD_TAGS[] = {"b", "strong"}; const char* BOLD_TAGS[] = {"b", "strong"};
constexpr int NUM_BOLD_TAGS = sizeof(BOLD_TAGS) / sizeof(BOLD_TAGS[0]); constexpr int NUM_BOLD_TAGS = sizeof(BOLD_TAGS) / sizeof(BOLD_TAGS[0]);
@@ -55,6 +58,7 @@ BlockStyle createBlockStyleFromCss(const CssStyle& cssStyle) {
blockStyle.paddingTop = cssStyle.paddingTop; blockStyle.paddingTop = cssStyle.paddingTop;
blockStyle.paddingBottom = cssStyle.paddingBottom; blockStyle.paddingBottom = cssStyle.paddingBottom;
blockStyle.textIndent = static_cast<int16_t>(cssStyle.indentPixels); blockStyle.textIndent = static_cast<int16_t>(cssStyle.indentPixels);
blockStyle.marginLeft = static_cast<int16_t>(cssStyle.marginLeft);
return blockStyle; return blockStyle;
} }
@@ -83,7 +87,7 @@ void ChapterHtmlSlimParser::updateEffectiveInlineStyle() {
// Flush the contents of partWordBuffer to currentTextBlock // Flush the contents of partWordBuffer to currentTextBlock
void ChapterHtmlSlimParser::flushPartWordBuffer() { void ChapterHtmlSlimParser::flushPartWordBuffer() {
if (partWordBufferIndex == 0) return; if (partWordBufferIndex == 0) return;
// Determine font style using effective styles // Determine font style using effective styles
EpdFontFamily::Style fontStyle = EpdFontFamily::REGULAR; EpdFontFamily::Style fontStyle = EpdFontFamily::REGULAR;
if (effectiveBold && effectiveItalic) { if (effectiveBold && effectiveItalic) {
@@ -93,7 +97,7 @@ void ChapterHtmlSlimParser::flushPartWordBuffer() {
} else if (effectiveItalic) { } else if (effectiveItalic) {
fontStyle = EpdFontFamily::ITALIC; fontStyle = EpdFontFamily::ITALIC;
} }
// Flush the buffer // Flush the buffer
partWordBuffer[partWordBufferIndex] = '\0'; partWordBuffer[partWordBufferIndex] = '\0';
currentTextBlock->addWord(partWordBuffer, fontStyle, effectiveUnderline); currentTextBlock->addWord(partWordBuffer, fontStyle, effectiveUnderline);
@@ -290,7 +294,7 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
} else { } else {
placeholder = "[Image unavailable]"; placeholder = "[Image unavailable]";
} }
self->startNewTextBlock(TextBlock::CENTER_ALIGN); self->startNewTextBlock(TextBlock::CENTER_ALIGN);
self->italicUntilDepth = std::min(self->italicUntilDepth, self->depth); self->italicUntilDepth = std::min(self->italicUntilDepth, self->depth);
self->depth += 1; self->depth += 1;
@@ -320,6 +324,18 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
// Determine if this is a block element // Determine if this is a block element
bool isBlockElement = matches(name, HEADER_TAGS, NUM_HEADER_TAGS) || matches(name, BLOCK_TAGS, NUM_BLOCK_TAGS); bool isBlockElement = matches(name, HEADER_TAGS, NUM_HEADER_TAGS) || matches(name, BLOCK_TAGS, NUM_BLOCK_TAGS);
bool isListTag = matches(name, LIST_TAGS, NUM_LIST_TAGS);
// Handle list container tags (ol, ul)
if (isListTag) {
ListContext ctx;
ctx.isOrdered = strcmp(name, "ol") == 0;
ctx.counter = 0;
ctx.depth = self->depth;
self->listStack.push_back(ctx);
self->depth += 1;
return; // Lists themselves don't create text blocks
}
// Compute CSS style for this element // Compute CSS style for this element
CssStyle cssStyle; CssStyle cssStyle;
@@ -365,6 +381,20 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
// This fixes issue where <br/> incorrectly wrapped the preceding word to a new line // This fixes issue where <br/> incorrectly wrapped the preceding word to a new line
self->flushPartWordBuffer(); self->flushPartWordBuffer();
self->startNewTextBlock(self->currentTextBlock->getStyle()); self->startNewTextBlock(self->currentTextBlock->getStyle());
} else if (strcmp(name, "li") == 0) {
// For list items, DON'T create a text block yet - wait for the first content element
// This prevents the marker from being on its own line when <li><p>content</p></li>
self->insideListItem = true;
self->listItemDepth = self->depth;
self->listItemHasContent = false;
// Increment counter now (so nested lists work correctly)
if (!self->listStack.empty()) {
self->listStack.back().counter++;
}
// Don't create text block or add marker yet - will be done when first content arrives
self->depth += 1;
return;
} else { } else {
// Determine alignment from CSS or default // Determine alignment from CSS or default
auto alignment = static_cast<TextBlock::Style>(self->paragraphAlignment); auto alignment = static_cast<TextBlock::Style>(self->paragraphAlignment);
@@ -387,12 +417,71 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
} }
} }
// Apply default styling for blockquote if no CSS margin is specified
const bool isBlockquote = strcmp(name, "blockquote") == 0;
if (isBlockquote) {
if (!cssStyle.hasMarginLeft()) {
// Default left indent for blockquotes (~1.5em at 16px base = 24px)
cssStyle.marginLeft = 24.0f;
cssStyle.defined.marginLeft = 1;
}
// Also make blockquotes italic by default if not specified
if (!cssStyle.hasFontStyle()) {
cssStyle.fontStyle = CssFontStyle::Italic;
cssStyle.defined.fontStyle = 1;
}
// Track blockquote context for child elements
self->insideBlockquote = true;
self->blockquoteDepth = self->depth;
self->blockquoteMarginLeft = cssStyle.marginLeft;
}
// Apply blockquote styling to child block elements
if (self->insideBlockquote && !isBlockquote) {
// Inherit margin and border from parent blockquote
if (!cssStyle.hasMarginLeft()) {
cssStyle.marginLeft = self->blockquoteMarginLeft;
cssStyle.defined.marginLeft = 1;
}
}
// Apply left margin to list items (indent the whole block)
if (self->insideListItem && !cssStyle.hasMarginLeft()) {
// Default left indent for list items (~1.5em at 16px base = 24px)
cssStyle.marginLeft = 24.0f;
cssStyle.defined.marginLeft = 1;
}
self->currentBlockStyle = cssStyle; self->currentBlockStyle = cssStyle;
self->startNewTextBlock(alignment, createBlockStyleFromCss(cssStyle)); BlockStyle blockStyleForElement = createBlockStyleFromCss(cssStyle);
if (isBlockquote || self->insideBlockquote) {
blockStyleForElement.hasLeftBorder = true; // Draw vertical bar for blockquotes
}
self->startNewTextBlock(alignment, blockStyleForElement);
self->updateEffectiveInlineStyle(); self->updateEffectiveInlineStyle();
if (strcmp(name, "li") == 0) { // If this is a blockquote, apply italic styling
self->currentTextBlock->addWord("\xe2\x80\xa2", EpdFontFamily::REGULAR); if (isBlockquote && cssStyle.hasFontStyle() && cssStyle.fontStyle == CssFontStyle::Italic) {
self->italicUntilDepth = std::min(self->italicUntilDepth, self->depth);
}
// If this is the first block element inside a list item, add the marker
if (self->insideListItem && !self->listItemHasContent) {
if (!self->listStack.empty()) {
const ListContext& ctx = self->listStack.back();
if (ctx.isOrdered) {
// Ordered list: use number (counter was already incremented)
std::string marker = std::to_string(ctx.counter) + ". ";
self->currentTextBlock->addWord(marker, EpdFontFamily::REGULAR);
} else {
// Unordered list: use bullet
self->currentTextBlock->addWord("\xe2\x80\xa2", EpdFontFamily::REGULAR);
}
} else {
// No list context (orphan li), use bullet as fallback
self->currentTextBlock->addWord("\xe2\x80\xa2", EpdFontFamily::REGULAR);
}
self->listItemHasContent = true;
} }
} }
} else if (matches(name, UNDERLINE_TAGS, NUM_UNDERLINE_TAGS)) { } else if (matches(name, UNDERLINE_TAGS, NUM_UNDERLINE_TAGS)) {
@@ -478,7 +567,7 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char
if (self->skipUntilDepth < self->depth) { if (self->skipUntilDepth < self->depth) {
return; return;
} }
// Capture byte offset of this character data for page position tracking // Capture byte offset of this character data for page position tracking
if (self->xmlParser) { if (self->xmlParser) {
self->lastCharDataOffset = XML_GetCurrentByteIndex(self->xmlParser); self->lastCharDataOffset = XML_GetCurrentByteIndex(self->xmlParser);
@@ -566,7 +655,8 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n
const bool shouldFlush = styleWillChange || matches(name, BLOCK_TAGS, NUM_BLOCK_TAGS) || const bool shouldFlush = styleWillChange || matches(name, BLOCK_TAGS, NUM_BLOCK_TAGS) ||
matches(name, HEADER_TAGS, NUM_HEADER_TAGS) || matches(name, BOLD_TAGS, NUM_BOLD_TAGS) || matches(name, HEADER_TAGS, NUM_HEADER_TAGS) || matches(name, BOLD_TAGS, NUM_BOLD_TAGS) ||
matches(name, ITALIC_TAGS, NUM_ITALIC_TAGS) || matches(name, ITALIC_TAGS, NUM_ITALIC_TAGS) ||
matches(name, UNDERLINE_TAGS, NUM_UNDERLINE_TAGS) || self->depth == 1; matches(name, UNDERLINE_TAGS, NUM_UNDERLINE_TAGS) ||
matches(name, LIST_TAGS, NUM_LIST_TAGS) || self->depth == 1;
if (shouldFlush) { if (shouldFlush) {
// Use combined depth-based and CSS-based style // Use combined depth-based and CSS-based style
@@ -596,6 +686,27 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n
self->skipUntilDepth = INT_MAX; self->skipUntilDepth = INT_MAX;
} }
// Leaving list container (ol, ul)
if (matches(name, LIST_TAGS, NUM_LIST_TAGS)) {
if (!self->listStack.empty() && self->listStack.back().depth == self->depth) {
self->listStack.pop_back();
}
}
// Leaving list item (li)
if (strcmp(name, "li") == 0 && self->listItemDepth == self->depth) {
self->insideListItem = false;
self->listItemDepth = INT_MAX;
self->listItemHasContent = false;
}
// Leaving blockquote
if (strcmp(name, "blockquote") == 0 && self->blockquoteDepth == self->depth) {
self->insideBlockquote = false;
self->blockquoteDepth = INT_MAX;
self->blockquoteMarginLeft = 0.0f;
}
// Leaving bold tag // Leaving bold tag
if (self->boldUntilDepth == self->depth) { if (self->boldUntilDepth == self->depth) {
self->boldUntilDepth = INT_MAX; self->boldUntilDepth = INT_MAX;
@@ -647,7 +758,7 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
const size_t totalSize = file.size(); const size_t totalSize = file.size();
size_t bytesRead = 0; size_t bytesRead = 0;
int lastProgress = -1; int lastProgress = -1;
// Initialize offset tracking - first page starts at offset 0 // Initialize offset tracking - first page starts at offset 0
currentPageStartOffset = 0; currentPageStartOffset = 0;
lastCharDataOffset = 0; lastCharDataOffset = 0;
@@ -739,7 +850,7 @@ void ChapterHtmlSlimParser::addLineToPage(std::shared_ptr<TextBlock> line) {
currentPage->firstContentOffset = static_cast<uint32_t>(currentPageStartOffset); currentPage->firstContentOffset = static_cast<uint32_t>(currentPageStartOffset);
} }
completePageFn(std::move(currentPage)); completePageFn(std::move(currentPage));
// Start new page - offset will be set when first content is added // Start new page - offset will be set when first content is added
currentPage.reset(new Page()); currentPage.reset(new Page());
currentPageStartOffset = lastCharDataOffset; // Use offset from when content was parsed currentPageStartOffset = lastCharDataOffset; // Use offset from when content was parsed

View File

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

View File

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

View File

@@ -327,7 +327,8 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con
// Calculate screen Y position // Calculate screen Y position
const int screenYStart = y + static_cast<int>(std::floor(logicalY * scale)); const int screenYStart = y + static_cast<int>(std::floor(logicalY * scale));
// For upscaling, calculate the end position for this source row // For upscaling, calculate the end position for this source row
const int screenYEnd = isUpscaling ? (y + static_cast<int>(std::floor((logicalY + 1) * scale))) : (screenYStart + 1); const int screenYEnd =
isUpscaling ? (y + static_cast<int>(std::floor((logicalY + 1) * scale))) : (screenYStart + 1);
// Draw to all Y positions this source row maps to (for upscaling, this fills gaps) // Draw to all Y positions this source row maps to (for upscaling, this fills gaps)
for (int screenY = screenYStart; screenY < screenYEnd; screenY++) { for (int screenY = screenYStart; screenY < screenYEnd; screenY++) {
@@ -340,7 +341,8 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con
// Calculate screen X position // Calculate screen X position
const int screenXStart = x + static_cast<int>(std::floor(srcX * scale)); const int screenXStart = x + static_cast<int>(std::floor(srcX * scale));
// For upscaling, calculate the end position for this source pixel // For upscaling, calculate the end position for this source pixel
const int screenXEnd = isUpscaling ? (x + static_cast<int>(std::floor((srcX + 1) * scale))) : (screenXStart + 1); const int screenXEnd =
isUpscaling ? (x + static_cast<int>(std::floor((srcX + 1) * scale))) : (screenXStart + 1);
const uint8_t val = outputRow[bmpX / 4] >> (6 - ((bmpX * 2) % 8)) & 0x3; const uint8_t val = outputRow[bmpX / 4] >> (6 - ((bmpX * 2) % 8)) & 0x3;
@@ -409,7 +411,8 @@ void GfxRenderer::drawBitmap1Bit(const Bitmap& bitmap, const int x, const int y,
// Calculate screen Y position // Calculate screen Y position
const int screenYStart = y + static_cast<int>(std::floor(logicalY * scale)); const int screenYStart = y + static_cast<int>(std::floor(logicalY * scale));
// For upscaling, calculate the end position for this source row // For upscaling, calculate the end position for this source row
const int screenYEnd = isUpscaling ? (y + static_cast<int>(std::floor((logicalY + 1) * scale))) : (screenYStart + 1); const int screenYEnd =
isUpscaling ? (y + static_cast<int>(std::floor((logicalY + 1) * scale))) : (screenYStart + 1);
// Draw to all Y positions this source row maps to (for upscaling, this fills gaps) // Draw to all Y positions this source row maps to (for upscaling, this fills gaps)
for (int screenY = screenYStart; screenY < screenYEnd; screenY++) { for (int screenY = screenYStart; screenY < screenYEnd; screenY++) {
@@ -420,7 +423,8 @@ void GfxRenderer::drawBitmap1Bit(const Bitmap& bitmap, const int x, const int y,
// Calculate screen X position // Calculate screen X position
const int screenXStart = x + static_cast<int>(std::floor(bmpX * scale)); const int screenXStart = x + static_cast<int>(std::floor(bmpX * scale));
// For upscaling, calculate the end position for this source pixel // For upscaling, calculate the end position for this source pixel
const int screenXEnd = isUpscaling ? (x + static_cast<int>(std::floor((bmpX + 1) * scale))) : (screenXStart + 1); const int screenXEnd =
isUpscaling ? (x + static_cast<int>(std::floor((bmpX + 1) * scale))) : (screenXStart + 1);
// Get 2-bit value (result of readNextRow quantization) // Get 2-bit value (result of readNextRow quantization)
const uint8_t val = outputRow[bmpX / 4] >> (6 - ((bmpX * 2) % 8)) & 0x3; const uint8_t val = outputRow[bmpX / 4] >> (6 - ((bmpX * 2) % 8)) & 0x3;
@@ -646,7 +650,8 @@ void GfxRenderer::drawButtonHints(const int fontId, const char* btn1, const char
setOrientation(orig_orientation); 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(); const Orientation orig_orientation = getOrientation();
setOrientation(Orientation::Portrait); setOrientation(Orientation::Portrait);
@@ -667,27 +672,32 @@ void GfxRenderer::drawSideButtonHints(const int fontId, const char* topBtn, cons
// Draw the shared border for both buttons as one unit // Draw the shared border for both buttons as one unit
const int x = screenWidth - buttonX - buttonWidth; const int x = screenWidth - buttonX - buttonWidth;
// Draw top button outline (3 sides, bottom open) if (drawBorder) {
if (topBtn != nullptr && topBtn[0] != '\0') { // Draw top button outline (3 sides, bottom open)
drawLine(x, topButtonY, x + buttonWidth - 1, topButtonY); // Top if (topBtn != nullptr && topBtn[0] != '\0') {
drawLine(x, topButtonY, x, topButtonY + buttonHeight - 1); // Left drawLine(x, topButtonY, x + buttonWidth - 1, topButtonY); // Top
drawLine(x + buttonWidth - 1, topButtonY, x + buttonWidth - 1, topButtonY + buttonHeight - 1); // Right drawLine(x, topButtonY, x, topButtonY + buttonHeight - 1); // Left
} drawLine(x + buttonWidth - 1, topButtonY, x + buttonWidth - 1, topButtonY + buttonHeight - 1); // Right
}
// Draw shared middle border // Draw shared middle border
if ((topBtn != nullptr && topBtn[0] != '\0') || (bottomBtn != nullptr && bottomBtn[0] != '\0')) { if ((topBtn != nullptr && topBtn[0] != '\0') || (bottomBtn != nullptr && bottomBtn[0] != '\0')) {
drawLine(x, topButtonY + buttonHeight, x + buttonWidth - 1, topButtonY + buttonHeight); // Shared border drawLine(x, topButtonY + buttonHeight, x + buttonWidth - 1, topButtonY + buttonHeight); // Shared border
} }
// Draw bottom button outline (3 sides, top is shared) // Draw bottom button outline (3 sides, top is shared)
if (bottomBtn != nullptr && bottomBtn[0] != '\0') { if (bottomBtn != nullptr && bottomBtn[0] != '\0') {
drawLine(x, topButtonY + buttonHeight, x, topButtonY + 2 * buttonHeight - 1); // Left drawLine(x, topButtonY + buttonHeight, x, topButtonY + 2 * buttonHeight - 1); // Left
drawLine(x + buttonWidth - 1, topButtonY + buttonHeight, x + buttonWidth - 1, drawLine(x + buttonWidth - 1, topButtonY + buttonHeight, x + buttonWidth - 1,
topButtonY + 2 * buttonHeight - 1); // Right topButtonY + 2 * buttonHeight - 1); // Right
drawLine(x, topButtonY + 2 * buttonHeight - 1, x + buttonWidth - 1, topButtonY + 2 * buttonHeight - 1); // Bottom drawLine(x, topButtonY + 2 * buttonHeight - 1, x + buttonWidth - 1, topButtonY + 2 * buttonHeight - 1); // Bottom
}
} }
// Draw text for each button // 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++) { for (int i = 0; i < 2; i++) {
if (labels[i] != nullptr && labels[i][0] != '\0') { if (labels[i] != nullptr && labels[i][0] != '\0') {
const int y = topButtonY + i * buttonHeight; const int y = topButtonY + i * buttonHeight;
@@ -696,11 +706,22 @@ void GfxRenderer::drawSideButtonHints(const int fontId, const char* topBtn, cons
const int textWidth = getTextWidth(fontId, labels[i]); const int textWidth = getTextWidth(fontId, labels[i]);
const int textHeight = getTextHeight(fontId); const int textHeight = getTextHeight(fontId);
// Center the rotated text in the button int textX, textY;
const int textX = x + (buttonWidth - textHeight) / 2; if (drawBorder) {
const int textY = y + (buttonHeight + textWidth) / 2; // 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]);
}
} }
} }
@@ -798,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(); } uint8_t* GfxRenderer::getFrameBuffer() const { return einkDisplay.getFrameBuffer(); }
size_t GfxRenderer::getBufferSize() { return EInkDisplay::BUFFER_SIZE; } size_t GfxRenderer::getBufferSize() { return EInkDisplay::BUFFER_SIZE; }
@@ -998,26 +1102,38 @@ int mapPhysicalToLogicalEdge(int bezelEdge, GfxRenderer::Orientation orientation
return bezelEdge; return bezelEdge;
case GfxRenderer::LandscapeClockwise: case GfxRenderer::LandscapeClockwise:
switch (bezelEdge) { switch (bezelEdge) {
case 0: return 2; // Physical bottom -> logical left case 0:
case 1: return 3; // Physical top -> logical right return 2; // Physical bottom -> logical left
case 2: return 1; // Physical left -> logical top case 1:
case 3: return 0; // Physical right -> logical bottom return 3; // Physical top -> logical right
case 2:
return 1; // Physical left -> logical top
case 3:
return 0; // Physical right -> logical bottom
} }
break; break;
case GfxRenderer::PortraitInverted: case GfxRenderer::PortraitInverted:
switch (bezelEdge) { switch (bezelEdge) {
case 0: return 1; // Physical bottom -> logical top case 0:
case 1: return 0; // Physical top -> logical bottom return 1; // Physical bottom -> logical top
case 2: return 3; // Physical left -> logical right case 1:
case 3: return 2; // Physical right -> logical left return 0; // Physical top -> logical bottom
case 2:
return 3; // Physical left -> logical right
case 3:
return 2; // Physical right -> logical left
} }
break; break;
case GfxRenderer::LandscapeCounterClockwise: case GfxRenderer::LandscapeCounterClockwise:
switch (bezelEdge) { switch (bezelEdge) {
case 0: return 3; // Physical bottom -> logical right case 0:
case 1: return 2; // Physical top -> logical left return 3; // Physical bottom -> logical right
case 2: return 0; // Physical left -> logical bottom case 1:
case 3: return 1; // Physical right -> logical top return 2; // Physical top -> logical left
case 2:
return 0; // Physical left -> logical bottom
case 3:
return 1; // Physical right -> logical top
} }
break; break;
} }
@@ -1074,23 +1190,34 @@ void GfxRenderer::getOrientedViewableTRBL(int* outTop, int* outRight, int* outBo
*outLeft = getViewableMarginLeft(); *outLeft = getViewableMarginLeft();
break; break;
case LandscapeClockwise: case LandscapeClockwise:
*outTop = BASE_VIEWABLE_MARGIN_LEFT + (mapPhysicalToLogicalEdge(bezelEdge, orientation) == 1 ? bezelCompensation : 0); *outTop =
*outRight = BASE_VIEWABLE_MARGIN_TOP + (mapPhysicalToLogicalEdge(bezelEdge, orientation) == 3 ? bezelCompensation : 0); BASE_VIEWABLE_MARGIN_LEFT + (mapPhysicalToLogicalEdge(bezelEdge, orientation) == 1 ? bezelCompensation : 0);
*outBottom = BASE_VIEWABLE_MARGIN_RIGHT + (mapPhysicalToLogicalEdge(bezelEdge, orientation) == 0 ? bezelCompensation : 0); *outRight =
*outLeft = BASE_VIEWABLE_MARGIN_BOTTOM + (mapPhysicalToLogicalEdge(bezelEdge, orientation) == 2 ? bezelCompensation : 0); BASE_VIEWABLE_MARGIN_TOP + (mapPhysicalToLogicalEdge(bezelEdge, orientation) == 3 ? bezelCompensation : 0);
*outBottom =
BASE_VIEWABLE_MARGIN_RIGHT + (mapPhysicalToLogicalEdge(bezelEdge, orientation) == 0 ? bezelCompensation : 0);
*outLeft =
BASE_VIEWABLE_MARGIN_BOTTOM + (mapPhysicalToLogicalEdge(bezelEdge, orientation) == 2 ? bezelCompensation : 0);
break; break;
case PortraitInverted: case PortraitInverted:
*outTop = BASE_VIEWABLE_MARGIN_BOTTOM + (mapPhysicalToLogicalEdge(bezelEdge, orientation) == 1 ? bezelCompensation : 0); *outTop =
*outRight = BASE_VIEWABLE_MARGIN_LEFT + (mapPhysicalToLogicalEdge(bezelEdge, orientation) == 3 ? bezelCompensation : 0); BASE_VIEWABLE_MARGIN_BOTTOM + (mapPhysicalToLogicalEdge(bezelEdge, orientation) == 1 ? bezelCompensation : 0);
*outBottom = BASE_VIEWABLE_MARGIN_TOP + (mapPhysicalToLogicalEdge(bezelEdge, orientation) == 0 ? bezelCompensation : 0); *outRight =
*outLeft = BASE_VIEWABLE_MARGIN_RIGHT + (mapPhysicalToLogicalEdge(bezelEdge, orientation) == 2 ? bezelCompensation : 0); BASE_VIEWABLE_MARGIN_LEFT + (mapPhysicalToLogicalEdge(bezelEdge, orientation) == 3 ? bezelCompensation : 0);
*outBottom =
BASE_VIEWABLE_MARGIN_TOP + (mapPhysicalToLogicalEdge(bezelEdge, orientation) == 0 ? bezelCompensation : 0);
*outLeft =
BASE_VIEWABLE_MARGIN_RIGHT + (mapPhysicalToLogicalEdge(bezelEdge, orientation) == 2 ? bezelCompensation : 0);
break; break;
case LandscapeCounterClockwise: case LandscapeCounterClockwise:
*outTop = BASE_VIEWABLE_MARGIN_RIGHT + (mapPhysicalToLogicalEdge(bezelEdge, orientation) == 1 ? bezelCompensation : 0); *outTop =
*outRight = BASE_VIEWABLE_MARGIN_BOTTOM + (mapPhysicalToLogicalEdge(bezelEdge, orientation) == 3 ? bezelCompensation : 0); BASE_VIEWABLE_MARGIN_RIGHT + (mapPhysicalToLogicalEdge(bezelEdge, orientation) == 1 ? bezelCompensation : 0);
*outBottom = BASE_VIEWABLE_MARGIN_LEFT + (mapPhysicalToLogicalEdge(bezelEdge, orientation) == 0 ? bezelCompensation : 0); *outRight =
*outLeft = BASE_VIEWABLE_MARGIN_TOP + (mapPhysicalToLogicalEdge(bezelEdge, orientation) == 2 ? bezelCompensation : 0); BASE_VIEWABLE_MARGIN_BOTTOM + (mapPhysicalToLogicalEdge(bezelEdge, orientation) == 3 ? bezelCompensation : 0);
*outBottom =
BASE_VIEWABLE_MARGIN_LEFT + (mapPhysicalToLogicalEdge(bezelEdge, orientation) == 0 ? bezelCompensation : 0);
*outLeft =
BASE_VIEWABLE_MARGIN_TOP + (mapPhysicalToLogicalEdge(bezelEdge, orientation) == 2 ? bezelCompensation : 0);
break; break;
} }
} }

View File

@@ -94,10 +94,10 @@ class GfxRenderer {
// Handles current render mode (BW, GRAYSCALE_MSB, GRAYSCALE_LSB) // Handles current render mode (BW, GRAYSCALE_MSB, GRAYSCALE_LSB)
void fillRectGray(int x, int y, int width, int height, uint8_t grayLevel) const; void fillRectGray(int x, int y, int width, int height, uint8_t grayLevel) const;
void drawImage(const uint8_t bitmap[], int x, int y, int width, int height) const; void drawImage(const uint8_t bitmap[], int x, int y, int width, int height) const;
void drawImageRotated(const uint8_t bitmap[], int x, int y, int width, int height, void drawImageRotated(const uint8_t bitmap[], int x, int y, int width, int height, ImageRotation rotation,
ImageRotation rotation, bool invert = false) const; bool invert = false) const;
void drawBitmap(const Bitmap& bitmap, int x, int y, int maxWidth, int maxHeight, float cropX = 0, void drawBitmap(const Bitmap& bitmap, int x, int y, int maxWidth, int maxHeight, float cropX = 0, float cropY = 0,
float cropY = 0, bool invert = false) const; bool invert = false) const;
void drawBitmap1Bit(const Bitmap& bitmap, int x, int y, int maxWidth, int maxHeight, bool invert = false) const; void drawBitmap1Bit(const Bitmap& bitmap, int x, int y, int maxWidth, int maxHeight, bool invert = false) const;
void fillPolygon(const int* xPoints, const int* yPoints, int numPoints, bool state = true) const; void fillPolygon(const int* xPoints, const int* yPoints, int numPoints, bool state = true) const;
@@ -116,12 +116,15 @@ class GfxRenderer {
// UI Components // UI Components
void drawButtonHints(int fontId, const char* btn1, const char* btn2, const char* btn3, const char* btn4); 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: private:
// Helper for drawing rotated text (90 degrees clockwise, for side buttons) // 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, void drawTextRotated90CW(int fontId, int x, int y, const char* text, bool black = true,
EpdFontFamily::Style style = EpdFontFamily::REGULAR) const; 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; int getTextHeight(int fontId) const;
public: public:

View File

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

View File

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

View File

@@ -205,6 +205,19 @@ bool StarDict::loadDictzipHeader() {
bool StarDict::begin() { bool StarDict::begin() {
if (!loadInfo()) return false; 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; if (!loadDictzipHeader()) return false;
return true; return true;
} }
@@ -238,12 +251,46 @@ bool StarDict::readWordAtPosition(FsFile& idxFile, uint32_t& position, std::stri
return true; 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) { 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"; const std::string dzPath = basePath + ".dict.dz";
FsFile file; FsFile file;
if (!SdMan.openFileForRead("DICT", dzPath, file)) { if (!SdMan.openFileForRead("DICT", dzPath, file)) {
Serial.printf("[DICT-DBG] Failed to open dict.dz file\n");
return false; 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 endChunk = (offset + size - 1) / dzInfo.chunkLength;
const uint32_t startOffsetInChunk = offset % 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) { if (endChunk >= dzInfo.chunkCount) {
Serial.printf("[DICT-DBG] endChunk %lu >= chunkCount %u\n", endChunk, dzInfo.chunkCount);
file.close(); file.close();
return false; return false;
} }
@@ -263,13 +314,38 @@ bool StarDict::decompressDefinition(uint32_t offset, uint32_t size, std::string&
fileOffset += dzInfo.chunkSizes[i]; fileOffset += dzInfo.chunkSizes[i];
} }
// Allocate buffers // Calculate actual max compressed size needed for the chunks we'll process
const uint32_t maxCompressedSize = 65536; // Max compressed chunk size 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)); 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)); 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(compressedBuf);
free(decompressedBuf);
file.close(); file.close();
return false; return false;
} }
@@ -277,13 +353,15 @@ bool StarDict::decompressDefinition(uint32_t offset, uint32_t size, std::string&
definition.clear(); definition.clear();
definition.reserve(size); definition.reserve(size);
// Process each needed chunk // Process each needed chunk (reusing inflator allocation)
for (uint32_t chunk = startChunk; chunk <= endChunk; chunk++) { for (uint32_t chunk = startChunk; chunk <= endChunk; chunk++) {
const uint16_t compressedSize = dzInfo.chunkSizes[chunk]; const uint16_t compressedSize = dzInfo.chunkSizes[chunk];
// Seek and read compressed data // Seek and read compressed data
file.seek(fileOffset); file.seek(fileOffset);
if (file.read(compressedBuf, compressedSize) != compressedSize) { 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(compressedBuf);
free(decompressedBuf); free(decompressedBuf);
file.close(); file.close();
@@ -291,13 +369,6 @@ bool StarDict::decompressDefinition(uint32_t offset, uint32_t size, std::string&
} }
// Decompress using raw inflate (no zlib header) // 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); tinfl_init(inflator);
size_t inBytes = compressedSize; 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_decompress(inflator, compressedBuf, &inBytes, decompressedBuf, decompressedBuf, &outBytes,
TINFL_FLAG_USING_NON_WRAPPING_OUTPUT_BUF | TINFL_FLAG_PARSE_ZLIB_HEADER); 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) { if (status != TINFL_STATUS_DONE && status != TINFL_STATUS_HAS_MORE_OUTPUT) {
// Try without zlib header flag // Try without zlib header flag
inflator = static_cast<tinfl_decompressor*>(malloc(sizeof(tinfl_decompressor))); tinfl_init(inflator);
if (inflator) { inBytes = compressedSize;
tinfl_init(inflator); outBytes = dzInfo.chunkLength;
inBytes = compressedSize; tinfl_decompress(inflator, compressedBuf, &inBytes, decompressedBuf, decompressedBuf, &outBytes,
outBytes = dzInfo.chunkLength; TINFL_FLAG_USING_NON_WRAPPING_OUTPUT_BUF);
tinfl_decompress(inflator, compressedBuf, &inBytes, decompressedBuf, decompressedBuf, &outBytes,
TINFL_FLAG_USING_NON_WRAPPING_OUTPUT_BUF);
free(inflator);
}
} }
// Extract the portion we need from this chunk // 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; fileOffset += compressedSize;
} }
free(inflator);
free(compressedBuf); free(compressedBuf);
free(decompressedBuf); free(decompressedBuf);
file.close(); file.close();
@@ -349,9 +415,9 @@ bool StarDict::decompressDefinition(uint32_t offset, uint32_t size, std::string&
return true; 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) { 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; size_t i = 0;
while (i < a.length() && i < b.length()) { while (i < a.length() && i < b.length()) {
const int ca = std::tolower(static_cast<unsigned char>(a[i])); 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()) { if (a.length() != b.length()) {
return static_cast<int>(a.length()) - static_cast<int>(b.length()); return static_cast<int>(a.length()) - static_cast<int>(b.length());
} }
// If case-insensitive equal, use case-sensitive as tiebreaker // Case-insensitive match found
return a.compare(b); return 0;
} }
std::string StarDict::normalizeWord(const std::string& word) { std::string StarDict::normalizeWord(const std::string& word) {
@@ -403,6 +469,9 @@ StarDict::LookupResult StarDict::lookup(const std::string& word) {
return result; 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 // First try .idx (main entries) - use prefix jump table for fast lookup
const std::string idxPath = basePath + ".idx"; const std::string idxPath = basePath + ".idx";
FsFile idxFile; FsFile idxFile;
@@ -418,7 +487,10 @@ StarDict::LookupResult StarDict::lookup(const std::string& word) {
const uint16_t prefixIdx = DictPrefixIndex::prefixToIndex(normalizedSearch[0], normalizedSearch[1]); const uint16_t prefixIdx = DictPrefixIndex::prefixToIndex(normalizedSearch[0], normalizedSearch[1]);
position = DictPrefixIndex::dictPrefixOffsets[prefixIdx]; position = DictPrefixIndex::dictPrefixOffsets[prefixIdx];
} }
Serial.printf("[DICT-DBG] Starting at position %lu (prefix: %c%c)\n",
position, normalizedSearch[0], normalizedSearch[1]);
bool found = false; bool found = false;
uint32_t wordCount = 0;
while (position < info.idxfilesize) { while (position < info.idxfilesize) {
std::string currentWord; std::string currentWord;
@@ -427,13 +499,24 @@ StarDict::LookupResult StarDict::lookup(const std::string& word) {
if (!readWordAtPosition(idxFile, position, currentWord, dictOffset, dictSize)) { if (!readWordAtPosition(idxFile, position, currentWord, dictOffset, dictSize)) {
break; 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 // Use stardictStrcmp for case-insensitive matching
const int cmp = stardictStrcmp(normalizedSearch, currentWord); const int cmp = stardictStrcmp(normalizedSearch, currentWord);
if (cmp == 0) { 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; 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) { if (!found) {
result.word = currentWord; result.word = currentWord;
result.definition = definition; result.definition = definition;
@@ -442,14 +525,20 @@ StarDict::LookupResult StarDict::lookup(const std::string& word) {
} else { } else {
result.definition += "</html>" + definition; result.definition += "</html>" + definition;
} }
} else {
Serial.printf("[DICT-DBG] Definition load FAILED!\n");
} }
// Continue scanning for additional matches (same word, different case) // Continue scanning for additional matches (same word, different case)
} else if (cmp < 0) { } else if (found) {
// Passed where target would be (file is sorted) // We had matches but now moved past them - safe to stop
break; 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(); idxFile.close();
// If not found in main index, try synonym file with prefix jump // 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; uint32_t dictOffset, dictSize;
if (readWordAtPosition(idxFile2, pos, mainWord, dictOffset, dictSize)) { if (readWordAtPosition(idxFile2, pos, mainWord, dictOffset, dictSize)) {
std::string definition; 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.word = synWord;
result.definition = definition; result.definition = definition;
result.found = true; result.found = true;
@@ -513,10 +605,9 @@ StarDict::LookupResult StarDict::lookup(const std::string& word) {
idxFile2.close(); idxFile2.close();
} }
break; // Found a match, stop searching 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(); synFile.close();
} }
@@ -588,8 +679,12 @@ static std::string decodeHtmlEntity(const std::string& html, size_t& i) {
const char* replacement; const char* replacement;
}; };
static const EntityMapping entities[] = { static const EntityMapping entities[] = {
{"&nbsp;", " "}, {"&lt;", "<"}, {"&gt;", ">"}, {"&nbsp;", " "},
{"&amp;", "&"}, {"&quot;", "\""}, {"&apos;", "'"}, {"&lt;", "<"},
{"&gt;", ">"},
{"&amp;", "&"},
{"&quot;", "\""},
{"&apos;", "'"},
{"&mdash;", "\xe2\x80\x94"}, // — {"&mdash;", "\xe2\x80\x94"}, // —
{"&ndash;", "\xe2\x80\x93"}, // {"&ndash;", "\xe2\x80\x93"}, //
{"&hellip;", "\xe2\x80\xa6"}, // … {"&hellip;", "\xe2\x80\xa6"}, // …
@@ -688,8 +783,8 @@ std::string StarDict::stripHtml(const std::string& html) {
// Extract tag name // Extract tag name
size_t tagEnd = tagStart; size_t tagEnd = tagStart;
while (tagEnd < html.length() && !std::isspace(static_cast<unsigned char>(html[tagEnd])) && while (tagEnd < html.length() && !std::isspace(static_cast<unsigned char>(html[tagEnd])) && html[tagEnd] != '>' &&
html[tagEnd] != '>' && html[tagEnd] != '/') { html[tagEnd] != '/') {
tagEnd++; tagEnd++;
} }

View File

@@ -6,7 +6,7 @@
#include <string> #include <string>
// StarDict dictionary lookup library // 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 { class StarDict {
public: public:
struct DictInfo { struct DictInfo {
@@ -32,22 +32,28 @@ class StarDict {
struct DictzipInfo { struct DictzipInfo {
uint32_t chunkLength = 0; // Uncompressed chunk size (usually 58315) uint32_t chunkLength = 0; // Uncompressed chunk size (usually 58315)
uint16_t chunkCount = 0; uint16_t chunkCount = 0;
uint32_t headerSize = 0; // Total header size to skip uint32_t headerSize = 0; // Total header size to skip
uint16_t* chunkSizes = nullptr; // Array of compressed chunk sizes uint16_t* chunkSizes = nullptr; // Array of compressed chunk sizes
bool loaded = false; bool loaded = false;
}; };
DictzipInfo dzInfo; DictzipInfo dzInfo;
// Whether to use uncompressed .dict file (preferred) or compressed .dict.dz
bool useUncompressed = false;
// Parse .ifo file // Parse .ifo file
bool loadInfo(); bool loadInfo();
// Load dictzip header for random access // Load dictzip header for random access (only if using compressed)
bool loadDictzipHeader(); bool loadDictzipHeader();
// Read word at given index file position, returns word and advances position // 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, bool readWordAtPosition(FsFile& idxFile, uint32_t& position, std::string& word, uint32_t& dictOffset,
uint32_t& dictSize); 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 // Decompress a portion of the .dict.dz file
bool decompressDefinition(uint32_t offset, uint32_t size, std::string& definition); bool decompressDefinition(uint32_t offset, uint32_t size, std::string& definition);

View File

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

View File

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

View File

@@ -2,7 +2,8 @@
default_envs = default default_envs = default
[crosspoint] [crosspoint]
version = 0.15.0 # 0.15.0 CrossPoint base, ef-1.0.0 is the first release of the ef branch
version = 0.15.ef-1.0.4
[base] [base]
platform = espressif32 @ 6.12.0 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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -26,7 +26,14 @@ class CrossPointSettings {
}; };
// Status bar display type enum // Status bar display type enum
enum STATUS_BAR_MODE { NONE = 0, NO_PROGRESS = 1, FULL = 2, FULL_WITH_PROGRESS_BAR = 3, ONLY_PROGRESS_BAR = 4, STATUS_BAR_MODE_COUNT }; enum STATUS_BAR_MODE {
NONE = 0,
NO_PROGRESS = 1,
FULL = 2,
FULL_WITH_PROGRESS_BAR = 3,
ONLY_PROGRESS_BAR = 4,
STATUS_BAR_MODE_COUNT
};
enum ORIENTATION { enum ORIENTATION {
PORTRAIT = 0, // 480x800 logical coordinates (current default) PORTRAIT = 0, // 480x800 logical coordinates (current default)
@@ -116,7 +123,7 @@ class CrossPointSettings {
uint8_t sideButtonLayout = PREV_NEXT; uint8_t sideButtonLayout = PREV_NEXT;
// Reader font settings // Reader font settings
uint8_t fontFamily = BOOKERLY; uint8_t fontFamily = BOOKERLY;
uint8_t customFontIndex = 0; // Which custom font to use (0 to CUSTOM_FONT_COUNT-1) uint8_t customFontIndex = 0; // Which custom font to use (0 to CUSTOM_FONT_COUNT-1)
uint8_t fallbackFontFamily = BOOKERLY; // Fallback for missing glyphs/weights in custom fonts uint8_t fallbackFontFamily = BOOKERLY; // Fallback for missing glyphs/weights in custom fonts
uint8_t fontSize = MEDIUM; uint8_t fontSize = MEDIUM;
uint8_t lineSpacing = NORMAL; uint8_t lineSpacing = NORMAL;
@@ -148,6 +155,11 @@ class CrossPointSettings {
// Pinned list name (empty = none pinned) // Pinned list name (empty = none pinned)
char pinnedListName[64] = ""; 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; ~CrossPointSettings() = default;
// Get singleton instance // Get singleton instance

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -18,7 +18,7 @@
* - PortraitInverted: Front=TOP, Side=LEFT * - PortraitInverted: Front=TOP, Side=LEFT
* - LandscapeCCW: Front=RIGHT, Side=TOP * - LandscapeCCW: Front=RIGHT, Side=TOP
*/ */
inline void getDictionaryContentMargins(GfxRenderer& renderer, int* outTop, int* outRight, int* outBottom, inline void getDictionaryContentMargins(const GfxRenderer& renderer, int* outTop, int* outRight, int* outBottom,
int* outLeft) { int* outLeft) {
// Start with same base margins as reader (getOrientedViewableTRBL + screenMargin) // Start with same base margins as reader (getOrientedViewableTRBL + screenMargin)
renderer.getOrientedViewableTRBL(outTop, outRight, outBottom, outLeft); renderer.getOrientedViewableTRBL(outTop, outRight, outBottom, outLeft);

View File

@@ -9,8 +9,7 @@
namespace { namespace {
constexpr int MAX_MENU_ITEM_COUNT = 2; constexpr int MAX_MENU_ITEM_COUNT = 2;
const char* MENU_ITEMS[MAX_MENU_ITEM_COUNT] = {"Select from Screen", "Enter a Word"}; const char* MENU_ITEMS[MAX_MENU_ITEM_COUNT] = {"Select from Screen", "Enter a Word"};
const char* MENU_DESCRIPTIONS[MAX_MENU_ITEM_COUNT] = {"Choose a word from the current page", const char* MENU_DESCRIPTIONS[MAX_MENU_ITEM_COUNT] = {"Choose a word from the current page", "Type a word to look up"};
"Type a word to look up"};
} // namespace } // namespace
void DictionaryMenuActivity::taskTrampoline(void* param) { void DictionaryMenuActivity::taskTrampoline(void* param) {
@@ -40,12 +39,14 @@ void DictionaryMenuActivity::onEnter() {
void DictionaryMenuActivity::onExit() { void DictionaryMenuActivity::onExit() {
Activity::onExit(); Activity::onExit();
// Wait until not rendering to delete task // Take mutex to ensure task isn't in render()
xSemaphoreTake(renderingMutex, portMAX_DELAY); xSemaphoreTake(renderingMutex, portMAX_DELAY);
if (displayTaskHandle) { if (displayTaskHandle) {
// Task is definitely not in render() because we hold the mutex.
// Delete the task - it will never run again.
vTaskDelete(displayTaskHandle); vTaskDelete(displayTaskHandle);
displayTaskHandle = nullptr; 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); vSemaphoreDelete(renderingMutex);
renderingMutex = nullptr; renderingMutex = nullptr;
@@ -57,16 +58,20 @@ void DictionaryMenuActivity::loop() {
// Handle back button - cancel // Handle back button - cancel
// Use wasReleased to consume the full button event // Use wasReleased to consume the full button event
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { 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; return;
} }
// Handle confirm button - select current option // Handle confirm button - select current option
// Use wasReleased to consume the full button event // Use wasReleased to consume the full button event
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
const DictionaryMode mode = const DictionaryMode mode = (selectedIndex == 0) ? DictionaryMode::SELECT_FROM_SCREEN : DictionaryMode::ENTER_WORD;
(selectedIndex == 0) ? DictionaryMode::SELECT_FROM_SCREEN : DictionaryMode::ENTER_WORD; // Copy callback before invoking - the callback may destroy this object
onModeSelected(mode); auto callback = onModeSelected;
callback(mode);
return; return;
} }
@@ -102,7 +107,7 @@ void DictionaryMenuActivity::displayTaskLoop() {
void DictionaryMenuActivity::render() const { void DictionaryMenuActivity::render() const {
renderer.clearScreen(); 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; int marginTop, marginRight, marginBottom, marginLeft;
getDictionaryContentMargins(renderer, &marginTop, &marginRight, &marginBottom, &marginLeft); getDictionaryContentMargins(renderer, &marginTop, &marginRight, &marginBottom, &marginLeft);
@@ -114,11 +119,11 @@ void DictionaryMenuActivity::render() const {
const int contentWidth = pageWidth - marginLeft - marginRight; const int contentWidth = pageWidth - marginLeft - marginRight;
const int contentHeight = pageHeight - marginTop - marginBottom; const int contentHeight = pageHeight - marginTop - marginBottom;
// Draw header with top margin // Draw header
renderer.drawCenteredText(UI_12_FONT_ID, marginTop + 15, "Dictionary", true, EpdFontFamily::BOLD); renderer.drawCenteredText(UI_12_FONT_ID, marginTop + 5, "Dictionary", true, EpdFontFamily::BOLD);
// Draw subtitle // 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 // Draw menu items centered in content area
constexpr int itemHeight = 50; // Height for each menu item (including description) constexpr int itemHeight = 50; // Height for each menu item (including description)
@@ -139,9 +144,13 @@ void DictionaryMenuActivity::render() const {
renderer.drawText(SMALL_FONT_ID, marginLeft + 20, itemY + 22, MENU_DESCRIPTIONS[i], /*black=*/!isSelected); renderer.drawText(SMALL_FONT_ID, marginLeft + 20, itemY + 22, MENU_DESCRIPTIONS[i], /*black=*/!isSelected);
} }
// Draw help text at bottom // Draw front button hints (Prev/Next for list navigation)
const auto labels = mappedInput.mapLabels("\xc2\xab Back", "Select", "", ""); 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); 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(); renderer.displayBuffer();
} }

View File

@@ -3,6 +3,10 @@
#include <DictHtmlParser.h> #include <DictHtmlParser.h>
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <algorithm>
#include <cctype>
#include <cstring>
#include "DictionaryMargins.h" #include "DictionaryMargins.h"
#include "MappedInputManager.h" #include "MappedInputManager.h"
#include "fontIds.h" #include "fontIds.h"
@@ -15,22 +19,28 @@ void DictionaryResultActivity::taskTrampoline(void* param) {
void DictionaryResultActivity::onEnter() { void DictionaryResultActivity::onEnter() {
Activity::onEnter(); Activity::onEnter();
Serial.printf("[DICT-DBG] DictionaryResult onEnter, defLen=%u\n", rawDefinition.length());
renderingMutex = xSemaphoreCreateMutex(); renderingMutex = xSemaphoreCreateMutex();
currentPage = 0; currentPage = 0;
// Process definition for display // Process definition for display
if (!notFound) { if (!notFound) {
Serial.printf("[DICT-DBG] Starting paginateDefinition...\n");
paginateDefinition(); paginateDefinition();
Serial.printf("[DICT-DBG] Pagination done, %u pages\n", pages.size());
} }
updateRequired = true; updateRequired = true;
Serial.printf("[DICT-DBG] Creating display task...\n");
xTaskCreate(&DictionaryResultActivity::taskTrampoline, "DictResultTask", xTaskCreate(&DictionaryResultActivity::taskTrampoline, "DictResultTask",
4096, // Stack size 4096, // Stack size
this, // Parameters this, // Parameters
1, // Priority 1, // Priority
&displayTaskHandle // Task handle &displayTaskHandle // Task handle
); );
Serial.printf("[DICT-DBG] Task created\n");
} }
void DictionaryResultActivity::onExit() { void DictionaryResultActivity::onExit() {
@@ -61,31 +71,58 @@ void DictionaryResultActivity::loop() {
} }
// Handle page navigation - use orientation-aware PageBack/PageForward buttons // 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) || const bool prevPressed = mappedInput.wasPressed(MappedInputManager::Button::PageBack) ||
mappedInput.wasPressed(MappedInputManager::Button::Left); mappedInput.wasPressed(MappedInputManager::Button::Left);
const bool nextPressed = mappedInput.wasPressed(MappedInputManager::Button::PageForward) || const bool nextPressed = mappedInput.wasPressed(MappedInputManager::Button::PageForward) ||
mappedInput.wasPressed(MappedInputManager::Button::Right); mappedInput.wasPressed(MappedInputManager::Button::Right);
if (prevPressed && currentPage > 0) { if (prevPressed) {
currentPage--; if (currentPage > 0) {
updateRequired = true; // Navigate within cached pages
} else if (nextPressed && currentPage < static_cast<int>(pages.size()) - 1) { currentPage--;
currentPage++; updateRequired = true;
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() { void DictionaryResultActivity::paginateDefinition() {
pages.clear(); pages.clear();
parsePosition = 0;
hasMoreContent = false;
firstPageNumber = 1;
if (rawDefinition.empty()) { if (rawDefinition.empty()) {
notFound = true; notFound = true;
return; 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; int marginTop, marginRight, marginBottom, marginLeft;
getDictionaryContentMargins(renderer, &marginTop, &marginRight, &marginBottom, &marginLeft); getDictionaryContentMargins(renderer, &marginTop, &marginRight, &marginBottom, &marginLeft);
@@ -93,20 +130,61 @@ void DictionaryResultActivity::paginateDefinition() {
const auto pageHeight = renderer.getScreenHeight(); const auto pageHeight = renderer.getScreenHeight();
// Calculate available area for text (must match render() layout) // Calculate available area for text (must match render() layout)
constexpr int headerHeight = 80; // Space for word and header (relative to marginTop) constexpr int headerHeight = 55; // Space for "Dictionary" + lookup word
constexpr int footerHeight = 30; // Space for page indicator constexpr int footerHeight = 20; // Space for page indicator
const int textMargin = marginLeft + 10; const int textMargin = marginLeft + 10;
const int textWidth = pageWidth - textMargin - marginRight - 10; const int textWidth = pageWidth - textMargin - marginRight - 10;
const int textHeight = pageHeight - marginTop - marginBottom - headerHeight - footerHeight; const int textHeight = pageHeight - marginTop - marginBottom - headerHeight - footerHeight;
const int lineHeight = renderer.getLineHeight(UI_10_FONT_ID); 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; std::vector<std::shared_ptr<TextBlock>> allBlocks;
DictHtmlParser::parse(rawDefinition, UI_10_FONT_ID, renderer, textWidth, DictHtmlParser::parse(chunk, UI_10_FONT_ID, renderer, textWidth,
[&allBlocks](std::shared_ptr<TextBlock> block) { allBlocks.push_back(block); }); [&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()) { 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; return;
} }
@@ -131,6 +209,189 @@ void DictionaryResultActivity::paginateDefinition() {
if (!currentPageBlocks.empty()) { if (!currentPageBlocks.empty()) {
pages.push_back(currentPageBlocks); 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() { void DictionaryResultActivity::displayTaskLoop() {
@@ -148,18 +409,15 @@ void DictionaryResultActivity::displayTaskLoop() {
void DictionaryResultActivity::render() const { void DictionaryResultActivity::render() const {
renderer.clearScreen(); 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; int marginTop, marginRight, marginBottom, marginLeft;
getDictionaryContentMargins(renderer, &marginTop, &marginRight, &marginBottom, &marginLeft); getDictionaryContentMargins(renderer, &marginTop, &marginRight, &marginBottom, &marginLeft);
const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = renderer.getScreenHeight(); const auto pageHeight = renderer.getScreenHeight();
// Draw header with top margin // Draw header - "Dictionary" title and lookup word
renderer.drawCenteredText(UI_12_FONT_ID, marginTop + 15, "Dictionary", true, EpdFontFamily::BOLD); 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);
// Draw word being looked up (bold)
renderer.drawCenteredText(UI_12_FONT_ID, marginTop + 50, lookupWord.c_str(), true, EpdFontFamily::BOLD);
if (notFound) { if (notFound) {
// Show not found message (centered in content area) // Show not found message (centered in content area)
@@ -167,10 +425,12 @@ void DictionaryResultActivity::render() const {
renderer.drawCenteredText(UI_10_FONT_ID, centerY, "Word not found"); renderer.drawCenteredText(UI_10_FONT_ID, centerY, "Word not found");
} else if (!pages.empty()) { } else if (!pages.empty()) {
// Draw definition text using TextBlocks with rich formatting // 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 textMargin = marginLeft + 10;
const int lineHeight = renderer.getLineHeight(UI_10_FONT_ID); 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]; const auto& pageBlocks = pages[currentPage];
int y = textStartY; int y = textStartY;
@@ -182,19 +442,36 @@ void DictionaryResultActivity::render() const {
y += lineHeight; y += lineHeight;
} }
// Draw page indicator if multiple pages // Draw page indicator if multiple pages or more content available
if (pages.size() > 1) { const bool hasMultiplePages = pages.size() > 1 || hasMoreContent || firstPageNumber > 1;
char pageIndicator[32]; if (hasMultiplePages) {
snprintf(pageIndicator, sizeof(pageIndicator), "Page %d of %d", currentPage + 1, static_cast<int>(pages.size())); char pageIndicator[48];
renderer.drawCenteredText(SMALL_FONT_ID, pageHeight - marginBottom - 5, pageIndicator); 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 // Draw button hints
const char* leftHint = (pages.size() > 1 && currentPage > 0) ? "< Prev" : ""; // Show navigation hints when there are multiple pages or more content to load
const char* rightHint = (pages.size() > 1 && currentPage < static_cast<int>(pages.size()) - 1) ? "Next >" : ""; // 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); 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); 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(); renderer.displayBuffer();
} }

View File

@@ -1,10 +1,9 @@
#pragma once #pragma once
#include <Epub/blocks/TextBlock.h>
#include <freertos/FreeRTOS.h> #include <freertos/FreeRTOS.h>
#include <freertos/semphr.h> #include <freertos/semphr.h>
#include <freertos/task.h> #include <freertos/task.h>
#include <Epub/blocks/TextBlock.h>
#include <functional> #include <functional>
#include <memory> #include <memory>
#include <string> #include <string>
@@ -27,14 +26,24 @@ class DictionaryResultActivity final : public Activity {
const std::function<void()> onSearchAnother; const std::function<void()> onSearchAnother;
// Pagination - each page contains TextBlocks with styled text // 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; 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; 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); static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop(); [[noreturn]] void displayTaskLoop();
void render() const; void render() const;
void paginateDefinition(); 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: public:
/** /**
@@ -48,8 +57,7 @@ class DictionaryResultActivity final : public Activity {
*/ */
explicit DictionaryResultActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, explicit DictionaryResultActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const std::string& wordToLookup, const std::string& definition, const std::string& wordToLookup, const std::string& definition,
const std::function<void()>& onBack, const std::function<void()>& onBack, const std::function<void()>& onSearchAnother)
const std::function<void()>& onSearchAnother)
: Activity("DictionaryResult", renderer, mappedInput), : Activity("DictionaryResult", renderer, mappedInput),
lookupWord(wordToLookup), lookupWord(wordToLookup),
rawDefinition(definition), rawDefinition(definition),

View File

@@ -235,14 +235,14 @@ void DictionarySearchActivity::displayTaskLoop() {
void DictionarySearchActivity::render() const { void DictionarySearchActivity::render() const {
renderer.clearScreen(); 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; int marginTop, marginRight, marginBottom, marginLeft;
getDictionaryContentMargins(renderer, &marginTop, &marginRight, &marginBottom, &marginLeft); getDictionaryContentMargins(renderer, &marginTop, &marginRight, &marginBottom, &marginLeft);
const auto pageHeight = renderer.getScreenHeight(); const auto pageHeight = renderer.getScreenHeight();
// Draw header with top margin // Draw header
renderer.drawCenteredText(UI_12_FONT_ID, marginTop + 15, "Dictionary", true, EpdFontFamily::BOLD); renderer.drawCenteredText(UI_12_FONT_ID, marginTop + 5, "Dictionary", true, EpdFontFamily::BOLD);
if (isSearching) { if (isSearching) {
// Show searching status with word and animated ellipsis // Show searching status with word and animated ellipsis

View File

@@ -77,13 +77,8 @@ void EpubWordSelectionActivity::buildWordList() {
while (wordIt != words.end() && xPosIt != xPositions.end() && styleIt != styles.end()) { while (wordIt != words.end() && xPosIt != xPositions.end() && styleIt != styles.end()) {
// Skip whitespace-only words // Skip whitespace-only words
const std::string& wordText = *wordIt; const std::string& wordText = *wordIt;
bool hasAlpha = false; const bool hasAlpha = std::any_of(wordText.begin(), wordText.end(),
for (char c : wordText) { [](char c) { return std::isalpha(static_cast<unsigned char>(c)); });
if (std::isalpha(static_cast<unsigned char>(c))) {
hasAlpha = true;
break;
}
}
if (hasAlpha) { if (hasAlpha) {
WordInfo info; WordInfo info;
@@ -106,7 +101,6 @@ void EpubWordSelectionActivity::buildWordList() {
int EpubWordSelectionActivity::findLineForWordIndex(int wordIndex) const { int EpubWordSelectionActivity::findLineForWordIndex(int wordIndex) const {
if (wordIndex < 0 || wordIndex >= static_cast<int>(allWords.size())) return 0; if (wordIndex < 0 || wordIndex >= static_cast<int>(allWords.size())) return 0;
const int targetY = allWords[wordIndex].y;
int lineIdx = 0; int lineIdx = 0;
int lastY = -1; int lastY = -1;
@@ -229,10 +223,12 @@ void EpubWordSelectionActivity::displayTaskLoop() {
void EpubWordSelectionActivity::render() const { void EpubWordSelectionActivity::render() const {
renderer.clearScreen(); 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; int marginTop, marginRight, marginBottom, marginLeft;
getDictionaryContentMargins(renderer, &marginTop, &marginRight, &marginBottom, &marginLeft); getDictionaryContentMargins(renderer, &marginTop, &marginRight, &marginBottom, &marginLeft);
const auto screenHeight = renderer.getScreenHeight();
// Draw the page content (uses pre-calculated offsets from reader) // Draw the page content (uses pre-calculated offsets from reader)
// The page already has proper offsets, so render as-is // The page already has proper offsets, so render as-is
if (page) { if (page) {
@@ -252,13 +248,20 @@ void EpubWordSelectionActivity::render() const {
renderer.drawText(fontId, selected.x, selected.y, selected.text.c_str(), false, selected.style); 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 // Draw instruction text - always show, positioned just above the front button area
const auto screenHeight = renderer.getScreenHeight(); renderer.drawCenteredText(SMALL_FONT_ID, screenHeight - marginBottom - 10,
renderer.drawCenteredText(SMALL_FONT_ID, screenHeight - marginBottom - 10, "Navigate with arrows, select with confirm"); "Navigate with arrows, select with confirm");
// Draw button hints // Draw button hints with proper left/right navigation labels
const auto labels = mappedInput.mapLabels("\xc2\xab Cancel", "Select", "< >", ""); const auto labels = mappedInput.mapLabels("\xc2\xab Cancel", "Select", "< Prev", "Next >");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); renderer.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); renderer.displayBuffer(EInkDisplay::FAST_REFRESH);
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -600,9 +600,9 @@ void CrossPointWebServerActivity::renderWebBrowserScreen() const {
// Landscape layout (800x480): QR on left, text on right // Landscape layout (800x480): QR on left, text on right
constexpr int QR_X = 15; constexpr int QR_X = 15;
constexpr int QR_Y = 15; constexpr int QR_Y = 15;
constexpr int QR_PX = 7; // pixels per QR module constexpr int QR_PX = 7; // pixels per QR module
constexpr int QR_SIZE = 33 * QR_PX; // QR version 4 = 33 modules = 231px constexpr int QR_SIZE = 33 * QR_PX; // QR version 4 = 33 modules = 231px
constexpr int TEXT_X = QR_X + QR_SIZE + 35; // Text starts after QR + margin constexpr int TEXT_X = QR_X + QR_SIZE + 35; // Text starts after QR + margin
constexpr int LINE_SPACING = 32; constexpr int LINE_SPACING = 32;
// Draw title on right side // Draw title on right side
@@ -640,7 +640,8 @@ void CrossPointWebServerActivity::renderWebBrowserScreen() const {
std::string ssidInfo = "Network: " + connectedSSID; std::string ssidInfo = "Network: " + connectedSSID;
if (ssidInfo.length() > 35) { if (ssidInfo.length() > 35) {
ssidInfo = ssidInfo.substr(0, 32) + "..."; ssidInfo.resize(32);
ssidInfo += "...";
} }
renderer.drawText(NOTOSANS_12_FONT_ID, TEXT_X, textY, ssidInfo.c_str()); renderer.drawText(NOTOSANS_12_FONT_ID, TEXT_X, textY, ssidInfo.c_str());
textY += LINE_SPACING; textY += LINE_SPACING;
@@ -666,9 +667,9 @@ void CrossPointWebServerActivity::renderCompanionAppScreen() const {
// Landscape layout (800x480): QR on left, text on right // Landscape layout (800x480): QR on left, text on right
constexpr int QR_X = 15; constexpr int QR_X = 15;
constexpr int QR_Y = 15; constexpr int QR_Y = 15;
constexpr int QR_PX = 7; // pixels per QR module constexpr int QR_PX = 7; // pixels per QR module
constexpr int QR_SIZE = 33 * QR_PX; // QR version 4 = 33 modules = 231px constexpr int QR_SIZE = 33 * QR_PX; // QR version 4 = 33 modules = 231px
constexpr int TEXT_X = QR_X + QR_SIZE + 35; // Text starts after QR + margin constexpr int TEXT_X = QR_X + QR_SIZE + 35; // Text starts after QR + margin
constexpr int LINE_SPACING = 32; constexpr int LINE_SPACING = 32;
// Draw title on right side // Draw title on right side
@@ -679,7 +680,8 @@ void CrossPointWebServerActivity::renderCompanionAppScreen() const {
// Show network info // Show network info
std::string ssidInfo = "Network: " + connectedSSID; std::string ssidInfo = "Network: " + connectedSSID;
if (ssidInfo.length() > 35) { if (ssidInfo.length() > 35) {
ssidInfo = ssidInfo.substr(0, 32) + "..."; ssidInfo.resize(32);
ssidInfo += "...";
} }
renderer.drawText(NOTOSANS_12_FONT_ID, TEXT_X, textY, ssidInfo.c_str()); renderer.drawText(NOTOSANS_12_FONT_ID, TEXT_X, textY, ssidInfo.c_str());
textY += LINE_SPACING; textY += LINE_SPACING;
@@ -715,9 +717,9 @@ void CrossPointWebServerActivity::renderCompanionAppLibraryScreen() const {
// Landscape layout (800x480): QR on left, text on right // Landscape layout (800x480): QR on left, text on right
constexpr int QR_X = 15; constexpr int QR_X = 15;
constexpr int QR_Y = 15; constexpr int QR_Y = 15;
constexpr int QR_PX = 7; // pixels per QR module constexpr int QR_PX = 7; // pixels per QR module
constexpr int QR_SIZE = 33 * QR_PX; // QR version 4 = 33 modules = 231px constexpr int QR_SIZE = 33 * QR_PX; // QR version 4 = 33 modules = 231px
constexpr int TEXT_X = QR_X + QR_SIZE + 35; // Text starts after QR + margin constexpr int TEXT_X = QR_X + QR_SIZE + 35; // Text starts after QR + margin
constexpr int LINE_SPACING = 32; constexpr int LINE_SPACING = 32;
// Draw title on right side // Draw title on right side
@@ -728,7 +730,8 @@ void CrossPointWebServerActivity::renderCompanionAppLibraryScreen() const {
// Show network info // Show network info
std::string ssidInfo = "Network: " + connectedSSID; std::string ssidInfo = "Network: " + connectedSSID;
if (ssidInfo.length() > 35) { if (ssidInfo.length() > 35) {
ssidInfo = ssidInfo.substr(0, 32) + "..."; ssidInfo.resize(32);
ssidInfo += "...";
} }
renderer.drawText(NOTOSANS_12_FONT_ID, TEXT_X, textY, ssidInfo.c_str()); renderer.drawText(NOTOSANS_12_FONT_ID, TEXT_X, textY, ssidInfo.c_str());
textY += LINE_SPACING; textY += LINE_SPACING;

View File

@@ -111,24 +111,24 @@ void EpubReaderActivity::onEnter() {
FsFile f; FsFile f;
if (SdMan.openFileForRead("ERS", epub->getCachePath() + "/progress.bin", f)) { if (SdMan.openFileForRead("ERS", epub->getCachePath() + "/progress.bin", f)) {
const size_t fileSize = f.size(); const size_t fileSize = f.size();
if (fileSize >= 9) { if (fileSize >= 9) {
// New format: version (1) + spineIndex (2) + pageNumber (2) + contentOffset (4) = 9 bytes // New format: version (1) + spineIndex (2) + pageNumber (2) + contentOffset (4) = 9 bytes
uint8_t version; uint8_t version;
serialization::readPod(f, version); serialization::readPod(f, version);
if (version == EPUB_PROGRESS_VERSION) { if (version == EPUB_PROGRESS_VERSION) {
uint16_t spineIndex, pageNumber; uint16_t spineIndex, pageNumber;
serialization::readPod(f, spineIndex); serialization::readPod(f, spineIndex);
serialization::readPod(f, pageNumber); serialization::readPod(f, pageNumber);
serialization::readPod(f, savedContentOffset); serialization::readPod(f, savedContentOffset);
currentSpineIndex = spineIndex; currentSpineIndex = spineIndex;
nextPageNumber = pageNumber; nextPageNumber = pageNumber;
hasContentOffset = true; hasContentOffset = true;
Serial.printf("[%lu] [ERS] Loaded progress v1: spine %d, page %d, offset %u\n", Serial.printf("[%lu] [ERS] Loaded progress v1: spine %d, page %d, offset %u\n", millis(), currentSpineIndex,
millis(), currentSpineIndex, nextPageNumber, savedContentOffset); nextPageNumber, savedContentOffset);
} else { } else {
// Unknown version, try legacy format // Unknown version, try legacy format
f.seek(0); f.seek(0);
@@ -137,8 +137,8 @@ void EpubReaderActivity::onEnter() {
currentSpineIndex = data[0] + (data[1] << 8); currentSpineIndex = data[0] + (data[1] << 8);
nextPageNumber = data[2] + (data[3] << 8); nextPageNumber = data[2] + (data[3] << 8);
hasContentOffset = false; hasContentOffset = false;
Serial.printf("[%lu] [ERS] Loaded legacy progress (unknown version %d): spine %d, page %d\n", Serial.printf("[%lu] [ERS] Loaded legacy progress (unknown version %d): spine %d, page %d\n", millis(),
millis(), version, currentSpineIndex, nextPageNumber); version, currentSpineIndex, nextPageNumber);
} }
} }
} else if (fileSize >= 4) { } else if (fileSize >= 4) {
@@ -148,12 +148,13 @@ void EpubReaderActivity::onEnter() {
currentSpineIndex = data[0] + (data[1] << 8); currentSpineIndex = data[0] + (data[1] << 8);
nextPageNumber = data[2] + (data[3] << 8); nextPageNumber = data[2] + (data[3] << 8);
hasContentOffset = false; hasContentOffset = false;
Serial.printf("[%lu] [ERS] Loaded legacy progress: spine %d, page %d\n", Serial.printf("[%lu] [ERS] Loaded legacy progress: spine %d, page %d\n", millis(), currentSpineIndex,
millis(), currentSpineIndex, nextPageNumber); nextPageNumber);
} }
} }
f.close(); f.close();
} }
// We may want a better condition to detect if we are opening for the first time. // We may want a better condition to detect if we are opening for the first time.
// This will trigger if the book is re-opened at Chapter 0. // This will trigger if the book is re-opened at Chapter 0.
if (currentSpineIndex == 0) { if (currentSpineIndex == 0) {
@@ -235,6 +236,15 @@ void EpubReaderActivity::loop() {
updateRequired = true; updateRequired = true;
return; return;
} }
if (mappedInput.wasReleased(MappedInputManager::Button::PageForward) ||
mappedInput.wasReleased(MappedInputManager::Button::Right)) {
// Start over from beginning
currentSpineIndex = 0;
nextPageNumber = 0;
showingEndOfBookPrompt = false;
updateRequired = true;
return;
}
return; return;
} }
@@ -300,19 +310,20 @@ void EpubReaderActivity::loop() {
Section* cachedSection = section.get(); Section* cachedSection = section.get();
SemaphoreHandle_t cachedMutex = renderingMutex; SemaphoreHandle_t cachedMutex = renderingMutex;
EpubReaderActivity* self = this; EpubReaderActivity* self = this;
// Handle dictionary mode selection - exitActivity deletes DictionaryMenuActivity // Handle dictionary mode selection - exitActivity deletes DictionaryMenuActivity
exitActivity(); exitActivity();
if (mode == DictionaryMode::ENTER_WORD) { if (mode == DictionaryMode::ENTER_WORD) {
// Enter word mode - show keyboard and search // Enter word mode - show keyboard and search
self->enterNewActivity(new DictionarySearchActivity(cachedRenderer, cachedMappedInput, self->enterNewActivity(new DictionarySearchActivity(
[self]() { cachedRenderer, cachedMappedInput,
// On back from dictionary [self]() {
self->exitActivity(); // On back from dictionary
self->updateRequired = true; self->exitActivity();
}, self->updateRequired = true;
"")); // Empty string = show keyboard },
"")); // Empty string = show keyboard
} else { } else {
// Select from screen mode - show word selection on current page // Select from screen mode - show word selection on current page
if (cachedSection) { if (cachedSection) {
@@ -322,7 +333,7 @@ void EpubReaderActivity::loop() {
// Get margins for word selection positioning // Get margins for word selection positioning
int orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft; int orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft;
cachedRenderer.getOrientedViewableTRBL(&orientedMarginTop, &orientedMarginRight, &orientedMarginBottom, cachedRenderer.getOrientedViewableTRBL(&orientedMarginTop, &orientedMarginRight, &orientedMarginBottom,
&orientedMarginLeft); &orientedMarginLeft);
orientedMarginTop += SETTINGS.screenMargin; orientedMarginTop += SETTINGS.screenMargin;
orientedMarginLeft += SETTINGS.screenMargin; orientedMarginLeft += SETTINGS.screenMargin;
@@ -335,12 +346,13 @@ void EpubReaderActivity::loop() {
[self](const std::string& selectedWord) { [self](const std::string& selectedWord) {
// Word selected - look it up // Word selected - look it up
self->exitActivity(); self->exitActivity();
self->enterNewActivity(new DictionarySearchActivity(self->renderer, self->mappedInput, self->enterNewActivity(new DictionarySearchActivity(
[self]() { self->renderer, self->mappedInput,
self->exitActivity(); [self]() {
self->updateRequired = true; self->exitActivity();
}, self->updateRequired = true;
selectedWord)); },
selectedWord));
}, },
[self]() { [self]() {
// Cancelled word selection // Cancelled word selection
@@ -372,29 +384,29 @@ void EpubReaderActivity::loop() {
if (SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::QUICK_MENU && if (SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::QUICK_MENU &&
mappedInput.wasReleased(MappedInputManager::Button::Power)) { mappedInput.wasReleased(MappedInputManager::Button::Power)) {
xSemaphoreTake(renderingMutex, portMAX_DELAY); xSemaphoreTake(renderingMutex, portMAX_DELAY);
// Check if current page is bookmarked // Check if current page is bookmarked
bool isBookmarked = false; bool isBookmarked = false;
if (section) { if (section) {
const uint32_t contentOffset = section->getContentOffsetForPage(section->currentPage); const uint32_t contentOffset = section->getContentOffsetForPage(section->currentPage);
isBookmarked = BookmarkStore::isPageBookmarked(epub->getPath(), currentSpineIndex, contentOffset); isBookmarked = BookmarkStore::isPageBookmarked(epub->getPath(), currentSpineIndex, contentOffset);
} }
exitActivity(); exitActivity();
enterNewActivity(new QuickMenuActivity( enterNewActivity(new QuickMenuActivity(
renderer, mappedInput, renderer, mappedInput,
[this](QuickMenuAction action) { [this](QuickMenuAction action) {
// Cache values before exitActivity // Cache values before exitActivity
EpubReaderActivity* self = this; EpubReaderActivity* self = this;
GfxRenderer& cachedRenderer = renderer;
MappedInputManager& cachedMappedInput = mappedInput;
Section* cachedSection = section.get();
SemaphoreHandle_t cachedMutex = renderingMutex; SemaphoreHandle_t cachedMutex = renderingMutex;
exitActivity(); exitActivity();
if (action == QuickMenuAction::DICTIONARY) { if (action == QuickMenuAction::DICTIONARY) {
// Open dictionary menu // Open dictionary menu - cache renderer/input for this scope
GfxRenderer& cachedRenderer = self->renderer;
MappedInputManager& cachedMappedInput = self->mappedInput;
const Section* cachedSection = self->section.get();
self->enterNewActivity(new DictionaryMenuActivity( self->enterNewActivity(new DictionaryMenuActivity(
cachedRenderer, cachedMappedInput, cachedRenderer, cachedMappedInput,
[self](DictionaryMode mode) { [self](DictionaryMode mode) {
@@ -402,15 +414,17 @@ void EpubReaderActivity::loop() {
MappedInputManager& m = self->mappedInput; MappedInputManager& m = self->mappedInput;
Section* s = self->section.get(); Section* s = self->section.get();
SemaphoreHandle_t mtx = self->renderingMutex; SemaphoreHandle_t mtx = self->renderingMutex;
self->exitActivity(); self->exitActivity();
if (mode == DictionaryMode::ENTER_WORD) { if (mode == DictionaryMode::ENTER_WORD) {
self->enterNewActivity(new DictionarySearchActivity(r, m, self->enterNewActivity(new DictionarySearchActivity(
r, m,
[self]() { [self]() {
self->exitActivity(); self->exitActivity();
self->updateRequired = true; self->updateRequired = true;
}, "")); },
""));
} else if (s) { } else if (s) {
xSemaphoreTake(mtx, portMAX_DELAY); xSemaphoreTake(mtx, portMAX_DELAY);
auto page = s->loadPageFromSectionFile(); auto page = s->loadPageFromSectionFile();
@@ -420,7 +434,7 @@ void EpubReaderActivity::loop() {
mt += SETTINGS.screenMargin; mt += SETTINGS.screenMargin;
ml += SETTINGS.screenMargin; ml += SETTINGS.screenMargin;
const int fontId = SETTINGS.getReaderFontId(); const int fontId = SETTINGS.getReaderFontId();
self->enterNewActivity(new EpubWordSelectionActivity( self->enterNewActivity(new EpubWordSelectionActivity(
r, m, std::move(page), fontId, ml, mt, r, m, std::move(page), fontId, ml, mt,
[self](const std::string& word) { [self](const std::string& word) {
@@ -430,7 +444,8 @@ void EpubReaderActivity::loop() {
[self]() { [self]() {
self->exitActivity(); self->exitActivity();
self->updateRequired = true; self->updateRequired = true;
}, word)); },
word));
}, },
[self]() { [self]() {
self->exitActivity(); self->exitActivity();
@@ -455,7 +470,7 @@ void EpubReaderActivity::loop() {
if (self->section) { if (self->section) {
const uint32_t contentOffset = self->section->getContentOffsetForPage(self->section->currentPage); const uint32_t contentOffset = self->section->getContentOffsetForPage(self->section->currentPage);
const std::string& bookPath = self->epub->getPath(); const std::string& bookPath = self->epub->getPath();
if (BookmarkStore::isPageBookmarked(bookPath, self->currentSpineIndex, contentOffset)) { if (BookmarkStore::isPageBookmarked(bookPath, self->currentSpineIndex, contentOffset)) {
// Remove bookmark // Remove bookmark
BookmarkStore::removeBookmark(bookPath, self->currentSpineIndex, contentOffset); BookmarkStore::removeBookmark(bookPath, self->currentSpineIndex, contentOffset);
@@ -466,7 +481,7 @@ void EpubReaderActivity::loop() {
bm.contentOffset = contentOffset; bm.contentOffset = contentOffset;
bm.pageNumber = self->section->currentPage; bm.pageNumber = self->section->currentPage;
bm.timestamp = millis() / 1000; // Approximate timestamp bm.timestamp = millis() / 1000; // Approximate timestamp
// Generate name: "Chapter - Page X" or fallback // Generate name: "Chapter - Page X" or fallback
std::string chapterTitle; std::string chapterTitle;
const int tocIndex = self->epub->getTocIndexForSpineIndex(self->currentSpineIndex); const int tocIndex = self->epub->getTocIndexForSpineIndex(self->currentSpineIndex);
@@ -478,7 +493,7 @@ void EpubReaderActivity::loop() {
} else { } else {
bm.name = "Page " + std::to_string(self->section->currentPage + 1); bm.name = "Page " + std::to_string(self->section->currentPage + 1);
} }
BookmarkStore::addBookmark(bookPath, bm); BookmarkStore::addBookmark(bookPath, bm);
} }
} }
@@ -490,6 +505,28 @@ void EpubReaderActivity::loop() {
self->onGoToClearCache(); self->onGoToClearCache();
return; 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; self->updateRequired = true;
} else if (action == QuickMenuAction::GO_TO_SETTINGS) { } else if (action == QuickMenuAction::GO_TO_SETTINGS) {
// Navigate to Settings activity // Navigate to Settings activity
@@ -636,7 +673,7 @@ void EpubReaderActivity::renderScreen() {
const uint16_t viewportHeight = renderer.getScreenHeight() - orientedMarginTop - orientedMarginBottom; const uint16_t viewportHeight = renderer.getScreenHeight() - orientedMarginTop - orientedMarginBottom;
bool sectionWasReIndexed = false; bool sectionWasReIndexed = false;
if (!section->loadSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(), if (!section->loadSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth, SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
viewportHeight, SETTINGS.hyphenationEnabled)) { viewportHeight, SETTINGS.hyphenationEnabled)) {
@@ -683,7 +720,7 @@ void EpubReaderActivity::renderScreen() {
renderer.displayBuffer(EInkDisplay::FAST_REFRESH); renderer.displayBuffer(EInkDisplay::FAST_REFRESH);
}; };
if (!section->createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(), if (!section->createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth, SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
viewportHeight, SETTINGS.hyphenationEnabled, progressSetup, progressCallback)) { viewportHeight, SETTINGS.hyphenationEnabled, progressSetup, progressCallback)) {
Serial.printf("[%lu] [ERS] Failed to persist page data to SD\n", millis()); Serial.printf("[%lu] [ERS] Failed to persist page data to SD\n", millis());
@@ -703,8 +740,8 @@ void EpubReaderActivity::renderScreen() {
// Use the offset to find the correct page // Use the offset to find the correct page
const int restoredPage = section->findPageForContentOffset(savedContentOffset); const int restoredPage = section->findPageForContentOffset(savedContentOffset);
section->currentPage = restoredPage; section->currentPage = restoredPage;
Serial.printf("[%lu] [ERS] Restored position via offset: %u -> page %d (was page %d)\n", Serial.printf("[%lu] [ERS] Restored position via offset: %u -> page %d (was page %d)\n", millis(),
millis(), savedContentOffset, restoredPage, nextPageNumber); savedContentOffset, restoredPage, nextPageNumber);
// Clear the offset flag since we've used it // Clear the offset flag since we've used it
hasContentOffset = false; hasContentOffset = false;
} else { } else {
@@ -724,7 +761,8 @@ void EpubReaderActivity::renderScreen() {
} }
if (section->currentPage < 0 || section->currentPage >= section->pageCount) { if (section->currentPage < 0 || section->currentPage >= section->pageCount) {
Serial.printf("[%lu] [ERS] Page out of bounds: %d (max %d)\n", millis(), section->currentPage, section->pageCount); Serial.printf("[%lu] [ERS] Page out of bounds: %d (max %d)\n", millis(), section->currentPage,
section->pageCount);
renderer.drawCenteredText(UI_12_FONT_ID, 300, "Out of bounds", true, EpdFontFamily::BOLD); renderer.drawCenteredText(UI_12_FONT_ID, 300, "Out of bounds", true, EpdFontFamily::BOLD);
renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft); renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
renderer.displayBuffer(); renderer.displayBuffer();
@@ -739,7 +777,7 @@ void EpubReaderActivity::renderScreen() {
section.reset(); section.reset();
return renderScreen(); return renderScreen();
} }
// Handle empty pages (e.g., from malformed chapters that couldn't be parsed) // Handle empty pages (e.g., from malformed chapters that couldn't be parsed)
if (p->elements.empty()) { if (p->elements.empty()) {
Serial.printf("[%lu] [ERS] Page has no content (possibly malformed chapter)\n", millis()); Serial.printf("[%lu] [ERS] Page has no content (possibly malformed chapter)\n", millis());
@@ -749,7 +787,7 @@ void EpubReaderActivity::renderScreen() {
renderer.displayBuffer(); renderer.displayBuffer();
return; return;
} }
const auto start = millis(); const auto start = millis();
renderContents(std::move(p), orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft); renderContents(std::move(p), orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
Serial.printf("[%lu] [ERS] Rendered page in %dms\n", millis(), millis() - start); Serial.printf("[%lu] [ERS] Rendered page in %dms\n", millis(), millis() - start);
@@ -760,16 +798,16 @@ void EpubReaderActivity::renderScreen() {
if (SdMan.openFileForWrite("ERS", epub->getCachePath() + "/progress.bin", f)) { if (SdMan.openFileForWrite("ERS", epub->getCachePath() + "/progress.bin", f)) {
// Get content offset for current page // Get content offset for current page
const uint32_t contentOffset = section->getContentOffsetForPage(section->currentPage); const uint32_t contentOffset = section->getContentOffsetForPage(section->currentPage);
// New format: version (1) + spineIndex (2) + pageNumber (2) + contentOffset (4) = 9 bytes // New format: version (1) + spineIndex (2) + pageNumber (2) + contentOffset (4) = 9 bytes
serialization::writePod(f, EPUB_PROGRESS_VERSION); serialization::writePod(f, EPUB_PROGRESS_VERSION);
serialization::writePod(f, static_cast<uint16_t>(currentSpineIndex)); serialization::writePod(f, static_cast<uint16_t>(currentSpineIndex));
serialization::writePod(f, static_cast<uint16_t>(section->currentPage)); serialization::writePod(f, static_cast<uint16_t>(section->currentPage));
serialization::writePod(f, contentOffset); serialization::writePod(f, contentOffset);
f.close(); f.close();
Serial.printf("[%lu] [ERS] Saved progress: spine %d, page %d, offset %u\n", Serial.printf("[%lu] [ERS] Saved progress: spine %d, page %d, offset %u\n", millis(), currentSpineIndex,
millis(), currentSpineIndex, section->currentPage, contentOffset); section->currentPage, contentOffset);
} }
} }
@@ -777,7 +815,7 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
const int orientedMarginRight, const int orientedMarginBottom, const int orientedMarginRight, const int orientedMarginBottom,
const int orientedMarginLeft) { const int orientedMarginLeft) {
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop); page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
// Draw bookmark indicator (folded corner) if this page is bookmarked // Draw bookmark indicator (folded corner) if this page is bookmarked
if (section) { if (section) {
const uint32_t contentOffset = section->getContentOffsetForPage(section->currentPage); const uint32_t contentOffset = section->getContentOffsetForPage(section->currentPage);
@@ -787,14 +825,14 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
constexpr int cornerSize = 20; constexpr int cornerSize = 20;
const int cornerX = screenWidth - orientedMarginRight - cornerSize; const int cornerX = screenWidth - orientedMarginRight - cornerSize;
const int cornerY = orientedMarginTop; const int cornerY = orientedMarginTop;
// Draw triangle (folded corner effect) // Draw triangle (folded corner effect)
const int xPoints[3] = {cornerX, cornerX + cornerSize, cornerX + cornerSize}; const int xPoints[3] = {cornerX, cornerX + cornerSize, cornerX + cornerSize};
const int yPoints[3] = {cornerY, cornerY, cornerY + cornerSize}; const int yPoints[3] = {cornerY, cornerY, cornerY + cornerSize};
renderer.fillPolygon(xPoints, yPoints, 3, true); // Black triangle renderer.fillPolygon(xPoints, yPoints, 3, true); // Black triangle
} }
} }
renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft); renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
if (pagesUntilFullRefresh <= 1) { if (pagesUntilFullRefresh <= 1) {
renderer.displayBuffer(EInkDisplay::HALF_REFRESH); renderer.displayBuffer(EInkDisplay::HALF_REFRESH);
@@ -938,7 +976,8 @@ void EpubReaderActivity::renderEndOfBookPrompt() {
// Book title (truncated if needed) // Book title (truncated if needed)
std::string bookTitle = epub->getTitle(); std::string bookTitle = epub->getTitle();
if (bookTitle.length() > 30) { if (bookTitle.length() > 30) {
bookTitle = bookTitle.substr(0, 27) + "..."; bookTitle.resize(27);
bookTitle += "...";
} }
renderer.drawCenteredText(UI_10_FONT_ID, 120, bookTitle.c_str()); renderer.drawCenteredText(UI_10_FONT_ID, 120, bookTitle.c_str());
@@ -958,7 +997,7 @@ void EpubReaderActivity::renderEndOfBookPrompt() {
} }
// Button hints // Button hints
const auto labels = mappedInput.mapLabels("« Back", "Select", "", ""); const auto labels = mappedInput.mapLabels("« Back", "Select", "", "Start Over");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer(); renderer.displayBuffer();

View File

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

View File

@@ -10,7 +10,12 @@ namespace {
constexpr int SKIP_PAGE_MS = 700; constexpr int SKIP_PAGE_MS = 700;
} // namespace } // namespace
bool EpubReaderChapterSelectionActivity::hasSyncOption() const { return false; } // Sync feature is currently disabled - will be enabled when implemented
// NOLINTNEXTLINE(readability-convert-member-functions-to-static)
bool EpubReaderChapterSelectionActivity::hasSyncOption() const {
// TODO: Return true when sync credentials are configured
return false;
}
int EpubReaderChapterSelectionActivity::getTotalItems() const { int EpubReaderChapterSelectionActivity::getTotalItems() const {
// Add 2 for sync options (top and bottom) if credentials are configured // Add 2 for sync options (top and bottom) if credentials are configured
@@ -18,12 +23,6 @@ int EpubReaderChapterSelectionActivity::getTotalItems() const {
return epub->getTocItemsCount() + syncCount; return epub->getTocItemsCount() + syncCount;
} }
bool EpubReaderChapterSelectionActivity::isSyncItem(int index) const {
if (!hasSyncOption()) return false;
// First item and last item are sync options
return index == 0 || index == getTotalItems() - 1;
}
int EpubReaderChapterSelectionActivity::tocIndexFromItemIndex(int itemIndex) const { int EpubReaderChapterSelectionActivity::tocIndexFromItemIndex(int itemIndex) const {
// Account for the sync option at the top // Account for the sync option at the top
const int offset = hasSyncOption() ? 1 : 0; const int offset = hasSyncOption() ? 1 : 0;
@@ -94,10 +93,6 @@ void EpubReaderChapterSelectionActivity::onExit() {
renderingMutex = nullptr; renderingMutex = nullptr;
} }
void EpubReaderChapterSelectionActivity::launchSyncActivity() {
// KOReader sync functionality removed
}
void EpubReaderChapterSelectionActivity::loop() { void EpubReaderChapterSelectionActivity::loop() {
if (subActivity) { if (subActivity) {
subActivity->loop(); subActivity->loop();
@@ -114,13 +109,7 @@ void EpubReaderChapterSelectionActivity::loop() {
const int totalItems = getTotalItems(); const int totalItems = getTotalItems();
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
// Check if sync option is selected (first or last item) // Get TOC index (account for top sync offset if enabled)
if (isSyncItem(selectorIndex)) {
launchSyncActivity();
return;
}
// Get TOC index (account for top sync offset)
const int tocIndex = tocIndexFromItemIndex(selectorIndex); const int tocIndex = tocIndexFromItemIndex(selectorIndex);
const auto newSpineIndex = epub->getSpineIndexForTocIndex(tocIndex); const auto newSpineIndex = epub->getSpineIndexForTocIndex(tocIndex);
if (newSpineIndex == -1) { if (newSpineIndex == -1) {
@@ -171,30 +160,25 @@ void EpubReaderChapterSelectionActivity::renderScreen() {
const int bezelLeft = renderer.getBezelOffsetLeft(); const int bezelLeft = renderer.getBezelOffsetLeft();
const int bezelRight = renderer.getBezelOffsetRight(); const int bezelRight = renderer.getBezelOffsetRight();
const std::string title = const std::string title = renderer.truncatedText(UI_12_FONT_ID, epub->getTitle().c_str(),
renderer.truncatedText(UI_12_FONT_ID, epub->getTitle().c_str(), pageWidth - 40 - bezelLeft - bezelRight, EpdFontFamily::BOLD); pageWidth - 40 - bezelLeft - bezelRight, EpdFontFamily::BOLD);
renderer.drawCenteredText(UI_12_FONT_ID, 15 + bezelTop, title.c_str(), true, EpdFontFamily::BOLD); renderer.drawCenteredText(UI_12_FONT_ID, 15 + bezelTop, title.c_str(), true, EpdFontFamily::BOLD);
const auto pageStartIndex = selectorIndex / pageItems * pageItems; const auto pageStartIndex = selectorIndex / pageItems * pageItems;
renderer.fillRect(bezelLeft, 60 + bezelTop + (selectorIndex % pageItems) * 30 - 2, pageWidth - 1 - bezelLeft - bezelRight, 30); renderer.fillRect(bezelLeft, 60 + bezelTop + (selectorIndex % pageItems) * 30 - 2,
pageWidth - 1 - bezelLeft - bezelRight, 30);
for (int itemIndex = pageStartIndex; itemIndex < totalItems && itemIndex < pageStartIndex + pageItems; itemIndex++) { for (int itemIndex = pageStartIndex; itemIndex < totalItems && itemIndex < pageStartIndex + pageItems; itemIndex++) {
const int displayY = 60 + bezelTop + (itemIndex % pageItems) * 30; const int displayY = 60 + bezelTop + (itemIndex % pageItems) * 30;
const bool isSelected = (itemIndex == selectorIndex); const bool isSelected = (itemIndex == selectorIndex);
if (isSyncItem(itemIndex)) { // Draw TOC item
// Draw sync option (at top or bottom) const int tocIndex = tocIndexFromItemIndex(itemIndex);
renderer.drawText(UI_10_FONT_ID, 20 + bezelLeft, displayY, ">> Sync Progress", !isSelected); auto item = epub->getTocItem(tocIndex);
} else { const int indentSize = 20 + bezelLeft + (item.level - 1) * 15;
// Draw TOC item (account for top sync offset) const std::string chapterName = renderer.truncatedText(
const int tocIndex = tocIndexFromItemIndex(itemIndex); UI_10_FONT_ID, item.title.c_str(), pageWidth - 40 - bezelLeft - bezelRight - indentSize + 20 + bezelLeft);
auto item = epub->getTocItem(tocIndex); renderer.drawText(UI_10_FONT_ID, indentSize, displayY, chapterName.c_str(), !isSelected);
const int indentSize = 20 + bezelLeft + (item.level - 1) * 15;
const std::string chapterName =
renderer.truncatedText(UI_10_FONT_ID, item.title.c_str(), pageWidth - 40 - bezelLeft - bezelRight - indentSize + 20 + bezelLeft);
renderer.drawText(UI_10_FONT_ID, indentSize, 60 + bezelTop + (tocIndex % pageItems) * 30, chapterName.c_str(),
tocIndex != selectorIndex);
}
} }
const auto labels = mappedInput.mapLabels("« Back", "Select", "Up", "Down"); const auto labels = mappedInput.mapLabels("« Back", "Select", "Up", "Down");

View File

@@ -30,18 +30,15 @@ class EpubReaderChapterSelectionActivity final : public ActivityWithSubactivity
int getTotalItems() const; int getTotalItems() const;
// Check if sync option is available (credentials configured) // Check if sync option is available (credentials configured)
// Note: Currently always returns false - placeholder for future sync feature
bool hasSyncOption() const; bool hasSyncOption() const;
// Check if given item index is a sync option (first or last)
bool isSyncItem(int index) const;
// Convert item index to TOC index (accounting for top sync option offset) // Convert item index to TOC index (accounting for top sync option offset)
int tocIndexFromItemIndex(int itemIndex) const; int tocIndexFromItemIndex(int itemIndex) const;
static void taskTrampoline(void* param); static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop(); [[noreturn]] void displayTaskLoop();
void renderScreen(); void renderScreen();
void launchSyncActivity();
public: public:
explicit EpubReaderChapterSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, explicit EpubReaderChapterSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,

View File

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

View File

@@ -88,7 +88,7 @@ void TxtReaderActivity::onEnter() {
renderer.displayBuffer(EInkDisplay::FAST_REFRESH); renderer.displayBuffer(EInkDisplay::FAST_REFRESH);
// Generate covers with progress callback // Generate covers with progress callback
txt->generateAllCovers([&](int percent) { (void)txt->generateAllCovers([&](int percent) {
const unsigned long now = millis(); const unsigned long now = millis();
if ((now - lastUpdate) >= 3000) { if ((now - lastUpdate) >= 3000) {
lastUpdate = now; lastUpdate = now;
@@ -171,6 +171,14 @@ void TxtReaderActivity::loop() {
updateRequired = true; updateRequired = true;
return; return;
} }
if (mappedInput.wasReleased(MappedInputManager::Button::PageForward) ||
mappedInput.wasReleased(MappedInputManager::Button::Right)) {
// Start over from beginning
currentPage = 0;
showingEndOfBookPrompt = false;
updateRequired = true;
return;
}
return; return;
} }
@@ -658,15 +666,15 @@ void TxtReaderActivity::saveProgress() const {
if (SdMan.openFileForWrite("TRS", txt->getCachePath() + "/progress.bin", f)) { if (SdMan.openFileForWrite("TRS", txt->getCachePath() + "/progress.bin", f)) {
// New format: version + byte offset + page number (for backwards compatibility debugging) // New format: version + byte offset + page number (for backwards compatibility debugging)
serialization::writePod(f, PROGRESS_VERSION); serialization::writePod(f, PROGRESS_VERSION);
// Store byte offset - this is stable across font/setting changes // Store byte offset - this is stable across font/setting changes
const size_t byteOffset = (currentPage >= 0 && currentPage < static_cast<int>(pageOffsets.size())) const size_t byteOffset =
? pageOffsets[currentPage] : 0; (currentPage >= 0 && currentPage < static_cast<int>(pageOffsets.size())) ? pageOffsets[currentPage] : 0;
serialization::writePod(f, static_cast<uint32_t>(byteOffset)); serialization::writePod(f, static_cast<uint32_t>(byteOffset));
// Also store page number for debugging/logging purposes // Also store page number for debugging/logging purposes
serialization::writePod(f, static_cast<uint16_t>(currentPage)); serialization::writePod(f, static_cast<uint16_t>(currentPage));
f.close(); f.close();
Serial.printf("[%lu] [TRS] Saved progress: page %d, offset %zu\n", millis(), currentPage, byteOffset); Serial.printf("[%lu] [TRS] Saved progress: page %d, offset %zu\n", millis(), currentPage, byteOffset);
} }
@@ -677,24 +685,24 @@ void TxtReaderActivity::loadProgress() {
if (SdMan.openFileForRead("TRS", txt->getCachePath() + "/progress.bin", f)) { if (SdMan.openFileForRead("TRS", txt->getCachePath() + "/progress.bin", f)) {
// Check file size to determine format // Check file size to determine format
const size_t fileSize = f.size(); const size_t fileSize = f.size();
if (fileSize >= 7) { if (fileSize >= 7) {
// New format: version (1) + byte offset (4) + page number (2) = 7 bytes // New format: version (1) + byte offset (4) + page number (2) = 7 bytes
uint8_t version; uint8_t version;
serialization::readPod(f, version); serialization::readPod(f, version);
if (version == PROGRESS_VERSION) { if (version == PROGRESS_VERSION) {
uint32_t savedOffset; uint32_t savedOffset;
serialization::readPod(f, savedOffset); serialization::readPod(f, savedOffset);
uint16_t savedPage; uint16_t savedPage;
serialization::readPod(f, savedPage); serialization::readPod(f, savedPage);
// Use byte offset to find the correct page (works even if re-indexed) // Use byte offset to find the correct page (works even if re-indexed)
currentPage = findPageForOffset(savedOffset); currentPage = findPageForOffset(savedOffset);
Serial.printf("[%lu] [TRS] Loaded progress: offset %u -> page %d/%d (was page %d)\n", Serial.printf("[%lu] [TRS] Loaded progress: offset %u -> page %d/%d (was page %d)\n", millis(), savedOffset,
millis(), savedOffset, currentPage, totalPages, savedPage); currentPage, totalPages, savedPage);
} else { } else {
// Unknown version, fall back to legacy behavior // Unknown version, fall back to legacy behavior
Serial.printf("[%lu] [TRS] Unknown progress version %d, ignoring\n", millis(), version); Serial.printf("[%lu] [TRS] Unknown progress version %d, ignoring\n", millis(), version);
@@ -708,7 +716,7 @@ void TxtReaderActivity::loadProgress() {
Serial.printf("[%lu] [TRS] Loaded legacy progress: page %d/%d\n", millis(), currentPage, totalPages); Serial.printf("[%lu] [TRS] Loaded legacy progress: page %d/%d\n", millis(), currentPage, totalPages);
} }
} }
// Bounds check // Bounds check
if (currentPage >= totalPages) { if (currentPage >= totalPages) {
currentPage = totalPages - 1; currentPage = totalPages - 1;
@@ -716,7 +724,7 @@ void TxtReaderActivity::loadProgress() {
if (currentPage < 0) { if (currentPage < 0) {
currentPage = 0; currentPage = 0;
} }
f.close(); f.close();
} }
} }
@@ -725,16 +733,16 @@ int TxtReaderActivity::findPageForOffset(size_t targetOffset) const {
if (pageOffsets.empty()) { if (pageOffsets.empty()) {
return 0; return 0;
} }
// Binary search: find the largest offset that is <= targetOffset // Binary search: find the largest offset that is <= targetOffset
// This finds the page that contains or starts at the target offset // This finds the page that contains or starts at the target offset
auto it = std::upper_bound(pageOffsets.begin(), pageOffsets.end(), targetOffset); auto it = std::upper_bound(pageOffsets.begin(), pageOffsets.end(), targetOffset);
if (it == pageOffsets.begin()) { if (it == pageOffsets.begin()) {
// Target is before the first page, return page 0 // Target is before the first page, return page 0
return 0; return 0;
} }
// upper_bound returns iterator to first element > targetOffset // upper_bound returns iterator to first element > targetOffset
// So we need the element before it (which is <= targetOffset) // So we need the element before it (which is <= targetOffset)
return static_cast<int>(std::distance(pageOffsets.begin(), it) - 1); return static_cast<int>(std::distance(pageOffsets.begin(), it) - 1);
@@ -888,7 +896,8 @@ void TxtReaderActivity::renderEndOfBookPrompt() {
filename = filename.substr(lastSlash + 1); filename = filename.substr(lastSlash + 1);
} }
if (filename.length() > 30) { if (filename.length() > 30) {
filename = filename.substr(0, 27) + "..."; filename.resize(27);
filename += "...";
} }
renderer.drawCenteredText(UI_10_FONT_ID, 120, filename.c_str()); renderer.drawCenteredText(UI_10_FONT_ID, 120, filename.c_str());
@@ -908,7 +917,7 @@ void TxtReaderActivity::renderEndOfBookPrompt() {
} }
// Button hints // Button hints
const auto labels = mappedInput.mapLabels("« Back", "Select", "", ""); const auto labels = mappedInput.mapLabels("« Back", "Select", "", "Start Over");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer(); renderer.displayBuffer();

View File

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

View File

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

View File

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

View File

@@ -249,7 +249,7 @@ void KeyboardEntryActivity::loop() {
} }
void KeyboardEntryActivity::render() const { 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; int marginTop, marginRight, marginBottom, marginLeft;
getDictionaryContentMargins(renderer, &marginTop, &marginRight, &marginBottom, &marginLeft); getDictionaryContentMargins(renderer, &marginTop, &marginRight, &marginBottom, &marginLeft);

View File

@@ -2,24 +2,25 @@
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include "CrossPointSettings.h"
#include "MappedInputManager.h" #include "MappedInputManager.h"
#include "fontIds.h" #include "fontIds.h"
namespace { namespace {
constexpr int MENU_ITEM_COUNT = 4; // Base menu item count (reorderable items)
const char* MENU_ITEMS[MENU_ITEM_COUNT] = {"Dictionary", "Bookmark", "Clear Cache", "Settings"}; constexpr int BASE_MENU_ITEM_COUNT = 5;
const char* MENU_DESCRIPTIONS_ADD[MENU_ITEM_COUNT] = { // Total display count including "Edit List Order"
"Look up a word", constexpr int DISPLAY_ITEM_COUNT = 6;
"Add bookmark to this page",
"Free up storage space", // Menu items indexed by QuickMenuAction enum value
"Open settings menu" // 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_REMOVE[MENU_ITEM_COUNT] = { const char* MENU_DESCRIPTIONS_ADD[BASE_MENU_ITEM_COUNT] = {"Look up a word", "Add bookmark to this page",
"Look up a word", "Free up storage space", "Toggle screen orientation",
"Remove bookmark from this page", "Open settings menu"};
"Free up storage space", const char* MENU_DESCRIPTIONS_REMOVE[BASE_MENU_ITEM_COUNT] = {"Look up a word", "Remove bookmark from this page",
"Open settings menu" "Free up storage space", "Toggle screen orientation",
}; "Open settings menu"};
} // namespace } // namespace
void QuickMenuActivity::taskTrampoline(void* param) { void QuickMenuActivity::taskTrampoline(void* param) {
@@ -61,6 +62,16 @@ void QuickMenuActivity::onExit() {
} }
void QuickMenuActivity::loop() { void QuickMenuActivity::loop() {
if (editMode) {
// Edit mode logic
handleEditMode();
} else {
// Normal mode logic
handleNormalMode();
}
}
void QuickMenuActivity::handleNormalMode() {
// Handle back button - cancel // Handle back button - cancel
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
onCancel(); onCancel();
@@ -69,8 +80,22 @@ void QuickMenuActivity::loop() {
// Handle confirm button - select current option // Handle confirm button - select current option
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { 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; QuickMenuAction action;
switch (selectedIndex) { switch (actionIndex) {
case 0: case 0:
action = QuickMenuAction::DICTIONARY; action = QuickMenuAction::DICTIONARY;
break; break;
@@ -81,6 +106,9 @@ void QuickMenuActivity::loop() {
action = QuickMenuAction::CLEAR_CACHE; action = QuickMenuAction::CLEAR_CACHE;
break; break;
case 3: case 3:
action = QuickMenuAction::TOGGLE_ORIENTATION;
break;
case 4:
default: default:
action = QuickMenuAction::GO_TO_SETTINGS; action = QuickMenuAction::GO_TO_SETTINGS;
break; break;
@@ -96,10 +124,69 @@ void QuickMenuActivity::loop() {
mappedInput.wasPressed(MappedInputManager::Button::Right); mappedInput.wasPressed(MappedInputManager::Button::Right);
if (prevPressed) { if (prevPressed) {
selectedIndex = (selectedIndex + MENU_ITEM_COUNT - 1) % MENU_ITEM_COUNT; selectedIndex = (selectedIndex + DISPLAY_ITEM_COUNT - 1) % DISPLAY_ITEM_COUNT;
updateRequired = true; updateRequired = true;
} else if (nextPressed) { } 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; updateRequired = true;
} }
} }
@@ -121,53 +208,117 @@ void QuickMenuActivity::render() const {
const auto pageWidth = renderer.getScreenWidth(); const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = renderer.getScreenHeight(); const auto pageHeight = renderer.getScreenHeight();
// Get bezel offsets // Get bezel offsets
const int bezelTop = renderer.getBezelOffsetTop(); const int bezelTop = renderer.getBezelOffsetTop();
const int bezelLeft = renderer.getBezelOffsetLeft(); const int bezelLeft = renderer.getBezelOffsetLeft();
const int bezelRight = renderer.getBezelOffsetRight(); const int bezelRight = renderer.getBezelOffsetRight();
const int bezelBottom = renderer.getBezelOffsetBottom(); const int bezelBottom = renderer.getBezelOffsetBottom();
// Calculate usable content area // Button hint space constants
const int marginLeft = 20 + bezelLeft; constexpr int FRONT_BUTTON_SPACE = 45; // 40px button height + 5px padding
const int marginRight = 20 + bezelRight; constexpr int SIDE_BUTTON_SPACE = 50; // 45px button area + 5px padding
const int marginTop = 15 + bezelTop;
const int contentWidth = pageWidth - marginLeft - marginRight;
const int contentHeight = pageHeight - marginTop - 60 - bezelBottom; // 60 for button hints
// Draw header // Calculate button hint margins based on orientation
renderer.drawCenteredText(UI_12_FONT_ID, marginTop, "Quick Menu", true, EpdFontFamily::BOLD); // 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 // Select descriptions based on bookmark state
const char* const* descriptions = isPageBookmarked ? MENU_DESCRIPTIONS_REMOVE : MENU_DESCRIPTIONS_ADD; 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 // Draw menu items centered in content area
constexpr int itemHeight = 50; // Height for each menu item (including description) 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 int itemY = startY + i * itemHeight;
const bool isSelected = (i == selectedIndex); const bool isSelected = (i == selectedIndex);
const bool isBeingMoved = (editMode && i == movingIndex);
// Draw selection highlight (black fill) for selected item // Draw selection highlight (black fill) for selected item
if (isSelected) { if (isSelected) {
renderer.fillRect(marginLeft + 10, itemY - 2, contentWidth - 20, itemHeight - 6); renderer.fillRect(marginLeft + 10, itemY - 2, contentWidth - 20, itemHeight - 6);
} }
// Draw outline for item being moved (when cursor is elsewhere)
// Draw menu item text if (isBeingMoved && !isSelected) {
const char* itemText = MENU_ITEMS[i]; renderer.drawRect(marginLeft + 10, itemY - 2, contentWidth - 20, itemHeight - 6);
// For bookmark item, show different text based on state }
if (i == 1) {
itemText = isPageBookmarked ? "Remove Bookmark" : "Add Bookmark"; // 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);
} }
renderer.drawText(UI_10_FONT_ID, marginLeft + 20, itemY, itemText, !isSelected);
renderer.drawText(SMALL_FONT_ID, marginLeft + 20, itemY + 22, descriptions[i], !isSelected);
} }
// Draw help text at bottom // Draw help text at bottom - different hints for edit mode
const auto labels = mappedInput.mapLabels("\xc2\xab Back", "Select", "", ""); if (editMode) {
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 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(); renderer.displayBuffer();
} }

View File

@@ -8,7 +8,7 @@
#include "../Activity.h" #include "../Activity.h"
// Enum for quick menu selection // 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. * 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 std::function<void()> onCancel;
const bool isPageBookmarked; // True if current page already has a bookmark 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); static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop(); [[noreturn]] void displayTaskLoop();
void render() const; void render() const;
void handleNormalMode();
void handleEditMode();
public: public:
explicit QuickMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, explicit QuickMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
#include <Arduino.h> #include <Arduino.h>
#include <BitmapHelpers.h>
#include <EInkDisplay.h> #include <EInkDisplay.h>
#include <Epub.h> #include <Epub.h>
#include <GfxRenderer.h> #include <GfxRenderer.h>
@@ -10,8 +11,6 @@
#include <cstring> #include <cstring>
#include <BitmapHelpers.h>
#include "Battery.h" #include "Battery.h"
#include "BookListStore.h" #include "BookListStore.h"
#include "CrossPointSettings.h" #include "CrossPointSettings.h"
@@ -123,11 +122,8 @@ unsigned long t2 = 0;
// Memory debugging helper - logs heap state for tracking leaks // Memory debugging helper - logs heap state for tracking leaks
#ifdef DEBUG_MEMORY #ifdef DEBUG_MEMORY
void logMemoryState(const char* tag, const char* context) { void logMemoryState(const char* tag, const char* context) {
Serial.printf("[%lu] [%s] [MEM] %s - Free: %d, Largest: %d, MinFree: %d\n", Serial.printf("[%lu] [%s] [MEM] %s - Free: %d, Largest: %d, MinFree: %d\n", millis(), tag, context, ESP.getFreeHeap(),
millis(), tag, context, ESP.getMaxAllocHeap(), ESP.getMinFreeHeap());
ESP.getFreeHeap(),
ESP.getMaxAllocHeap(),
ESP.getMinFreeHeap());
} }
#else #else
// No-op when not in debug mode // No-op when not in debug mode
@@ -138,6 +134,7 @@ void logMemoryState(const char* tag, const char* context) {
static String flashCmdBuffer; static String flashCmdBuffer;
void checkForFlashCommand() { void checkForFlashCommand() {
if (!Serial) return; // Early exit if Serial not initialized
while (Serial.available()) { while (Serial.available()) {
char c = Serial.read(); char c = Serial.read();
if (c == '\n') { if (c == '\n') {
@@ -196,39 +193,31 @@ void checkForFlashCommand() {
// USB port locations: Portrait=bottom-left, PortraitInverted=top-right, // USB port locations: Portrait=bottom-left, PortraitInverted=top-right,
// LandscapeCW=top-left, LandscapeCCW=bottom-right // LandscapeCW=top-left, LandscapeCCW=bottom-right
// Position offsets: edge margin + half-width offset to center on USB port // Position offsets: edge margin + half-width offset to center on USB port
constexpr int edgeMargin = 28; // Distance from screen edge constexpr int edgeMargin = 28; // Distance from screen edge
constexpr int halfWidth = LOCK_ICON_WIDTH / 2; // 16px offset for centering constexpr int halfWidth = LOCK_ICON_WIDTH / 2; // 16px offset for centering
int iconX, iconY; int iconX, iconY;
GfxRenderer::ImageRotation rotation; GfxRenderer::ImageRotation rotation;
int outW, outH; // Note: 90/270 rotation swaps output dimensions // Note: 90/270 rotation swaps output dimensions (W<->H)
switch (renderer.getOrientation()) { switch (renderer.getOrientation()) {
case GfxRenderer::Portrait: // USB at bottom-left, shackle points right case GfxRenderer::Portrait: // USB at bottom-left, shackle points right
rotation = GfxRenderer::ROTATE_90; rotation = GfxRenderer::ROTATE_90;
outW = LOCK_ICON_HEIGHT;
outH = LOCK_ICON_WIDTH;
iconX = edgeMargin; iconX = edgeMargin;
iconY = screenH - outH - edgeMargin - halfWidth; iconY = screenH - LOCK_ICON_WIDTH - edgeMargin - halfWidth;
break; break;
case GfxRenderer::PortraitInverted: // USB at top-right, shackle points left case GfxRenderer::PortraitInverted: // USB at top-right, shackle points left
rotation = GfxRenderer::ROTATE_270; rotation = GfxRenderer::ROTATE_270;
outW = LOCK_ICON_HEIGHT; iconX = screenW - LOCK_ICON_HEIGHT - edgeMargin;
outH = LOCK_ICON_WIDTH;
iconX = screenW - outW - edgeMargin;
iconY = edgeMargin + halfWidth; iconY = edgeMargin + halfWidth;
break; break;
case GfxRenderer::LandscapeClockwise: // USB at top-left, shackle points down case GfxRenderer::LandscapeClockwise: // USB at top-left, shackle points down
rotation = GfxRenderer::ROTATE_180; rotation = GfxRenderer::ROTATE_180;
outW = LOCK_ICON_WIDTH;
outH = LOCK_ICON_HEIGHT;
iconX = edgeMargin + halfWidth; iconX = edgeMargin + halfWidth;
iconY = edgeMargin; iconY = edgeMargin;
break; break;
case GfxRenderer::LandscapeCounterClockwise: // USB at bottom-right, shackle points up case GfxRenderer::LandscapeCounterClockwise: // USB at bottom-right, shackle points up
rotation = GfxRenderer::ROTATE_0; rotation = GfxRenderer::ROTATE_0;
outW = LOCK_ICON_WIDTH; iconX = screenW - LOCK_ICON_WIDTH - edgeMargin - halfWidth;
outH = LOCK_ICON_HEIGHT; iconY = screenH - LOCK_ICON_HEIGHT - edgeMargin;
iconX = screenW - outW - edgeMargin - halfWidth;
iconY = screenH - outH - edgeMargin;
break; break;
} }
renderer.drawImageRotated(LockIcon, iconX, iconY, LOCK_ICON_WIDTH, LOCK_ICON_HEIGHT, rotation); renderer.drawImageRotated(LockIcon, iconX, iconY, LOCK_ICON_WIDTH, LOCK_ICON_HEIGHT, rotation);
@@ -342,9 +331,8 @@ void onGoToClearCache();
void onGoToSettings(); void onGoToSettings();
void onGoToReader(const std::string& initialEpubPath, MyLibraryActivity::Tab fromTab) { void onGoToReader(const std::string& initialEpubPath, MyLibraryActivity::Tab fromTab) {
exitActivity(); exitActivity();
enterNewActivity( enterNewActivity(new ReaderActivity(renderer, mappedInputManager, initialEpubPath, fromTab, onGoHome,
new ReaderActivity(renderer, mappedInputManager, initialEpubPath, fromTab, onGoHome, onGoToMyLibraryWithTab, onGoToMyLibraryWithTab, onGoToClearCache, onGoToSettings));
onGoToClearCache, onGoToSettings));
} }
void onContinueReading() { onGoToReader(APP_STATE.openEpubPath, MyLibraryActivity::Tab::Recent); } void onContinueReading() { onGoToReader(APP_STATE.openEpubPath, MyLibraryActivity::Tab::Recent); }
@@ -359,8 +347,7 @@ void onGoToReaderFromList(const std::string& bookPath) {
// View a specific list // View a specific list
void onGoToListView(const std::string& listName) { void onGoToListView(const std::string& listName) {
exitActivity(); exitActivity();
enterNewActivity( enterNewActivity(new ListViewActivity(renderer, mappedInputManager, listName, onGoToMyLibrary, onGoToReaderFromList));
new ListViewActivity(renderer, mappedInputManager, listName, onGoToMyLibrary, onGoToReaderFromList));
} }
// View bookmarks for a specific book // View bookmarks for a specific book
@@ -373,9 +360,8 @@ void onGoToBookmarkList(const std::string& bookPath, const std::string& bookTitl
// Navigate to bookmark location in the book // Navigate to bookmark location in the book
// For now, just open the book (TODO: pass bookmark location to reader) // For now, just open the book (TODO: pass bookmark location to reader)
exitActivity(); exitActivity();
enterNewActivity(new ReaderActivity(renderer, mappedInputManager, bookPath, enterNewActivity(new ReaderActivity(renderer, mappedInputManager, bookPath, MyLibraryActivity::Tab::Bookmarks,
MyLibraryActivity::Tab::Bookmarks, onGoHome, onGoToMyLibraryWithTab, onGoHome, onGoToMyLibraryWithTab, onGoToClearCache, onGoToSettings));
onGoToClearCache, onGoToSettings));
})); }));
} }
@@ -395,6 +381,15 @@ void onGoToListsOrPinned() {
void onGoToFileTransfer() { void onGoToFileTransfer() {
exitActivity(); exitActivity();
// Free memory not needed during file transfer to maximize heap for webserver
RECENT_BOOKS.clearFromMemory();
APP_STATE.openBookTitle.clear();
APP_STATE.openBookTitle.shrink_to_fit();
APP_STATE.openBookAuthor.clear();
APP_STATE.openBookAuthor.shrink_to_fit();
Serial.printf("[%lu] [FT] Cleared non-essential memory before File Transfer\n", millis());
enterNewActivity(new CrossPointWebServerActivity(renderer, mappedInputManager, onGoHome)); enterNewActivity(new CrossPointWebServerActivity(renderer, mappedInputManager, onGoHome));
} }
@@ -410,12 +405,26 @@ void onGoToClearCache() {
void onGoToMyLibrary() { void onGoToMyLibrary() {
exitActivity(); exitActivity();
enterNewActivity(new MyLibraryActivity(renderer, mappedInputManager, onGoHome, onGoToReader, onGoToListView, onGoToBookmarkList));
// Reload recent books if they were cleared (e.g., when exiting File Transfer mode)
if (RECENT_BOOKS.getCount() == 0) {
RECENT_BOOKS.loadFromFile();
}
enterNewActivity(
new MyLibraryActivity(renderer, mappedInputManager, onGoHome, onGoToReader, onGoToListView, onGoToBookmarkList));
} }
void onGoToMyLibraryWithTab(const std::string& path, MyLibraryActivity::Tab tab) { void onGoToMyLibraryWithTab(const std::string& path, MyLibraryActivity::Tab tab) {
exitActivity(); exitActivity();
enterNewActivity(new MyLibraryActivity(renderer, mappedInputManager, onGoHome, onGoToReader, onGoToListView, onGoToBookmarkList, tab, path));
// Reload recent books if they were cleared (e.g., when exiting File Transfer mode)
if (RECENT_BOOKS.getCount() == 0) {
RECENT_BOOKS.loadFromFile();
}
enterNewActivity(new MyLibraryActivity(renderer, mappedInputManager, onGoHome, onGoToReader, onGoToListView,
onGoToBookmarkList, tab, path));
} }
void onGoToBrowser() { void onGoToBrowser() {
@@ -425,6 +434,12 @@ void onGoToBrowser() {
void onGoHome() { void onGoHome() {
exitActivity(); exitActivity();
// Reload recent books if they were cleared (e.g., when exiting File Transfer mode)
if (RECENT_BOOKS.getCount() == 0) {
RECENT_BOOKS.loadFromFile();
}
enterNewActivity(new HomeActivity(renderer, mappedInputManager, onContinueReading, onGoToListsOrPinned, enterNewActivity(new HomeActivity(renderer, mappedInputManager, onContinueReading, onGoToListsOrPinned,
onGoToMyLibrary, onGoToSettings, onGoToFileTransfer, onGoToBrowser)); onGoToMyLibrary, onGoToSettings, onGoToFileTransfer, onGoToBrowser));
} }
@@ -550,14 +565,13 @@ void loop() {
// Basic heap info // Basic heap info
Serial.printf("[%lu] [MEM] Free: %d bytes, Total: %d bytes, Min Free: %d bytes\n", millis(), ESP.getFreeHeap(), Serial.printf("[%lu] [MEM] Free: %d bytes, Total: %d bytes, Min Free: %d bytes\n", millis(), ESP.getFreeHeap(),
ESP.getHeapSize(), ESP.getMinFreeHeap()); ESP.getHeapSize(), ESP.getMinFreeHeap());
// Detailed fragmentation info using ESP-IDF heap caps API // Detailed fragmentation info using ESP-IDF heap caps API
multi_heap_info_t info; multi_heap_info_t info;
heap_caps_get_info(&info, MALLOC_CAP_8BIT); heap_caps_get_info(&info, MALLOC_CAP_8BIT);
Serial.printf("[%lu] [HEAP] Largest: %d, Allocated: %d, Blocks: %d, Free blocks: %d\n", millis(), Serial.printf("[%lu] [HEAP] Largest: %d, Allocated: %d, Blocks: %d, Free blocks: %d\n", millis(),
info.largest_free_block, info.total_allocated_bytes, info.largest_free_block, info.total_allocated_bytes, info.allocated_blocks, info.free_blocks);
info.allocated_blocks, info.free_blocks);
lastMemPrint = millis(); lastMemPrint = millis();
} }
@@ -592,7 +606,7 @@ void loop() {
const unsigned long loopDuration = millis() - loopStartTime; const unsigned long loopDuration = millis() - loopStartTime;
if (loopDuration > maxLoopDuration) { if (loopDuration > maxLoopDuration) {
maxLoopDuration = loopDuration; maxLoopDuration = loopDuration;
if (maxLoopDuration > 50) { if (Serial && maxLoopDuration > 50) {
Serial.printf("[%lu] [LOOP] New max loop duration: %lu ms (activity: %lu ms)\n", millis(), maxLoopDuration, Serial.printf("[%lu] [LOOP] New max loop duration: %lu ms (activity: %lu ms)\n", millis(), maxLoopDuration,
activityDuration); activityDuration);
} }

View File

@@ -441,17 +441,33 @@ void CrossPointWebServer::handleFileListData() const {
showHidden = server->arg("showHidden") == "true"; showHidden = server->arg("showHidden") == "true";
} }
// Check client connection before starting
if (!server->client().connected()) {
Serial.printf("[%lu] [WEB] Client disconnected before file list could start\n", millis());
return;
}
server->setContentLength(CONTENT_LENGTH_UNKNOWN); server->setContentLength(CONTENT_LENGTH_UNKNOWN);
server->send(200, "application/json", ""); server->send(200, "application/json", "");
server->sendContent("["); if (!sendContentSafe("[")) {
Serial.printf("[%lu] [WEB] Client disconnected at start of file list\n", millis());
return;
}
char output[512]; char output[512];
constexpr size_t outputSize = sizeof(output); constexpr size_t outputSize = sizeof(output);
bool seenFirst = false; bool seenFirst = false;
bool clientDisconnected = false;
JsonDocument doc; JsonDocument doc;
scanFiles( scanFiles(
currentPath.c_str(), currentPath.c_str(),
[this, &output, &doc, seenFirst](const FileInfo& info) mutable { [this, &output, &doc, &seenFirst, &clientDisconnected](const FileInfo& info) mutable {
// Skip remaining files if client already disconnected
if (clientDisconnected) {
return;
}
doc.clear(); doc.clear();
doc["name"] = info.name; doc["name"] = info.name;
doc["size"] = info.size; doc["size"] = info.size;
@@ -475,18 +491,33 @@ void CrossPointWebServer::handleFileListData() const {
return; return;
} }
// Send comma separator before all entries except the first
if (seenFirst) { if (seenFirst) {
server->sendContent(","); if (!sendContentSafe(",")) {
clientDisconnected = true;
Serial.printf("[%lu] [WEB] Client disconnected during file list\n", millis());
return;
}
} else { } else {
seenFirst = true; seenFirst = true;
} }
server->sendContent(output);
// Send the JSON entry with flow control
if (!sendContentSafe(output)) {
clientDisconnected = true;
Serial.printf("[%lu] [WEB] Client disconnected during file list\n", millis());
return;
}
}, },
showHidden); showHidden);
server->sendContent("]");
// End of streamed response, empty chunk to signal client // Only send closing bracket if client is still connected
server->sendContent(""); if (!clientDisconnected) {
Serial.printf("[%lu] [WEB] Served file listing page for path: %s\n", millis(), currentPath.c_str()); sendContentSafe("]");
// End of streamed response, empty chunk to signal client
server->sendContent("");
Serial.printf("[%lu] [WEB] Served file listing page for path: %s\n", millis(), currentPath.c_str());
}
} }
// Static variables for upload handling // Static variables for upload handling
@@ -1264,6 +1295,40 @@ void CrossPointWebServer::handleRename() const {
} }
} }
// Counter for flow control pacing
static uint8_t sendContentCounter = 0;
bool CrossPointWebServer::sendContentSafe(const char* content) const {
if (!server || !server->client().connected()) {
return false;
}
// Send the content
server->sendContent(content);
// Flow control: give TCP stack time to transmit data and drain the send buffer
// The ESP32 TCP buffer is limited and fills quickly when streaming many small chunks.
// We use progressive delays:
// - yield() after every send to allow WiFi processing
// - delay(5ms) every send to allow buffer draining
// - delay(50ms) every 10 sends to allow larger buffer flush
yield();
sendContentCounter++;
if (sendContentCounter >= 10) {
sendContentCounter = 0;
delay(50); // Longer pause every 10 sends for buffer catchup
} else {
delay(5); // Short pause each send
}
return server->client().connected();
}
bool CrossPointWebServer::sendContentSafe(const String& content) const {
return sendContentSafe(content.c_str());
}
bool CrossPointWebServer::copyFile(const String& srcPath, const String& destPath) const { bool CrossPointWebServer::copyFile(const String& srcPath, const String& destPath) const {
FsFile srcFile; FsFile srcFile;
FsFile destFile; FsFile destFile;

View File

@@ -108,6 +108,11 @@ class CrossPointWebServer {
bool copyFile(const String& srcPath, const String& destPath) const; bool copyFile(const String& srcPath, const String& destPath) const;
bool copyFolder(const String& srcPath, const String& destPath) const; bool copyFolder(const String& srcPath, const String& destPath) const;
// Helper for safe content sending with flow control
// Returns false if client disconnected, true otherwise
bool sendContentSafe(const char* content) const;
bool sendContentSafe(const String& content) const;
// List management handlers // List management handlers
void handleListGet() const; void handleListGet() const;
void handleListPost() const; void handleListPost() const;