51 Commits

Author SHA1 Message Date
cottongin
21a75c624d feat: Implement bookmark functionality for epub reader
Replace bookmark stubs with full add/remove/navigate implementation:

- BookmarkStore: per-book binary persistence on SD card with v2 format
  supporting text snippets (backward-compatible with v1)
- Visual bookmark ribbon indicator drawn on bookmarked pages via fillPolygon
- Reader menu dynamically shows Add/Remove Bookmark based on current page state
- Bookmark selection activity with chapter name, first sentence snippet, and
  page number display; long-press to delete with confirmation
- Go to Bookmark falls back to Table of Contents when no bookmarks exist
- Smart snippet extraction: skips partial sentences (lowercase first word)
  to capture the first full sentence on the page
- Label truncation reserves space for page suffix so it's never cut off
- Half refresh forced on menu exit to clear popup/menu artifacts

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 20:40:07 -05:00
cottongin
8d4bbf284d feat: Add dictionary word lookup feature with cached index
Implements StarDict-based dictionary lookup from the reader menu,
adapted from upstream PR #857 with /.dictionary/ folder path,
std::vector compatibility (PR #802), HTML definition rendering,
orientation-aware button hints, side button hints with CCW text
rotation, sparse index caching to SD card, pronunciation line
filtering, and reorganized reader menu with bookmark stubs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 19:36:14 -05:00
cottongin
905f694576 prerender book covers and thumbnails when opening a book for the first time
Moves cover/thumbnail generation from lazy (Home screen, Sleep screen) into
each reader activity's onEnter(). On first open, generates all needed BMPs
(cover, cover_crop, thumbnails for all theme heights) with a "Preparing
book..." progress popup. Subsequent opens skip instantly when files exist.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 16:13:55 -05:00
cottongin
e798065a5c merge upstream PR #852: feat: lower CPU freq on idle, add HalPowerManager 2026-02-12 12:09:20 -05:00
cottongin
5e269f912f merge upstream PR #802: perf: Replace std::list with std::vector in text layout 2026-02-12 12:09:10 -05:00
cottongin
182c236050 Merge branch 'master' into mod/master
Resolve single conflict in SleepActivity.cpp: adopt upstream millis()
timestamp log format while preserving mod's edgeCachePath argument to
renderBitmapSleepScreen().

Upstream changes (14 commits): unified navigation handling, Italian
hyphenation, natural file sort, auto WiFi reconnect, power saving on
idle, OPDS fixes, uniform debug logging, and more.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 11:54:40 -05:00
Xuan Son Nguyen
73cd05827a move IDLE_POWER_SAVING_MS 2026-02-12 13:19:37 +01:00
Xuan Son Nguyen
ea32ba0f8d add HalPowerManager 2026-02-12 13:12:13 +01:00
Xuan Son Nguyen
f7b1113819 Merge branch 'master' into xsn/idle_cpu_freq 2026-02-12 11:37:32 +01:00
Xuan Son Nguyen
228a1cb511 rm test 2026-02-12 11:37:12 +01:00
Xuan-Son Nguyen
0991782fb4 feat: more power saving on idle (#801)
Some checks failed
CI (build) / clang-format (push) Has been cancelled
CI (build) / cppcheck (push) Has been cancelled
CI (build) / build (push) Has been cancelled
CI (build) / Test Status (push) Has been cancelled
## Summary

This PR extends the delay in main loop from 10ms to 50ms after the
device is idle for a while. This translates to extended battery life in
a longer period (see testing section above), while not hurting too much
the user experience.

With the help from [this
patch](https://github.com/ngxson/crosspoint-reader/tree/xsn/measure_cpu_usage),
I was able to measure the CPU usage on idle:

```
PR:
[20017] [MEM] Free: 150188 bytes, Total: 232092 bytes, Min Free: 150092 bytes
[20017] [IDLE] Idle time: 99.62% (CPU load: 0.38%)
[30042] [MEM] Free: 150188 bytes, Total: 232092 bytes, Min Free: 150092 bytes
[30042] [IDLE] Idle time: 99.63% (CPU load: 0.37%)
[40067] [MEM] Free: 150188 bytes, Total: 232092 bytes, Min Free: 150092 bytes
[40067] [IDLE] Idle time: 99.62% (CPU load: 0.38%)

master:
[20012] [MEM] Free: 195016 bytes, Total: 231532 bytes, Min Free: 132460 bytes
[20012] [IDLE] Idle time: 98.53% (CPU load: 1.47%)
[30017] [MEM] Free: 195016 bytes, Total: 231532 bytes, Min Free: 132460 bytes
[30017] [IDLE] Idle time: 98.53% (CPU load: 1.47%)
[40022] [MEM] Free: 195016 bytes, Total: 231532 bytes, Min Free: 132460 bytes
[40022] [IDLE] Idle time: 98.53% (CPU load: 1.47%)
```

While this is a x3.8 reduce in CPU usage, it doesn't translate to the
same amount of battery life extension in real life. The reasons are:
1. The CPU is not shut down completely
2. freeRTOS tick is still running (however, I planned to experiment with
tickless functionality)
3. Current leakage to other components, for example: voltage dividers,
eink screen, SD card, etc

A note on
[light-sleep](https://docs.espressif.com/projects/esp-idf/en/stable/esp32c3/api-reference/system/sleep_modes.html)
functionality: it is not possible in our use case because:
- Light-sleep for 50ms introduce too much overhead on wake up, it has
negative effect on battery life
- Light-sleep for longer period doesn't work because the ADC GPIO
buttons cannot be used as wake up source

## Testing (duration = 6 hrs)

To test this, I patched the `CrossPointSettings::getSleepTimeoutMs()` to
always returns a timeout of 6 hrs. This allow me to leave the device
idle for 6 hrs straight.

- On master branch, 6 hrs costs 26% battery life (100% --> 74%), meaning
battery life is ~23 hrs
- With this PR, 6 hrs costs 20% battery life (100% --> 80%), meaning
battery life is ~30 hrs

So in theory, this extends the battery by about 7 hrs. Even with some
error margin added, I think 3 hrs increase is possible with a normal
usage setup (i.e. only read ebooks, no wifi)

## Additional Context

Would appreciate if someone can test this with an oscilloscope.

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? **NO**
2026-02-12 09:49:05 +01:00
jpirnay
3ae1007cbe fix: chore: make all debug messages uniform (#825)
## Summary

* Unify all serial port debug messages

## Additional Context

* All messages sent to the serial port now follow the "[timestamp]
[origin] payload" format (notable exception framework messages)

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? No
2026-02-11 16:25:17 +01:00
Jonas Diemer
efb9b72e64 fix: Show "Back" in file browser if not in root, "Home" otherwise. (#822)
## Summary

Show "Back" in file browser if not in root, "Home" otherwise.

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? YES
2026-02-11 16:44:10 +03:00
Dave Allie
4a210823a8 fix: Manually trigger GPIO update in File Browser mode (#819)
## Summary

* Manually trigger GPIO update in File Browser mode
* Previously just assumed that the GPIO data would update automatically
(presumably via yield), the data is currently updated in the main loop
(and now here as well during the middle of the processing loop).
* This allows the back button to be correctly detected instead of only
being checked once every 100ms or so for the button state.

## Additional Context

* Fixes
https://github.com/crosspoint-reader/crosspoint-reader/issues/579

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? No


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Enhanced input state detection in the web server interface for more
responsive and accurate user command recognition during high-frequency
operations.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-02-11 13:42:37 +03:00
Xuan Son Nguyen
b72283d304 change cpu freq on idle 2026-02-10 23:27:45 +01:00
Jonas Diemer
f5b85f5ca1 fix: Reduce MIN_SIZE_FOR_POPUP to 10KB (#809)
Noticed that the Indexing... popup went missing despite 3-5 seconds
delay. Reducing to 10KB, so we get a popup for delays > ~2s.


### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? no
2026-02-10 16:15:23 +01:00
Xuan Son Nguyen
8cf226613b clang format 2026-02-10 14:19:16 +01:00
Jonas Diemer
7e93411f46 docs: Update USER_GUIDE.md (#817)
Added explanation how to recover from broken config/cache.



### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? no
2026-02-10 23:23:14 +11:00
Dave Allie
44452a42e9 fix: Prevent sleeping when in OPDS browser / downloading books (#818)
## Summary

* Prevent sleeping when in OPDS browser / downloading books

## Additional Context

* Raised in
https://github.com/crosspoint-reader/crosspoint-reader/discussions/673

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? No
2026-02-10 22:56:22 +11:00
jpirnay
0c2df24f5c feat: Extend python debugging monitor functionality (keyword filter / suppress) (#810)
## Summary

* I needed the ability to filter and or suppress debug messages
containig certain keywords (eg [GFX] for render related stuff)
* Update of debugging_monitor.py script for development work

## Additional Context
```
usage: debugging_monitor.py [-h] [--baud BAUD] [--filter FILTER] [--suppress SUPPRESS] [port]

ESP32 Monitor with Graph

positional arguments:
  port                 Serial port

options:
  -h, --help           show this help message and exit
  --baud BAUD          Baud rate
  --filter FILTER      Only display lines containing this keyword (case-insensitive)
  --suppress SUPPRESS  Suppress lines containing this keyword (case-insensitive)
```
* plus a couple of platform specific defaults (port, pip style)
---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? NO
2026-02-10 22:07:56 +11:00
Jonas Diemer
3a12ca2725 docs: Update USER_GUIDE.md (#808)
Added info about optimizing EPUB.

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? no
2026-02-10 22:04:32 +11:00
Xuan Son Nguyen
d4f25c44bf lower to 3 seconds 2026-02-10 11:31:28 +01:00
Eliz
98e6789626 feat: Connect to last wifi by default (#752)
## Summary

* **What is the goal of this PR?** 

Use last connected network as default

* **What changes are included?**

- Refactor how an action type of Settings are handled
- Add a new System Settings option → Network
- Add the ability to forget a network in the Network Selection Screen
- Add the ability to Refresh network list
- Save the last connected network SSID
- Use the last connection whenever network is needed (OPDS, Koreader
sync, update etc)

## Additional Context

* Add any other information that might be helpful for the reviewer
(e.g., performance implications, potential risks,
  specific areas to focus on).


![IMG_6504](https://github.com/user-attachments/assets/e48fb013-b5c3-45c0-b284-e183e6fd5a68)

![IMG_6503](https://github.com/user-attachments/assets/78c4b6b6-4e7b-4656-b356-19d65ff6aa12)




https://github.com/user-attachments/assets/95bf34a8-44ce-4279-8cd8-f78524ce745b





---

### AI Usage

Did you use AI tools to help write this code? _** PARTIALLY: I wrote
most of it but I also used Gemini as assist.

---------

Co-authored-by: Eliz Kilic <elizk@google.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-10 20:41:44 +11:00
ThatCrispyToast
b5d28a3a9c feat: use natural sort in file browser (#722)
## Summary

* **What is the goal of this PR?** (e.g., Implements the new feature for
file uploading.)

Implement natural sort (e.g. "file1.txt, file2.txt, file10.txt" instead
of "file1.txt, file10.txt, file2.txt") for files in the
MyLibraryActivity menu

* **What changes are included?**

Modifies the `sortFileList` function under
`src/activities/home/MyLibraryActivity.cpp` to use natural sort as
opposed to lexicographical sort

## Additional Context

I wasn't entirely sure whether or not i should make this a configurable
option, but most file browsers and directory listing tools have this set
as an immutable default, so I opted against it.

* Add any other information that might be helpful for the reviewer
(e.g., performance implications, potential risks,
  specific areas to focus on).

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? _**NO**_
2026-02-10 01:09:24 +03:00
Kuanysh Bekkulov
bc12556da1 perf: Replace std::list with std::vector in TextBlock and ParsedText
Replace std::list with std::vector for the words, wordStyles,
wordXpos, and wordContinues containers in TextBlock and ParsedText.

Vectors provide contiguous memory layout for better cache locality
and O(1) random access, eliminating per-node heap allocation and
the 16-byte prev/next pointer overhead of doubly-linked list nodes.
The indexed access also removes the need for a separate continuesVec
copy that was previously built from the list for O(1) layout access.
2026-02-09 23:46:08 +05:00
Xuan Son Nguyen
4e7bb8979c revert test 2026-02-09 19:20:36 +01:00
cottongin
4edb14bdd9 feat: Sleep screen letterbox fill and image upscaling
Some checks failed
CI (build) / clang-format (push) Has been cancelled
CI (build) / cppcheck (push) Has been cancelled
CI (build) / build (push) Has been cancelled
CI (build) / Test Status (push) Has been cancelled
Add configurable letterbox fill for sleep screen cover images that don't
match the display aspect ratio. Four fill modes are available: Solid
(single dominant edge shade), Blended (per-pixel edge colors), Gradient
(edge colors interpolated toward white/black), and None.

Enable upscaling of cover images smaller than the display in Fit mode by
modifying drawBitmap/drawBitmap1Bit to support both up and downscaling
via a unified block-fill approach.

Edge sampling data is cached to .crosspoint alongside the cover BMP to
avoid redundant bitmap scanning on subsequent sleeps. Cache is validated
against screen dimensions and auto-regenerated when stale.

New settings: Letterbox Fill (None/Solid/Blended/Gradient) and Gradient
Direction (To White/To Black).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-09 11:52:55 -05:00
Xuan Son Nguyen
eb79b98f2b power saving on idle 2026-02-09 12:45:16 +01:00
harshit181
14ef625679 fix: issue if book href are absolute url and not relative to server (#741)
## Summary

fixing issue if book href are absolute url and not relative to the
server

## Additional Context

* Fixes
https://github.com/crosspoint-reader/crosspoint-reader/issues/632
* https://github.com/harshit181/RSSPub/issues/43

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? _**<  PARTIALLY>**_
2026-02-09 22:12:21 +11:00
Istiak Tridip
64d161e88b feat: unify navigation handling with system-wide continuous navigation (#600)
This PR unifies navigation handling & adds system-wide support for
continuous navigation.

## Summary
Holding down a navigation button now continuously advances through items
until the button is released. This removes the need for repeated
press-and-release actions and makes navigation faster and smoother,
especially in long menus or documents.

When page-based navigation is available, it will navigate through pages.
If not, it will progress through menu items or similar list-based UI
elements.

Additionally, this PR fixes inconsistencies in wrap-around behavior and
navigation index calculations.

Places where the navigation system was updated:
- Home Page
- Settings Pages
- My Library Page
- WiFi Selection Page
- OPDS Browser Page
- Keyboard
- File Transfer Page
- XTC Chapter Selector Page
- EPUB Chapter Selector Page

I’ve tested this on the device as much as possible and tried to match
the existing behavior. Please let me know if I missed anything. Thanks 🙏


![crosspoint](https://github.com/user-attachments/assets/6a3c7482-f45e-4a77-b156-721bb3b679e6)

---

Following the request from @osteotek and @daveallie for system-wide
support, the old PR (#379) has been closed in favor of this
consolidated, system-wide implementation.

---

### AI Usage

Did you use AI tools to help write this code? _**PARTIALLY**_

---------

Co-authored-by: Dave Allie <dave@daveallie.com>
2026-02-09 20:19:34 +11:00
cottongin
a85d5e627b .gitignore tweaks for mod fork 2026-02-09 04:15:00 -05:00
Fabio Barbon
e73bb3213f feat: Add Italian hyphenation support (#584)
## Summary

* **What is the goal of this PR?** Add Italian language hyphenation
support to improve text rendering for Italian books.
* **What changes are included?**

* Added Italian hyphenation trie (hyph-it.trie.h) generated from Typst's
hypher patterns
* Registered italianHyphenator in LanguageRegistry.cpp for language tag
it
  * Added Italian to the hyphenation evaluation test suite
  * Added Italian test data file with 5000 test cases

## Additional Context

* Add any other information that might be helpful for the reviewer
(e.g., performance implications, potential risks,
  specific areas to focus on).

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? _**NO**_

---------

Co-authored-by: drbourbon <fabio@MacBook-Air-di-Fabio.local>
2026-02-09 19:55:58 +11:00
Dave Allie
6202bfd651 Merge branch 'release/1.0.0' 2026-02-09 17:18:24 +11:00
Jake Kenneally
9b04c2ec76 feat: Add percentage support to CSS properties (#738)
## Summary
- Closes #730

**What is the goal of this PR?**
- Adds percentage-based value support to CSS properties that accept
percentages (padding, margin, text-indent)
 
**What changes are included?**
- Adds `Percent` as another CSS unit
- Passes the viewport width to `fromCssStyle` so that we can resolve
percentage-based values
- Adds a fallback of using an emspace for text-indent if we have an
unresolvable value for whatever reason

## Additional Context

- This was missed in my CSS support feature, and the fallback when we
encounter a percentage value is to use px instead. This means 5% (which
would be ~30px on the screen) turns into 5px. When percentages are used
in `text-indent`, this fallback behavior makes the indent look like a
single space character. Whoops! 😬

My test EPUB has been updated
[here](https://github.com/jdk2pq/css-test-epub) with percentage based
CSS values at the end of the book.

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? _**YES**_, Claude Code
2026-02-09 08:31:52 +11:00
Dave Allie
ffddc2472b Use GITHUB_REF_NAME over GITHUB_HEAD_REF in release candidate workflow 2026-02-09 08:22:20 +11:00
Dave Allie
5765bbe821 Add release candidate workflow 2026-02-09 08:16:36 +11:00
Dave Allie
b4b028be3a fix: Allow OTA update from RC build to full release (#778)
## Summary

* Allow OTA update from RC build to full release
* If all the segments match, then also check if the current version
contains "-rc"

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? No
2026-02-09 08:08:19 +11:00
Yaroslav
f34d7d2aac fix(ui): Add Back label in KOReader Sync screen (#770)
## Summary

- Remove duplicate Cancel option 
- Add Back label

<img width="435" height="613" alt="image"
src="https://github.com/user-attachments/assets/a3af4133-46fa-46e6-8360-a15dd7c4fe2a"
/>


## Result

<img width="575" height="431" alt="image"
src="https://github.com/user-attachments/assets/6ccdac89-43df-45bf-bcfa-3a7cc4bd88e4"
/>


---

### AI Usage

Did you use AI tools to help write this code? _**< PARTIALLY >**_

Closes #754
2026-02-09 07:51:51 +11:00
Justin Mitchell
71769490fb fix: Add EPUB 3 cover image detection (#760)
I had an epub that just showed a blank cover and wouldnt work for the
sleep screen either, turns out it was an epub3 and I guess we didn't
support that. Super simple fix here
2026-02-09 07:49:49 +11:00
Jesse Vincent
cda0a3f898 feat: A web editor for settings (#667)
## Summary

This is an updated version of @itsthisjustin's #346 that builds on
current master and also deduplicates the settings list so we don't have
two copies of the settings. In the Web UI, it should organize the
settings a little closer to what you see on device.

## Additional Context

I tested this live on device and it seems to play nicely for me. It's
re-based on master since master's settings stuff has moved somewhat
since the original PR and addresses the sole review comment #346 - it
also means that I don't need to manually key in the URL for my OPDS
server. :)

---

### AI Usage

My changes were implemented with Claude Opus 4.5 and Claude Code 2.1.25.
I don't know if @itsthisjustin's original work used AI assistance.

Co-authored-by: Dave Allie <dave@daveallie.com>
2026-02-09 07:46:14 +11:00
Xuan-Son Nguyen
7f40c3f477 feat: add HalStorage (#656)
## Summary

Continue my changes to introduce the HAL infrastructure from
https://github.com/crosspoint-reader/crosspoint-reader/pull/522

This PR touches quite a lot of files, but most of them are just name
changing. It should not have any impacts to the end behavior.

## Additional Context

My plan is to firstly add this small shim layer, which sounds useless at
first, but then I'll implement an emulated driver which can be helpful
for testing and for development.

Currently, on my fork, I'm using a FS driver that allow "mounting" a
local directory from my computer to the device, much like the `-v` mount
option on docker. This allows me to quickly reset `.crosspoint`
directory if anything goes wrong. I plan to upstream this feature when
this PR get merged.

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? NO
2026-02-09 07:29:14 +11:00
Xuan-Son Nguyen
a87eacc6ab perf: optimize drawPixel() (#748)
## Summary

Ref https://github.com/crosspoint-reader/crosspoint-reader/pull/737

This PR further reduce ~25ms from rendering time, testing inside the
Setting screen:

```
master:
[68440] [GFX] Time = 73 ms from clearScreen to displayBuffer

PR:
[97806] [GFX] Time = 47 ms from clearScreen to displayBuffer
```

And in extreme case (fill the entire screen with black or gray color):

```
master:
[1125] [   ] Test fillRectDither drawn in 327 ms
[1347] [   ] Test fillRect drawn in 222 ms

PR:
[1334] [   ] Test fillRectDither drawn in 225 ms
[1455] [   ] Test fillRect drawn in 121 ms
```

Note that
https://github.com/crosspoint-reader/crosspoint-reader/pull/737 is NOT
applied on top of this PR. But with 2 of them combined, it should reduce
from 47ms --> 42ms

## Details

This PR based on the fact that function calls are costly if the function
is small enough. For example, this simple call:

```
  int rotatedX = 0;
  int rotatedY = 0;
  rotateCoordinates(x, y, &rotatedX, &rotatedY);
```

Generated assembly code:

<img width="771" height="215" alt="image"
src="https://github.com/user-attachments/assets/37991659-3304-41c3-a3b2-fb967da53f82"
/>

This adds ~10 instructions just to prepare the registers prior to the
function call, plus some more instructions for the function's
epilogue/prologue. Inlining it removing all of these:

<img width="1471" height="832" alt="image"
src="https://github.com/user-attachments/assets/b67a22ee-93ba-4017-88ed-c973e28ec914"
/>

Of course, this optimization is not magic. It's only beneficial under 3
conditions:
- The function is small, not in size, but in terms of effective
instructions. For example, the `rotateCoordinates` is simply a jump
table, where each branch is just 3-4 inst
- The function has multiple input arguments, which requires some move to
put it onto the correct place
- The function is called very frequently (i.e. critical path)

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? **NO**
2026-02-09 05:05:42 +11:00
Arthur Tazhitdinov
1caad578fc feat: wakeup target detection (#731)
## Summary

* If going to sleep was from the Reader view, wake up to the same book.
Otherwise, wakeup to the Home view
2026-02-09 05:01:30 +11:00
CaptainFrito
5b90b68e99 fix: Scrolling page items calculation (#716)
## Summary

Fix for the page skip issue detected
https://github.com/crosspoint-reader/crosspoint-reader/pull/700#issuecomment-3856374323
by user @whyte-j

Skipping down on the last page now skips to the last item, and up on the
first page to the first item, rather than wrapping around the list in a
weird way.

## Additional Context

The calculation was outdated after several changes were added afterwards

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? _**NO**_
2026-02-09 04:58:46 +11:00
Jake Kenneally
67ddd60fce refactor: Rename "Embedded Style" to "Book's Embedded Style" (#746)
## Summary

**What is the goal of this PR?**
- Just a simple rename after feedback in #738

**What changes are included?**
- Renamed "Embedded Style" to "Book's Embedded Style" to more clearly
associate it with "Book's Style" option in "Paragraph Alignment"
settings

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? _**NO**_
2026-02-08 20:34:06 +03:00
Xuan-Son Nguyen
76908d38e1 feat: optimize fillRectDither (#737)
## Summary

This PR optimizes the `fillRectDither` function, making it as fast as a
normal `fillRect`

Testing code:

```cpp
  {
    auto start_t = millis();
    renderer.fillRectDither(0, 0, renderer.getScreenWidth(), renderer.getScreenHeight(), Color::LightGray);
    auto elapsed = millis() - start_t;
    Serial.printf("[%lu] [   ] Test fillRectDither drawn in %lu ms\n", millis(), elapsed);
  }

  {
    auto start_t = millis();
    renderer.fillRect(0, 0, renderer.getScreenWidth(), renderer.getScreenHeight(), true);
    auto elapsed = millis() - start_t;
    Serial.printf("[%lu] [   ] Test fillRect drawn in %lu ms\n", millis(), elapsed);
  }
```

Before:

```
[1125] [   ] Test fillRectDither drawn in 327 ms
[1347] [   ] Test fillRect drawn in 222 ms
```

After:

```
[1065] [ ] Test fillRectDither drawn in 238 ms
[1287] [ ] Test fillRect drawn in 222 ms
```

## Visual validation

Before:

<img width="415" height="216" alt="Screenshot 2026-02-07 at 01 04 19"
src="https://github.com/user-attachments/assets/5802dbba-187b-4d2b-a359-1318d3932d38"
/>

After:

<img width="420" height="191" alt="Screenshot 2026-02-07 at 01 36 30"
src="https://github.com/user-attachments/assets/3c3c8e14-3f3a-4205-be78-6ed771dcddf4"
/>

## Details

The original version is quite slow because it does quite a lot of
computations. A single pixel needs around 20 instructions just to know
if it's black or white:

<img width="1170" height="693" alt="Screenshot 2026-02-07 at 00 15 54"
src="https://github.com/user-attachments/assets/7c5a55e7-0598-4340-8b7b-17307d7921cb"
/>

With the new, templated and more light-weight approach, each pixel takes
only 3-4 instructions, the modulo operator is translated into bitwise
ops:

<img width="1175" height="682" alt="Screenshot 2026-02-07 at 01 47 51"
src="https://github.com/user-attachments/assets/4ec2cf74-6cc0-4b5b-87d5-831563ef164f"
/>

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? **NO**
2026-02-08 14:59:13 +03:00
Arthur Tazhitdinov
e6f5fa43e6 feat(ux): invert BACK button behavior in reader activities (#726)
## Summary

* Inverts back button behaviour while reading - short press to go home,
long press to open file browser

## Additional Context

* It seems counterintuitive that going into a book from home screen and
pressing back doesn’t take you back to the home screen. With the recent
books now displayed in the home view and a separate recents view, going
directly to the file browser is less necessary.
2026-02-07 10:17:00 -05:00
James Whyte
e7e31ac487 fix: increase lyra sideButtonHintsWidth to 30 (#727)
## Summary

Increase the width of Lyra's side button hints. It has been set to 30,
the same width as the classic theme.


Before:
<img width="457" height="742" alt="image"
src="https://github.com/user-attachments/assets/316e4679-fbf0-4f6e-b117-413075da1be2"
/>

After:
<img width="512" height="849" alt="image"
src="https://github.com/user-attachments/assets/3b0cf069-55ad-4d5a-a93c-4aeca3ff67f8"
/>



## Additional Context

Resolves
https://github.com/crosspoint-reader/crosspoint-reader/pull/700#issuecomment-3856983832

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code?
No
2026-02-07 10:15:41 -05:00
Jake Kenneally
9f78fd33e8 fix: Remove separations after style changes (#720)
Closes #182. Closes #710. Closes #711.

## Summary

**What is the goal of this PR?**
- A longer-term, more robust fix for the issue with spurious spaces
appearing after style changes. Replaces solution from #694.

**What changes are included?**
- Add continuation flags to determine if to add a space after a word or
if the word connects to the previous word. Replaces simple solution that
only considered ending punctuation.
- Fixed an issue with greedy line-breaking algorithm where punctuation
could appear on the next line, separated from the word, if there was a
style change between the word and punctuation

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? _**YES**_, Claude Code
2026-02-06 19:10:37 +11:00
CaptainFrito
bd8132a260 fix: Lag before displaying covers on home screen (#721)
## Summary

Reduce/fix the lag on the home screen before recent book covers are
rendered

## Additional Context

We were previously rendering the screen in two steps, delaying the
recent book covers render to avoid a lag before the screen loads.
In this PR, we are now doing that only if at least one book doesn't have
the cover thumbnail generated yet. If all thumbs are already generated,
we load and display them right away, with no lag.

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? _**NO **_
2026-02-06 18:58:32 +11:00
Jake Kenneally
f89ce514c8 feat: Add Settings for toggling CSS on or off (#717)
Closes #712 

## Summary

**What is the goal of this PR?** 

- To add new settings for toggling on/off embedded CSS styles in the
reader. This gives more control and customization to the user over how
the ereader experience looks.

**What changes are included?**

- Added new "Embedded Style" option to the Reader settings
- Added new "Book's Style" option for "Paragraph Alignment"
- User's selected "Paragraph Alignment" will take precedence and
override the embedded CSS `text-align` property, _unless_ the user has
"Book's Style" set as their "Paragraph Alignment"

## Additional Context

![IMG_6336](https://github.com/user-attachments/assets/dff619ef-986d-465e-b352-73a76baae334)


https://github.com/user-attachments/assets/9e404b13-c7e0-41c7-9406-4715f389166a


Addresses feedback from the community about the new CSS feature:
https://github.com/crosspoint-reader/crosspoint-reader/pull/700

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? _**YES**_, Claude Code
2026-02-06 18:49:04 +11:00
117 changed files with 10837 additions and 928 deletions

5
.gitignore vendored
View File

@@ -9,3 +9,8 @@ build
**/__pycache__/
/compile_commands.json
/.cache
# mod
mod/*
.cursor/*
chat-summaries/*

View File

@@ -230,6 +230,7 @@ Accessible by pressing **Confirm** while inside a book.
Please note that this firmware is currently in active development. The following features are **not yet supported** but are planned for future updates:
* **Images:** Embedded images in e-books will not render.
* **Cover Images:** Large cover images embedded into EPUB require several seconds (~10s for ~2000 pixel tall image) to convert for sleep screen and home screen thumbnail. Consider optimizing the EPUB with e.g. https://github.com/bigbag/epub-to-xtc-converter to speed this up.
---
@@ -242,3 +243,5 @@ pio device monitor
```
If the device is stuck in a bootloop, press and release the Reset button. Then, press and hold on to the configured Back button and the Power Button to boot to the Home Screen.
There can be issues with broken cache or config. In this case, delete the `.crosspoint` directory on your SD card (or consider deleting only `settings.bin`, `state.bin`, or `epub_*` cache directories in the `.crosspoint/` folder).

View File

@@ -13,7 +13,9 @@ fi
# --modified: files tracked by git that have been modified (staged or unstaged)
# --exclude-standard: ignores files in .gitignore
# Additionally exclude files in 'lib/EpdFont/builtinFonts/' as they are script-generated.
# Also exclude files in 'lib/Epub/Epub/hyphenation/generated/' as they are script-generated.
git ls-files --exclude-standard ${GIT_LS_FILES_FLAGS} \
| grep -E '\.(c|cpp|h|hpp)$' \
| grep -v -E '^lib/EpdFont/builtinFonts/' \
| grep -v -E '^lib/Epub/Epub/hyphenation/generated/' \
| xargs -r clang-format -style=file -i

View File

@@ -1,9 +1,9 @@
#include "Epub.h"
#include <FsHelpers.h>
#include <HalStorage.h>
#include <HardwareSerial.h>
#include <JpegToBmpConverter.h>
#include <SDCardManager.h>
#include <ZipFile.h>
#include "Epub/parsers/ContainerParser.h"
@@ -105,12 +105,12 @@ bool Epub::parseTocNcxFile() const {
const auto tmpNcxPath = getCachePath() + "/toc.ncx";
FsFile tempNcxFile;
if (!SdMan.openFileForWrite("EBP", tmpNcxPath, tempNcxFile)) {
if (!Storage.openFileForWrite("EBP", tmpNcxPath, tempNcxFile)) {
return false;
}
readItemContentsToStream(tocNcxItem, tempNcxFile, 1024);
tempNcxFile.close();
if (!SdMan.openFileForRead("EBP", tmpNcxPath, tempNcxFile)) {
if (!Storage.openFileForRead("EBP", tmpNcxPath, tempNcxFile)) {
return false;
}
const auto ncxSize = tempNcxFile.size();
@@ -145,7 +145,7 @@ bool Epub::parseTocNcxFile() const {
free(ncxBuffer);
tempNcxFile.close();
SdMan.remove(tmpNcxPath.c_str());
Storage.remove(tmpNcxPath.c_str());
Serial.printf("[%lu] [EBP] Parsed TOC items\n", millis());
return true;
@@ -162,12 +162,12 @@ bool Epub::parseTocNavFile() const {
const auto tmpNavPath = getCachePath() + "/toc.nav";
FsFile tempNavFile;
if (!SdMan.openFileForWrite("EBP", tmpNavPath, tempNavFile)) {
if (!Storage.openFileForWrite("EBP", tmpNavPath, tempNavFile)) {
return false;
}
readItemContentsToStream(tocNavItem, tempNavFile, 1024);
tempNavFile.close();
if (!SdMan.openFileForRead("EBP", tmpNavPath, tempNavFile)) {
if (!Storage.openFileForRead("EBP", tmpNavPath, tempNavFile)) {
return false;
}
const auto navSize = tempNavFile.size();
@@ -202,7 +202,7 @@ bool Epub::parseTocNavFile() const {
free(navBuffer);
tempNavFile.close();
SdMan.remove(tmpNavPath.c_str());
Storage.remove(tmpNavPath.c_str());
Serial.printf("[%lu] [EBP] Parsed TOC nav items\n", millis());
return true;
@@ -212,7 +212,7 @@ std::string Epub::getCssRulesCache() const { return cachePath + "/css_rules.cach
bool Epub::loadCssRulesFromCache() const {
FsFile cssCacheFile;
if (SdMan.openFileForRead("EBP", getCssRulesCache(), cssCacheFile)) {
if (Storage.openFileForRead("EBP", getCssRulesCache(), cssCacheFile)) {
if (cssParser->loadFromCache(cssCacheFile)) {
cssCacheFile.close();
Serial.printf("[%lu] [EBP] Loaded CSS rules from cache\n", millis());
@@ -238,32 +238,32 @@ void Epub::parseCssFiles() const {
// Extract CSS file to temp location
const auto tmpCssPath = getCachePath() + "/.tmp.css";
FsFile tempCssFile;
if (!SdMan.openFileForWrite("EBP", tmpCssPath, tempCssFile)) {
if (!Storage.openFileForWrite("EBP", tmpCssPath, tempCssFile)) {
Serial.printf("[%lu] [EBP] Could not create temp CSS file\n", millis());
continue;
}
if (!readItemContentsToStream(cssPath, tempCssFile, 1024)) {
Serial.printf("[%lu] [EBP] Could not read CSS file: %s\n", millis(), cssPath.c_str());
tempCssFile.close();
SdMan.remove(tmpCssPath.c_str());
Storage.remove(tmpCssPath.c_str());
continue;
}
tempCssFile.close();
// Parse the CSS file
if (!SdMan.openFileForRead("EBP", tmpCssPath, tempCssFile)) {
if (!Storage.openFileForRead("EBP", tmpCssPath, tempCssFile)) {
Serial.printf("[%lu] [EBP] Could not open temp CSS file for reading\n", millis());
SdMan.remove(tmpCssPath.c_str());
Storage.remove(tmpCssPath.c_str());
continue;
}
cssParser->loadFromStream(tempCssFile);
tempCssFile.close();
SdMan.remove(tmpCssPath.c_str());
Storage.remove(tmpCssPath.c_str());
}
// Save to cache for next time
FsFile cssCacheFile;
if (SdMan.openFileForWrite("EBP", getCssRulesCache(), cssCacheFile)) {
if (Storage.openFileForWrite("EBP", getCssRulesCache(), cssCacheFile)) {
cssParser->saveToCache(cssCacheFile);
cssCacheFile.close();
}
@@ -399,12 +399,12 @@ bool Epub::load(const bool buildIfMissing, const bool skipLoadingCss) {
}
bool Epub::clearCache() const {
if (!SdMan.exists(cachePath.c_str())) {
if (!Storage.exists(cachePath.c_str())) {
Serial.printf("[%lu] [EPB] Cache does not exist, no action needed\n", millis());
return true;
}
if (!SdMan.removeDir(cachePath.c_str())) {
if (!Storage.removeDir(cachePath.c_str())) {
Serial.printf("[%lu] [EPB] Failed to clear cache\n", millis());
return false;
}
@@ -414,11 +414,11 @@ bool Epub::clearCache() const {
}
void Epub::setupCacheDir() const {
if (SdMan.exists(cachePath.c_str())) {
if (Storage.exists(cachePath.c_str())) {
return;
}
SdMan.mkdir(cachePath.c_str());
Storage.mkdir(cachePath.c_str());
}
const std::string& Epub::getCachePath() const { return cachePath; }
@@ -459,7 +459,7 @@ std::string Epub::getCoverBmpPath(bool cropped) const {
bool Epub::generateCoverBmp(bool cropped) const {
// Already generated, return true
if (SdMan.exists(getCoverBmpPath(cropped).c_str())) {
if (Storage.exists(getCoverBmpPath(cropped).c_str())) {
return true;
}
@@ -480,29 +480,29 @@ bool Epub::generateCoverBmp(bool cropped) const {
const auto coverJpgTempPath = getCachePath() + "/.cover.jpg";
FsFile coverJpg;
if (!SdMan.openFileForWrite("EBP", coverJpgTempPath, coverJpg)) {
if (!Storage.openFileForWrite("EBP", coverJpgTempPath, coverJpg)) {
return false;
}
readItemContentsToStream(coverImageHref, coverJpg, 1024);
coverJpg.close();
if (!SdMan.openFileForRead("EBP", coverJpgTempPath, coverJpg)) {
if (!Storage.openFileForRead("EBP", coverJpgTempPath, coverJpg)) {
return false;
}
FsFile coverBmp;
if (!SdMan.openFileForWrite("EBP", getCoverBmpPath(cropped), coverBmp)) {
if (!Storage.openFileForWrite("EBP", getCoverBmpPath(cropped), coverBmp)) {
coverJpg.close();
return false;
}
const bool success = JpegToBmpConverter::jpegFileToBmpStream(coverJpg, coverBmp, cropped);
coverJpg.close();
coverBmp.close();
SdMan.remove(coverJpgTempPath.c_str());
Storage.remove(coverJpgTempPath.c_str());
if (!success) {
Serial.printf("[%lu] [EBP] Failed to generate BMP from JPG cover image\n", millis());
SdMan.remove(getCoverBmpPath(cropped).c_str());
Storage.remove(getCoverBmpPath(cropped).c_str());
}
Serial.printf("[%lu] [EBP] Generated BMP from JPG cover image, success: %s\n", millis(), success ? "yes" : "no");
return success;
@@ -518,7 +518,7 @@ std::string Epub::getThumbBmpPath(int height) const { return cachePath + "/thumb
bool Epub::generateThumbBmp(int height) const {
// Already generated, return true
if (SdMan.exists(getThumbBmpPath(height).c_str())) {
if (Storage.exists(getThumbBmpPath(height).c_str())) {
return true;
}
@@ -536,18 +536,18 @@ bool Epub::generateThumbBmp(int height) const {
const auto coverJpgTempPath = getCachePath() + "/.cover.jpg";
FsFile coverJpg;
if (!SdMan.openFileForWrite("EBP", coverJpgTempPath, coverJpg)) {
if (!Storage.openFileForWrite("EBP", coverJpgTempPath, coverJpg)) {
return false;
}
readItemContentsToStream(coverImageHref, coverJpg, 1024);
coverJpg.close();
if (!SdMan.openFileForRead("EBP", coverJpgTempPath, coverJpg)) {
if (!Storage.openFileForRead("EBP", coverJpgTempPath, coverJpg)) {
return false;
}
FsFile thumbBmp;
if (!SdMan.openFileForWrite("EBP", getThumbBmpPath(height), thumbBmp)) {
if (!Storage.openFileForWrite("EBP", getThumbBmpPath(height), thumbBmp)) {
coverJpg.close();
return false;
}
@@ -559,11 +559,11 @@ bool Epub::generateThumbBmp(int height) const {
THUMB_TARGET_HEIGHT);
coverJpg.close();
thumbBmp.close();
SdMan.remove(coverJpgTempPath.c_str());
Storage.remove(coverJpgTempPath.c_str());
if (!success) {
Serial.printf("[%lu] [EBP] Failed to generate thumb BMP from JPG cover image\n", millis());
SdMan.remove(getThumbBmpPath(height).c_str());
Storage.remove(getThumbBmpPath(height).c_str());
}
Serial.printf("[%lu] [EBP] Generated thumb BMP from JPG cover image, success: %s\n", millis(),
success ? "yes" : "no");
@@ -574,7 +574,7 @@ bool Epub::generateThumbBmp(int height) const {
// Write an empty bmp file to avoid generation attempts in the future
FsFile thumbBmp;
SdMan.openFileForWrite("EBP", getThumbBmpPath(height), thumbBmp);
Storage.openFileForWrite("EBP", getThumbBmpPath(height), thumbBmp);
thumbBmp.close();
return false;
}

View File

@@ -29,7 +29,7 @@ bool BookMetadataCache::beginContentOpfPass() {
Serial.printf("[%lu] [BMC] Beginning content opf pass\n", millis());
// Open spine file for writing
return SdMan.openFileForWrite("BMC", cachePath + tmpSpineBinFile, spineFile);
return Storage.openFileForWrite("BMC", cachePath + tmpSpineBinFile, spineFile);
}
bool BookMetadataCache::endContentOpfPass() {
@@ -40,10 +40,10 @@ bool BookMetadataCache::endContentOpfPass() {
bool BookMetadataCache::beginTocPass() {
Serial.printf("[%lu] [BMC] Beginning toc pass\n", millis());
if (!SdMan.openFileForRead("BMC", cachePath + tmpSpineBinFile, spineFile)) {
if (!Storage.openFileForRead("BMC", cachePath + tmpSpineBinFile, spineFile)) {
return false;
}
if (!SdMan.openFileForWrite("BMC", cachePath + tmpTocBinFile, tocFile)) {
if (!Storage.openFileForWrite("BMC", cachePath + tmpTocBinFile, tocFile)) {
spineFile.close();
return false;
}
@@ -98,16 +98,16 @@ bool BookMetadataCache::endWrite() {
bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMetadata& metadata) {
// Open all three files, writing to meta, reading from spine and toc
if (!SdMan.openFileForWrite("BMC", cachePath + bookBinFile, bookFile)) {
if (!Storage.openFileForWrite("BMC", cachePath + bookBinFile, bookFile)) {
return false;
}
if (!SdMan.openFileForRead("BMC", cachePath + tmpSpineBinFile, spineFile)) {
if (!Storage.openFileForRead("BMC", cachePath + tmpSpineBinFile, spineFile)) {
bookFile.close();
return false;
}
if (!SdMan.openFileForRead("BMC", cachePath + tmpTocBinFile, tocFile)) {
if (!Storage.openFileForRead("BMC", cachePath + tmpTocBinFile, tocFile)) {
bookFile.close();
spineFile.close();
return false;
@@ -275,11 +275,11 @@ bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMeta
}
bool BookMetadataCache::cleanupTmpFiles() const {
if (SdMan.exists((cachePath + tmpSpineBinFile).c_str())) {
SdMan.remove((cachePath + tmpSpineBinFile).c_str());
if (Storage.exists((cachePath + tmpSpineBinFile).c_str())) {
Storage.remove((cachePath + tmpSpineBinFile).c_str());
}
if (SdMan.exists((cachePath + tmpTocBinFile).c_str())) {
SdMan.remove((cachePath + tmpTocBinFile).c_str());
if (Storage.exists((cachePath + tmpTocBinFile).c_str())) {
Storage.remove((cachePath + tmpTocBinFile).c_str());
}
return true;
}
@@ -364,7 +364,7 @@ void BookMetadataCache::createTocEntry(const std::string& title, const std::stri
/* ============= READING / LOADING FUNCTIONS ================ */
bool BookMetadataCache::load() {
if (!SdMan.openFileForRead("BMC", cachePath + bookBinFile, bookFile)) {
if (!Storage.openFileForRead("BMC", cachePath + bookBinFile, bookFile)) {
return false;
}

View File

@@ -1,6 +1,6 @@
#pragma once
#include <SDCardManager.h>
#include <HalStorage.h>
#include <algorithm>
#include <string>

View File

@@ -1,5 +1,5 @@
#pragma once
#include <SdFat.h>
#include <HalStorage.h>
#include <utility>
#include <vector>
@@ -28,6 +28,7 @@ class PageLine final : public PageElement {
public:
PageLine(std::shared_ptr<TextBlock> block, const int16_t xPos, const int16_t yPos)
: PageElement(xPos, yPos), block(std::move(block)) {}
const std::shared_ptr<TextBlock>& getBlock() const { return block; }
void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) override;
bool serialize(FsFile& file) override;
static std::unique_ptr<PageLine> deserialize(FsFile& file);

View File

@@ -5,7 +5,6 @@
#include <algorithm>
#include <cmath>
#include <functional>
#include <iterator>
#include <limits>
#include <vector>
@@ -77,37 +76,26 @@ void ParsedText::layoutAndExtractLines(const GfxRenderer& renderer, const int fo
const int spaceWidth = renderer.getSpaceWidth(fontId);
auto wordWidths = calculateWordWidths(renderer, fontId);
// Build indexed continues vector from the parallel list for O(1) access during layout
std::vector<bool> continuesVec(wordContinues.begin(), wordContinues.end());
std::vector<size_t> lineBreakIndices;
if (hyphenationEnabled) {
// Use greedy layout that can split words mid-loop when a hyphenated prefix fits.
lineBreakIndices = computeHyphenatedLineBreaks(renderer, fontId, pageWidth, spaceWidth, wordWidths, continuesVec);
lineBreakIndices = computeHyphenatedLineBreaks(renderer, fontId, pageWidth, spaceWidth, wordWidths, wordContinues);
} else {
lineBreakIndices = computeLineBreaks(renderer, fontId, pageWidth, spaceWidth, wordWidths, continuesVec);
lineBreakIndices = computeLineBreaks(renderer, fontId, pageWidth, spaceWidth, wordWidths, wordContinues);
}
const size_t lineCount = includeLastLine ? lineBreakIndices.size() : lineBreakIndices.size() - 1;
for (size_t i = 0; i < lineCount; ++i) {
extractLine(i, pageWidth, spaceWidth, wordWidths, continuesVec, lineBreakIndices, processLine);
extractLine(i, pageWidth, spaceWidth, wordWidths, wordContinues, lineBreakIndices, processLine);
}
}
std::vector<uint16_t> ParsedText::calculateWordWidths(const GfxRenderer& renderer, const int fontId) {
const size_t totalWordCount = words.size();
std::vector<uint16_t> wordWidths;
wordWidths.reserve(totalWordCount);
wordWidths.reserve(words.size());
auto wordsIt = words.begin();
auto wordStylesIt = wordStyles.begin();
while (wordsIt != words.end()) {
wordWidths.push_back(measureWordWidth(renderer, fontId, *wordsIt, *wordStylesIt));
std::advance(wordsIt, 1);
std::advance(wordStylesIt, 1);
for (size_t i = 0; i < words.size(); ++i) {
wordWidths.push_back(measureWordWidth(renderer, fontId, words[i], wordStyles[i]));
}
return wordWidths;
@@ -132,8 +120,7 @@ std::vector<size_t> ParsedText::computeLineBreaks(const GfxRenderer& renderer, c
// First word needs to fit in reduced width if there's an indent
const int effectiveWidth = i == 0 ? pageWidth - firstLineIndent : pageWidth;
while (wordWidths[i] > effectiveWidth) {
if (!hyphenateWordAtIndex(i, effectiveWidth, renderer, fontId, wordWidths, /*allowFallbackBreaks=*/true,
&continuesVec)) {
if (!hyphenateWordAtIndex(i, effectiveWidth, renderer, fontId, wordWidths, /*allowFallbackBreaks=*/true)) {
break;
}
}
@@ -279,8 +266,8 @@ std::vector<size_t> ParsedText::computeHyphenatedLineBreaks(const GfxRenderer& r
const int availableWidth = effectivePageWidth - lineWidth - spacing;
const bool allowFallbackBreaks = isFirstWord; // Only for first word on line
if (availableWidth > 0 && hyphenateWordAtIndex(currentIndex, availableWidth, renderer, fontId, wordWidths,
allowFallbackBreaks, &continuesVec)) {
if (availableWidth > 0 &&
hyphenateWordAtIndex(currentIndex, availableWidth, renderer, fontId, wordWidths, allowFallbackBreaks)) {
// Prefix now fits; append it to this line and move to next line
lineWidth += spacing + wordWidths[currentIndex];
++currentIndex;
@@ -312,20 +299,14 @@ std::vector<size_t> ParsedText::computeHyphenatedLineBreaks(const GfxRenderer& r
// available width.
bool ParsedText::hyphenateWordAtIndex(const size_t wordIndex, const int availableWidth, const GfxRenderer& renderer,
const int fontId, std::vector<uint16_t>& wordWidths,
const bool allowFallbackBreaks, std::vector<bool>* continuesVec) {
const bool allowFallbackBreaks) {
// Guard against invalid indices or zero available width before attempting to split.
if (availableWidth <= 0 || wordIndex >= words.size()) {
return false;
}
// Get iterators to target word and style.
auto wordIt = words.begin();
auto styleIt = wordStyles.begin();
std::advance(wordIt, wordIndex);
std::advance(styleIt, wordIndex);
const std::string& word = *wordIt;
const auto style = *styleIt;
const std::string& word = words[wordIndex];
const auto style = wordStyles[wordIndex];
// Collect candidate breakpoints (byte offsets and hyphen requirements).
auto breakInfos = Hyphenator::breakOffsets(word, allowFallbackBreaks);
@@ -362,32 +343,20 @@ bool ParsedText::hyphenateWordAtIndex(const size_t wordIndex, const int availabl
// Split the word at the selected breakpoint and append a hyphen if required.
std::string remainder = word.substr(chosenOffset);
wordIt->resize(chosenOffset);
words[wordIndex].resize(chosenOffset);
if (chosenNeedsHyphen) {
wordIt->push_back('-');
words[wordIndex].push_back('-');
}
// Insert the remainder word (with matching style and continuation flag) directly after the prefix.
auto insertWordIt = std::next(wordIt);
auto insertStyleIt = std::next(styleIt);
words.insert(insertWordIt, remainder);
wordStyles.insert(insertStyleIt, style);
words.insert(words.begin() + wordIndex + 1, remainder);
wordStyles.insert(wordStyles.begin() + wordIndex + 1, style);
// The remainder inherits whatever continuation status the original word had with the word after it.
// Find the continues entry for the original word and insert the remainder's entry after it.
auto continuesIt = wordContinues.begin();
std::advance(continuesIt, wordIndex);
const bool originalContinuedToNext = *continuesIt;
const bool originalContinuedToNext = wordContinues[wordIndex];
// The original word (now prefix) does NOT continue to remainder (hyphen separates them)
*continuesIt = false;
const auto insertContinuesIt = std::next(continuesIt);
wordContinues.insert(insertContinuesIt, originalContinuedToNext);
// Keep the indexed vector in sync if provided
if (continuesVec) {
(*continuesVec)[wordIndex] = false;
continuesVec->insert(continuesVec->begin() + wordIndex + 1, originalContinuedToNext);
}
wordContinues[wordIndex] = false;
wordContinues.insert(wordContinues.begin() + wordIndex + 1, originalContinuedToNext);
// Update cached widths to reflect the new prefix/remainder pairing.
wordWidths[wordIndex] = static_cast<uint16_t>(chosenWidth);
@@ -447,7 +416,8 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const
// Pre-calculate X positions for words
// Continuation words attach to the previous word with no space before them
std::list<uint16_t> lineXPos;
std::vector<uint16_t> lineXPos;
lineXPos.reserve(lineWordCount);
for (size_t wordIdx = 0; wordIdx < lineWordCount; wordIdx++) {
const uint16_t currentWordWidth = wordWidths[lastBreakAt + wordIdx];
@@ -460,23 +430,10 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const
xpos += currentWordWidth + (nextIsContinuation ? 0 : spacing);
}
// Iterators always start at the beginning as we are moving content with splice below
auto wordEndIt = words.begin();
auto wordStyleEndIt = wordStyles.begin();
auto wordContinuesEndIt = wordContinues.begin();
std::advance(wordEndIt, lineWordCount);
std::advance(wordStyleEndIt, lineWordCount);
std::advance(wordContinuesEndIt, lineWordCount);
// *** CRITICAL STEP: CONSUME DATA USING SPLICE ***
std::list<std::string> lineWords;
lineWords.splice(lineWords.begin(), words, words.begin(), wordEndIt);
std::list<EpdFontFamily::Style> lineWordStyles;
lineWordStyles.splice(lineWordStyles.begin(), wordStyles, wordStyles.begin(), wordStyleEndIt);
// Consume continues flags (not passed to TextBlock, but must be consumed to stay in sync)
std::list<bool> lineContinues;
lineContinues.splice(lineContinues.begin(), wordContinues, wordContinues.begin(), wordContinuesEndIt);
// Build line data by moving from the original vectors using index range
std::vector<std::string> lineWords(std::make_move_iterator(words.begin() + lastBreakAt),
std::make_move_iterator(words.begin() + lineBreak));
std::vector<EpdFontFamily::Style> lineWordStyles(wordStyles.begin() + lastBreakAt, wordStyles.begin() + lineBreak);
for (auto& word : lineWords) {
if (containsSoftHyphen(word)) {

View File

@@ -3,7 +3,6 @@
#include <EpdFontFamily.h>
#include <functional>
#include <list>
#include <memory>
#include <string>
#include <vector>
@@ -14,9 +13,9 @@
class GfxRenderer;
class ParsedText {
std::list<std::string> words;
std::list<EpdFontFamily::Style> wordStyles;
std::list<bool> wordContinues; // true = word attaches to previous (no space before it)
std::vector<std::string> words;
std::vector<EpdFontFamily::Style> wordStyles;
std::vector<bool> wordContinues; // true = word attaches to previous (no space before it)
BlockStyle blockStyle;
bool extraParagraphSpacing;
bool hyphenationEnabled;
@@ -28,8 +27,7 @@ class ParsedText {
int spaceWidth, std::vector<uint16_t>& wordWidths,
std::vector<bool>& continuesVec);
bool hyphenateWordAtIndex(size_t wordIndex, int availableWidth, const GfxRenderer& renderer, int fontId,
std::vector<uint16_t>& wordWidths, bool allowFallbackBreaks,
std::vector<bool>* continuesVec = nullptr);
std::vector<uint16_t>& wordWidths, bool allowFallbackBreaks);
void extractLine(size_t breakIndex, int pageWidth, int spaceWidth, const std::vector<uint16_t>& wordWidths,
const std::vector<bool>& continuesVec, const std::vector<size_t>& lineBreakIndices,
const std::function<void(std::shared_ptr<TextBlock>)>& processLine);

View File

@@ -1,6 +1,6 @@
#include "Section.h"
#include <SDCardManager.h>
#include <HalStorage.h>
#include <Serialization.h>
#include "Page.h"
@@ -60,7 +60,7 @@ void Section::writeSectionFileHeader(const int fontId, const float lineCompressi
bool Section::loadSectionFile(const int fontId, const float lineCompression, const bool extraParagraphSpacing,
const uint8_t paragraphAlignment, const uint16_t viewportWidth,
const uint16_t viewportHeight, const bool hyphenationEnabled, const bool embeddedStyle) {
if (!SdMan.openFileForRead("SCT", filePath, file)) {
if (!Storage.openFileForRead("SCT", filePath, file)) {
return false;
}
@@ -110,12 +110,12 @@ bool Section::loadSectionFile(const int fontId, const float lineCompression, con
// Your updated class method (assuming you are using the 'SD' object, which is a wrapper for a specific filesystem)
bool Section::clearCache() const {
if (!SdMan.exists(filePath.c_str())) {
if (!Storage.exists(filePath.c_str())) {
Serial.printf("[%lu] [SCT] Cache does not exist, no action needed\n", millis());
return true;
}
if (!SdMan.remove(filePath.c_str())) {
if (!Storage.remove(filePath.c_str())) {
Serial.printf("[%lu] [SCT] Failed to clear cache\n", millis());
return false;
}
@@ -134,7 +134,7 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
// Create cache directory if it doesn't exist
{
const auto sectionsDir = epub->getCachePath() + "/sections";
SdMan.mkdir(sectionsDir.c_str());
Storage.mkdir(sectionsDir.c_str());
}
// Retry logic for SD card timing issues
@@ -147,12 +147,12 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
}
// Remove any incomplete file from previous attempt before retrying
if (SdMan.exists(tmpHtmlPath.c_str())) {
SdMan.remove(tmpHtmlPath.c_str());
if (Storage.exists(tmpHtmlPath.c_str())) {
Storage.remove(tmpHtmlPath.c_str());
}
FsFile tmpHtml;
if (!SdMan.openFileForWrite("SCT", tmpHtmlPath, tmpHtml)) {
if (!Storage.openFileForWrite("SCT", tmpHtmlPath, tmpHtml)) {
continue;
}
success = epub->readItemContentsToStream(localPath, tmpHtml, 1024);
@@ -160,8 +160,8 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
tmpHtml.close();
// If streaming failed, remove the incomplete file immediately
if (!success && SdMan.exists(tmpHtmlPath.c_str())) {
SdMan.remove(tmpHtmlPath.c_str());
if (!success && Storage.exists(tmpHtmlPath.c_str())) {
Storage.remove(tmpHtmlPath.c_str());
Serial.printf("[%lu] [SCT] Removed incomplete temp file after failed attempt\n", millis());
}
}
@@ -173,7 +173,7 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
Serial.printf("[%lu] [SCT] Streamed temp HTML to %s (%d bytes)\n", millis(), tmpHtmlPath.c_str(), fileSize);
if (!SdMan.openFileForWrite("SCT", filePath, file)) {
if (!Storage.openFileForWrite("SCT", filePath, file)) {
return false;
}
writeSectionFileHeader(fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth,
@@ -188,11 +188,11 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
Hyphenator::setPreferredLanguage(epub->getLanguage());
success = visitor.parseAndBuildPages();
SdMan.remove(tmpHtmlPath.c_str());
Storage.remove(tmpHtmlPath.c_str());
if (!success) {
Serial.printf("[%lu] [SCT] Failed to parse XML and build pages\n", millis());
file.close();
SdMan.remove(filePath.c_str());
Storage.remove(filePath.c_str());
return false;
}
@@ -210,7 +210,7 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
if (hasFailedLutRecords) {
Serial.printf("[%lu] [SCT] Failed to write LUT due to invalid page positions\n", millis());
file.close();
SdMan.remove(filePath.c_str());
Storage.remove(filePath.c_str());
return false;
}
@@ -223,7 +223,7 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
}
std::unique_ptr<Page> Section::loadPageFromSectionFile() {
if (!SdMan.openFileForRead("SCT", filePath, file)) {
if (!Storage.openFileForRead("SCT", filePath, file)) {
return nullptr;
}

View File

@@ -11,16 +11,13 @@ void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int
return;
}
auto wordIt = words.begin();
auto wordStylesIt = wordStyles.begin();
auto wordXposIt = wordXpos.begin();
for (size_t i = 0; i < words.size(); i++) {
const int wordX = *wordXposIt + x;
const EpdFontFamily::Style currentStyle = *wordStylesIt;
renderer.drawText(fontId, wordX, y, wordIt->c_str(), true, currentStyle);
const int wordX = wordXpos[i] + x;
const EpdFontFamily::Style currentStyle = wordStyles[i];
renderer.drawText(fontId, wordX, y, words[i].c_str(), true, currentStyle);
if ((currentStyle & EpdFontFamily::UNDERLINE) != 0) {
const std::string& w = *wordIt;
const std::string& w = words[i];
const int fullWordWidth = renderer.getTextWidth(fontId, w.c_str(), currentStyle);
// y is the top of the text line; add ascender to reach baseline, then offset 2px below
const int underlineY = y + renderer.getFontAscenderSize(fontId) + 2;
@@ -40,10 +37,6 @@ void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int
renderer.drawLine(startX, underlineY, startX + underlineWidth, underlineY, true);
}
std::advance(wordIt, 1);
std::advance(wordStylesIt, 1);
std::advance(wordXposIt, 1);
}
}
@@ -79,15 +72,15 @@ bool TextBlock::serialize(FsFile& file) const {
std::unique_ptr<TextBlock> TextBlock::deserialize(FsFile& file) {
uint16_t wc;
std::list<std::string> words;
std::list<uint16_t> wordXpos;
std::list<EpdFontFamily::Style> wordStyles;
std::vector<std::string> words;
std::vector<uint16_t> wordXpos;
std::vector<EpdFontFamily::Style> wordStyles;
BlockStyle blockStyle;
// Word count
serialization::readPod(file, wc);
// Sanity check: prevent allocation of unreasonably large lists (max 10000 words per block)
// Sanity check: prevent allocation of unreasonably large vectors (max 10000 words per block)
if (wc > 10000) {
Serial.printf("[%lu] [TXB] Deserialization failed: word count %u exceeds maximum\n", millis(), wc);
return nullptr;

View File

@@ -1,10 +1,10 @@
#pragma once
#include <EpdFontFamily.h>
#include <SdFat.h>
#include <HalStorage.h>
#include <list>
#include <memory>
#include <string>
#include <vector>
#include "Block.h"
#include "BlockStyle.h"
@@ -12,14 +12,14 @@
// Represents a line of text on a page
class TextBlock final : public Block {
private:
std::list<std::string> words;
std::list<uint16_t> wordXpos;
std::list<EpdFontFamily::Style> wordStyles;
std::vector<std::string> words;
std::vector<uint16_t> wordXpos;
std::vector<EpdFontFamily::Style> wordStyles;
BlockStyle blockStyle;
public:
explicit TextBlock(std::list<std::string> words, std::list<uint16_t> word_xpos,
std::list<EpdFontFamily::Style> word_styles, const BlockStyle& blockStyle = BlockStyle())
explicit TextBlock(std::vector<std::string> words, std::vector<uint16_t> word_xpos,
std::vector<EpdFontFamily::Style> word_styles, const BlockStyle& blockStyle = BlockStyle())
: words(std::move(words)),
wordXpos(std::move(word_xpos)),
wordStyles(std::move(word_styles)),
@@ -27,6 +27,9 @@ class TextBlock final : public Block {
~TextBlock() override = default;
void setBlockStyle(const BlockStyle& blockStyle) { this->blockStyle = blockStyle; }
const BlockStyle& getBlockStyle() const { return blockStyle; }
const std::vector<std::string>& getWords() const { return words; }
const std::vector<uint16_t>& getWordXpos() const { return wordXpos; }
const std::vector<EpdFontFamily::Style>& getWordStyles() const { return wordStyles; }
bool isEmpty() override { return words.empty(); }
void layout(GfxRenderer& renderer) override {};
// given a renderer works out where to break the words into lines

View File

@@ -1,6 +1,6 @@
#pragma once
#include <SdFat.h>
#include <HalStorage.h>
#include <string>
#include <unordered_map>

View File

@@ -8,6 +8,7 @@
#include "generated/hyph-en.trie.h"
#include "generated/hyph-es.trie.h"
#include "generated/hyph-fr.trie.h"
#include "generated/hyph-it.trie.h"
#include "generated/hyph-ru.trie.h"
namespace {
@@ -18,15 +19,17 @@ LanguageHyphenator frenchHyphenator(fr_patterns, isLatinLetter, toLowerLatin);
LanguageHyphenator germanHyphenator(de_patterns, isLatinLetter, toLowerLatin);
LanguageHyphenator russianHyphenator(ru_ru_patterns, isCyrillicLetter, toLowerCyrillic);
LanguageHyphenator spanishHyphenator(es_patterns, isLatinLetter, toLowerLatin);
LanguageHyphenator italianHyphenator(it_patterns, isLatinLetter, toLowerLatin);
using EntryArray = std::array<LanguageEntry, 5>;
using EntryArray = std::array<LanguageEntry, 6>;
const EntryArray& entries() {
static const EntryArray kEntries = {{{"english", "en", &englishHyphenator},
{"french", "fr", &frenchHyphenator},
{"german", "de", &germanHyphenator},
{"russian", "ru", &russianHyphenator},
{"spanish", "es", &spanishHyphenator}}};
{"spanish", "es", &spanishHyphenator},
{"italian", "it", &italianHyphenator}}};
return kEntries;
}

View File

@@ -0,0 +1,113 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include "../SerializedHyphenationTrie.h"
// Auto-generated by generate_hyphenation_trie.py. Do not edit manually.
alignas(4) constexpr uint8_t it_trie_data[] = {
0x00, 0x00, 0x05, 0xC4, 0x17, 0x0C, 0x33, 0x35, 0x0C, 0x29, 0x22, 0x0D, 0x3E, 0x0B, 0x47, 0x20,
0x0D, 0x16, 0x0B, 0x34, 0x0D, 0x21, 0x0C, 0x3D, 0x1F, 0x0C, 0x2A, 0x17, 0x2A, 0x0B, 0x02, 0x0C,
0x01, 0x02, 0x16, 0x02, 0x0D, 0x0C, 0x0C, 0x0D, 0x03, 0x0C, 0x01, 0x0C, 0x0E, 0x0D, 0x04, 0x02,
0x0B, 0xA0, 0x00, 0x42, 0x21, 0x6E, 0xFD, 0xA0, 0x00, 0x72, 0x21, 0x6E, 0xFD, 0xA1, 0x00, 0x61,
0x6D, 0xFD, 0x21, 0x69, 0xFB, 0x21, 0x74, 0xFD, 0x22, 0x70, 0x6E, 0xEC, 0xFD, 0xA0, 0x00, 0x91,
0x21, 0x6F, 0xFD, 0x21, 0x69, 0xFD, 0xA0, 0x00, 0xA2, 0x21, 0x73, 0xFD, 0x21, 0x70, 0xFD, 0xA0,
0x00, 0xC2, 0x21, 0x6D, 0xFD, 0x21, 0x75, 0xFD, 0x21, 0x63, 0xFD, 0x21, 0x72, 0xFD, 0xA0, 0x00,
0xE1, 0x21, 0x6F, 0xFD, 0x21, 0x72, 0xFD, 0x21, 0x74, 0xFD, 0x21, 0x6E, 0xFD, 0xA3, 0x01, 0x11,
0x61, 0x69, 0x6F, 0xDF, 0xEE, 0xFD, 0xA0, 0x00, 0xF2, 0x21, 0x65, 0xFD, 0x21, 0x6E, 0xFD, 0x21,
0x69, 0xFD, 0x21, 0x63, 0xFD, 0x21, 0x73, 0xFD, 0xA1, 0x01, 0x11, 0x69, 0xFD, 0xA0, 0x01, 0x12,
0x21, 0x75, 0xFD, 0x21, 0x65, 0xFD, 0x21, 0x78, 0xFD, 0xA0, 0x01, 0x32, 0x21, 0x6B, 0xFD, 0x21,
0x6E, 0xFD, 0xA0, 0x00, 0x71, 0x21, 0x65, 0xFD, 0x22, 0x61, 0x65, 0xF7, 0xFD, 0x21, 0x72, 0xFB,
0xA0, 0x01, 0x52, 0x21, 0x61, 0xFD, 0x21, 0x73, 0xFD, 0x21, 0x70, 0xFD, 0x21, 0x69, 0xFD, 0xA0,
0x01, 0x71, 0x21, 0x6F, 0xFD, 0x21, 0x63, 0xFD, 0x21, 0x72, 0xFD, 0x21, 0x61, 0xFD, 0xA0, 0x00,
0x61, 0x21, 0x6F, 0xFD, 0x21, 0x74, 0xFD, 0x41, 0x70, 0xFF, 0x50, 0x21, 0x6F, 0xFC, 0x21, 0x74,
0xFD, 0x22, 0x70, 0x72, 0xF3, 0xFD, 0x21, 0x61, 0xE8, 0x21, 0x72, 0xFD, 0xA0, 0x00, 0xF1, 0x22,
0x6C, 0x72, 0xFD, 0xFD, 0x21, 0x69, 0xE3, 0x21, 0x6C, 0xFD, 0x41, 0x65, 0xFF, 0x43, 0xA0, 0x01,
0x11, 0x25, 0x61, 0x68, 0x6F, 0x72, 0x73, 0xE8, 0xEE, 0xF6, 0xF9, 0xFD, 0xA0, 0x01, 0x82, 0x21,
0x72, 0xFD, 0x21, 0x63, 0xFD, 0x21, 0x73, 0xFD, 0x21, 0x69, 0xFD, 0x21, 0x65, 0xFD, 0xA0, 0x01,
0xA2, 0x21, 0x65, 0xFD, 0x21, 0x72, 0xFD, 0x21, 0x61, 0xFD, 0x41, 0x75, 0xFF, 0x4C, 0x42, 0x6C,
0x72, 0xFF, 0xFC, 0xFF, 0x48, 0x21, 0x62, 0xF9, 0x22, 0x68, 0x75, 0xEF, 0xFD, 0x47, 0x63, 0x64,
0x6C, 0x6E, 0x70, 0x72, 0x74, 0xFF, 0x5C, 0xFF, 0x5C, 0xFF, 0x5C, 0xFF, 0x5C, 0xFF, 0x5C, 0xFF,
0x5C, 0xFF, 0x5C, 0x21, 0x73, 0xEA, 0x21, 0x6E, 0xFD, 0x21, 0x61, 0xFD, 0xA1, 0x01, 0x11, 0x72,
0xFD, 0x41, 0x6E, 0xFF, 0x15, 0x21, 0x67, 0xFC, 0xA0, 0x01, 0xC2, 0x21, 0x74, 0xFD, 0x21, 0x6C,
0xFD, 0x22, 0x61, 0x65, 0xF4, 0xFD, 0x52, 0x61, 0x62, 0x63, 0x64, 0x65, 0x66, 0x6C, 0x6E, 0x6F,
0x70, 0x72, 0x73, 0x74, 0x77, 0x68, 0x6A, 0x6B, 0x7A, 0xFE, 0xC2, 0xFE, 0xCD, 0xFE, 0xF7, 0xFF,
0x12, 0xFF, 0x20, 0xFF, 0x37, 0xFF, 0x46, 0xFF, 0x55, 0xFF, 0x6B, 0xFF, 0x8B, 0xFF, 0xA5, 0xFF,
0xC2, 0xFF, 0xE6, 0xFF, 0xFB, 0xFF, 0x88, 0xFF, 0x88, 0xFF, 0x88, 0xFF, 0x88, 0xA0, 0x01, 0xE2,
0xA0, 0x00, 0xD1, 0x24, 0x61, 0x65, 0x6F, 0x75, 0xFD, 0xFD, 0xFD, 0xFD, 0x21, 0x6F, 0xF4, 0x21,
0x61, 0xF1, 0xA0, 0x01, 0xE1, 0x21, 0x2E, 0xFD, 0x24, 0x69, 0x75, 0x79, 0x74, 0xEB, 0xF4, 0xF7,
0xFD, 0x21, 0x75, 0xDF, 0xA0, 0x00, 0x51, 0x22, 0x69, 0x77, 0xFA, 0xFD, 0x21, 0x69, 0xD7, 0xAE,
0x02, 0x01, 0x62, 0x63, 0x64, 0x66, 0x6D, 0x6E, 0x70, 0x73, 0x74, 0x76, 0x6C, 0x72, 0x2E, 0x27,
0xE3, 0xE3, 0xE3, 0xE3, 0xE3, 0xE3, 0xE3, 0xE3, 0xE3, 0xE3, 0xF5, 0xF5, 0xE3, 0xE3, 0x22, 0x2E,
0x27, 0xC4, 0xC7, 0xC6, 0x00, 0x51, 0x68, 0x2E, 0x27, 0x62, 0x72, 0x6E, 0xFF, 0xBF, 0xFF, 0xBF,
0xFF, 0xFB, 0xFF, 0xBF, 0xFE, 0xFB, 0xFF, 0xBF, 0xD0, 0x02, 0x01, 0x62, 0x63, 0x64, 0x66, 0x6B,
0x6D, 0x6E, 0x71, 0x73, 0x74, 0x7A, 0x68, 0x6C, 0x72, 0x2E, 0x27, 0xFF, 0xAA, 0xFF, 0xAA, 0xFF,
0xAA, 0xFF, 0xAA, 0xFF, 0xAA, 0xFF, 0xAA, 0xFF, 0xAA, 0xFF, 0xAA, 0xFF, 0xAA, 0xFF, 0xAA, 0xFF,
0xAA, 0xFF, 0xEB, 0xFF, 0xBC, 0xFF, 0xBC, 0xFF, 0xAA, 0xFF, 0xAA, 0xCE, 0x02, 0x01, 0x62, 0x64,
0x67, 0x6C, 0x6D, 0x6E, 0x70, 0x72, 0x73, 0x74, 0x76, 0x77, 0x2E, 0x27, 0xFF, 0x77, 0xFF, 0x77,
0xFF, 0x77, 0xFF, 0x77, 0xFF, 0x77, 0xFF, 0x77, 0xFF, 0x77, 0xFF, 0x89, 0xFF, 0x77, 0xFF, 0x77,
0xFF, 0x77, 0xFF, 0x77, 0xFF, 0x77, 0xFF, 0x77, 0xCA, 0x02, 0x01, 0x62, 0x67, 0x66, 0x6E, 0x6C,
0x72, 0x73, 0x74, 0x2E, 0x27, 0xFF, 0x4A, 0xFF, 0x4A, 0xFF, 0x4A, 0xFF, 0x4A, 0xFF, 0x5C, 0xFF,
0x5C, 0xFF, 0x4A, 0xFF, 0x4A, 0xFF, 0x4A, 0xFF, 0x4A, 0xA0, 0x02, 0x12, 0xA1, 0x00, 0x51, 0x74,
0xFD, 0xD1, 0x02, 0x01, 0x62, 0x64, 0x66, 0x67, 0x68, 0x6C, 0x6D, 0x6E, 0x70, 0x72, 0x73, 0x74,
0x76, 0x77, 0x7A, 0x2E, 0x27, 0xFF, 0x21, 0xFF, 0x21, 0xFF, 0x21, 0xFF, 0x21, 0xFF, 0xFB, 0xFF,
0x33, 0xFF, 0x21, 0xFF, 0x33, 0xFF, 0x21, 0xFF, 0x33, 0xFF, 0x21, 0xFF, 0x21, 0xFF, 0x21, 0xFF,
0x21, 0xFF, 0x21, 0xFF, 0x21, 0xFF, 0x21, 0x41, 0x70, 0xFD, 0x4D, 0xCB, 0x02, 0x01, 0x62, 0x64,
0x68, 0x69, 0x6C, 0x6D, 0x6E, 0x72, 0x76, 0x2E, 0x27, 0xFE, 0xE7, 0xFE, 0xE7, 0xFE, 0xE7, 0xFF,
0xFC, 0xFE, 0xF9, 0xFE, 0xE7, 0xFE, 0xE7, 0xFE, 0xE7, 0xFE, 0xE7, 0xFE, 0xE7, 0xFE, 0xE7, 0xC2,
0x02, 0x01, 0x2E, 0x27, 0xFE, 0xC3, 0xFE, 0xC3, 0xCB, 0x02, 0x01, 0x67, 0x66, 0x68, 0x6B, 0x6C,
0x6D, 0x72, 0x73, 0x74, 0x2E, 0x27, 0xFE, 0xBA, 0xFE, 0xBA, 0xFE, 0xCC, 0xFE, 0xBA, 0xFE, 0xCC,
0xFE, 0xBA, 0xFE, 0xCC, 0xFE, 0xBA, 0xFE, 0xBA, 0xFE, 0xBA, 0xFE, 0xBA, 0xA0, 0x02, 0x33, 0x42,
0x2E, 0x27, 0xFE, 0x93, 0xFE, 0x93, 0xD5, 0x02, 0x01, 0x62, 0x63, 0x64, 0x66, 0x67, 0x68, 0x6A,
0x6B, 0x6C, 0x6D, 0x6E, 0x70, 0x71, 0x72, 0x73, 0x74, 0x76, 0x77, 0x7A, 0x2E, 0x27, 0xFE, 0x8C,
0xFE, 0x8C, 0xFE, 0x8C, 0xFF, 0xF6, 0xFE, 0x8C, 0xFE, 0x9E, 0xFE, 0x9E, 0xFE, 0x8C, 0xFE, 0x8C,
0xFE, 0x8C, 0xFE, 0x8C, 0xFE, 0x8C, 0xFE, 0x8C, 0xFE, 0x8C, 0xFE, 0x8C, 0xFE, 0x8C, 0xFE, 0x8C,
0xFE, 0x8C, 0xFE, 0x8C, 0xFE, 0x8C, 0xFF, 0xF9, 0xCF, 0x02, 0x01, 0x62, 0x63, 0x66, 0x6C, 0x6D,
0x6E, 0x70, 0x71, 0x72, 0x73, 0x74, 0x76, 0x77, 0x2E, 0x27, 0xFE, 0x4A, 0xFE, 0x4A, 0xFE, 0x4A,
0xFE, 0x4A, 0xFE, 0x4A, 0xFE, 0x4A, 0xFE, 0x4A, 0xFE, 0x4A, 0xFE, 0x4A, 0xFE, 0x4A, 0xFE, 0x4A,
0xFE, 0x4A, 0xFE, 0x4A, 0xFE, 0x4A, 0xFE, 0x4A, 0xA0, 0x02, 0x62, 0xA1, 0x01, 0xE1, 0x6E, 0xFD,
0x21, 0x72, 0xF8, 0x21, 0x65, 0xFD, 0xA1, 0x01, 0xE1, 0x66, 0xFD, 0x41, 0x74, 0xFE, 0x07, 0x21,
0x69, 0xFC, 0x21, 0x65, 0xFD, 0xD3, 0x02, 0x01, 0x62, 0x63, 0x64, 0x66, 0x67, 0x6B, 0x6C, 0x6D,
0x6E, 0x70, 0x71, 0x72, 0x73, 0x74, 0x76, 0x7A, 0x68, 0x2E, 0x27, 0xFD, 0xFD, 0xFD, 0xFD, 0xFD,
0xFD, 0xFD, 0xFD, 0xFF, 0xE6, 0xFD, 0xFD, 0xFD, 0xFD, 0xFD, 0xFD, 0xFD, 0xFD, 0xFD, 0xFD, 0xFD,
0xFD, 0xFD, 0xFD, 0xFF, 0xF1, 0xFD, 0xFD, 0xFD, 0xFD, 0xFD, 0xFD, 0xFF, 0xFD, 0xFD, 0xFD, 0xFD,
0xFD, 0xA0, 0x02, 0x82, 0xA1, 0x01, 0xE1, 0x65, 0xFD, 0x21, 0x63, 0xF8, 0xA1, 0x01, 0xE1, 0x69,
0xFD, 0xCB, 0x02, 0x01, 0x64, 0x68, 0x6C, 0x6E, 0x70, 0x72, 0x73, 0x74, 0x7A, 0x2E, 0x27, 0xFD,
0xB1, 0xFD, 0xC3, 0xFD, 0xC3, 0xFF, 0xF3, 0xFD, 0xB1, 0xFD, 0xC3, 0xFF, 0xFB, 0xFD, 0xB1, 0xFD,
0xB1, 0xFD, 0xB1, 0xFD, 0xB1, 0xC3, 0x02, 0x01, 0x71, 0x2E, 0x27, 0xFD, 0x8D, 0xFD, 0x8D, 0xFD,
0x8D, 0xA0, 0x02, 0x53, 0xA1, 0x01, 0xE1, 0x73, 0xFD, 0xD5, 0x02, 0x01, 0x62, 0x63, 0x64, 0x66,
0x68, 0x67, 0x6B, 0x6C, 0x6D, 0x6E, 0x70, 0x71, 0x72, 0x73, 0x74, 0x76, 0x78, 0x77, 0x7A, 0x2E,
0x27, 0xFD, 0x79, 0xFD, 0x79, 0xFD, 0x79, 0xFD, 0x79, 0xFD, 0x8B, 0xFD, 0x79, 0xFD, 0x79, 0xFD,
0x79, 0xFD, 0x79, 0xFD, 0x79, 0xFD, 0x79, 0xFD, 0x79, 0xFD, 0x79, 0xFD, 0x79, 0xFF, 0xFB, 0xFD,
0x79, 0xFD, 0x79, 0xFD, 0x79, 0xFD, 0x79, 0xFD, 0x79, 0xFD, 0x79, 0x43, 0x6D, 0x2E, 0x27, 0xFD,
0x37, 0xFD, 0x37, 0xFD, 0x37, 0xA0, 0x02, 0xC2, 0xA1, 0x02, 0x32, 0x6D, 0xFD, 0x41, 0x6E, 0xFE,
0x8F, 0x4B, 0x62, 0x63, 0x64, 0x66, 0x67, 0x6D, 0x6E, 0x70, 0x73, 0x74, 0x76, 0xFD, 0x21, 0xFD,
0x21, 0xFD, 0x21, 0xFD, 0x21, 0xFD, 0x21, 0xFD, 0x21, 0xFD, 0x21, 0xFD, 0x21, 0xFD, 0x21, 0xFD,
0x21, 0xFD, 0x21, 0xA0, 0x02, 0xE1, 0x22, 0x2E, 0x27, 0xFD, 0xFD, 0xC7, 0x02, 0xA2, 0x68, 0x73,
0x70, 0x74, 0x7A, 0x2E, 0x27, 0xFF, 0xC0, 0xFF, 0xCD, 0xFF, 0xD2, 0xFF, 0xD6, 0xFC, 0xF7, 0xFF,
0xF8, 0xFF, 0xFB, 0xC1, 0x00, 0x51, 0x2E, 0xFC, 0xDF, 0x41, 0x68, 0xFF, 0x18, 0xA1, 0x00, 0x51,
0x63, 0xFC, 0xC1, 0x01, 0xE1, 0x73, 0xFE, 0xB6, 0xC2, 0x00, 0x51, 0x6B, 0x73, 0xFC, 0xCA, 0xFC,
0x06, 0xD2, 0x02, 0x01, 0x62, 0x63, 0x64, 0x66, 0x67, 0x68, 0x6C, 0x6D, 0x6E, 0x70, 0x72, 0x73,
0x74, 0x76, 0x77, 0x7A, 0x2E, 0x27, 0xFC, 0xC1, 0xFC, 0xC1, 0xFC, 0xC1, 0xFC, 0xC1, 0xFC, 0xC1,
0xFF, 0xE2, 0xFC, 0xD3, 0xFC, 0xC1, 0xFC, 0xC1, 0xFC, 0xC1, 0xFC, 0xD3, 0xFF, 0xEC, 0xFF, 0xF1,
0xFC, 0xC1, 0xFC, 0xC1, 0xFF, 0xF7, 0xFC, 0xC1, 0xFE, 0x2E, 0xC6, 0x02, 0x01, 0x63, 0x6C, 0x72,
0x76, 0x2E, 0x27, 0xFC, 0x88, 0xFC, 0x9A, 0xFC, 0x9A, 0xFC, 0x88, 0xFC, 0x88, 0xFD, 0xF5, 0x41,
0x72, 0xFB, 0xAF, 0xA0, 0x02, 0xF2, 0xC5, 0x02, 0x01, 0x68, 0x61, 0x79, 0x2E, 0x27, 0xFC, 0x7E,
0xFF, 0xF9, 0xFF, 0xFD, 0xFC, 0x6C, 0xFC, 0x6C, 0xCA, 0x02, 0x01, 0x62, 0x63, 0x66, 0x68, 0x6D,
0x70, 0x74, 0x77, 0x2E, 0x27, 0xFC, 0x5A, 0xFC, 0x5A, 0xFC, 0x5A, 0xFC, 0x5A, 0xFC, 0x5A, 0xFC,
0x5A, 0xFC, 0x5A, 0xFC, 0x5A, 0xFC, 0x5A, 0xFC, 0x5A, 0x42, 0x6F, 0x69, 0xFC, 0x48, 0xFC, 0x27,
0xCB, 0x02, 0x01, 0x62, 0x64, 0x6C, 0x6E, 0x70, 0x74, 0x73, 0x76, 0x7A, 0x2E, 0x27, 0xFC, 0x32,
0xFC, 0x32, 0xFC, 0x32, 0xFC, 0x32, 0xFC, 0x32, 0xFC, 0x32, 0xFC, 0x32, 0xFC, 0x32, 0xFC, 0x32,
0xFC, 0x32, 0xFD, 0x9F, 0x5A, 0x2E, 0x27, 0x61, 0x65, 0x6F, 0x62, 0x63, 0x64, 0x66, 0x67, 0x68,
0x6A, 0x6B, 0x6C, 0x6D, 0x6E, 0x70, 0x71, 0x72, 0x73, 0x74, 0x76, 0x77, 0x78, 0x79, 0x7A, 0xFB,
0xC2, 0xFB, 0xF9, 0xFC, 0x14, 0xFC, 0x23, 0xFC, 0x28, 0xFC, 0x2B, 0xFC, 0x64, 0xFC, 0x97, 0xFC,
0xC4, 0xFC, 0xED, 0xFD, 0x27, 0xFD, 0x4B, 0xFD, 0x54, 0xFD, 0x82, 0xFD, 0xC4, 0xFE, 0x11, 0xFE,
0x5D, 0xFE, 0x81, 0xFE, 0x95, 0xFF, 0x17, 0xFF, 0x4D, 0xFF, 0x86, 0xFF, 0xA2, 0xFF, 0xB4, 0xFF,
0xD5, 0xFF, 0xDC,
};
constexpr SerializedHyphenationPatterns it_patterns = {
it_trie_data,
sizeof(it_trie_data),
};

View File

@@ -1,8 +1,8 @@
#include "ChapterHtmlSlimParser.h"
#include <GfxRenderer.h>
#include <HalStorage.h>
#include <HardwareSerial.h>
#include <SDCardManager.h>
#include <expat.h>
#include "../Page.h"
@@ -11,7 +11,7 @@ const char* HEADER_TAGS[] = {"h1", "h2", "h3", "h4", "h5", "h6"};
constexpr int NUM_HEADER_TAGS = sizeof(HEADER_TAGS) / sizeof(HEADER_TAGS[0]);
// Minimum file size (in bytes) to show indexing popup - smaller chapters don't benefit from it
constexpr size_t MIN_SIZE_FOR_POPUP = 50 * 1024; // 50KB
constexpr size_t MIN_SIZE_FOR_POPUP = 10 * 1024; // 10KB
const char* BLOCK_TAGS[] = {"p", "li", "div", "br", "blockquote"};
constexpr int NUM_BLOCK_TAGS = sizeof(BLOCK_TAGS) / sizeof(BLOCK_TAGS[0]);
@@ -482,7 +482,7 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
}
FsFile file;
if (!SdMan.openFileForRead("EHP", filepath, file)) {
if (!Storage.openFileForRead("EHP", filepath, file)) {
XML_ParserFree(parser);
return false;
}

View File

@@ -36,8 +36,8 @@ ContentOpfParser::~ContentOpfParser() {
if (tempItemStore) {
tempItemStore.close();
}
if (SdMan.exists((cachePath + itemCacheFile).c_str())) {
SdMan.remove((cachePath + itemCacheFile).c_str());
if (Storage.exists((cachePath + itemCacheFile).c_str())) {
Storage.remove((cachePath + itemCacheFile).c_str());
}
itemIndex.clear();
itemIndex.shrink_to_fit();
@@ -118,7 +118,7 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name
if (self->state == IN_PACKAGE && (strcmp(name, "manifest") == 0 || strcmp(name, "opf:manifest") == 0)) {
self->state = IN_MANIFEST;
if (!SdMan.openFileForWrite("COF", self->cachePath + itemCacheFile, self->tempItemStore)) {
if (!Storage.openFileForWrite("COF", self->cachePath + itemCacheFile, self->tempItemStore)) {
Serial.printf(
"[%lu] [COF] Couldn't open temp items file for writing. This is probably going to be a fatal error.\n",
millis());
@@ -128,7 +128,7 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name
if (self->state == IN_PACKAGE && (strcmp(name, "spine") == 0 || strcmp(name, "opf:spine") == 0)) {
self->state = IN_SPINE;
if (!SdMan.openFileForRead("COF", self->cachePath + itemCacheFile, self->tempItemStore)) {
if (!Storage.openFileForRead("COF", self->cachePath + itemCacheFile, self->tempItemStore)) {
Serial.printf(
"[%lu] [COF] Couldn't open temp items file for reading. This is probably going to be a fatal error.\n",
millis());
@@ -149,7 +149,7 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name
self->state = IN_GUIDE;
// TODO Remove print
Serial.printf("[%lu] [COF] Entering guide state.\n", millis());
if (!SdMan.openFileForRead("COF", self->cachePath + itemCacheFile, self->tempItemStore)) {
if (!Storage.openFileForRead("COF", self->cachePath + itemCacheFile, self->tempItemStore)) {
Serial.printf(
"[%lu] [COF] Couldn't open temp items file for reading. This is probably going to be a fatal error.\n",
millis());
@@ -232,6 +232,14 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name
Serial.printf("[%lu] [COF] Found EPUB 3 nav document: %s\n", millis(), href.c_str());
}
}
// EPUB 3: Check for cover image (properties contains "cover-image")
if (!properties.empty() && self->coverItemHref.empty()) {
if (properties == "cover-image" || properties.find("cover-image ") == 0 ||
properties.find(" cover-image") != std::string::npos) {
self->coverItemHref = href;
}
}
return;
}

View File

@@ -1,6 +1,6 @@
#pragma once
#include <SdFat.h>
#include <HalStorage.h>
#include <cstdint>

View File

@@ -104,3 +104,20 @@ uint8_t quantize1bit(int gray, int x, int y) {
const int adjustedThreshold = 128 + ((threshold - 128) / 2); // Range: 64-192
return (gray >= adjustedThreshold) ? 1 : 0;
}
// Noise dithering for gradient fills - always uses hash-based noise regardless of global dithering config.
// Produces smooth-looking gradients on the 4-level e-ink display.
uint8_t quantizeNoiseDither(int gray, int x, int y) {
uint32_t hash = static_cast<uint32_t>(x) * 374761393u + static_cast<uint32_t>(y) * 668265263u;
hash = (hash ^ (hash >> 13)) * 1274126177u;
const int threshold = static_cast<int>(hash >> 24);
const int scaled = gray * 3;
if (scaled < 255) {
return (scaled + threshold >= 255) ? 1 : 0;
} else if (scaled < 510) {
return ((scaled - 255) + threshold >= 255) ? 2 : 1;
} else {
return ((scaled - 510) + threshold >= 255) ? 3 : 2;
}
}

View File

@@ -7,6 +7,7 @@ uint8_t quantize(int gray, int x, int y);
uint8_t quantizeSimple(int gray);
uint8_t quantize1bit(int gray, int x, int y);
int adjustPixel(int gray);
uint8_t quantizeNoiseDither(int gray, int x, int y);
// 1-bit Atkinson dithering - better quality than noise dithering for thumbnails
// Error distribution pattern (same as 2-bit but quantizes to 2 levels):

View File

@@ -72,6 +72,16 @@ void GfxRenderer::drawPixel(const int x, const int y, const bool state) const {
}
}
void GfxRenderer::drawPixelGray(const int x, const int y, const uint8_t val2bit) const {
if (renderMode == BW && val2bit < 3) {
drawPixel(x, y);
} else if (renderMode == GRAYSCALE_MSB && (val2bit == 1 || val2bit == 2)) {
drawPixel(x, y, false);
} else if (renderMode == GRAYSCALE_LSB && val2bit == 1) {
drawPixel(x, y, false);
}
}
int GfxRenderer::getTextWidth(const int fontId, const char* text, const EpdFontFamily::Style style) const {
if (fontMap.count(fontId) == 0) {
Serial.printf("[%lu] [GFX] Font %d not found\n", millis(), fontId);
@@ -422,12 +432,20 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con
Serial.printf("[%lu] [GFX] Cropping %dx%d by %dx%d pix, is %s\n", millis(), bitmap.getWidth(), bitmap.getHeight(),
cropPixX, cropPixY, bitmap.isTopDown() ? "top-down" : "bottom-up");
if (maxWidth > 0 && (1.0f - cropX) * bitmap.getWidth() > maxWidth) {
scale = static_cast<float>(maxWidth) / static_cast<float>((1.0f - cropX) * bitmap.getWidth());
const float effectiveWidth = (1.0f - cropX) * bitmap.getWidth();
const float effectiveHeight = (1.0f - cropY) * bitmap.getHeight();
// Calculate scale factor: supports both downscaling and upscaling when both constraints are provided
if (maxWidth > 0 && maxHeight > 0) {
const float scaleX = static_cast<float>(maxWidth) / effectiveWidth;
const float scaleY = static_cast<float>(maxHeight) / effectiveHeight;
scale = std::min(scaleX, scaleY);
isScaled = (scale < 0.999f || scale > 1.001f);
} else if (maxWidth > 0 && effectiveWidth > static_cast<float>(maxWidth)) {
scale = static_cast<float>(maxWidth) / effectiveWidth;
isScaled = true;
}
if (maxHeight > 0 && (1.0f - cropY) * bitmap.getHeight() > maxHeight) {
scale = std::min(scale, static_cast<float>(maxHeight) / static_cast<float>((1.0f - cropY) * bitmap.getHeight()));
} else if (maxHeight > 0 && effectiveHeight > static_cast<float>(maxHeight)) {
scale = static_cast<float>(maxHeight) / effectiveHeight;
isScaled = true;
}
Serial.printf("[%lu] [GFX] Scaling by %f - %s\n", millis(), scale, isScaled ? "scaled" : "not scaled");
@@ -448,12 +466,17 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con
for (int bmpY = 0; bmpY < (bitmap.getHeight() - cropPixY); bmpY++) {
// The BMP's (0, 0) is the bottom-left corner (if the height is positive, top-left if negative).
// Screen's (0, 0) is the top-left corner.
int screenY = -cropPixY + (bitmap.isTopDown() ? bmpY : bitmap.getHeight() - 1 - bmpY);
const int logicalY = -cropPixY + (bitmap.isTopDown() ? bmpY : bitmap.getHeight() - 1 - bmpY);
int screenYStart, screenYEnd;
if (isScaled) {
screenY = std::floor(screenY * scale);
screenYStart = static_cast<int>(std::floor(logicalY * scale)) + y;
screenYEnd = static_cast<int>(std::floor((logicalY + 1) * scale)) + y;
} else {
screenYStart = logicalY + y;
screenYEnd = screenYStart + 1;
}
screenY += y; // the offset should not be scaled
if (screenY >= getScreenHeight()) {
if (screenYStart >= getScreenHeight()) {
break;
}
@@ -464,7 +487,7 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con
return;
}
if (screenY < 0) {
if (screenYEnd <= 0) {
continue;
}
@@ -473,27 +496,42 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con
continue;
}
const int syStart = std::max(screenYStart, 0);
const int syEnd = std::min(screenYEnd, getScreenHeight());
for (int bmpX = cropPixX; bmpX < bitmap.getWidth() - cropPixX; bmpX++) {
int screenX = bmpX - cropPixX;
const int outX = bmpX - cropPixX;
int screenXStart, screenXEnd;
if (isScaled) {
screenX = std::floor(screenX * scale);
screenXStart = static_cast<int>(std::floor(outX * scale)) + x;
screenXEnd = static_cast<int>(std::floor((outX + 1) * scale)) + x;
} else {
screenXStart = outX + x;
screenXEnd = screenXStart + 1;
}
screenX += x; // the offset should not be scaled
if (screenX >= getScreenWidth()) {
if (screenXStart >= getScreenWidth()) {
break;
}
if (screenX < 0) {
if (screenXEnd <= 0) {
continue;
}
const uint8_t val = outputRow[bmpX / 4] >> (6 - ((bmpX * 2) % 8)) & 0x3;
if (renderMode == BW && val < 3) {
drawPixel(screenX, screenY);
} else if (renderMode == GRAYSCALE_MSB && (val == 1 || val == 2)) {
drawPixel(screenX, screenY, false);
} else if (renderMode == GRAYSCALE_LSB && val == 1) {
drawPixel(screenX, screenY, false);
const int sxStart = std::max(screenXStart, 0);
const int sxEnd = std::min(screenXEnd, getScreenWidth());
for (int sy = syStart; sy < syEnd; sy++) {
for (int sx = sxStart; sx < sxEnd; sx++) {
if (renderMode == BW && val < 3) {
drawPixel(sx, sy);
} else if (renderMode == GRAYSCALE_MSB && (val == 1 || val == 2)) {
drawPixel(sx, sy, false);
} else if (renderMode == GRAYSCALE_LSB && val == 1) {
drawPixel(sx, sy, false);
}
}
}
}
}
@@ -506,11 +544,16 @@ void GfxRenderer::drawBitmap1Bit(const Bitmap& bitmap, const int x, const int y,
const int maxHeight) const {
float scale = 1.0f;
bool isScaled = false;
if (maxWidth > 0 && bitmap.getWidth() > maxWidth) {
// Calculate scale factor: supports both downscaling and upscaling when both constraints are provided
if (maxWidth > 0 && maxHeight > 0) {
const float scaleX = static_cast<float>(maxWidth) / static_cast<float>(bitmap.getWidth());
const float scaleY = static_cast<float>(maxHeight) / static_cast<float>(bitmap.getHeight());
scale = std::min(scaleX, scaleY);
isScaled = (scale < 0.999f || scale > 1.001f);
} else if (maxWidth > 0 && bitmap.getWidth() > maxWidth) {
scale = static_cast<float>(maxWidth) / static_cast<float>(bitmap.getWidth());
isScaled = true;
}
if (maxHeight > 0 && bitmap.getHeight() > maxHeight) {
} else if (maxHeight > 0 && bitmap.getHeight() > maxHeight) {
scale = std::min(scale, static_cast<float>(maxHeight) / static_cast<float>(bitmap.getHeight()));
isScaled = true;
}
@@ -538,20 +581,37 @@ void GfxRenderer::drawBitmap1Bit(const Bitmap& bitmap, const int x, const int y,
// Calculate screen Y based on whether BMP is top-down or bottom-up
const int bmpYOffset = bitmap.isTopDown() ? bmpY : bitmap.getHeight() - 1 - bmpY;
int screenY = y + (isScaled ? static_cast<int>(std::floor(bmpYOffset * scale)) : bmpYOffset);
if (screenY >= getScreenHeight()) {
int screenYStart, screenYEnd;
if (isScaled) {
screenYStart = static_cast<int>(std::floor(bmpYOffset * scale)) + y;
screenYEnd = static_cast<int>(std::floor((bmpYOffset + 1) * scale)) + y;
} else {
screenYStart = bmpYOffset + y;
screenYEnd = screenYStart + 1;
}
if (screenYStart >= getScreenHeight()) {
continue; // Continue reading to keep row counter in sync
}
if (screenY < 0) {
if (screenYEnd <= 0) {
continue;
}
const int syStart = std::max(screenYStart, 0);
const int syEnd = std::min(screenYEnd, getScreenHeight());
for (int bmpX = 0; bmpX < bitmap.getWidth(); bmpX++) {
int screenX = x + (isScaled ? static_cast<int>(std::floor(bmpX * scale)) : bmpX);
if (screenX >= getScreenWidth()) {
int screenXStart, screenXEnd;
if (isScaled) {
screenXStart = static_cast<int>(std::floor(bmpX * scale)) + x;
screenXEnd = static_cast<int>(std::floor((bmpX + 1) * scale)) + x;
} else {
screenXStart = bmpX + x;
screenXEnd = screenXStart + 1;
}
if (screenXStart >= getScreenWidth()) {
break;
}
if (screenX < 0) {
if (screenXEnd <= 0) {
continue;
}
@@ -561,7 +621,13 @@ void GfxRenderer::drawBitmap1Bit(const Bitmap& bitmap, const int x, const int y,
// For 1-bit source: 0 or 1 -> map to black (0,1,2) or white (3)
// val < 3 means black pixel (draw it)
if (val < 3) {
drawPixel(screenX, screenY, true);
const int sxStart = std::max(screenXStart, 0);
const int sxEnd = std::min(screenXEnd, getScreenWidth());
for (int sy = syStart; sy < syEnd; sy++) {
for (int sx = sxStart; sx < sxEnd; sx++) {
drawPixel(sx, sy, true);
}
}
}
// White pixels (val == 3) are not drawn (leave background)
}
@@ -839,6 +905,92 @@ void GfxRenderer::drawTextRotated90CW(const int fontId, const int x, const int y
}
}
void GfxRenderer::drawTextRotated90CCW(const int fontId, const int x, const int y, const char* text, const bool black,
const EpdFontFamily::Style style) const {
// Cannot draw a NULL / empty string
if (text == nullptr || *text == '\0') {
return;
}
if (fontMap.count(fontId) == 0) {
Serial.printf("[%lu] [GFX] Font %d not found\n", millis(), fontId);
return;
}
const auto font = fontMap.at(fontId);
// No printable characters
if (!font.hasPrintableChars(text, style)) {
return;
}
// For 90° counter-clockwise rotation:
// Mirror of CW: glyphY maps to -X direction, glyphX maps to +Y direction
// Text reads from top to bottom
const int advanceY = font.getData(style)->advanceY;
const int ascender = font.getData(style)->ascender;
int yPos = y; // Current Y position (increases as we draw characters)
uint32_t cp;
while ((cp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&text)))) {
const EpdGlyph* glyph = font.getGlyph(cp, style);
if (!glyph) {
glyph = font.getGlyph(REPLACEMENT_GLYPH, style);
}
if (!glyph) {
continue;
}
const int is2Bit = font.getData(style)->is2Bit;
const uint32_t offset = glyph->dataOffset;
const uint8_t width = glyph->width;
const uint8_t height = glyph->height;
const int left = glyph->left;
const int top = glyph->top;
const uint8_t* bitmap = &font.getData(style)->bitmap[offset];
if (bitmap != nullptr) {
for (int glyphY = 0; glyphY < height; glyphY++) {
for (int glyphX = 0; glyphX < width; glyphX++) {
const int pixelPosition = glyphY * width + glyphX;
// 90° counter-clockwise rotation transformation:
// screenX = mirrored CW X (right-to-left within advanceY span)
// screenY = yPos + (left + glyphX) (downward)
const int screenX = x + advanceY - 1 - (ascender - 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 frameBuffer; }
size_t GfxRenderer::getBufferSize() { return HalDisplay::BUFFER_SIZE; }

View File

@@ -77,6 +77,7 @@ class GfxRenderer {
// Drawing
void drawPixel(int x, int y, bool state = true) const;
void drawPixelGray(int x, int y, uint8_t val2bit) const;
void drawLine(int x1, int y1, int x2, int y2, bool state = true) const;
void drawLine(int x1, int y1, int x2, int y2, int lineWidth, bool state) const;
void drawArc(int maxRadius, int cx, int cy, int xDir, int yDir, int lineWidth, bool state) const;
@@ -110,9 +111,11 @@ class GfxRenderer {
std::string truncatedText(int fontId, const char* text, int maxWidth,
EpdFontFamily::Style style = EpdFontFamily::REGULAR) const;
// Helper for drawing rotated text (90 degrees clockwise, for side buttons)
// Helpers for drawing rotated text (for side buttons)
void drawTextRotated90CW(int fontId, int x, int y, const char* text, bool black = true,
EpdFontFamily::Style style = EpdFontFamily::REGULAR) const;
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;
// Grayscale functions

View File

@@ -1,7 +1,7 @@
#include "JpegToBmpConverter.h"
#include <HalStorage.h>
#include <HardwareSerial.h>
#include <SdFat.h>
#include <picojpeg.h>
#include <cstdio>

View File

@@ -1,8 +1,8 @@
#include "KOReaderCredentialStore.h"
#include <HalStorage.h>
#include <HardwareSerial.h>
#include <MD5Builder.h>
#include <SDCardManager.h>
#include <Serialization.h>
// Initialize the static instance
@@ -32,10 +32,10 @@ void KOReaderCredentialStore::obfuscate(std::string& data) const {
bool KOReaderCredentialStore::saveToFile() const {
// Make sure the directory exists
SdMan.mkdir("/.crosspoint");
Storage.mkdir("/.crosspoint");
FsFile file;
if (!SdMan.openFileForWrite("KRS", KOREADER_FILE, file)) {
if (!Storage.openFileForWrite("KRS", KOREADER_FILE, file)) {
return false;
}
@@ -64,7 +64,7 @@ bool KOReaderCredentialStore::saveToFile() const {
bool KOReaderCredentialStore::loadFromFile() {
FsFile file;
if (!SdMan.openFileForRead("KRS", KOREADER_FILE, file)) {
if (!Storage.openFileForRead("KRS", KOREADER_FILE, file)) {
Serial.printf("[%lu] [KRS] No credentials file found\n", millis());
return false;
}

View File

@@ -1,8 +1,8 @@
#include "KOReaderDocumentId.h"
#include <HalStorage.h>
#include <HardwareSerial.h>
#include <MD5Builder.h>
#include <SDCardManager.h>
namespace {
// Extract filename from path (everything after last '/')
@@ -43,7 +43,7 @@ size_t KOReaderDocumentId::getOffset(int i) {
std::string KOReaderDocumentId::calculate(const std::string& filePath) {
FsFile file;
if (!SdMan.openFileForRead("KODoc", filePath, file)) {
if (!Storage.openFileForRead("KODoc", filePath, file)) {
Serial.printf("[%lu] [KODoc] Failed to open file: %s\n", millis(), filePath.c_str());
return "";
}

View File

@@ -1,5 +1,5 @@
#pragma once
#include <SdFat.h>
#include <HalStorage.h>
#include <iostream>

View File

@@ -15,13 +15,13 @@ bool Txt::load() {
return true;
}
if (!SdMan.exists(filepath.c_str())) {
if (!Storage.exists(filepath.c_str())) {
Serial.printf("[%lu] [TXT] File does not exist: %s\n", millis(), filepath.c_str());
return false;
}
FsFile file;
if (!SdMan.openFileForRead("TXT", filepath, file)) {
if (!Storage.openFileForRead("TXT", filepath, file)) {
Serial.printf("[%lu] [TXT] Failed to open file: %s\n", millis(), filepath.c_str());
return false;
}
@@ -48,11 +48,11 @@ std::string Txt::getTitle() const {
}
void Txt::setupCacheDir() const {
if (!SdMan.exists(cacheBasePath.c_str())) {
SdMan.mkdir(cacheBasePath.c_str());
if (!Storage.exists(cacheBasePath.c_str())) {
Storage.mkdir(cacheBasePath.c_str());
}
if (!SdMan.exists(cachePath.c_str())) {
SdMan.mkdir(cachePath.c_str());
if (!Storage.exists(cachePath.c_str())) {
Storage.mkdir(cachePath.c_str());
}
}
@@ -73,7 +73,7 @@ std::string Txt::findCoverImage() const {
// First priority: look for image with same name as txt file (e.g., mybook.jpg)
for (const auto& ext : extensions) {
std::string coverPath = folder + "/" + baseName + ext;
if (SdMan.exists(coverPath.c_str())) {
if (Storage.exists(coverPath.c_str())) {
Serial.printf("[%lu] [TXT] Found matching cover image: %s\n", millis(), coverPath.c_str());
return coverPath;
}
@@ -84,7 +84,7 @@ std::string Txt::findCoverImage() const {
for (const auto& name : coverNames) {
for (const auto& ext : extensions) {
std::string coverPath = folder + "/" + std::string(name) + ext;
if (SdMan.exists(coverPath.c_str())) {
if (Storage.exists(coverPath.c_str())) {
Serial.printf("[%lu] [TXT] Found fallback cover image: %s\n", millis(), coverPath.c_str());
return coverPath;
}
@@ -98,7 +98,7 @@ std::string Txt::getCoverBmpPath() const { return cachePath + "/cover.bmp"; }
bool Txt::generateCoverBmp() const {
// Already generated, return true
if (SdMan.exists(getCoverBmpPath().c_str())) {
if (Storage.exists(getCoverBmpPath().c_str())) {
return true;
}
@@ -122,10 +122,10 @@ bool Txt::generateCoverBmp() const {
// Copy BMP file to cache
Serial.printf("[%lu] [TXT] Copying BMP cover image to cache\n", millis());
FsFile src, dst;
if (!SdMan.openFileForRead("TXT", coverImagePath, src)) {
if (!Storage.openFileForRead("TXT", coverImagePath, src)) {
return false;
}
if (!SdMan.openFileForWrite("TXT", getCoverBmpPath(), dst)) {
if (!Storage.openFileForWrite("TXT", getCoverBmpPath(), dst)) {
src.close();
return false;
}
@@ -144,10 +144,10 @@ bool Txt::generateCoverBmp() const {
// Convert JPG/JPEG to BMP (same approach as Epub)
Serial.printf("[%lu] [TXT] Generating BMP from JPG cover image\n", millis());
FsFile coverJpg, coverBmp;
if (!SdMan.openFileForRead("TXT", coverImagePath, coverJpg)) {
if (!Storage.openFileForRead("TXT", coverImagePath, coverJpg)) {
return false;
}
if (!SdMan.openFileForWrite("TXT", getCoverBmpPath(), coverBmp)) {
if (!Storage.openFileForWrite("TXT", getCoverBmpPath(), coverBmp)) {
coverJpg.close();
return false;
}
@@ -157,7 +157,7 @@ bool Txt::generateCoverBmp() const {
if (!success) {
Serial.printf("[%lu] [TXT] Failed to generate BMP from JPG cover image\n", millis());
SdMan.remove(getCoverBmpPath().c_str());
Storage.remove(getCoverBmpPath().c_str());
} else {
Serial.printf("[%lu] [TXT] Generated BMP from JPG cover image\n", millis());
}
@@ -175,7 +175,7 @@ bool Txt::readContent(uint8_t* buffer, size_t offset, size_t length) const {
}
FsFile file;
if (!SdMan.openFileForRead("TXT", filepath, file)) {
if (!Storage.openFileForRead("TXT", filepath, file)) {
return false;
}

View File

@@ -1,6 +1,6 @@
#pragma once
#include <SDCardManager.h>
#include <HalStorage.h>
#include <memory>
#include <string>

View File

@@ -7,8 +7,8 @@
#include "Xtc.h"
#include <HalStorage.h>
#include <HardwareSerial.h>
#include <SDCardManager.h>
bool Xtc::load() {
Serial.printf("[%lu] [XTC] Loading XTC: %s\n", millis(), filepath.c_str());
@@ -30,12 +30,12 @@ bool Xtc::load() {
}
bool Xtc::clearCache() const {
if (!SdMan.exists(cachePath.c_str())) {
if (!Storage.exists(cachePath.c_str())) {
Serial.printf("[%lu] [XTC] Cache does not exist, no action needed\n", millis());
return true;
}
if (!SdMan.removeDir(cachePath.c_str())) {
if (!Storage.removeDir(cachePath.c_str())) {
Serial.printf("[%lu] [XTC] Failed to clear cache\n", millis());
return false;
}
@@ -45,17 +45,17 @@ bool Xtc::clearCache() const {
}
void Xtc::setupCacheDir() const {
if (SdMan.exists(cachePath.c_str())) {
if (Storage.exists(cachePath.c_str())) {
return;
}
// Create directories recursively
for (size_t i = 1; i < cachePath.length(); i++) {
if (cachePath[i] == '/') {
SdMan.mkdir(cachePath.substr(0, i).c_str());
Storage.mkdir(cachePath.substr(0, i).c_str());
}
}
SdMan.mkdir(cachePath.c_str());
Storage.mkdir(cachePath.c_str());
}
std::string Xtc::getTitle() const {
@@ -114,7 +114,7 @@ std::string Xtc::getCoverBmpPath() const { return cachePath + "/cover.bmp"; }
bool Xtc::generateCoverBmp() const {
// Already generated
if (SdMan.exists(getCoverBmpPath().c_str())) {
if (Storage.exists(getCoverBmpPath().c_str())) {
return true;
}
@@ -166,7 +166,7 @@ bool Xtc::generateCoverBmp() const {
// Create BMP file
FsFile coverBmp;
if (!SdMan.openFileForWrite("XTC", getCoverBmpPath(), coverBmp)) {
if (!Storage.openFileForWrite("XTC", getCoverBmpPath(), coverBmp)) {
Serial.printf("[%lu] [XTC] Failed to create cover BMP file\n", millis());
free(pageBuffer);
return false;
@@ -306,7 +306,7 @@ std::string Xtc::getThumbBmpPath(int height) const { return cachePath + "/thumb_
bool Xtc::generateThumbBmp(int height) const {
// Already generated
if (SdMan.exists(getThumbBmpPath(height).c_str())) {
if (Storage.exists(getThumbBmpPath(height).c_str())) {
return true;
}
@@ -348,8 +348,8 @@ bool Xtc::generateThumbBmp(int height) const {
// Copy cover.bmp to thumb.bmp
if (generateCoverBmp()) {
FsFile src, dst;
if (SdMan.openFileForRead("XTC", getCoverBmpPath(), src)) {
if (SdMan.openFileForWrite("XTC", getThumbBmpPath(height), dst)) {
if (Storage.openFileForRead("XTC", getCoverBmpPath(), src)) {
if (Storage.openFileForWrite("XTC", getThumbBmpPath(height), dst)) {
uint8_t buffer[512];
while (src.available()) {
size_t bytesRead = src.read(buffer, sizeof(buffer));
@@ -360,7 +360,7 @@ bool Xtc::generateThumbBmp(int height) const {
src.close();
}
Serial.printf("[%lu] [XTC] Copied cover to thumb (no scaling needed)\n", millis());
return SdMan.exists(getThumbBmpPath(height).c_str());
return Storage.exists(getThumbBmpPath(height).c_str());
}
return false;
}
@@ -394,7 +394,7 @@ bool Xtc::generateThumbBmp(int height) const {
// Create thumbnail BMP file - use 1-bit format for fast home screen rendering (no gray passes)
FsFile thumbBmp;
if (!SdMan.openFileForWrite("XTC", getThumbBmpPath(height), thumbBmp)) {
if (!Storage.openFileForWrite("XTC", getThumbBmpPath(height), thumbBmp)) {
Serial.printf("[%lu] [XTC] Failed to create thumb BMP file\n", millis());
free(pageBuffer);
return false;

View File

@@ -8,8 +8,8 @@
#include "XtcParser.h"
#include <FsHelpers.h>
#include <HalStorage.h>
#include <HardwareSerial.h>
#include <SDCardManager.h>
#include <cstring>
@@ -34,7 +34,7 @@ XtcError XtcParser::open(const char* filepath) {
}
// Open file
if (!SdMan.openFileForRead("XTC", filepath, m_file)) {
if (!Storage.openFileForRead("XTC", filepath, m_file)) {
m_lastError = XtcError::FILE_NOT_FOUND;
return m_lastError;
}
@@ -444,7 +444,7 @@ XtcError XtcParser::loadPageStreaming(uint32_t pageIndex,
bool XtcParser::isValidXtcFile(const char* filepath) {
FsFile file;
if (!SdMan.openFileForRead("XTC", filepath, file)) {
if (!Storage.openFileForRead("XTC", filepath, file)) {
return false;
}

View File

@@ -7,7 +7,7 @@
#pragma once
#include <SdFat.h>
#include <HalStorage.h>
#include <functional>
#include <memory>

View File

@@ -1,7 +1,7 @@
#include "ZipFile.h"
#include <HalStorage.h>
#include <HardwareSerial.h>
#include <SDCardManager.h>
#include <miniz.h>
#include <algorithm>
@@ -279,7 +279,7 @@ bool ZipFile::loadZipDetails() {
}
bool ZipFile::open() {
if (!SdMan.openFileForRead("ZIP", filePath, file)) {
if (!Storage.openFileForRead("ZIP", filePath, file)) {
return false;
}
return true;

View File

@@ -1,5 +1,5 @@
#pragma once
#include <SdFat.h>
#include <HalStorage.h>
#include <string>
#include <unordered_map>

View File

@@ -1,11 +1,9 @@
#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);
}
@@ -23,23 +21,6 @@ bool HalGPIO::wasAnyReleased() const { return inputMgr.wasAnyReleased(); }
unsigned long HalGPIO::getHeldTime() const { return inputMgr.getHeldTime(); }
void HalGPIO::startDeepSleep() {
// 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();
}
// Arm the wakeup trigger *after* the button is released
esp_deep_sleep_enable_gpio_wakeup(1ULL << InputManager::POWER_BUTTON_PIN, ESP_GPIO_WAKEUP_GPIO_LOW);
// 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;

View File

@@ -38,12 +38,6 @@ class HalGPIO {
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;

View File

@@ -0,0 +1,48 @@
#include "HalPowerManager.h"
#include <esp_sleep.h>
#include "HalGPIO.h"
void HalPowerManager::begin() {
pinMode(BAT_GPIO0, INPUT);
normalFreq = getCpuFrequencyMhz();
}
void HalPowerManager::setPowerSaving(bool enabled) {
if (normalFreq <= 0) {
return; // invalid state
}
if (enabled && !isLowPower) {
Serial.printf("[%lu] [PWR] Going to low-power mode\n", millis());
if (!setCpuFrequencyMhz(LOW_POWER_FREQ)) {
Serial.printf("[%lu] [PWR] Failed to set low-power CPU frequency\n", millis());
return;
}
}
if (!enabled && isLowPower) {
Serial.printf("[%lu] [PWR] Restoring normal CPU frequency\n", millis());
if (!setCpuFrequencyMhz(normalFreq)) {
Serial.printf("[%lu] [PWR] Failed to restore normal CPU frequency\n", millis());
return;
}
}
isLowPower = enabled;
}
void HalPowerManager::startDeepSleep(HalGPIO& gpio) const {
// Ensure that the power button has been released to avoid immediately turning back on if you're holding it
while (gpio.isPressed(HalGPIO::BTN_POWER)) {
delay(50);
gpio.update();
}
// Arm the wakeup trigger *after* the button is released
esp_deep_sleep_enable_gpio_wakeup(1ULL << InputManager::POWER_BUTTON_PIN, ESP_GPIO_WAKEUP_GPIO_LOW);
// Enter Deep Sleep
esp_deep_sleep_start();
}
int HalPowerManager::getBatteryPercentage() const {
static const BatteryMonitor battery = BatteryMonitor(BAT_GPIO0);
return battery.readPercentage();
}

27
lib/hal/HalPowerManager.h Normal file
View File

@@ -0,0 +1,27 @@
#pragma once
#include <Arduino.h>
#include <BatteryMonitor.h>
#include <InputManager.h>
#include "HalGPIO.h"
class HalPowerManager {
int normalFreq = 0; // MHz
bool isLowPower = false;
public:
static constexpr int LOW_POWER_FREQ = 10; // MHz
static constexpr unsigned long IDLE_POWER_SAVING_MS = 3000; // ms
void begin();
// Control CPU frequency for power saving
void setPowerSaving(bool enabled);
// Setup wake up GPIO and enter deep sleep
void startDeepSleep(HalGPIO& gpio) const;
// Get battery percentage (range 0-100)
int getBatteryPercentage() const;
};

65
lib/hal/HalStorage.cpp Normal file
View File

@@ -0,0 +1,65 @@
#include "HalStorage.h"
#include <SDCardManager.h>
#define SDCard SDCardManager::getInstance()
HalStorage HalStorage::instance;
HalStorage::HalStorage() {}
bool HalStorage::begin() { return SDCard.begin(); }
bool HalStorage::ready() const { return SDCard.ready(); }
std::vector<String> HalStorage::listFiles(const char* path, int maxFiles) { return SDCard.listFiles(path, maxFiles); }
String HalStorage::readFile(const char* path) { return SDCard.readFile(path); }
bool HalStorage::readFileToStream(const char* path, Print& out, size_t chunkSize) {
return SDCard.readFileToStream(path, out, chunkSize);
}
size_t HalStorage::readFileToBuffer(const char* path, char* buffer, size_t bufferSize, size_t maxBytes) {
return SDCard.readFileToBuffer(path, buffer, bufferSize, maxBytes);
}
bool HalStorage::writeFile(const char* path, const String& content) { return SDCard.writeFile(path, content); }
bool HalStorage::ensureDirectoryExists(const char* path) { return SDCard.ensureDirectoryExists(path); }
FsFile HalStorage::open(const char* path, const oflag_t oflag) { return SDCard.open(path, oflag); }
bool HalStorage::mkdir(const char* path, const bool pFlag) { return SDCard.mkdir(path, pFlag); }
bool HalStorage::exists(const char* path) { return SDCard.exists(path); }
bool HalStorage::remove(const char* path) { return SDCard.remove(path); }
bool HalStorage::rmdir(const char* path) { return SDCard.rmdir(path); }
bool HalStorage::openFileForRead(const char* moduleName, const char* path, FsFile& file) {
return SDCard.openFileForRead(moduleName, path, file);
}
bool HalStorage::openFileForRead(const char* moduleName, const std::string& path, FsFile& file) {
return openFileForRead(moduleName, path.c_str(), file);
}
bool HalStorage::openFileForRead(const char* moduleName, const String& path, FsFile& file) {
return openFileForRead(moduleName, path.c_str(), file);
}
bool HalStorage::openFileForWrite(const char* moduleName, const char* path, FsFile& file) {
return SDCard.openFileForWrite(moduleName, path, file);
}
bool HalStorage::openFileForWrite(const char* moduleName, const std::string& path, FsFile& file) {
return openFileForWrite(moduleName, path.c_str(), file);
}
bool HalStorage::openFileForWrite(const char* moduleName, const String& path, FsFile& file) {
return openFileForWrite(moduleName, path.c_str(), file);
}
bool HalStorage::removeDir(const char* path) { return SDCard.removeDir(path); }

54
lib/hal/HalStorage.h Normal file
View File

@@ -0,0 +1,54 @@
#pragma once
#include <SDCardManager.h>
#include <vector>
class HalStorage {
public:
HalStorage();
bool begin();
bool ready() const;
std::vector<String> listFiles(const char* path = "/", int maxFiles = 200);
// Read the entire file at `path` into a String. Returns empty string on failure.
String readFile(const char* path);
// Low-memory helpers:
// Stream the file contents to a `Print` (e.g. `Serial`, or any `Print`-derived object).
// Returns true on success, false on failure.
bool readFileToStream(const char* path, Print& out, size_t chunkSize = 256);
// Read up to `bufferSize-1` bytes into `buffer`, null-terminating it. Returns bytes read.
size_t readFileToBuffer(const char* path, char* buffer, size_t bufferSize, size_t maxBytes = 0);
// Write a string to `path` on the SD card. Overwrites existing file.
// Returns true on success.
bool writeFile(const char* path, const String& content);
// Ensure a directory exists, creating it if necessary. Returns true on success.
bool ensureDirectoryExists(const char* path);
FsFile open(const char* path, const oflag_t oflag = O_RDONLY);
bool mkdir(const char* path, const bool pFlag = true);
bool exists(const char* path);
bool remove(const char* path);
bool rmdir(const char* path);
bool openFileForRead(const char* moduleName, const char* path, FsFile& file);
bool openFileForRead(const char* moduleName, const std::string& path, FsFile& file);
bool openFileForRead(const char* moduleName, const String& path, FsFile& file);
bool openFileForWrite(const char* moduleName, const char* path, FsFile& file);
bool openFileForWrite(const char* moduleName, const std::string& path, FsFile& file);
bool openFileForWrite(const char* moduleName, const String& path, FsFile& file);
bool removeDir(const char* path);
static HalStorage& getInstance() { return instance; }
private:
static HalStorage instance;
bool initialized = false;
};
#define Storage HalStorage::getInstance()
// Downstream code must use Storage instead of SdMan
#ifdef SdMan
#undef SdMan
#endif

View File

@@ -1,32 +1,46 @@
#!/usr/bin/env python3
"""
ESP32 Serial Monitor with Memory Graph
This script provides a real-time serial monitor for ESP32 devices with
integrated memory usage graphing capabilities. It reads serial output,
parses memory information, and displays it in both console and graphical form.
"""
import sys
import argparse
import re
import threading
from datetime import datetime
from collections import deque
import time
# Try to import potentially missing packages
PACKAGE_MAPPING: dict[str, str] = {
"serial": "pyserial",
"colorama": "colorama",
"matplotlib": "matplotlib",
}
try:
import serial
from colorama import init, Fore, Style
import matplotlib.pyplot as plt
import matplotlib.animation as animation
from matplotlib import animation
except ImportError as e:
missing_package = e.name
ERROR_MSG = str(e).lower()
missing_packages = [pkg for mod, pkg in PACKAGE_MAPPING.items() if mod in ERROR_MSG]
if not missing_packages:
# Fallback if mapping doesn't cover
missing_packages = ["pyserial", "colorama", "matplotlib"]
print("\n" + "!" * 50)
print(f" Error: The required package '{missing_package}' is not installed.")
print(f" Error: Required package(s) not installed: {', '.join(missing_packages)}")
print("!" * 50)
print(f"\nTo fix this, please run the following command in your terminal:\n")
install_cmd = "pip install "
packages = []
if 'serial' in str(e): packages.append("pyserial")
if 'colorama' in str(e): packages.append("colorama")
if 'matplotlib' in str(e): packages.append("matplotlib")
print(f" {install_cmd}{' '.join(packages)}")
print("\nTo fix this, please run the following command in your terminal:\n")
INSTALL_CMD = "pip install " if sys.platform.startswith("win") else "pip3 install "
print(f" {INSTALL_CMD}{' '.join(missing_packages)}")
print("\nExiting...")
sys.exit(1)
@@ -34,50 +48,92 @@ except ImportError as e:
# --- Global Variables for Data Sharing ---
# Store last 50 data points
MAX_POINTS = 50
time_data = deque(maxlen=MAX_POINTS)
free_mem_data = deque(maxlen=MAX_POINTS)
total_mem_data = deque(maxlen=MAX_POINTS)
data_lock = threading.Lock() # Prevent reading while writing
time_data: deque[str] = deque(maxlen=MAX_POINTS)
free_mem_data: deque[float] = deque(maxlen=MAX_POINTS)
total_mem_data: deque[float] = deque(maxlen=MAX_POINTS)
data_lock: threading.Lock = threading.Lock() # Prevent reading while writing
# Initialize colors
init(autoreset=True)
def get_color_for_line(line):
# Color mapping for log lines
COLOR_KEYWORDS: dict[str, list[str]] = {
Fore.RED: ["ERROR", "[ERR]", "[SCT]", "FAILED", "WARNING"],
Fore.CYAN: ["[MEM]", "FREE:"],
Fore.MAGENTA: [
"[GFX]",
"[ERS]",
"DISPLAY",
"RAM WRITE",
"RAM COMPLETE",
"REFRESH",
"POWERING ON",
"FRAME BUFFER",
"LUT",
],
Fore.GREEN: [
"[EBP]",
"[BMC]",
"[ZIP]",
"[PARSER]",
"[EHP]",
"LOADING EPUB",
"CACHE",
"DECOMPRESSED",
"PARSING",
],
Fore.YELLOW: ["[ACT]", "ENTERING ACTIVITY", "EXITING ACTIVITY"],
Fore.BLUE: ["RENDERED PAGE", "[LOOP]", "DURATION", "WAIT COMPLETE"],
Fore.LIGHTYELLOW_EX: [
"[CPS]",
"SETTINGS",
"[CLEAR_CACHE]",
"[CHAP]",
"[OPDS]",
"[COF]",
],
Fore.LIGHTBLACK_EX: [
"ESP-ROM",
"BUILD:",
"RST:",
"BOOT:",
"SPIWP:",
"MODE:",
"LOAD:",
"ENTRY",
"[SD]",
"STARTING CROSSPOINT",
"VERSION",
],
Fore.LIGHTCYAN_EX: ["[RBS]"],
Fore.LIGHTMAGENTA_EX: [
"[KRS]",
"EINKDISPLAY:",
"STATIC FRAME",
"INITIALIZING",
"SPI INITIALIZED",
"GPIO PINS",
"RESETTING",
"SSD1677",
"E-INK",
],
Fore.LIGHTGREEN_EX: ["[FNS]", "FOOTNOTE"],
}
# pylint: disable=R0912
def get_color_for_line(line: str) -> str:
"""
Classify log lines by type and assign appropriate colors.
"""
line_upper = line.upper()
if any(keyword in line_upper for keyword in ["ERROR", "[ERR]", "[SCT]", "FAILED", "WARNING"]):
return Fore.RED
if "[MEM]" in line_upper or "FREE:" in line_upper:
return Fore.CYAN
if any(keyword in line_upper for keyword in ["[GFX]", "[ERS]", "DISPLAY", "RAM WRITE", "RAM COMPLETE", "REFRESH", "POWERING ON", "FRAME BUFFER", "LUT"]):
return Fore.MAGENTA
if any(keyword in line_upper for keyword in ["[EBP]", "[BMC]", "[ZIP]", "[PARSER]", "[EHP]", "LOADING EPUB", "CACHE", "DECOMPRESSED", "PARSING"]):
return Fore.GREEN
if "[ACT]" in line_upper or "ENTERING ACTIVITY" in line_upper or "EXITING ACTIVITY" in line_upper:
return Fore.YELLOW
if any(keyword in line_upper for keyword in ["RENDERED PAGE", "[LOOP]", "DURATION", "WAIT COMPLETE"]):
return Fore.BLUE
if any(keyword in line_upper for keyword in ["[CPS]", "SETTINGS", "[CLEAR_CACHE]"]):
return Fore.LIGHTYELLOW_EX
if any(keyword in line_upper for keyword in ["ESP-ROM", "BUILD:", "RST:", "BOOT:", "SPIWP:", "MODE:", "LOAD:", "ENTRY", "[SD]", "STARTING CROSSPOINT", "VERSION"]):
return Fore.LIGHTBLACK_EX
if "[RBS]" in line_upper:
return Fore.LIGHTCYAN_EX
if "[KRS]" in line_upper:
return Fore.LIGHTMAGENTA_EX
if any(keyword in line_upper for keyword in ["EINKDISPLAY:", "STATIC FRAME", "INITIALIZING", "SPI INITIALIZED", "GPIO PINS", "RESETTING", "SSD1677", "E-INK"]):
return Fore.LIGHTMAGENTA_EX
if any(keyword in line_upper for keyword in ["[FNS]", "FOOTNOTE"]):
return Fore.LIGHTGREEN_EX
if any(keyword in line_upper for keyword in ["[CHAP]", "[OPDS]", "[COF]"]):
return Fore.LIGHTYELLOW_EX
for color, keywords in COLOR_KEYWORDS.items():
if any(keyword in line_upper for keyword in keywords):
return color
return Fore.WHITE
def parse_memory_line(line):
def parse_memory_line(line: str) -> tuple[int | None, int | None]:
"""
Extracts Free and Total bytes from the specific log line.
Format: [MEM] Free: 196344 bytes, Total: 226412 bytes, Min Free: 112620 bytes
@@ -93,12 +149,29 @@ def parse_memory_line(line):
return None, None
return None, None
def serial_worker(port, baud):
def serial_worker(port: str, baud: int, kwargs: dict[str, str]) -> None:
"""
Runs in a background thread. Handles reading serial, printing to console,
and updating the data lists.
"""
print(f"{Fore.CYAN}--- Opening {port} at {baud} baud ---{Style.RESET_ALL}")
filter_keyword = kwargs.get("filter", "").lower()
suppress = kwargs.get("suppress", "").lower()
if filter_keyword and suppress and filter_keyword == suppress:
print(
f"{Fore.YELLOW}Warning: Filter and Suppress keywords are the same. "
f"This may result in no output.{Style.RESET_ALL}"
)
if filter_keyword:
print(
f"{Fore.YELLOW}Filtering lines to only show those containing: "
f"'{filter_keyword}'{Style.RESET_ALL}"
)
if suppress:
print(
f"{Fore.YELLOW}Suppressing lines containing: '{suppress}'{Style.RESET_ALL}"
)
try:
ser = serial.Serial(port, baud, timeout=0.1)
@@ -111,7 +184,7 @@ def serial_worker(port, baud):
try:
while True:
try:
raw_data = ser.readline().decode('utf-8', errors='replace')
raw_data = ser.readline().decode("utf-8", errors="replace")
if not raw_data:
continue
@@ -127,88 +200,146 @@ def serial_worker(port, baud):
# Check for Memory Line
if "[MEM]" in formatted_line:
free_val, total_val = parse_memory_line(formatted_line)
if free_val is not None:
if free_val is not None and total_val is not None:
with data_lock:
time_data.append(pc_time)
free_mem_data.append(free_val / 1024) # Convert to KB
total_mem_data.append(total_val / 1024) # Convert to KB
free_mem_data.append(free_val / 1024) # Convert to KB
total_mem_data.append(total_val / 1024) # Convert to KB
# Apply filters
if filter_keyword and filter_keyword not in formatted_line.lower():
continue
if suppress and suppress in formatted_line.lower():
continue
# Print to console
line_color = get_color_for_line(formatted_line)
print(f"{line_color}{formatted_line}")
except OSError:
print(f"{Fore.RED}Device disconnected.{Style.RESET_ALL}")
except (OSError, UnicodeDecodeError):
print(f"{Fore.RED}Device disconnected or data error.{Style.RESET_ALL}")
break
except Exception as e:
except KeyboardInterrupt:
# If thread is killed violently (e.g. main exit), silence errors
pass
finally:
if 'ser' in locals() and ser.is_open:
if "ser" in locals() and ser.is_open:
ser.close()
def update_graph(frame):
def update_graph(frame) -> list: # pylint: disable=unused-argument
"""
Called by Matplotlib animation to redraw the chart.
"""
with data_lock:
if not time_data:
return
return []
# Convert deques to lists for plotting
x = list(time_data)
y_free = list(free_mem_data)
y_total = list(total_mem_data)
plt.cla() # Clear axis
plt.cla() # Clear axis
# Plot Total RAM
plt.plot(x, y_total, label='Total RAM (KB)', color='red', linestyle='--')
plt.plot(x, y_total, label="Total RAM (KB)", color="red", linestyle="--")
# Plot Free RAM
plt.plot(x, y_free, label='Free RAM (KB)', color='green', marker='o')
plt.plot(x, y_free, label="Free RAM (KB)", color="green", marker="o")
# Fill area under Free RAM
plt.fill_between(x, y_free, color='green', alpha=0.1)
plt.fill_between(x, y_free, color="green", alpha=0.1)
plt.title("ESP32 Memory Monitor")
plt.ylabel("Memory (KB)")
plt.xlabel("Time")
plt.legend(loc='upper left')
plt.grid(True, linestyle=':', alpha=0.6)
plt.legend(loc="upper left")
plt.grid(True, linestyle=":", alpha=0.6)
# Rotate date labels
plt.xticks(rotation=45, ha='right')
plt.xticks(rotation=45, ha="right")
plt.tight_layout()
def main():
return []
def main() -> None:
"""
Main entry point for the ESP32 monitor application.
Sets up argument parsing, starts serial monitoring thread, and initializes the memory graph.
"""
parser = argparse.ArgumentParser(description="ESP32 Monitor with Graph")
parser.add_argument("port", nargs="?", default="/dev/ttyACM0", help="Serial port")
parser.add_argument("--baud", type=int, default=115200, help="Baud rate")
if sys.platform.startswith("win"):
default_port = "COM8"
elif sys.platform.startswith("darwin"):
default_port = "/dev/cu.usbmodem101"
else:
default_port = "/dev/ttyACM0"
default_baudrate = 115200
parser.add_argument(
"port",
nargs="?",
default=default_port,
help=f"Serial port (default: {default_port})",
)
parser.add_argument(
"--baud",
type=int,
default=default_baudrate,
help=f"Baud rate (default: {default_baudrate})",
)
parser.add_argument(
"--filter",
type=str,
default="",
help="Only display lines containing this keyword (case-insensitive)",
)
parser.add_argument(
"--suppress",
type=str,
default="",
help="Suppress lines containing this keyword (case-insensitive)",
)
args = parser.parse_args()
# 1. Start the Serial Reader in a separate thread
# Daemon=True means this thread dies when the main program closes
t = threading.Thread(target=serial_worker, args=(args.port, args.baud), daemon=True)
myargs = vars(args) # Convert Namespace to dict for easier passing
t = threading.Thread(
target=serial_worker, args=(args.port, args.baud, myargs), daemon=True
)
t.start()
# 2. Set up the Graph (Main Thread)
try:
plt.style.use('light_background')
except:
import matplotlib.style as mplstyle # pylint: disable=import-outside-toplevel
default_styles = ("light_background", "ggplot", "seaborn", "dark_background", )
styles = list(mplstyle.available)
for default_style in default_styles:
if default_style in styles:
print(
f"\n{Fore.CYAN}--- Using Matplotlib style: {default_style} ---{Style.RESET_ALL}"
)
mplstyle.use(default_style)
break
except (AttributeError, ValueError):
pass
fig = plt.figure(figsize=(10, 6))
# Update graph every 1000ms
ani = animation.FuncAnimation(fig, update_graph, interval=1000)
_ = animation.FuncAnimation(
fig, update_graph, interval=1000, cache_frame_data=False
)
try:
print(f"{Fore.YELLOW}Starting Graph Window... (Close window to exit){Style.RESET_ALL}")
print(
f"{Fore.YELLOW}Starting Graph Window... (Close window to exit){Style.RESET_ALL}"
)
plt.show()
except KeyboardInterrupt:
print(f"\n{Fore.YELLOW}Exiting...{Style.RESET_ALL}")
plt.close('all') # Force close any lingering plot windows
plt.close("all") # Force close any lingering plot windows
if __name__ == "__main__":
main()

View File

@@ -1,7 +1,7 @@
#include "CrossPointSettings.h"
#include <HalStorage.h>
#include <HardwareSerial.h>
#include <SDCardManager.h>
#include <Serialization.h>
#include <cstring>
@@ -22,7 +22,7 @@ void readAndValidate(FsFile& file, uint8_t& member, const uint8_t maxValue) {
namespace {
constexpr uint8_t SETTINGS_FILE_VERSION = 1;
// Increment this when adding new persisted settings fields
constexpr uint8_t SETTINGS_COUNT = 30;
constexpr uint8_t SETTINGS_COUNT = 32;
constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin";
// Validate front button mapping to ensure each hardware button is unique.
@@ -79,10 +79,10 @@ void applyLegacyFrontButtonLayout(CrossPointSettings& settings) {
bool CrossPointSettings::saveToFile() const {
// Make sure the directory exists
SdMan.mkdir("/.crosspoint");
Storage.mkdir("/.crosspoint");
FsFile outputFile;
if (!SdMan.openFileForWrite("CPS", SETTINGS_FILE, outputFile)) {
if (!Storage.openFileForWrite("CPS", SETTINGS_FILE, outputFile)) {
return false;
}
@@ -118,6 +118,8 @@ bool CrossPointSettings::saveToFile() const {
serialization::writePod(outputFile, frontButtonRight);
serialization::writePod(outputFile, fadingFix);
serialization::writePod(outputFile, embeddedStyle);
serialization::writePod(outputFile, sleepScreenLetterboxFill);
serialization::writePod(outputFile, sleepScreenGradientDir);
// New fields added at end for backward compatibility
outputFile.close();
@@ -127,7 +129,7 @@ bool CrossPointSettings::saveToFile() const {
bool CrossPointSettings::loadFromFile() {
FsFile inputFile;
if (!SdMan.openFileForRead("CPS", SETTINGS_FILE, inputFile)) {
if (!Storage.openFileForRead("CPS", SETTINGS_FILE, inputFile)) {
return false;
}
@@ -223,6 +225,10 @@ bool CrossPointSettings::loadFromFile() {
if (++settingsRead >= fileSettingsCount) break;
serialization::readPod(inputFile, embeddedStyle);
if (++settingsRead >= fileSettingsCount) break;
readAndValidate(inputFile, sleepScreenLetterboxFill, SLEEP_SCREEN_LETTERBOX_FILL_COUNT);
if (++settingsRead >= fileSettingsCount) break;
readAndValidate(inputFile, sleepScreenGradientDir, SLEEP_SCREEN_GRADIENT_DIR_COUNT);
if (++settingsRead >= fileSettingsCount) break;
// New fields added at end for backward compatibility
} while (false);

View File

@@ -31,6 +31,14 @@ class CrossPointSettings {
INVERTED_BLACK_AND_WHITE = 2,
SLEEP_SCREEN_COVER_FILTER_COUNT
};
enum SLEEP_SCREEN_LETTERBOX_FILL {
LETTERBOX_NONE = 0,
LETTERBOX_SOLID = 1,
LETTERBOX_BLENDED = 2,
LETTERBOX_GRADIENT = 3,
SLEEP_SCREEN_LETTERBOX_FILL_COUNT
};
enum SLEEP_SCREEN_GRADIENT_DIR { GRADIENT_TO_WHITE = 0, GRADIENT_TO_BLACK = 1, SLEEP_SCREEN_GRADIENT_DIR_COUNT };
// Status bar display type enum
enum STATUS_BAR_MODE {
@@ -125,6 +133,10 @@ class CrossPointSettings {
uint8_t sleepScreenCoverMode = FIT;
// Sleep screen cover filter
uint8_t sleepScreenCoverFilter = NO_FILTER;
// Sleep screen letterbox fill mode (None / Solid / Blended / Gradient)
uint8_t sleepScreenLetterboxFill = LETTERBOX_GRADIENT;
// Sleep screen gradient direction (towards white or black)
uint8_t sleepScreenGradientDir = GRADIENT_TO_WHITE;
// Status bar settings
uint8_t statusBar = FULL;
// Text rendering settings

View File

@@ -1,7 +1,7 @@
#include "CrossPointState.h"
#include <HalStorage.h>
#include <HardwareSerial.h>
#include <SDCardManager.h>
#include <Serialization.h>
namespace {
@@ -13,7 +13,7 @@ CrossPointState CrossPointState::instance;
bool CrossPointState::saveToFile() const {
FsFile outputFile;
if (!SdMan.openFileForWrite("CPS", STATE_FILE, outputFile)) {
if (!Storage.openFileForWrite("CPS", STATE_FILE, outputFile)) {
return false;
}
@@ -28,7 +28,7 @@ bool CrossPointState::saveToFile() const {
bool CrossPointState::loadFromFile() {
FsFile inputFile;
if (!SdMan.openFileForRead("CPS", STATE_FILE, inputFile)) {
if (!Storage.openFileForRead("CPS", STATE_FILE, inputFile)) {
return false;
}

View File

@@ -15,6 +15,7 @@ class MappedInputManager {
explicit MappedInputManager(HalGPIO& gpio) : gpio(gpio) {}
void update() const { gpio.update(); }
bool wasPressed(Button button) const;
bool wasReleased(Button button) const;
bool isPressed(Button button) const;

View File

@@ -1,8 +1,8 @@
#include "RecentBooksStore.h"
#include <Epub.h>
#include <HalStorage.h>
#include <HardwareSerial.h>
#include <SDCardManager.h>
#include <Serialization.h>
#include <Xtc.h>
@@ -53,10 +53,10 @@ void RecentBooksStore::updateBook(const std::string& path, const std::string& ti
bool RecentBooksStore::saveToFile() const {
// Make sure the directory exists
SdMan.mkdir("/.crosspoint");
Storage.mkdir("/.crosspoint");
FsFile outputFile;
if (!SdMan.openFileForWrite("RBS", RECENT_BOOKS_FILE, outputFile)) {
if (!Storage.openFileForWrite("RBS", RECENT_BOOKS_FILE, outputFile)) {
return false;
}
@@ -83,7 +83,7 @@ RecentBook RecentBooksStore::getDataFromBook(std::string path) const {
lastBookFileName = path.substr(lastSlash + 1);
}
Serial.printf("Loading recent book: %s\n", path.c_str());
Serial.printf("[%lu] [RBS] Loading recent book: %s\n", millis(), path.c_str());
// If epub, try to load the metadata for title/author and cover
if (StringUtils::checkFileExtension(lastBookFileName, ".epub")) {
@@ -106,7 +106,7 @@ RecentBook RecentBooksStore::getDataFromBook(std::string path) const {
bool RecentBooksStore::loadFromFile() {
FsFile inputFile;
if (!SdMan.openFileForRead("RBS", RECENT_BOOKS_FILE, inputFile)) {
if (!Storage.openFileForRead("RBS", RECENT_BOOKS_FILE, inputFile)) {
return false;
}

105
src/SettingsList.h Normal file
View File

@@ -0,0 +1,105 @@
#pragma once
#include <vector>
#include "CrossPointSettings.h"
#include "KOReaderCredentialStore.h"
#include "activities/settings/SettingsActivity.h"
// Shared settings list used by both the device settings UI and the web settings API.
// Each entry has a key (for JSON API) and category (for grouping).
// ACTION-type entries and entries without a key are device-only.
inline std::vector<SettingInfo> getSettingsList() {
return {
// --- Display ---
SettingInfo::Enum("Sleep Screen", &CrossPointSettings::sleepScreen,
{"Dark", "Light", "Custom", "Cover", "None", "Cover + Custom"}, "sleepScreen", "Display"),
SettingInfo::Enum("Sleep Screen Cover Mode", &CrossPointSettings::sleepScreenCoverMode, {"Fit", "Crop"},
"sleepScreenCoverMode", "Display"),
SettingInfo::Enum("Sleep Screen Cover Filter", &CrossPointSettings::sleepScreenCoverFilter,
{"None", "Contrast", "Inverted"}, "sleepScreenCoverFilter", "Display"),
SettingInfo::Enum("Letterbox Fill", &CrossPointSettings::sleepScreenLetterboxFill,
{"None", "Solid", "Blended", "Gradient"}, "sleepScreenLetterboxFill", "Display"),
SettingInfo::Enum("Gradient Direction", &CrossPointSettings::sleepScreenGradientDir, {"To White", "To Black"},
"sleepScreenGradientDir", "Display"),
SettingInfo::Enum(
"Status Bar", &CrossPointSettings::statusBar,
{"None", "No Progress", "Full w/ Percentage", "Full w/ Book Bar", "Book Bar Only", "Full w/ Chapter Bar"},
"statusBar", "Display"),
SettingInfo::Enum("Hide Battery %", &CrossPointSettings::hideBatteryPercentage, {"Never", "In Reader", "Always"},
"hideBatteryPercentage", "Display"),
SettingInfo::Enum("Refresh Frequency", &CrossPointSettings::refreshFrequency,
{"1 page", "5 pages", "10 pages", "15 pages", "30 pages"}, "refreshFrequency", "Display"),
SettingInfo::Enum("UI Theme", &CrossPointSettings::uiTheme, {"Classic", "Lyra"}, "uiTheme", "Display"),
SettingInfo::Toggle("Sunlight Fading Fix", &CrossPointSettings::fadingFix, "fadingFix", "Display"),
// --- Reader ---
SettingInfo::Enum("Font Family", &CrossPointSettings::fontFamily, {"Bookerly", "Noto Sans", "Open Dyslexic"},
"fontFamily", "Reader"),
SettingInfo::Enum("Font Size", &CrossPointSettings::fontSize, {"Small", "Medium", "Large", "X Large"}, "fontSize",
"Reader"),
SettingInfo::Enum("Line Spacing", &CrossPointSettings::lineSpacing, {"Tight", "Normal", "Wide"}, "lineSpacing",
"Reader"),
SettingInfo::Value("Screen Margin", &CrossPointSettings::screenMargin, {5, 40, 5}, "screenMargin", "Reader"),
SettingInfo::Enum("Paragraph Alignment", &CrossPointSettings::paragraphAlignment,
{"Justify", "Left", "Center", "Right", "Book's Style"}, "paragraphAlignment", "Reader"),
SettingInfo::Toggle("Book's Embedded Style", &CrossPointSettings::embeddedStyle, "embeddedStyle", "Reader"),
SettingInfo::Toggle("Hyphenation", &CrossPointSettings::hyphenationEnabled, "hyphenationEnabled", "Reader"),
SettingInfo::Enum("Reading Orientation", &CrossPointSettings::orientation,
{"Portrait", "Landscape CW", "Inverted", "Landscape CCW"}, "orientation", "Reader"),
SettingInfo::Toggle("Extra Paragraph Spacing", &CrossPointSettings::extraParagraphSpacing,
"extraParagraphSpacing", "Reader"),
SettingInfo::Toggle("Text Anti-Aliasing", &CrossPointSettings::textAntiAliasing, "textAntiAliasing", "Reader"),
// --- Controls ---
SettingInfo::Enum("Side Button Layout (reader)", &CrossPointSettings::sideButtonLayout,
{"Prev, Next", "Next, Prev"}, "sideButtonLayout", "Controls"),
SettingInfo::Toggle("Long-press Chapter Skip", &CrossPointSettings::longPressChapterSkip, "longPressChapterSkip",
"Controls"),
SettingInfo::Enum("Short Power Button Click", &CrossPointSettings::shortPwrBtn, {"Ignore", "Sleep", "Page Turn"},
"shortPwrBtn", "Controls"),
// --- System ---
SettingInfo::Enum("Time to Sleep", &CrossPointSettings::sleepTimeout,
{"1 min", "5 min", "10 min", "15 min", "30 min"}, "sleepTimeout", "System"),
// --- KOReader Sync (web-only, uses KOReaderCredentialStore) ---
SettingInfo::DynamicString(
"KOReader Username", [] { return KOREADER_STORE.getUsername(); },
[](const std::string& v) {
KOREADER_STORE.setCredentials(v, KOREADER_STORE.getPassword());
KOREADER_STORE.saveToFile();
},
"koUsername", "KOReader Sync"),
SettingInfo::DynamicString(
"KOReader Password", [] { return KOREADER_STORE.getPassword(); },
[](const std::string& v) {
KOREADER_STORE.setCredentials(KOREADER_STORE.getUsername(), v);
KOREADER_STORE.saveToFile();
},
"koPassword", "KOReader Sync"),
SettingInfo::DynamicString(
"Sync Server URL", [] { return KOREADER_STORE.getServerUrl(); },
[](const std::string& v) {
KOREADER_STORE.setServerUrl(v);
KOREADER_STORE.saveToFile();
},
"koServerUrl", "KOReader Sync"),
SettingInfo::DynamicEnum(
"Document Matching", {"Filename", "Binary"},
[] { return static_cast<uint8_t>(KOREADER_STORE.getMatchMethod()); },
[](uint8_t v) {
KOREADER_STORE.setMatchMethod(static_cast<DocumentMatchMethod>(v));
KOREADER_STORE.saveToFile();
},
"koMatchMethod", "KOReader Sync"),
// --- OPDS Browser (web-only, uses CrossPointSettings char arrays) ---
SettingInfo::String("OPDS Server URL", SETTINGS.opdsServerUrl, sizeof(SETTINGS.opdsServerUrl), "opdsServerUrl",
"OPDS Browser"),
SettingInfo::String("OPDS Username", SETTINGS.opdsUsername, sizeof(SETTINGS.opdsUsername), "opdsUsername",
"OPDS Browser"),
SettingInfo::String("OPDS Password", SETTINGS.opdsPassword, sizeof(SETTINGS.opdsPassword), "opdsPassword",
"OPDS Browser"),
};
}

View File

@@ -1,7 +1,7 @@
#include "WifiCredentialStore.h"
#include <HalStorage.h>
#include <HardwareSerial.h>
#include <SDCardManager.h>
#include <Serialization.h>
// Initialize the static instance
@@ -9,7 +9,7 @@ WifiCredentialStore WifiCredentialStore::instance;
namespace {
// File format version
constexpr uint8_t WIFI_FILE_VERSION = 1;
constexpr uint8_t WIFI_FILE_VERSION = 2; // Increased version
// WiFi credentials file path
constexpr char WIFI_FILE[] = "/.crosspoint/wifi.bin";
@@ -29,15 +29,16 @@ void WifiCredentialStore::obfuscate(std::string& data) const {
bool WifiCredentialStore::saveToFile() const {
// Make sure the directory exists
SdMan.mkdir("/.crosspoint");
Storage.mkdir("/.crosspoint");
FsFile file;
if (!SdMan.openFileForWrite("WCS", WIFI_FILE, file)) {
if (!Storage.openFileForWrite("WCS", WIFI_FILE, file)) {
return false;
}
// Write header
serialization::writePod(file, WIFI_FILE_VERSION);
serialization::writeString(file, lastConnectedSsid); // Save last connected SSID
serialization::writePod(file, static_cast<uint8_t>(credentials.size()));
// Write each credential
@@ -60,19 +61,25 @@ bool WifiCredentialStore::saveToFile() const {
bool WifiCredentialStore::loadFromFile() {
FsFile file;
if (!SdMan.openFileForRead("WCS", WIFI_FILE, file)) {
if (!Storage.openFileForRead("WCS", WIFI_FILE, file)) {
return false;
}
// Read and verify version
uint8_t version;
serialization::readPod(file, version);
if (version != WIFI_FILE_VERSION) {
if (version > WIFI_FILE_VERSION) {
Serial.printf("[%lu] [WCS] Unknown file version: %u\n", millis(), version);
file.close();
return false;
}
if (version >= 2) {
serialization::readString(file, lastConnectedSsid);
} else {
lastConnectedSsid.clear();
}
// Read credential count
uint8_t count;
serialization::readPod(file, count);
@@ -128,6 +135,9 @@ bool WifiCredentialStore::removeCredential(const std::string& ssid) {
if (cred != credentials.end()) {
credentials.erase(cred);
Serial.printf("[%lu] [WCS] Removed credentials for: %s\n", millis(), ssid.c_str());
if (ssid == lastConnectedSsid) {
clearLastConnectedSsid();
}
return saveToFile();
}
return false; // Not found
@@ -146,8 +156,25 @@ const WifiCredential* WifiCredentialStore::findCredential(const std::string& ssi
bool WifiCredentialStore::hasSavedCredential(const std::string& ssid) const { return findCredential(ssid) != nullptr; }
void WifiCredentialStore::setLastConnectedSsid(const std::string& ssid) {
if (lastConnectedSsid != ssid) {
lastConnectedSsid = ssid;
saveToFile();
}
}
const std::string& WifiCredentialStore::getLastConnectedSsid() const { return lastConnectedSsid; }
void WifiCredentialStore::clearLastConnectedSsid() {
if (!lastConnectedSsid.empty()) {
lastConnectedSsid.clear();
saveToFile();
}
}
void WifiCredentialStore::clearAll() {
credentials.clear();
lastConnectedSsid.clear();
saveToFile();
Serial.printf("[%lu] [WCS] Cleared all WiFi credentials\n", millis());
}

View File

@@ -16,6 +16,7 @@ class WifiCredentialStore {
private:
static WifiCredentialStore instance;
std::vector<WifiCredential> credentials;
std::string lastConnectedSsid;
static constexpr size_t MAX_NETWORKS = 8;
@@ -48,6 +49,11 @@ class WifiCredentialStore {
// Check if a network is saved
bool hasSavedCredential(const std::string& ssid) const;
// Last connected network
void setLastConnectedSsid(const std::string& ssid);
const std::string& getLastConnectedSsid() const;
void clearLastConnectedSsid();
// Clear all credentials
void clearAll();
};

View File

@@ -1,11 +1,15 @@
#include "SleepActivity.h"
#include <BitmapHelpers.h>
#include <Epub.h>
#include <GfxRenderer.h>
#include <SDCardManager.h>
#include <HalStorage.h>
#include <Serialization.h>
#include <Txt.h>
#include <Xtc.h>
#include <algorithm>
#include "CrossPointSettings.h"
#include "CrossPointState.h"
#include "components/UITheme.h"
@@ -13,6 +17,364 @@
#include "images/Logo120.h"
#include "util/StringUtils.h"
namespace {
// Number of source pixels along the image edge to average for the gradient color
constexpr int EDGE_SAMPLE_DEPTH = 20;
// Map a 2-bit quantized pixel value to an 8-bit grayscale value
constexpr uint8_t val2bitToGray(uint8_t val2bit) { return val2bit * 85; }
// Edge gradient data produced by sampleBitmapEdges and consumed by drawLetterboxGradients.
// edgeA is the "first" edge (top or left), edgeB is the "second" edge (bottom or right).
struct LetterboxGradientData {
uint8_t* edgeA = nullptr;
uint8_t* edgeB = nullptr;
int edgeCount = 0;
int letterboxA = 0; // pixel size of the first letterbox area (top or left)
int letterboxB = 0; // pixel size of the second letterbox area (bottom or right)
bool horizontal = false; // true = top/bottom letterbox, false = left/right
void free() {
::free(edgeA);
::free(edgeB);
edgeA = nullptr;
edgeB = nullptr;
}
};
// Binary cache version for edge data files
constexpr uint8_t EDGE_CACHE_VERSION = 1;
// Load cached edge data from a binary file. Returns true if the cache was valid and loaded successfully.
// Validates cache version and screen dimensions to detect stale data.
bool loadEdgeCache(const std::string& path, int screenWidth, int screenHeight, LetterboxGradientData& data) {
FsFile file;
if (!Storage.openFileForRead("SLP", path, file)) return false;
uint8_t version;
serialization::readPod(file, version);
if (version != EDGE_CACHE_VERSION) {
file.close();
return false;
}
uint16_t cachedW, cachedH;
serialization::readPod(file, cachedW);
serialization::readPod(file, cachedH);
if (cachedW != static_cast<uint16_t>(screenWidth) || cachedH != static_cast<uint16_t>(screenHeight)) {
file.close();
return false;
}
uint8_t horizontal;
serialization::readPod(file, horizontal);
data.horizontal = (horizontal != 0);
uint16_t edgeCount;
serialization::readPod(file, edgeCount);
data.edgeCount = edgeCount;
int16_t lbA, lbB;
serialization::readPod(file, lbA);
serialization::readPod(file, lbB);
data.letterboxA = lbA;
data.letterboxB = lbB;
if (edgeCount == 0 || edgeCount > 2048) {
file.close();
return false;
}
data.edgeA = static_cast<uint8_t*>(malloc(edgeCount));
data.edgeB = static_cast<uint8_t*>(malloc(edgeCount));
if (!data.edgeA || !data.edgeB) {
data.free();
file.close();
return false;
}
if (file.read(data.edgeA, edgeCount) != static_cast<int>(edgeCount) ||
file.read(data.edgeB, edgeCount) != static_cast<int>(edgeCount)) {
data.free();
file.close();
return false;
}
file.close();
Serial.printf("[%lu] [SLP] Loaded edge cache from %s (%d edges)\n", millis(), path.c_str(), edgeCount);
return true;
}
// Save edge data to a binary cache file for reuse on subsequent sleep screens.
bool saveEdgeCache(const std::string& path, int screenWidth, int screenHeight, const LetterboxGradientData& data) {
if (!data.edgeA || !data.edgeB || data.edgeCount <= 0) return false;
FsFile file;
if (!Storage.openFileForWrite("SLP", path, file)) return false;
serialization::writePod(file, EDGE_CACHE_VERSION);
serialization::writePod(file, static_cast<uint16_t>(screenWidth));
serialization::writePod(file, static_cast<uint16_t>(screenHeight));
serialization::writePod(file, static_cast<uint8_t>(data.horizontal ? 1 : 0));
serialization::writePod(file, static_cast<uint16_t>(data.edgeCount));
serialization::writePod(file, static_cast<int16_t>(data.letterboxA));
serialization::writePod(file, static_cast<int16_t>(data.letterboxB));
file.write(data.edgeA, data.edgeCount);
file.write(data.edgeB, data.edgeCount);
file.close();
Serial.printf("[%lu] [SLP] Saved edge cache to %s (%d edges)\n", millis(), path.c_str(), data.edgeCount);
return true;
}
// Read the bitmap once to sample the first/last EDGE_SAMPLE_DEPTH rows or columns.
// Returns edge color arrays in source pixel resolution. Caller must call data.free() when done.
// After sampling the bitmap is rewound via rewindToData().
LetterboxGradientData sampleBitmapEdges(const Bitmap& bitmap, int imgX, int imgY, int pageWidth, int pageHeight,
float scale, float cropX, float cropY) {
LetterboxGradientData data;
const int cropPixX = static_cast<int>(std::floor(bitmap.getWidth() * cropX / 2.0f));
const int cropPixY = static_cast<int>(std::floor(bitmap.getHeight() * cropY / 2.0f));
const int visibleWidth = bitmap.getWidth() - 2 * cropPixX;
const int visibleHeight = bitmap.getHeight() - 2 * cropPixY;
if (visibleWidth <= 0 || visibleHeight <= 0) return data;
const int outputRowSize = (bitmap.getWidth() + 3) / 4;
auto* outputRow = static_cast<uint8_t*>(malloc(outputRowSize));
auto* rowBytes = static_cast<uint8_t*>(malloc(bitmap.getRowBytes()));
if (!outputRow || !rowBytes) {
::free(outputRow);
::free(rowBytes);
return data;
}
if (imgY > 0) {
// Top/bottom letterboxing -- sample per-column averages of first/last N rows
data.horizontal = true;
data.edgeCount = visibleWidth;
const int scaledHeight = static_cast<int>(std::round(static_cast<float>(visibleHeight) * scale));
data.letterboxA = imgY;
data.letterboxB = pageHeight - imgY - scaledHeight;
if (data.letterboxB < 0) data.letterboxB = 0;
const int sampleRows = std::min(EDGE_SAMPLE_DEPTH, visibleHeight);
auto* accumTop = static_cast<uint32_t*>(calloc(visibleWidth, sizeof(uint32_t)));
auto* accumBot = static_cast<uint32_t*>(calloc(visibleWidth, sizeof(uint32_t)));
data.edgeA = static_cast<uint8_t*>(malloc(visibleWidth));
data.edgeB = static_cast<uint8_t*>(malloc(visibleWidth));
if (!accumTop || !accumBot || !data.edgeA || !data.edgeB) {
::free(accumTop);
::free(accumBot);
data.free();
::free(outputRow);
::free(rowBytes);
return data;
}
for (int bmpY = 0; bmpY < bitmap.getHeight(); bmpY++) {
if (bitmap.readNextRow(outputRow, rowBytes) != BmpReaderError::Ok) break;
const int logicalY = bitmap.isTopDown() ? bmpY : bitmap.getHeight() - 1 - bmpY;
if (logicalY < cropPixY || logicalY >= bitmap.getHeight() - cropPixY) continue;
const int outY = logicalY - cropPixY;
const bool inTop = (outY < sampleRows);
const bool inBot = (outY >= visibleHeight - sampleRows);
if (!inTop && !inBot) continue;
for (int bmpX = cropPixX; bmpX < bitmap.getWidth() - cropPixX; bmpX++) {
const int outX = bmpX - cropPixX;
const uint8_t val = (outputRow[bmpX / 4] >> (6 - ((bmpX * 2) % 8))) & 0x3;
const uint8_t gray = val2bitToGray(val);
if (inTop) accumTop[outX] += gray;
if (inBot) accumBot[outX] += gray;
}
}
for (int i = 0; i < visibleWidth; i++) {
data.edgeA[i] = static_cast<uint8_t>(accumTop[i] / sampleRows);
data.edgeB[i] = static_cast<uint8_t>(accumBot[i] / sampleRows);
}
::free(accumTop);
::free(accumBot);
} else if (imgX > 0) {
// Left/right letterboxing -- sample per-row averages of first/last N columns
data.horizontal = false;
data.edgeCount = visibleHeight;
const int scaledWidth = static_cast<int>(std::round(static_cast<float>(visibleWidth) * scale));
data.letterboxA = imgX;
data.letterboxB = pageWidth - imgX - scaledWidth;
if (data.letterboxB < 0) data.letterboxB = 0;
const int sampleCols = std::min(EDGE_SAMPLE_DEPTH, visibleWidth);
auto* accumLeft = static_cast<uint32_t*>(calloc(visibleHeight, sizeof(uint32_t)));
auto* accumRight = static_cast<uint32_t*>(calloc(visibleHeight, sizeof(uint32_t)));
data.edgeA = static_cast<uint8_t*>(malloc(visibleHeight));
data.edgeB = static_cast<uint8_t*>(malloc(visibleHeight));
if (!accumLeft || !accumRight || !data.edgeA || !data.edgeB) {
::free(accumLeft);
::free(accumRight);
data.free();
::free(outputRow);
::free(rowBytes);
return data;
}
for (int bmpY = 0; bmpY < bitmap.getHeight(); bmpY++) {
if (bitmap.readNextRow(outputRow, rowBytes) != BmpReaderError::Ok) break;
const int logicalY = bitmap.isTopDown() ? bmpY : bitmap.getHeight() - 1 - bmpY;
if (logicalY < cropPixY || logicalY >= bitmap.getHeight() - cropPixY) continue;
const int outY = logicalY - cropPixY;
// Sample left edge columns
for (int bmpX = cropPixX; bmpX < cropPixX + sampleCols; bmpX++) {
const uint8_t val = (outputRow[bmpX / 4] >> (6 - ((bmpX * 2) % 8))) & 0x3;
accumLeft[outY] += val2bitToGray(val);
}
// Sample right edge columns
for (int bmpX = bitmap.getWidth() - cropPixX - sampleCols; bmpX < bitmap.getWidth() - cropPixX; bmpX++) {
const uint8_t val = (outputRow[bmpX / 4] >> (6 - ((bmpX * 2) % 8))) & 0x3;
accumRight[outY] += val2bitToGray(val);
}
}
for (int i = 0; i < visibleHeight; i++) {
data.edgeA[i] = static_cast<uint8_t>(accumLeft[i] / sampleCols);
data.edgeB[i] = static_cast<uint8_t>(accumRight[i] / sampleCols);
}
::free(accumLeft);
::free(accumRight);
}
::free(outputRow);
::free(rowBytes);
bitmap.rewindToData();
return data;
}
// Draw dithered fills in the letterbox areas using the sampled edge colors.
// fillMode selects the fill algorithm: SOLID (single dominant shade), BLENDED (per-pixel edge color),
// or GRADIENT (per-pixel edge color interpolated toward targetColor).
// targetColor is the color the gradient fades toward (255=white, 0=black); only used in GRADIENT mode.
// Must be called once per render pass (BW, GRAYSCALE_LSB, GRAYSCALE_MSB).
void drawLetterboxFill(GfxRenderer& renderer, const LetterboxGradientData& data, float scale, uint8_t fillMode,
int targetColor) {
if (!data.edgeA || !data.edgeB || data.edgeCount <= 0) return;
const bool isSolid = (fillMode == CrossPointSettings::SLEEP_SCREEN_LETTERBOX_FILL::LETTERBOX_SOLID);
const bool isGradient = (fillMode == CrossPointSettings::SLEEP_SCREEN_LETTERBOX_FILL::LETTERBOX_GRADIENT);
// For SOLID mode, compute the dominant (average) shade for each edge once
uint8_t solidColorA = 0, solidColorB = 0;
if (isSolid) {
uint32_t sumA = 0, sumB = 0;
for (int i = 0; i < data.edgeCount; i++) {
sumA += data.edgeA[i];
sumB += data.edgeB[i];
}
solidColorA = static_cast<uint8_t>(sumA / data.edgeCount);
solidColorB = static_cast<uint8_t>(sumB / data.edgeCount);
}
// Helper: compute gray value for a pixel given the edge color and interpolation factor t (0..1)
// GRADIENT interpolates from edgeColor toward targetColor; SOLID and BLENDED return edgeColor directly.
auto computeGray = [&](int edgeColor, float t) -> int {
if (isGradient) return edgeColor + static_cast<int>(static_cast<float>(targetColor - edgeColor) * t);
return edgeColor;
};
if (data.horizontal) {
// Top letterbox
if (data.letterboxA > 0) {
const int imgTopY = data.letterboxA;
for (int screenY = 0; screenY < imgTopY; screenY++) {
const float t = static_cast<float>(imgTopY - screenY) / static_cast<float>(imgTopY);
for (int screenX = 0; screenX < renderer.getScreenWidth(); screenX++) {
int edgeColor;
if (isSolid) {
edgeColor = solidColorA;
} else {
int srcCol = static_cast<int>(screenX / scale);
srcCol = std::max(0, std::min(srcCol, data.edgeCount - 1));
edgeColor = data.edgeA[srcCol];
}
const int gray = computeGray(edgeColor, t);
renderer.drawPixelGray(screenX, screenY, quantizeNoiseDither(gray, screenX, screenY));
}
}
}
// Bottom letterbox
if (data.letterboxB > 0) {
const int imgBottomY = renderer.getScreenHeight() - data.letterboxB;
for (int screenY = imgBottomY; screenY < renderer.getScreenHeight(); screenY++) {
const float t = static_cast<float>(screenY - imgBottomY + 1) / static_cast<float>(data.letterboxB);
for (int screenX = 0; screenX < renderer.getScreenWidth(); screenX++) {
int edgeColor;
if (isSolid) {
edgeColor = solidColorB;
} else {
int srcCol = static_cast<int>(screenX / scale);
srcCol = std::max(0, std::min(srcCol, data.edgeCount - 1));
edgeColor = data.edgeB[srcCol];
}
const int gray = computeGray(edgeColor, t);
renderer.drawPixelGray(screenX, screenY, quantizeNoiseDither(gray, screenX, screenY));
}
}
}
} else {
// Left letterbox
if (data.letterboxA > 0) {
const int imgLeftX = data.letterboxA;
for (int screenX = 0; screenX < imgLeftX; screenX++) {
const float t = static_cast<float>(imgLeftX - screenX) / static_cast<float>(imgLeftX);
for (int screenY = 0; screenY < renderer.getScreenHeight(); screenY++) {
int edgeColor;
if (isSolid) {
edgeColor = solidColorA;
} else {
int srcRow = static_cast<int>(screenY / scale);
srcRow = std::max(0, std::min(srcRow, data.edgeCount - 1));
edgeColor = data.edgeA[srcRow];
}
const int gray = computeGray(edgeColor, t);
renderer.drawPixelGray(screenX, screenY, quantizeNoiseDither(gray, screenX, screenY));
}
}
}
// Right letterbox
if (data.letterboxB > 0) {
const int imgRightX = renderer.getScreenWidth() - data.letterboxB;
for (int screenX = imgRightX; screenX < renderer.getScreenWidth(); screenX++) {
const float t = static_cast<float>(screenX - imgRightX + 1) / static_cast<float>(data.letterboxB);
for (int screenY = 0; screenY < renderer.getScreenHeight(); screenY++) {
int edgeColor;
if (isSolid) {
edgeColor = solidColorB;
} else {
int srcRow = static_cast<int>(screenY / scale);
srcRow = std::max(0, std::min(srcRow, data.edgeCount - 1));
edgeColor = data.edgeB[srcRow];
}
const int gray = computeGray(edgeColor, t);
renderer.drawPixelGray(screenX, screenY, quantizeNoiseDither(gray, screenX, screenY));
}
}
}
}
}
} // namespace
void SleepActivity::onEnter() {
Activity::onEnter();
GUI.drawPopup(renderer, "Entering Sleep...");
@@ -32,7 +394,7 @@ void SleepActivity::onEnter() {
void SleepActivity::renderCustomSleepScreen() const {
// Check if we have a /sleep directory
auto dir = SdMan.open("/sleep");
auto dir = Storage.open("/sleep");
if (dir && dir.isDirectory()) {
std::vector<std::string> files;
char name[500];
@@ -75,7 +437,7 @@ void SleepActivity::renderCustomSleepScreen() const {
APP_STATE.saveToFile();
const auto filename = "/sleep/" + files[randomFileIndex];
FsFile file;
if (SdMan.openFileForRead("SLP", filename, file)) {
if (Storage.openFileForRead("SLP", filename, file)) {
Serial.printf("[%lu] [SLP] Randomly loading: /sleep/%s\n", millis(), files[randomFileIndex].c_str());
delay(100);
Bitmap bitmap(file, true);
@@ -92,7 +454,7 @@ void SleepActivity::renderCustomSleepScreen() const {
// Look for sleep.bmp on the root of the sd card to determine if we should
// render a custom sleep screen instead of the default.
FsFile file;
if (SdMan.openFileForRead("SLP", "/sleep.bmp", file)) {
if (Storage.openFileForRead("SLP", "/sleep.bmp", file)) {
Bitmap bitmap(file, true);
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
Serial.printf("[%lu] [SLP] Loading: /sleep.bmp\n", millis());
@@ -121,7 +483,7 @@ void SleepActivity::renderDefaultSleepScreen() const {
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
}
void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap) const {
void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap, const std::string& edgeCachePath) const {
int x, y;
const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = renderer.getScreenHeight();
@@ -129,45 +491,79 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap) const {
Serial.printf("[%lu] [SLP] bitmap %d x %d, screen %d x %d\n", millis(), bitmap.getWidth(), bitmap.getHeight(),
pageWidth, pageHeight);
if (bitmap.getWidth() > pageWidth || bitmap.getHeight() > pageHeight) {
// image will scale, make sure placement is right
float ratio = static_cast<float>(bitmap.getWidth()) / static_cast<float>(bitmap.getHeight());
const float screenRatio = static_cast<float>(pageWidth) / static_cast<float>(pageHeight);
Serial.printf("[%lu] [SLP] bitmap ratio: %f, screen ratio: %f\n", millis(), ratio, screenRatio);
if (ratio > screenRatio) {
// image wider than viewport ratio, scaled down image needs to be centered vertically
if (SETTINGS.sleepScreenCoverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::CROP) {
cropX = 1.0f - (screenRatio / ratio);
Serial.printf("[%lu] [SLP] Cropping bitmap x: %f\n", millis(), cropX);
ratio = (1.0f - cropX) * static_cast<float>(bitmap.getWidth()) / static_cast<float>(bitmap.getHeight());
}
x = 0;
y = std::round((static_cast<float>(pageHeight) - static_cast<float>(pageWidth) / ratio) / 2);
Serial.printf("[%lu] [SLP] Centering with ratio %f to y=%d\n", millis(), ratio, y);
} else {
// image taller than viewport ratio, scaled down image needs to be centered horizontally
if (SETTINGS.sleepScreenCoverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::CROP) {
cropY = 1.0f - (ratio / screenRatio);
Serial.printf("[%lu] [SLP] Cropping bitmap y: %f\n", millis(), cropY);
ratio = static_cast<float>(bitmap.getWidth()) / ((1.0f - cropY) * static_cast<float>(bitmap.getHeight()));
}
x = std::round((static_cast<float>(pageWidth) - static_cast<float>(pageHeight) * ratio) / 2);
y = 0;
Serial.printf("[%lu] [SLP] Centering with ratio %f to x=%d\n", millis(), ratio, x);
// Always compute aspect-ratio-preserving scale and position (supports both larger and smaller images)
float ratio = static_cast<float>(bitmap.getWidth()) / static_cast<float>(bitmap.getHeight());
const float screenRatio = static_cast<float>(pageWidth) / static_cast<float>(pageHeight);
Serial.printf("[%lu] [SLP] bitmap ratio: %f, screen ratio: %f\n", millis(), ratio, screenRatio);
if (ratio > screenRatio) {
// image wider than viewport ratio, needs to be centered vertically
if (SETTINGS.sleepScreenCoverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::CROP) {
cropX = 1.0f - (screenRatio / ratio);
Serial.printf("[%lu] [SLP] Cropping bitmap x: %f\n", millis(), cropX);
ratio = (1.0f - cropX) * static_cast<float>(bitmap.getWidth()) / static_cast<float>(bitmap.getHeight());
}
x = 0;
y = std::round((static_cast<float>(pageHeight) - static_cast<float>(pageWidth) / ratio) / 2);
Serial.printf("[%lu] [SLP] Centering with ratio %f to y=%d\n", millis(), ratio, y);
} else {
// center the image
x = (pageWidth - bitmap.getWidth()) / 2;
y = (pageHeight - bitmap.getHeight()) / 2;
// image taller than or equal to viewport ratio, needs to be centered horizontally
if (SETTINGS.sleepScreenCoverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::CROP) {
cropY = 1.0f - (ratio / screenRatio);
Serial.printf("[%lu] [SLP] Cropping bitmap y: %f\n", millis(), cropY);
ratio = static_cast<float>(bitmap.getWidth()) / ((1.0f - cropY) * static_cast<float>(bitmap.getHeight()));
}
x = std::round((static_cast<float>(pageWidth) - static_cast<float>(pageHeight) * ratio) / 2);
y = 0;
Serial.printf("[%lu] [SLP] Centering with ratio %f to x=%d\n", millis(), ratio, x);
}
Serial.printf("[%lu] [SLP] drawing to %d x %d\n", millis(), x, y);
// Compute the scale factor (same formula as drawBitmap) so we can map screen coords to source coords
const float effectiveWidth = (1.0f - cropX) * bitmap.getWidth();
const float effectiveHeight = (1.0f - cropY) * bitmap.getHeight();
const float scale =
std::min(static_cast<float>(pageWidth) / effectiveWidth, static_cast<float>(pageHeight) / effectiveHeight);
// Determine letterbox fill settings
const uint8_t fillMode = SETTINGS.sleepScreenLetterboxFill;
const bool wantFill = (fillMode != CrossPointSettings::SLEEP_SCREEN_LETTERBOX_FILL::LETTERBOX_NONE);
const int targetColor =
(SETTINGS.sleepScreenGradientDir == CrossPointSettings::SLEEP_SCREEN_GRADIENT_DIR::GRADIENT_TO_BLACK) ? 0 : 255;
static const char* fillModeNames[] = {"none", "solid", "blended", "gradient"};
const char* fillModeName = (fillMode < 4) ? fillModeNames[fillMode] : "unknown";
// Load cached edge data or sample from bitmap (first pass over bitmap, then rewind)
LetterboxGradientData gradientData;
const bool hasLetterbox = (x > 0 || y > 0);
if (hasLetterbox && wantFill) {
bool cacheLoaded = false;
if (!edgeCachePath.empty()) {
cacheLoaded = loadEdgeCache(edgeCachePath, pageWidth, pageHeight, gradientData);
}
if (!cacheLoaded) {
Serial.printf("[%lu] [SLP] Letterbox detected (x=%d, y=%d), sampling edges for %s fill\n", millis(), x, y,
fillModeName);
gradientData = sampleBitmapEdges(bitmap, x, y, pageWidth, pageHeight, scale, cropX, cropY);
if (!edgeCachePath.empty() && gradientData.edgeA) {
saveEdgeCache(edgeCachePath, pageWidth, pageHeight, gradientData);
}
}
}
renderer.clearScreen();
const bool hasGreyscale = bitmap.hasGreyscale() &&
SETTINGS.sleepScreenCoverFilter == CrossPointSettings::SLEEP_SCREEN_COVER_FILTER::NO_FILTER;
// Draw letterbox fill (BW pass)
if (gradientData.edgeA) {
drawLetterboxFill(renderer, gradientData, scale, fillMode, targetColor);
}
renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY);
if (SETTINGS.sleepScreenCoverFilter == CrossPointSettings::SLEEP_SCREEN_COVER_FILTER::INVERTED_BLACK_AND_WHITE) {
@@ -180,18 +576,26 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap) const {
bitmap.rewindToData();
renderer.clearScreen(0x00);
renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB);
if (gradientData.edgeA) {
drawLetterboxFill(renderer, gradientData, scale, fillMode, targetColor);
}
renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY);
renderer.copyGrayscaleLsbBuffers();
bitmap.rewindToData();
renderer.clearScreen(0x00);
renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB);
if (gradientData.edgeA) {
drawLetterboxFill(renderer, gradientData, scale, fillMode, targetColor);
}
renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY);
renderer.copyGrayscaleMsbBuffers();
renderer.displayGrayBuffer();
renderer.setRenderMode(GfxRenderer::BW);
}
gradientData.free();
}
void SleepActivity::renderCoverSleepScreen() const {
@@ -218,12 +622,12 @@ void SleepActivity::renderCoverSleepScreen() const {
// Handle XTC file
Xtc lastXtc(APP_STATE.openEpubPath, "/.crosspoint");
if (!lastXtc.load()) {
Serial.println("[SLP] Failed to load last XTC");
Serial.printf("[%lu] [SLP] Failed to load last XTC\n", millis());
return (this->*renderNoCoverSleepScreen)();
}
if (!lastXtc.generateCoverBmp()) {
Serial.println("[SLP] Failed to generate XTC cover bmp");
Serial.printf("[%lu] [SLP] Failed to generate XTC cover bmp\n", millis());
return (this->*renderNoCoverSleepScreen)();
}
@@ -232,12 +636,12 @@ void SleepActivity::renderCoverSleepScreen() const {
// Handle TXT file - looks for cover image in the same folder
Txt lastTxt(APP_STATE.openEpubPath, "/.crosspoint");
if (!lastTxt.load()) {
Serial.println("[SLP] Failed to load last TXT");
Serial.printf("[%lu] [SLP] Failed to load last TXT\n", millis());
return (this->*renderNoCoverSleepScreen)();
}
if (!lastTxt.generateCoverBmp()) {
Serial.println("[SLP] No cover image found for TXT file");
Serial.printf("[%lu] [SLP] No cover image found for TXT file\n", millis());
return (this->*renderNoCoverSleepScreen)();
}
@@ -247,12 +651,12 @@ void SleepActivity::renderCoverSleepScreen() const {
Epub lastEpub(APP_STATE.openEpubPath, "/.crosspoint");
// Skip loading css since we only need metadata here
if (!lastEpub.load(true, true)) {
Serial.println("[SLP] Failed to load last epub");
Serial.printf("[%lu] [SLP] Failed to load last epub\n", millis());
return (this->*renderNoCoverSleepScreen)();
}
if (!lastEpub.generateCoverBmp(cropped)) {
Serial.println("[SLP] Failed to generate cover bmp");
Serial.printf("[%lu] [SLP] Failed to generate cover bmp\n", millis());
return (this->*renderNoCoverSleepScreen)();
}
@@ -261,12 +665,18 @@ void SleepActivity::renderCoverSleepScreen() const {
return (this->*renderNoCoverSleepScreen)();
}
// Derive edge cache path from cover BMP path (e.g. cover.bmp -> cover_edges.bin)
std::string edgeCachePath;
if (coverBmpPath.size() > 4) {
edgeCachePath = coverBmpPath.substr(0, coverBmpPath.size() - 4) + "_edges.bin";
}
FsFile file;
if (SdMan.openFileForRead("SLP", coverBmpPath, file)) {
if (Storage.openFileForRead("SLP", coverBmpPath, file)) {
Bitmap bitmap(file);
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
Serial.printf("[SLP] Rendering sleep cover: %s\n", coverBmpPath.c_str());
renderBitmapSleepScreen(bitmap);
Serial.printf("[%lu] [SLP] Rendering sleep cover: %s\n", millis(), coverBmpPath.c_str());
renderBitmapSleepScreen(bitmap, edgeCachePath);
return;
}
}

View File

@@ -1,4 +1,6 @@
#pragma once
#include <string>
#include "../Activity.h"
class Bitmap;
@@ -13,6 +15,6 @@ class SleepActivity final : public Activity {
void renderDefaultSleepScreen() const;
void renderCustomSleepScreen() const;
void renderCoverSleepScreen() const;
void renderBitmapSleepScreen(const Bitmap& bitmap) const;
void renderBitmapSleepScreen(const Bitmap& bitmap, const std::string& edgeCachePath = "") const;
void renderBlankSleepScreen() const;
};

View File

@@ -17,7 +17,6 @@
namespace {
constexpr int PAGE_ITEMS = 23;
constexpr int SKIP_PAGE_MS = 700;
} // namespace
void OpdsBookBrowserActivity::taskTrampoline(void* param) {
@@ -118,12 +117,6 @@ void OpdsBookBrowserActivity::loop() {
// Handle browsing state
if (state == BrowserState::BROWSING) {
const bool prevReleased = mappedInput.wasReleased(MappedInputManager::Button::Up) ||
mappedInput.wasReleased(MappedInputManager::Button::Left);
const bool nextReleased = mappedInput.wasReleased(MappedInputManager::Button::Down) ||
mappedInput.wasReleased(MappedInputManager::Button::Right);
const bool skipPage = mappedInput.getHeldTime() > SKIP_PAGE_MS;
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (!entries.empty()) {
const auto& entry = entries[selectorIndex];
@@ -135,20 +128,29 @@ void OpdsBookBrowserActivity::loop() {
}
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
navigateBack();
} else if (prevReleased && !entries.empty()) {
if (skipPage) {
selectorIndex = ((selectorIndex / PAGE_ITEMS - 1) * PAGE_ITEMS + entries.size()) % entries.size();
} else {
selectorIndex = (selectorIndex + entries.size() - 1) % entries.size();
}
updateRequired = true;
} else if (nextReleased && !entries.empty()) {
if (skipPage) {
selectorIndex = ((selectorIndex / PAGE_ITEMS + 1) * PAGE_ITEMS) % entries.size();
} else {
selectorIndex = (selectorIndex + 1) % entries.size();
}
updateRequired = true;
}
// Handle navigation
if (!entries.empty()) {
buttonNavigator.onNextRelease([this] {
selectorIndex = ButtonNavigator::nextIndex(selectorIndex, entries.size());
updateRequired = true;
});
buttonNavigator.onPreviousRelease([this] {
selectorIndex = ButtonNavigator::previousIndex(selectorIndex, entries.size());
updateRequired = true;
});
buttonNavigator.onNextContinuous([this] {
selectorIndex = ButtonNavigator::nextPageIndex(selectorIndex, entries.size(), PAGE_ITEMS);
updateRequired = true;
});
buttonNavigator.onPreviousContinuous([this] {
selectorIndex = ButtonNavigator::previousPageIndex(selectorIndex, entries.size(), PAGE_ITEMS);
updateRequired = true;
});
}
}
}

View File

@@ -9,6 +9,7 @@
#include <vector>
#include "../ActivityWithSubactivity.h"
#include "util/ButtonNavigator.h"
/**
* Activity for browsing and downloading books from an OPDS server.
@@ -37,6 +38,7 @@ class OpdsBookBrowserActivity final : public ActivityWithSubactivity {
private:
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
ButtonNavigator buttonNavigator;
bool updateRequired = false;
BrowserState state = BrowserState::LOADING;
@@ -62,4 +64,5 @@ class OpdsBookBrowserActivity final : public ActivityWithSubactivity {
void navigateToEntry(const OpdsEntry& entry);
void navigateBack();
void downloadBook(const OpdsEntry& book);
bool preventAutoSleep() override { return true; }
};

View File

@@ -3,7 +3,7 @@
#include <Bitmap.h>
#include <Epub.h>
#include <GfxRenderer.h>
#include <SDCardManager.h>
#include <HalStorage.h>
#include <Utf8.h>
#include <Xtc.h>
@@ -47,7 +47,7 @@ void HomeActivity::loadRecentBooks(int maxBooks) {
}
// Skip if file no longer exists
if (!SdMan.exists(book.path.c_str())) {
if (!Storage.exists(book.path.c_str())) {
continue;
}
@@ -64,7 +64,7 @@ void HomeActivity::loadRecentCovers(int coverHeight) {
for (RecentBook& book : recentBooks) {
if (!book.coverBmpPath.empty()) {
std::string coverPath = UITheme::getCoverThumbPath(book.coverBmpPath, coverHeight);
if (!SdMan.exists(coverPath.c_str())) {
if (!Storage.exists(coverPath.c_str())) {
// If epub, try to load the metadata for title/author and cover
if (StringUtils::checkFileExtension(book.path, ".epub")) {
Epub epub(book.path, "/.crosspoint");
@@ -196,13 +196,18 @@ void HomeActivity::freeCoverBuffer() {
}
void HomeActivity::loop() {
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);
const int menuCount = getMenuItemCount();
buttonNavigator.onNext([this, menuCount] {
selectorIndex = ButtonNavigator::nextIndex(selectorIndex, menuCount);
updateRequired = true;
});
buttonNavigator.onPrevious([this, menuCount] {
selectorIndex = ButtonNavigator::previousIndex(selectorIndex, menuCount);
updateRequired = true;
});
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
// Calculate dynamic indices based on which options are available
int idx = 0;
@@ -226,12 +231,6 @@ void HomeActivity::loop() {
} else if (menuSelectedIndex == settingsIdx) {
onSettingsOpen();
}
} else if (prevPressed) {
selectorIndex = (selectorIndex + menuCount - 1) % menuCount;
updateRequired = true;
} else if (nextPressed) {
selectorIndex = (selectorIndex + 1) % menuCount;
updateRequired = true;
}
}

View File

@@ -8,6 +8,7 @@
#include "../Activity.h"
#include "./MyLibraryActivity.h"
#include "util/ButtonNavigator.h"
struct RecentBook;
struct Rect;
@@ -15,6 +16,7 @@ struct Rect;
class HomeActivity final : public Activity {
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
ButtonNavigator buttonNavigator;
int selectorIndex = 0;
bool updateRequired = false;
bool recentsLoading = false;

View File

@@ -1,7 +1,7 @@
#include "MyLibraryActivity.h"
#include <GfxRenderer.h>
#include <SDCardManager.h>
#include <HalStorage.h>
#include <algorithm>
@@ -11,17 +11,58 @@
#include "util/StringUtils.h"
namespace {
constexpr int SKIP_PAGE_MS = 700;
constexpr unsigned long GO_HOME_MS = 1000;
} // namespace
void sortFileList(std::vector<std::string>& strs) {
std::sort(begin(strs), end(strs), [](const std::string& str1, const std::string& str2) {
if (str1.back() == '/' && str2.back() != '/') return true;
if (str1.back() != '/' && str2.back() == '/') return false;
return lexicographical_compare(
begin(str1), end(str1), begin(str2), end(str2),
[](const char& char1, const char& char2) { return tolower(char1) < tolower(char2); });
// Directories first
bool isDir1 = str1.back() == '/';
bool isDir2 = str2.back() == '/';
if (isDir1 != isDir2) return isDir1;
// Start naive natural sort
const char* s1 = str1.c_str();
const char* s2 = str2.c_str();
// Iterate while both strings have characters
while (*s1 && *s2) {
// Check if both are at the start of a number
if (isdigit(*s1) && isdigit(*s2)) {
// Skip leading zeros and track them
const char* start1 = s1;
const char* start2 = s2;
while (*s1 == '0') s1++;
while (*s2 == '0') s2++;
// Count digits to compare lengths first
int len1 = 0, len2 = 0;
while (isdigit(s1[len1])) len1++;
while (isdigit(s2[len2])) len2++;
// Different length so return smaller integer value
if (len1 != len2) return len1 < len2;
// Same length so compare digit by digit
for (int i = 0; i < len1; i++) {
if (s1[i] != s2[i]) return s1[i] < s2[i];
}
// Numbers equal so advance pointers
s1 += len1;
s2 += len2;
} else {
// Regular case-insensitive character comparison
char c1 = tolower(*s1);
char c2 = tolower(*s2);
if (c1 != c2) return c1 < c2;
s1++;
s2++;
}
}
// One string is prefix of other
return *s1 == '\0' && *s2 != '\0';
});
}
@@ -33,7 +74,7 @@ void MyLibraryActivity::taskTrampoline(void* param) {
void MyLibraryActivity::loadFiles() {
files.clear();
auto root = SdMan.open(basepath.c_str());
auto root = Storage.open(basepath.c_str());
if (!root || !root.isDirectory()) {
if (root) root.close();
return;
@@ -109,13 +150,6 @@ void MyLibraryActivity::loop() {
return;
}
const bool upReleased = mappedInput.wasReleased(MappedInputManager::Button::Left) ||
mappedInput.wasReleased(MappedInputManager::Button::Up);
;
const bool downReleased = mappedInput.wasReleased(MappedInputManager::Button::Right) ||
mappedInput.wasReleased(MappedInputManager::Button::Down);
const bool skipPage = mappedInput.getHeldTime() > SKIP_PAGE_MS;
const int pageItems = UITheme::getInstance().getNumberOfItemsPerPage(renderer, true, false, true, false);
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
@@ -157,21 +191,26 @@ void MyLibraryActivity::loop() {
}
int listSize = static_cast<int>(files.size());
if (upReleased) {
if (skipPage) {
selectorIndex = std::max(static_cast<int>((selectorIndex / pageItems - 1) * pageItems), 0);
} else {
selectorIndex = (selectorIndex + listSize - 1) % listSize;
}
buttonNavigator.onNextRelease([this, listSize] {
selectorIndex = ButtonNavigator::nextIndex(static_cast<int>(selectorIndex), listSize);
updateRequired = true;
} else if (downReleased) {
if (skipPage) {
selectorIndex = std::min(static_cast<int>((selectorIndex / pageItems + 1) * pageItems), listSize - 1);
} else {
selectorIndex = (selectorIndex + 1) % listSize;
}
});
buttonNavigator.onPreviousRelease([this, listSize] {
selectorIndex = ButtonNavigator::previousIndex(static_cast<int>(selectorIndex), listSize);
updateRequired = true;
}
});
buttonNavigator.onNextContinuous([this, listSize, pageItems] {
selectorIndex = ButtonNavigator::nextPageIndex(static_cast<int>(selectorIndex), listSize, pageItems);
updateRequired = true;
});
buttonNavigator.onPreviousContinuous([this, listSize, pageItems] {
selectorIndex = ButtonNavigator::previousPageIndex(static_cast<int>(selectorIndex), listSize, pageItems);
updateRequired = true;
});
}
void MyLibraryActivity::displayTaskLoop() {
@@ -207,7 +246,7 @@ void MyLibraryActivity::render() const {
}
// Help text
const auto labels = mappedInput.mapLabels("« Home", "Open", "Up", "Down");
const auto labels = mappedInput.mapLabels(basepath == "/" ? "« Home" : "« Back", "Open", "Up", "Down");
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer();
@@ -217,4 +256,4 @@ size_t MyLibraryActivity::findEntry(const std::string& name) const {
for (size_t i = 0; i < files.size(); i++)
if (files[i] == name) return i;
return 0;
}
}

View File

@@ -8,11 +8,14 @@
#include <vector>
#include "../Activity.h"
#include "RecentBooksStore.h"
#include "util/ButtonNavigator.h"
class MyLibraryActivity final : public Activity {
private:
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
ButtonNavigator buttonNavigator;
size_t selectorIndex = 0;
bool updateRequired = false;

View File

@@ -1,7 +1,7 @@
#include "RecentBooksActivity.h"
#include <GfxRenderer.h>
#include <SDCardManager.h>
#include <HalStorage.h>
#include <algorithm>
@@ -12,7 +12,6 @@
#include "util/StringUtils.h"
namespace {
constexpr int SKIP_PAGE_MS = 700;
constexpr unsigned long GO_HOME_MS = 1000;
} // namespace
@@ -28,7 +27,7 @@ void RecentBooksActivity::loadRecentBooks() {
for (const auto& book : books) {
// Skip if file no longer exists
if (!SdMan.exists(book.path.c_str())) {
if (!Storage.exists(book.path.c_str())) {
continue;
}
recentBooks.push_back(book);
@@ -70,18 +69,11 @@ void RecentBooksActivity::onExit() {
}
void RecentBooksActivity::loop() {
const bool upReleased = mappedInput.wasReleased(MappedInputManager::Button::Left) ||
mappedInput.wasReleased(MappedInputManager::Button::Up);
;
const bool downReleased = mappedInput.wasReleased(MappedInputManager::Button::Right) ||
mappedInput.wasReleased(MappedInputManager::Button::Down);
const bool skipPage = mappedInput.getHeldTime() > SKIP_PAGE_MS;
const int pageItems = UITheme::getInstance().getNumberOfItemsPerPage(renderer, true, false, true, true);
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (!recentBooks.empty() && selectorIndex < static_cast<int>(recentBooks.size())) {
Serial.printf("Selected recent book: %s\n", recentBooks[selectorIndex].path.c_str());
Serial.printf("[%lu] [RBA] Selected recent book: %s\n", millis(), recentBooks[selectorIndex].path.c_str());
onSelectBook(recentBooks[selectorIndex].path);
return;
}
@@ -92,21 +84,26 @@ void RecentBooksActivity::loop() {
}
int listSize = static_cast<int>(recentBooks.size());
if (upReleased) {
if (skipPage) {
selectorIndex = std::max(static_cast<int>((selectorIndex / pageItems - 1) * pageItems), 0);
} else {
selectorIndex = (selectorIndex + listSize - 1) % listSize;
}
buttonNavigator.onNextRelease([this, listSize] {
selectorIndex = ButtonNavigator::nextIndex(static_cast<int>(selectorIndex), listSize);
updateRequired = true;
} else if (downReleased) {
if (skipPage) {
selectorIndex = std::min(static_cast<int>((selectorIndex / pageItems + 1) * pageItems), listSize - 1);
} else {
selectorIndex = (selectorIndex + 1) % listSize;
}
});
buttonNavigator.onPreviousRelease([this, listSize] {
selectorIndex = ButtonNavigator::previousIndex(static_cast<int>(selectorIndex), listSize);
updateRequired = true;
}
});
buttonNavigator.onNextContinuous([this, listSize, pageItems] {
selectorIndex = ButtonNavigator::nextPageIndex(static_cast<int>(selectorIndex), listSize, pageItems);
updateRequired = true;
});
buttonNavigator.onPreviousContinuous([this, listSize, pageItems] {
selectorIndex = ButtonNavigator::previousPageIndex(static_cast<int>(selectorIndex), listSize, pageItems);
updateRequired = true;
});
}
void RecentBooksActivity::displayTaskLoop() {

View File

@@ -9,11 +9,13 @@
#include "../Activity.h"
#include "RecentBooksStore.h"
#include "util/ButtonNavigator.h"
class RecentBooksActivity final : public Activity {
private:
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
ButtonNavigator buttonNavigator;
size_t selectorIndex = 0;
bool updateRequired = false;

View File

@@ -348,6 +348,9 @@ void CrossPointWebServerActivity::loop() {
// Yield and check for exit button every 64 iterations
if ((i & 0x3F) == 0x3F) {
yield();
// Force trigger an update of which buttons are being pressed so be have accurate state
// for back button checking
mappedInput.update();
// Check for exit button inside loop for responsiveness
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
onGoBack();

View File

@@ -73,18 +73,15 @@ void NetworkModeSelectionActivity::loop() {
}
// Handle navigation
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);
buttonNavigator.onNext([this] {
selectedIndex = ButtonNavigator::nextIndex(selectedIndex, MENU_ITEM_COUNT);
updateRequired = true;
});
if (prevPressed) {
selectedIndex = (selectedIndex + MENU_ITEM_COUNT - 1) % MENU_ITEM_COUNT;
buttonNavigator.onPrevious([this] {
selectedIndex = ButtonNavigator::previousIndex(selectedIndex, MENU_ITEM_COUNT);
updateRequired = true;
} else if (nextPressed) {
selectedIndex = (selectedIndex + 1) % MENU_ITEM_COUNT;
updateRequired = true;
}
});
}
void NetworkModeSelectionActivity::displayTaskLoop() {

View File

@@ -6,6 +6,7 @@
#include <functional>
#include "../Activity.h"
#include "util/ButtonNavigator.h"
// Enum for network mode selection
enum class NetworkMode { JOIN_NETWORK, CONNECT_CALIBRE, CREATE_HOTSPOT };
@@ -22,6 +23,8 @@ enum class NetworkMode { JOIN_NETWORK, CONNECT_CALIBRE, CREATE_HOTSPOT };
class NetworkModeSelectionActivity final : public Activity {
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
ButtonNavigator buttonNavigator;
int selectedIndex = 0;
bool updateRequired = false;
const std::function<void(NetworkMode)> onModeSelected;

View File

@@ -21,7 +21,8 @@ void WifiSelectionActivity::onEnter() {
renderingMutex = xSemaphoreCreateMutex();
// Load saved WiFi credentials - SD card operations need lock as we use SPI for both
// Load saved WiFi credentials - SD card operations need lock as we use SPI
// for both
xSemaphoreTake(renderingMutex, portMAX_DELAY);
WIFI_STORE.loadFromFile();
xSemaphoreGive(renderingMutex);
@@ -37,6 +38,7 @@ void WifiSelectionActivity::onEnter() {
usedSavedPassword = false;
savePromptSelection = 0;
forgetPromptSelection = 0;
autoConnecting = false;
// Cache MAC address for display
uint8_t mac[6];
@@ -46,9 +48,7 @@ void WifiSelectionActivity::onEnter() {
mac[5]);
cachedMacAddress = std::string(macStr);
// Trigger first update to show scanning message
updateRequired = true;
// Task creation
xTaskCreate(&WifiSelectionActivity::taskTrampoline, "WifiSelectionTask",
4096, // Stack size (larger for WiFi operations)
this, // Parameters
@@ -56,7 +56,26 @@ void WifiSelectionActivity::onEnter() {
&displayTaskHandle // Task handle
);
// Start WiFi scan
// Attempt to auto-connect to the last network
if (allowAutoConnect) {
const std::string lastSsid = WIFI_STORE.getLastConnectedSsid();
if (!lastSsid.empty()) {
const auto* cred = WIFI_STORE.findCredential(lastSsid);
if (cred) {
Serial.printf("[%lu] [WIFI] Attempting to auto-connect to %s\n", millis(), lastSsid.c_str());
selectedSSID = cred->ssid;
enteredPassword = cred->password;
selectedRequiresPassword = !cred->password.empty();
usedSavedPassword = true;
autoConnecting = true;
attemptConnection();
updateRequired = true;
return;
}
}
}
// Fallback to scanning
startWifiScan();
}
@@ -70,15 +89,17 @@ void WifiSelectionActivity::onExit() {
WiFi.scanDelete();
Serial.printf("[%lu] [WIFI] [MEM] Free heap after scanDelete: %d bytes\n", millis(), ESP.getFreeHeap());
// Note: We do NOT disconnect WiFi here - the parent activity (CrossPointWebServerActivity)
// manages WiFi connection state. We just clean up the scan and task.
// Note: We do NOT disconnect WiFi here - the parent activity
// (CrossPointWebServerActivity) manages WiFi connection state. We just clean
// up the scan and task.
// Acquire mutex before deleting task to ensure task isn't using it
// This prevents hangs/crashes if the task holds the mutex when deleted
Serial.printf("[%lu] [WIFI] Acquiring rendering mutex before task deletion...\n", millis());
xSemaphoreTake(renderingMutex, portMAX_DELAY);
// Delete the display task (we now hold the mutex, so task is blocked if it needs it)
// Delete the display task (we now hold the mutex, so task is blocked if it
// needs it)
Serial.printf("[%lu] [WIFI] Deleting display task...\n", millis());
if (displayTaskHandle) {
vTaskDelete(displayTaskHandle);
@@ -96,6 +117,7 @@ void WifiSelectionActivity::onExit() {
}
void WifiSelectionActivity::startWifiScan() {
autoConnecting = false;
state = WifiSelectionState::SCANNING;
networks.clear();
updateRequired = true;
@@ -181,6 +203,7 @@ void WifiSelectionActivity::selectNetwork(const int index) {
selectedRequiresPassword = network.isEncrypted;
usedSavedPassword = false;
enteredPassword.clear();
autoConnecting = false;
// Check if we have saved credentials for this network
const auto* savedCred = WIFI_STORE.findCredential(selectedSSID);
@@ -223,7 +246,7 @@ void WifiSelectionActivity::selectNetwork(const int index) {
}
void WifiSelectionActivity::attemptConnection() {
state = WifiSelectionState::CONNECTING;
state = autoConnecting ? WifiSelectionState::AUTO_CONNECTING : WifiSelectionState::CONNECTING;
connectionStartTime = millis();
connectedIP.clear();
connectionError.clear();
@@ -239,7 +262,7 @@ void WifiSelectionActivity::attemptConnection() {
}
void WifiSelectionActivity::checkConnectionStatus() {
if (state != WifiSelectionState::CONNECTING) {
if (state != WifiSelectionState::CONNECTING && state != WifiSelectionState::AUTO_CONNECTING) {
return;
}
@@ -251,6 +274,13 @@ void WifiSelectionActivity::checkConnectionStatus() {
char ipStr[16];
snprintf(ipStr, sizeof(ipStr), "%d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]);
connectedIP = ipStr;
autoConnecting = false;
// Save this as the last connected network - SD card operations need lock as
// we use SPI for both
xSemaphoreTake(renderingMutex, portMAX_DELAY);
WIFI_STORE.setLastConnectedSsid(selectedSSID);
xSemaphoreGive(renderingMutex);
// If we entered a new password, ask if user wants to save it
// Otherwise, immediately complete so parent can start web server
@@ -260,7 +290,10 @@ void WifiSelectionActivity::checkConnectionStatus() {
updateRequired = true;
} else {
// Using saved password or open network - complete immediately
Serial.printf("[%lu] [WIFI] Connected with saved/open credentials, completing immediately\n", millis());
Serial.printf(
"[%lu] [WIFI] Connected with saved/open credentials, "
"completing immediately\n",
millis());
onComplete(true);
}
return;
@@ -299,7 +332,7 @@ void WifiSelectionActivity::loop() {
}
// Check connection progress
if (state == WifiSelectionState::CONNECTING) {
if (state == WifiSelectionState::CONNECTING || state == WifiSelectionState::AUTO_CONNECTING) {
checkConnectionStatus();
return;
}
@@ -368,17 +401,16 @@ void WifiSelectionActivity::loop() {
}
}
// Go back to network list (whether Cancel or Forget network was selected)
state = WifiSelectionState::NETWORK_LIST;
updateRequired = true;
startWifiScan();
} else if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
// Skip forgetting, go back to network list
state = WifiSelectionState::NETWORK_LIST;
updateRequired = true;
startWifiScan();
}
return;
}
// Handle connected state (should not normally be reached - connection completes immediately)
// Handle connected state (should not normally be reached - connection
// completes immediately)
if (state == WifiSelectionState::CONNECTED) {
// Safety fallback - immediately complete
onComplete(true);
@@ -389,12 +421,14 @@ void WifiSelectionActivity::loop() {
if (state == WifiSelectionState::CONNECTION_FAILED) {
if (mappedInput.wasPressed(MappedInputManager::Button::Back) ||
mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
// If we used saved credentials, offer to forget the network
if (usedSavedPassword) {
// If we were auto-connecting or using a saved credential, offer to forget
// the network
if (autoConnecting || usedSavedPassword) {
autoConnecting = false;
state = WifiSelectionState::FORGET_PROMPT;
forgetPromptSelection = 0; // Default to "Cancel"
} else {
// Go back to network list on failure
// Go back to network list on failure for non-saved credentials
state = WifiSelectionState::NETWORK_LIST;
}
updateRequired = true;
@@ -420,20 +454,33 @@ void WifiSelectionActivity::loop() {
return;
}
// Handle UP/DOWN navigation
if (mappedInput.wasPressed(MappedInputManager::Button::Up) ||
mappedInput.wasPressed(MappedInputManager::Button::Left)) {
if (selectedNetworkIndex > 0) {
selectedNetworkIndex--;
updateRequired = true;
}
} else if (mappedInput.wasPressed(MappedInputManager::Button::Down) ||
mappedInput.wasPressed(MappedInputManager::Button::Right)) {
if (!networks.empty() && selectedNetworkIndex < static_cast<int>(networks.size()) - 1) {
selectedNetworkIndex++;
if (mappedInput.wasPressed(MappedInputManager::Button::Right)) {
startWifiScan();
return;
}
const bool leftPressed = mappedInput.wasPressed(MappedInputManager::Button::Left);
if (leftPressed) {
const bool hasSavedPassword = !networks.empty() && networks[selectedNetworkIndex].hasSavedPassword;
if (hasSavedPassword) {
selectedSSID = networks[selectedNetworkIndex].ssid;
state = WifiSelectionState::FORGET_PROMPT;
forgetPromptSelection = 0; // Default to "Cancel"
updateRequired = true;
return;
}
}
// Handle navigation
buttonNavigator.onNext([this] {
selectedNetworkIndex = ButtonNavigator::nextIndex(selectedNetworkIndex, networks.size());
updateRequired = true;
});
buttonNavigator.onPrevious([this] {
selectedNetworkIndex = ButtonNavigator::previousIndex(selectedNetworkIndex, networks.size());
updateRequired = true;
});
}
}
@@ -483,6 +530,9 @@ void WifiSelectionActivity::render() const {
renderer.clearScreen();
switch (state) {
case WifiSelectionState::AUTO_CONNECTING:
renderConnecting();
break;
case WifiSelectionState::SCANNING:
renderConnecting(); // Reuse connecting screen with different message
break;
@@ -586,7 +636,11 @@ void WifiSelectionActivity::renderNetworkList() const {
// Draw help text
renderer.drawText(SMALL_FONT_ID, 20, pageHeight - 75, "* = Encrypted | + = Saved");
const auto labels = mappedInput.mapLabels("« Back", "Connect", "", "");
const bool hasSavedPassword = !networks.empty() && networks[selectedNetworkIndex].hasSavedPassword;
const char* forgetLabel = hasSavedPassword ? "Forget" : "";
const auto labels = mappedInput.mapLabels("« Back", "Connect", forgetLabel, "Refresh");
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
}
@@ -690,8 +744,7 @@ void WifiSelectionActivity::renderForgetPrompt() const {
const auto height = renderer.getLineHeight(UI_10_FONT_ID);
const auto top = (pageHeight - height * 3) / 2;
renderer.drawCenteredText(UI_12_FONT_ID, top - 40, "Connection Failed", true, EpdFontFamily::BOLD);
renderer.drawCenteredText(UI_12_FONT_ID, top - 40, "Forget Network", true, EpdFontFamily::BOLD);
std::string ssidInfo = "Network: " + selectedSSID;
if (ssidInfo.length() > 28) {
ssidInfo.replace(25, ssidInfo.length() - 25, "...");

View File

@@ -10,6 +10,7 @@
#include <vector>
#include "activities/ActivityWithSubactivity.h"
#include "util/ButtonNavigator.h"
// Structure to hold WiFi network information
struct WifiNetworkInfo {
@@ -21,6 +22,7 @@ struct WifiNetworkInfo {
// WiFi selection states
enum class WifiSelectionState {
AUTO_CONNECTING, // Trying to connect to the last known network
SCANNING, // Scanning for networks
NETWORK_LIST, // Displaying available networks
PASSWORD_ENTRY, // Entering password for selected network
@@ -45,6 +47,7 @@ enum class WifiSelectionState {
class WifiSelectionActivity final : public ActivityWithSubactivity {
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
ButtonNavigator buttonNavigator;
bool updateRequired = false;
WifiSelectionState state = WifiSelectionState::SCANNING;
int selectedNetworkIndex = 0;
@@ -68,6 +71,12 @@ class WifiSelectionActivity final : public ActivityWithSubactivity {
// Whether network was connected using a saved password (skip save prompt)
bool usedSavedPassword = false;
// Whether to attempt auto-connect on entry
const bool allowAutoConnect;
// Whether we are attempting to auto-connect
bool autoConnecting = false;
// Save/forget prompt selection (0 = Yes, 1 = No)
int savePromptSelection = 0;
int forgetPromptSelection = 0;
@@ -96,8 +105,10 @@ class WifiSelectionActivity final : public ActivityWithSubactivity {
public:
explicit WifiSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const std::function<void(bool connected)>& onComplete)
: ActivityWithSubactivity("WifiSelection", renderer, mappedInput), onComplete(onComplete) {}
const std::function<void(bool connected)>& onComplete, bool autoConnect = true)
: ActivityWithSubactivity("WifiSelection", renderer, mappedInput),
onComplete(onComplete),
allowAutoConnect(autoConnect) {}
void onEnter() override;
void onExit() override;
void loop() override;

View File

@@ -0,0 +1,537 @@
#include "DictionaryDefinitionActivity.h"
#include <GfxRenderer.h>
#include <algorithm>
#include <cctype>
#include <cstdlib>
#include "CrossPointSettings.h"
#include "MappedInputManager.h"
#include "components/UITheme.h"
#include "fontIds.h"
void DictionaryDefinitionActivity::taskTrampoline(void* param) {
auto* self = static_cast<DictionaryDefinitionActivity*>(param);
self->displayTaskLoop();
}
void DictionaryDefinitionActivity::displayTaskLoop() {
while (true) {
if (updateRequired) {
updateRequired = false;
xSemaphoreTake(renderingMutex, portMAX_DELAY);
renderScreen();
xSemaphoreGive(renderingMutex);
}
vTaskDelay(10 / portTICK_PERIOD_MS);
}
}
void DictionaryDefinitionActivity::onEnter() {
Activity::onEnter();
renderingMutex = xSemaphoreCreateMutex();
wrapText();
updateRequired = true;
xTaskCreate(&DictionaryDefinitionActivity::taskTrampoline, "DictDefTask", 4096, this, 1, &displayTaskHandle);
}
void DictionaryDefinitionActivity::onExit() {
Activity::onExit();
xSemaphoreTake(renderingMutex, portMAX_DELAY);
if (displayTaskHandle) {
vTaskDelete(displayTaskHandle);
displayTaskHandle = nullptr;
}
vSemaphoreDelete(renderingMutex);
renderingMutex = nullptr;
}
// ---------------------------------------------------------------------------
// Check if a Unicode codepoint is likely renderable by the e-ink bitmap font.
// Keeps Latin text, combining marks, common punctuation, currency, and letterlike symbols.
// Skips IPA extensions, Greek, Cyrillic, Arabic, CJK, and other non-Latin scripts.
// ---------------------------------------------------------------------------
bool DictionaryDefinitionActivity::isRenderableCodepoint(uint32_t cp) {
if (cp <= 0x024F) return true; // Basic Latin + Latin Extended-A/B
if (cp >= 0x0300 && cp <= 0x036F) return true; // Combining Diacritical Marks
if (cp >= 0x2000 && cp <= 0x206F) return true; // General Punctuation
if (cp >= 0x20A0 && cp <= 0x20CF) return true; // Currency Symbols
if (cp >= 0x2100 && cp <= 0x214F) return true; // Letterlike Symbols
if (cp >= 0x2190 && cp <= 0x21FF) return true; // Arrows
return false;
}
// ---------------------------------------------------------------------------
// HTML entity decoder
// ---------------------------------------------------------------------------
std::string DictionaryDefinitionActivity::decodeEntity(const std::string& entity) {
// Named entities
if (entity == "amp") return "&";
if (entity == "lt") return "<";
if (entity == "gt") return ">";
if (entity == "quot") return "\"";
if (entity == "apos") return "'";
if (entity == "nbsp" || entity == "thinsp" || entity == "ensp" || entity == "emsp") return " ";
if (entity == "ndash") return "\xE2\x80\x93"; // U+2013
if (entity == "mdash") return "\xE2\x80\x94"; // U+2014
if (entity == "lsquo") return "\xE2\x80\x98";
if (entity == "rsquo") return "\xE2\x80\x99";
if (entity == "ldquo") return "\xE2\x80\x9C";
if (entity == "rdquo") return "\xE2\x80\x9D";
if (entity == "hellip") return "\xE2\x80\xA6";
if (entity == "lrm" || entity == "rlm" || entity == "zwj" || entity == "zwnj") return "";
// Numeric entities: &#123; or &#x1F;
if (!entity.empty() && entity[0] == '#') {
unsigned long cp = 0;
if (entity.size() > 1 && (entity[1] == 'x' || entity[1] == 'X')) {
cp = std::strtoul(entity.c_str() + 2, nullptr, 16);
} else {
cp = std::strtoul(entity.c_str() + 1, nullptr, 10);
}
if (cp > 0 && cp < 0x80) {
return std::string(1, static_cast<char>(cp));
}
if (cp >= 0x80 && cp < 0x800) {
char buf[3] = {static_cast<char>(0xC0 | (cp >> 6)), static_cast<char>(0x80 | (cp & 0x3F)), '\0'};
return std::string(buf, 2);
}
if (cp >= 0x800 && cp < 0x10000) {
char buf[4] = {static_cast<char>(0xE0 | (cp >> 12)), static_cast<char>(0x80 | ((cp >> 6) & 0x3F)),
static_cast<char>(0x80 | (cp & 0x3F)), '\0'};
return std::string(buf, 3);
}
if (cp >= 0x10000 && cp < 0x110000) {
char buf[5] = {static_cast<char>(0xF0 | (cp >> 18)), static_cast<char>(0x80 | ((cp >> 12) & 0x3F)),
static_cast<char>(0x80 | ((cp >> 6) & 0x3F)), static_cast<char>(0x80 | (cp & 0x3F)), '\0'};
return std::string(buf, 4);
}
}
return ""; // unknown entity — drop it
}
// ---------------------------------------------------------------------------
// HTML → TextAtom list
// ---------------------------------------------------------------------------
std::vector<DictionaryDefinitionActivity::TextAtom> DictionaryDefinitionActivity::parseHtml(const std::string& html) {
std::vector<TextAtom> atoms;
bool isBold = false;
bool isItalic = false;
bool inSvg = false;
int svgDepth = 0;
std::vector<ListState> listStack;
std::string currentWord;
auto currentStyle = [&]() -> EpdFontFamily::Style {
if (isBold && isItalic) return EpdFontFamily::BOLD_ITALIC;
if (isBold) return EpdFontFamily::BOLD;
if (isItalic) return EpdFontFamily::ITALIC;
return EpdFontFamily::REGULAR;
};
auto flushWord = [&]() {
if (!currentWord.empty() && !inSvg) {
atoms.push_back({currentWord, currentStyle(), false, 0});
currentWord.clear();
}
};
auto indentPx = [&]() -> int {
// 15 pixels per nesting level (the first level has no extra indent)
int depth = static_cast<int>(listStack.size());
return (depth > 1) ? (depth - 1) * 15 : 0;
};
// Skip any leading non-HTML text (e.g. pronunciation guides like "/ˈsɪm.pəl/, /ˈsɪmpəl/")
// that appears before the first tag in sametypesequence=h entries.
size_t i = 0;
{
size_t firstTag = html.find('<');
if (firstTag != std::string::npos) i = firstTag;
}
while (i < html.size()) {
// ------- HTML tag -------
if (html[i] == '<') {
flushWord();
size_t tagEnd = html.find('>', i);
if (tagEnd == std::string::npos) break;
std::string tagContent = html.substr(i + 1, tagEnd - i - 1);
// Extract tag name: first token, lowercased, trailing '/' stripped.
size_t space = tagContent.find(' ');
std::string tagName = (space != std::string::npos) ? tagContent.substr(0, space) : tagContent;
for (auto& c : tagName) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
if (!tagName.empty() && tagName.back() == '/') tagName.pop_back();
// --- SVG handling (skip all content inside <svg>…</svg>) ---
if (tagName == "svg") {
inSvg = true;
svgDepth = 1;
} else if (inSvg) {
if (tagName == "svg") {
svgDepth++;
} else if (tagName == "/svg") {
svgDepth--;
if (svgDepth <= 0) inSvg = false;
}
}
if (!inSvg) {
// --- Inline style tags ---
if (tagName == "b" || tagName == "strong") {
isBold = true;
} else if (tagName == "/b" || tagName == "/strong") {
isBold = false;
} else if (tagName == "i" || tagName == "em") {
isItalic = true;
} else if (tagName == "/i" || tagName == "/em") {
isItalic = false;
// --- Block-level tags → newlines ---
} else if (tagName == "p" || tagName == "h1" || tagName == "h2" || tagName == "h3" || tagName == "h4") {
atoms.push_back({"", EpdFontFamily::REGULAR, true, indentPx()});
// Headings get bold style applied to following text
if (tagName != "p") isBold = true;
} else if (tagName == "/p" || tagName == "/h1" || tagName == "/h2" || tagName == "/h3" || tagName == "/h4") {
atoms.push_back({"", EpdFontFamily::REGULAR, true, indentPx()});
isBold = false;
} else if (tagName == "br") {
atoms.push_back({"", EpdFontFamily::REGULAR, true, indentPx()});
// --- Separator between definition entries ---
} else if (tagName == "/html") {
atoms.push_back({"", EpdFontFamily::REGULAR, true, 0});
atoms.push_back({"", EpdFontFamily::REGULAR, true, 0}); // extra blank line
isBold = false;
isItalic = false;
// Skip any raw text between </html> and the next tag — this is where
// pronunciation guides (e.g. /ˈsɪmpəl/, /ksɛpt/) live in this dictionary.
size_t nextTag = html.find('<', tagEnd + 1);
i = (nextTag != std::string::npos) ? nextTag : html.size();
continue;
// --- Lists ---
} else if (tagName == "ol") {
bool alpha = tagContent.find("lower-alpha") != std::string::npos;
listStack.push_back({0, alpha});
} else if (tagName == "ul") {
listStack.push_back({0, false});
} else if (tagName == "/ol" || tagName == "/ul") {
if (!listStack.empty()) listStack.pop_back();
} else if (tagName == "li") {
atoms.push_back({"", EpdFontFamily::REGULAR, true, indentPx()});
if (!listStack.empty()) {
auto& ls = listStack.back();
ls.counter++;
std::string marker;
if (ls.isAlpha && ls.counter >= 1 && ls.counter <= 26) {
marker = std::string(1, static_cast<char>('a' + ls.counter - 1)) + ". ";
} else if (ls.isAlpha) {
marker = std::to_string(ls.counter) + ". ";
} else {
marker = std::to_string(ls.counter) + ". ";
}
atoms.push_back({marker, EpdFontFamily::REGULAR, false, 0});
} else {
// Unordered list or bare <li>
atoms.push_back({"\xE2\x80\xA2 ", EpdFontFamily::REGULAR, false, 0});
}
}
// All other tags (span, div, code, sup, sub, table, etc.) are silently ignored;
// their text content will still be emitted.
}
i = tagEnd + 1;
continue;
}
// Skip content inside SVG
if (inSvg) {
i++;
continue;
}
// ------- HTML entity -------
if (html[i] == '&') {
size_t semicolon = html.find(';', i);
if (semicolon != std::string::npos && semicolon - i < 16) {
std::string entity = html.substr(i + 1, semicolon - i - 1);
std::string decoded = decodeEntity(entity);
if (!decoded.empty()) {
// Treat decoded chars like normal text (could be space etc.)
for (char dc : decoded) {
if (dc == ' ') {
flushWord();
} else {
currentWord += dc;
}
}
}
i = semicolon + 1;
continue;
}
// Not a valid entity — emit '&' literally
currentWord += '&';
i++;
continue;
}
// ------- IPA pronunciation (skip /…/ and […] containing non-ASCII) -------
if (html[i] == '/' || html[i] == '[') {
char closeDelim = (html[i] == '/') ? '/' : ']';
size_t end = html.find(closeDelim, i + 1);
if (end != std::string::npos && end - i < 80) {
bool hasNonAscii = false;
for (size_t j = i + 1; j < end; j++) {
if (static_cast<unsigned char>(html[j]) > 127) {
hasNonAscii = true;
break;
}
}
if (hasNonAscii) {
flushWord();
i = end + 1; // skip entire IPA section including delimiters
continue;
}
}
// Not IPA — fall through to treat as regular character
}
// ------- Whitespace -------
if (html[i] == ' ' || html[i] == '\t' || html[i] == '\n' || html[i] == '\r') {
flushWord();
i++;
continue;
}
// ------- Regular character (with non-renderable character filter) -------
{
unsigned char byte = static_cast<unsigned char>(html[i]);
if (byte < 0x80) {
// ASCII — always renderable
currentWord += html[i];
i++;
} else {
// Multi-byte UTF-8: decode codepoint and check if renderable
int seqLen = 1;
uint32_t cp = 0;
if ((byte & 0xE0) == 0xC0) {
seqLen = 2;
cp = byte & 0x1F;
} else if ((byte & 0xF0) == 0xE0) {
seqLen = 3;
cp = byte & 0x0F;
} else if ((byte & 0xF8) == 0xF0) {
seqLen = 4;
cp = byte & 0x07;
} else {
i++;
continue;
} // invalid start byte
if (i + static_cast<size_t>(seqLen) > html.size()) {
i++;
continue;
}
bool valid = true;
for (int j = 1; j < seqLen; j++) {
unsigned char cb = static_cast<unsigned char>(html[i + j]);
if ((cb & 0xC0) != 0x80) {
valid = false;
break;
}
cp = (cp << 6) | (cb & 0x3F);
}
if (valid && isRenderableCodepoint(cp)) {
for (int j = 0; j < seqLen; j++) {
currentWord += html[i + j];
}
}
// else: silently skip non-renderable character
i += valid ? seqLen : 1;
}
}
}
flushWord();
return atoms;
}
// ---------------------------------------------------------------------------
// Word-wrap the parsed HTML atoms into positioned line segments
// ---------------------------------------------------------------------------
void DictionaryDefinitionActivity::wrapText() {
wrappedLines.clear();
const bool landscape = orientation == CrossPointSettings::ORIENTATION::LANDSCAPE_CW ||
orientation == CrossPointSettings::ORIENTATION::LANDSCAPE_CCW;
const int screenWidth = renderer.getScreenWidth();
const int lineHeight = renderer.getLineHeight(readerFontId);
const int sidePadding = landscape ? 50 : 20;
constexpr int topArea = 50;
constexpr int bottomArea = 50;
const int maxWidth = screenWidth - 2 * sidePadding;
const int spaceWidth = renderer.getSpaceWidth(readerFontId);
linesPerPage = (renderer.getScreenHeight() - topArea - bottomArea) / lineHeight;
if (linesPerPage < 1) linesPerPage = 1;
auto atoms = parseHtml(definition);
std::vector<Segment> currentLine;
int currentX = 0;
int baseIndent = 0; // indent for continuation lines within the same block
for (const auto& atom : atoms) {
// ---- Newline directive ----
if (atom.isNewline) {
// Collapse multiple consecutive blank lines
if (currentLine.empty() && !wrappedLines.empty() && wrappedLines.back().empty()) {
// Already have a blank line; update indent but don't push another
baseIndent = atom.indent;
currentX = baseIndent;
continue;
}
wrappedLines.push_back(std::move(currentLine));
currentLine.clear();
baseIndent = atom.indent;
currentX = baseIndent;
continue;
}
// ---- Text word ----
int wordWidth = renderer.getTextWidth(readerFontId, atom.text.c_str(), atom.style);
int gap = (currentX > baseIndent) ? spaceWidth : 0;
// Wrap if this word won't fit
if (currentX + gap + wordWidth > maxWidth && currentX > baseIndent) {
wrappedLines.push_back(std::move(currentLine));
currentLine.clear();
currentX = baseIndent;
gap = 0;
}
int16_t x = static_cast<int16_t>(currentX + gap);
currentLine.push_back({atom.text, x, atom.style});
currentX = x + wordWidth;
}
// Flush last line
if (!currentLine.empty()) {
wrappedLines.push_back(std::move(currentLine));
}
totalPages = (static_cast<int>(wrappedLines.size()) + linesPerPage - 1) / linesPerPage;
if (totalPages < 1) totalPages = 1;
}
void DictionaryDefinitionActivity::loop() {
const bool prevPage = mappedInput.wasReleased(MappedInputManager::Button::PageBack) ||
mappedInput.wasReleased(MappedInputManager::Button::Left);
const bool nextPage = mappedInput.wasReleased(MappedInputManager::Button::PageForward) ||
mappedInput.wasReleased(MappedInputManager::Button::Right);
if (prevPage && currentPage > 0) {
currentPage--;
updateRequired = true;
}
if (nextPage && currentPage < totalPages - 1) {
currentPage++;
updateRequired = true;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Back) ||
mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
onBack();
return;
}
}
void DictionaryDefinitionActivity::renderScreen() {
renderer.clearScreen();
const bool landscape = orientation == CrossPointSettings::ORIENTATION::LANDSCAPE_CW ||
orientation == CrossPointSettings::ORIENTATION::LANDSCAPE_CCW;
const int sidePadding = landscape ? 50 : 20;
constexpr int titleY = 10;
const int lineHeight = renderer.getLineHeight(readerFontId);
constexpr int bodyStartY = 50;
// Title: the word in bold (UI font)
renderer.drawText(UI_12_FONT_ID, sidePadding, titleY, headword.c_str(), true, EpdFontFamily::BOLD);
// Separator line
renderer.drawLine(sidePadding, 40, renderer.getScreenWidth() - sidePadding, 40);
// Body: styled definition lines
int startLine = currentPage * linesPerPage;
for (int i = 0; i < linesPerPage && (startLine + i) < static_cast<int>(wrappedLines.size()); i++) {
int y = bodyStartY + i * lineHeight;
const auto& line = wrappedLines[startLine + i];
for (const auto& seg : line) {
renderer.drawText(readerFontId, sidePadding + seg.x, y, seg.text.c_str(), true, seg.style);
}
}
// Pagination indicator (bottom right)
if (totalPages > 1) {
std::string pageInfo = std::to_string(currentPage + 1) + "/" + std::to_string(totalPages);
int textWidth = renderer.getTextWidth(SMALL_FONT_ID, pageInfo.c_str());
renderer.drawText(SMALL_FONT_ID, renderer.getScreenWidth() - sidePadding - textWidth,
renderer.getScreenHeight() - 50, pageInfo.c_str());
}
// Button hints (bottom face buttons — hide Confirm stub like Home Screen)
const auto labels = mappedInput.mapLabels("\xC2\xAB Back", "", "\xC2\xAB Page", "Page \xC2\xBB");
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
// Side button hints (drawn in portrait coordinates for correct placement)
{
const auto origOrientation = renderer.getOrientation();
renderer.setOrientation(GfxRenderer::Orientation::Portrait);
const int portW = renderer.getScreenWidth();
constexpr int sideButtonWidth = 30;
constexpr int sideButtonHeight = 78;
constexpr int sideButtonGap = 5;
constexpr int sideTopY = 345;
constexpr int cornerRadius = 6;
const int sideX = portW - sideButtonWidth;
const int sideButtonY[2] = {sideTopY, sideTopY + sideButtonHeight + sideButtonGap};
const char* sideLabels[2] = {"\xC2\xAB Page", "Page \xC2\xBB"};
const bool useCCW = (orientation == CrossPointSettings::ORIENTATION::LANDSCAPE_CCW);
for (int i = 0; i < 2; i++) {
renderer.fillRect(sideX, sideButtonY[i], sideButtonWidth, sideButtonHeight, false);
renderer.drawRoundedRect(sideX, sideButtonY[i], sideButtonWidth, sideButtonHeight, 1, cornerRadius, true, false,
true, false, true);
const std::string truncated = renderer.truncatedText(SMALL_FONT_ID, sideLabels[i], sideButtonHeight);
const int tw = renderer.getTextWidth(SMALL_FONT_ID, truncated.c_str());
if (useCCW) {
renderer.drawTextRotated90CCW(SMALL_FONT_ID, sideX,
sideButtonY[i] + (sideButtonHeight - tw) / 2, truncated.c_str());
} else {
renderer.drawTextRotated90CW(SMALL_FONT_ID, sideX,
sideButtonY[i] + (sideButtonHeight + tw) / 2, truncated.c_str());
}
}
renderer.setOrientation(origOrientation);
}
// Use half refresh when entering the screen for cleaner transition; fast refresh for page turns.
renderer.displayBuffer(firstRender ? HalDisplay::HALF_REFRESH : HalDisplay::FAST_REFRESH);
firstRender = false;
}

View File

@@ -0,0 +1,74 @@
#pragma once
#include <EpdFontFamily.h>
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <freertos/task.h>
#include <functional>
#include <string>
#include <vector>
#include "../Activity.h"
class DictionaryDefinitionActivity final : public Activity {
public:
explicit DictionaryDefinitionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const std::string& headword, const std::string& definition, int readerFontId,
uint8_t orientation, const std::function<void()>& onBack)
: Activity("DictionaryDefinition", renderer, mappedInput),
headword(headword),
definition(definition),
readerFontId(readerFontId),
orientation(orientation),
onBack(onBack) {}
void onEnter() override;
void onExit() override;
void loop() override;
private:
// A positioned text segment within a wrapped line (pre-calculated x offset and style).
struct Segment {
std::string text;
int16_t x;
EpdFontFamily::Style style;
};
// An intermediate token produced by the HTML parser before word-wrapping.
struct TextAtom {
std::string text;
EpdFontFamily::Style style;
bool isNewline;
int indent; // pixels to indent the new line (for nested lists)
};
// Tracks ordered/unordered list nesting during HTML parsing.
struct ListState {
int counter; // incremented per <li>, 0 = not yet used
bool isAlpha; // true for list-style-type: lower-alpha
};
std::string headword;
std::string definition;
int readerFontId;
uint8_t orientation;
const std::function<void()> onBack;
std::vector<std::vector<Segment>> wrappedLines;
int currentPage = 0;
int linesPerPage = 0;
int totalPages = 0;
bool updateRequired = false;
bool firstRender = true;
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
std::vector<TextAtom> parseHtml(const std::string& html);
static std::string decodeEntity(const std::string& entity);
static bool isRenderableCodepoint(uint32_t cp);
void wrapText();
void renderScreen();
static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop();
};

View File

@@ -0,0 +1,541 @@
#include "DictionaryWordSelectActivity.h"
#include <GfxRenderer.h>
#include <algorithm>
#include <climits>
#include "CrossPointSettings.h"
#include "MappedInputManager.h"
#include "components/UITheme.h"
#include "fontIds.h"
#include "util/Dictionary.h"
#include "util/LookupHistory.h"
void DictionaryWordSelectActivity::taskTrampoline(void* param) {
auto* self = static_cast<DictionaryWordSelectActivity*>(param);
self->displayTaskLoop();
}
void DictionaryWordSelectActivity::displayTaskLoop() {
while (true) {
if (updateRequired) {
updateRequired = false;
xSemaphoreTake(renderingMutex, portMAX_DELAY);
renderScreen();
xSemaphoreGive(renderingMutex);
}
vTaskDelay(10 / portTICK_PERIOD_MS);
}
}
void DictionaryWordSelectActivity::onEnter() {
Activity::onEnter();
renderingMutex = xSemaphoreCreateMutex();
extractWords();
mergeHyphenatedWords();
if (!rows.empty()) {
currentRow = static_cast<int>(rows.size()) / 3;
currentWordInRow = 0;
}
updateRequired = true;
xTaskCreate(&DictionaryWordSelectActivity::taskTrampoline, "DictWordSelTask", 4096, this, 1, &displayTaskHandle);
}
void DictionaryWordSelectActivity::onExit() {
Activity::onExit();
xSemaphoreTake(renderingMutex, portMAX_DELAY);
if (displayTaskHandle) {
vTaskDelete(displayTaskHandle);
displayTaskHandle = nullptr;
}
vSemaphoreDelete(renderingMutex);
renderingMutex = nullptr;
}
bool DictionaryWordSelectActivity::isLandscape() const {
return orientation == CrossPointSettings::ORIENTATION::LANDSCAPE_CW ||
orientation == CrossPointSettings::ORIENTATION::LANDSCAPE_CCW;
}
bool DictionaryWordSelectActivity::isInverted() const {
return orientation == CrossPointSettings::ORIENTATION::INVERTED;
}
void DictionaryWordSelectActivity::extractWords() {
words.clear();
rows.clear();
for (const auto& element : page->elements) {
// PageLine is the only concrete PageElement type, identified by tag
const auto* line = static_cast<const PageLine*>(element.get());
const auto& block = line->getBlock();
if (!block) continue;
const auto& wordList = block->getWords();
const auto& xPosList = block->getWordXpos();
auto wordIt = wordList.begin();
auto xIt = xPosList.begin();
while (wordIt != wordList.end() && xIt != xPosList.end()) {
int16_t screenX = line->xPos + static_cast<int16_t>(*xIt) + marginLeft;
int16_t screenY = line->yPos + marginTop;
int16_t wordWidth = renderer.getTextWidth(fontId, wordIt->c_str());
words.push_back({*wordIt, screenX, screenY, wordWidth, 0});
++wordIt;
++xIt;
}
}
// Group words into rows by Y position
if (words.empty()) return;
int16_t currentY = words[0].screenY;
rows.push_back({currentY, {}});
for (size_t i = 0; i < words.size(); i++) {
// Allow small Y tolerance (words on same line may differ by a pixel)
if (std::abs(words[i].screenY - currentY) > 2) {
currentY = words[i].screenY;
rows.push_back({currentY, {}});
}
words[i].row = static_cast<int16_t>(rows.size() - 1);
rows.back().wordIndices.push_back(static_cast<int>(i));
}
}
void DictionaryWordSelectActivity::mergeHyphenatedWords() {
for (size_t r = 0; r + 1 < rows.size(); r++) {
if (rows[r].wordIndices.empty() || rows[r + 1].wordIndices.empty()) continue;
int lastWordIdx = rows[r].wordIndices.back();
const std::string& lastWord = words[lastWordIdx].text;
if (lastWord.empty()) continue;
// Check if word ends with hyphen (regular '-' or soft hyphen U+00AD: 0xC2 0xAD)
bool endsWithHyphen = false;
if (lastWord.back() == '-') {
endsWithHyphen = true;
} else if (lastWord.size() >= 2 && static_cast<uint8_t>(lastWord[lastWord.size() - 2]) == 0xC2 &&
static_cast<uint8_t>(lastWord[lastWord.size() - 1]) == 0xAD) {
endsWithHyphen = true;
}
if (!endsWithHyphen) continue;
int nextWordIdx = rows[r + 1].wordIndices.front();
// Set bidirectional continuation links for highlighting both parts
words[lastWordIdx].continuationIndex = nextWordIdx;
words[nextWordIdx].continuationOf = lastWordIdx;
// Build merged lookup text: remove trailing hyphen and combine
std::string firstPart = lastWord;
if (firstPart.back() == '-') {
firstPart.pop_back();
} else if (firstPart.size() >= 2 && static_cast<uint8_t>(firstPart[firstPart.size() - 2]) == 0xC2 &&
static_cast<uint8_t>(firstPart[firstPart.size() - 1]) == 0xAD) {
firstPart.erase(firstPart.size() - 2);
}
std::string merged = firstPart + words[nextWordIdx].text;
words[lastWordIdx].lookupText = merged;
words[nextWordIdx].lookupText = merged;
words[nextWordIdx].continuationIndex = nextWordIdx; // self-ref so highlight logic finds the second part
}
// Remove empty rows that may result from merging (e.g., a row whose only word was a continuation)
rows.erase(std::remove_if(rows.begin(), rows.end(), [](const Row& r) { return r.wordIndices.empty(); }), rows.end());
}
void DictionaryWordSelectActivity::loop() {
if (words.empty()) {
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
onBack();
}
return;
}
bool changed = false;
const bool landscape = isLandscape();
const bool inverted = isInverted();
// Button mapping depends on physical orientation:
// - Portrait: side Up/Down = row nav, face Left/Right = word nav
// - Inverted: same axes but reversed directions (device is flipped 180)
// - Landscape: face Left/Right = row nav (swapped), side Up/Down = word nav
bool rowPrevPressed, rowNextPressed, wordPrevPressed, wordNextPressed;
if (landscape && orientation == CrossPointSettings::ORIENTATION::LANDSCAPE_CW) {
rowPrevPressed = mappedInput.wasReleased(MappedInputManager::Button::Left);
rowNextPressed = mappedInput.wasReleased(MappedInputManager::Button::Right);
wordPrevPressed = mappedInput.wasReleased(MappedInputManager::Button::PageForward) ||
mappedInput.wasReleased(MappedInputManager::Button::Down);
wordNextPressed = mappedInput.wasReleased(MappedInputManager::Button::PageBack) ||
mappedInput.wasReleased(MappedInputManager::Button::Up);
} else if (landscape) {
rowPrevPressed = mappedInput.wasReleased(MappedInputManager::Button::Right);
rowNextPressed = mappedInput.wasReleased(MappedInputManager::Button::Left);
wordPrevPressed = mappedInput.wasReleased(MappedInputManager::Button::PageBack) ||
mappedInput.wasReleased(MappedInputManager::Button::Up);
wordNextPressed = mappedInput.wasReleased(MappedInputManager::Button::PageForward) ||
mappedInput.wasReleased(MappedInputManager::Button::Down);
} else if (inverted) {
rowPrevPressed = mappedInput.wasReleased(MappedInputManager::Button::PageForward) ||
mappedInput.wasReleased(MappedInputManager::Button::Down);
rowNextPressed = mappedInput.wasReleased(MappedInputManager::Button::PageBack) ||
mappedInput.wasReleased(MappedInputManager::Button::Up);
wordPrevPressed = mappedInput.wasReleased(MappedInputManager::Button::Right);
wordNextPressed = mappedInput.wasReleased(MappedInputManager::Button::Left);
} else {
// Portrait (default)
rowPrevPressed = mappedInput.wasReleased(MappedInputManager::Button::PageBack) ||
mappedInput.wasReleased(MappedInputManager::Button::Up);
rowNextPressed = mappedInput.wasReleased(MappedInputManager::Button::PageForward) ||
mappedInput.wasReleased(MappedInputManager::Button::Down);
wordPrevPressed = mappedInput.wasReleased(MappedInputManager::Button::Left);
wordNextPressed = mappedInput.wasReleased(MappedInputManager::Button::Right);
}
const int rowCount = static_cast<int>(rows.size());
// Helper: find closest word by X position in a target row
auto findClosestWord = [&](int targetRow) {
int wordIdx = rows[currentRow].wordIndices[currentWordInRow];
int currentCenterX = words[wordIdx].screenX + words[wordIdx].width / 2;
int bestMatch = 0;
int bestDist = INT_MAX;
for (int i = 0; i < static_cast<int>(rows[targetRow].wordIndices.size()); i++) {
int idx = rows[targetRow].wordIndices[i];
int centerX = words[idx].screenX + words[idx].width / 2;
int dist = std::abs(centerX - currentCenterX);
if (dist < bestDist) {
bestDist = dist;
bestMatch = i;
}
}
return bestMatch;
};
// Move to previous row (wrap to bottom)
if (rowPrevPressed) {
int targetRow = (currentRow > 0) ? currentRow - 1 : rowCount - 1;
currentWordInRow = findClosestWord(targetRow);
currentRow = targetRow;
changed = true;
}
// Move to next row (wrap to top)
if (rowNextPressed) {
int targetRow = (currentRow < rowCount - 1) ? currentRow + 1 : 0;
currentWordInRow = findClosestWord(targetRow);
currentRow = targetRow;
changed = true;
}
// Move to previous word (wrap to end of previous row)
if (wordPrevPressed) {
if (currentWordInRow > 0) {
currentWordInRow--;
} else if (rowCount > 1) {
currentRow = (currentRow > 0) ? currentRow - 1 : rowCount - 1;
currentWordInRow = static_cast<int>(rows[currentRow].wordIndices.size()) - 1;
}
changed = true;
}
// Move to next word (wrap to start of next row)
if (wordNextPressed) {
if (currentWordInRow < static_cast<int>(rows[currentRow].wordIndices.size()) - 1) {
currentWordInRow++;
} else if (rowCount > 1) {
currentRow = (currentRow < rowCount - 1) ? currentRow + 1 : 0;
currentWordInRow = 0;
}
changed = true;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
int wordIdx = rows[currentRow].wordIndices[currentWordInRow];
const std::string& rawWord = words[wordIdx].lookupText;
std::string cleaned = Dictionary::cleanWord(rawWord);
if (cleaned.empty()) {
GUI.drawPopup(renderer, "No word");
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
vTaskDelay(1000 / portTICK_PERIOD_MS);
updateRequired = true;
return;
}
// Show looking up popup, then release mutex so display task can run
xSemaphoreTake(renderingMutex, portMAX_DELAY);
Rect popupLayout = GUI.drawPopup(renderer, "Looking up...");
xSemaphoreGive(renderingMutex);
bool cancelled = false;
std::string definition = Dictionary::lookup(
cleaned,
[this, &popupLayout](int percent) {
xSemaphoreTake(renderingMutex, portMAX_DELAY);
GUI.fillPopupProgress(renderer, popupLayout, percent);
xSemaphoreGive(renderingMutex);
},
[this, &cancelled]() -> bool {
mappedInput.update();
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
cancelled = true;
return true;
}
return false;
});
if (cancelled) {
updateRequired = true;
return;
}
if (definition.empty()) {
GUI.drawPopup(renderer, "Not found");
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
vTaskDelay(1500 / portTICK_PERIOD_MS);
updateRequired = true;
return;
}
LookupHistory::addWord(cachePath, cleaned);
onLookup(cleaned, definition);
return;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
onBack();
return;
}
if (changed) {
updateRequired = true;
}
}
void DictionaryWordSelectActivity::renderScreen() {
renderer.clearScreen();
// Render the page content
page->render(renderer, fontId, marginLeft, marginTop);
if (!words.empty() && currentRow < static_cast<int>(rows.size())) {
int wordIdx = rows[currentRow].wordIndices[currentWordInRow];
const auto& w = words[wordIdx];
// Draw inverted highlight behind selected word
const int lineHeight = renderer.getLineHeight(fontId);
renderer.fillRect(w.screenX - 1, w.screenY - 1, w.width + 2, lineHeight + 2, true);
renderer.drawText(fontId, w.screenX, w.screenY, w.text.c_str(), false);
// Highlight the other half of a hyphenated word (whether selecting first or second part)
int otherIdx = (w.continuationOf >= 0) ? w.continuationOf : -1;
if (otherIdx < 0 && w.continuationIndex >= 0 && w.continuationIndex != wordIdx) {
otherIdx = w.continuationIndex;
}
if (otherIdx >= 0) {
const auto& other = words[otherIdx];
renderer.fillRect(other.screenX - 1, other.screenY - 1, other.width + 2, lineHeight + 2, true);
renderer.drawText(fontId, other.screenX, other.screenY, other.text.c_str(), false);
}
}
drawHints();
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
}
void DictionaryWordSelectActivity::drawHints() {
// Draw button hints in portrait orientation (matching physical buttons and theme).
// Any hint whose area would overlap the selected word highlight is completely skipped,
// leaving the page content underneath visible.
const auto origOrientation = renderer.getOrientation();
// Get portrait dimensions for overlap math
renderer.setOrientation(GfxRenderer::Orientation::Portrait);
const int portW = renderer.getScreenWidth(); // 480 in portrait
const int portH = renderer.getScreenHeight(); // 800 in portrait
renderer.setOrientation(origOrientation);
// Bottom button constants (match LyraTheme::drawButtonHints)
constexpr int buttonHeight = 40; // LyraMetrics::values.buttonHintsHeight
constexpr int buttonWidth = 80;
constexpr int cornerRadius = 6;
constexpr int textYOffset = 7;
constexpr int smallButtonHeight = 15;
constexpr int buttonPositions[] = {58, 146, 254, 342};
// Side button constants (match LyraTheme::drawSideButtonHints)
constexpr int sideButtonWidth = 30; // LyraMetrics::values.sideButtonHintsWidth
constexpr int sideButtonHeight = 78;
constexpr int sideButtonGap = 5;
constexpr int sideTopY = 345; // topHintButtonY
const int sideX = portW - sideButtonWidth;
const int sideButtonY[2] = {sideTopY, sideTopY + sideButtonHeight + sideButtonGap};
// Labels for face and side buttons depend on orientation,
// because the physical-to-logical mapping rotates with the screen.
const char* facePrev; // label for physical Left face button
const char* faceNext; // label for physical Right face button
const char* sideTop; // label for physical top side button (PageBack)
const char* sideBottom; // label for physical bottom side button (PageForward)
const bool landscape = isLandscape();
const bool inverted = isInverted();
if (landscape && orientation == CrossPointSettings::ORIENTATION::LANDSCAPE_CW) {
facePrev = "Line Up"; faceNext = "Line Dn";
sideTop = "Word \xC2\xBB"; sideBottom = "\xC2\xAB Word";
} else if (landscape) { // LANDSCAPE_CCW
facePrev = "Line Dn"; faceNext = "Line Up";
sideTop = "\xC2\xAB Word"; sideBottom = "Word \xC2\xBB";
} else if (inverted) {
facePrev = "Word \xC2\xBB"; faceNext = "\xC2\xAB Word";
sideTop = "Line Dn"; sideBottom = "Line Up";
} else { // Portrait (default)
facePrev = "\xC2\xAB Word"; faceNext = "Word \xC2\xBB";
sideTop = "Line Up"; sideBottom = "Line Dn";
}
const auto labels = mappedInput.mapLabels("\xC2\xAB Back", "Select", facePrev, faceNext);
const char* btnLabels[] = {labels.btn1, labels.btn2, labels.btn3, labels.btn4};
const char* sideLabels[] = {sideTop, sideBottom};
// ---- Determine which hints overlap the selected word ----
bool hideHint[4] = {false, false, false, false};
bool hideSide[2] = {false, false};
if (!words.empty() && currentRow < static_cast<int>(rows.size())) {
const int lineHeight = renderer.getLineHeight(fontId);
// Collect bounding boxes of the selected word (and its continuation) in current-orientation coords.
struct Box {
int x, y, w, h;
};
Box boxes[2];
int boxCount = 0;
int wordIdx = rows[currentRow].wordIndices[currentWordInRow];
const auto& sel = words[wordIdx];
boxes[0] = {sel.screenX - 1, sel.screenY - 1, sel.width + 2, lineHeight + 2};
boxCount = 1;
int otherIdx = (sel.continuationOf >= 0) ? sel.continuationOf : -1;
if (otherIdx < 0 && sel.continuationIndex >= 0 && sel.continuationIndex != wordIdx) {
otherIdx = sel.continuationIndex;
}
if (otherIdx >= 0) {
const auto& other = words[otherIdx];
boxes[1] = {other.screenX - 1, other.screenY - 1, other.width + 2, lineHeight + 2};
boxCount = 2;
}
// Convert each box from the current orientation to portrait coordinates,
// then check overlap against both bottom and side button hints.
for (int b = 0; b < boxCount; b++) {
int px, py, pw, ph;
if (origOrientation == GfxRenderer::Orientation::Portrait) {
px = boxes[b].x;
py = boxes[b].y;
pw = boxes[b].w;
ph = boxes[b].h;
} else if (origOrientation == GfxRenderer::Orientation::PortraitInverted) {
px = portW - boxes[b].x - boxes[b].w;
py = portH - boxes[b].y - boxes[b].h;
pw = boxes[b].w;
ph = boxes[b].h;
} else if (origOrientation == GfxRenderer::Orientation::LandscapeClockwise) {
px = boxes[b].y;
py = portH - boxes[b].x - boxes[b].w;
pw = boxes[b].h;
ph = boxes[b].w;
} else {
px = portW - boxes[b].y - boxes[b].h;
py = boxes[b].x;
pw = boxes[b].h;
ph = boxes[b].w;
}
// Bottom button overlap
int hintTop = portH - buttonHeight;
if (py + ph > hintTop) {
for (int i = 0; i < 4; i++) {
if (px + pw > buttonPositions[i] && px < buttonPositions[i] + buttonWidth) {
hideHint[i] = true;
}
}
}
// Side button overlap
if (px + pw > sideX) {
for (int s = 0; s < 2; s++) {
if (py + ph > sideButtonY[s] && py < sideButtonY[s] + sideButtonHeight) {
hideSide[s] = true;
}
}
}
}
}
// ---- Draw all hints in portrait mode ----
// Hidden buttons are skipped entirely so the page content underneath stays visible.
renderer.setOrientation(GfxRenderer::Orientation::Portrait);
// Bottom face buttons
for (int i = 0; i < 4; i++) {
if (hideHint[i]) continue;
const int x = buttonPositions[i];
renderer.fillRect(x, portH - buttonHeight, buttonWidth, buttonHeight, false);
if (btnLabels[i] != nullptr && btnLabels[i][0] != '\0') {
renderer.drawRoundedRect(x, portH - buttonHeight, buttonWidth, buttonHeight, 1, cornerRadius, true, true, false,
false, true);
const int tw = renderer.getTextWidth(SMALL_FONT_ID, btnLabels[i]);
const int tx = x + (buttonWidth - 1 - tw) / 2;
renderer.drawText(SMALL_FONT_ID, tx, portH - buttonHeight + textYOffset, btnLabels[i]);
} else {
renderer.drawRoundedRect(x, portH - smallButtonHeight, buttonWidth, smallButtonHeight, 1, cornerRadius, true,
true, false, false, true);
}
}
// Side buttons (custom-drawn with background, overlap hiding, truncation, and rotation)
const bool useCCW = (orientation == CrossPointSettings::ORIENTATION::LANDSCAPE_CCW);
for (int i = 0; i < 2; i++) {
if (hideSide[i]) continue;
if (sideLabels[i] == nullptr || sideLabels[i][0] == '\0') continue;
// Solid background
renderer.fillRect(sideX, sideButtonY[i], sideButtonWidth, sideButtonHeight, false);
// Outline (rounded on inner side, square on screen edge — matches theme)
renderer.drawRoundedRect(sideX, sideButtonY[i], sideButtonWidth, sideButtonHeight, 1, cornerRadius, true, false,
true, false, true);
// Truncate text if it would overflow the button height
const std::string truncated = renderer.truncatedText(SMALL_FONT_ID, sideLabels[i], sideButtonHeight);
const int tw = renderer.getTextWidth(SMALL_FONT_ID, truncated.c_str());
if (useCCW) {
// Text reads top-to-bottom (90° CCW rotation): y starts near top of button
renderer.drawTextRotated90CCW(SMALL_FONT_ID, sideX,
sideButtonY[i] + (sideButtonHeight - tw) / 2, truncated.c_str());
} else {
// Text reads bottom-to-top (90° CW rotation): y starts near bottom of button
renderer.drawTextRotated90CW(SMALL_FONT_ID, sideX,
sideButtonY[i] + (sideButtonHeight + tw) / 2, truncated.c_str());
}
}
renderer.setOrientation(origOrientation);
}

View File

@@ -0,0 +1,80 @@
#pragma once
#include <Epub/Page.h>
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <freertos/task.h>
#include <functional>
#include <memory>
#include <string>
#include <vector>
#include "../Activity.h"
class DictionaryWordSelectActivity final : public Activity {
public:
explicit DictionaryWordSelectActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
std::unique_ptr<Page> page, int fontId, int marginLeft, int marginTop,
const std::string& cachePath, uint8_t orientation,
const std::function<void()>& onBack,
const std::function<void(const std::string&, const std::string&)>& onLookup)
: Activity("DictionaryWordSelect", renderer, mappedInput),
page(std::move(page)),
fontId(fontId),
marginLeft(marginLeft),
marginTop(marginTop),
cachePath(cachePath),
orientation(orientation),
onBack(onBack),
onLookup(onLookup) {}
void onEnter() override;
void onExit() override;
void loop() override;
private:
struct WordInfo {
std::string text;
std::string lookupText;
int16_t screenX;
int16_t screenY;
int16_t width;
int16_t row;
int continuationIndex;
int continuationOf;
WordInfo(const std::string& t, int16_t x, int16_t y, int16_t w, int16_t r)
: text(t), lookupText(t), screenX(x), screenY(y), width(w), row(r), continuationIndex(-1), continuationOf(-1) {}
};
struct Row {
int16_t yPos;
std::vector<int> wordIndices;
};
std::unique_ptr<Page> page;
int fontId;
int marginLeft;
int marginTop;
std::string cachePath;
uint8_t orientation;
const std::function<void()> onBack;
const std::function<void(const std::string&, const std::string&)> onLookup;
std::vector<WordInfo> words;
std::vector<Row> rows;
int currentRow = 0;
int currentWordInRow = 0;
bool updateRequired = false;
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
bool isLandscape() const;
bool isInverted() const;
void extractWords();
void mergeHyphenatedWords();
void renderScreen();
void drawHints();
static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop();
};

View File

@@ -3,10 +3,11 @@
#include <Epub/Page.h>
#include <FsHelpers.h>
#include <GfxRenderer.h>
#include <SDCardManager.h>
#include <HalStorage.h>
#include "CrossPointSettings.h"
#include "CrossPointState.h"
#include "EpubReaderBookmarkSelectionActivity.h"
#include "EpubReaderChapterSelectionActivity.h"
#include "EpubReaderPercentSelectionActivity.h"
#include "KOReaderCredentialStore.h"
@@ -15,6 +16,9 @@
#include "RecentBooksStore.h"
#include "components/UITheme.h"
#include "fontIds.h"
#include "util/BookmarkStore.h"
#include "util/Dictionary.h"
#include "util/LookupHistory.h"
namespace {
// pagesPerRefresh now comes from SETTINGS.getRefreshFrequency()
@@ -77,7 +81,7 @@ void EpubReaderActivity::onEnter() {
epub->setupCacheDir();
FsFile f;
if (SdMan.openFileForRead("ERS", epub->getCachePath() + "/progress.bin", f)) {
if (Storage.openFileForRead("ERS", epub->getCachePath() + "/progress.bin", f)) {
uint8_t data[6];
int dataSize = f.read(data, 6);
if (dataSize == 4 || dataSize == 6) {
@@ -102,6 +106,42 @@ void EpubReaderActivity::onEnter() {
}
}
// Prerender covers and thumbnails on first open so Home and Sleep screens are instant.
// Each generate* call is a no-op if the file already exists, so this only does work once.
{
int totalSteps = 0;
if (!Storage.exists(epub->getCoverBmpPath(false).c_str())) totalSteps++;
if (!Storage.exists(epub->getCoverBmpPath(true).c_str())) totalSteps++;
for (int i = 0; i < PRERENDER_THUMB_HEIGHTS_COUNT; i++) {
if (!Storage.exists(epub->getThumbBmpPath(PRERENDER_THUMB_HEIGHTS[i]).c_str())) totalSteps++;
}
if (totalSteps > 0) {
Rect popupRect = GUI.drawPopup(renderer, "Preparing book...");
int completedSteps = 0;
auto updateProgress = [&]() {
completedSteps++;
GUI.fillPopupProgress(renderer, popupRect, completedSteps * 100 / totalSteps);
};
if (!Storage.exists(epub->getCoverBmpPath(false).c_str())) {
epub->generateCoverBmp(false);
updateProgress();
}
if (!Storage.exists(epub->getCoverBmpPath(true).c_str())) {
epub->generateCoverBmp(true);
updateProgress();
}
for (int i = 0; i < PRERENDER_THUMB_HEIGHTS_COUNT; i++) {
if (!Storage.exists(epub->getThumbBmpPath(PRERENDER_THUMB_HEIGHTS[i]).c_str())) {
epub->generateThumbBmp(PRERENDER_THUMB_HEIGHTS[i]);
updateProgress();
}
}
}
}
// Save current epub as last opened epub and add to recent books
APP_STATE.openEpubPath = epub->getPath();
APP_STATE.saveToFile();
@@ -196,23 +236,27 @@ void EpubReaderActivity::loop() {
bookProgress = epub->calculateProgress(currentSpineIndex, chapterProgress) * 100.0f;
}
const int bookProgressPercent = clampPercent(static_cast<int>(bookProgress + 0.5f));
const bool hasDictionary = Dictionary::exists();
const bool isBookmarked = BookmarkStore::hasBookmark(
epub->getCachePath(), currentSpineIndex, section ? section->currentPage : 0);
exitActivity();
enterNewActivity(new EpubReaderMenuActivity(
this->renderer, this->mappedInput, epub->getTitle(), currentPage, totalPages, bookProgressPercent,
SETTINGS.orientation, [this](const uint8_t orientation) { onReaderMenuBack(orientation); },
SETTINGS.orientation, hasDictionary, isBookmarked,
[this](const uint8_t orientation) { onReaderMenuBack(orientation); },
[this](EpubReaderMenuActivity::MenuAction action) { onReaderMenuConfirm(action); }));
xSemaphoreGive(renderingMutex);
}
// Long press BACK (1s+) goes directly to home
// Long press BACK (1s+) goes to file selection
if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= goHomeMs) {
onGoHome();
onGoBack();
return;
}
// Short press BACK goes to file selection
// Short press BACK goes directly to home
if (mappedInput.wasReleased(MappedInputManager::Button::Back) && mappedInput.getHeldTime() < goHomeMs) {
onGoBack();
onGoHome();
return;
}
@@ -293,6 +337,8 @@ void EpubReaderActivity::onReaderMenuBack(const uint8_t orientation) {
// Apply the user-selected orientation when the menu is dismissed.
// This ensures the menu can be navigated without immediately rotating the screen.
applyOrientation(orientation);
// Force a half refresh on the next render to clear menu/popup artifacts
pagesUntilFullRefresh = 1;
updateRequired = true;
}
@@ -360,6 +406,170 @@ void EpubReaderActivity::jumpToPercent(int percent) {
void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action) {
switch (action) {
case EpubReaderMenuActivity::MenuAction::ADD_BOOKMARK: {
const int page = section ? section->currentPage : 0;
// Extract first full sentence from the current page for the bookmark snippet.
// If the first word is lowercase, the page starts mid-sentence — skip to the
// next sentence boundary and start collecting from there.
std::string snippet;
if (section) {
auto p = section->loadPageFromSectionFile();
if (p) {
// Gather all words on the page into a flat list for easier traversal
std::vector<std::string> allWords;
for (const auto& element : p->elements) {
const auto* line = static_cast<const PageLine*>(element.get());
if (!line) continue;
const auto& block = line->getBlock();
if (!block) continue;
for (const auto& word : block->getWords()) {
allWords.push_back(word);
}
}
if (!allWords.empty()) {
size_t startIdx = 0;
// Check if the first word starts with a lowercase letter (mid-sentence)
const char firstChar = allWords[0].empty() ? '\0' : allWords[0][0];
if (firstChar >= 'a' && firstChar <= 'z') {
// Skip past the end of this partial sentence
for (size_t i = 0; i < allWords.size(); i++) {
if (!allWords[i].empty()) {
char last = allWords[i].back();
if (last == '.' || last == '!' || last == '?' || last == ':') {
startIdx = i + 1;
break;
}
}
}
// If no sentence boundary found, fall back to using everything from the start
if (startIdx >= allWords.size()) {
startIdx = 0;
}
}
// Collect words from startIdx until the next sentence boundary
for (size_t i = startIdx; i < allWords.size(); i++) {
if (!snippet.empty()) snippet += " ";
snippet += allWords[i];
if (!allWords[i].empty()) {
char last = allWords[i].back();
if (last == '.' || last == '!' || last == '?' || last == ':') {
break;
}
}
}
}
}
}
BookmarkStore::addBookmark(epub->getCachePath(), currentSpineIndex, page, snippet);
xSemaphoreTake(renderingMutex, portMAX_DELAY);
GUI.drawPopup(renderer, "Bookmark added");
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
xSemaphoreGive(renderingMutex);
vTaskDelay(750 / portTICK_PERIOD_MS);
// Exit the menu and return to reading — the bookmark indicator will show on re-render,
// and next menu open will reflect the updated state.
exitActivity();
pagesUntilFullRefresh = 1;
updateRequired = true;
break;
}
case EpubReaderMenuActivity::MenuAction::REMOVE_BOOKMARK: {
const int page = section ? section->currentPage : 0;
BookmarkStore::removeBookmark(epub->getCachePath(), currentSpineIndex, page);
xSemaphoreTake(renderingMutex, portMAX_DELAY);
GUI.drawPopup(renderer, "Bookmark removed");
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
xSemaphoreGive(renderingMutex);
vTaskDelay(750 / portTICK_PERIOD_MS);
exitActivity();
pagesUntilFullRefresh = 1;
updateRequired = true;
break;
}
case EpubReaderMenuActivity::MenuAction::GO_TO_BOOKMARK: {
auto bookmarks = BookmarkStore::load(epub->getCachePath());
if (bookmarks.empty()) {
// No bookmarks: fall back to Table of Contents if available, otherwise go back
if (epub->getTocItemsCount() > 0) {
const int currentP = section ? section->currentPage : 0;
const int totalP = section ? section->pageCount : 0;
const int spineIdx = currentSpineIndex;
const std::string path = epub->getPath();
xSemaphoreTake(renderingMutex, portMAX_DELAY);
exitActivity();
enterNewActivity(new EpubReaderChapterSelectionActivity(
this->renderer, this->mappedInput, epub, path, spineIdx, currentP, totalP,
[this] {
exitActivity();
updateRequired = true;
},
[this](const int newSpineIndex) {
if (currentSpineIndex != newSpineIndex) {
currentSpineIndex = newSpineIndex;
nextPageNumber = 0;
section.reset();
}
exitActivity();
updateRequired = true;
},
[this](const int newSpineIndex, const int newPage) {
if (currentSpineIndex != newSpineIndex || (section && section->currentPage != newPage)) {
currentSpineIndex = newSpineIndex;
nextPageNumber = newPage;
section.reset();
}
exitActivity();
updateRequired = true;
}));
xSemaphoreGive(renderingMutex);
}
// If no TOC either, just return to reader (menu already closed by callback)
break;
}
xSemaphoreTake(renderingMutex, portMAX_DELAY);
exitActivity();
enterNewActivity(new EpubReaderBookmarkSelectionActivity(
this->renderer, this->mappedInput, epub, std::move(bookmarks), epub->getCachePath(),
[this] {
exitActivity();
updateRequired = true;
},
[this](const int newSpineIndex, const int newPage) {
if (currentSpineIndex != newSpineIndex || (section && section->currentPage != newPage)) {
currentSpineIndex = newSpineIndex;
nextPageNumber = newPage;
section.reset();
}
exitActivity();
updateRequired = true;
}));
xSemaphoreGive(renderingMutex);
break;
}
case EpubReaderMenuActivity::MenuAction::DELETE_DICT_CACHE: {
if (Dictionary::cacheExists()) {
Dictionary::deleteCache();
xSemaphoreTake(renderingMutex, portMAX_DELAY);
GUI.drawPopup(renderer, "Dictionary cache deleted");
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
xSemaphoreGive(renderingMutex);
} else {
xSemaphoreTake(renderingMutex, portMAX_DELAY);
GUI.drawPopup(renderer, "No cache to delete");
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
xSemaphoreGive(renderingMutex);
}
vTaskDelay(1500 / portTICK_PERIOD_MS);
break;
}
case EpubReaderMenuActivity::MenuAction::SELECT_CHAPTER: {
// Calculate values BEFORE we start destroying things
const int currentP = section ? section->currentPage : 0;
@@ -427,6 +637,92 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
xSemaphoreGive(renderingMutex);
break;
}
case EpubReaderMenuActivity::MenuAction::LOOKUP: {
xSemaphoreTake(renderingMutex, portMAX_DELAY);
// Compute margins (same logic as renderScreen)
int orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft;
renderer.getOrientedViewableTRBL(&orientedMarginTop, &orientedMarginRight, &orientedMarginBottom,
&orientedMarginLeft);
orientedMarginTop += SETTINGS.screenMargin;
orientedMarginLeft += SETTINGS.screenMargin;
orientedMarginRight += SETTINGS.screenMargin;
orientedMarginBottom += SETTINGS.screenMargin;
if (SETTINGS.statusBar != CrossPointSettings::STATUS_BAR_MODE::NONE) {
auto metrics = UITheme::getInstance().getMetrics();
const bool showProgressBar =
SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::BOOK_PROGRESS_BAR ||
SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::ONLY_BOOK_PROGRESS_BAR ||
SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::CHAPTER_PROGRESS_BAR;
orientedMarginBottom += statusBarMargin - SETTINGS.screenMargin +
(showProgressBar ? (metrics.bookProgressBarHeight + progressBarMarginTop) : 0);
}
// Load the current page
auto pageForLookup = section ? section->loadPageFromSectionFile() : nullptr;
const int readerFontId = SETTINGS.getReaderFontId();
const std::string bookCachePath = epub->getCachePath();
const uint8_t currentOrientation = SETTINGS.orientation;
exitActivity();
if (pageForLookup) {
enterNewActivity(new DictionaryWordSelectActivity(
renderer, mappedInput, std::move(pageForLookup), readerFontId, orientedMarginLeft, orientedMarginTop,
bookCachePath, currentOrientation,
[this]() {
// On back from word select
pendingSubactivityExit = true;
},
[this, bookCachePath, readerFontId, currentOrientation](const std::string& headword,
const std::string& definition) {
// On successful lookup - show definition
exitActivity();
enterNewActivity(new DictionaryDefinitionActivity(renderer, mappedInput, headword, definition,
readerFontId, currentOrientation,
[this]() { pendingSubactivityExit = true; }));
}));
}
xSemaphoreGive(renderingMutex);
break;
}
case EpubReaderMenuActivity::MenuAction::LOOKED_UP_WORDS: {
xSemaphoreTake(renderingMutex, portMAX_DELAY);
const std::string bookCachePath = epub->getCachePath();
const int readerFontId = SETTINGS.getReaderFontId();
const uint8_t currentOrientation = SETTINGS.orientation;
exitActivity();
enterNewActivity(new LookedUpWordsActivity(
renderer, mappedInput, bookCachePath,
[this]() {
// On back from looked up words
pendingSubactivityExit = true;
},
[this, bookCachePath, readerFontId, currentOrientation](const std::string& headword) {
// Look up the word and show definition with progress bar
Rect popupLayout = GUI.drawPopup(renderer, "Looking up...");
std::string definition = Dictionary::lookup(
headword, [this, &popupLayout](int percent) { GUI.fillPopupProgress(renderer, popupLayout, percent); });
if (definition.empty()) {
GUI.drawPopup(renderer, "Not found");
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
vTaskDelay(1500 / portTICK_PERIOD_MS);
return;
}
exitActivity();
enterNewActivity(new DictionaryDefinitionActivity(renderer, mappedInput, headword, definition, readerFontId,
currentOrientation,
[this]() { pendingSubactivityExit = true; }));
}));
xSemaphoreGive(renderingMutex);
break;
}
case EpubReaderMenuActivity::MenuAction::GO_HOME: {
// Defer go home to avoid race condition with display task
pendingGoHome = true;
@@ -654,7 +950,7 @@ void EpubReaderActivity::renderScreen() {
void EpubReaderActivity::saveProgress(int spineIndex, int currentPage, int pageCount) {
FsFile f;
if (SdMan.openFileForWrite("ERS", epub->getCachePath() + "/progress.bin", f)) {
if (Storage.openFileForWrite("ERS", epub->getCachePath() + "/progress.bin", f)) {
uint8_t data[6];
data[0] = currentSpineIndex & 0xFF;
data[1] = (currentSpineIndex >> 8) & 0xFF;
@@ -664,15 +960,31 @@ void EpubReaderActivity::saveProgress(int spineIndex, int currentPage, int pageC
data[5] = (pageCount >> 8) & 0xFF;
f.write(data, 6);
f.close();
Serial.printf("[ERS] Progress saved: Chapter %d, Page %d\n", spineIndex, currentPage);
Serial.printf("[%lu] [ERS] Progress saved: Chapter %d, Page %d\n", millis(), spineIndex, currentPage);
} else {
Serial.printf("[ERS] Could not save progress!\n");
Serial.printf("[%lu] [ERS] Could not save progress!\n", millis());
}
}
void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int orientedMarginTop,
const int orientedMarginRight, const int orientedMarginBottom,
const int orientedMarginLeft) {
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
// Draw bookmark ribbon indicator in top-right corner if current page is bookmarked
if (section && BookmarkStore::hasBookmark(epub->getCachePath(), currentSpineIndex, section->currentPage)) {
const int screenWidth = renderer.getScreenWidth();
const int bkWidth = 12;
const int bkHeight = 22;
const int bkX = screenWidth - orientedMarginRight - bkWidth + 2;
const int bkY = 0;
const int notchDepth = bkHeight / 3;
const int centerX = bkX + bkWidth / 2;
const int xPoints[5] = {bkX, bkX + bkWidth, bkX + bkWidth, centerX, bkX};
const int yPoints[5] = {bkY, bkY, bkY + bkHeight, bkY + bkHeight - notchDepth, bkY + bkHeight};
renderer.fillPolygon(xPoints, yPoints, 5, true);
}
renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
if (pagesUntilFullRefresh <= 1) {
renderer.displayBuffer(HalDisplay::HALF_REFRESH);

View File

@@ -5,7 +5,10 @@
#include <freertos/semphr.h>
#include <freertos/task.h>
#include "DictionaryDefinitionActivity.h"
#include "DictionaryWordSelectActivity.h"
#include "EpubReaderMenuActivity.h"
#include "LookedUpWordsActivity.h"
#include "activities/ActivityWithSubactivity.h"
class EpubReaderActivity final : public ActivityWithSubactivity {

View File

@@ -0,0 +1,262 @@
#include "EpubReaderBookmarkSelectionActivity.h"
#include <GfxRenderer.h>
#include "MappedInputManager.h"
#include "components/UITheme.h"
#include "fontIds.h"
int EpubReaderBookmarkSelectionActivity::getTotalItems() const { return static_cast<int>(bookmarks.size()); }
int EpubReaderBookmarkSelectionActivity::getPageItems() const {
constexpr int lineHeight = 30;
const int screenHeight = renderer.getScreenHeight();
const auto orientation = renderer.getOrientation();
const bool isPortraitInverted = orientation == GfxRenderer::Orientation::PortraitInverted;
const int hintGutterHeight = isPortraitInverted ? 50 : 0;
const int startY = 60 + hintGutterHeight;
const int availableHeight = screenHeight - startY - lineHeight;
return std::max(1, availableHeight / lineHeight);
}
std::string EpubReaderBookmarkSelectionActivity::getBookmarkPrefix(const Bookmark& bookmark) const {
std::string label;
if (epub) {
const int tocIndex = epub->getTocIndexForSpineIndex(bookmark.spineIndex);
if (tocIndex >= 0 && tocIndex < epub->getTocItemsCount()) {
label = epub->getTocItem(tocIndex).title;
} else {
label = "Chapter " + std::to_string(bookmark.spineIndex + 1);
}
} else {
label = "Chapter " + std::to_string(bookmark.spineIndex + 1);
}
if (!bookmark.snippet.empty()) {
label += " - " + bookmark.snippet;
}
return label;
}
std::string EpubReaderBookmarkSelectionActivity::getPageSuffix(const Bookmark& bookmark) {
return " - Page " + std::to_string(bookmark.pageNumber + 1);
}
void EpubReaderBookmarkSelectionActivity::taskTrampoline(void* param) {
auto* self = static_cast<EpubReaderBookmarkSelectionActivity*>(param);
self->displayTaskLoop();
}
void EpubReaderBookmarkSelectionActivity::onEnter() {
ActivityWithSubactivity::onEnter();
renderingMutex = xSemaphoreCreateMutex();
// Trigger first update
updateRequired = true;
xTaskCreate(&EpubReaderBookmarkSelectionActivity::taskTrampoline, "BookmarkSelTask",
4096, // Stack size
this, // Parameters
1, // Priority
&displayTaskHandle // Task handle
);
}
void EpubReaderBookmarkSelectionActivity::onExit() {
ActivityWithSubactivity::onExit();
xSemaphoreTake(renderingMutex, portMAX_DELAY);
if (displayTaskHandle) {
vTaskDelete(displayTaskHandle);
displayTaskHandle = nullptr;
}
vSemaphoreDelete(renderingMutex);
renderingMutex = nullptr;
}
void EpubReaderBookmarkSelectionActivity::loop() {
if (subActivity) {
subActivity->loop();
return;
}
const int totalItems = getTotalItems();
if (totalItems == 0) {
// All bookmarks deleted, go back
if (mappedInput.wasReleased(MappedInputManager::Button::Back) ||
mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
onGoBack();
}
return;
}
// Delete confirmation mode: wait for confirm (delete) or back (cancel)
if (deleteConfirmMode) {
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (ignoreNextConfirmRelease) {
// Ignore the release from the initial long press
ignoreNextConfirmRelease = false;
} else {
// Confirm delete
BookmarkStore::removeBookmark(cachePath, bookmarks[pendingDeleteIndex].spineIndex,
bookmarks[pendingDeleteIndex].pageNumber);
bookmarks.erase(bookmarks.begin() + pendingDeleteIndex);
if (selectorIndex >= static_cast<int>(bookmarks.size())) {
selectorIndex = std::max(0, static_cast<int>(bookmarks.size()) - 1);
}
deleteConfirmMode = false;
updateRequired = true;
}
}
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
deleteConfirmMode = false;
ignoreNextConfirmRelease = false;
updateRequired = true;
}
return;
}
// Detect long press on Confirm to trigger delete
constexpr unsigned long DELETE_HOLD_MS = 700;
if (mappedInput.isPressed(MappedInputManager::Button::Confirm) && mappedInput.getHeldTime() >= DELETE_HOLD_MS) {
if (totalItems > 0 && selectorIndex >= 0 && selectorIndex < totalItems) {
deleteConfirmMode = true;
ignoreNextConfirmRelease = true;
pendingDeleteIndex = selectorIndex;
updateRequired = true;
}
return;
}
const int pageItems = getPageItems();
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (selectorIndex >= 0 && selectorIndex < totalItems) {
const auto& b = bookmarks[selectorIndex];
onSelectBookmark(b.spineIndex, b.pageNumber);
} else {
onGoBack();
}
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
onGoBack();
}
buttonNavigator.onNextRelease([this, totalItems] {
selectorIndex = ButtonNavigator::nextIndex(selectorIndex, totalItems);
updateRequired = true;
});
buttonNavigator.onPreviousRelease([this, totalItems] {
selectorIndex = ButtonNavigator::previousIndex(selectorIndex, totalItems);
updateRequired = true;
});
buttonNavigator.onNextContinuous([this, totalItems, pageItems] {
selectorIndex = ButtonNavigator::nextPageIndex(selectorIndex, totalItems, pageItems);
updateRequired = true;
});
buttonNavigator.onPreviousContinuous([this, totalItems, pageItems] {
selectorIndex = ButtonNavigator::previousPageIndex(selectorIndex, totalItems, pageItems);
updateRequired = true;
});
}
void EpubReaderBookmarkSelectionActivity::displayTaskLoop() {
while (true) {
if (updateRequired && !subActivity) {
updateRequired = false;
xSemaphoreTake(renderingMutex, portMAX_DELAY);
renderScreen();
xSemaphoreGive(renderingMutex);
}
vTaskDelay(10 / portTICK_PERIOD_MS);
}
}
void EpubReaderBookmarkSelectionActivity::renderScreen() {
renderer.clearScreen();
const auto pageWidth = renderer.getScreenWidth();
const auto orientation = renderer.getOrientation();
const bool isLandscapeCw = orientation == GfxRenderer::Orientation::LandscapeClockwise;
const bool isLandscapeCcw = orientation == GfxRenderer::Orientation::LandscapeCounterClockwise;
const bool isPortraitInverted = orientation == GfxRenderer::Orientation::PortraitInverted;
const int hintGutterWidth = (isLandscapeCw || isLandscapeCcw) ? 30 : 0;
const int contentX = isLandscapeCw ? hintGutterWidth : 0;
const int contentWidth = pageWidth - hintGutterWidth;
const int hintGutterHeight = isPortraitInverted ? 50 : 0;
const int contentY = hintGutterHeight;
const int pageItems = getPageItems();
const int totalItems = getTotalItems();
// Title
const int titleX =
contentX + (contentWidth - renderer.getTextWidth(UI_12_FONT_ID, "Go to Bookmark", EpdFontFamily::BOLD)) / 2;
renderer.drawText(UI_12_FONT_ID, titleX, 15 + contentY, "Go to Bookmark", true, EpdFontFamily::BOLD);
if (totalItems == 0) {
renderer.drawCenteredText(UI_10_FONT_ID, 100 + contentY, "No bookmarks", true);
} else {
const auto pageStartIndex = selectorIndex / pageItems * pageItems;
renderer.fillRect(contentX, 60 + contentY + (selectorIndex % pageItems) * 30 - 2, contentWidth - 1, 30);
const int maxLabelWidth = contentWidth - 40 - contentX - 20;
for (int i = 0; i < pageItems; i++) {
int itemIndex = pageStartIndex + i;
if (itemIndex >= totalItems) break;
const int displayY = 60 + contentY + i * 30;
const bool isSelected = (itemIndex == selectorIndex);
const std::string suffix = getPageSuffix(bookmarks[itemIndex]);
const int suffixWidth = renderer.getTextWidth(UI_10_FONT_ID, suffix.c_str());
// Truncate the prefix (chapter + snippet) to leave room for the page suffix
const std::string prefix = getBookmarkPrefix(bookmarks[itemIndex]);
const std::string truncatedPrefix =
renderer.truncatedText(UI_10_FONT_ID, prefix.c_str(), maxLabelWidth - suffixWidth);
const std::string label = truncatedPrefix + suffix;
renderer.drawText(UI_10_FONT_ID, contentX + 20, displayY, label.c_str(), !isSelected);
}
}
if (deleteConfirmMode && pendingDeleteIndex < static_cast<int>(bookmarks.size())) {
// Draw delete confirmation overlay
const std::string suffix = getPageSuffix(bookmarks[pendingDeleteIndex]);
std::string msg = "Delete bookmark" + suffix + "?";
constexpr int margin = 15;
constexpr int popupY = 200;
const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, msg.c_str(), EpdFontFamily::BOLD);
const int textHeight = renderer.getLineHeight(UI_12_FONT_ID);
const int w = textWidth + margin * 2;
const int h = textHeight + margin * 2;
const int x = (renderer.getScreenWidth() - w) / 2;
renderer.fillRect(x - 2, popupY - 2, w + 4, h + 4, true);
renderer.fillRect(x, popupY, w, h, false);
const int textX = x + (w - textWidth) / 2;
const int textY = popupY + margin - 2;
renderer.drawText(UI_12_FONT_ID, textX, textY, msg.c_str(), true, EpdFontFamily::BOLD);
const auto labels = mappedInput.mapLabels("Cancel", "Delete", "", "");
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
} else {
if (!bookmarks.empty()) {
const char* deleteHint = "Hold select to delete";
const int hintWidth = renderer.getTextWidth(SMALL_FONT_ID, deleteHint);
renderer.drawText(SMALL_FONT_ID, (renderer.getScreenWidth() - hintWidth) / 2,
renderer.getScreenHeight() - 70, deleteHint);
}
const auto labels = mappedInput.mapLabels("\xC2\xAB Back", "Select", "Up", "Down");
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
}
renderer.displayBuffer();
}

View File

@@ -0,0 +1,60 @@
#pragma once
#include <Epub.h>
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <freertos/task.h>
#include <memory>
#include <vector>
#include "../ActivityWithSubactivity.h"
#include "util/BookmarkStore.h"
#include "util/ButtonNavigator.h"
class EpubReaderBookmarkSelectionActivity final : public ActivityWithSubactivity {
std::shared_ptr<Epub> epub;
std::vector<Bookmark> bookmarks;
std::string cachePath;
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
ButtonNavigator buttonNavigator;
int selectorIndex = 0;
bool updateRequired = false;
bool deleteConfirmMode = false;
bool ignoreNextConfirmRelease = false;
int pendingDeleteIndex = 0;
const std::function<void()> onGoBack;
const std::function<void(int newSpineIndex, int newPage)> onSelectBookmark;
// Number of items that fit on a page, derived from logical screen height.
int getPageItems() const;
int getTotalItems() const;
// Build the prefix portion of a bookmark label (chapter + snippet, without page suffix)
std::string getBookmarkPrefix(const Bookmark& bookmark) const;
// Build the page suffix (e.g. " - Page 5")
static std::string getPageSuffix(const Bookmark& bookmark);
static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop();
void renderScreen();
public:
explicit EpubReaderBookmarkSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const std::shared_ptr<Epub>& epub,
std::vector<Bookmark> bookmarks,
const std::string& cachePath,
const std::function<void()>& onGoBack,
const std::function<void(int newSpineIndex, int newPage)>& onSelectBookmark)
: ActivityWithSubactivity("EpubReaderBookmarkSelection", renderer, mappedInput),
epub(epub),
bookmarks(std::move(bookmarks)),
cachePath(cachePath),
onGoBack(onGoBack),
onSelectBookmark(onSelectBookmark) {}
void onEnter() override;
void onExit() override;
void loop() override;
};

View File

@@ -6,11 +6,6 @@
#include "components/UITheme.h"
#include "fontIds.h"
namespace {
// Time threshold for treating a long press as a page-up/page-down
constexpr int SKIP_PAGE_MS = 700;
} // namespace
int EpubReaderChapterSelectionActivity::getTotalItems() const { return epub->getTocItemsCount(); }
int EpubReaderChapterSelectionActivity::getPageItems() const {
@@ -77,12 +72,6 @@ void EpubReaderChapterSelectionActivity::loop() {
return;
}
const bool prevReleased = mappedInput.wasReleased(MappedInputManager::Button::Up) ||
mappedInput.wasReleased(MappedInputManager::Button::Left);
const bool nextReleased = mappedInput.wasReleased(MappedInputManager::Button::Down) ||
mappedInput.wasReleased(MappedInputManager::Button::Right);
const bool skipPage = mappedInput.getHeldTime() > SKIP_PAGE_MS;
const int pageItems = getPageItems();
const int totalItems = getTotalItems();
@@ -95,21 +84,27 @@ void EpubReaderChapterSelectionActivity::loop() {
}
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
onGoBack();
} else if (prevReleased) {
if (skipPage) {
selectorIndex = ((selectorIndex / pageItems - 1) * pageItems + totalItems) % totalItems;
} else {
selectorIndex = (selectorIndex + totalItems - 1) % totalItems;
}
updateRequired = true;
} else if (nextReleased) {
if (skipPage) {
selectorIndex = ((selectorIndex / pageItems + 1) * pageItems) % totalItems;
} else {
selectorIndex = (selectorIndex + 1) % totalItems;
}
updateRequired = true;
}
buttonNavigator.onNextRelease([this, totalItems] {
selectorIndex = ButtonNavigator::nextIndex(selectorIndex, totalItems);
updateRequired = true;
});
buttonNavigator.onPreviousRelease([this, totalItems] {
selectorIndex = ButtonNavigator::previousIndex(selectorIndex, totalItems);
updateRequired = true;
});
buttonNavigator.onNextContinuous([this, totalItems, pageItems] {
selectorIndex = ButtonNavigator::nextPageIndex(selectorIndex, totalItems, pageItems);
updateRequired = true;
});
buttonNavigator.onPreviousContinuous([this, totalItems, pageItems] {
selectorIndex = ButtonNavigator::previousPageIndex(selectorIndex, totalItems, pageItems);
updateRequired = true;
});
}
void EpubReaderChapterSelectionActivity::displayTaskLoop() {

View File

@@ -7,12 +7,14 @@
#include <memory>
#include "../ActivityWithSubactivity.h"
#include "util/ButtonNavigator.h"
class EpubReaderChapterSelectionActivity final : public ActivityWithSubactivity {
std::shared_ptr<Epub> epub;
std::string epubPath;
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
ButtonNavigator buttonNavigator;
int currentSpineIndex = 0;
int currentPage = 0;
int totalPagesInSpine = 0;

View File

@@ -48,16 +48,19 @@ void EpubReaderMenuActivity::loop() {
return;
}
// Handle navigation
buttonNavigator.onNext([this] {
selectedIndex = ButtonNavigator::nextIndex(selectedIndex, static_cast<int>(menuItems.size()));
updateRequired = true;
});
buttonNavigator.onPrevious([this] {
selectedIndex = ButtonNavigator::previousIndex(selectedIndex, static_cast<int>(menuItems.size()));
updateRequired = true;
});
// Use local variables for items we need to check after potential deletion
if (mappedInput.wasReleased(MappedInputManager::Button::Up) ||
mappedInput.wasReleased(MappedInputManager::Button::Left)) {
selectedIndex = (selectedIndex + menuItems.size() - 1) % menuItems.size();
updateRequired = true;
} else if (mappedInput.wasReleased(MappedInputManager::Button::Down) ||
mappedInput.wasReleased(MappedInputManager::Button::Right)) {
selectedIndex = (selectedIndex + 1) % menuItems.size();
updateRequired = true;
} else if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
const auto selectedAction = menuItems[selectedIndex].action;
if (selectedAction == MenuAction::ROTATE_SCREEN) {
// Cycle orientation preview locally; actual rotation happens on menu exit.

View File

@@ -9,17 +9,34 @@
#include <vector>
#include "../ActivityWithSubactivity.h"
#include "util/ButtonNavigator.h"
class EpubReaderMenuActivity final : public ActivityWithSubactivity {
public:
// Menu actions available from the reader menu.
enum class MenuAction { SELECT_CHAPTER, GO_TO_PERCENT, ROTATE_SCREEN, GO_HOME, SYNC, DELETE_CACHE };
enum class MenuAction {
ADD_BOOKMARK,
REMOVE_BOOKMARK,
LOOKUP,
LOOKED_UP_WORDS,
ROTATE_SCREEN,
SELECT_CHAPTER,
GO_TO_BOOKMARK,
GO_TO_PERCENT,
GO_HOME,
SYNC,
DELETE_CACHE,
DELETE_DICT_CACHE
};
explicit EpubReaderMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& title,
const int currentPage, const int totalPages, const int bookProgressPercent,
const uint8_t currentOrientation, const std::function<void(uint8_t)>& onBack,
const uint8_t currentOrientation, const bool hasDictionary,
const bool isBookmarked,
const std::function<void(uint8_t)>& onBack,
const std::function<void(MenuAction)>& onAction)
: ActivityWithSubactivity("EpubReaderMenu", renderer, mappedInput),
menuItems(buildMenuItems(hasDictionary, isBookmarked)),
title(title),
pendingOrientation(currentOrientation),
currentPage(currentPage),
@@ -38,16 +55,13 @@ class EpubReaderMenuActivity final : public ActivityWithSubactivity {
std::string label;
};
// Fixed menu layout (order matters for up/down navigation).
const std::vector<MenuItem> menuItems = {
{MenuAction::SELECT_CHAPTER, "Go to Chapter"}, {MenuAction::ROTATE_SCREEN, "Reading Orientation"},
{MenuAction::GO_TO_PERCENT, "Go to %"}, {MenuAction::GO_HOME, "Go Home"},
{MenuAction::SYNC, "Sync Progress"}, {MenuAction::DELETE_CACHE, "Delete Book Cache"}};
std::vector<MenuItem> menuItems;
int selectedIndex = 0;
bool updateRequired = false;
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
ButtonNavigator buttonNavigator;
std::string title = "Reader Menu";
uint8_t pendingOrientation = 0;
const std::vector<const char*> orientationLabels = {"Portrait", "Landscape CW", "Inverted", "Landscape CCW"};
@@ -58,6 +72,30 @@ class EpubReaderMenuActivity final : public ActivityWithSubactivity {
const std::function<void(uint8_t)> onBack;
const std::function<void(MenuAction)> onAction;
static std::vector<MenuItem> buildMenuItems(bool hasDictionary, bool isBookmarked) {
std::vector<MenuItem> items;
if (isBookmarked) {
items.push_back({MenuAction::REMOVE_BOOKMARK, "Remove Bookmark"});
} else {
items.push_back({MenuAction::ADD_BOOKMARK, "Add Bookmark"});
}
if (hasDictionary) {
items.push_back({MenuAction::LOOKUP, "Lookup Word"});
items.push_back({MenuAction::LOOKED_UP_WORDS, "Lookup Word History"});
}
items.push_back({MenuAction::ROTATE_SCREEN, "Reading Orientation"});
items.push_back({MenuAction::SELECT_CHAPTER, "Table of Contents"});
items.push_back({MenuAction::GO_TO_BOOKMARK, "Go to Bookmark"});
items.push_back({MenuAction::GO_TO_PERCENT, "Go to %"});
items.push_back({MenuAction::GO_HOME, "Close Book"});
items.push_back({MenuAction::SYNC, "Sync Progress"});
items.push_back({MenuAction::DELETE_CACHE, "Delete Book Cache"});
if (hasDictionary) {
items.push_back({MenuAction::DELETE_DICT_CACHE, "Delete Dictionary Cache"});
}
return items;
}
static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop();
void renderScreen();

View File

@@ -79,25 +79,11 @@ void EpubReaderPercentSelectionActivity::loop() {
return;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Left)) {
adjustPercent(-kSmallStep);
return;
}
buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Left}, [this] { adjustPercent(-kSmallStep); });
buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Right}, [this] { adjustPercent(kSmallStep); });
if (mappedInput.wasReleased(MappedInputManager::Button::Right)) {
adjustPercent(kSmallStep);
return;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Up)) {
adjustPercent(kLargeStep);
return;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Down)) {
adjustPercent(-kLargeStep);
return;
}
buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Up}, [this] { adjustPercent(kLargeStep); });
buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Down}, [this] { adjustPercent(-kLargeStep); });
}
void EpubReaderPercentSelectionActivity::renderScreen() {

View File

@@ -7,6 +7,7 @@
#include "MappedInputManager.h"
#include "activities/ActivityWithSubactivity.h"
#include "util/ButtonNavigator.h"
class EpubReaderPercentSelectionActivity final : public ActivityWithSubactivity {
public:
@@ -31,6 +32,7 @@ class EpubReaderPercentSelectionActivity final : public ActivityWithSubactivity
// FreeRTOS task and mutex for rendering.
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
ButtonNavigator buttonNavigator;
// Callback invoked when the user confirms a percent.
const std::function<void(int)> onSelect;

View File

@@ -317,7 +317,6 @@ void KOReaderSyncActivity::render() {
localProgress.percentage * 100);
renderer.drawText(UI_10_FONT_ID, 20, 320, localPageStr);
// Options
const int optionY = 350;
const int optionHeight = 30;
@@ -333,13 +332,8 @@ void KOReaderSyncActivity::render() {
}
renderer.drawText(UI_10_FONT_ID, 20, optionY + optionHeight, "Upload local progress", selectedOption != 1);
// Cancel option
if (selectedOption == 2) {
renderer.fillRect(0, optionY + optionHeight * 2 - 2, pageWidth - 1, optionHeight);
}
renderer.drawText(UI_10_FONT_ID, 20, optionY + optionHeight * 2, "Cancel", selectedOption != 2);
const auto labels = mappedInput.mapLabels("", "Select", "", "");
// Bottom button hints: show Back and Select
const auto labels = mappedInput.mapLabels("Back", "Select", "", "");
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer();
return;
@@ -349,7 +343,7 @@ void KOReaderSyncActivity::render() {
renderer.drawCenteredText(UI_10_FONT_ID, 280, "No remote progress found", true, EpdFontFamily::BOLD);
renderer.drawCenteredText(UI_10_FONT_ID, 320, "Upload current position?");
const auto labels = mappedInput.mapLabels("Cancel", "Upload", "", "");
const auto labels = mappedInput.mapLabels("Back", "Upload", "", "");
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer();
return;
@@ -392,11 +386,11 @@ void KOReaderSyncActivity::loop() {
// Navigate options
if (mappedInput.wasPressed(MappedInputManager::Button::Up) ||
mappedInput.wasPressed(MappedInputManager::Button::Left)) {
selectedOption = (selectedOption + 2) % 3; // Wrap around
selectedOption = (selectedOption + 1) % 2; // Wrap around among 2 options
updateRequired = true;
} else if (mappedInput.wasPressed(MappedInputManager::Button::Down) ||
mappedInput.wasPressed(MappedInputManager::Button::Right)) {
selectedOption = (selectedOption + 1) % 3;
selectedOption = (selectedOption + 1) % 2; // Wrap around among 2 options
updateRequired = true;
}
@@ -407,9 +401,6 @@ void KOReaderSyncActivity::loop() {
} else if (selectedOption == 1) {
// Upload local progress
performUpload();
} else {
// Cancel
onCancel();
}
}

View File

@@ -18,7 +18,7 @@
* 1. Connect to WiFi (if not connected)
* 2. Calculate document hash
* 3. Fetch remote progress
* 4. Show comparison and options (Apply/Upload/Cancel)
* 4. Show comparison and options (Apply/Upload)
* 5. Apply or upload progress
*/
class KOReaderSyncActivity final : public ActivityWithSubactivity {
@@ -82,7 +82,7 @@ class KOReaderSyncActivity final : public ActivityWithSubactivity {
// Local progress as KOReader format (for display)
KOReaderPosition localProgress;
// Selection in result screen (0=Apply, 1=Upload, 2=Cancel)
// Selection in result screen (0=Apply, 1=Upload)
int selectedOption = 0;
OnCancelCallback onCancel;

View File

@@ -0,0 +1,196 @@
#include "LookedUpWordsActivity.h"
#include <GfxRenderer.h>
#include <algorithm>
#include "MappedInputManager.h"
#include "components/UITheme.h"
#include "fontIds.h"
#include "util/LookupHistory.h"
void LookedUpWordsActivity::taskTrampoline(void* param) {
auto* self = static_cast<LookedUpWordsActivity*>(param);
self->displayTaskLoop();
}
void LookedUpWordsActivity::displayTaskLoop() {
while (true) {
if (updateRequired && !subActivity) {
updateRequired = false;
xSemaphoreTake(renderingMutex, portMAX_DELAY);
renderScreen();
xSemaphoreGive(renderingMutex);
}
vTaskDelay(10 / portTICK_PERIOD_MS);
}
}
void LookedUpWordsActivity::onEnter() {
ActivityWithSubactivity::onEnter();
renderingMutex = xSemaphoreCreateMutex();
words = LookupHistory::load(cachePath);
updateRequired = true;
xTaskCreate(&LookedUpWordsActivity::taskTrampoline, "LookedUpTask", 4096, this, 1, &displayTaskHandle);
}
void LookedUpWordsActivity::onExit() {
ActivityWithSubactivity::onExit();
xSemaphoreTake(renderingMutex, portMAX_DELAY);
if (displayTaskHandle) {
vTaskDelete(displayTaskHandle);
displayTaskHandle = nullptr;
}
vSemaphoreDelete(renderingMutex);
renderingMutex = nullptr;
}
void LookedUpWordsActivity::loop() {
if (subActivity) {
subActivity->loop();
return;
}
if (words.empty()) {
if (mappedInput.wasReleased(MappedInputManager::Button::Back) ||
mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
onBack();
}
return;
}
// Delete confirmation mode: wait for confirm (delete) or back (cancel)
if (deleteConfirmMode) {
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (ignoreNextConfirmRelease) {
// Ignore the release from the initial long press
ignoreNextConfirmRelease = false;
} else {
// Confirm delete
LookupHistory::removeWord(cachePath, words[pendingDeleteIndex]);
words.erase(words.begin() + pendingDeleteIndex);
if (selectedIndex >= static_cast<int>(words.size())) {
selectedIndex = std::max(0, static_cast<int>(words.size()) - 1);
}
deleteConfirmMode = false;
updateRequired = true;
}
}
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
deleteConfirmMode = false;
ignoreNextConfirmRelease = false;
updateRequired = true;
}
return;
}
// Detect long press on Confirm to trigger delete
constexpr unsigned long DELETE_HOLD_MS = 700;
if (mappedInput.isPressed(MappedInputManager::Button::Confirm) && mappedInput.getHeldTime() >= DELETE_HOLD_MS) {
deleteConfirmMode = true;
ignoreNextConfirmRelease = true;
pendingDeleteIndex = selectedIndex;
updateRequired = true;
return;
}
buttonNavigator.onNext([this] {
selectedIndex = ButtonNavigator::nextIndex(selectedIndex, static_cast<int>(words.size()));
updateRequired = true;
});
buttonNavigator.onPrevious([this] {
selectedIndex = ButtonNavigator::previousIndex(selectedIndex, static_cast<int>(words.size()));
updateRequired = true;
});
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
onSelectWord(words[selectedIndex]);
return;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
onBack();
return;
}
}
void LookedUpWordsActivity::renderScreen() {
renderer.clearScreen();
constexpr int sidePadding = 20;
constexpr int titleY = 15;
constexpr int startY = 60;
constexpr int lineHeight = 30;
// Title
const int titleX =
(renderer.getScreenWidth() - renderer.getTextWidth(UI_12_FONT_ID, "Lookup History", EpdFontFamily::BOLD)) / 2;
renderer.drawText(UI_12_FONT_ID, titleX, titleY, "Lookup History", true, EpdFontFamily::BOLD);
if (words.empty()) {
renderer.drawCenteredText(UI_10_FONT_ID, 300, "No words looked up yet");
} else {
const int screenHeight = renderer.getScreenHeight();
const int pageItems = std::max(1, (screenHeight - startY - 40) / lineHeight);
const int pageStart = selectedIndex / pageItems * pageItems;
for (int i = 0; i < pageItems; i++) {
int idx = pageStart + i;
if (idx >= static_cast<int>(words.size())) break;
const int displayY = startY + i * lineHeight;
const bool isSelected = (idx == selectedIndex);
if (isSelected) {
renderer.fillRect(0, displayY - 2, renderer.getScreenWidth() - 1, lineHeight);
}
renderer.drawText(UI_10_FONT_ID, sidePadding, displayY, words[idx].c_str(), !isSelected);
}
}
if (deleteConfirmMode && pendingDeleteIndex < static_cast<int>(words.size())) {
// Draw delete confirmation overlay
const std::string& word = words[pendingDeleteIndex];
std::string displayWord = word;
if (displayWord.size() > 20) {
displayWord.erase(17);
displayWord += "...";
}
std::string msg = "Delete '" + displayWord + "'?";
constexpr int margin = 15;
constexpr int popupY = 200;
const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, msg.c_str(), EpdFontFamily::BOLD);
const int textHeight = renderer.getLineHeight(UI_12_FONT_ID);
const int w = textWidth + margin * 2;
const int h = textHeight + margin * 2;
const int x = (renderer.getScreenWidth() - w) / 2;
renderer.fillRect(x - 2, popupY - 2, w + 4, h + 4, true);
renderer.fillRect(x, popupY, w, h, false);
const int textX = x + (w - textWidth) / 2;
const int textY = popupY + margin - 2;
renderer.drawText(UI_12_FONT_ID, textX, textY, msg.c_str(), true, EpdFontFamily::BOLD);
// Button hints for delete mode
const auto labels = mappedInput.mapLabels("Cancel", "Delete", "", "");
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
} else {
// "Hold select to delete" hint above button hints
if (!words.empty()) {
const char* deleteHint = "Hold select to delete";
const int hintWidth = renderer.getTextWidth(SMALL_FONT_ID, deleteHint);
renderer.drawText(SMALL_FONT_ID, (renderer.getScreenWidth() - hintWidth) / 2, renderer.getScreenHeight() - 70,
deleteHint);
}
// Normal button hints
const auto labels = mappedInput.mapLabels("\xC2\xAB Back", "Select", "^", "v");
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
}
renderer.displayBuffer();
}

View File

@@ -0,0 +1,48 @@
#pragma once
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <freertos/task.h>
#include <functional>
#include <string>
#include <vector>
#include "../ActivityWithSubactivity.h"
#include "util/ButtonNavigator.h"
class LookedUpWordsActivity final : public ActivityWithSubactivity {
public:
explicit LookedUpWordsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& cachePath,
const std::function<void()>& onBack,
const std::function<void(const std::string&)>& onSelectWord)
: ActivityWithSubactivity("LookedUpWords", renderer, mappedInput),
cachePath(cachePath),
onBack(onBack),
onSelectWord(onSelectWord) {}
void onEnter() override;
void onExit() override;
void loop() override;
private:
std::string cachePath;
const std::function<void()> onBack;
const std::function<void(const std::string&)> onSelectWord;
std::vector<std::string> words;
int selectedIndex = 0;
bool updateRequired = false;
ButtonNavigator buttonNavigator;
// Delete confirmation state
bool deleteConfirmMode = false;
bool ignoreNextConfirmRelease = false;
int pendingDeleteIndex = 0;
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
void renderScreen();
static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop();
};

View File

@@ -1,5 +1,7 @@
#include "ReaderActivity.h"
#include <HalStorage.h>
#include "Epub.h"
#include "EpubReaderActivity.h"
#include "Txt.h"
@@ -27,7 +29,7 @@ bool ReaderActivity::isTxtFile(const std::string& path) {
}
std::unique_ptr<Epub> ReaderActivity::loadEpub(const std::string& path) {
if (!SdMan.exists(path.c_str())) {
if (!Storage.exists(path.c_str())) {
Serial.printf("[%lu] [ ] File does not exist: %s\n", millis(), path.c_str());
return nullptr;
}
@@ -42,7 +44,7 @@ std::unique_ptr<Epub> ReaderActivity::loadEpub(const std::string& path) {
}
std::unique_ptr<Xtc> ReaderActivity::loadXtc(const std::string& path) {
if (!SdMan.exists(path.c_str())) {
if (!Storage.exists(path.c_str())) {
Serial.printf("[%lu] [ ] File does not exist: %s\n", millis(), path.c_str());
return nullptr;
}
@@ -57,7 +59,7 @@ std::unique_ptr<Xtc> ReaderActivity::loadXtc(const std::string& path) {
}
std::unique_ptr<Txt> ReaderActivity::loadTxt(const std::string& path) {
if (!SdMan.exists(path.c_str())) {
if (!Storage.exists(path.c_str())) {
Serial.printf("[%lu] [ ] File does not exist: %s\n", millis(), path.c_str());
return nullptr;
}

View File

@@ -1,7 +1,7 @@
#include "TxtReaderActivity.h"
#include <GfxRenderer.h>
#include <SDCardManager.h>
#include <HalStorage.h>
#include <Serialization.h>
#include <Utf8.h>
@@ -57,6 +57,15 @@ void TxtReaderActivity::onEnter() {
txt->setupCacheDir();
// Prerender cover on first open so the Sleep screen is instant.
// generateCoverBmp() is a no-op if the file already exists, so this only does work once.
// TXT has no thumbnail support, so only the sleep screen cover is generated.
if (!Storage.exists(txt->getCoverBmpPath().c_str())) {
Rect popupRect = GUI.drawPopup(renderer, "Preparing book...");
txt->generateCoverBmp();
GUI.fillPopupProgress(renderer, popupRect, 100);
}
// Save current txt as last opened file and add to recent books
auto filePath = txt->getPath();
auto fileName = filePath.substr(filePath.rfind('/') + 1);
@@ -102,15 +111,15 @@ void TxtReaderActivity::loop() {
return;
}
// Long press BACK (1s+) goes directly to home
// Long press BACK (1s+) goes to file selection
if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= goHomeMs) {
onGoHome();
onGoBack();
return;
}
// Short press BACK goes to file selection
// Short press BACK goes directly to home
if (mappedInput.wasReleased(MappedInputManager::Button::Back) && mappedInput.getHeldTime() < goHomeMs) {
onGoBack();
onGoHome();
return;
}
@@ -565,7 +574,7 @@ void TxtReaderActivity::renderStatusBar(const int orientedMarginRight, const int
void TxtReaderActivity::saveProgress() const {
FsFile f;
if (SdMan.openFileForWrite("TRS", txt->getCachePath() + "/progress.bin", f)) {
if (Storage.openFileForWrite("TRS", txt->getCachePath() + "/progress.bin", f)) {
uint8_t data[4];
data[0] = currentPage & 0xFF;
data[1] = (currentPage >> 8) & 0xFF;
@@ -578,7 +587,7 @@ void TxtReaderActivity::saveProgress() const {
void TxtReaderActivity::loadProgress() {
FsFile f;
if (SdMan.openFileForRead("TRS", txt->getCachePath() + "/progress.bin", f)) {
if (Storage.openFileForRead("TRS", txt->getCachePath() + "/progress.bin", f)) {
uint8_t data[4];
if (f.read(data, 4) == 4) {
currentPage = data[0] + (data[1] << 8);
@@ -609,7 +618,7 @@ bool TxtReaderActivity::loadPageIndexCache() {
std::string cachePath = txt->getCachePath() + "/index.bin";
FsFile f;
if (!SdMan.openFileForRead("TRS", cachePath, f)) {
if (!Storage.openFileForRead("TRS", cachePath, f)) {
Serial.printf("[%lu] [TRS] No page index cache found\n", millis());
return false;
}
@@ -701,7 +710,7 @@ bool TxtReaderActivity::loadPageIndexCache() {
void TxtReaderActivity::savePageIndexCache() const {
std::string cachePath = txt->getCachePath() + "/index.bin";
FsFile f;
if (!SdMan.openFileForWrite("TRS", cachePath, f)) {
if (!Storage.openFileForWrite("TRS", cachePath, f)) {
Serial.printf("[%lu] [TRS] Failed to save page index cache\n", millis());
return;
}

View File

@@ -9,7 +9,7 @@
#include <FsHelpers.h>
#include <GfxRenderer.h>
#include <SDCardManager.h>
#include <HalStorage.h>
#include "CrossPointSettings.h"
#include "CrossPointState.h"
@@ -43,6 +43,37 @@ void XtcReaderActivity::onEnter() {
// Load saved progress
loadProgress();
// Prerender covers and thumbnails on first open so Home and Sleep screens are instant.
// Each generate* call is a no-op if the file already exists, so this only does work once.
{
int totalSteps = 0;
if (!Storage.exists(xtc->getCoverBmpPath().c_str())) totalSteps++;
for (int i = 0; i < PRERENDER_THUMB_HEIGHTS_COUNT; i++) {
if (!Storage.exists(xtc->getThumbBmpPath(PRERENDER_THUMB_HEIGHTS[i]).c_str())) totalSteps++;
}
if (totalSteps > 0) {
Rect popupRect = GUI.drawPopup(renderer, "Preparing book...");
int completedSteps = 0;
auto updateProgress = [&]() {
completedSteps++;
GUI.fillPopupProgress(renderer, popupRect, completedSteps * 100 / totalSteps);
};
if (!Storage.exists(xtc->getCoverBmpPath().c_str())) {
xtc->generateCoverBmp();
updateProgress();
}
for (int i = 0; i < PRERENDER_THUMB_HEIGHTS_COUNT; i++) {
if (!Storage.exists(xtc->getThumbBmpPath(PRERENDER_THUMB_HEIGHTS[i]).c_str())) {
xtc->generateThumbBmp(PRERENDER_THUMB_HEIGHTS[i]);
updateProgress();
}
}
}
}
// Save current XTC as last opened book and add to recent books
APP_STATE.openEpubPath = xtc->getPath();
APP_STATE.saveToFile();
@@ -102,15 +133,15 @@ void XtcReaderActivity::loop() {
}
}
// Long press BACK (1s+) goes directly to home
// Long press BACK (1s+) goes to file selection
if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= goHomeMs) {
onGoHome();
onGoBack();
return;
}
// Short press BACK goes to file selection
// Short press BACK goes directly to home
if (mappedInput.wasReleased(MappedInputManager::Button::Back) && mappedInput.getHeldTime() < goHomeMs) {
onGoBack();
onGoHome();
return;
}
@@ -372,7 +403,7 @@ void XtcReaderActivity::renderPage() {
void XtcReaderActivity::saveProgress() const {
FsFile f;
if (SdMan.openFileForWrite("XTR", xtc->getCachePath() + "/progress.bin", f)) {
if (Storage.openFileForWrite("XTR", xtc->getCachePath() + "/progress.bin", f)) {
uint8_t data[4];
data[0] = currentPage & 0xFF;
data[1] = (currentPage >> 8) & 0xFF;
@@ -385,7 +416,7 @@ void XtcReaderActivity::saveProgress() const {
void XtcReaderActivity::loadProgress() {
FsFile f;
if (SdMan.openFileForRead("XTR", xtc->getCachePath() + "/progress.bin", f)) {
if (Storage.openFileForRead("XTR", xtc->getCachePath() + "/progress.bin", f)) {
uint8_t data[4];
if (f.read(data, 4) == 4) {
currentPage = data[0] | (data[1] << 8) | (data[2] << 16) | (data[3] << 24);

View File

@@ -8,10 +8,6 @@
#include "components/UITheme.h"
#include "fontIds.h"
namespace {
constexpr int SKIP_PAGE_MS = 700;
} // namespace
int XtcReaderChapterSelectionActivity::getPageItems() const {
constexpr int lineHeight = 30;
@@ -78,13 +74,8 @@ void XtcReaderChapterSelectionActivity::onExit() {
}
void XtcReaderChapterSelectionActivity::loop() {
const bool prevReleased = mappedInput.wasReleased(MappedInputManager::Button::Up) ||
mappedInput.wasReleased(MappedInputManager::Button::Left);
const bool nextReleased = mappedInput.wasReleased(MappedInputManager::Button::Down) ||
mappedInput.wasReleased(MappedInputManager::Button::Right);
const bool skipPage = mappedInput.getHeldTime() > SKIP_PAGE_MS;
const int pageItems = getPageItems();
const int totalItems = static_cast<int>(xtc->getChapters().size());
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
const auto& chapters = xtc->getChapters();
@@ -93,29 +84,27 @@ void XtcReaderChapterSelectionActivity::loop() {
}
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
onGoBack();
} else if (prevReleased) {
const int total = static_cast<int>(xtc->getChapters().size());
if (total == 0) {
return;
}
if (skipPage) {
selectorIndex = ((selectorIndex / pageItems - 1) * pageItems + total) % total;
} else {
selectorIndex = (selectorIndex + total - 1) % total;
}
updateRequired = true;
} else if (nextReleased) {
const int total = static_cast<int>(xtc->getChapters().size());
if (total == 0) {
return;
}
if (skipPage) {
selectorIndex = ((selectorIndex / pageItems + 1) * pageItems) % total;
} else {
selectorIndex = (selectorIndex + 1) % total;
}
updateRequired = true;
}
buttonNavigator.onNextRelease([this, totalItems] {
selectorIndex = ButtonNavigator::nextIndex(selectorIndex, totalItems);
updateRequired = true;
});
buttonNavigator.onPreviousRelease([this, totalItems] {
selectorIndex = ButtonNavigator::previousIndex(selectorIndex, totalItems);
updateRequired = true;
});
buttonNavigator.onNextContinuous([this, totalItems, pageItems] {
selectorIndex = ButtonNavigator::nextPageIndex(selectorIndex, totalItems, pageItems);
updateRequired = true;
});
buttonNavigator.onPreviousContinuous([this, totalItems, pageItems] {
selectorIndex = ButtonNavigator::previousPageIndex(selectorIndex, totalItems, pageItems);
updateRequired = true;
});
}
void XtcReaderChapterSelectionActivity::displayTaskLoop() {

View File

@@ -7,11 +7,13 @@
#include <memory>
#include "../Activity.h"
#include "util/ButtonNavigator.h"
class XtcReaderChapterSelectionActivity final : public Activity {
std::shared_ptr<Xtc> xtc;
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
ButtonNavigator buttonNavigator;
uint32_t currentPage = 0;
int selectorIndex = 0;
bool updateRequired = false;

View File

@@ -63,15 +63,16 @@ void CalibreSettingsActivity::loop() {
return;
}
if (mappedInput.wasPressed(MappedInputManager::Button::Up) ||
mappedInput.wasPressed(MappedInputManager::Button::Left)) {
selectedIndex = (selectedIndex + MENU_ITEMS - 1) % MENU_ITEMS;
updateRequired = true;
} else if (mappedInput.wasPressed(MappedInputManager::Button::Down) ||
mappedInput.wasPressed(MappedInputManager::Button::Right)) {
// Handle navigation
buttonNavigator.onNext([this] {
selectedIndex = (selectedIndex + 1) % MENU_ITEMS;
updateRequired = true;
}
});
buttonNavigator.onPrevious([this] {
selectedIndex = (selectedIndex + MENU_ITEMS - 1) % MENU_ITEMS;
updateRequired = true;
});
}
void CalibreSettingsActivity::handleSelection() {

View File

@@ -6,6 +6,7 @@
#include <functional>
#include "activities/ActivityWithSubactivity.h"
#include "util/ButtonNavigator.h"
/**
* Submenu for OPDS Browser settings.
@@ -24,6 +25,7 @@ class CalibreSettingsActivity final : public ActivityWithSubactivity {
private:
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
ButtonNavigator buttonNavigator;
bool updateRequired = false;
int selectedIndex = 0;

View File

@@ -1,8 +1,8 @@
#include "ClearCacheActivity.h"
#include <GfxRenderer.h>
#include <HalStorage.h>
#include <HardwareSerial.h>
#include <SDCardManager.h>
#include "MappedInputManager.h"
#include "components/UITheme.h"
@@ -107,7 +107,7 @@ void ClearCacheActivity::clearCache() {
Serial.printf("[%lu] [CLEAR_CACHE] Clearing cache...\n", millis());
// Open .crosspoint directory
auto root = SdMan.open("/.crosspoint");
auto root = Storage.open("/.crosspoint");
if (!root || !root.isDirectory()) {
Serial.printf("[%lu] [CLEAR_CACHE] Failed to open cache directory\n", millis());
if (root) root.close();
@@ -132,7 +132,7 @@ void ClearCacheActivity::clearCache() {
file.close(); // Close before attempting to delete
if (SdMan.removeDir(fullPath.c_str())) {
if (Storage.removeDir(fullPath.c_str())) {
clearedCount++;
} else {
Serial.printf("[%lu] [CLEAR_CACHE] Failed to remove: %s\n", millis(), fullPath.c_str());

View File

@@ -64,15 +64,16 @@ void KOReaderSettingsActivity::loop() {
return;
}
if (mappedInput.wasPressed(MappedInputManager::Button::Up) ||
mappedInput.wasPressed(MappedInputManager::Button::Left)) {
selectedIndex = (selectedIndex + MENU_ITEMS - 1) % MENU_ITEMS;
updateRequired = true;
} else if (mappedInput.wasPressed(MappedInputManager::Button::Down) ||
mappedInput.wasPressed(MappedInputManager::Button::Right)) {
// Handle navigation
buttonNavigator.onNext([this] {
selectedIndex = (selectedIndex + 1) % MENU_ITEMS;
updateRequired = true;
}
});
buttonNavigator.onPrevious([this] {
selectedIndex = (selectedIndex + MENU_ITEMS - 1) % MENU_ITEMS;
updateRequired = true;
});
}
void KOReaderSettingsActivity::handleSelection() {

View File

@@ -6,6 +6,7 @@
#include <functional>
#include "activities/ActivityWithSubactivity.h"
#include "util/ButtonNavigator.h"
/**
* Submenu for KOReader Sync settings.
@@ -24,6 +25,7 @@ class KOReaderSettingsActivity final : public ActivityWithSubactivity {
private:
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
ButtonNavigator buttonNavigator;
bool updateRequired = false;
int selectedIndex = 0;

View File

@@ -10,63 +10,13 @@
#include "KOReaderSettingsActivity.h"
#include "MappedInputManager.h"
#include "OtaUpdateActivity.h"
#include "SettingsList.h"
#include "activities/network/WifiSelectionActivity.h"
#include "components/UITheme.h"
#include "fontIds.h"
const char* SettingsActivity::categoryNames[categoryCount] = {"Display", "Reader", "Controls", "System"};
namespace {
constexpr int changeTabsMs = 700;
constexpr int displaySettingsCount = 8;
const SettingInfo displaySettings[displaySettingsCount] = {
// Should match with SLEEP_SCREEN_MODE
SettingInfo::Enum("Sleep Screen", &CrossPointSettings::sleepScreen,
{"Dark", "Light", "Custom", "Cover", "None", "Cover + Custom"}),
SettingInfo::Enum("Sleep Screen Cover Mode", &CrossPointSettings::sleepScreenCoverMode, {"Fit", "Crop"}),
SettingInfo::Enum("Sleep Screen Cover Filter", &CrossPointSettings::sleepScreenCoverFilter,
{"None", "Contrast", "Inverted"}),
SettingInfo::Enum(
"Status Bar", &CrossPointSettings::statusBar,
{"None", "No Progress", "Full w/ Percentage", "Full w/ Book Bar", "Book Bar Only", "Full w/ Chapter Bar"}),
SettingInfo::Enum("Hide Battery %", &CrossPointSettings::hideBatteryPercentage, {"Never", "In Reader", "Always"}),
SettingInfo::Enum("Refresh Frequency", &CrossPointSettings::refreshFrequency,
{"1 page", "5 pages", "10 pages", "15 pages", "30 pages"}),
SettingInfo::Enum("UI Theme", &CrossPointSettings::uiTheme, {"Classic", "Lyra"}),
SettingInfo::Toggle("Sunlight Fading Fix", &CrossPointSettings::fadingFix),
};
constexpr int readerSettingsCount = 10;
const SettingInfo readerSettings[readerSettingsCount] = {
SettingInfo::Enum("Font Family", &CrossPointSettings::fontFamily, {"Bookerly", "Noto Sans", "Open Dyslexic"}),
SettingInfo::Enum("Font Size", &CrossPointSettings::fontSize, {"Small", "Medium", "Large", "X Large"}),
SettingInfo::Enum("Line Spacing", &CrossPointSettings::lineSpacing, {"Tight", "Normal", "Wide"}),
SettingInfo::Value("Screen Margin", &CrossPointSettings::screenMargin, {5, 40, 5}),
SettingInfo::Enum("Paragraph Alignment", &CrossPointSettings::paragraphAlignment,
{"Justify", "Left", "Center", "Right", "Book's Style"}),
SettingInfo::Toggle("Book's Embedded Style", &CrossPointSettings::embeddedStyle),
SettingInfo::Toggle("Hyphenation", &CrossPointSettings::hyphenationEnabled),
SettingInfo::Enum("Reading Orientation", &CrossPointSettings::orientation,
{"Portrait", "Landscape CW", "Inverted", "Landscape CCW"}),
SettingInfo::Toggle("Extra Paragraph Spacing", &CrossPointSettings::extraParagraphSpacing),
SettingInfo::Toggle("Text Anti-Aliasing", &CrossPointSettings::textAntiAliasing)};
constexpr int controlsSettingsCount = 4;
const SettingInfo controlsSettings[controlsSettingsCount] = {
// Launches the remap wizard for front buttons.
SettingInfo::Action("Remap Front Buttons"),
SettingInfo::Enum("Side Button Layout (reader)", &CrossPointSettings::sideButtonLayout,
{"Prev, Next", "Next, Prev"}),
SettingInfo::Toggle("Long-press Chapter Skip", &CrossPointSettings::longPressChapterSkip),
SettingInfo::Enum("Short Power Button Click", &CrossPointSettings::shortPwrBtn, {"Ignore", "Sleep", "Page Turn"})};
constexpr int systemSettingsCount = 5;
const SettingInfo systemSettings[systemSettingsCount] = {
SettingInfo::Enum("Time to Sleep", &CrossPointSettings::sleepTimeout,
{"1 min", "5 min", "10 min", "15 min", "30 min"}),
SettingInfo::Action("KOReader Sync"), SettingInfo::Action("OPDS Browser"), SettingInfo::Action("Clear Cache"),
SettingInfo::Action("Check for updates")};
} // namespace
void SettingsActivity::taskTrampoline(void* param) {
auto* self = static_cast<SettingsActivity*>(param);
self->displayTaskLoop();
@@ -76,13 +26,42 @@ void SettingsActivity::onEnter() {
Activity::onEnter();
renderingMutex = xSemaphoreCreateMutex();
// Build per-category vectors from the shared settings list
displaySettings.clear();
readerSettings.clear();
controlsSettings.clear();
systemSettings.clear();
for (auto& setting : getSettingsList()) {
if (!setting.category) continue;
if (strcmp(setting.category, "Display") == 0) {
displaySettings.push_back(std::move(setting));
} else if (strcmp(setting.category, "Reader") == 0) {
readerSettings.push_back(std::move(setting));
} else if (strcmp(setting.category, "Controls") == 0) {
controlsSettings.push_back(std::move(setting));
} else if (strcmp(setting.category, "System") == 0) {
systemSettings.push_back(std::move(setting));
}
// Web-only categories (KOReader Sync, OPDS Browser) are skipped for device UI
}
// Append device-only ACTION items
controlsSettings.insert(controlsSettings.begin(),
SettingInfo::Action("Remap Front Buttons", SettingAction::RemapFrontButtons));
systemSettings.push_back(SettingInfo::Action("Network", SettingAction::Network));
systemSettings.push_back(SettingInfo::Action("KOReader Sync", SettingAction::KOReaderSync));
systemSettings.push_back(SettingInfo::Action("OPDS Browser", SettingAction::OPDSBrowser));
systemSettings.push_back(SettingInfo::Action("Clear Cache", SettingAction::ClearCache));
systemSettings.push_back(SettingInfo::Action("Check for updates", SettingAction::CheckForUpdates));
// Reset selection to first category
selectedCategoryIndex = 0;
selectedSettingIndex = 0;
// Initialize with first category (Display)
settingsList = displaySettings;
settingsCount = displaySettingsCount;
currentSettings = &displaySettings;
settingsCount = static_cast<int>(displaySettings.size());
// Trigger first update
updateRequired = true;
@@ -136,49 +115,46 @@ void SettingsActivity::loop() {
return;
}
const bool upReleased = mappedInput.wasReleased(MappedInputManager::Button::Up);
const bool downReleased = mappedInput.wasReleased(MappedInputManager::Button::Down);
const bool leftReleased = mappedInput.wasReleased(MappedInputManager::Button::Left);
const bool rightReleased = mappedInput.wasReleased(MappedInputManager::Button::Right);
const bool changeTab = mappedInput.getHeldTime() > changeTabsMs;
// Handle navigation
if (upReleased && changeTab) {
buttonNavigator.onNextRelease([this] {
selectedSettingIndex = ButtonNavigator::nextIndex(selectedSettingIndex, settingsCount + 1);
updateRequired = true;
});
buttonNavigator.onPreviousRelease([this] {
selectedSettingIndex = ButtonNavigator::previousIndex(selectedSettingIndex, settingsCount + 1);
updateRequired = true;
});
buttonNavigator.onNextContinuous([this, &hasChangedCategory] {
hasChangedCategory = true;
selectedCategoryIndex = (selectedCategoryIndex > 0) ? (selectedCategoryIndex - 1) : (categoryCount - 1);
selectedCategoryIndex = ButtonNavigator::nextIndex(selectedCategoryIndex, categoryCount);
updateRequired = true;
} else if (downReleased && changeTab) {
});
buttonNavigator.onPreviousContinuous([this, &hasChangedCategory] {
hasChangedCategory = true;
selectedCategoryIndex = (selectedCategoryIndex < categoryCount - 1) ? (selectedCategoryIndex + 1) : 0;
selectedCategoryIndex = ButtonNavigator::previousIndex(selectedCategoryIndex, categoryCount);
updateRequired = true;
} else if (upReleased || leftReleased) {
selectedSettingIndex = (selectedSettingIndex > 0) ? (selectedSettingIndex - 1) : (settingsCount);
updateRequired = true;
} else if (rightReleased || downReleased) {
selectedSettingIndex = (selectedSettingIndex < settingsCount) ? (selectedSettingIndex + 1) : 0;
updateRequired = true;
}
});
if (hasChangedCategory) {
selectedSettingIndex = (selectedSettingIndex == 0) ? 0 : 1;
switch (selectedCategoryIndex) {
case 0: // Display
settingsList = displaySettings;
settingsCount = displaySettingsCount;
case 0:
currentSettings = &displaySettings;
break;
case 1: // Reader
settingsList = readerSettings;
settingsCount = readerSettingsCount;
case 1:
currentSettings = &readerSettings;
break;
case 2: // Controls
settingsList = controlsSettings;
settingsCount = controlsSettingsCount;
case 2:
currentSettings = &controlsSettings;
break;
case 3: // System
settingsList = systemSettings;
settingsCount = systemSettingsCount;
case 3:
currentSettings = &systemSettings;
break;
}
settingsCount = static_cast<int>(currentSettings->size());
}
}
@@ -188,7 +164,7 @@ void SettingsActivity::toggleCurrentSetting() {
return;
}
const auto& setting = settingsList[selectedSetting];
const auto& setting = (*currentSettings)[selectedSetting];
if (setting.type == SettingType::TOGGLE && setting.valuePtr != nullptr) {
// Toggle the boolean value using the member pointer
@@ -205,46 +181,45 @@ void SettingsActivity::toggleCurrentSetting() {
SETTINGS.*(setting.valuePtr) = currentValue + setting.valueRange.step;
}
} else if (setting.type == SettingType::ACTION) {
if (strcmp(setting.name, "Remap Front Buttons") == 0) {
auto enterSubActivity = [this](Activity* activity) {
xSemaphoreTake(renderingMutex, portMAX_DELAY);
exitActivity();
enterNewActivity(new ButtonRemapActivity(renderer, mappedInput, [this] {
exitActivity();
updateRequired = true;
}));
enterNewActivity(activity);
xSemaphoreGive(renderingMutex);
} else if (strcmp(setting.name, "KOReader Sync") == 0) {
xSemaphoreTake(renderingMutex, portMAX_DELAY);
};
auto onComplete = [this] {
exitActivity();
enterNewActivity(new KOReaderSettingsActivity(renderer, mappedInput, [this] {
exitActivity();
updateRequired = true;
}));
xSemaphoreGive(renderingMutex);
} else if (strcmp(setting.name, "OPDS Browser") == 0) {
xSemaphoreTake(renderingMutex, portMAX_DELAY);
updateRequired = true;
};
auto onCompleteBool = [this](bool) {
exitActivity();
enterNewActivity(new CalibreSettingsActivity(renderer, mappedInput, [this] {
exitActivity();
updateRequired = true;
}));
xSemaphoreGive(renderingMutex);
} else if (strcmp(setting.name, "Clear Cache") == 0) {
xSemaphoreTake(renderingMutex, portMAX_DELAY);
exitActivity();
enterNewActivity(new ClearCacheActivity(renderer, mappedInput, [this] {
exitActivity();
updateRequired = true;
}));
xSemaphoreGive(renderingMutex);
} else if (strcmp(setting.name, "Check for updates") == 0) {
xSemaphoreTake(renderingMutex, portMAX_DELAY);
exitActivity();
enterNewActivity(new OtaUpdateActivity(renderer, mappedInput, [this] {
exitActivity();
updateRequired = true;
}));
xSemaphoreGive(renderingMutex);
updateRequired = true;
};
switch (setting.action) {
case SettingAction::RemapFrontButtons:
enterSubActivity(new ButtonRemapActivity(renderer, mappedInput, onComplete));
break;
case SettingAction::KOReaderSync:
enterSubActivity(new KOReaderSettingsActivity(renderer, mappedInput, onComplete));
break;
case SettingAction::OPDSBrowser:
enterSubActivity(new CalibreSettingsActivity(renderer, mappedInput, onComplete));
break;
case SettingAction::Network:
enterSubActivity(new WifiSelectionActivity(renderer, mappedInput, onCompleteBool, false));
break;
case SettingAction::ClearCache:
enterSubActivity(new ClearCacheActivity(renderer, mappedInput, onComplete));
break;
case SettingAction::CheckForUpdates:
enterSubActivity(new OtaUpdateActivity(renderer, mappedInput, onComplete));
break;
case SettingAction::None:
// Do nothing
break;
}
} else {
return;
@@ -283,24 +258,24 @@ void SettingsActivity::render() const {
GUI.drawTabBar(renderer, Rect{0, metrics.topPadding + metrics.headerHeight, pageWidth, metrics.tabBarHeight}, tabs,
selectedSettingIndex == 0);
const auto& settings = *currentSettings;
GUI.drawList(
renderer,
Rect{0, metrics.topPadding + metrics.headerHeight + metrics.tabBarHeight + metrics.verticalSpacing, pageWidth,
pageHeight - (metrics.topPadding + metrics.headerHeight + metrics.tabBarHeight + metrics.buttonHintsHeight +
metrics.verticalSpacing * 2)},
settingsCount, selectedSettingIndex - 1, [this](int index) { return std::string(settingsList[index].name); },
settingsCount, selectedSettingIndex - 1, [&settings](int index) { return std::string(settings[index].name); },
nullptr, nullptr,
[this](int i) {
const auto& setting = settingsList[i];
[&settings](int i) {
std::string valueText = "";
if (settingsList[i].type == SettingType::TOGGLE && settingsList[i].valuePtr != nullptr) {
const bool value = SETTINGS.*(settingsList[i].valuePtr);
if (settings[i].type == SettingType::TOGGLE && settings[i].valuePtr != nullptr) {
const bool value = SETTINGS.*(settings[i].valuePtr);
valueText = value ? "ON" : "OFF";
} else if (settingsList[i].type == SettingType::ENUM && settingsList[i].valuePtr != nullptr) {
const uint8_t value = SETTINGS.*(settingsList[i].valuePtr);
valueText = settingsList[i].enumValues[value];
} else if (settingsList[i].type == SettingType::VALUE && settingsList[i].valuePtr != nullptr) {
valueText = std::to_string(SETTINGS.*(settingsList[i].valuePtr));
} else if (settings[i].type == SettingType::ENUM && settings[i].valuePtr != nullptr) {
const uint8_t value = SETTINGS.*(settings[i].valuePtr);
valueText = settings[i].enumValues[value];
} else if (settings[i].type == SettingType::VALUE && settings[i].valuePtr != nullptr) {
valueText = std::to_string(SETTINGS.*(settings[i].valuePtr));
}
return valueText;
});
@@ -316,4 +291,4 @@ void SettingsActivity::render() const {
// Always use standard refresh for settings screen
renderer.displayBuffer();
}
}

View File

@@ -8,47 +8,147 @@
#include <vector>
#include "activities/ActivityWithSubactivity.h"
#include "util/ButtonNavigator.h"
class CrossPointSettings;
enum class SettingType { TOGGLE, ENUM, ACTION, VALUE };
enum class SettingType { TOGGLE, ENUM, ACTION, VALUE, STRING };
enum class SettingAction {
None,
RemapFrontButtons,
KOReaderSync,
OPDSBrowser,
Network,
ClearCache,
CheckForUpdates,
};
struct SettingInfo {
const char* name;
SettingType type;
uint8_t CrossPointSettings::* valuePtr;
uint8_t CrossPointSettings::* valuePtr = nullptr;
std::vector<std::string> enumValues;
SettingAction action = SettingAction::None;
struct ValueRange {
uint8_t min;
uint8_t max;
uint8_t step;
};
ValueRange valueRange;
ValueRange valueRange = {};
static SettingInfo Toggle(const char* name, uint8_t CrossPointSettings::* ptr) {
return {name, SettingType::TOGGLE, ptr};
const char* key = nullptr; // JSON API key (nullptr for ACTION types)
const char* category = nullptr; // Category for web UI grouping
// Direct char[] string fields (for settings stored in CrossPointSettings)
char* stringPtr = nullptr;
size_t stringMaxLen = 0;
// Dynamic accessors (for settings stored outside CrossPointSettings, e.g. KOReaderCredentialStore)
std::function<uint8_t()> valueGetter;
std::function<void(uint8_t)> valueSetter;
std::function<std::string()> stringGetter;
std::function<void(const std::string&)> stringSetter;
static SettingInfo Toggle(const char* name, uint8_t CrossPointSettings::* ptr, const char* key = nullptr,
const char* category = nullptr) {
SettingInfo s;
s.name = name;
s.type = SettingType::TOGGLE;
s.valuePtr = ptr;
s.key = key;
s.category = category;
return s;
}
static SettingInfo Enum(const char* name, uint8_t CrossPointSettings::* ptr, std::vector<std::string> values) {
return {name, SettingType::ENUM, ptr, std::move(values)};
static SettingInfo Enum(const char* name, uint8_t CrossPointSettings::* ptr, std::vector<std::string> values,
const char* key = nullptr, const char* category = nullptr) {
SettingInfo s;
s.name = name;
s.type = SettingType::ENUM;
s.valuePtr = ptr;
s.enumValues = std::move(values);
s.key = key;
s.category = category;
return s;
}
static SettingInfo Action(const char* name) { return {name, SettingType::ACTION, nullptr}; }
static SettingInfo Action(const char* name, SettingAction action) {
SettingInfo s;
s.name = name;
s.type = SettingType::ACTION;
s.action = action;
return s;
}
static SettingInfo Value(const char* name, uint8_t CrossPointSettings::* ptr, const ValueRange valueRange) {
return {name, SettingType::VALUE, ptr, {}, valueRange};
static SettingInfo Value(const char* name, uint8_t CrossPointSettings::* ptr, const ValueRange valueRange,
const char* key = nullptr, const char* category = nullptr) {
SettingInfo s;
s.name = name;
s.type = SettingType::VALUE;
s.valuePtr = ptr;
s.valueRange = valueRange;
s.key = key;
s.category = category;
return s;
}
static SettingInfo String(const char* name, char* ptr, size_t maxLen, const char* key = nullptr,
const char* category = nullptr) {
SettingInfo s;
s.name = name;
s.type = SettingType::STRING;
s.stringPtr = ptr;
s.stringMaxLen = maxLen;
s.key = key;
s.category = category;
return s;
}
static SettingInfo DynamicEnum(const char* name, std::vector<std::string> values, std::function<uint8_t()> getter,
std::function<void(uint8_t)> setter, const char* key = nullptr,
const char* category = nullptr) {
SettingInfo s;
s.name = name;
s.type = SettingType::ENUM;
s.enumValues = std::move(values);
s.valueGetter = std::move(getter);
s.valueSetter = std::move(setter);
s.key = key;
s.category = category;
return s;
}
static SettingInfo DynamicString(const char* name, std::function<std::string()> getter,
std::function<void(const std::string&)> setter, const char* key = nullptr,
const char* category = nullptr) {
SettingInfo s;
s.name = name;
s.type = SettingType::STRING;
s.stringGetter = std::move(getter);
s.stringSetter = std::move(setter);
s.key = key;
s.category = category;
return s;
}
};
class SettingsActivity final : public ActivityWithSubactivity {
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
ButtonNavigator buttonNavigator;
bool updateRequired = false;
int selectedCategoryIndex = 0; // Currently selected category
int selectedSettingIndex = 0;
int settingsCount = 0;
const SettingInfo* settingsList = nullptr;
// Per-category settings derived from shared list + device-only actions
std::vector<SettingInfo> displaySettings;
std::vector<SettingInfo> readerSettings;
std::vector<SettingInfo> controlsSettings;
std::vector<SettingInfo> systemSettings;
const std::vector<SettingInfo>* currentSettings = nullptr;
const std::function<void()> onGoHome;
@@ -68,4 +168,4 @@ class SettingsActivity final : public ActivityWithSubactivity {
void onEnter() override;
void onExit() override;
void loop() override;
};
};

View File

@@ -142,37 +142,24 @@ void KeyboardEntryActivity::handleKeyPress() {
}
void KeyboardEntryActivity::loop() {
// Navigation
if (mappedInput.wasPressed(MappedInputManager::Button::Up)) {
if (selectedRow > 0) {
selectedRow--;
// Clamp column to valid range for new row
const int maxCol = getRowLength(selectedRow) - 1;
if (selectedCol > maxCol) selectedCol = maxCol;
} else {
// Wrap to bottom row
selectedRow = NUM_ROWS - 1;
const int maxCol = getRowLength(selectedRow) - 1;
if (selectedCol > maxCol) selectedCol = maxCol;
}
updateRequired = true;
}
// Handle navigation
buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Up}, [this] {
selectedRow = ButtonNavigator::previousIndex(selectedRow, NUM_ROWS);
if (mappedInput.wasPressed(MappedInputManager::Button::Down)) {
if (selectedRow < NUM_ROWS - 1) {
selectedRow++;
const int maxCol = getRowLength(selectedRow) - 1;
if (selectedCol > maxCol) selectedCol = maxCol;
} else {
// Wrap to top row
selectedRow = 0;
const int maxCol = getRowLength(selectedRow) - 1;
if (selectedCol > maxCol) selectedCol = maxCol;
}
const int maxCol = getRowLength(selectedRow) - 1;
if (selectedCol > maxCol) selectedCol = maxCol;
updateRequired = true;
}
});
if (mappedInput.wasPressed(MappedInputManager::Button::Left)) {
buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Down}, [this] {
selectedRow = ButtonNavigator::nextIndex(selectedRow, NUM_ROWS);
const int maxCol = getRowLength(selectedRow) - 1;
if (selectedCol > maxCol) selectedCol = maxCol;
updateRequired = true;
});
buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Left}, [this] {
const int maxCol = getRowLength(selectedRow) - 1;
// Special bottom row case
@@ -191,20 +178,14 @@ void KeyboardEntryActivity::loop() {
// At done button, move to backspace
selectedCol = BACKSPACE_COL;
}
updateRequired = true;
return;
}
if (selectedCol > 0) {
selectedCol--;
} else {
// Wrap to end of current row
selectedCol = maxCol;
selectedCol = ButtonNavigator::previousIndex(selectedCol, maxCol + 1);
}
updateRequired = true;
}
if (mappedInput.wasPressed(MappedInputManager::Button::Right)) {
updateRequired = true;
});
buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Right}, [this] {
const int maxCol = getRowLength(selectedRow) - 1;
// Special bottom row case
@@ -223,18 +204,11 @@ void KeyboardEntryActivity::loop() {
// At done button, wrap to beginning of row
selectedCol = SHIFT_COL;
}
updateRequired = true;
return;
}
if (selectedCol < maxCol) {
selectedCol++;
} else {
// Wrap to beginning of current row
selectedCol = 0;
selectedCol = ButtonNavigator::nextIndex(selectedCol, maxCol + 1);
}
updateRequired = true;
}
});
// Selection
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {

View File

@@ -9,6 +9,7 @@
#include <utility>
#include "../Activity.h"
#include "util/ButtonNavigator.h"
/**
* Reusable keyboard entry activity for text input.
@@ -65,6 +66,7 @@ class KeyboardEntryActivity : public Activity {
bool isPassword;
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
ButtonNavigator buttonNavigator;
bool updateRequired = false;
// Keyboard state

View File

@@ -27,5 +27,10 @@ class UITheme {
const BaseTheme* currentTheme;
};
// Known theme thumbnail heights to prerender when opening a book for the first time.
// These correspond to homeCoverHeight values across all themes (Lyra=226, Base=400).
static constexpr int PRERENDER_THUMB_HEIGHTS[] = {226, 400};
static constexpr int PRERENDER_THUMB_HEIGHTS_COUNT = 2;
// Helper macro to access current theme
#define GUI UITheme::getInstance().getTheme()

View File

@@ -1,7 +1,7 @@
#include "BaseTheme.h"
#include <GfxRenderer.h>
#include <SDCardManager.h>
#include <HalStorage.h>
#include <Utf8.h>
#include <cstdint>
@@ -308,7 +308,7 @@ void BaseTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std:
// First time: load cover from SD and render
FsFile file;
if (SdMan.openFileForRead("HOME", coverBmpPath, file)) {
if (Storage.openFileForRead("HOME", coverBmpPath, file)) {
Bitmap bitmap(file);
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
Serial.printf("Rendering bmp\n");

View File

@@ -1,7 +1,7 @@
#include "LyraTheme.h"
#include <GfxRenderer.h>
#include <SDCardManager.h>
#include <HalStorage.h>
#include <cstdint>
#include <string>
@@ -283,7 +283,7 @@ void LyraTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std:
// First time: load cover from SD and render
FsFile file;
if (SdMan.openFileForRead("HOME", coverBmpPath, file)) {
if (Storage.openFileForRead("HOME", coverBmpPath, file)) {
Bitmap bitmap(file);
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
float coverHeight = static_cast<float>(bitmap.getHeight());

View File

@@ -3,7 +3,8 @@
#include <GfxRenderer.h>
#include <HalDisplay.h>
#include <HalGPIO.h>
#include <SDCardManager.h>
#include <HalPowerManager.h>
#include <HalStorage.h>
#include <SPI.h>
#include <builtinFonts/all.h>
@@ -27,9 +28,11 @@
#include "activities/util/FullScreenMessageActivity.h"
#include "components/UITheme.h"
#include "fontIds.h"
#include "util/ButtonNavigator.h"
HalDisplay display;
HalGPIO gpio;
HalPowerManager powerManager;
MappedInputManager mappedInputManager(gpio);
GfxRenderer renderer(display);
Activity* currentActivity;
@@ -180,7 +183,7 @@ void verifyPowerButtonDuration() {
if (abort) {
// Button released too early. Returning to sleep.
// IMPORTANT: Re-arm the wakeup trigger before sleeping again
gpio.startDeepSleep();
powerManager.startDeepSleep(gpio);
}
}
@@ -203,7 +206,7 @@ void enterDeepSleep() {
Serial.printf("[%lu] [ ] Power button press calibration value: %lu ms\n", millis(), t2 - t1);
Serial.printf("[%lu] [ ] Entering deep sleep.\n", millis());
gpio.startDeepSleep();
powerManager.startDeepSleep(gpio);
}
void onGoHome();
@@ -280,6 +283,7 @@ void setup() {
t1 = millis();
gpio.begin();
powerManager.begin();
// Only start serial if USB connected
if (gpio.isUsbConnected()) {
@@ -293,7 +297,7 @@ void setup() {
// SD Card Initialization
// We need 6 open files concurrently when parsing a new chapter
if (!SdMan.begin()) {
if (!Storage.begin()) {
Serial.printf("[%lu] [ ] SD card initialization failed\n", millis());
setupDisplayAndFonts();
exitActivity();
@@ -304,6 +308,7 @@ void setup() {
SETTINGS.loadFromFile();
KOREADER_STORE.loadFromFile();
UITheme::getInstance().reload();
ButtonNavigator::setMappedInputManager(mappedInputManager);
switch (gpio.getWakeupReason()) {
case HalGPIO::WakeupReason::PowerButton:
@@ -314,7 +319,7 @@ void setup() {
case HalGPIO::WakeupReason::AfterUSBPower:
// If USB power caused a cold boot, go back to sleep
Serial.printf("[%lu] [ ] Wakeup reason: After USB Power\n", millis());
gpio.startDeepSleep();
powerManager.startDeepSleep(gpio);
break;
case HalGPIO::WakeupReason::AfterFlash:
// After flashing, just proceed to boot
@@ -370,7 +375,8 @@ void loop() {
// Check for any user activity (button press or release) or active background work
static unsigned long lastActivityTime = millis();
if (gpio.wasAnyPressed() || gpio.wasAnyReleased() || (currentActivity && currentActivity->preventAutoSleep())) {
lastActivityTime = millis(); // Reset inactivity timer
lastActivityTime = millis(); // Reset inactivity timer
powerManager.setPowerSaving(false); // Restore normal CPU frequency on user activity
}
const unsigned long sleepTimeoutMs = SETTINGS.getSleepTimeoutMs();
@@ -408,6 +414,13 @@ void loop() {
if (currentActivity && currentActivity->skipLoopDelay()) {
yield(); // Give FreeRTOS a chance to run tasks, but return immediately
} else {
delay(10); // Normal delay when no activity requires fast response
if (millis() - lastActivityTime >= HalPowerManager::IDLE_POWER_SAVING_MS) {
// If we've been inactive for a while, increase the delay to save power
powerManager.setPowerSaving(true); // Lower CPU frequency after extended inactivity
delay(50);
} else {
// Short delay to prevent tight loop while still being responsive
delay(10);
}
}
}

View File

@@ -3,14 +3,17 @@
#include <ArduinoJson.h>
#include <Epub.h>
#include <FsHelpers.h>
#include <SDCardManager.h>
#include <HalStorage.h>
#include <WiFi.h>
#include <esp_task_wdt.h>
#include <algorithm>
#include "CrossPointSettings.h"
#include "SettingsList.h"
#include "html/FilesPageHtml.generated.h"
#include "html/HomePageHtml.generated.h"
#include "html/SettingsPageHtml.generated.h"
#include "util/StringUtils.h"
namespace {
@@ -148,6 +151,11 @@ void CrossPointWebServer::begin() {
// Delete file/folder endpoint
server->on("/delete", HTTP_POST, [this] { handleDelete(); });
// Settings endpoints
server->on("/settings", HTTP_GET, [this] { handleSettingsPage(); });
server->on("/api/settings", HTTP_GET, [this] { handleGetSettings(); });
server->on("/api/settings", HTTP_POST, [this] { handlePostSettings(); });
server->onNotFound([this] { handleNotFound(); });
Serial.printf("[%lu] [WEB] [MEM] Free heap after route setup: %d bytes\n", millis(), ESP.getFreeHeap());
@@ -316,7 +324,7 @@ void CrossPointWebServer::handleStatus() const {
}
void CrossPointWebServer::scanFiles(const char* path, const std::function<void(FileInfo)>& callback) const {
FsFile root = SdMan.open(path);
FsFile root = Storage.open(path);
if (!root) {
Serial.printf("[%lu] [WEB] Failed to open directory: %s\n", millis(), path);
return;
@@ -458,12 +466,12 @@ void CrossPointWebServer::handleDownload() const {
}
}
if (!SdMan.exists(itemPath.c_str())) {
if (!Storage.exists(itemPath.c_str())) {
server->send(404, "text/plain", "Item not found");
return;
}
FsFile file = SdMan.open(itemPath.c_str());
FsFile file = Storage.open(itemPath.c_str());
if (!file) {
server->send(500, "text/plain", "Failed to open file");
return;
@@ -574,15 +582,15 @@ void CrossPointWebServer::handleUpload(UploadState& state) const {
// Check if file already exists - SD operations can be slow
esp_task_wdt_reset();
if (SdMan.exists(filePath.c_str())) {
if (Storage.exists(filePath.c_str())) {
Serial.printf("[%lu] [WEB] [UPLOAD] Overwriting existing file: %s\n", millis(), filePath.c_str());
esp_task_wdt_reset();
SdMan.remove(filePath.c_str());
Storage.remove(filePath.c_str());
}
// Open file for writing - this can be slow due to FAT cluster allocation
esp_task_wdt_reset();
if (!SdMan.openFileForWrite("WEB", filePath, state.file)) {
if (!Storage.openFileForWrite("WEB", filePath, state.file)) {
state.error = "Failed to create file on SD card";
Serial.printf("[%lu] [WEB] [UPLOAD] FAILED to create file: %s\n", millis(), filePath.c_str());
return;
@@ -660,7 +668,7 @@ void CrossPointWebServer::handleUpload(UploadState& state) const {
String filePath = state.path;
if (!filePath.endsWith("/")) filePath += "/";
filePath += state.fileName;
SdMan.remove(filePath.c_str());
Storage.remove(filePath.c_str());
}
state.error = "Upload aborted";
Serial.printf("[%lu] [WEB] Upload aborted\n", millis());
@@ -711,13 +719,13 @@ void CrossPointWebServer::handleCreateFolder() const {
Serial.printf("[%lu] [WEB] Creating folder: %s\n", millis(), folderPath.c_str());
// Check if already exists
if (SdMan.exists(folderPath.c_str())) {
if (Storage.exists(folderPath.c_str())) {
server->send(400, "text/plain", "Folder already exists");
return;
}
// Create the folder
if (SdMan.mkdir(folderPath.c_str())) {
if (Storage.mkdir(folderPath.c_str())) {
Serial.printf("[%lu] [WEB] Folder created successfully: %s\n", millis(), folderPath.c_str());
server->send(200, "text/plain", "Folder created: " + folderName);
} else {
@@ -763,12 +771,12 @@ void CrossPointWebServer::handleRename() const {
return;
}
if (!SdMan.exists(itemPath.c_str())) {
if (!Storage.exists(itemPath.c_str())) {
server->send(404, "text/plain", "Item not found");
return;
}
FsFile file = SdMan.open(itemPath.c_str());
FsFile file = Storage.open(itemPath.c_str());
if (!file) {
server->send(500, "text/plain", "Failed to open file");
return;
@@ -789,7 +797,7 @@ void CrossPointWebServer::handleRename() const {
}
newPath += newName;
if (SdMan.exists(newPath.c_str())) {
if (Storage.exists(newPath.c_str())) {
file.close();
server->send(409, "text/plain", "Target already exists");
return;
@@ -839,12 +847,12 @@ void CrossPointWebServer::handleMove() const {
}
}
if (!SdMan.exists(itemPath.c_str())) {
if (!Storage.exists(itemPath.c_str())) {
server->send(404, "text/plain", "Item not found");
return;
}
FsFile file = SdMan.open(itemPath.c_str());
FsFile file = Storage.open(itemPath.c_str());
if (!file) {
server->send(500, "text/plain", "Failed to open file");
return;
@@ -855,12 +863,12 @@ void CrossPointWebServer::handleMove() const {
return;
}
if (!SdMan.exists(destPath.c_str())) {
if (!Storage.exists(destPath.c_str())) {
file.close();
server->send(404, "text/plain", "Destination not found");
return;
}
FsFile destDir = SdMan.open(destPath.c_str());
FsFile destDir = Storage.open(destPath.c_str());
if (!destDir || !destDir.isDirectory()) {
if (destDir) {
destDir.close();
@@ -882,7 +890,7 @@ void CrossPointWebServer::handleMove() const {
server->send(200, "text/plain", "Already in destination");
return;
}
if (SdMan.exists(newPath.c_str())) {
if (Storage.exists(newPath.c_str())) {
file.close();
server->send(409, "text/plain", "Target already exists");
return;
@@ -942,7 +950,7 @@ void CrossPointWebServer::handleDelete() const {
}
// Check if item exists
if (!SdMan.exists(itemPath.c_str())) {
if (!Storage.exists(itemPath.c_str())) {
Serial.printf("[%lu] [WEB] Delete failed - item not found: %s\n", millis(), itemPath.c_str());
server->send(404, "text/plain", "Item not found");
return;
@@ -954,7 +962,7 @@ void CrossPointWebServer::handleDelete() const {
if (itemType == "folder") {
// For folders, try to remove (will fail if not empty)
FsFile dir = SdMan.open(itemPath.c_str());
FsFile dir = Storage.open(itemPath.c_str());
if (dir && dir.isDirectory()) {
// Check if folder is empty
FsFile entry = dir.openNextFile();
@@ -968,10 +976,10 @@ void CrossPointWebServer::handleDelete() const {
}
dir.close();
}
success = SdMan.rmdir(itemPath.c_str());
success = Storage.rmdir(itemPath.c_str());
} else {
// For files, use remove
success = SdMan.remove(itemPath.c_str());
success = Storage.remove(itemPath.c_str());
}
if (success) {
@@ -983,6 +991,168 @@ void CrossPointWebServer::handleDelete() const {
}
}
void CrossPointWebServer::handleSettingsPage() const {
server->send(200, "text/html", SettingsPageHtml);
Serial.printf("[%lu] [WEB] Served settings page\n", millis());
}
void CrossPointWebServer::handleGetSettings() const {
auto settings = getSettingsList();
server->setContentLength(CONTENT_LENGTH_UNKNOWN);
server->send(200, "application/json", "");
server->sendContent("[");
char output[512];
constexpr size_t outputSize = sizeof(output);
bool seenFirst = false;
JsonDocument doc;
for (const auto& s : settings) {
if (!s.key) continue; // Skip ACTION-only entries
doc.clear();
doc["key"] = s.key;
doc["name"] = s.name;
doc["category"] = s.category;
switch (s.type) {
case SettingType::TOGGLE: {
doc["type"] = "toggle";
if (s.valuePtr) {
doc["value"] = static_cast<int>(SETTINGS.*(s.valuePtr));
}
break;
}
case SettingType::ENUM: {
doc["type"] = "enum";
if (s.valuePtr) {
doc["value"] = static_cast<int>(SETTINGS.*(s.valuePtr));
} else if (s.valueGetter) {
doc["value"] = static_cast<int>(s.valueGetter());
}
JsonArray options = doc["options"].to<JsonArray>();
for (const auto& opt : s.enumValues) {
options.add(opt);
}
break;
}
case SettingType::VALUE: {
doc["type"] = "value";
if (s.valuePtr) {
doc["value"] = static_cast<int>(SETTINGS.*(s.valuePtr));
}
doc["min"] = s.valueRange.min;
doc["max"] = s.valueRange.max;
doc["step"] = s.valueRange.step;
break;
}
case SettingType::STRING: {
doc["type"] = "string";
if (s.stringGetter) {
doc["value"] = s.stringGetter();
} else if (s.stringPtr) {
doc["value"] = s.stringPtr;
}
break;
}
default:
continue;
}
const size_t written = serializeJson(doc, output, outputSize);
if (written >= outputSize) {
Serial.printf("[%lu] [WEB] Skipping oversized setting JSON for: %s\n", millis(), s.key);
continue;
}
if (seenFirst) {
server->sendContent(",");
} else {
seenFirst = true;
}
server->sendContent(output);
}
server->sendContent("]");
server->sendContent("");
Serial.printf("[%lu] [WEB] Served settings API\n", millis());
}
void CrossPointWebServer::handlePostSettings() {
if (!server->hasArg("plain")) {
server->send(400, "text/plain", "Missing JSON body");
return;
}
const String body = server->arg("plain");
JsonDocument doc;
const DeserializationError err = deserializeJson(doc, body);
if (err) {
server->send(400, "text/plain", String("Invalid JSON: ") + err.c_str());
return;
}
auto settings = getSettingsList();
int applied = 0;
for (auto& s : settings) {
if (!s.key) continue;
if (!doc[s.key].is<JsonVariant>()) continue;
switch (s.type) {
case SettingType::TOGGLE: {
const int val = doc[s.key].as<int>() ? 1 : 0;
if (s.valuePtr) {
SETTINGS.*(s.valuePtr) = val;
}
applied++;
break;
}
case SettingType::ENUM: {
const int val = doc[s.key].as<int>();
if (val >= 0 && val < static_cast<int>(s.enumValues.size())) {
if (s.valuePtr) {
SETTINGS.*(s.valuePtr) = static_cast<uint8_t>(val);
} else if (s.valueSetter) {
s.valueSetter(static_cast<uint8_t>(val));
}
applied++;
}
break;
}
case SettingType::VALUE: {
const int val = doc[s.key].as<int>();
if (val >= s.valueRange.min && val <= s.valueRange.max) {
if (s.valuePtr) {
SETTINGS.*(s.valuePtr) = static_cast<uint8_t>(val);
}
applied++;
}
break;
}
case SettingType::STRING: {
const std::string val = doc[s.key].as<std::string>();
if (s.stringSetter) {
s.stringSetter(val);
} else if (s.stringPtr && s.stringMaxLen > 0) {
strncpy(s.stringPtr, val.c_str(), s.stringMaxLen - 1);
s.stringPtr[s.stringMaxLen - 1] = '\0';
}
applied++;
break;
}
default:
break;
}
}
SETTINGS.saveToFile();
Serial.printf("[%lu] [WEB] Applied %d setting(s)\n", millis(), applied);
server->send(200, "text/plain", String("Applied ") + String(applied) + " setting(s)");
}
// WebSocket callback trampoline
void CrossPointWebServer::wsEventCallback(uint8_t num, WStype_t type, uint8_t* payload, size_t length) {
if (wsInstance) {
@@ -1007,7 +1177,7 @@ void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t*
String filePath = wsUploadPath;
if (!filePath.endsWith("/")) filePath += "/";
filePath += wsUploadFileName;
SdMan.remove(filePath.c_str());
Storage.remove(filePath.c_str());
Serial.printf("[%lu] [WS] Deleted incomplete upload: %s\n", millis(), filePath.c_str());
}
wsUploadInProgress = false;
@@ -1051,13 +1221,13 @@ void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t*
// Check if file exists and remove it
esp_task_wdt_reset();
if (SdMan.exists(filePath.c_str())) {
SdMan.remove(filePath.c_str());
if (Storage.exists(filePath.c_str())) {
Storage.remove(filePath.c_str());
}
// Open file for writing
esp_task_wdt_reset();
if (!SdMan.openFileForWrite("WS", filePath, wsUploadFile)) {
if (!Storage.openFileForWrite("WS", filePath, wsUploadFile)) {
wsServer->sendTXT(num, "ERROR:Failed to create file");
wsUploadInProgress = false;
return;

Some files were not shown because too many files have changed in this diff Show More