Compare commits
31 Commits
e8d332e34f
...
ef-1.0.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbe7d2feb4 | ||
|
|
520a0cb124 | ||
|
|
be8b02efd6 | ||
|
|
448ce55bb4 | ||
|
|
5464d9de3a | ||
|
|
48267ad848 | ||
|
|
dd630dcf72 | ||
|
|
ef705d3ac6 | ||
|
|
bab374a675 | ||
|
|
c171813045 | ||
|
|
d5e42b9e40 | ||
|
|
168c8fdb69 | ||
|
|
492cf976f5 | ||
|
|
25e255af50 | ||
|
|
a4adbb9dfe | ||
|
|
6ceba56620 | ||
|
|
62643ae933 | ||
|
|
8b41dccfb9 | ||
|
|
3204fa0339 | ||
|
|
bc6dc357eb | ||
|
|
ffe2aebd7e | ||
|
|
4965e63ad4 | ||
|
|
4db384edb6 | ||
|
|
f3075002c1 | ||
|
|
3e3be8bd23 | ||
|
|
800b07a2e5 | ||
|
|
2a31559747 | ||
|
|
c052512b1b | ||
|
|
bd95bfd44d | ||
|
|
fe446d4690 | ||
|
|
23e73312b4 |
@@ -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
|
||||||
|
|||||||
@@ -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
4
.gitignore
vendored
@@ -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
3
.gitmodules
vendored
@@ -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
|
||||||
|
|||||||
13
README.md
13
README.md
@@ -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.
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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.
|
|
||||||
70
claude_notes/missing-serial-guards-2026-01-28.md
Normal file
70
claude_notes/missing-serial-guards-2026-01-28.md
Normal 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.
|
||||||
125
claude_notes/serial-blocking-debug-2026-01-28.md
Normal file
125
claude_notes/serial-blocking-debug-2026-01-28.md
Normal 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)
|
||||||
132
claude_notes/usb-serial-blocking-fix-2026-01-28.md
Normal file
132
claude_notes/usb-serial-blocking-fix-2026-01-28.md
Normal 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.
|
||||||
422
docs/branch-comparison-summary.md
Normal file
422
docs/branch-comparison-summary.md
Normal 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.
|
||||||
603
docs/crosspoint-ef-features.md
Normal file
603
docs/crosspoint-ef-features.md
Normal 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+
|
||||||
555
docs/crosspoint-ef-user-guide.md
Normal file
555
docs/crosspoint-ef-user-guide.md
Normal 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 |
|
||||||
195
ef-CHANGELOG.md
Normal file
195
ef-CHANGELOG.md
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
# crosspoint-ef Changelog
|
||||||
|
|
||||||
|
All notable changes to the crosspoint-ef fork are documented here.
|
||||||
|
|
||||||
|
Base: CrossPoint Reader 0.15.0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ef-1.0.5
|
||||||
|
|
||||||
|
**Stability & Memory Improvements**
|
||||||
|
|
||||||
|
### Bug Fixes - Webserver
|
||||||
|
|
||||||
|
- **File Transfer Stability**: Removed blocking MD5 hash computation from file listings that caused EAGAIN errors and connection stalls
|
||||||
|
- **JSON Batching**: Implemented 2KB batch streaming for file listings with pacing to prevent TCP buffer overflow
|
||||||
|
- **Simplified Flow Control**: Removed unnecessary yield/delay logic from content streaming
|
||||||
|
|
||||||
|
### Bug Fixes - Memory
|
||||||
|
|
||||||
|
- **QR Code Caching**: Generate QR codes once on server start instead of regenerating on each screen render
|
||||||
|
- **WiFi Scan Optimization**: Replaced memory-heavy `std::map` deduplication with in-place vector search, limited results to 20 networks, earlier `WiFi.scanDelete()` for faster memory recovery
|
||||||
|
- **Cover Buffer Leak**: Fixed 48KB memory leak when navigating from Home to File Transfer (cover buffer now explicitly freed)
|
||||||
|
|
||||||
|
### Bug Fixes - EPUB Reader
|
||||||
|
|
||||||
|
- **Errant Underlining**: Fixed words before styled inline elements (like `<a>` tags with CSS underline) incorrectly receiving the element's style by flushing the text buffer before style changes
|
||||||
|
|
||||||
|
### Bug Fixes - Flashing Screen
|
||||||
|
|
||||||
|
- **Version String Overflow**: Fixed flash notification parsing failing on longer version strings (buffer limit increased from 30 to 50 characters)
|
||||||
|
- **Display Quality**: Changed flashing screen to half refresh for cleaner appearance
|
||||||
|
- **Timing**: Adjusted pre-flash script timing for half refresh completion
|
||||||
|
|
||||||
|
### Upstream Merges
|
||||||
|
|
||||||
|
- **PR #522 - HAL Abstraction Layer**: Merged hardware abstraction layer refactor introducing `HalDisplay` and `HalGPIO` classes, decoupling application code from direct hardware access
|
||||||
|
- **PR #603 - Sunlight Fading Fix**: Added user-toggleable setting to turn off display between refreshes, mitigating the sunlight fading issue on e-ink displays
|
||||||
|
- New "Sunlight Fading Fix" toggle in Display settings (OFF/ON)
|
||||||
|
- Passes `turnOffScreen` parameter through display stack when enabled
|
||||||
|
|
||||||
|
### Files Changed
|
||||||
|
|
||||||
|
- `src/main.cpp` - flash screen fixes, cover buffer free on File Transfer entry, fading fix integration
|
||||||
|
- `scripts/pre_flash.py` - timing adjustments for full refresh
|
||||||
|
- `src/network/CrossPointWebServer.cpp` - JSON batching, removed MD5 from listings
|
||||||
|
- `src/network/CrossPointWebServer.h` - removed md5 from FileInfo, simplified sendContentSafe
|
||||||
|
- `src/activities/network/CrossPointWebServerActivity.cpp` - QR code caching
|
||||||
|
- `src/activities/network/CrossPointWebServerActivity.h` - QR code cache members
|
||||||
|
- `src/activities/network/WifiSelectionActivity.cpp` - WiFi scan memory optimization
|
||||||
|
- `lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp` - flush buffer before style changes
|
||||||
|
- `lib/hal/HalDisplay.h` - new HAL abstraction for display (PR #522), turnOffScreen parameter (PR #603)
|
||||||
|
- `lib/hal/HalDisplay.cpp` - HAL display implementation with fading fix passthrough
|
||||||
|
- `lib/hal/HalGPIO.h` - new HAL abstraction for GPIO (PR #522)
|
||||||
|
- `lib/hal/HalGPIO.cpp` - HAL GPIO implementation
|
||||||
|
- `lib/GfxRenderer/GfxRenderer.h` - updated for HAL layer, added fadingFix member
|
||||||
|
- `lib/GfxRenderer/GfxRenderer.cpp` - updated for HAL layer, passes fadingFix to display
|
||||||
|
- `src/CrossPointSettings.h` - added fadingFix setting
|
||||||
|
- `src/CrossPointSettings.cpp` - fadingFix persistence
|
||||||
|
- `src/activities/settings/SettingsActivity.cpp` - added Sunlight Fading Fix toggle
|
||||||
|
- `open-x4-sdk` - updated submodule with turnOffScreen support in EInkDisplay
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ef-1.0.4
|
||||||
|
|
||||||
|
**EPUB Rendering & Stability**
|
||||||
|
|
||||||
|
### New Features
|
||||||
|
|
||||||
|
- **End-of-Book "Start Over"**: Press next at end of book to wrap to first page
|
||||||
|
|
||||||
|
### EPUB Rendering Improvements
|
||||||
|
|
||||||
|
- CSS `margin-left`/`padding-left` parsing for block indentation
|
||||||
|
- Vertical bar and italic styling for blockquotes
|
||||||
|
- Left margin indentation for list items (`<ol>`/`<ul>`)
|
||||||
|
- Fixed ordered lists showing bullets instead of numbers
|
||||||
|
- Fixed nested `<p>` inside `<li>` causing marker on separate line
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **Webserver**: Fixed file listing disconnection issues with flow control
|
||||||
|
- **Webserver**: Memory optimization for File Transfer mode (frees heap before starting)
|
||||||
|
- **Dictionary**: Fixed zip dictionary allocation order for better memory allocation success
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ef-1.0.3
|
||||||
|
|
||||||
|
**Maintenance Release**
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Fixed cppcheck CI failure: removed unused `screenWidth` variable in word selection activity
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ef-1.0.2
|
||||||
|
|
||||||
|
**Quick Menu Enhancements**
|
||||||
|
|
||||||
|
### New Features
|
||||||
|
|
||||||
|
- **Screen Rotation Toggle**: Quick toggle between Portrait and Landscape CCW directly from the quick menu
|
||||||
|
- Automatically reindexes content for new screen dimensions
|
||||||
|
- Preserves reading position via content offset restoration
|
||||||
|
- **Customizable Menu Order**: Reorder quick menu items to your preference
|
||||||
|
- New "Edit List Order" option at bottom of menu
|
||||||
|
- Pick-and-place reordering: select item, navigate to destination, place
|
||||||
|
- Order persists across sessions
|
||||||
|
|
||||||
|
### UI Improvements
|
||||||
|
|
||||||
|
- Added navigation button hints to quick menu (prev/next on front buttons, up/down on side buttons)
|
||||||
|
- Fixed orientation-aware margins for button hint areas in landscape modes
|
||||||
|
- New default menu order: Bookmark, Dictionary, Rotate Screen, Settings, Clear Cache
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ef-1.0.1
|
||||||
|
|
||||||
|
**Dictionary Stability & UX Improvements**
|
||||||
|
|
||||||
|
### Bug Fixes - Stability
|
||||||
|
|
||||||
|
- Fixed dictionary crashes caused by heap fragmentation from repeated page navigation
|
||||||
|
- Refactored TextBlock/ParsedText from `std::list` to `std::vector`, reducing heap allocations by ~12x per TextBlock
|
||||||
|
- Affects EPUB reader page rendering, dictionary definition display, and word selection
|
||||||
|
- Contiguous memory improves cache locality during text layout and reduces heap fragmentation on the memory-constrained ESP32
|
||||||
|
- Added uncompressed dictionary (`.dict`) support to avoid decompression memory issues with large dictzip chunks (58KB chunks -> direct read)
|
||||||
|
- Implemented chunked on-demand HTML parsing for large definitions, parsing pages as user navigates rather than all at once
|
||||||
|
- Limited cached pages to 4 with re-parse capability for backward navigation beyond cache window
|
||||||
|
- Fixed double-button press bug when loading new dictionary chunks
|
||||||
|
|
||||||
|
### Bug Fixes - UI/Layout
|
||||||
|
|
||||||
|
- Restored proper orientation-aware button hint spacing (front: 45px, side: 50px)
|
||||||
|
- Added side button hints to definition screen with "<" / ">" labels for page navigation
|
||||||
|
- Added side button hints to word selection screen ("UP"/"DOWN" labels, borderless, small font)
|
||||||
|
- Added side button hints to dictionary menu ("< Prev", "Next >")
|
||||||
|
- Moved page indicator up to avoid bezel cutoff in landscape orientations
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ef-1.0.0
|
||||||
|
|
||||||
|
**First Official Release** (previously ef-0.15.99)
|
||||||
|
|
||||||
|
First milestone release of the crosspoint-ef fork, building on CrossPoint Reader 0.15.0 with 14+ major new features and enhancements.
|
||||||
|
|
||||||
|
### New Features
|
||||||
|
|
||||||
|
- **Dictionary Support**: Offline StarDict dictionary with word selection from reader, fast prefix-indexed search, rich HTML formatting, and multi-page pagination
|
||||||
|
- **Bookmark System**: Per-book bookmarks with visual folded-corner indicators, dedicated management interface, and auto-generated bookmark names
|
||||||
|
- **Quick Menu**: In-reader quick access menu for common actions (Dictionary, Bookmark, Clear Cache, Settings) via short power button press
|
||||||
|
- **Library Search**: Search across all books by title, author, or filename with dynamic character picker and weighted relevance scoring
|
||||||
|
- **CSS Support**: Parse and apply CSS styles from EPUB stylesheets (text-align, font-style, font-weight, text-decoration, margins, padding)
|
||||||
|
- **Inline Image Support**: PNG and Baseline JPEG rendering within EPUB content with 2-bit grayscale dithering and caching
|
||||||
|
- **Custom Fonts**: Atkinson Hyperlegible Next (low-vision readers) and Fern Micro (small screens)
|
||||||
|
- **Enhanced Web Server**: File management (upload, download, delete, rename, copy, move, mkdir), companion app API, WebSocket uploads, mDNS discovery at `crosspoint.local`
|
||||||
|
- **Reading Lists**: Create, manage, and pin custom book lists with web API support (CSV format)
|
||||||
|
- **Enhanced Tab Bar**: Unified tab bar with horizontal scrolling and overflow indicators (Recent, Lists, Bookmarks, Search, Files)
|
||||||
|
- **Progress Bar Status**: Additional status bar option showing visual reading progress
|
||||||
|
- **OPDS Browser Enhancements**: Navigation history, page skipping (hold Up/Down), error retry, HTTP Basic Auth support
|
||||||
|
|
||||||
|
### Display Enhancements
|
||||||
|
|
||||||
|
- **High Contrast Mode**: System-wide contrast adjustment
|
||||||
|
- **Bezel Compensation**: Configurable margin (0-10px) for physical screen edge defects
|
||||||
|
- **Sleep Screen Improvements**: Edge-aware color filling for seamless letterbox appearance
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Fixed device hanging when booted without USB connected (Serial.available()/Serial.read() called without Serial.begin())
|
||||||
|
- Fixed grayscale state corruption causing ghosting artifacts when anti-aliasing enabled under memory pressure
|
||||||
|
- Memory optimization with graceful degradation when memory is low
|
||||||
|
|
||||||
|
### Development Tools
|
||||||
|
|
||||||
|
- `pre_flash.py`: Displays "Flashing firmware..." screen during upload
|
||||||
|
- `debugging_monitor.py`: Enhanced serial monitor with memory graphs
|
||||||
|
- `pio_helper.py`: Interactive PlatformIO workflow helper
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Differences from Upstream 0.16.0
|
||||||
|
|
||||||
|
This fork is based on upstream 0.15.0. The following 0.16.0 features are not included:
|
||||||
|
|
||||||
|
- KOReader sync support
|
||||||
|
- Non-English hyphenation patterns (Spanish, German, French, Russian)
|
||||||
|
- XTC/XTCH file format support
|
||||||
|
|
||||||
|
See [crosspoint-ef-features.md](docs/crosspoint-ef-features.md) for complete feature documentation.
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,35 @@ 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(std::make_move_iterator(words.begin()),
|
||||||
auto wordUnderlineEndIt = wordUnderlines.begin();
|
std::make_move_iterator(words.begin() + lineWordCount));
|
||||||
std::advance(wordEndIt, lineWordCount);
|
words.erase(words.begin(), words.begin() + lineWordCount);
|
||||||
std::advance(wordStyleEndIt, lineWordCount);
|
|
||||||
std::advance(wordUnderlineEndIt, lineWordCount);
|
|
||||||
|
|
||||||
// *** CRITICAL STEP: CONSUME DATA USING SPLICE ***
|
std::vector<EpdFontFamily::Style> lineWordStyles(std::make_move_iterator(wordStyles.begin()),
|
||||||
std::list<std::string> lineWords;
|
std::make_move_iterator(wordStyles.begin() + lineWordCount));
|
||||||
lineWords.splice(lineWords.begin(), words, words.begin(), wordEndIt);
|
wordStyles.erase(wordStyles.begin(), wordStyles.begin() + lineWordCount);
|
||||||
std::list<EpdFontFamily::Style> lineWordStyles;
|
|
||||||
lineWordStyles.splice(lineWordStyles.begin(), wordStyles, wordStyles.begin(), wordStyleEndIt);
|
std::vector<bool> lineWordUnderlines(wordUnderlines.begin(), wordUnderlines.begin() + lineWordCount);
|
||||||
std::list<bool> lineWordUnderlines;
|
wordUnderlines.erase(wordUnderlines.begin(), wordUnderlines.begin() + lineWordCount);
|
||||||
lineWordUnderlines.splice(lineWordUnderlines.begin(), wordUnderlines, wordUnderlines.begin(), wordUnderlineEndIt);
|
|
||||||
|
|
||||||
for (auto& word : lineWords) {
|
for (auto& word : lineWords) {
|
||||||
if (containsSoftHyphen(word)) {
|
if (containsSoftHyphen(word)) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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)));
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
#include <EpdFontFamily.h>
|
#include <EpdFontFamily.h>
|
||||||
#include <SdFat.h>
|
#include <SdFat.h>
|
||||||
|
|
||||||
#include <list>
|
|
||||||
#include <memory>
|
#include <memory>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
#include "Block.h"
|
#include "Block.h"
|
||||||
#include "BlockStyle.h"
|
#include "BlockStyle.h"
|
||||||
@@ -20,17 +20,18 @@ 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 +51,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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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,15 +417,77 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply default styling for blockquote if no CSS margin is specified
|
||||||
|
const bool isBlockquote = strcmp(name, "blockquote") == 0;
|
||||||
|
if (isBlockquote) {
|
||||||
|
if (!cssStyle.hasMarginLeft()) {
|
||||||
|
// Default left indent for blockquotes (~1.5em at 16px base = 24px)
|
||||||
|
cssStyle.marginLeft = 24.0f;
|
||||||
|
cssStyle.defined.marginLeft = 1;
|
||||||
|
}
|
||||||
|
// Also make blockquotes italic by default if not specified
|
||||||
|
if (!cssStyle.hasFontStyle()) {
|
||||||
|
cssStyle.fontStyle = CssFontStyle::Italic;
|
||||||
|
cssStyle.defined.fontStyle = 1;
|
||||||
|
}
|
||||||
|
// Track blockquote context for child elements
|
||||||
|
self->insideBlockquote = true;
|
||||||
|
self->blockquoteDepth = self->depth;
|
||||||
|
self->blockquoteMarginLeft = cssStyle.marginLeft;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply blockquote styling to child block elements
|
||||||
|
if (self->insideBlockquote && !isBlockquote) {
|
||||||
|
// Inherit margin and border from parent blockquote
|
||||||
|
if (!cssStyle.hasMarginLeft()) {
|
||||||
|
cssStyle.marginLeft = self->blockquoteMarginLeft;
|
||||||
|
cssStyle.defined.marginLeft = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply left margin to list items (indent the whole block)
|
||||||
|
if (self->insideListItem && !cssStyle.hasMarginLeft()) {
|
||||||
|
// Default left indent for list items (~1.5em at 16px base = 24px)
|
||||||
|
cssStyle.marginLeft = 24.0f;
|
||||||
|
cssStyle.defined.marginLeft = 1;
|
||||||
|
}
|
||||||
|
|
||||||
self->currentBlockStyle = cssStyle;
|
self->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
|
||||||
|
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);
|
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)) {
|
||||||
|
// Flush buffer with CURRENT style before changing effective style
|
||||||
|
self->flushPartWordBuffer();
|
||||||
|
|
||||||
self->underlineUntilDepth = std::min(self->underlineUntilDepth, self->depth);
|
self->underlineUntilDepth = std::min(self->underlineUntilDepth, self->depth);
|
||||||
// Push inline style entry for underline tag
|
// Push inline style entry for underline tag
|
||||||
StyleStackEntry entry;
|
StyleStackEntry entry;
|
||||||
@@ -413,6 +505,9 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
|||||||
self->inlineStyleStack.push_back(entry);
|
self->inlineStyleStack.push_back(entry);
|
||||||
self->updateEffectiveInlineStyle();
|
self->updateEffectiveInlineStyle();
|
||||||
} else if (matches(name, BOLD_TAGS, NUM_BOLD_TAGS)) {
|
} else if (matches(name, BOLD_TAGS, NUM_BOLD_TAGS)) {
|
||||||
|
// Flush buffer with CURRENT style before changing effective style
|
||||||
|
self->flushPartWordBuffer();
|
||||||
|
|
||||||
self->boldUntilDepth = std::min(self->boldUntilDepth, self->depth);
|
self->boldUntilDepth = std::min(self->boldUntilDepth, self->depth);
|
||||||
// Push inline style entry for bold tag
|
// Push inline style entry for bold tag
|
||||||
StyleStackEntry entry;
|
StyleStackEntry entry;
|
||||||
@@ -430,6 +525,9 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
|||||||
self->inlineStyleStack.push_back(entry);
|
self->inlineStyleStack.push_back(entry);
|
||||||
self->updateEffectiveInlineStyle();
|
self->updateEffectiveInlineStyle();
|
||||||
} else if (matches(name, ITALIC_TAGS, NUM_ITALIC_TAGS)) {
|
} else if (matches(name, ITALIC_TAGS, NUM_ITALIC_TAGS)) {
|
||||||
|
// Flush buffer with CURRENT style before changing effective style
|
||||||
|
self->flushPartWordBuffer();
|
||||||
|
|
||||||
self->italicUntilDepth = std::min(self->italicUntilDepth, self->depth);
|
self->italicUntilDepth = std::min(self->italicUntilDepth, self->depth);
|
||||||
// Push inline style entry for italic tag
|
// Push inline style entry for italic tag
|
||||||
StyleStackEntry entry;
|
StyleStackEntry entry;
|
||||||
@@ -449,6 +547,10 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
|||||||
} else if (strcmp(name, "span") == 0 || !isBlockElement) {
|
} else if (strcmp(name, "span") == 0 || !isBlockElement) {
|
||||||
// Handle span and other inline elements for CSS styling
|
// Handle span and other inline elements for CSS styling
|
||||||
if (cssStyle.hasFontWeight() || cssStyle.hasFontStyle() || cssStyle.hasTextDecoration()) {
|
if (cssStyle.hasFontWeight() || cssStyle.hasFontStyle() || cssStyle.hasTextDecoration()) {
|
||||||
|
// Flush buffer with CURRENT style before changing effective style
|
||||||
|
// This prevents text accumulated before this element from getting the new style
|
||||||
|
self->flushPartWordBuffer();
|
||||||
|
|
||||||
StyleStackEntry entry;
|
StyleStackEntry entry;
|
||||||
entry.depth = self->depth; // Track depth for matching pop
|
entry.depth = self->depth; // Track depth for matching pop
|
||||||
if (cssStyle.hasFontWeight()) {
|
if (cssStyle.hasFontWeight()) {
|
||||||
@@ -484,6 +586,33 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char
|
|||||||
self->lastCharDataOffset = XML_GetCurrentByteIndex(self->xmlParser);
|
self->lastCharDataOffset = XML_GetCurrentByteIndex(self->xmlParser);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If we're inside an <li> but no text block was created yet (direct text without inner <p>),
|
||||||
|
// create a text block and add the list marker now
|
||||||
|
if (self->insideListItem && !self->listItemHasContent) {
|
||||||
|
// Apply left margin for list items
|
||||||
|
CssStyle cssStyle;
|
||||||
|
cssStyle.marginLeft = 24.0f; // Default indent (~1.5em at 16px base)
|
||||||
|
cssStyle.defined.marginLeft = 1;
|
||||||
|
|
||||||
|
BlockStyle blockStyle = createBlockStyleFromCss(cssStyle);
|
||||||
|
self->startNewTextBlock(static_cast<TextBlock::Style>(self->paragraphAlignment), blockStyle);
|
||||||
|
|
||||||
|
// Add the list marker
|
||||||
|
if (!self->listStack.empty()) {
|
||||||
|
const ListContext& ctx = self->listStack.back();
|
||||||
|
if (ctx.isOrdered) {
|
||||||
|
std::string marker = std::to_string(ctx.counter) + ". ";
|
||||||
|
self->currentTextBlock->addWord(marker, EpdFontFamily::REGULAR);
|
||||||
|
} else {
|
||||||
|
self->currentTextBlock->addWord("\xe2\x80\xa2", EpdFontFamily::REGULAR);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No list context (orphan li), use bullet as fallback
|
||||||
|
self->currentTextBlock->addWord("\xe2\x80\xa2", EpdFontFamily::REGULAR);
|
||||||
|
}
|
||||||
|
self->listItemHasContent = true;
|
||||||
|
}
|
||||||
|
|
||||||
// Determine font style from depth-based tracking and CSS effective style
|
// Determine font style from depth-based tracking and CSS effective style
|
||||||
const bool isBold = self->boldUntilDepth < self->depth || self->effectiveBold;
|
const bool isBold = self->boldUntilDepth < self->depth || self->effectiveBold;
|
||||||
const bool isItalic = self->italicUntilDepth < self->depth || self->effectiveItalic;
|
const bool isItalic = self->italicUntilDepth < self->depth || self->effectiveItalic;
|
||||||
@@ -566,7 +695,8 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n
|
|||||||
const bool shouldFlush = styleWillChange || matches(name, BLOCK_TAGS, NUM_BLOCK_TAGS) ||
|
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 +726,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;
|
||||||
|
|||||||
@@ -59,6 +59,22 @@ class ChapterHtmlSlimParser {
|
|||||||
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -10,19 +10,19 @@ void GfxRenderer::rotateCoordinates(const int x, const int y, int* rotatedX, int
|
|||||||
// Logical portrait (480x800) → panel (800x480)
|
// Logical portrait (480x800) → panel (800x480)
|
||||||
// Rotation: 90 degrees clockwise
|
// Rotation: 90 degrees clockwise
|
||||||
*rotatedX = y;
|
*rotatedX = y;
|
||||||
*rotatedY = EInkDisplay::DISPLAY_HEIGHT - 1 - x;
|
*rotatedY = HalDisplay::DISPLAY_HEIGHT - 1 - x;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case LandscapeClockwise: {
|
case LandscapeClockwise: {
|
||||||
// Logical landscape (800x480) rotated 180 degrees (swap top/bottom and left/right)
|
// Logical landscape (800x480) rotated 180 degrees (swap top/bottom and left/right)
|
||||||
*rotatedX = EInkDisplay::DISPLAY_WIDTH - 1 - x;
|
*rotatedX = HalDisplay::DISPLAY_WIDTH - 1 - x;
|
||||||
*rotatedY = EInkDisplay::DISPLAY_HEIGHT - 1 - y;
|
*rotatedY = HalDisplay::DISPLAY_HEIGHT - 1 - y;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case PortraitInverted: {
|
case PortraitInverted: {
|
||||||
// Logical portrait (480x800) → panel (800x480)
|
// Logical portrait (480x800) → panel (800x480)
|
||||||
// Rotation: 90 degrees counter-clockwise
|
// Rotation: 90 degrees counter-clockwise
|
||||||
*rotatedX = EInkDisplay::DISPLAY_WIDTH - 1 - y;
|
*rotatedX = HalDisplay::DISPLAY_WIDTH - 1 - y;
|
||||||
*rotatedY = x;
|
*rotatedY = x;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -36,7 +36,7 @@ void GfxRenderer::rotateCoordinates(const int x, const int y, int* rotatedX, int
|
|||||||
}
|
}
|
||||||
|
|
||||||
void GfxRenderer::drawPixel(const int x, const int y, const bool state) const {
|
void GfxRenderer::drawPixel(const int x, const int y, const bool state) const {
|
||||||
uint8_t* frameBuffer = einkDisplay.getFrameBuffer();
|
uint8_t* frameBuffer = display.getFrameBuffer();
|
||||||
|
|
||||||
// Early return if no framebuffer is set
|
// Early return if no framebuffer is set
|
||||||
if (!frameBuffer) {
|
if (!frameBuffer) {
|
||||||
@@ -49,14 +49,13 @@ void GfxRenderer::drawPixel(const int x, const int y, const bool state) const {
|
|||||||
rotateCoordinates(x, y, &rotatedX, &rotatedY);
|
rotateCoordinates(x, y, &rotatedX, &rotatedY);
|
||||||
|
|
||||||
// Bounds checking against physical panel dimensions
|
// Bounds checking against physical panel dimensions
|
||||||
if (rotatedX < 0 || rotatedX >= EInkDisplay::DISPLAY_WIDTH || rotatedY < 0 ||
|
if (rotatedX < 0 || rotatedX >= HalDisplay::DISPLAY_WIDTH || rotatedY < 0 || rotatedY >= HalDisplay::DISPLAY_HEIGHT) {
|
||||||
rotatedY >= EInkDisplay::DISPLAY_HEIGHT) {
|
|
||||||
Serial.printf("[%lu] [GFX] !! Outside range (%d, %d) -> (%d, %d)\n", millis(), x, y, rotatedX, rotatedY);
|
Serial.printf("[%lu] [GFX] !! Outside range (%d, %d) -> (%d, %d)\n", millis(), x, y, rotatedX, rotatedY);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate byte position and bit position
|
// Calculate byte position and bit position
|
||||||
const uint16_t byteIndex = rotatedY * EInkDisplay::DISPLAY_WIDTH_BYTES + (rotatedX / 8);
|
const uint16_t byteIndex = rotatedY * HalDisplay::DISPLAY_WIDTH_BYTES + (rotatedX / 8);
|
||||||
const uint8_t bitPosition = 7 - (rotatedX % 8); // MSB first
|
const uint8_t bitPosition = 7 - (rotatedX % 8); // MSB first
|
||||||
|
|
||||||
if (state) {
|
if (state) {
|
||||||
@@ -202,7 +201,7 @@ void GfxRenderer::drawImage(const uint8_t bitmap[], const int x, const int y, co
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
// TODO: Rotate bits
|
// TODO: Rotate bits
|
||||||
einkDisplay.drawImage(bitmap, rotatedX, rotatedY, width, height);
|
display.drawImage(bitmap, rotatedX, rotatedY, width, height);
|
||||||
}
|
}
|
||||||
|
|
||||||
void GfxRenderer::drawImageRotated(const uint8_t bitmap[], const int x, const int y, const int width, const int height,
|
void GfxRenderer::drawImageRotated(const uint8_t bitmap[], const int x, const int y, const int width, const int height,
|
||||||
@@ -327,7 +326,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 +340,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 +410,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 +422,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;
|
||||||
@@ -515,21 +518,21 @@ void GfxRenderer::fillPolygon(const int* xPoints, const int* yPoints, int numPoi
|
|||||||
free(nodeX);
|
free(nodeX);
|
||||||
}
|
}
|
||||||
|
|
||||||
void GfxRenderer::clearScreen(const uint8_t color) const { einkDisplay.clearScreen(color); }
|
void GfxRenderer::clearScreen(const uint8_t color) const { display.clearScreen(color); }
|
||||||
|
|
||||||
void GfxRenderer::invertScreen() const {
|
void GfxRenderer::invertScreen() const {
|
||||||
uint8_t* buffer = einkDisplay.getFrameBuffer();
|
uint8_t* buffer = display.getFrameBuffer();
|
||||||
if (!buffer) {
|
if (!buffer) {
|
||||||
Serial.printf("[%lu] [GFX] !! No framebuffer in invertScreen\n", millis());
|
Serial.printf("[%lu] [GFX] !! No framebuffer in invertScreen\n", millis());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (int i = 0; i < EInkDisplay::BUFFER_SIZE; i++) {
|
for (int i = 0; i < HalDisplay::BUFFER_SIZE; i++) {
|
||||||
buffer[i] = ~buffer[i];
|
buffer[i] = ~buffer[i];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void GfxRenderer::displayBuffer(const EInkDisplay::RefreshMode refreshMode) const {
|
void GfxRenderer::displayBuffer(const HalDisplay::RefreshMode refreshMode) const {
|
||||||
einkDisplay.displayBuffer(refreshMode);
|
display.displayBuffer(refreshMode, fadingFix);
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string GfxRenderer::truncatedText(const int fontId, const char* text, const int maxWidth,
|
std::string GfxRenderer::truncatedText(const int fontId, const char* text, const int maxWidth,
|
||||||
@@ -549,13 +552,13 @@ int GfxRenderer::getScreenWidth() const {
|
|||||||
case Portrait:
|
case Portrait:
|
||||||
case PortraitInverted:
|
case PortraitInverted:
|
||||||
// 480px wide in portrait logical coordinates
|
// 480px wide in portrait logical coordinates
|
||||||
return EInkDisplay::DISPLAY_HEIGHT;
|
return HalDisplay::DISPLAY_HEIGHT;
|
||||||
case LandscapeClockwise:
|
case LandscapeClockwise:
|
||||||
case LandscapeCounterClockwise:
|
case LandscapeCounterClockwise:
|
||||||
// 800px wide in landscape logical coordinates
|
// 800px wide in landscape logical coordinates
|
||||||
return EInkDisplay::DISPLAY_WIDTH;
|
return HalDisplay::DISPLAY_WIDTH;
|
||||||
}
|
}
|
||||||
return EInkDisplay::DISPLAY_HEIGHT;
|
return HalDisplay::DISPLAY_HEIGHT;
|
||||||
}
|
}
|
||||||
|
|
||||||
int GfxRenderer::getScreenHeight() const {
|
int GfxRenderer::getScreenHeight() const {
|
||||||
@@ -563,13 +566,13 @@ int GfxRenderer::getScreenHeight() const {
|
|||||||
case Portrait:
|
case Portrait:
|
||||||
case PortraitInverted:
|
case PortraitInverted:
|
||||||
// 800px tall in portrait logical coordinates
|
// 800px tall in portrait logical coordinates
|
||||||
return EInkDisplay::DISPLAY_WIDTH;
|
return HalDisplay::DISPLAY_WIDTH;
|
||||||
case LandscapeClockwise:
|
case LandscapeClockwise:
|
||||||
case LandscapeCounterClockwise:
|
case LandscapeCounterClockwise:
|
||||||
// 480px tall in landscape logical coordinates
|
// 480px tall in landscape logical coordinates
|
||||||
return EInkDisplay::DISPLAY_HEIGHT;
|
return HalDisplay::DISPLAY_HEIGHT;
|
||||||
}
|
}
|
||||||
return EInkDisplay::DISPLAY_WIDTH;
|
return HalDisplay::DISPLAY_WIDTH;
|
||||||
}
|
}
|
||||||
|
|
||||||
int GfxRenderer::getSpaceWidth(const int fontId) const {
|
int GfxRenderer::getSpaceWidth(const int fontId) const {
|
||||||
@@ -646,7 +649,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,6 +671,7 @@ 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;
|
||||||
|
|
||||||
|
if (drawBorder) {
|
||||||
// Draw top button outline (3 sides, bottom open)
|
// Draw top button outline (3 sides, bottom open)
|
||||||
if (topBtn != nullptr && topBtn[0] != '\0') {
|
if (topBtn != nullptr && topBtn[0] != '\0') {
|
||||||
drawLine(x, topButtonY, x + buttonWidth - 1, topButtonY); // Top
|
drawLine(x, topButtonY, x + buttonWidth - 1, topButtonY); // Top
|
||||||
@@ -686,8 +691,12 @@ void GfxRenderer::drawSideButtonHints(const int fontId, const char* topBtn, cons
|
|||||||
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,13 +705,24 @@ 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);
|
||||||
|
|
||||||
|
int textX, textY;
|
||||||
|
if (drawBorder) {
|
||||||
// Center the rotated text in the button
|
// Center the rotated text in the button
|
||||||
const int textX = x + (buttonWidth - textHeight) / 2;
|
textX = x + (buttonWidth - textHeight) / 2;
|
||||||
const int textY = y + (buttonHeight + textWidth) / 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (useCCW) {
|
||||||
|
drawTextRotated90CCW(fontId, textX, textY, labels[i]);
|
||||||
|
} else {
|
||||||
drawTextRotated90CW(fontId, textX, textY, labels[i]);
|
drawTextRotated90CW(fontId, textX, textY, labels[i]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setOrientation(orig_orientation);
|
setOrientation(orig_orientation);
|
||||||
}
|
}
|
||||||
@@ -798,17 +818,101 @@ void GfxRenderer::drawTextRotated90CW(const int fontId, const int x, const int y
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
uint8_t* GfxRenderer::getFrameBuffer() const { return einkDisplay.getFrameBuffer(); }
|
void GfxRenderer::drawTextRotated90CCW(const int fontId, const int x, const int y, const char* text, const bool black,
|
||||||
|
const EpdFontFamily::Style style) const {
|
||||||
|
// Cannot draw a NULL / empty string
|
||||||
|
if (text == nullptr || *text == '\0') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
size_t GfxRenderer::getBufferSize() { return EInkDisplay::BUFFER_SIZE; }
|
if (fontMap.count(fontId) == 0) {
|
||||||
|
Serial.printf("[%lu] [GFX] Font %d not found\n", millis(), fontId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const auto font = fontMap.at(fontId);
|
||||||
|
|
||||||
void GfxRenderer::grayscaleRevert() const { einkDisplay.grayscaleRevert(); }
|
// No printable characters
|
||||||
|
if (!font.hasPrintableChars(text, style)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
void GfxRenderer::copyGrayscaleLsbBuffers() const { einkDisplay.copyGrayscaleLsbBuffers(einkDisplay.getFrameBuffer()); }
|
// For 90° counter-clockwise rotation:
|
||||||
|
// Original (glyphX, glyphY) -> Rotated (-glyphY, glyphX)
|
||||||
|
// Text reads from top to bottom
|
||||||
|
|
||||||
void GfxRenderer::copyGrayscaleMsbBuffers() const { einkDisplay.copyGrayscaleMsbBuffers(einkDisplay.getFrameBuffer()); }
|
int yPos = y; // Current Y position (increases as we draw characters)
|
||||||
|
|
||||||
void GfxRenderer::displayGrayBuffer() const { einkDisplay.displayGrayBuffer(); }
|
uint32_t cp;
|
||||||
|
while ((cp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&text)))) {
|
||||||
|
const EpdGlyph* glyph = font.getGlyph(cp, style);
|
||||||
|
if (!glyph) {
|
||||||
|
glyph = font.getGlyph(REPLACEMENT_GLYPH, style);
|
||||||
|
}
|
||||||
|
if (!glyph) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const int is2Bit = font.getData(style)->is2Bit;
|
||||||
|
const uint32_t offset = glyph->dataOffset;
|
||||||
|
const uint8_t width = glyph->width;
|
||||||
|
const uint8_t height = glyph->height;
|
||||||
|
const int left = glyph->left;
|
||||||
|
const int top = glyph->top;
|
||||||
|
|
||||||
|
const uint8_t* bitmap = &font.getData(style)->bitmap[offset];
|
||||||
|
|
||||||
|
if (bitmap != nullptr) {
|
||||||
|
for (int glyphY = 0; glyphY < height; glyphY++) {
|
||||||
|
for (int glyphX = 0; glyphX < width; glyphX++) {
|
||||||
|
const int pixelPosition = glyphY * width + glyphX;
|
||||||
|
|
||||||
|
// 90° counter-clockwise rotation transformation:
|
||||||
|
// screenX = x + (top - glyphY)
|
||||||
|
// screenY = yPos + (left + glyphX)
|
||||||
|
const int screenX = x + (top - glyphY);
|
||||||
|
const int screenY = yPos + left + glyphX;
|
||||||
|
|
||||||
|
if (is2Bit) {
|
||||||
|
const uint8_t byte = bitmap[pixelPosition / 4];
|
||||||
|
const uint8_t bit_index = (3 - pixelPosition % 4) * 2;
|
||||||
|
const uint8_t bmpVal = 3 - (byte >> bit_index) & 0x3;
|
||||||
|
|
||||||
|
if (renderMode == BW && bmpVal < 3) {
|
||||||
|
drawPixel(screenX, screenY, black);
|
||||||
|
} else if (renderMode == GRAYSCALE_MSB && (bmpVal == 1 || bmpVal == 2)) {
|
||||||
|
drawPixel(screenX, screenY, false);
|
||||||
|
} else if (renderMode == GRAYSCALE_LSB && bmpVal == 1) {
|
||||||
|
drawPixel(screenX, screenY, false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const uint8_t byte = bitmap[pixelPosition / 8];
|
||||||
|
const uint8_t bit_index = 7 - (pixelPosition % 8);
|
||||||
|
|
||||||
|
if ((byte >> bit_index) & 1) {
|
||||||
|
drawPixel(screenX, screenY, black);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move to next character position (going down, so increase Y)
|
||||||
|
yPos += glyph->advanceX;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t* GfxRenderer::getFrameBuffer() const { return display.getFrameBuffer(); }
|
||||||
|
|
||||||
|
size_t GfxRenderer::getBufferSize() { return HalDisplay::BUFFER_SIZE; }
|
||||||
|
|
||||||
|
// unused
|
||||||
|
// void GfxRenderer::grayscaleRevert() const { display.grayscaleRevert(); }
|
||||||
|
|
||||||
|
void GfxRenderer::copyGrayscaleLsbBuffers() const { display.copyGrayscaleLsbBuffers(display.getFrameBuffer()); }
|
||||||
|
|
||||||
|
void GfxRenderer::copyGrayscaleMsbBuffers() const { display.copyGrayscaleMsbBuffers(display.getFrameBuffer()); }
|
||||||
|
|
||||||
|
void GfxRenderer::displayGrayBuffer() const { display.displayGrayBuffer(fadingFix); }
|
||||||
|
|
||||||
void GfxRenderer::freeBwBufferChunks() {
|
void GfxRenderer::freeBwBufferChunks() {
|
||||||
for (auto& bwBufferChunk : bwBufferChunks) {
|
for (auto& bwBufferChunk : bwBufferChunks) {
|
||||||
@@ -826,7 +930,7 @@ void GfxRenderer::freeBwBufferChunks() {
|
|||||||
* Returns true if buffer was stored successfully, false if allocation failed.
|
* Returns true if buffer was stored successfully, false if allocation failed.
|
||||||
*/
|
*/
|
||||||
bool GfxRenderer::storeBwBuffer() {
|
bool GfxRenderer::storeBwBuffer() {
|
||||||
const uint8_t* frameBuffer = einkDisplay.getFrameBuffer();
|
const uint8_t* frameBuffer = display.getFrameBuffer();
|
||||||
if (!frameBuffer) {
|
if (!frameBuffer) {
|
||||||
Serial.printf("[%lu] [GFX] !! No framebuffer in storeBwBuffer\n", millis());
|
Serial.printf("[%lu] [GFX] !! No framebuffer in storeBwBuffer\n", millis());
|
||||||
return false;
|
return false;
|
||||||
@@ -881,14 +985,14 @@ void GfxRenderer::restoreBwBuffer() {
|
|||||||
// CRITICAL: Even if restore fails, we must clean up the grayscale state
|
// CRITICAL: Even if restore fails, we must clean up the grayscale state
|
||||||
// to prevent grayscaleRevert() from being called with corrupted RAM state
|
// to prevent grayscaleRevert() from being called with corrupted RAM state
|
||||||
// Use the current framebuffer content (which may not be ideal but prevents worse issues)
|
// Use the current framebuffer content (which may not be ideal but prevents worse issues)
|
||||||
uint8_t* frameBuffer = einkDisplay.getFrameBuffer();
|
uint8_t* frameBuffer = display.getFrameBuffer();
|
||||||
if (frameBuffer) {
|
if (frameBuffer) {
|
||||||
einkDisplay.cleanupGrayscaleBuffers(frameBuffer);
|
display.cleanupGrayscaleBuffers(frameBuffer);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
uint8_t* frameBuffer = einkDisplay.getFrameBuffer();
|
uint8_t* frameBuffer = display.getFrameBuffer();
|
||||||
if (!frameBuffer) {
|
if (!frameBuffer) {
|
||||||
Serial.printf("[%lu] [GFX] !! No framebuffer in restoreBwBuffer\n", millis());
|
Serial.printf("[%lu] [GFX] !! No framebuffer in restoreBwBuffer\n", millis());
|
||||||
freeBwBufferChunks();
|
freeBwBufferChunks();
|
||||||
@@ -901,7 +1005,7 @@ void GfxRenderer::restoreBwBuffer() {
|
|||||||
Serial.printf("[%lu] [GFX] !! BW buffer chunks not stored - this is likely a bug\n", millis());
|
Serial.printf("[%lu] [GFX] !! BW buffer chunks not stored - this is likely a bug\n", millis());
|
||||||
freeBwBufferChunks();
|
freeBwBufferChunks();
|
||||||
// CRITICAL: Clean up grayscale state even on mid-restore failure
|
// CRITICAL: Clean up grayscale state even on mid-restore failure
|
||||||
einkDisplay.cleanupGrayscaleBuffers(frameBuffer);
|
display.cleanupGrayscaleBuffers(frameBuffer);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -909,7 +1013,7 @@ void GfxRenderer::restoreBwBuffer() {
|
|||||||
memcpy(frameBuffer + offset, bwBufferChunks[i], BW_BUFFER_CHUNK_SIZE);
|
memcpy(frameBuffer + offset, bwBufferChunks[i], BW_BUFFER_CHUNK_SIZE);
|
||||||
}
|
}
|
||||||
|
|
||||||
einkDisplay.cleanupGrayscaleBuffers(frameBuffer);
|
display.cleanupGrayscaleBuffers(frameBuffer);
|
||||||
|
|
||||||
freeBwBufferChunks();
|
freeBwBufferChunks();
|
||||||
Serial.printf("[%lu] [GFX] Restored and freed BW buffer chunks\n", millis());
|
Serial.printf("[%lu] [GFX] Restored and freed BW buffer chunks\n", millis());
|
||||||
@@ -920,9 +1024,9 @@ void GfxRenderer::restoreBwBuffer() {
|
|||||||
* Use this when BW buffer was re-rendered instead of stored/restored.
|
* Use this when BW buffer was re-rendered instead of stored/restored.
|
||||||
*/
|
*/
|
||||||
void GfxRenderer::cleanupGrayscaleWithFrameBuffer() const {
|
void GfxRenderer::cleanupGrayscaleWithFrameBuffer() const {
|
||||||
uint8_t* frameBuffer = einkDisplay.getFrameBuffer();
|
uint8_t* frameBuffer = display.getFrameBuffer();
|
||||||
if (frameBuffer) {
|
if (frameBuffer) {
|
||||||
einkDisplay.cleanupGrayscaleBuffers(frameBuffer);
|
display.cleanupGrayscaleBuffers(frameBuffer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include <EInkDisplay.h>
|
|
||||||
#include <EpdFontFamily.h>
|
#include <EpdFontFamily.h>
|
||||||
|
#include <HalDisplay.h>
|
||||||
|
|
||||||
#include <map>
|
#include <map>
|
||||||
|
|
||||||
@@ -24,8 +24,8 @@ class GfxRenderer {
|
|||||||
|
|
||||||
private:
|
private:
|
||||||
static constexpr size_t BW_BUFFER_CHUNK_SIZE = 8000; // 8KB chunks to allow for non-contiguous memory
|
static constexpr size_t BW_BUFFER_CHUNK_SIZE = 8000; // 8KB chunks to allow for non-contiguous memory
|
||||||
static constexpr size_t BW_BUFFER_NUM_CHUNKS = EInkDisplay::BUFFER_SIZE / BW_BUFFER_CHUNK_SIZE;
|
static constexpr size_t BW_BUFFER_NUM_CHUNKS = HalDisplay::BUFFER_SIZE / BW_BUFFER_CHUNK_SIZE;
|
||||||
static_assert(BW_BUFFER_CHUNK_SIZE * BW_BUFFER_NUM_CHUNKS == EInkDisplay::BUFFER_SIZE,
|
static_assert(BW_BUFFER_CHUNK_SIZE * BW_BUFFER_NUM_CHUNKS == HalDisplay::BUFFER_SIZE,
|
||||||
"BW buffer chunking does not line up with display buffer size");
|
"BW buffer chunking does not line up with display buffer size");
|
||||||
|
|
||||||
// Base viewable margins (hardware-specific, before bezel compensation)
|
// Base viewable margins (hardware-specific, before bezel compensation)
|
||||||
@@ -34,9 +34,10 @@ class GfxRenderer {
|
|||||||
static constexpr int BASE_VIEWABLE_MARGIN_BOTTOM = 3;
|
static constexpr int BASE_VIEWABLE_MARGIN_BOTTOM = 3;
|
||||||
static constexpr int BASE_VIEWABLE_MARGIN_LEFT = 3;
|
static constexpr int BASE_VIEWABLE_MARGIN_LEFT = 3;
|
||||||
|
|
||||||
EInkDisplay& einkDisplay;
|
HalDisplay& display;
|
||||||
RenderMode renderMode;
|
RenderMode renderMode;
|
||||||
Orientation orientation;
|
Orientation orientation;
|
||||||
|
bool fadingFix = false; // Sunlight fading fix - turn off screen after refresh
|
||||||
int bezelCompensation = 0; // Pixels to add for bezel defect compensation
|
int bezelCompensation = 0; // Pixels to add for bezel defect compensation
|
||||||
int bezelEdge = 0; // Which physical edge (0=bottom, 1=top, 2=left, 3=right in portrait)
|
int bezelEdge = 0; // Which physical edge (0=bottom, 1=top, 2=left, 3=right in portrait)
|
||||||
uint8_t* bwBufferChunks[BW_BUFFER_NUM_CHUNKS] = {nullptr};
|
uint8_t* bwBufferChunks[BW_BUFFER_NUM_CHUNKS] = {nullptr};
|
||||||
@@ -47,7 +48,7 @@ class GfxRenderer {
|
|||||||
void rotateCoordinates(int x, int y, int* rotatedX, int* rotatedY) const;
|
void rotateCoordinates(int x, int y, int* rotatedX, int* rotatedY) const;
|
||||||
|
|
||||||
public:
|
public:
|
||||||
explicit GfxRenderer(EInkDisplay& einkDisplay) : einkDisplay(einkDisplay), renderMode(BW), orientation(Portrait) {}
|
explicit GfxRenderer(HalDisplay& halDisplay) : display(halDisplay), renderMode(BW), orientation(Portrait) {}
|
||||||
~GfxRenderer() { freeBwBufferChunks(); }
|
~GfxRenderer() { freeBwBufferChunks(); }
|
||||||
|
|
||||||
// Viewable margins (includes bezel compensation applied to the configured edge)
|
// Viewable margins (includes bezel compensation applied to the configured edge)
|
||||||
@@ -76,10 +77,13 @@ class GfxRenderer {
|
|||||||
void setOrientation(const Orientation o) { orientation = o; }
|
void setOrientation(const Orientation o) { orientation = o; }
|
||||||
Orientation getOrientation() const { return orientation; }
|
Orientation getOrientation() const { return orientation; }
|
||||||
|
|
||||||
|
// Fading fix control
|
||||||
|
void setFadingFix(const bool enabled) { fadingFix = enabled; }
|
||||||
|
|
||||||
// Screen ops
|
// Screen ops
|
||||||
int getScreenWidth() const;
|
int getScreenWidth() const;
|
||||||
int getScreenHeight() const;
|
int getScreenHeight() const;
|
||||||
void displayBuffer(EInkDisplay::RefreshMode refreshMode = EInkDisplay::FAST_REFRESH) const;
|
void displayBuffer(HalDisplay::RefreshMode refreshMode = HalDisplay::FAST_REFRESH) const;
|
||||||
// EXPERIMENTAL: Windowed update - display only a rectangular region
|
// EXPERIMENTAL: Windowed update - display only a rectangular region
|
||||||
void displayWindow(int x, int y, int width, int height) const;
|
void displayWindow(int x, int y, int width, int height) const;
|
||||||
void invertScreen() const;
|
void invertScreen() const;
|
||||||
@@ -94,10 +98,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 +120,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:
|
||||||
|
|||||||
@@ -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"; }
|
||||||
|
|
||||||
|
|||||||
@@ -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,10 @@ 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 +313,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 +352,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 +368,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 +376,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)));
|
|
||||||
if (inflator) {
|
|
||||||
tinfl_init(inflator);
|
tinfl_init(inflator);
|
||||||
inBytes = compressedSize;
|
inBytes = compressedSize;
|
||||||
outBytes = dzInfo.chunkLength;
|
outBytes = dzInfo.chunkLength;
|
||||||
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_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 +406,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 +414,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 +427,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 +468,8 @@ 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 +485,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 +497,23 @@ 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 +522,19 @@ 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 +587,9 @@ 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 +600,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 +674,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[] = {
|
||||||
{" ", " "}, {"<", "<"}, {">", ">"},
|
{" ", " "},
|
||||||
{"&", "&"}, {""", "\""}, {"'", "'"},
|
{"<", "<"},
|
||||||
|
{">", ">"},
|
||||||
|
{"&", "&"},
|
||||||
|
{""", "\""},
|
||||||
|
{"'", "'"},
|
||||||
{"—", "\xe2\x80\x94"}, // —
|
{"—", "\xe2\x80\x94"}, // —
|
||||||
{"–", "\xe2\x80\x93"}, // –
|
{"–", "\xe2\x80\x93"}, // –
|
||||||
{"…", "\xe2\x80\xa6"}, // …
|
{"…", "\xe2\x80\xa6"}, // …
|
||||||
@@ -688,8 +778,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++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -38,16 +38,22 @@ class StarDict {
|
|||||||
};
|
};
|
||||||
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);
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
53
lib/hal/HalDisplay.cpp
Normal file
53
lib/hal/HalDisplay.cpp
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
#include <HalDisplay.h>
|
||||||
|
#include <HalGPIO.h>
|
||||||
|
|
||||||
|
#define SD_SPI_MISO 7
|
||||||
|
|
||||||
|
HalDisplay::HalDisplay() : einkDisplay(EPD_SCLK, EPD_MOSI, EPD_CS, EPD_DC, EPD_RST, EPD_BUSY) {}
|
||||||
|
|
||||||
|
HalDisplay::~HalDisplay() {}
|
||||||
|
|
||||||
|
void HalDisplay::begin() { einkDisplay.begin(); }
|
||||||
|
|
||||||
|
void HalDisplay::clearScreen(uint8_t color) const { einkDisplay.clearScreen(color); }
|
||||||
|
|
||||||
|
void HalDisplay::drawImage(const uint8_t* imageData, uint16_t x, uint16_t y, uint16_t w, uint16_t h,
|
||||||
|
bool fromProgmem) const {
|
||||||
|
einkDisplay.drawImage(imageData, x, y, w, h, fromProgmem);
|
||||||
|
}
|
||||||
|
|
||||||
|
EInkDisplay::RefreshMode convertRefreshMode(HalDisplay::RefreshMode mode) {
|
||||||
|
switch (mode) {
|
||||||
|
case HalDisplay::FULL_REFRESH:
|
||||||
|
return EInkDisplay::FULL_REFRESH;
|
||||||
|
case HalDisplay::HALF_REFRESH:
|
||||||
|
return EInkDisplay::HALF_REFRESH;
|
||||||
|
case HalDisplay::FAST_REFRESH:
|
||||||
|
default:
|
||||||
|
return EInkDisplay::FAST_REFRESH;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void HalDisplay::displayBuffer(HalDisplay::RefreshMode mode, bool turnOffScreen) {
|
||||||
|
einkDisplay.displayBuffer(convertRefreshMode(mode), turnOffScreen);
|
||||||
|
}
|
||||||
|
|
||||||
|
void HalDisplay::refreshDisplay(HalDisplay::RefreshMode mode, bool turnOffScreen) {
|
||||||
|
einkDisplay.refreshDisplay(convertRefreshMode(mode), turnOffScreen);
|
||||||
|
}
|
||||||
|
|
||||||
|
void HalDisplay::deepSleep() { einkDisplay.deepSleep(); }
|
||||||
|
|
||||||
|
uint8_t* HalDisplay::getFrameBuffer() const { return einkDisplay.getFrameBuffer(); }
|
||||||
|
|
||||||
|
void HalDisplay::copyGrayscaleBuffers(const uint8_t* lsbBuffer, const uint8_t* msbBuffer) {
|
||||||
|
einkDisplay.copyGrayscaleBuffers(lsbBuffer, msbBuffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
void HalDisplay::copyGrayscaleLsbBuffers(const uint8_t* lsbBuffer) { einkDisplay.copyGrayscaleLsbBuffers(lsbBuffer); }
|
||||||
|
|
||||||
|
void HalDisplay::copyGrayscaleMsbBuffers(const uint8_t* msbBuffer) { einkDisplay.copyGrayscaleMsbBuffers(msbBuffer); }
|
||||||
|
|
||||||
|
void HalDisplay::cleanupGrayscaleBuffers(const uint8_t* bwBuffer) { einkDisplay.cleanupGrayscaleBuffers(bwBuffer); }
|
||||||
|
|
||||||
|
void HalDisplay::displayGrayBuffer(bool turnOffScreen) { einkDisplay.displayGrayBuffer(turnOffScreen); }
|
||||||
52
lib/hal/HalDisplay.h
Normal file
52
lib/hal/HalDisplay.h
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <Arduino.h>
|
||||||
|
#include <EInkDisplay.h>
|
||||||
|
|
||||||
|
class HalDisplay {
|
||||||
|
public:
|
||||||
|
// Constructor with pin configuration
|
||||||
|
HalDisplay();
|
||||||
|
|
||||||
|
// Destructor
|
||||||
|
~HalDisplay();
|
||||||
|
|
||||||
|
// Refresh modes
|
||||||
|
enum RefreshMode {
|
||||||
|
FULL_REFRESH, // Full refresh with complete waveform
|
||||||
|
HALF_REFRESH, // Half refresh (1720ms) - balanced quality and speed
|
||||||
|
FAST_REFRESH // Fast refresh using custom LUT
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize the display hardware and driver
|
||||||
|
void begin();
|
||||||
|
|
||||||
|
// Display dimensions
|
||||||
|
static constexpr uint16_t DISPLAY_WIDTH = EInkDisplay::DISPLAY_WIDTH;
|
||||||
|
static constexpr uint16_t DISPLAY_HEIGHT = EInkDisplay::DISPLAY_HEIGHT;
|
||||||
|
static constexpr uint16_t DISPLAY_WIDTH_BYTES = DISPLAY_WIDTH / 8;
|
||||||
|
static constexpr uint32_t BUFFER_SIZE = DISPLAY_WIDTH_BYTES * DISPLAY_HEIGHT;
|
||||||
|
|
||||||
|
// Frame buffer operations
|
||||||
|
void clearScreen(uint8_t color = 0xFF) const;
|
||||||
|
void drawImage(const uint8_t* imageData, uint16_t x, uint16_t y, uint16_t w, uint16_t h,
|
||||||
|
bool fromProgmem = false) const;
|
||||||
|
|
||||||
|
void displayBuffer(RefreshMode mode = RefreshMode::FAST_REFRESH, bool turnOffScreen = false);
|
||||||
|
void refreshDisplay(RefreshMode mode = RefreshMode::FAST_REFRESH, bool turnOffScreen = false);
|
||||||
|
|
||||||
|
// Power management
|
||||||
|
void deepSleep();
|
||||||
|
|
||||||
|
// Access to frame buffer
|
||||||
|
uint8_t* getFrameBuffer() const;
|
||||||
|
|
||||||
|
void copyGrayscaleBuffers(const uint8_t* lsbBuffer, const uint8_t* msbBuffer);
|
||||||
|
void copyGrayscaleLsbBuffers(const uint8_t* lsbBuffer);
|
||||||
|
void copyGrayscaleMsbBuffers(const uint8_t* msbBuffer);
|
||||||
|
void cleanupGrayscaleBuffers(const uint8_t* bwBuffer);
|
||||||
|
|
||||||
|
void displayGrayBuffer(bool turnOffScreen = false);
|
||||||
|
|
||||||
|
private:
|
||||||
|
EInkDisplay einkDisplay;
|
||||||
|
};
|
||||||
55
lib/hal/HalGPIO.cpp
Normal file
55
lib/hal/HalGPIO.cpp
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
#include <HalGPIO.h>
|
||||||
|
#include <SPI.h>
|
||||||
|
#include <esp_sleep.h>
|
||||||
|
|
||||||
|
void HalGPIO::begin() {
|
||||||
|
inputMgr.begin();
|
||||||
|
SPI.begin(EPD_SCLK, SPI_MISO, EPD_MOSI, EPD_CS);
|
||||||
|
pinMode(BAT_GPIO0, INPUT);
|
||||||
|
pinMode(UART0_RXD, INPUT);
|
||||||
|
}
|
||||||
|
|
||||||
|
void HalGPIO::update() { inputMgr.update(); }
|
||||||
|
|
||||||
|
bool HalGPIO::isPressed(uint8_t buttonIndex) const { return inputMgr.isPressed(buttonIndex); }
|
||||||
|
|
||||||
|
bool HalGPIO::wasPressed(uint8_t buttonIndex) const { return inputMgr.wasPressed(buttonIndex); }
|
||||||
|
|
||||||
|
bool HalGPIO::wasAnyPressed() const { return inputMgr.wasAnyPressed(); }
|
||||||
|
|
||||||
|
bool HalGPIO::wasReleased(uint8_t buttonIndex) const { return inputMgr.wasReleased(buttonIndex); }
|
||||||
|
|
||||||
|
bool HalGPIO::wasAnyReleased() const { return inputMgr.wasAnyReleased(); }
|
||||||
|
|
||||||
|
unsigned long HalGPIO::getHeldTime() const { return inputMgr.getHeldTime(); }
|
||||||
|
|
||||||
|
void HalGPIO::startDeepSleep() {
|
||||||
|
esp_deep_sleep_enable_gpio_wakeup(1ULL << InputManager::POWER_BUTTON_PIN, ESP_GPIO_WAKEUP_GPIO_LOW);
|
||||||
|
// Ensure that the power button has been released to avoid immediately turning back on if you're holding it
|
||||||
|
while (inputMgr.isPressed(BTN_POWER)) {
|
||||||
|
delay(50);
|
||||||
|
inputMgr.update();
|
||||||
|
}
|
||||||
|
// Enter Deep Sleep
|
||||||
|
esp_deep_sleep_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
int HalGPIO::getBatteryPercentage() const {
|
||||||
|
static const BatteryMonitor battery = BatteryMonitor(BAT_GPIO0);
|
||||||
|
return battery.readPercentage();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool HalGPIO::isUsbConnected() const {
|
||||||
|
// U0RXD/GPIO20 reads HIGH when USB is connected
|
||||||
|
return digitalRead(UART0_RXD) == HIGH;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool HalGPIO::isWakeupByPowerButton() const {
|
||||||
|
const auto wakeupCause = esp_sleep_get_wakeup_cause();
|
||||||
|
const auto resetReason = esp_reset_reason();
|
||||||
|
if (isUsbConnected()) {
|
||||||
|
return wakeupCause == ESP_SLEEP_WAKEUP_GPIO;
|
||||||
|
} else {
|
||||||
|
return (wakeupCause == ESP_SLEEP_WAKEUP_UNDEFINED) && (resetReason == ESP_RST_POWERON);
|
||||||
|
}
|
||||||
|
}
|
||||||
61
lib/hal/HalGPIO.h
Normal file
61
lib/hal/HalGPIO.h
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
#include <BatteryMonitor.h>
|
||||||
|
#include <InputManager.h>
|
||||||
|
|
||||||
|
// Display SPI pins (custom pins for XteinkX4, not hardware SPI defaults)
|
||||||
|
#define EPD_SCLK 8 // SPI Clock
|
||||||
|
#define EPD_MOSI 10 // SPI MOSI (Master Out Slave In)
|
||||||
|
#define EPD_CS 21 // Chip Select
|
||||||
|
#define EPD_DC 4 // Data/Command
|
||||||
|
#define EPD_RST 5 // Reset
|
||||||
|
#define EPD_BUSY 6 // Busy
|
||||||
|
|
||||||
|
#define SPI_MISO 7 // SPI MISO, shared between SD card and display (Master In Slave Out)
|
||||||
|
|
||||||
|
#define BAT_GPIO0 0 // Battery voltage
|
||||||
|
|
||||||
|
#define UART0_RXD 20 // Used for USB connection detection
|
||||||
|
|
||||||
|
class HalGPIO {
|
||||||
|
#if CROSSPOINT_EMULATED == 0
|
||||||
|
InputManager inputMgr;
|
||||||
|
#endif
|
||||||
|
|
||||||
|
public:
|
||||||
|
HalGPIO() = default;
|
||||||
|
|
||||||
|
// Start button GPIO and setup SPI for screen and SD card
|
||||||
|
void begin();
|
||||||
|
|
||||||
|
// Button input methods
|
||||||
|
void update();
|
||||||
|
bool isPressed(uint8_t buttonIndex) const;
|
||||||
|
bool wasPressed(uint8_t buttonIndex) const;
|
||||||
|
bool wasAnyPressed() const;
|
||||||
|
bool wasReleased(uint8_t buttonIndex) const;
|
||||||
|
bool wasAnyReleased() const;
|
||||||
|
unsigned long getHeldTime() const;
|
||||||
|
|
||||||
|
// Setup wake up GPIO and enter deep sleep
|
||||||
|
void startDeepSleep();
|
||||||
|
|
||||||
|
// Get battery percentage (range 0-100)
|
||||||
|
int getBatteryPercentage() const;
|
||||||
|
|
||||||
|
// Check if USB is connected
|
||||||
|
bool isUsbConnected() const;
|
||||||
|
|
||||||
|
// Check if wakeup was caused by power button press
|
||||||
|
bool isWakeupByPowerButton() const;
|
||||||
|
|
||||||
|
// Button indices
|
||||||
|
static constexpr uint8_t BTN_BACK = 0;
|
||||||
|
static constexpr uint8_t BTN_CONFIRM = 1;
|
||||||
|
static constexpr uint8_t BTN_LEFT = 2;
|
||||||
|
static constexpr uint8_t BTN_RIGHT = 3;
|
||||||
|
static constexpr uint8_t BTN_UP = 4;
|
||||||
|
static constexpr uint8_t BTN_DOWN = 5;
|
||||||
|
static constexpr uint8_t BTN_POWER = 6;
|
||||||
|
};
|
||||||
Submodule open-x4-sdk updated: bd4e670750...be6ba1b62b
@@ -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.5
|
||||||
|
|
||||||
[base]
|
[base]
|
||||||
platform = espressif32 @ 6.12.0
|
platform = espressif32 @ 6.12.0
|
||||||
|
|||||||
@@ -5,7 +5,11 @@ This allows the firmware to display "Flashing firmware..." on the e-ink display
|
|||||||
before the actual flash begins. The e-ink retains this message throughout the
|
before the actual flash begins. The e-ink retains this message throughout the
|
||||||
flash process since it doesn't require power to maintain the display.
|
flash process since it doesn't require power to maintain the display.
|
||||||
|
|
||||||
Protocol: Sends "FLASH:version\n" where version is read from platformio.ini
|
Protocol (Plan A - Simple timing):
|
||||||
|
1. Host opens serial port and sends "FLASH:version"
|
||||||
|
2. Host keeps port open briefly for device to receive and process
|
||||||
|
3. Device displays flash screen when it receives the command
|
||||||
|
4. Host proceeds with flash
|
||||||
"""
|
"""
|
||||||
|
|
||||||
Import("env")
|
Import("env")
|
||||||
@@ -15,7 +19,7 @@ from version_utils import get_version
|
|||||||
|
|
||||||
|
|
||||||
def before_upload(source, target, env):
|
def before_upload(source, target, env):
|
||||||
"""Send FLASH command with version to device before upload begins."""
|
"""Send FLASH command to device before uploading firmware."""
|
||||||
port = env.GetProjectOption("upload_port", None)
|
port = env.GetProjectOption("upload_port", None)
|
||||||
|
|
||||||
if not port:
|
if not port:
|
||||||
@@ -29,19 +33,20 @@ def before_upload(source, target, env):
|
|||||||
]
|
]
|
||||||
port = ports[0] if ports else None
|
port = ports[0] if ports else None
|
||||||
|
|
||||||
if port:
|
if not port:
|
||||||
|
print("[pre_flash] No serial port found, skipping notification")
|
||||||
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
version = get_version(env)
|
version = get_version(env)
|
||||||
ser = serial.Serial(port, 115200, timeout=1)
|
ser = serial.Serial(port, 115200, timeout=1)
|
||||||
ser.write(f"FLASH:{version}\n".encode())
|
ser.write(f"FLASH:{version}\n".encode())
|
||||||
ser.flush()
|
ser.flush()
|
||||||
|
time.sleep(4.0) # Keep port open for device to receive and complete full refresh (~2-3s)
|
||||||
ser.close()
|
ser.close()
|
||||||
time.sleep(0.8) # Wait for e-ink fast refresh (~500ms) plus margin
|
|
||||||
print(f"[pre_flash] Flash notification sent to {port} (version {version})")
|
print(f"[pre_flash] Flash notification sent to {port} (version {version})")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[pre_flash] Notification skipped: {e}")
|
print(f"[pre_flash] Notification skipped: {e}")
|
||||||
else:
|
|
||||||
print("[pre_flash] No serial port found, skipping notification")
|
|
||||||
|
|
||||||
|
|
||||||
env.AddPreAction("upload", before_upload)
|
env.AddPreAction("upload", before_upload)
|
||||||
|
|||||||
335
scripts/recompress_dictzip.py
Normal file
335
scripts/recompress_dictzip.py
Normal 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()
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -305,6 +303,86 @@ bool BookManager::deleteBook(const std::string& bookPath, bool isArchived) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool BookManager::clearBookCache(const std::string& bookPath, bool preserveProgress) {
|
||||||
|
Serial.printf("[%lu] [%s] Clearing cache for: %s (preserveProgress=%d)\n", millis(), LOG_TAG, bookPath.c_str(),
|
||||||
|
preserveProgress);
|
||||||
|
|
||||||
|
const std::string cacheDir = getCacheDir(bookPath);
|
||||||
|
if (cacheDir.empty()) {
|
||||||
|
Serial.printf("[%lu] [%s] No cache directory for unsupported format\n", millis(), LOG_TAG);
|
||||||
|
return true; // Nothing to clear, not an error
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!SdMan.exists(cacheDir.c_str())) {
|
||||||
|
Serial.printf("[%lu] [%s] Cache directory doesn't exist: %s\n", millis(), LOG_TAG, cacheDir.c_str());
|
||||||
|
return true; // Nothing to clear, not an error
|
||||||
|
}
|
||||||
|
|
||||||
|
FsFile dir = SdMan.open(cacheDir.c_str());
|
||||||
|
if (!dir || !dir.isDirectory()) {
|
||||||
|
Serial.printf("[%lu] [%s] Failed to open cache directory\n", millis(), LOG_TAG);
|
||||||
|
if (dir) dir.close();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Files to preserve (always keep bookmarks, optionally keep progress)
|
||||||
|
const auto shouldPreserve = [preserveProgress](const char* name) {
|
||||||
|
// Always preserve bookmarks
|
||||||
|
if (strcmp(name, "bookmarks.bin") == 0) return true;
|
||||||
|
// Optionally preserve progress
|
||||||
|
if (preserveProgress && strcmp(name, "progress.bin") == 0) return true;
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
int deletedCount = 0;
|
||||||
|
int failedCount = 0;
|
||||||
|
char name[128];
|
||||||
|
|
||||||
|
// First pass: delete files (not directories)
|
||||||
|
for (FsFile entry = dir.openNextFile(); entry; entry = dir.openNextFile()) {
|
||||||
|
entry.getName(name, sizeof(name));
|
||||||
|
const bool isDir = entry.isDirectory();
|
||||||
|
entry.close();
|
||||||
|
|
||||||
|
if (!isDir && !shouldPreserve(name)) {
|
||||||
|
std::string fullPath = cacheDir + "/" + name;
|
||||||
|
if (SdMan.remove(fullPath.c_str())) {
|
||||||
|
deletedCount++;
|
||||||
|
} else {
|
||||||
|
Serial.printf("[%lu] [%s] Failed to delete: %s\n", millis(), LOG_TAG, fullPath.c_str());
|
||||||
|
failedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dir.close();
|
||||||
|
|
||||||
|
// Second pass: delete subdirectories (like "sections/")
|
||||||
|
dir = SdMan.open(cacheDir.c_str());
|
||||||
|
if (dir && dir.isDirectory()) {
|
||||||
|
for (FsFile entry = dir.openNextFile(); entry; entry = dir.openNextFile()) {
|
||||||
|
entry.getName(name, sizeof(name));
|
||||||
|
const bool isDir = entry.isDirectory();
|
||||||
|
entry.close();
|
||||||
|
|
||||||
|
if (isDir) {
|
||||||
|
std::string fullPath = cacheDir + "/" + name;
|
||||||
|
if (SdMan.removeDir(fullPath.c_str())) {
|
||||||
|
deletedCount++;
|
||||||
|
Serial.printf("[%lu] [%s] Deleted subdirectory: %s\n", millis(), LOG_TAG, fullPath.c_str());
|
||||||
|
} else {
|
||||||
|
Serial.printf("[%lu] [%s] Failed to delete subdirectory: %s\n", millis(), LOG_TAG, fullPath.c_str());
|
||||||
|
failedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dir.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
Serial.printf("[%lu] [%s] Cache cleared: %d items deleted, %d failed\n", millis(), LOG_TAG, deletedCount,
|
||||||
|
failedCount);
|
||||||
|
return failedCount == 0;
|
||||||
|
}
|
||||||
|
|
||||||
std::vector<std::string> BookManager::listArchivedBooks() {
|
std::vector<std::string> BookManager::listArchivedBooks() {
|
||||||
std::vector<std::string> archivedBooks;
|
std::vector<std::string> archivedBooks;
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,14 @@ class BookManager {
|
|||||||
*/
|
*/
|
||||||
static std::string getCacheDir(const std::string& bookPath);
|
static std::string getCacheDir(const std::string& bookPath);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear cache for a single book, optionally preserving reading progress
|
||||||
|
* @param bookPath Full path to the book file
|
||||||
|
* @param preserveProgress If true, keeps progress.bin and bookmarks.bin
|
||||||
|
* @return true if successful (or if no cache exists)
|
||||||
|
*/
|
||||||
|
static bool clearBookCache(const std::string& bookPath, bool preserveProgress);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
// Extract filename from a full path
|
// Extract filename from a full path
|
||||||
static std::string getFilename(const std::string& path);
|
static std::string getFilename(const std::string& path);
|
||||||
|
|||||||
@@ -23,8 +23,7 @@ std::string getCacheDir(const 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);
|
||||||
}
|
}
|
||||||
@@ -54,8 +53,8 @@ bool BookmarkStore::addBookmark(const std::string& bookPath, const Bookmark& boo
|
|||||||
});
|
});
|
||||||
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,9 +95,8 @@ bool BookmarkStore::isPageBookmarked(const std::string& bookPath, uint16_t spine
|
|||||||
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) {
|
||||||
@@ -199,9 +197,8 @@ std::vector<BookmarkedBook> BookmarkStore::getBooksWithBookmarks() {
|
|||||||
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ void readAndValidate(FsFile& file, uint8_t& member, const uint8_t maxValue) {
|
|||||||
namespace {
|
namespace {
|
||||||
constexpr uint8_t SETTINGS_FILE_VERSION = 1;
|
constexpr uint8_t SETTINGS_FILE_VERSION = 1;
|
||||||
// Increment this when adding new persisted settings fields
|
// Increment this when adding new persisted settings fields
|
||||||
constexpr uint8_t SETTINGS_COUNT = 29; // 28 + bezelCompensationEdge
|
constexpr uint8_t SETTINGS_COUNT = 30; // 29 + fadingFix
|
||||||
constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin";
|
constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin";
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
@@ -72,6 +72,8 @@ bool CrossPointSettings::saveToFile() const {
|
|||||||
serialization::writePod(outputFile, bezelCompensation);
|
serialization::writePod(outputFile, bezelCompensation);
|
||||||
// Which physical edge needs bezel compensation
|
// Which physical edge needs bezel compensation
|
||||||
serialization::writePod(outputFile, bezelCompensationEdge);
|
serialization::writePod(outputFile, bezelCompensationEdge);
|
||||||
|
// Sunlight fading fix
|
||||||
|
serialization::writePod(outputFile, fadingFix);
|
||||||
// New fields added at end for backward compatibility
|
// New fields added at end for backward compatibility
|
||||||
outputFile.close();
|
outputFile.close();
|
||||||
|
|
||||||
@@ -182,6 +184,9 @@ bool CrossPointSettings::loadFromFile() {
|
|||||||
// Which physical edge needs bezel compensation
|
// Which physical edge needs bezel compensation
|
||||||
readAndValidate(inputFile, bezelCompensationEdge, BEZEL_EDGE_COUNT);
|
readAndValidate(inputFile, bezelCompensationEdge, BEZEL_EDGE_COUNT);
|
||||||
if (++settingsRead >= fileSettingsCount) break;
|
if (++settingsRead >= fileSettingsCount) break;
|
||||||
|
// Sunlight fading fix
|
||||||
|
serialization::readPod(inputFile, fadingFix);
|
||||||
|
if (++settingsRead >= fileSettingsCount) break;
|
||||||
// New fields added at end for backward compatibility
|
// New fields added at end for backward compatibility
|
||||||
} while (false);
|
} while (false);
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -137,6 +144,8 @@ class CrossPointSettings {
|
|||||||
uint8_t hideBatteryPercentage = HIDE_NEVER;
|
uint8_t hideBatteryPercentage = HIDE_NEVER;
|
||||||
// Long-press chapter skip on side buttons
|
// Long-press chapter skip on side buttons
|
||||||
uint8_t longPressChapterSkip = 1;
|
uint8_t longPressChapterSkip = 1;
|
||||||
|
// Sunlight fading compensation (0 = off, 1 = on)
|
||||||
|
uint8_t fadingFix = 0;
|
||||||
// System-wide display contrast (0 = normal, 1 = high)
|
// System-wide display contrast (0 = normal, 1 = high)
|
||||||
uint8_t displayContrast = 0;
|
uint8_t displayContrast = 0;
|
||||||
// Bezel compensation - extra margin for physical screen edge defects (0-10px)
|
// Bezel compensation - extra margin for physical screen edge defects (0-10px)
|
||||||
@@ -148,6 +157,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
|
||||||
|
|||||||
@@ -2,103 +2,77 @@
|
|||||||
|
|
||||||
#include "CrossPointSettings.h"
|
#include "CrossPointSettings.h"
|
||||||
|
|
||||||
decltype(InputManager::BTN_BACK) MappedInputManager::mapButton(const Button button) const {
|
namespace {
|
||||||
|
using ButtonIndex = uint8_t;
|
||||||
|
|
||||||
|
struct FrontLayoutMap {
|
||||||
|
ButtonIndex back;
|
||||||
|
ButtonIndex confirm;
|
||||||
|
ButtonIndex left;
|
||||||
|
ButtonIndex right;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct SideLayoutMap {
|
||||||
|
ButtonIndex pageBack;
|
||||||
|
ButtonIndex pageForward;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Order matches CrossPointSettings::FRONT_BUTTON_LAYOUT.
|
||||||
|
constexpr FrontLayoutMap kFrontLayouts[] = {
|
||||||
|
{HalGPIO::BTN_BACK, HalGPIO::BTN_CONFIRM, HalGPIO::BTN_LEFT, HalGPIO::BTN_RIGHT},
|
||||||
|
{HalGPIO::BTN_LEFT, HalGPIO::BTN_RIGHT, HalGPIO::BTN_BACK, HalGPIO::BTN_CONFIRM},
|
||||||
|
{HalGPIO::BTN_CONFIRM, HalGPIO::BTN_LEFT, HalGPIO::BTN_BACK, HalGPIO::BTN_RIGHT},
|
||||||
|
{HalGPIO::BTN_BACK, HalGPIO::BTN_CONFIRM, HalGPIO::BTN_RIGHT, HalGPIO::BTN_LEFT},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Order matches CrossPointSettings::SIDE_BUTTON_LAYOUT.
|
||||||
|
constexpr SideLayoutMap kSideLayouts[] = {
|
||||||
|
{HalGPIO::BTN_UP, HalGPIO::BTN_DOWN},
|
||||||
|
{HalGPIO::BTN_DOWN, HalGPIO::BTN_UP},
|
||||||
|
};
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
bool MappedInputManager::mapButton(const Button button, bool (HalGPIO::*fn)(uint8_t) const) const {
|
||||||
const auto frontLayout = static_cast<CrossPointSettings::FRONT_BUTTON_LAYOUT>(SETTINGS.frontButtonLayout);
|
const auto frontLayout = static_cast<CrossPointSettings::FRONT_BUTTON_LAYOUT>(SETTINGS.frontButtonLayout);
|
||||||
const auto sideLayout = static_cast<CrossPointSettings::SIDE_BUTTON_LAYOUT>(SETTINGS.sideButtonLayout);
|
const auto sideLayout = static_cast<CrossPointSettings::SIDE_BUTTON_LAYOUT>(SETTINGS.sideButtonLayout);
|
||||||
|
const auto& front = kFrontLayouts[frontLayout];
|
||||||
|
const auto& side = kSideLayouts[sideLayout];
|
||||||
|
|
||||||
switch (button) {
|
switch (button) {
|
||||||
case Button::Back:
|
case Button::Back:
|
||||||
switch (frontLayout) {
|
return (gpio.*fn)(front.back);
|
||||||
case CrossPointSettings::LEFT_RIGHT_BACK_CONFIRM:
|
|
||||||
return InputManager::BTN_LEFT;
|
|
||||||
case CrossPointSettings::LEFT_BACK_CONFIRM_RIGHT:
|
|
||||||
return InputManager::BTN_CONFIRM;
|
|
||||||
case CrossPointSettings::BACK_CONFIRM_LEFT_RIGHT:
|
|
||||||
/* fall through */
|
|
||||||
case CrossPointSettings::BACK_CONFIRM_RIGHT_LEFT:
|
|
||||||
/* fall through */
|
|
||||||
default:
|
|
||||||
return InputManager::BTN_BACK;
|
|
||||||
}
|
|
||||||
case Button::Confirm:
|
case Button::Confirm:
|
||||||
switch (frontLayout) {
|
return (gpio.*fn)(front.confirm);
|
||||||
case CrossPointSettings::LEFT_RIGHT_BACK_CONFIRM:
|
|
||||||
return InputManager::BTN_RIGHT;
|
|
||||||
case CrossPointSettings::LEFT_BACK_CONFIRM_RIGHT:
|
|
||||||
return InputManager::BTN_LEFT;
|
|
||||||
case CrossPointSettings::BACK_CONFIRM_LEFT_RIGHT:
|
|
||||||
/* fall through */
|
|
||||||
case CrossPointSettings::BACK_CONFIRM_RIGHT_LEFT:
|
|
||||||
/* fall through */
|
|
||||||
default:
|
|
||||||
return InputManager::BTN_CONFIRM;
|
|
||||||
}
|
|
||||||
case Button::Left:
|
case Button::Left:
|
||||||
switch (frontLayout) {
|
return (gpio.*fn)(front.left);
|
||||||
case CrossPointSettings::LEFT_RIGHT_BACK_CONFIRM:
|
|
||||||
/* fall through */
|
|
||||||
case CrossPointSettings::LEFT_BACK_CONFIRM_RIGHT:
|
|
||||||
return InputManager::BTN_BACK;
|
|
||||||
case CrossPointSettings::BACK_CONFIRM_RIGHT_LEFT:
|
|
||||||
return InputManager::BTN_RIGHT;
|
|
||||||
case CrossPointSettings::BACK_CONFIRM_LEFT_RIGHT:
|
|
||||||
/* fall through */
|
|
||||||
default:
|
|
||||||
return InputManager::BTN_LEFT;
|
|
||||||
}
|
|
||||||
case Button::Right:
|
case Button::Right:
|
||||||
switch (frontLayout) {
|
return (gpio.*fn)(front.right);
|
||||||
case CrossPointSettings::LEFT_RIGHT_BACK_CONFIRM:
|
|
||||||
return InputManager::BTN_CONFIRM;
|
|
||||||
case CrossPointSettings::BACK_CONFIRM_RIGHT_LEFT:
|
|
||||||
return InputManager::BTN_LEFT;
|
|
||||||
case CrossPointSettings::BACK_CONFIRM_LEFT_RIGHT:
|
|
||||||
/* fall through */
|
|
||||||
case CrossPointSettings::LEFT_BACK_CONFIRM_RIGHT:
|
|
||||||
/* fall through */
|
|
||||||
default:
|
|
||||||
return InputManager::BTN_RIGHT;
|
|
||||||
}
|
|
||||||
case Button::Up:
|
case Button::Up:
|
||||||
return InputManager::BTN_UP;
|
return (gpio.*fn)(HalGPIO::BTN_UP);
|
||||||
case Button::Down:
|
case Button::Down:
|
||||||
return InputManager::BTN_DOWN;
|
return (gpio.*fn)(HalGPIO::BTN_DOWN);
|
||||||
case Button::Power:
|
case Button::Power:
|
||||||
return InputManager::BTN_POWER;
|
return (gpio.*fn)(HalGPIO::BTN_POWER);
|
||||||
case Button::PageBack:
|
case Button::PageBack:
|
||||||
switch (sideLayout) {
|
return (gpio.*fn)(side.pageBack);
|
||||||
case CrossPointSettings::NEXT_PREV:
|
|
||||||
return InputManager::BTN_DOWN;
|
|
||||||
case CrossPointSettings::PREV_NEXT:
|
|
||||||
/* fall through */
|
|
||||||
default:
|
|
||||||
return InputManager::BTN_UP;
|
|
||||||
}
|
|
||||||
case Button::PageForward:
|
case Button::PageForward:
|
||||||
switch (sideLayout) {
|
return (gpio.*fn)(side.pageForward);
|
||||||
case CrossPointSettings::NEXT_PREV:
|
|
||||||
return InputManager::BTN_UP;
|
|
||||||
case CrossPointSettings::PREV_NEXT:
|
|
||||||
/* fall through */
|
|
||||||
default:
|
|
||||||
return InputManager::BTN_DOWN;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return InputManager::BTN_BACK;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool MappedInputManager::wasPressed(const Button button) const { return inputManager.wasPressed(mapButton(button)); }
|
bool MappedInputManager::wasPressed(const Button button) const { return mapButton(button, &HalGPIO::wasPressed); }
|
||||||
|
|
||||||
bool MappedInputManager::wasReleased(const Button button) const { return inputManager.wasReleased(mapButton(button)); }
|
bool MappedInputManager::wasReleased(const Button button) const { return mapButton(button, &HalGPIO::wasReleased); }
|
||||||
|
|
||||||
bool MappedInputManager::isPressed(const Button button) const { return inputManager.isPressed(mapButton(button)); }
|
bool MappedInputManager::isPressed(const Button button) const { return mapButton(button, &HalGPIO::isPressed); }
|
||||||
|
|
||||||
bool MappedInputManager::wasAnyPressed() const { return inputManager.wasAnyPressed(); }
|
bool MappedInputManager::wasAnyPressed() const { return gpio.wasAnyPressed(); }
|
||||||
|
|
||||||
bool MappedInputManager::wasAnyReleased() const { return inputManager.wasAnyReleased(); }
|
bool MappedInputManager::wasAnyReleased() const { return gpio.wasAnyReleased(); }
|
||||||
|
|
||||||
unsigned long MappedInputManager::getHeldTime() const { return inputManager.getHeldTime(); }
|
unsigned long MappedInputManager::getHeldTime() const { return gpio.getHeldTime(); }
|
||||||
|
|
||||||
MappedInputManager::Labels MappedInputManager::mapLabels(const char* back, const char* confirm, const char* previous,
|
MappedInputManager::Labels MappedInputManager::mapLabels(const char* back, const char* confirm, const char* previous,
|
||||||
const char* next) const {
|
const char* next) const {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include <InputManager.h>
|
#include <HalGPIO.h>
|
||||||
|
|
||||||
class MappedInputManager {
|
class MappedInputManager {
|
||||||
public:
|
public:
|
||||||
@@ -13,7 +13,7 @@ class MappedInputManager {
|
|||||||
const char* btn4;
|
const char* btn4;
|
||||||
};
|
};
|
||||||
|
|
||||||
explicit MappedInputManager(InputManager& inputManager) : inputManager(inputManager) {}
|
explicit MappedInputManager(HalGPIO& gpio) : gpio(gpio) {}
|
||||||
|
|
||||||
bool wasPressed(Button button) const;
|
bool wasPressed(Button button) const;
|
||||||
bool wasReleased(Button button) const;
|
bool wasReleased(Button button) const;
|
||||||
@@ -24,6 +24,7 @@ class MappedInputManager {
|
|||||||
Labels mapLabels(const char* back, const char* confirm, const char* previous, const char* next) const;
|
Labels mapLabels(const char* back, const char* confirm, const char* previous, const char* next) const;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
InputManager& inputManager;
|
HalGPIO& gpio;
|
||||||
decltype(InputManager::BTN_BACK) mapButton(Button button) const;
|
|
||||||
|
bool mapButton(Button button, bool (HalGPIO::*fn)(uint8_t) const) const;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|
||||||
|
|||||||
@@ -90,7 +90,8 @@ 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,
|
||||||
|
int selectedIndex, bool showCursor) {
|
||||||
constexpr int tabPadding = 20; // Horizontal padding between tabs
|
constexpr int tabPadding = 20; // Horizontal padding between tabs
|
||||||
constexpr int leftMargin = 20; // Left margin for first tab
|
constexpr int leftMargin = 20; // Left margin for first tab
|
||||||
constexpr int rightMargin = 20; // Right margin
|
constexpr int rightMargin = 20; // Right margin
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ class GfxRenderer;
|
|||||||
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:
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ void SleepActivity::renderDefaultSleepScreen() const {
|
|||||||
renderer.invertScreen();
|
renderer.invertScreen();
|
||||||
}
|
}
|
||||||
|
|
||||||
renderer.displayBuffer(EInkDisplay::HALF_REFRESH);
|
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
||||||
}
|
}
|
||||||
|
|
||||||
void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap, const std::string& bmpPath) const {
|
void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap, const std::string& bmpPath) const {
|
||||||
@@ -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() &&
|
||||||
@@ -270,7 +269,7 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap, const std::str
|
|||||||
renderer.invertScreen();
|
renderer.invertScreen();
|
||||||
}
|
}
|
||||||
|
|
||||||
renderer.displayBuffer(EInkDisplay::HALF_REFRESH);
|
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
||||||
|
|
||||||
if (hasGreyscale) {
|
if (hasGreyscale) {
|
||||||
// Grayscale LSB pass
|
// Grayscale LSB pass
|
||||||
@@ -401,7 +400,7 @@ void SleepActivity::renderCoverSleepScreen() const {
|
|||||||
|
|
||||||
void SleepActivity::renderBlankSleepScreen() const {
|
void SleepActivity::renderBlankSleepScreen() const {
|
||||||
renderer.clearScreen();
|
renderer.clearScreen();
|
||||||
renderer.displayBuffer(EInkDisplay::HALF_REFRESH);
|
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string SleepActivity::getEdgeCachePath(const std::string& bmpPath) {
|
std::string SleepActivity::getEdgeCachePath(const std::string& bmpPath) {
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
if (currentPage > 0) {
|
||||||
|
// Navigate within cached pages
|
||||||
currentPage--;
|
currentPage--;
|
||||||
updateRequired = true;
|
updateRequired = true;
|
||||||
} else if (nextPressed && currentPage < static_cast<int>(pages.size()) - 1) {
|
} 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++;
|
currentPage++;
|
||||||
updateRequired = true;
|
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,59 @@ 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()) {
|
||||||
|
// 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;
|
notFound = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
notFound = true;
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,6 +207,185 @@ 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 +403,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 +419,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 +436,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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
#include "EpubWordSelectionActivity.h"
|
#include "EpubWordSelectionActivity.h"
|
||||||
|
|
||||||
#include <EInkDisplay.h>
|
|
||||||
#include <GfxRenderer.h>
|
#include <GfxRenderer.h>
|
||||||
|
#include <HalDisplay.h>
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cctype>
|
#include <cctype>
|
||||||
@@ -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);
|
||||||
|
|
||||||
renderer.displayBuffer(EInkDisplay::FAST_REFRESH);
|
// Draw side button hints for up/down line navigation (no border, small font)
|
||||||
|
// Top physical button = Up (prev line), Bottom physical button = Down (next line)
|
||||||
|
const int lastLine = findLineForWordIndex(static_cast<int>(allWords.size()) - 1);
|
||||||
|
const char* sideTopHint = (currentLineIndex > 0) ? "UP" : "";
|
||||||
|
const char* sideBottomHint = (currentLineIndex < lastLine) ? "DOWN" : "";
|
||||||
|
renderer.drawSideButtonHints(SMALL_FONT_ID, sideTopHint, sideBottomHint, false); // No border
|
||||||
|
|
||||||
|
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
@@ -191,8 +187,10 @@ 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");
|
||||||
|
|||||||
@@ -50,8 +50,8 @@ 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),
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -50,8 +50,7 @@ class HomeActivity final : public Activity {
|
|||||||
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),
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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()) {
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -90,7 +91,8 @@ int MyLibraryActivity::getPageItems() const {
|
|||||||
// 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;
|
||||||
@@ -98,8 +100,8 @@ int MyLibraryActivity::getPageItems() const {
|
|||||||
|
|
||||||
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;
|
||||||
@@ -154,12 +156,11 @@ void MyLibraryActivity::loadBookmarkedBooks() {
|
|||||||
|
|
||||||
// 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;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -193,8 +194,7 @@ void MyLibraryActivity::loadAllBooks() {
|
|||||||
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;
|
||||||
@@ -207,12 +207,11 @@ void MyLibraryActivity::loadAllBooks() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 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);
|
||||||
@@ -227,8 +226,7 @@ void MyLibraryActivity::loadAllBooks() {
|
|||||||
|
|
||||||
// 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); });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -282,11 +280,9 @@ void MyLibraryActivity::buildSearchCharacters() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
||||||
@@ -304,16 +300,20 @@ void MyLibraryActivity::updateSearchResults() {
|
|||||||
|
|
||||||
// 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;
|
||||||
|
|
||||||
@@ -340,9 +340,7 @@ void MyLibraryActivity::updateSearchResults() {
|
|||||||
|
|
||||||
// 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() {
|
||||||
@@ -505,17 +503,23 @@ void MyLibraryActivity::executeAction() {
|
|||||||
} else if (selectedAction == ActionType::RemoveFromRecents) {
|
} else if (selectedAction == ActionType::RemoveFromRecents) {
|
||||||
// Just remove from recents list, don't touch the file
|
// Just remove from recents list, don't touch the file
|
||||||
success = RECENT_BOOKS.removeBook(actionTargetPath);
|
success = RECENT_BOOKS.removeBook(actionTargetPath);
|
||||||
|
} else if (selectedAction == ActionType::ClearCache) {
|
||||||
|
// Clear cache for this book, optionally preserving progress
|
||||||
|
success = BookManager::clearBookCache(actionTargetPath, clearCachePreserveProgress);
|
||||||
|
// Also clear thumbnail existence cache since thumbnails may have been deleted
|
||||||
|
clearThumbExistsCache();
|
||||||
}
|
}
|
||||||
// Note: ClearAllRecents is handled directly in loop() via ClearAllRecentsConfirming state
|
// Note: ClearAllRecents is handled directly in loop() via ClearAllRecentsConfirming state
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
// Reload data
|
// Reload data
|
||||||
loadRecentBooks();
|
loadRecentBooks();
|
||||||
if (selectedAction != ActionType::RemoveFromRecents) {
|
if (selectedAction != ActionType::RemoveFromRecents && selectedAction != ActionType::ClearCache) {
|
||||||
loadFiles(); // Only reload files for Archive/Delete
|
loadFiles(); // Only reload files for Archive/Delete (not needed for cache clear)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Adjust selector if needed
|
// Adjust selector if needed (not needed for ClearCache since item count doesn't change)
|
||||||
|
if (selectedAction != ActionType::ClearCache) {
|
||||||
const int itemCount = getCurrentItemCount();
|
const int itemCount = getCurrentItemCount();
|
||||||
if (selectorIndex >= itemCount && itemCount > 0) {
|
if (selectorIndex >= itemCount && itemCount > 0) {
|
||||||
selectorIndex = itemCount - 1;
|
selectorIndex = itemCount - 1;
|
||||||
@@ -523,6 +527,7 @@ void MyLibraryActivity::executeAction() {
|
|||||||
selectorIndex = 0;
|
selectorIndex = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
uiState = UIState::Normal;
|
uiState = UIState::Normal;
|
||||||
updateRequired = true;
|
updateRequired = true;
|
||||||
@@ -575,11 +580,12 @@ 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) {
|
||||||
// Menu has 4 options in Recent tab, 2 options in Files tab
|
// Menu has 5 options in Recent tab, 3 options in Files tab
|
||||||
const int maxMenuSelection = (currentTab == Tab::Recent) ? 3 : 1;
|
const int maxMenuSelection = (currentTab == Tab::Recent) ? 4 : 2;
|
||||||
|
|
||||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||||
uiState = UIState::Normal;
|
uiState = UIState::Normal;
|
||||||
@@ -609,7 +615,7 @@ void MyLibraryActivity::loop() {
|
|||||||
|
|
||||||
// Map menu selection to action type
|
// Map menu selection to action type
|
||||||
if (currentTab == Tab::Recent) {
|
if (currentTab == Tab::Recent) {
|
||||||
// Recent tab: Archive(0), Delete(1), Remove from Recents(2), Clear All Recents(3)
|
// Recent tab: Archive(0), Delete(1), Clear Cache(2), Remove from Recents(3), Clear All Recents(4)
|
||||||
switch (menuSelection) {
|
switch (menuSelection) {
|
||||||
case 0:
|
case 0:
|
||||||
selectedAction = ActionType::Archive;
|
selectedAction = ActionType::Archive;
|
||||||
@@ -618,20 +624,37 @@ void MyLibraryActivity::loop() {
|
|||||||
selectedAction = ActionType::Delete;
|
selectedAction = ActionType::Delete;
|
||||||
break;
|
break;
|
||||||
case 2:
|
case 2:
|
||||||
selectedAction = ActionType::RemoveFromRecents;
|
selectedAction = ActionType::ClearCache;
|
||||||
break;
|
break;
|
||||||
case 3:
|
case 3:
|
||||||
|
selectedAction = ActionType::RemoveFromRecents;
|
||||||
|
break;
|
||||||
|
case 4:
|
||||||
selectedAction = ActionType::ClearAllRecents;
|
selectedAction = ActionType::ClearAllRecents;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Files tab: Archive(0), Delete(1)
|
// Files tab: Archive(0), Delete(1), Clear Cache(2)
|
||||||
selectedAction = (menuSelection == 0) ? ActionType::Archive : ActionType::Delete;
|
switch (menuSelection) {
|
||||||
|
case 0:
|
||||||
|
selectedAction = ActionType::Archive;
|
||||||
|
break;
|
||||||
|
case 1:
|
||||||
|
selectedAction = ActionType::Delete;
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
selectedAction = ActionType::ClearCache;
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear All Recents needs its own confirmation dialog
|
// Clear All Recents needs its own confirmation dialog
|
||||||
if (selectedAction == ActionType::ClearAllRecents) {
|
if (selectedAction == ActionType::ClearAllRecents) {
|
||||||
uiState = UIState::ClearAllRecentsConfirming;
|
uiState = UIState::ClearAllRecentsConfirming;
|
||||||
|
} else if (selectedAction == ActionType::ClearCache) {
|
||||||
|
// Clear Cache shows options dialog first
|
||||||
|
clearCachePreserveProgress = true; // Default to preserving progress
|
||||||
|
uiState = UIState::ClearCacheOptionsConfirming;
|
||||||
} else {
|
} else {
|
||||||
uiState = UIState::Confirming;
|
uiState = UIState::Confirming;
|
||||||
}
|
}
|
||||||
@@ -736,6 +759,30 @@ void MyLibraryActivity::loop() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle clear cache options confirmation state
|
||||||
|
if (uiState == UIState::ClearCacheOptionsConfirming) {
|
||||||
|
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||||
|
uiState = UIState::ActionMenu;
|
||||||
|
updateRequired = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Up/Down toggle between Yes/No for preserve progress
|
||||||
|
if (mappedInput.wasReleased(MappedInputManager::Button::Up) ||
|
||||||
|
mappedInput.wasReleased(MappedInputManager::Button::Down)) {
|
||||||
|
clearCachePreserveProgress = !clearCachePreserveProgress;
|
||||||
|
updateRequired = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||||
|
executeAction();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Normal state handling
|
// Normal state handling
|
||||||
const int itemCount = getCurrentItemCount();
|
const int itemCount = getCurrentItemCount();
|
||||||
const int pageItems = getPageItems();
|
const int pageItems = getPageItems();
|
||||||
@@ -865,16 +912,14 @@ void MyLibraryActivity::loop() {
|
|||||||
// 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;
|
||||||
@@ -944,8 +989,7 @@ void MyLibraryActivity::loop() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 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();
|
||||||
@@ -976,16 +1020,14 @@ void MyLibraryActivity::loop() {
|
|||||||
// 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;
|
||||||
}
|
}
|
||||||
@@ -1063,8 +1105,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;
|
||||||
}
|
}
|
||||||
@@ -1309,6 +1351,12 @@ void MyLibraryActivity::render() const {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (uiState == UIState::ClearCacheOptionsConfirming) {
|
||||||
|
renderClearCacheOptionsConfirmation();
|
||||||
|
renderer.displayBuffer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate bezel-adjusted margins
|
// Calculate bezel-adjusted margins
|
||||||
const int bezelTop = renderer.getBezelOffsetTop();
|
const int bezelTop = renderer.getBezelOffsetTop();
|
||||||
const int bezelBottom = renderer.getBezelOffsetBottom();
|
const int bezelBottom = renderer.getBezelOffsetBottom();
|
||||||
@@ -1372,7 +1420,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();
|
||||||
@@ -1526,8 +1573,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()) {
|
||||||
@@ -1558,7 +1605,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 +1627,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++) {
|
||||||
@@ -1610,7 +1656,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 +1678,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++) {
|
||||||
@@ -1666,42 +1711,50 @@ 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 - 5 for Recent tab, 3 for Files tab
|
||||||
const bool isRecentTab = (currentTab == Tab::Recent);
|
const bool isRecentTab = (currentTab == Tab::Recent);
|
||||||
const int menuItemCount = isRecentTab ? 4 : 2;
|
const int menuItemCount = isRecentTab ? 5 : 3;
|
||||||
constexpr int menuLineHeight = 35;
|
constexpr int menuLineHeight = 35;
|
||||||
constexpr int menuItemWidth = 160;
|
constexpr int menuItemWidth = 160;
|
||||||
const int menuX = (pageWidth - menuItemWidth) / 2;
|
const int menuX = (pageWidth - menuItemWidth) / 2;
|
||||||
const int menuStartY = pageHeight / 2 - (menuItemCount * menuLineHeight) / 2;
|
const int menuStartY = pageHeight / 2 - (menuItemCount * menuLineHeight) / 2;
|
||||||
|
|
||||||
// Archive option
|
// Archive option (index 0)
|
||||||
if (menuSelection == 0) {
|
if (menuSelection == 0) {
|
||||||
renderer.fillRect(menuX - 10, menuStartY - 5, menuItemWidth + 20, menuLineHeight);
|
renderer.fillRect(menuX - 10, menuStartY - 5, menuItemWidth + 20, menuLineHeight);
|
||||||
}
|
}
|
||||||
renderer.drawCenteredText(UI_10_FONT_ID, menuStartY, "Archive", menuSelection != 0);
|
renderer.drawCenteredText(UI_10_FONT_ID, menuStartY, "Archive", menuSelection != 0);
|
||||||
|
|
||||||
// Delete option
|
// Delete option (index 1)
|
||||||
if (menuSelection == 1) {
|
if (menuSelection == 1) {
|
||||||
renderer.fillRect(menuX - 10, menuStartY + menuLineHeight - 5, menuItemWidth + 20, menuLineHeight);
|
renderer.fillRect(menuX - 10, menuStartY + menuLineHeight - 5, menuItemWidth + 20, menuLineHeight);
|
||||||
}
|
}
|
||||||
renderer.drawCenteredText(UI_10_FONT_ID, menuStartY + menuLineHeight, "Delete", menuSelection != 1);
|
renderer.drawCenteredText(UI_10_FONT_ID, menuStartY + menuLineHeight, "Delete", menuSelection != 1);
|
||||||
|
|
||||||
// Recent tab only: Remove from Recents and Clear All Recents
|
// Clear Cache option (index 2) - available in both tabs
|
||||||
if (isRecentTab) {
|
|
||||||
// Remove from Recents option
|
|
||||||
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, "Clear Cache", menuSelection != 2);
|
||||||
|
|
||||||
// Clear All Recents option
|
// Recent tab only: Remove from Recents and Clear All Recents
|
||||||
|
if (isRecentTab) {
|
||||||
|
// Remove from Recents option (index 3)
|
||||||
if (menuSelection == 3) {
|
if (menuSelection == 3) {
|
||||||
renderer.fillRect(menuX - 10, menuStartY + menuLineHeight * 3 - 5, menuItemWidth + 20, menuLineHeight);
|
renderer.fillRect(menuX - 10, menuStartY + menuLineHeight * 3 - 5, menuItemWidth + 20, menuLineHeight);
|
||||||
}
|
}
|
||||||
renderer.drawCenteredText(UI_10_FONT_ID, menuStartY + menuLineHeight * 3, "Clear All Recents", menuSelection != 3);
|
renderer.drawCenteredText(UI_10_FONT_ID, menuStartY + menuLineHeight * 3, "Remove from Recents",
|
||||||
|
menuSelection != 3);
|
||||||
|
|
||||||
|
// Clear All Recents option (index 4)
|
||||||
|
if (menuSelection == 4) {
|
||||||
|
renderer.fillRect(menuX - 10, menuStartY + menuLineHeight * 4 - 5, menuItemWidth + 20, menuLineHeight);
|
||||||
|
}
|
||||||
|
renderer.drawCenteredText(UI_10_FONT_ID, menuStartY + menuLineHeight * 4, "Clear All Recents", menuSelection != 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw side button hints (up/down navigation)
|
// Draw side button hints (up/down navigation)
|
||||||
@@ -1809,7 +1862,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
|
||||||
@@ -1834,11 +1888,58 @@ void MyLibraryActivity::renderClearAllRecentsConfirmation() const {
|
|||||||
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void MyLibraryActivity::renderClearCacheOptionsConfirmation() const {
|
||||||
|
const auto pageWidth = renderer.getScreenWidth();
|
||||||
|
const auto pageHeight = renderer.getScreenHeight();
|
||||||
|
|
||||||
|
// Bezel compensation
|
||||||
|
const int bezelTop = renderer.getBezelOffsetTop();
|
||||||
|
|
||||||
|
// Title
|
||||||
|
renderer.drawCenteredText(UI_12_FONT_ID, 20 + bezelTop, "Clear Book Cache", true, EpdFontFamily::BOLD);
|
||||||
|
|
||||||
|
// Show filename
|
||||||
|
const int filenameY = 60 + bezelTop;
|
||||||
|
const int bezelLeft = renderer.getBezelOffsetLeft();
|
||||||
|
const int bezelRight = renderer.getBezelOffsetRight();
|
||||||
|
auto truncatedName =
|
||||||
|
renderer.truncatedText(UI_10_FONT_ID, actionTargetName.c_str(), pageWidth - 40 - bezelLeft - bezelRight);
|
||||||
|
renderer.drawCenteredText(UI_10_FONT_ID, filenameY, truncatedName.c_str());
|
||||||
|
|
||||||
|
// Question text
|
||||||
|
const int questionY = pageHeight / 2 - 50;
|
||||||
|
renderer.drawCenteredText(UI_10_FONT_ID, questionY, "Preserve reading progress?");
|
||||||
|
|
||||||
|
// Yes/No options
|
||||||
|
constexpr int optionLineHeight = 35;
|
||||||
|
constexpr int optionWidth = 100;
|
||||||
|
const int optionX = (pageWidth - optionWidth) / 2;
|
||||||
|
const int optionStartY = questionY + 40;
|
||||||
|
|
||||||
|
// Yes option
|
||||||
|
if (clearCachePreserveProgress) {
|
||||||
|
renderer.fillRect(optionX - 10, optionStartY - 5, optionWidth + 20, optionLineHeight);
|
||||||
|
}
|
||||||
|
renderer.drawCenteredText(UI_10_FONT_ID, optionStartY, "Yes", !clearCachePreserveProgress);
|
||||||
|
|
||||||
|
// No option
|
||||||
|
if (!clearCachePreserveProgress) {
|
||||||
|
renderer.fillRect(optionX - 10, optionStartY + optionLineHeight - 5, optionWidth + 20, optionLineHeight);
|
||||||
|
}
|
||||||
|
renderer.drawCenteredText(UI_10_FONT_ID, optionStartY + optionLineHeight, "No", clearCachePreserveProgress);
|
||||||
|
|
||||||
|
// Draw side button hints (up/down navigation)
|
||||||
|
renderer.drawSideButtonHints(UI_10_FONT_ID, ">", "<");
|
||||||
|
|
||||||
|
// Draw bottom button hints
|
||||||
|
const auto labels = mappedInput.mapLabels("« Cancel", "Clear", "", "");
|
||||||
|
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||||
|
}
|
||||||
|
|
||||||
void MyLibraryActivity::renderBookmarksTab() const {
|
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();
|
||||||
@@ -1926,7 +2027,8 @@ 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
|
||||||
@@ -1998,8 +2100,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()) {
|
||||||
@@ -2043,7 +2145,6 @@ void MyLibraryActivity::renderCharacterPicker(int y) const {
|
|||||||
|
|
||||||
// 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
|
||||||
@@ -2061,9 +2162,8 @@ void MyLibraryActivity::renderCharacterPicker(int y) const {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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);
|
||||||
@@ -2085,7 +2185,8 @@ void MyLibraryActivity::renderCharacterPicker(int y) const {
|
|||||||
// 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;
|
||||||
@@ -2145,21 +2246,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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,8 +38,16 @@ 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 {
|
||||||
enum class ActionType { Archive, Delete, RemoveFromRecents, ClearAllRecents };
|
Normal,
|
||||||
|
ActionMenu,
|
||||||
|
Confirming,
|
||||||
|
ListActionMenu,
|
||||||
|
ListConfirmingDelete,
|
||||||
|
ClearAllRecentsConfirming,
|
||||||
|
ClearCacheOptionsConfirming
|
||||||
|
};
|
||||||
|
enum class ActionType { Archive, Delete, RemoveFromRecents, ClearCache, ClearAllRecents };
|
||||||
|
|
||||||
private:
|
private:
|
||||||
TaskHandle_t displayTaskHandle = nullptr;
|
TaskHandle_t displayTaskHandle = nullptr;
|
||||||
@@ -57,6 +65,7 @@ class MyLibraryActivity final : public Activity {
|
|||||||
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
|
||||||
|
bool clearCachePreserveProgress = true; // For Clear Cache: whether to preserve reading progress
|
||||||
|
|
||||||
// Recent tab state
|
// Recent tab state
|
||||||
std::vector<RecentBook> recentBooks;
|
std::vector<RecentBook> recentBooks;
|
||||||
@@ -70,7 +79,6 @@ class MyLibraryActivity final : public Activity {
|
|||||||
static void clearThumbExistsCache();
|
static void clearThumbExistsCache();
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
|
||||||
// Lists tab state
|
// Lists tab state
|
||||||
std::vector<std::string> lists;
|
std::vector<std::string> lists;
|
||||||
|
|
||||||
@@ -147,9 +155,12 @@ class MyLibraryActivity final : public Activity {
|
|||||||
// Clear all recents confirmation
|
// Clear all recents confirmation
|
||||||
void renderClearAllRecentsConfirmation() const;
|
void renderClearAllRecentsConfirmation() const;
|
||||||
|
|
||||||
|
// Clear cache options confirmation
|
||||||
|
void renderClearCacheOptionsConfirmation() 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,
|
||||||
|
|||||||
@@ -300,6 +300,36 @@ void CrossPointWebServerActivity::startAccessPoint() {
|
|||||||
startWebServer();
|
startWebServer();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void CrossPointWebServerActivity::generateQRCodes() {
|
||||||
|
Serial.printf("[%lu] [WEBACT] Generating QR codes (cached)...\n", millis());
|
||||||
|
const unsigned long startTime = millis();
|
||||||
|
|
||||||
|
// Web browser URL QR code
|
||||||
|
std::string webUrl = "http://" + connectedIP + "/";
|
||||||
|
qrcode_initText(&qrWebBrowser, qrWebBrowserBuffer, 4, ECC_LOW, webUrl.c_str());
|
||||||
|
Serial.printf("[%lu] [WEBACT] QR cached: %s\n", millis(), webUrl.c_str());
|
||||||
|
|
||||||
|
// Companion App (Files) deep link QR code
|
||||||
|
std::string filesUrl = getCompanionAppUrl();
|
||||||
|
qrcode_initText(&qrCompanionApp, qrCompanionAppBuffer, 4, ECC_LOW, filesUrl.c_str());
|
||||||
|
Serial.printf("[%lu] [WEBACT] QR cached: %s\n", millis(), filesUrl.c_str());
|
||||||
|
|
||||||
|
// Companion App (Library) deep link QR code
|
||||||
|
std::string libraryUrl = getCompanionAppLibraryUrl();
|
||||||
|
qrcode_initText(&qrCompanionAppLibrary, qrCompanionAppLibraryBuffer, 4, ECC_LOW, libraryUrl.c_str());
|
||||||
|
Serial.printf("[%lu] [WEBACT] QR cached: %s\n", millis(), libraryUrl.c_str());
|
||||||
|
|
||||||
|
// WiFi config QR code (for AP mode)
|
||||||
|
if (isApMode) {
|
||||||
|
std::string wifiConfig = std::string("WIFI:S:") + connectedSSID + ";;";
|
||||||
|
qrcode_initText(&qrWifiConfig, qrWifiConfigBuffer, 4, ECC_LOW, wifiConfig.c_str());
|
||||||
|
Serial.printf("[%lu] [WEBACT] QR cached: %s\n", millis(), wifiConfig.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
qrCacheValid = true;
|
||||||
|
Serial.printf("[%lu] [WEBACT] QR codes cached in %lu ms\n", millis(), millis() - startTime);
|
||||||
|
}
|
||||||
|
|
||||||
void CrossPointWebServerActivity::startWebServer() {
|
void CrossPointWebServerActivity::startWebServer() {
|
||||||
Serial.printf("[%lu] [WEBACT] Starting web server...\n", millis());
|
Serial.printf("[%lu] [WEBACT] Starting web server...\n", millis());
|
||||||
|
|
||||||
@@ -311,6 +341,9 @@ void CrossPointWebServerActivity::startWebServer() {
|
|||||||
state = WebServerActivityState::SERVER_RUNNING;
|
state = WebServerActivityState::SERVER_RUNNING;
|
||||||
Serial.printf("[%lu] [WEBACT] Web server started successfully\n", millis());
|
Serial.printf("[%lu] [WEBACT] Web server started successfully\n", millis());
|
||||||
|
|
||||||
|
// Generate and cache QR codes now that we have IP and server ports
|
||||||
|
generateQRCodes();
|
||||||
|
|
||||||
// Force an immediate render since we're transitioning from a subactivity
|
// Force an immediate render since we're transitioning from a subactivity
|
||||||
// that had its own rendering task. We need to make sure our display is shown.
|
// that had its own rendering task. We need to make sure our display is shown.
|
||||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||||
@@ -468,23 +501,18 @@ void CrossPointWebServerActivity::render() const {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw QR code at specified position with configurable pixel size per module
|
// Draw QR code from pre-computed QRCode data at specified position
|
||||||
// Returns the size of the QR code in pixels (width = height = size * pixelsPerModule)
|
// Returns the size of the QR code in pixels (width = height = size * pixelsPerModule)
|
||||||
int drawQRCode(const GfxRenderer& renderer, const int x, const int y, const std::string& data,
|
int drawQRCodeCached(const GfxRenderer& renderer, const int x, const int y, QRCode* qrcode,
|
||||||
const uint8_t pixelsPerModule = 7) {
|
const uint8_t pixelsPerModule = 7) {
|
||||||
QRCode qrcode;
|
for (uint8_t cy = 0; cy < qrcode->size; cy++) {
|
||||||
uint8_t qrcodeBytes[qrcode_getBufferSize(4)];
|
for (uint8_t cx = 0; cx < qrcode->size; cx++) {
|
||||||
Serial.printf("[%lu] [WEBACT] QR Code (%lu): %s\n", millis(), data.length(), data.c_str());
|
if (qrcode_getModule(qrcode, cx, cy)) {
|
||||||
|
|
||||||
qrcode_initText(&qrcode, qrcodeBytes, 4, ECC_LOW, data.c_str());
|
|
||||||
for (uint8_t cy = 0; cy < qrcode.size; cy++) {
|
|
||||||
for (uint8_t cx = 0; cx < qrcode.size; cx++) {
|
|
||||||
if (qrcode_getModule(&qrcode, cx, cy)) {
|
|
||||||
renderer.fillRect(x + pixelsPerModule * cx, y + pixelsPerModule * cy, pixelsPerModule, pixelsPerModule, true);
|
renderer.fillRect(x + pixelsPerModule * cx, y + pixelsPerModule * cy, pixelsPerModule, pixelsPerModule, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return qrcode.size * pixelsPerModule;
|
return qrcode->size * pixelsPerModule;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to format bytes into human-readable sizes
|
// Helper to format bytes into human-readable sizes
|
||||||
@@ -612,8 +640,7 @@ void CrossPointWebServerActivity::renderWebBrowserScreen() const {
|
|||||||
|
|
||||||
if (isApMode) {
|
if (isApMode) {
|
||||||
// AP mode: Show WiFi QR code on left, connection info on right
|
// AP mode: Show WiFi QR code on left, connection info on right
|
||||||
const std::string wifiConfig = std::string("WIFI:S:") + connectedSSID + ";;";
|
drawQRCodeCached(renderer, QR_X, QR_Y, &qrWifiConfig, QR_PX);
|
||||||
drawQRCode(renderer, QR_X, QR_Y, wifiConfig, QR_PX);
|
|
||||||
|
|
||||||
std::string ssidInfo = "Network: " + connectedSSID;
|
std::string ssidInfo = "Network: " + connectedSSID;
|
||||||
renderer.drawText(NOTOSANS_12_FONT_ID, TEXT_X, textY, ssidInfo.c_str());
|
renderer.drawText(NOTOSANS_12_FONT_ID, TEXT_X, textY, ssidInfo.c_str());
|
||||||
@@ -635,12 +662,12 @@ void CrossPointWebServerActivity::renderWebBrowserScreen() const {
|
|||||||
renderer.drawText(UI_12_FONT_ID, TEXT_X, textY, hostnameUrl.c_str());
|
renderer.drawText(UI_12_FONT_ID, TEXT_X, textY, hostnameUrl.c_str());
|
||||||
} else {
|
} else {
|
||||||
// STA mode: Show URL QR code on left, connection info on right
|
// STA mode: Show URL QR code on left, connection info on right
|
||||||
std::string webUrl = "http://" + connectedIP + "/";
|
drawQRCodeCached(renderer, QR_X, QR_Y, &qrWebBrowser, QR_PX);
|
||||||
drawQRCode(renderer, QR_X, QR_Y, webUrl, QR_PX);
|
|
||||||
|
|
||||||
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;
|
||||||
@@ -649,6 +676,7 @@ void CrossPointWebServerActivity::renderWebBrowserScreen() const {
|
|||||||
renderer.drawText(NOTOSANS_12_FONT_ID, TEXT_X, textY, ipInfo.c_str());
|
renderer.drawText(NOTOSANS_12_FONT_ID, TEXT_X, textY, ipInfo.c_str());
|
||||||
textY += LINE_SPACING + 8;
|
textY += LINE_SPACING + 8;
|
||||||
|
|
||||||
|
std::string webUrl = "http://" + connectedIP + "/";
|
||||||
renderer.drawText(UI_12_FONT_ID, TEXT_X, textY, webUrl.c_str(), true, EpdFontFamily::BOLD);
|
renderer.drawText(UI_12_FONT_ID, TEXT_X, textY, webUrl.c_str(), true, EpdFontFamily::BOLD);
|
||||||
textY += LINE_SPACING - 4;
|
textY += LINE_SPACING - 4;
|
||||||
|
|
||||||
@@ -679,7 +707,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;
|
||||||
@@ -702,12 +731,12 @@ void CrossPointWebServerActivity::renderCompanionAppScreen() const {
|
|||||||
std::string webUrl = "http://" + connectedIP + "/files";
|
std::string webUrl = "http://" + connectedIP + "/files";
|
||||||
renderer.drawText(UI_12_FONT_ID, TEXT_X, textY, webUrl.c_str());
|
renderer.drawText(UI_12_FONT_ID, TEXT_X, textY, webUrl.c_str());
|
||||||
|
|
||||||
// Draw QR code on left
|
// Draw cached QR code on left
|
||||||
const std::string appUrl = getCompanionAppUrl();
|
drawQRCodeCached(renderer, QR_X, QR_Y, &qrCompanionApp, QR_PX);
|
||||||
drawQRCode(renderer, QR_X, QR_Y, appUrl, QR_PX);
|
|
||||||
|
|
||||||
// Show deep link URL below QR code
|
// Show deep link URL below QR code
|
||||||
const int urlY = QR_Y + QR_SIZE + 10;
|
const int urlY = QR_Y + QR_SIZE + 10;
|
||||||
|
const std::string appUrl = getCompanionAppUrl();
|
||||||
renderer.drawText(UI_12_FONT_ID, QR_X, urlY, appUrl.c_str(), true, EpdFontFamily::BOLD);
|
renderer.drawText(UI_12_FONT_ID, QR_X, urlY, appUrl.c_str(), true, EpdFontFamily::BOLD);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -728,7 +757,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;
|
||||||
@@ -751,11 +781,11 @@ void CrossPointWebServerActivity::renderCompanionAppLibraryScreen() const {
|
|||||||
std::string webUrl = "http://" + connectedIP + "/";
|
std::string webUrl = "http://" + connectedIP + "/";
|
||||||
renderer.drawText(UI_12_FONT_ID, TEXT_X, textY, webUrl.c_str());
|
renderer.drawText(UI_12_FONT_ID, TEXT_X, textY, webUrl.c_str());
|
||||||
|
|
||||||
// Draw QR code on left
|
// Draw cached QR code on left
|
||||||
const std::string appUrl = getCompanionAppLibraryUrl();
|
drawQRCodeCached(renderer, QR_X, QR_Y, &qrCompanionAppLibrary, QR_PX);
|
||||||
drawQRCode(renderer, QR_X, QR_Y, appUrl, QR_PX);
|
|
||||||
|
|
||||||
// Show deep link URL below QR code
|
// Show deep link URL below QR code
|
||||||
const int urlY = QR_Y + QR_SIZE + 10;
|
const int urlY = QR_Y + QR_SIZE + 10;
|
||||||
|
const std::string appUrl = getCompanionAppLibraryUrl();
|
||||||
renderer.drawText(UI_12_FONT_ID, QR_X, urlY, appUrl.c_str(), true, EpdFontFamily::BOLD);
|
renderer.drawText(UI_12_FONT_ID, QR_X, urlY, appUrl.c_str(), true, EpdFontFamily::BOLD);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
#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 <qrcode.h>
|
||||||
|
|
||||||
#include <functional>
|
#include <functional>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
@@ -11,6 +12,10 @@
|
|||||||
#include "activities/ActivityWithSubactivity.h"
|
#include "activities/ActivityWithSubactivity.h"
|
||||||
#include "network/CrossPointWebServer.h"
|
#include "network/CrossPointWebServer.h"
|
||||||
|
|
||||||
|
// QR code cache - version 4 QR codes (33x33 modules)
|
||||||
|
// Buffer size for version 4: qrcode_getBufferSize(4) ≈ 185 bytes
|
||||||
|
constexpr size_t QR_BUFFER_SIZE = 185;
|
||||||
|
|
||||||
// Web server activity states
|
// Web server activity states
|
||||||
enum class WebServerActivityState {
|
enum class WebServerActivityState {
|
||||||
MODE_SELECTION, // Choosing between Join Network and Create Hotspot
|
MODE_SELECTION, // Choosing between Join Network and Create Hotspot
|
||||||
@@ -62,6 +67,19 @@ class CrossPointWebServerActivity final : public ActivityWithSubactivity {
|
|||||||
FileTransferScreen currentScreen = FileTransferScreen::COMPANION_APP_LIBRARY;
|
FileTransferScreen currentScreen = FileTransferScreen::COMPANION_APP_LIBRARY;
|
||||||
unsigned long lastStatsRefresh = 0;
|
unsigned long lastStatsRefresh = 0;
|
||||||
|
|
||||||
|
// Cached QR codes - generated once when server starts
|
||||||
|
// Avoids recomputing QR data on every render (every 30s stats refresh)
|
||||||
|
// Marked mutable since QR drawing doesn't modify logical state but qrcode_getModule takes non-const
|
||||||
|
bool qrCacheValid = false;
|
||||||
|
mutable QRCode qrWebBrowser = {};
|
||||||
|
mutable QRCode qrCompanionApp = {};
|
||||||
|
mutable QRCode qrCompanionAppLibrary = {};
|
||||||
|
mutable QRCode qrWifiConfig = {}; // For AP mode WiFi connection QR
|
||||||
|
uint8_t qrWebBrowserBuffer[QR_BUFFER_SIZE] = {};
|
||||||
|
uint8_t qrCompanionAppBuffer[QR_BUFFER_SIZE] = {};
|
||||||
|
uint8_t qrCompanionAppLibraryBuffer[QR_BUFFER_SIZE] = {};
|
||||||
|
uint8_t qrWifiConfigBuffer[QR_BUFFER_SIZE] = {};
|
||||||
|
|
||||||
static void taskTrampoline(void* param);
|
static void taskTrampoline(void* param);
|
||||||
[[noreturn]] void displayTaskLoop();
|
[[noreturn]] void displayTaskLoop();
|
||||||
void render() const;
|
void render() const;
|
||||||
@@ -78,6 +96,7 @@ class CrossPointWebServerActivity final : public ActivityWithSubactivity {
|
|||||||
void startAccessPoint();
|
void startAccessPoint();
|
||||||
void startWebServer();
|
void startWebServer();
|
||||||
void stopWebServer();
|
void stopWebServer();
|
||||||
|
void generateQRCodes();
|
||||||
|
|
||||||
public:
|
public:
|
||||||
explicit CrossPointWebServerActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
explicit CrossPointWebServerActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
#include <GfxRenderer.h>
|
#include <GfxRenderer.h>
|
||||||
#include <WiFi.h>
|
#include <WiFi.h>
|
||||||
|
|
||||||
#include <map>
|
#include <algorithm>
|
||||||
|
|
||||||
#include "MappedInputManager.h"
|
#include "MappedInputManager.h"
|
||||||
#include "WifiCredentialStore.h"
|
#include "WifiCredentialStore.h"
|
||||||
@@ -124,48 +124,55 @@ void WifiSelectionActivity::processWifiScanResults() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Scan complete, process results
|
// Scan complete, process results
|
||||||
// Use a map to deduplicate networks by SSID, keeping the strongest signal
|
// Deduplicate directly into the networks vector (avoids std::map overhead)
|
||||||
std::map<std::string, WifiNetworkInfo> uniqueNetworks;
|
networks.clear();
|
||||||
|
networks.reserve(std::min(scanResult, static_cast<int16_t>(20))); // Limit to 20 networks max
|
||||||
|
|
||||||
for (int i = 0; i < scanResult; i++) {
|
for (int i = 0; i < scanResult; i++) {
|
||||||
std::string ssid = WiFi.SSID(i).c_str();
|
String ssidStr = WiFi.SSID(i);
|
||||||
const int32_t rssi = WiFi.RSSI(i);
|
const int32_t rssi = WiFi.RSSI(i);
|
||||||
|
|
||||||
// Skip hidden networks (empty SSID)
|
// Skip hidden networks (empty SSID)
|
||||||
if (ssid.empty()) {
|
if (ssidStr.isEmpty()) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we've already seen this SSID
|
std::string ssid = ssidStr.c_str();
|
||||||
auto it = uniqueNetworks.find(ssid);
|
|
||||||
if (it == uniqueNetworks.end() || rssi > it->second.rssi) {
|
// Check if we've already seen this SSID (linear search is fine for small lists)
|
||||||
// New network or stronger signal than existing entry
|
auto existing = std::find_if(networks.begin(), networks.end(),
|
||||||
|
[&ssid](const WifiNetworkInfo& net) { return net.ssid == ssid; });
|
||||||
|
|
||||||
|
if (existing != networks.end()) {
|
||||||
|
// Update if stronger signal
|
||||||
|
if (rssi > existing->rssi) {
|
||||||
|
existing->rssi = rssi;
|
||||||
|
existing->isEncrypted = (WiFi.encryptionType(i) != WIFI_AUTH_OPEN);
|
||||||
|
}
|
||||||
|
} else if (networks.size() < 20) {
|
||||||
|
// New network - only add if under limit
|
||||||
WifiNetworkInfo network;
|
WifiNetworkInfo network;
|
||||||
network.ssid = ssid;
|
network.ssid = std::move(ssid);
|
||||||
network.rssi = rssi;
|
network.rssi = rssi;
|
||||||
network.isEncrypted = (WiFi.encryptionType(i) != WIFI_AUTH_OPEN);
|
network.isEncrypted = (WiFi.encryptionType(i) != WIFI_AUTH_OPEN);
|
||||||
network.hasSavedPassword = WIFI_STORE.hasSavedCredential(network.ssid);
|
network.hasSavedPassword = WIFI_STORE.hasSavedCredential(network.ssid);
|
||||||
uniqueNetworks[ssid] = network;
|
networks.push_back(std::move(network));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert map to vector
|
// Free WiFi scan memory immediately (before sorting)
|
||||||
networks.clear();
|
WiFi.scanDelete();
|
||||||
for (const auto& pair : uniqueNetworks) {
|
|
||||||
// cppcheck-suppress useStlAlgorithm
|
|
||||||
networks.push_back(pair.second);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by signal strength (strongest first)
|
// Sort by signal strength (strongest first), then by saved password
|
||||||
std::sort(networks.begin(), networks.end(),
|
|
||||||
[](const WifiNetworkInfo& a, const WifiNetworkInfo& b) { return a.rssi > b.rssi; });
|
|
||||||
|
|
||||||
// Show networks with PW first
|
|
||||||
std::sort(networks.begin(), networks.end(), [](const WifiNetworkInfo& a, const WifiNetworkInfo& b) {
|
std::sort(networks.begin(), networks.end(), [](const WifiNetworkInfo& a, const WifiNetworkInfo& b) {
|
||||||
return a.hasSavedPassword && !b.hasSavedPassword;
|
// Primary: saved passwords first
|
||||||
|
if (a.hasSavedPassword != b.hasSavedPassword) {
|
||||||
|
return a.hasSavedPassword;
|
||||||
|
}
|
||||||
|
// Secondary: strongest signal first
|
||||||
|
return a.rssi > b.rssi;
|
||||||
});
|
});
|
||||||
|
|
||||||
WiFi.scanDelete();
|
|
||||||
state = WifiSelectionState::NETWORK_LIST;
|
state = WifiSelectionState::NETWORK_LIST;
|
||||||
selectedNetworkIndex = 0;
|
selectedNetworkIndex = 0;
|
||||||
updateRequired = true;
|
updateRequired = true;
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ void EpubReaderActivity::onEnter() {
|
|||||||
renderer.fillRect(boxX, boxY, boxWidth, boxHeight, false);
|
renderer.fillRect(boxX, boxY, boxWidth, boxHeight, false);
|
||||||
renderer.drawRect(boxX + 5, boxY + 5, boxWidth - 10, boxHeight - 10);
|
renderer.drawRect(boxX + 5, boxY + 5, boxWidth - 10, boxHeight - 10);
|
||||||
renderer.drawText(UI_12_FONT_ID, boxX + boxMargin, boxY + boxMargin, "Preparing book... [0%]");
|
renderer.drawText(UI_12_FONT_ID, boxX + boxMargin, boxY + boxMargin, "Preparing book... [0%]");
|
||||||
renderer.displayBuffer(EInkDisplay::FAST_REFRESH);
|
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||||
|
|
||||||
// Generate covers with progress callback
|
// Generate covers with progress callback
|
||||||
epub->generateAllCovers([&](int percent) {
|
epub->generateAllCovers([&](int percent) {
|
||||||
@@ -103,7 +103,7 @@ void EpubReaderActivity::onEnter() {
|
|||||||
char progressStr[32];
|
char progressStr[32];
|
||||||
snprintf(progressStr, sizeof(progressStr), "Preparing book... [%d%%]", percent);
|
snprintf(progressStr, sizeof(progressStr), "Preparing book... [%d%%]", percent);
|
||||||
renderer.drawText(UI_12_FONT_ID, boxX + boxMargin, boxY + boxMargin, progressStr);
|
renderer.drawText(UI_12_FONT_ID, boxX + boxMargin, boxY + boxMargin, progressStr);
|
||||||
renderer.displayBuffer(EInkDisplay::FAST_REFRESH);
|
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -127,8 +127,8 @@ void EpubReaderActivity::onEnter() {
|
|||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,7 +316,8 @@ void EpubReaderActivity::loop() {
|
|||||||
|
|
||||||
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(
|
||||||
|
cachedRenderer, cachedMappedInput,
|
||||||
[self]() {
|
[self]() {
|
||||||
// On back from dictionary
|
// On back from dictionary
|
||||||
self->exitActivity();
|
self->exitActivity();
|
||||||
@@ -335,7 +346,8 @@ 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->renderer, self->mappedInput,
|
||||||
[self]() {
|
[self]() {
|
||||||
self->exitActivity();
|
self->exitActivity();
|
||||||
self->updateRequired = true;
|
self->updateRequired = true;
|
||||||
@@ -386,15 +398,15 @@ void EpubReaderActivity::loop() {
|
|||||||
[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) {
|
||||||
@@ -406,11 +418,13 @@ void EpubReaderActivity::loop() {
|
|||||||
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();
|
||||||
@@ -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();
|
||||||
@@ -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
|
||||||
@@ -680,7 +717,7 @@ void EpubReaderActivity::renderScreen() {
|
|||||||
auto progressCallback = [this, barX, barY, barWidth, barHeight](int progress) {
|
auto progressCallback = [this, barX, barY, barWidth, barHeight](int progress) {
|
||||||
const int fillWidth = (barWidth - 2) * progress / 100;
|
const int fillWidth = (barWidth - 2) * progress / 100;
|
||||||
renderer.fillRect(barX + 1, barY + 1, fillWidth, barHeight - 2, true);
|
renderer.fillRect(barX + 1, barY + 1, fillWidth, barHeight - 2, true);
|
||||||
renderer.displayBuffer(EInkDisplay::FAST_REFRESH);
|
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!section->createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
|
if (!section->createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
|
||||||
@@ -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 {
|
||||||
@@ -768,8 +805,8 @@ void EpubReaderActivity::renderScreen() {
|
|||||||
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -797,7 +834,7 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
|
|||||||
|
|
||||||
renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
|
renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
|
||||||
if (pagesUntilFullRefresh <= 1) {
|
if (pagesUntilFullRefresh <= 1) {
|
||||||
renderer.displayBuffer(EInkDisplay::HALF_REFRESH);
|
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
||||||
pagesUntilFullRefresh = SETTINGS.getRefreshFrequency();
|
pagesUntilFullRefresh = SETTINGS.getRefreshFrequency();
|
||||||
} else {
|
} else {
|
||||||
renderer.displayBuffer();
|
renderer.displayBuffer();
|
||||||
@@ -938,7 +975,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 +996,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();
|
||||||
|
|||||||
@@ -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)
|
|
||||||
renderer.drawText(UI_10_FONT_ID, 20 + bezelLeft, displayY, ">> Sync Progress", !isSelected);
|
|
||||||
} else {
|
|
||||||
// Draw TOC item (account for top sync offset)
|
|
||||||
const int tocIndex = tocIndexFromItemIndex(itemIndex);
|
const int tocIndex = tocIndexFromItemIndex(itemIndex);
|
||||||
auto item = epub->getTocItem(tocIndex);
|
auto item = epub->getTocItem(tocIndex);
|
||||||
const int indentSize = 20 + bezelLeft + (item.level - 1) * 15;
|
const int indentSize = 20 + bezelLeft + (item.level - 1) * 15;
|
||||||
const std::string chapterName =
|
const std::string chapterName = renderer.truncatedText(
|
||||||
renderer.truncatedText(UI_10_FONT_ID, item.title.c_str(), pageWidth - 40 - bezelLeft - bezelRight - indentSize + 20 + bezelLeft);
|
UI_10_FONT_ID, item.title.c_str(), pageWidth - 40 - bezelLeft - bezelRight - indentSize + 20 + bezelLeft);
|
||||||
renderer.drawText(UI_10_FONT_ID, indentSize, 60 + bezelTop + (tocIndex % pageItems) * 30, chapterName.c_str(),
|
renderer.drawText(UI_10_FONT_ID, indentSize, displayY, chapterName.c_str(), !isSelected);
|
||||||
tocIndex != selectorIndex);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const auto labels = mappedInput.mapLabels("« Back", "Select", "Up", "Down");
|
const auto labels = mappedInput.mapLabels("« Back", "Select", "Up", "Down");
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -85,10 +85,10 @@ void TxtReaderActivity::onEnter() {
|
|||||||
renderer.fillRect(boxX, boxY, boxWidth, boxHeight, false);
|
renderer.fillRect(boxX, boxY, boxWidth, boxHeight, false);
|
||||||
renderer.drawRect(boxX + 5, boxY + 5, boxWidth - 10, boxHeight - 10);
|
renderer.drawRect(boxX + 5, boxY + 5, boxWidth - 10, boxHeight - 10);
|
||||||
renderer.drawText(UI_12_FONT_ID, boxX + boxMargin, boxY + boxMargin, "Preparing book... [0%]");
|
renderer.drawText(UI_12_FONT_ID, boxX + boxMargin, boxY + boxMargin, "Preparing book... [0%]");
|
||||||
renderer.displayBuffer(EInkDisplay::FAST_REFRESH);
|
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||||
|
|
||||||
// Generate covers with progress callback
|
// 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;
|
||||||
@@ -99,7 +99,7 @@ void TxtReaderActivity::onEnter() {
|
|||||||
char progressStr[32];
|
char progressStr[32];
|
||||||
snprintf(progressStr, sizeof(progressStr), "Preparing book... [%d%%]", percent);
|
snprintf(progressStr, sizeof(progressStr), "Preparing book... [%d%%]", percent);
|
||||||
renderer.drawText(UI_12_FONT_ID, boxX + boxMargin, boxY + boxMargin, progressStr);
|
renderer.drawText(UI_12_FONT_ID, boxX + boxMargin, boxY + boxMargin, progressStr);
|
||||||
renderer.displayBuffer(EInkDisplay::FAST_REFRESH);
|
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -171,6 +171,14 @@ void TxtReaderActivity::loop() {
|
|||||||
updateRequired = true;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,7 +339,7 @@ void TxtReaderActivity::buildPageIndex() {
|
|||||||
// Fill progress bar
|
// Fill progress bar
|
||||||
const int fillWidth = (barWidth - 2) * progressPercent / 100;
|
const int fillWidth = (barWidth - 2) * progressPercent / 100;
|
||||||
renderer.fillRect(barX + 1, barY + 1, fillWidth, barHeight - 2, true);
|
renderer.fillRect(barX + 1, barY + 1, fillWidth, barHeight - 2, true);
|
||||||
renderer.displayBuffer(EInkDisplay::FAST_REFRESH);
|
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Yield to other tasks periodically
|
// Yield to other tasks periodically
|
||||||
@@ -563,7 +571,7 @@ void TxtReaderActivity::renderPage() {
|
|||||||
renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
|
renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
|
||||||
|
|
||||||
if (pagesUntilFullRefresh <= 1) {
|
if (pagesUntilFullRefresh <= 1) {
|
||||||
renderer.displayBuffer(EInkDisplay::HALF_REFRESH);
|
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
||||||
pagesUntilFullRefresh = SETTINGS.getRefreshFrequency();
|
pagesUntilFullRefresh = SETTINGS.getRefreshFrequency();
|
||||||
} else {
|
} else {
|
||||||
renderer.displayBuffer();
|
renderer.displayBuffer();
|
||||||
@@ -660,8 +668,8 @@ void TxtReaderActivity::saveProgress() const {
|
|||||||
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
|
||||||
@@ -693,8 +701,8 @@ void TxtReaderActivity::loadProgress() {
|
|||||||
// 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);
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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", "", "");
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
#include <HardwareSerial.h>
|
#include <HardwareSerial.h>
|
||||||
#include <SDCardManager.h>
|
#include <SDCardManager.h>
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
#include "MappedInputManager.h"
|
#include "MappedInputManager.h"
|
||||||
#include "activities/home/HomeActivity.h"
|
#include "activities/home/HomeActivity.h"
|
||||||
#include "activities/home/MyLibraryActivity.h"
|
#include "activities/home/MyLibraryActivity.h"
|
||||||
@@ -19,6 +22,7 @@ void ClearCacheActivity::onEnter() {
|
|||||||
|
|
||||||
renderingMutex = xSemaphoreCreateMutex();
|
renderingMutex = xSemaphoreCreateMutex();
|
||||||
state = WARNING;
|
state = WARNING;
|
||||||
|
preserveProgress = true; // Default to preserving progress
|
||||||
updateRequired = true;
|
updateRequired = true;
|
||||||
|
|
||||||
xTaskCreate(&ClearCacheActivity::taskTrampoline, "ClearCacheActivityTask",
|
xTaskCreate(&ClearCacheActivity::taskTrampoline, "ClearCacheActivityTask",
|
||||||
@@ -56,6 +60,7 @@ void ClearCacheActivity::displayTaskLoop() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void ClearCacheActivity::render() {
|
void ClearCacheActivity::render() {
|
||||||
|
const auto pageWidth = renderer.getScreenWidth();
|
||||||
const auto pageHeight = renderer.getScreenHeight();
|
const auto pageHeight = renderer.getScreenHeight();
|
||||||
|
|
||||||
// Bezel compensation
|
// Bezel compensation
|
||||||
@@ -67,11 +72,32 @@ void ClearCacheActivity::render() {
|
|||||||
renderer.drawCenteredText(UI_12_FONT_ID, 15 + bezelTop, "Clear Cache", true, EpdFontFamily::BOLD);
|
renderer.drawCenteredText(UI_12_FONT_ID, 15 + bezelTop, "Clear Cache", true, EpdFontFamily::BOLD);
|
||||||
|
|
||||||
if (state == WARNING) {
|
if (state == WARNING) {
|
||||||
renderer.drawCenteredText(UI_10_FONT_ID, centerY - 60, "This will clear all cached book data.", true);
|
renderer.drawCenteredText(UI_10_FONT_ID, centerY - 70, "This will clear all cached book data.", true);
|
||||||
renderer.drawCenteredText(UI_10_FONT_ID, centerY - 30, "All reading progress will be lost!", true,
|
renderer.drawCenteredText(UI_10_FONT_ID, centerY - 45, "Books will need to be re-indexed.", true);
|
||||||
EpdFontFamily::BOLD);
|
|
||||||
renderer.drawCenteredText(UI_10_FONT_ID, centerY + 10, "Books will need to be re-indexed", true);
|
// Preserve progress option
|
||||||
renderer.drawCenteredText(UI_10_FONT_ID, centerY + 30, "when opened again.", true);
|
renderer.drawCenteredText(UI_10_FONT_ID, centerY - 5, "Preserve reading progress?");
|
||||||
|
|
||||||
|
// Yes/No options
|
||||||
|
constexpr int optionLineHeight = 30;
|
||||||
|
constexpr int optionWidth = 80;
|
||||||
|
const int optionX = (pageWidth - optionWidth) / 2;
|
||||||
|
const int optionStartY = centerY + 25;
|
||||||
|
|
||||||
|
// Yes option
|
||||||
|
if (preserveProgress) {
|
||||||
|
renderer.fillRect(optionX - 10, optionStartY - 5, optionWidth + 20, optionLineHeight);
|
||||||
|
}
|
||||||
|
renderer.drawCenteredText(UI_10_FONT_ID, optionStartY, "Yes", !preserveProgress);
|
||||||
|
|
||||||
|
// No option
|
||||||
|
if (!preserveProgress) {
|
||||||
|
renderer.fillRect(optionX - 10, optionStartY + optionLineHeight - 5, optionWidth + 20, optionLineHeight);
|
||||||
|
}
|
||||||
|
renderer.drawCenteredText(UI_10_FONT_ID, optionStartY + optionLineHeight, "No", preserveProgress);
|
||||||
|
|
||||||
|
// Draw side button hints (up/down navigation)
|
||||||
|
renderer.drawSideButtonHints(UI_10_FONT_ID, ">", "<");
|
||||||
|
|
||||||
const auto labels = mappedInput.mapLabels("« Cancel", "Clear", "", "");
|
const auto labels = mappedInput.mapLabels("« Cancel", "Clear", "", "");
|
||||||
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);
|
||||||
@@ -110,8 +136,68 @@ void ClearCacheActivity::render() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void ClearCacheActivity::clearCacheDirectory(const char* dirPath) {
|
||||||
|
// Helper to check if a file should be preserved
|
||||||
|
const auto shouldPreserve = [this](const char* name) {
|
||||||
|
if (!preserveProgress) return false;
|
||||||
|
// Preserve progress and bookmarks when preserveProgress is enabled
|
||||||
|
return (strcmp(name, "progress.bin") == 0 || strcmp(name, "bookmarks.bin") == 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
FsFile dir = SdMan.open(dirPath);
|
||||||
|
if (!dir || !dir.isDirectory()) {
|
||||||
|
if (dir) dir.close();
|
||||||
|
failedCount++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
char name[128];
|
||||||
|
std::vector<std::string> filesToDelete;
|
||||||
|
std::vector<std::string> dirsToDelete;
|
||||||
|
|
||||||
|
// First pass: collect files and directories to delete
|
||||||
|
for (FsFile entry = dir.openNextFile(); entry; entry = dir.openNextFile()) {
|
||||||
|
entry.getName(name, sizeof(name));
|
||||||
|
const bool isDir = entry.isDirectory();
|
||||||
|
entry.close();
|
||||||
|
|
||||||
|
std::string fullPath = std::string(dirPath) + "/" + name;
|
||||||
|
if (isDir) {
|
||||||
|
dirsToDelete.push_back(fullPath);
|
||||||
|
} else if (!shouldPreserve(name)) {
|
||||||
|
filesToDelete.push_back(fullPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dir.close();
|
||||||
|
|
||||||
|
// Delete files
|
||||||
|
for (const auto& path : filesToDelete) {
|
||||||
|
if (SdMan.remove(path.c_str())) {
|
||||||
|
clearedCount++;
|
||||||
|
} else {
|
||||||
|
Serial.printf("[%lu] [CLEAR_CACHE] Failed to delete file: %s\n", millis(), path.c_str());
|
||||||
|
failedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete subdirectories (like "sections/")
|
||||||
|
for (const auto& path : dirsToDelete) {
|
||||||
|
if (SdMan.removeDir(path.c_str())) {
|
||||||
|
clearedCount++;
|
||||||
|
} else {
|
||||||
|
Serial.printf("[%lu] [CLEAR_CACHE] Failed to delete dir: %s\n", millis(), path.c_str());
|
||||||
|
failedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not preserving progress, try to remove the now-empty directory
|
||||||
|
if (!preserveProgress) {
|
||||||
|
SdMan.rmdir(dirPath); // This will fail if directory is not empty, which is fine
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void ClearCacheActivity::clearCache() {
|
void ClearCacheActivity::clearCache() {
|
||||||
Serial.printf("[%lu] [CLEAR_CACHE] Clearing cache...\n", millis());
|
Serial.printf("[%lu] [CLEAR_CACHE] Clearing cache (preserveProgress=%d)...\n", millis(), preserveProgress);
|
||||||
|
|
||||||
// Open .crosspoint directory
|
// Open .crosspoint directory
|
||||||
auto root = SdMan.open("/.crosspoint");
|
auto root = SdMan.open("/.crosspoint");
|
||||||
@@ -127,35 +213,32 @@ void ClearCacheActivity::clearCache() {
|
|||||||
failedCount = 0;
|
failedCount = 0;
|
||||||
char name[128];
|
char name[128];
|
||||||
|
|
||||||
// Iterate through all entries in the directory
|
// Collect all book cache directories first
|
||||||
|
std::vector<std::string> cacheDirs;
|
||||||
for (auto file = root.openNextFile(); file; file = root.openNextFile()) {
|
for (auto file = root.openNextFile(); file; file = root.openNextFile()) {
|
||||||
file.getName(name, sizeof(name));
|
file.getName(name, sizeof(name));
|
||||||
String itemName(name);
|
String itemName(name);
|
||||||
|
|
||||||
// Only delete directories starting with epub_ or txt_
|
// Only process directories starting with epub_ or txt_
|
||||||
if (file.isDirectory() && (itemName.startsWith("epub_") || itemName.startsWith("txt_"))) {
|
if (file.isDirectory() && (itemName.startsWith("epub_") || itemName.startsWith("txt_"))) {
|
||||||
String fullPath = "/.crosspoint/" + itemName;
|
cacheDirs.push_back("/.crosspoint/" + std::string(name));
|
||||||
Serial.printf("[%lu] [CLEAR_CACHE] Removing cache: %s\n", millis(), fullPath.c_str());
|
|
||||||
|
|
||||||
file.close(); // Close before attempting to delete
|
|
||||||
|
|
||||||
if (SdMan.removeDir(fullPath.c_str())) {
|
|
||||||
clearedCount++;
|
|
||||||
} else {
|
|
||||||
Serial.printf("[%lu] [CLEAR_CACHE] Failed to remove: %s\n", millis(), fullPath.c_str());
|
|
||||||
failedCount++;
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
file.close();
|
file.close();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
root.close();
|
root.close();
|
||||||
|
|
||||||
|
// Now clear each cache directory
|
||||||
|
for (const auto& cacheDir : cacheDirs) {
|
||||||
|
Serial.printf("[%lu] [CLEAR_CACHE] Clearing: %s\n", millis(), cacheDir.c_str());
|
||||||
|
clearCacheDirectory(cacheDir.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
// Also clear in-memory caches since disk cache is gone
|
// Also clear in-memory caches since disk cache is gone
|
||||||
HomeActivity::freeCoverBufferIfAllocated();
|
HomeActivity::freeCoverBufferIfAllocated();
|
||||||
MyLibraryActivity::clearThumbExistsCache();
|
MyLibraryActivity::clearThumbExistsCache();
|
||||||
|
|
||||||
Serial.printf("[%lu] [CLEAR_CACHE] Cache cleared: %d removed, %d failed\n", millis(), clearedCount, failedCount);
|
Serial.printf("[%lu] [CLEAR_CACHE] Cache cleared: %d items removed, %d failed\n", millis(), clearedCount,
|
||||||
|
failedCount);
|
||||||
|
|
||||||
state = SUCCESS;
|
state = SUCCESS;
|
||||||
updateRequired = true;
|
updateRequired = true;
|
||||||
@@ -163,8 +246,17 @@ void ClearCacheActivity::clearCache() {
|
|||||||
|
|
||||||
void ClearCacheActivity::loop() {
|
void ClearCacheActivity::loop() {
|
||||||
if (state == WARNING) {
|
if (state == WARNING) {
|
||||||
|
// Up/Down toggle preserve progress option
|
||||||
|
if (mappedInput.wasPressed(MappedInputManager::Button::Up) ||
|
||||||
|
mappedInput.wasPressed(MappedInputManager::Button::Down)) {
|
||||||
|
preserveProgress = !preserveProgress;
|
||||||
|
updateRequired = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
|
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
|
||||||
Serial.printf("[%lu] [CLEAR_CACHE] User confirmed, starting cache clear\n", millis());
|
Serial.printf("[%lu] [CLEAR_CACHE] User confirmed (preserveProgress=%d), starting cache clear\n", millis(),
|
||||||
|
preserveProgress);
|
||||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||||
state = CLEARING;
|
state = CLEARING;
|
||||||
xSemaphoreGive(renderingMutex);
|
xSemaphoreGive(renderingMutex);
|
||||||
|
|||||||
@@ -29,9 +29,11 @@ class ClearCacheActivity final : public ActivityWithSubactivity {
|
|||||||
|
|
||||||
int clearedCount = 0;
|
int clearedCount = 0;
|
||||||
int failedCount = 0;
|
int failedCount = 0;
|
||||||
|
bool preserveProgress = true; // Whether to keep progress.bin and bookmarks.bin
|
||||||
|
|
||||||
static void taskTrampoline(void* param);
|
static void taskTrampoline(void* param);
|
||||||
[[noreturn]] void displayTaskLoop();
|
[[noreturn]] void displayTaskLoop();
|
||||||
void render();
|
void render();
|
||||||
void clearCache();
|
void clearCache();
|
||||||
|
void clearCacheDirectory(const char* dirPath); // Helper to clear a single book's cache
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ namespace {
|
|||||||
// Visibility condition for bezel edge setting (only show when compensation > 0)
|
// Visibility condition for bezel edge setting (only show when compensation > 0)
|
||||||
bool isBezelCompensationEnabled() { return SETTINGS.bezelCompensation > 0; }
|
bool isBezelCompensationEnabled() { return SETTINGS.bezelCompensation > 0; }
|
||||||
|
|
||||||
constexpr int displaySettingsCount = 9;
|
constexpr int displaySettingsCount = 10;
|
||||||
const SettingInfo displaySettings[displaySettingsCount] = {
|
const SettingInfo displaySettings[displaySettingsCount] = {
|
||||||
// Should match with SLEEP_SCREEN_MODE
|
// Should match with SLEEP_SCREEN_MODE
|
||||||
SettingInfo::Enum("Sleep Screen", &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover", "None"}),
|
SettingInfo::Enum("Sleep Screen", &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover", "None"}),
|
||||||
@@ -28,9 +28,10 @@ const SettingInfo displaySettings[displaySettingsCount] = {
|
|||||||
SettingInfo::Enum("Hide Battery %", &CrossPointSettings::hideBatteryPercentage, {"Never", "In Reader", "Always"}),
|
SettingInfo::Enum("Hide Battery %", &CrossPointSettings::hideBatteryPercentage, {"Never", "In Reader", "Always"}),
|
||||||
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::Enum("Sunlight Fading Fix", &CrossPointSettings::fadingFix, {"OFF", "ON"}),
|
||||||
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 +63,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 +230,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 +242,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
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
#include <EInkDisplay.h>
|
|
||||||
#include <EpdFontFamily.h>
|
#include <EpdFontFamily.h>
|
||||||
|
#include <HalDisplay.h>
|
||||||
|
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <utility>
|
#include <utility>
|
||||||
@@ -10,12 +10,12 @@
|
|||||||
class FullScreenMessageActivity final : public Activity {
|
class FullScreenMessageActivity final : public Activity {
|
||||||
std::string text;
|
std::string text;
|
||||||
EpdFontFamily::Style style;
|
EpdFontFamily::Style style;
|
||||||
EInkDisplay::RefreshMode refreshMode;
|
HalDisplay::RefreshMode refreshMode;
|
||||||
|
|
||||||
public:
|
public:
|
||||||
explicit FullScreenMessageActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string text,
|
explicit FullScreenMessageActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string text,
|
||||||
const EpdFontFamily::Style style = EpdFontFamily::REGULAR,
|
const EpdFontFamily::Style style = EpdFontFamily::REGULAR,
|
||||||
const EInkDisplay::RefreshMode refreshMode = EInkDisplay::FAST_REFRESH)
|
const HalDisplay::RefreshMode refreshMode = HalDisplay::FAST_REFRESH)
|
||||||
: Activity("FullScreenMessage", renderer, mappedInput),
|
: Activity("FullScreenMessage", renderer, mappedInput),
|
||||||
text(std::move(text)),
|
text(std::move(text)),
|
||||||
style(style),
|
style(style),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
#include "KeyboardEntryActivity.h"
|
#include "KeyboardEntryActivity.h"
|
||||||
|
|
||||||
#include "activities/dictionary/DictionaryMargins.h"
|
|
||||||
#include "MappedInputManager.h"
|
#include "MappedInputManager.h"
|
||||||
|
#include "activities/dictionary/DictionaryMargins.h"
|
||||||
#include "fontIds.h"
|
#include "fontIds.h"
|
||||||
|
|
||||||
// Keyboard layouts - lowercase
|
// Keyboard layouts - lowercase
|
||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -128,46 +215,110 @@ void QuickMenuActivity::render() const {
|
|||||||
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)
|
||||||
|
if (isBeingMoved && !isSelected) {
|
||||||
|
renderer.drawRect(marginLeft + 10, itemY - 2, contentWidth - 20, itemHeight - 6);
|
||||||
|
}
|
||||||
|
|
||||||
// Draw menu item text
|
// Last item is always "Edit List Order" (fixed, not in the order array)
|
||||||
const char* itemText = MENU_ITEMS[i];
|
if (i == DISPLAY_ITEM_COUNT - 1) {
|
||||||
// For bookmark item, show different text based on state
|
renderer.drawText(UI_10_FONT_ID, marginLeft + 20, itemY, "- Edit List Order -", !isSelected);
|
||||||
if (i == 1) {
|
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";
|
itemText = isPageBookmarked ? "Remove Bookmark" : "Add Bookmark";
|
||||||
}
|
}
|
||||||
|
|
||||||
renderer.drawText(UI_10_FONT_ID, marginLeft + 20, itemY, itemText, !isSelected);
|
renderer.drawText(UI_10_FONT_ID, marginLeft + 20, itemY, itemText, !isSelected);
|
||||||
renderer.drawText(SMALL_FONT_ID, marginLeft + 20, itemY + 22, descriptions[i], !isSelected);
|
renderer.drawText(SMALL_FONT_ID, marginLeft + 20, itemY + 22, descriptions[actionIndex], !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) {
|
||||||
|
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);
|
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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
ATKINSONHYPERLEGIBLENEXT_18_FONT_ID},
|
||||||
{FERNMICRO_12_FONT_ID, FERNMICRO_14_FONT_ID, FERNMICRO_16_FONT_ID, FERNMICRO_18_FONT_ID},
|
{FERNMICRO_12_FONT_ID, FERNMICRO_14_FONT_ID, FERNMICRO_16_FONT_ID, FERNMICRO_18_FONT_ID},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
223
src/main.cpp
223
src/main.cpp
@@ -1,8 +1,9 @@
|
|||||||
#include <Arduino.h>
|
#include <Arduino.h>
|
||||||
#include <EInkDisplay.h>
|
#include <BitmapHelpers.h>
|
||||||
#include <Epub.h>
|
#include <Epub.h>
|
||||||
#include <GfxRenderer.h>
|
#include <GfxRenderer.h>
|
||||||
#include <InputManager.h>
|
#include <HalDisplay.h>
|
||||||
|
#include <HalGPIO.h>
|
||||||
#include <SDCardManager.h>
|
#include <SDCardManager.h>
|
||||||
#include <SPI.h>
|
#include <SPI.h>
|
||||||
#include <builtinFonts/all.h>
|
#include <builtinFonts/all.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"
|
||||||
@@ -33,23 +32,10 @@
|
|||||||
#include "fontIds.h"
|
#include "fontIds.h"
|
||||||
#include "images/LockIcon.h"
|
#include "images/LockIcon.h"
|
||||||
|
|
||||||
#define SPI_FQ 40000000
|
HalDisplay display;
|
||||||
// Display SPI pins (custom pins for XteinkX4, not hardware SPI defaults)
|
HalGPIO gpio;
|
||||||
#define EPD_SCLK 8 // SPI Clock
|
MappedInputManager mappedInputManager(gpio);
|
||||||
#define EPD_MOSI 10 // SPI MOSI (Master Out Slave In)
|
GfxRenderer renderer(display);
|
||||||
#define EPD_CS 21 // Chip Select
|
|
||||||
#define EPD_DC 4 // Data/Command
|
|
||||||
#define EPD_RST 5 // Reset
|
|
||||||
#define EPD_BUSY 6 // Busy
|
|
||||||
|
|
||||||
#define UART0_RXD 20 // Used for USB connection detection
|
|
||||||
|
|
||||||
#define SD_SPI_MISO 7
|
|
||||||
|
|
||||||
EInkDisplay einkDisplay(EPD_SCLK, EPD_MOSI, EPD_CS, EPD_DC, EPD_RST, EPD_BUSY);
|
|
||||||
InputManager inputManager;
|
|
||||||
MappedInputManager mappedInputManager(inputManager);
|
|
||||||
GfxRenderer renderer(einkDisplay);
|
|
||||||
Activity* currentActivity;
|
Activity* currentActivity;
|
||||||
|
|
||||||
// Fonts
|
// Fonts
|
||||||
@@ -123,21 +109,22 @@ 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
|
||||||
#define logMemoryState(tag, context) ((void)0)
|
#define logMemoryState(tag, context) ((void)0)
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// Flash command detection - receives "FLASH\n" from pre_flash.py script
|
// Flash command detection - receives "FLASH:version\n" from pre_flash.py script
|
||||||
|
// Plan A: Simple polling - host sends command, device checks when Serial is connected
|
||||||
static String flashCmdBuffer;
|
static String flashCmdBuffer;
|
||||||
|
|
||||||
void checkForFlashCommand() {
|
void checkForFlashCommand() {
|
||||||
|
// Only check when Serial is connected (host has port open)
|
||||||
|
if (!Serial) return;
|
||||||
|
|
||||||
while (Serial.available()) {
|
while (Serial.available()) {
|
||||||
char c = Serial.read();
|
char c = Serial.read();
|
||||||
if (c == '\n') {
|
if (c == '\n') {
|
||||||
@@ -168,78 +155,65 @@ void checkForFlashCommand() {
|
|||||||
const int screenH = renderer.getScreenHeight();
|
const int screenH = renderer.getScreenHeight();
|
||||||
|
|
||||||
// Show current version in bottom-left corner (orientation-aware)
|
// Show current version in bottom-left corner (orientation-aware)
|
||||||
// "Bottom-left" is relative to the current orientation
|
|
||||||
constexpr int versionMargin = 10;
|
constexpr int versionMargin = 10;
|
||||||
const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, CROSSPOINT_VERSION);
|
const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, CROSSPOINT_VERSION);
|
||||||
int versionX, versionY;
|
int versionX, versionY;
|
||||||
switch (renderer.getOrientation()) {
|
switch (renderer.getOrientation()) {
|
||||||
case GfxRenderer::Portrait: // Bottom-left is actual bottom-left
|
case GfxRenderer::Portrait:
|
||||||
versionX = versionMargin;
|
versionX = versionMargin;
|
||||||
versionY = screenH - 30;
|
versionY = screenH - 30;
|
||||||
break;
|
break;
|
||||||
case GfxRenderer::PortraitInverted: // Bottom-left is actual top-right
|
case GfxRenderer::PortraitInverted:
|
||||||
versionX = screenW - textWidth - versionMargin;
|
versionX = screenW - textWidth - versionMargin;
|
||||||
versionY = 20;
|
versionY = 20;
|
||||||
break;
|
break;
|
||||||
case GfxRenderer::LandscapeClockwise: // Bottom-left is actual bottom-right
|
case GfxRenderer::LandscapeClockwise:
|
||||||
versionX = screenW - textWidth - versionMargin;
|
versionX = screenW - textWidth - versionMargin;
|
||||||
versionY = screenH - 30;
|
versionY = screenH - 30;
|
||||||
break;
|
break;
|
||||||
case GfxRenderer::LandscapeCounterClockwise: // Bottom-left is actual bottom-left
|
case GfxRenderer::LandscapeCounterClockwise:
|
||||||
versionX = versionMargin;
|
versionX = versionMargin;
|
||||||
versionY = screenH - 30;
|
versionY = screenH - 30;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
renderer.drawText(SMALL_FONT_ID, versionX, versionY, CROSSPOINT_VERSION, false);
|
renderer.drawText(SMALL_FONT_ID, versionX, versionY, CROSSPOINT_VERSION, false);
|
||||||
|
|
||||||
// Position and rotate lock icon based on current orientation (USB port location)
|
// Position and rotate lock icon based on current orientation
|
||||||
// USB port locations: Portrait=bottom-left, PortraitInverted=top-right,
|
constexpr int edgeMargin = 28;
|
||||||
// LandscapeCW=top-left, LandscapeCCW=bottom-right
|
constexpr int halfWidth = LOCK_ICON_WIDTH / 2;
|
||||||
// Position offsets: edge margin + half-width offset to center on USB port
|
|
||||||
constexpr int edgeMargin = 28; // Distance from screen edge
|
|
||||||
constexpr int halfWidth = LOCK_ICON_WIDTH / 2; // 16px offset for centering
|
|
||||||
int iconX, iconY;
|
int iconX, iconY;
|
||||||
GfxRenderer::ImageRotation rotation;
|
GfxRenderer::ImageRotation rotation;
|
||||||
int outW, outH; // Note: 90/270 rotation swaps output dimensions
|
|
||||||
switch (renderer.getOrientation()) {
|
switch (renderer.getOrientation()) {
|
||||||
case GfxRenderer::Portrait: // USB at bottom-left, shackle points right
|
case GfxRenderer::Portrait:
|
||||||
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:
|
||||||
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:
|
||||||
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:
|
||||||
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);
|
||||||
|
|
||||||
renderer.displayBuffer(EInkDisplay::FAST_REFRESH);
|
// Use full refresh for clean display before flash overwrites firmware
|
||||||
|
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
||||||
}
|
}
|
||||||
flashCmdBuffer = "";
|
flashCmdBuffer = "";
|
||||||
} else if (c != '\r') {
|
} else if (c != '\r') {
|
||||||
flashCmdBuffer += c;
|
flashCmdBuffer += c;
|
||||||
// Prevent buffer overflow from random serial data (increased for version info)
|
if (flashCmdBuffer.length() > 50) {
|
||||||
if (flashCmdBuffer.length() > 30) {
|
|
||||||
flashCmdBuffer = "";
|
flashCmdBuffer = "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -285,21 +259,20 @@ void verifyPowerButtonDuration() {
|
|||||||
const uint16_t calibratedPressDuration =
|
const uint16_t calibratedPressDuration =
|
||||||
(calibration < SETTINGS.getPowerButtonDuration()) ? SETTINGS.getPowerButtonDuration() - calibration : 1;
|
(calibration < SETTINGS.getPowerButtonDuration()) ? SETTINGS.getPowerButtonDuration() - calibration : 1;
|
||||||
|
|
||||||
inputManager.update();
|
gpio.update();
|
||||||
// Verify the user has actually pressed
|
|
||||||
// Needed because inputManager.isPressed() may take up to ~500ms to return the correct state
|
// Needed because inputManager.isPressed() may take up to ~500ms to return the correct state
|
||||||
while (!inputManager.isPressed(InputManager::BTN_POWER) && millis() - start < 1000) {
|
while (!gpio.isPressed(HalGPIO::BTN_POWER) && millis() - start < 1000) {
|
||||||
delay(10); // only wait 10ms each iteration to not delay too much in case of short configured duration.
|
delay(10); // only wait 10ms each iteration to not delay too much in case of short configured duration.
|
||||||
inputManager.update();
|
gpio.update();
|
||||||
}
|
}
|
||||||
|
|
||||||
t2 = millis();
|
t2 = millis();
|
||||||
if (inputManager.isPressed(InputManager::BTN_POWER)) {
|
if (gpio.isPressed(HalGPIO::BTN_POWER)) {
|
||||||
do {
|
do {
|
||||||
delay(10);
|
delay(10);
|
||||||
inputManager.update();
|
gpio.update();
|
||||||
} while (inputManager.isPressed(InputManager::BTN_POWER) && inputManager.getHeldTime() < calibratedPressDuration);
|
} while (gpio.isPressed(HalGPIO::BTN_POWER) && gpio.getHeldTime() < calibratedPressDuration);
|
||||||
abort = inputManager.getHeldTime() < calibratedPressDuration;
|
abort = gpio.getHeldTime() < calibratedPressDuration;
|
||||||
} else {
|
} else {
|
||||||
abort = true;
|
abort = true;
|
||||||
}
|
}
|
||||||
@@ -307,16 +280,15 @@ void verifyPowerButtonDuration() {
|
|||||||
if (abort) {
|
if (abort) {
|
||||||
// Button released too early. Returning to sleep.
|
// Button released too early. Returning to sleep.
|
||||||
// IMPORTANT: Re-arm the wakeup trigger before sleeping again
|
// IMPORTANT: Re-arm the wakeup trigger before sleeping again
|
||||||
esp_deep_sleep_enable_gpio_wakeup(1ULL << InputManager::POWER_BUTTON_PIN, ESP_GPIO_WAKEUP_GPIO_LOW);
|
gpio.startDeepSleep();
|
||||||
esp_deep_sleep_start();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void waitForPowerRelease() {
|
void waitForPowerRelease() {
|
||||||
inputManager.update();
|
gpio.update();
|
||||||
while (inputManager.isPressed(InputManager::BTN_POWER)) {
|
while (gpio.isPressed(HalGPIO::BTN_POWER)) {
|
||||||
delay(50);
|
delay(50);
|
||||||
inputManager.update();
|
gpio.update();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -325,14 +297,11 @@ void enterDeepSleep() {
|
|||||||
exitActivity();
|
exitActivity();
|
||||||
enterNewActivity(new SleepActivity(renderer, mappedInputManager));
|
enterNewActivity(new SleepActivity(renderer, mappedInputManager));
|
||||||
|
|
||||||
einkDisplay.deepSleep();
|
display.deepSleep();
|
||||||
Serial.printf("[%lu] [ ] Power button press calibration value: %lu ms\n", millis(), t2 - t1);
|
Serial.printf("[%lu] [ ] Power button press calibration value: %lu ms\n", millis(), t2 - t1);
|
||||||
Serial.printf("[%lu] [ ] Entering deep sleep.\n", millis());
|
Serial.printf("[%lu] [ ] Entering deep sleep.\n", millis());
|
||||||
esp_deep_sleep_enable_gpio_wakeup(1ULL << InputManager::POWER_BUTTON_PIN, ESP_GPIO_WAKEUP_GPIO_LOW);
|
|
||||||
// Ensure that the power button has been released to avoid immediately turning back on if you're holding it
|
gpio.startDeepSleep();
|
||||||
waitForPowerRelease();
|
|
||||||
// Enter Deep Sleep
|
|
||||||
esp_deep_sleep_start();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void onGoHome();
|
void onGoHome();
|
||||||
@@ -342,9 +311,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 +327,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 +340,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 +361,16 @@ 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();
|
||||||
|
HomeActivity::freeCoverBufferIfAllocated(); // Free 48KB cover buffer
|
||||||
|
Serial.printf("[%lu] [FT] Cleared non-essential memory before File Transfer\n", millis());
|
||||||
|
|
||||||
enterNewActivity(new CrossPointWebServerActivity(renderer, mappedInputManager, onGoHome));
|
enterNewActivity(new CrossPointWebServerActivity(renderer, mappedInputManager, onGoHome));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -410,12 +386,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,12 +415,18 @@ 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));
|
||||||
}
|
}
|
||||||
|
|
||||||
void setupDisplayAndFonts() {
|
void setupDisplayAndFonts() {
|
||||||
einkDisplay.begin();
|
display.begin();
|
||||||
Serial.printf("[%lu] [ ] Display initialized\n", millis());
|
Serial.printf("[%lu] [ ] Display initialized\n", millis());
|
||||||
renderer.insertFont(BOOKERLY_14_FONT_ID, bookerly14FontFamily);
|
renderer.insertFont(BOOKERLY_14_FONT_ID, bookerly14FontFamily);
|
||||||
#ifndef OMIT_FONTS
|
#ifndef OMIT_FONTS
|
||||||
@@ -452,42 +448,24 @@ void setupDisplayAndFonts() {
|
|||||||
Serial.printf("[%lu] [ ] Fonts setup\n", millis());
|
Serial.printf("[%lu] [ ] Fonts setup\n", millis());
|
||||||
}
|
}
|
||||||
|
|
||||||
bool isUsbConnected() {
|
|
||||||
// U0RXD/GPIO20 reads HIGH when USB is connected
|
|
||||||
return digitalRead(UART0_RXD) == HIGH;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool isWakeupByPowerButton() {
|
|
||||||
const auto wakeupCause = esp_sleep_get_wakeup_cause();
|
|
||||||
const auto resetReason = esp_reset_reason();
|
|
||||||
if (isUsbConnected()) {
|
|
||||||
return wakeupCause == ESP_SLEEP_WAKEUP_GPIO;
|
|
||||||
} else {
|
|
||||||
return (wakeupCause == ESP_SLEEP_WAKEUP_UNDEFINED) && (resetReason == ESP_RST_POWERON);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void setup() {
|
void setup() {
|
||||||
t1 = millis();
|
t1 = millis();
|
||||||
|
|
||||||
// Only start serial if USB connected
|
gpio.begin();
|
||||||
pinMode(UART0_RXD, INPUT);
|
|
||||||
if (isUsbConnected()) {
|
// Always initialize Serial - safe on ESP32-C3 USB CDC even without USB connected
|
||||||
|
// (the peripheral just remains idle).
|
||||||
Serial.begin(115200);
|
Serial.begin(115200);
|
||||||
// Wait up to 3 seconds for Serial to be ready to catch early logs
|
|
||||||
|
// Only wait for terminal connection if USB is physically connected
|
||||||
|
// This allows catching early debug logs when a serial monitor is attached
|
||||||
|
if (gpio.isUsbConnected()) {
|
||||||
unsigned long start = millis();
|
unsigned long start = millis();
|
||||||
while (!Serial && (millis() - start) < 3000) {
|
while (!Serial && (millis() - start) < 3000) {
|
||||||
delay(10);
|
delay(10);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
inputManager.begin();
|
|
||||||
// Initialize pins
|
|
||||||
pinMode(BAT_GPIO0, INPUT);
|
|
||||||
|
|
||||||
// Initialize SPI with custom pins
|
|
||||||
SPI.begin(EPD_SCLK, SD_SPI_MISO, EPD_MOSI, EPD_CS);
|
|
||||||
|
|
||||||
// SD Card Initialization
|
// SD Card Initialization
|
||||||
// We need 6 open files concurrently when parsing a new chapter
|
// We need 6 open files concurrently when parsing a new chapter
|
||||||
if (!SdMan.begin()) {
|
if (!SdMan.begin()) {
|
||||||
@@ -504,7 +482,7 @@ void setup() {
|
|||||||
// Apply bezel compensation from settings
|
// Apply bezel compensation from settings
|
||||||
renderer.setBezelCompensation(SETTINGS.bezelCompensation, SETTINGS.bezelCompensationEdge);
|
renderer.setBezelCompensation(SETTINGS.bezelCompensation, SETTINGS.bezelCompensationEdge);
|
||||||
|
|
||||||
if (isWakeupByPowerButton()) {
|
if (gpio.isWakeupByPowerButton()) {
|
||||||
// For normal wakeups, verify power button press duration
|
// For normal wakeups, verify power button press duration
|
||||||
Serial.printf("[%lu] [ ] Verifying power button press duration\n", millis());
|
Serial.printf("[%lu] [ ] Verifying power button press duration\n", millis());
|
||||||
verifyPowerButtonDuration();
|
verifyPowerButtonDuration();
|
||||||
@@ -544,7 +522,9 @@ void loop() {
|
|||||||
const unsigned long loopStartTime = millis();
|
const unsigned long loopStartTime = millis();
|
||||||
static unsigned long lastMemPrint = 0;
|
static unsigned long lastMemPrint = 0;
|
||||||
|
|
||||||
inputManager.update();
|
gpio.update();
|
||||||
|
|
||||||
|
renderer.setFadingFix(SETTINGS.fadingFix);
|
||||||
|
|
||||||
if (Serial && millis() - lastMemPrint >= 10000) {
|
if (Serial && millis() - lastMemPrint >= 10000) {
|
||||||
// Basic heap info
|
// Basic heap info
|
||||||
@@ -555,16 +535,14 @@ void loop() {
|
|||||||
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for any user activity (button press or release) or active background work
|
// Check for any user activity (button press or release) or active background work
|
||||||
static unsigned long lastActivityTime = millis();
|
static unsigned long lastActivityTime = millis();
|
||||||
if (inputManager.wasAnyPressed() || inputManager.wasAnyReleased() ||
|
if (gpio.wasAnyPressed() || gpio.wasAnyReleased() || (currentActivity && currentActivity->preventAutoSleep())) {
|
||||||
(currentActivity && currentActivity->preventAutoSleep())) {
|
|
||||||
lastActivityTime = millis(); // Reset inactivity timer
|
lastActivityTime = millis(); // Reset inactivity timer
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -576,8 +554,7 @@ void loop() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (inputManager.isPressed(InputManager::BTN_POWER) &&
|
if (gpio.isPressed(HalGPIO::BTN_POWER) && gpio.getHeldTime() > SETTINGS.getPowerButtonDuration()) {
|
||||||
inputManager.getHeldTime() > SETTINGS.getPowerButtonDuration()) {
|
|
||||||
enterDeepSleep();
|
enterDeepSleep();
|
||||||
// This should never be hit as `enterDeepSleep` calls esp_deep_sleep_start
|
// This should never be hit as `enterDeepSleep` calls esp_deep_sleep_start
|
||||||
return;
|
return;
|
||||||
@@ -592,7 +569,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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -371,27 +371,10 @@ void CrossPointWebServer::scanFiles(const char* path, const std::function<void(F
|
|||||||
if (info.isDirectory) {
|
if (info.isDirectory) {
|
||||||
info.size = 0;
|
info.size = 0;
|
||||||
info.isEpub = false;
|
info.isEpub = false;
|
||||||
// md5 remains empty for directories
|
|
||||||
} else {
|
} else {
|
||||||
info.size = file.size();
|
info.size = file.size();
|
||||||
info.isEpub = isEpubFile(info.name);
|
info.isEpub = isEpubFile(info.name);
|
||||||
|
// MD5 not included in listing - clients can request via /api/hash endpoint
|
||||||
// For EPUBs, try to get cached MD5 hash
|
|
||||||
if (info.isEpub) {
|
|
||||||
// Build full file path
|
|
||||||
String fullPath = String(path);
|
|
||||||
if (!fullPath.endsWith("/")) {
|
|
||||||
fullPath += "/";
|
|
||||||
}
|
|
||||||
fullPath += fileName;
|
|
||||||
|
|
||||||
const std::string cachedMd5 =
|
|
||||||
Md5Utils::getCachedMd5(fullPath.c_str(), BookManager::CROSSPOINT_DIR, info.size);
|
|
||||||
if (!cachedMd5.empty()) {
|
|
||||||
info.md5 = String(cachedMd5.c_str());
|
|
||||||
}
|
|
||||||
// If not cached, md5 remains empty (companion app can request via /api/hash)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
callback(info);
|
callback(info);
|
||||||
@@ -435,55 +418,69 @@ void CrossPointWebServer::handleFileListData() const {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we should show hidden files
|
// Check if we should show hidden files (fork addition)
|
||||||
bool showHidden = false;
|
bool showHidden = server->hasArg("showHidden") && server->arg("showHidden") == "true";
|
||||||
if (server->hasArg("showHidden")) {
|
|
||||||
showHidden = server->arg("showHidden") == "true";
|
|
||||||
}
|
|
||||||
|
|
||||||
server->setContentLength(CONTENT_LENGTH_UNKNOWN);
|
server->setContentLength(CONTENT_LENGTH_UNKNOWN);
|
||||||
server->send(200, "application/json", "");
|
server->send(200, "application/json", "");
|
||||||
server->sendContent("[");
|
|
||||||
char output[512];
|
// Batch JSON entries to reduce number of sendContent calls
|
||||||
|
// This helps prevent TCP buffer overflow on memory-constrained systems
|
||||||
|
constexpr size_t BATCH_SIZE = 2048;
|
||||||
|
char batch[BATCH_SIZE];
|
||||||
|
size_t batchPos = 0;
|
||||||
|
batch[batchPos++] = '[';
|
||||||
|
|
||||||
|
char output[256]; // Single entry buffer (reduced from 512)
|
||||||
constexpr size_t outputSize = sizeof(output);
|
constexpr size_t outputSize = sizeof(output);
|
||||||
bool seenFirst = false;
|
bool seenFirst = false;
|
||||||
JsonDocument doc;
|
JsonDocument doc;
|
||||||
|
|
||||||
scanFiles(
|
scanFiles(
|
||||||
currentPath.c_str(),
|
currentPath.c_str(),
|
||||||
[this, &output, &doc, seenFirst](const FileInfo& info) mutable {
|
[this, &batch, &batchPos, &output, &doc, &seenFirst](const FileInfo& info) mutable {
|
||||||
doc.clear();
|
doc.clear();
|
||||||
doc["name"] = info.name;
|
doc["name"] = info.name;
|
||||||
doc["size"] = info.size;
|
doc["size"] = info.size;
|
||||||
doc["isDirectory"] = info.isDirectory;
|
doc["isDirectory"] = info.isDirectory;
|
||||||
doc["isEpub"] = info.isEpub;
|
doc["isEpub"] = info.isEpub;
|
||||||
|
|
||||||
// Include md5 field for EPUBs (null if not cached, hash string if available)
|
|
||||||
if (info.isEpub) {
|
|
||||||
if (info.md5.isEmpty()) {
|
|
||||||
doc["md5"] = nullptr; // JSON null
|
|
||||||
} else {
|
|
||||||
doc["md5"] = info.md5;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const size_t written = serializeJson(doc, output, outputSize);
|
const size_t written = serializeJson(doc, output, outputSize);
|
||||||
if (written >= outputSize) {
|
if (written >= outputSize) {
|
||||||
// JSON output truncated; skip this entry to avoid sending malformed JSON
|
|
||||||
Serial.printf("[%lu] [WEB] Skipping file entry with oversized JSON for name: %s\n", millis(),
|
Serial.printf("[%lu] [WEB] Skipping file entry with oversized JSON for name: %s\n", millis(),
|
||||||
info.name.c_str());
|
info.name.c_str());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Calculate space needed: comma (if not first) + entry
|
||||||
|
const size_t needed = (seenFirst ? 1 : 0) + written;
|
||||||
|
|
||||||
|
// If batch would overflow, send it first
|
||||||
|
if (batchPos + needed >= BATCH_SIZE - 1) {
|
||||||
|
batch[batchPos] = '\0';
|
||||||
|
server->sendContent(batch);
|
||||||
|
delay(5); // Brief delay between batch sends
|
||||||
|
batchPos = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add comma separator if not first entry
|
||||||
if (seenFirst) {
|
if (seenFirst) {
|
||||||
server->sendContent(",");
|
batch[batchPos++] = ',';
|
||||||
} else {
|
} else {
|
||||||
seenFirst = true;
|
seenFirst = true;
|
||||||
}
|
}
|
||||||
server->sendContent(output);
|
|
||||||
|
// Copy entry to batch
|
||||||
|
memcpy(batch + batchPos, output, written);
|
||||||
|
batchPos += written;
|
||||||
},
|
},
|
||||||
showHidden);
|
showHidden);
|
||||||
server->sendContent("]");
|
|
||||||
|
// Send remaining batch with closing bracket
|
||||||
|
batch[batchPos++] = ']';
|
||||||
|
batch[batchPos] = '\0';
|
||||||
|
server->sendContent(batch);
|
||||||
|
|
||||||
// End of streamed response, empty chunk to signal client
|
// End of streamed response, empty chunk to signal client
|
||||||
server->sendContent("");
|
server->sendContent("");
|
||||||
Serial.printf("[%lu] [WEB] Served file listing page for path: %s\n", millis(), currentPath.c_str());
|
Serial.printf("[%lu] [WEB] Served file listing page for path: %s\n", millis(), currentPath.c_str());
|
||||||
@@ -1264,6 +1261,16 @@ void CrossPointWebServer::handleRename() const {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool CrossPointWebServer::sendContentSafe(const char* content) const {
|
||||||
|
if (!server || !server->client().connected()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
server->sendContent(content);
|
||||||
|
return server->client().connected();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CrossPointWebServer::sendContentSafe(const String& content) const { return sendContentSafe(content.c_str()); }
|
||||||
|
|
||||||
bool CrossPointWebServer::copyFile(const String& srcPath, const String& destPath) const {
|
bool CrossPointWebServer::copyFile(const String& srcPath, const String& destPath) const {
|
||||||
FsFile srcFile;
|
FsFile srcFile;
|
||||||
FsFile destFile;
|
FsFile destFile;
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ struct FileInfo {
|
|||||||
size_t size;
|
size_t size;
|
||||||
bool isEpub;
|
bool isEpub;
|
||||||
bool isDirectory;
|
bool isDirectory;
|
||||||
String md5; // MD5 hash for EPUBs (empty if not cached/available)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
class CrossPointWebServer {
|
class CrossPointWebServer {
|
||||||
@@ -108,6 +107,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 connection check
|
||||||
|
// 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;
|
||||||
|
|||||||
Reference in New Issue
Block a user