Compare commits
32 Commits
mod/sync-u
...
3628d8eb37
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3628d8eb37
|
||
|
|
42011d5977
|
||
|
|
2aa13ea2de
|
||
|
|
19b6ad047b
|
||
|
|
2eae521b6a
|
||
|
|
9d9bc019a2
|
||
|
|
ff33b2b3be
|
||
|
|
0e2440aea8
|
||
|
|
39ef1e6d78
|
||
|
|
3cc127d658
|
||
|
|
98146f2545
|
||
|
|
f5b708424d
|
||
|
|
1c19899aa3
|
||
|
|
390f10f30d
|
||
|
|
49471e36f1
|
||
|
|
c44ac0272a
|
||
|
|
29954a3683
|
||
|
|
3eddb07a1a
|
||
|
|
f443f5dde0
|
||
|
|
3d51dfeeb7
|
||
|
|
4dadea1a03
|
||
|
|
0d9a1f4f89
|
||
|
|
1b350656a5
|
||
|
|
51dc498768
|
||
|
|
406c3aeace
|
||
|
|
55a1fef01a
|
||
|
|
18be265a4a
|
||
|
|
3a0641889f
|
||
|
|
ad282cadfe
|
||
|
|
c8ba4fe973
|
||
|
|
c1b8e53138
|
||
|
|
0fda9031fd
|
203
README.md
203
README.md
@@ -1,18 +1,21 @@
|
||||
# CrossPoint Reader
|
||||
# CrossPoint Reader (Mod)
|
||||
|
||||
Firmware for the **Xteink X4** e-paper display reader (unaffiliated with Xteink).
|
||||
Built using **PlatformIO** and targeting the **ESP32-C3** microcontroller.
|
||||
A modified fork of [CrossPoint Reader](https://github.com/crosspoint-reader/crosspoint-reader) for the **Xteink X4**
|
||||
e-paper display reader. Built using **PlatformIO** and targeting the **ESP32-C3** microcontroller.
|
||||
|
||||
CrossPoint Reader is a purpose-built firmware designed to be a drop-in, fully open-source replacement for the official
|
||||
Xteink firmware. It aims to match or improve upon the standard EPUB reading experience.
|
||||
This mod is maintained on the `mod/master` branch and tracks upstream `master`. It ports upstream PRs ahead of merge
|
||||
and adds features not yet available in the official project, including bookmarks, dictionary lookup, a clock, book
|
||||
management with archiving, an overhauled reader menu, and various rendering and performance improvements.
|
||||
|
||||
> **Upstream:** [crosspoint-reader/crosspoint-reader](https://github.com/crosspoint-reader/crosspoint-reader)
|
||||
|
||||

|
||||
|
||||
## Motivation
|
||||
|
||||
E-paper devices are fantastic for reading, but most commercially available readers are closed systems with limited
|
||||
E-paper devices are fantastic for reading, but most commercially available readers are closed systems with limited
|
||||
customisation. The **Xteink X4** is an affordable, e-paper device, however the official firmware remains closed.
|
||||
CrossPoint exists partly as a fun side-project and partly to open up the ecosystem and truely unlock the device's
|
||||
CrossPoint exists partly as a fun side-project and partly to open up the ecosystem and truly unlock the device's
|
||||
potential.
|
||||
|
||||
CrossPoint Reader aims to:
|
||||
@@ -21,54 +24,159 @@ CrossPoint Reader aims to:
|
||||
* Support **customisable font, layout, and display** options.
|
||||
* Run purely on the **Xteink X4 hardware**.
|
||||
|
||||
This project is **not affiliated with Xteink**; it's built as a community project.
|
||||
This mod exists to iterate faster on features and fixes while upstream reviews and merges PRs at its own pace. It is
|
||||
**not affiliated with Xteink** or the upstream CrossPoint project; it's a personal fork built on top of their work.
|
||||
|
||||
## History
|
||||
|
||||
This mod was forked at [#46c2109](https://github.com/crosspoint-reader/crosspoint-reader/commit/46c2109f1fe5cb41ef1a84a15eeb3db64cdca082). A major sync took place at v1.1.0-rc.
|
||||
|
||||
## Features & Usage
|
||||
|
||||
This is not all-inclusive, but in general:
|
||||
|
||||
- [x] EPUB parsing and rendering (EPUB 2 and EPUB 3)
|
||||
- [ ] Image support within EPUB
|
||||
- [x] Image support within EPUB (JPEG and PNG)
|
||||
- [x] Table rendering within EPUB
|
||||
- [x] Saved reading position
|
||||
- [x] Bookmarks (add, remove, navigate with snippet preview)
|
||||
- [x] Dictionary lookup (offline, StarDict format)
|
||||
- [x] File explorer with file picker
|
||||
- [x] Basic EPUB picker from root directory
|
||||
- [x] Support nested folders
|
||||
- [x] File extensions displayed
|
||||
- [x] Expandable selected row for long filenames
|
||||
- [ ] EPUB picker with cover art
|
||||
- [x] Book management (archive, unarchive, delete, reindex)
|
||||
- [x] Clock display (12h/24h, NTP sync, timezone support)
|
||||
- [x] Custom sleep screen
|
||||
- [x] Cover sleep screen
|
||||
- [x] Letterbox fill modes (Solid, Dithered, None) with per-book override
|
||||
- [x] Placeholder covers for books without embedded cover images
|
||||
- [x] Wifi book upload
|
||||
- [x] Wifi OTA updates
|
||||
- [x] Configurable font, layout, and display options
|
||||
- [ ] User provided fonts
|
||||
- [ ] Full UTF support
|
||||
- [x] Screen rotation
|
||||
- [x] Screen rotation (Portrait, Landscape CW, Inverted, Landscape CCW)
|
||||
- [x] End-of-book interactive menu
|
||||
- [x] Silent background chapter pre-indexing
|
||||
|
||||
Multi-language support: Read EPUBs in various languages, including English, Spanish, French, German, Italian, Portuguese, Russian, Ukrainian, Polish, Swedish, Norwegian, [and more](./USER_GUIDE.md#supported-languages).
|
||||
|
||||
See [the user guide](./USER_GUIDE.md) for instructions on operating CrossPoint.
|
||||
See [the user guide](./USER_GUIDE.md) for instructions on operating CrossPoint.
|
||||
|
||||
For more details about the scope of the project, see the [SCOPE.md](SCOPE.md) document.
|
||||
|
||||
## What This Mod Adds
|
||||
|
||||
This section describes features and improvements in the mod that are not present in upstream CrossPoint Reader.
|
||||
|
||||
### Reading Enhancements
|
||||
|
||||
* **Bookmarks** — Save and remove bookmarks per book. Each bookmark stores a snippet (first sentence) for quick
|
||||
identification. A ribbon indicator marks bookmarked pages. Navigate bookmarks via the reader menu.
|
||||
* **Dictionary lookup** — Offline word lookup using StarDict-format dictionaries stored in `/.dictionary/` on the SD
|
||||
card. Supports stemming, fuzzy matching, edit-distance suggestions, and lookup history. The dictionary index is cached
|
||||
to a binary file for fast subsequent access.
|
||||
* **Table rendering** — EPUB tables render with column alignment, colspan support, HTML/CSS width hints, cell padding,
|
||||
borders, and `<br>` line breaks within cells. Full-width spanning cells are center-aligned.
|
||||
* **End-of-book menu** — An interactive menu at the end of a book (Archive, Delete, Back to Beginning, Close) replaces
|
||||
the static end-of-book text.
|
||||
* **Long-press Confirm** — While reading, long-pressing Confirm opens the Table of Contents directly, bypassing the
|
||||
reader menu.
|
||||
|
||||
### Home Screen & Navigation
|
||||
|
||||
* **Clock** — Displays in all screen headers. Configurable format (off, 12h AM/PM, 24h) and size (small, medium,
|
||||
large). Supports NTP time sync over WiFi, timezone presets (UTC, US time zones), and custom UTC offset.
|
||||
* **Adaptive home screen** — Book card sizing adjusts based on cover aspect ratio. The home screen integrates with
|
||||
recent books and book management.
|
||||
* **File browser improvements** — File extensions are shown alongside filenames. When a selected filename overflows the
|
||||
row width, the row expands to two lines with smart text wrapping (breaks at dashes/separators, then word boundaries,
|
||||
then character-level).
|
||||
* **Long-press shortcuts** — Long-press on a book in the home screen or recents opens the book management menu.
|
||||
Long-press on "Browse Files" opens the archive browser (`/.archive/`).
|
||||
|
||||
### Book Management
|
||||
|
||||
* **Archive** — Move books to `/.archive/` on the SD card, preserving directory structure. Unarchive restores them to
|
||||
their original location.
|
||||
* **Manage Book menu** — A popup menu accessible from the home screen, file browser, recents, reader menu, and
|
||||
end-of-book menu. Actions include archive/unarchive, delete book, delete cache, reindex, and full reindex.
|
||||
* **Recent books** — A dedicated recent books list with book management integration.
|
||||
|
||||
### Reader Menu
|
||||
|
||||
* **Long-press actions** — Long-press "Lookup Word" to open the Looked Up Words history. Long-press "Toggle
|
||||
Orientation" to open a sub-menu for selecting any of the four orientations (Portrait, Landscape CW, Inverted,
|
||||
Landscape CCW).
|
||||
* **Letterbox fill** — Short-press cycles through letterbox fill modes (Default, Dithered, Solid, None). Per-book
|
||||
overrides are stored in `BookSettings`.
|
||||
* **Landscape CCW** — A fourth orientation option. All text rendering, button hints, and dictionary layout support
|
||||
counter-clockwise landscape.
|
||||
|
||||
### Display & Rendering
|
||||
|
||||
* **Silent pre-indexing** — The next chapter is pre-indexed in the background when approaching a chapter boundary.
|
||||
Configurable display mode: popup, status bar text, or status bar icon.
|
||||
* **Placeholder covers** — Books without an embedded cover image get a generated placeholder (title, author, icon
|
||||
layout) for the home screen and sleep screen.
|
||||
* **Sleep screen letterbox fill** — Multiple fill modes for the letterbox area around cover images on the sleep screen,
|
||||
with per-book override support.
|
||||
|
||||
### Performance
|
||||
|
||||
Several upstream PRs have been ported ahead of their merge into upstream `master`:
|
||||
|
||||
* **Byte-level framebuffer writes** — 232-470x speedup for `fillRect`, `fillRectDither`, and axis-aligned `drawLine`
|
||||
operations. Upstream [PR #1055](https://github.com/crosspoint-reader/crosspoint-reader/pull/1055).
|
||||
* **Word-width cache and hyphenation early exit** — 5-9% layout time reduction via a 128-entry direct-mapped cache and
|
||||
monotonic early exit in the hyphenation loop. Upstream
|
||||
[PR #1027](https://github.com/crosspoint-reader/crosspoint-reader/pull/1027).
|
||||
* **`std::list` to `std::vector` in text layout** — 11% faster chapter parse time and ~50KB heap savings. Upstream
|
||||
[PR #1038](https://github.com/crosspoint-reader/crosspoint-reader/pull/1038).
|
||||
* **Combining mark rendering** — Proper rendering of decomposed Unicode characters with NFC-like precomposition for
|
||||
hyphenation pattern matching. Upstream
|
||||
[PR #1037](https://github.com/crosspoint-reader/crosspoint-reader/pull/1037).
|
||||
* **URL hyphenation** — Long URLs can now be line-wrapped at path separators without crashing. Upstream
|
||||
[PR #1068](https://github.com/crosspoint-reader/crosspoint-reader/pull/1068).
|
||||
|
||||
For detailed porting notes and differences from upstream, see [mod/prs/MERGED.md](mod/prs/MERGED.md). Note that this document was created well after many features were added manually so it is not all-inclusive. Sorry!
|
||||
|
||||
## Upstream Compatibility
|
||||
|
||||
This mod tracks upstream `master` and manually ports relevant PRs. Some upstream features are not present in the mod,
|
||||
and the mod's build configuration differs slightly.
|
||||
|
||||
**Features on upstream `master` not yet in the mod:**
|
||||
* Catalan language support
|
||||
* Improved Spanish translations
|
||||
|
||||
**Build differences:**
|
||||
* The `[env:mod]` build environment omits the OpenDyslexic font and some hyphenation patterns (German, Spanish, French,
|
||||
Italian, Russian) to save flash space. These can be re-enabled by using `[env:default]` or removing the corresponding
|
||||
`-DOMIT_*` flags.
|
||||
* The mod version string is `<version>-mod+<git-hash>` (e.g., `1.1.2-mod+abc1234`).
|
||||
|
||||
See [mod/prs/MERGED.md](mod/prs/MERGED.md) for the full list of upstream PRs ported into this mod, including what was
|
||||
changed or enhanced during the port.
|
||||
|
||||
## Installing
|
||||
|
||||
### Web (latest firmware)
|
||||
This mod is built from source. There is no web flasher for the mod firmware.
|
||||
|
||||
1. Connect your Xteink X4 to your computer via USB-C and wake/unlock the device
|
||||
2. Go to https://xteink.dve.al/ and click "Flash CrossPoint firmware"
|
||||
Connect your Xteink X4 to your computer via USB-C, wake/unlock the device, and run:
|
||||
|
||||
To revert back to the official firmware, you can flash the latest official firmware from https://xteink.dve.al/, or swap
|
||||
back to the other partition using the "Swap boot partition" button here https://xteink.dve.al/debug.
|
||||
```sh
|
||||
pio run -e mod --target upload
|
||||
```
|
||||
|
||||
### Web (specific firmware version)
|
||||
You can also use `pio run -e default --target upload` for a build without the mod's flash-saving omissions (see
|
||||
[Upstream Compatibility](#upstream-compatibility)).
|
||||
|
||||
1. Connect your Xteink X4 to your computer via USB-C
|
||||
2. Download the `firmware.bin` file from the release of your choice via the [releases page](https://github.com/crosspoint-reader/crosspoint-reader/releases)
|
||||
3. Go to https://xteink.dve.al/ and flash the firmware file using the "OTA fast flash controls" section
|
||||
|
||||
To revert back to the official firmware, you can flash the latest official firmware from https://xteink.dve.al/, or swap
|
||||
back to the other partition using the "Swap boot partition" button here https://xteink.dve.al/debug.
|
||||
|
||||
### Manual
|
||||
|
||||
See [Development](#development) below.
|
||||
To revert to upstream CrossPoint or the official Xteink firmware, flash via https://xteink.dve.al/ or swap back to the
|
||||
other partition using the "Swap boot partition" button at https://xteink.dve.al/debug.
|
||||
|
||||
## Development
|
||||
|
||||
@@ -81,21 +189,29 @@ See [Development](#development) below.
|
||||
|
||||
### Checking out the code
|
||||
|
||||
CrossPoint uses PlatformIO for building and flashing the firmware. To get started, clone the repository:
|
||||
CrossPoint uses PlatformIO for building and flashing the firmware. To get started, clone this repository and check out
|
||||
the mod branch:
|
||||
|
||||
```
|
||||
git clone --recursive https://github.com/crosspoint-reader/crosspoint-reader
|
||||
```sh
|
||||
git clone --recursive -b mod/master https://github.com/crosspoint-reader/crosspoint-reader
|
||||
|
||||
# Or, if you've already cloned without --recursive:
|
||||
git submodule update --init --recursive
|
||||
```
|
||||
|
||||
### Build environments
|
||||
|
||||
| Environment | Description |
|
||||
| ----------- | ----------- |
|
||||
| `mod` | **Recommended.** Includes serial logging, version tagging (`-mod+<hash>`), and omits some fonts/hyphenation patterns to save flash. |
|
||||
| `default` | Standard upstream-equivalent build with all fonts and hyphenation patterns included. |
|
||||
|
||||
### Flashing your device
|
||||
|
||||
Connect your Xteink X4 to your computer via USB-C and run the following command.
|
||||
Connect your Xteink X4 to your computer via USB-C and run:
|
||||
|
||||
```sh
|
||||
pio run --target upload
|
||||
pio run -e mod --target upload
|
||||
```
|
||||
### Debugging
|
||||
|
||||
@@ -152,24 +268,17 @@ For more details on the internal file structures, see the [file formats document
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are very welcome!
|
||||
This is a personal mod fork. If you'd like to contribute to the upstream CrossPoint Reader project, head to the
|
||||
[upstream repository](https://github.com/crosspoint-reader/crosspoint-reader) and check out the
|
||||
[ideas discussion board](https://github.com/crosspoint-reader/crosspoint-reader/discussions/categories/ideas).
|
||||
|
||||
If you're looking for a way to help out, take a look at the [ideas discussion board](https://github.com/crosspoint-reader/crosspoint-reader/discussions/categories/ideas).
|
||||
If there's something there you'd like to work on, leave a comment so that we can avoid duplicated effort.
|
||||
|
||||
Everyone here is a volunteer, so please be respectful and patient. For more details on our goverance and community
|
||||
principles, please see [GOVERNANCE.md](GOVERNANCE.md).
|
||||
|
||||
### To submit a contribution:
|
||||
|
||||
1. Fork the repo
|
||||
2. Create a branch (`feature/dithering-improvement`)
|
||||
3. Make changes
|
||||
4. Submit a PR
|
||||
For more details on upstream governance and community principles, see [GOVERNANCE.md](GOVERNANCE.md).
|
||||
|
||||
---
|
||||
|
||||
CrossPoint Reader is **not affiliated with Xteink or any manufacturer of the X4 hardware**.
|
||||
|
||||
Huge shoutout to [**diy-esp32-epub-reader** by atomic14](https://github.com/atomic14/diy-esp32-epub-reader), which was a project I took a lot of inspiration from as I
|
||||
was making CrossPoint.
|
||||
This mod is not **not affilitated with CrossPoint Reader**.
|
||||
|
||||
Huge shoutout to [**diy-esp32-epub-reader** by atomic14](https://github.com/atomic14/diy-esp32-epub-reader), which was
|
||||
a project the original CrossPoint author took a lot of inspiration from.
|
||||
|
||||
99
docs/contributing/koreader-sync-xpath-mapping.md
Normal file
99
docs/contributing/koreader-sync-xpath-mapping.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# KOReader Sync XPath Mapping
|
||||
|
||||
This note documents how CrossPoint maps reading positions to and from KOReader sync payloads.
|
||||
|
||||
## Problem
|
||||
|
||||
CrossPoint internally stores position as:
|
||||
|
||||
- `spineIndex` (chapter index, 0-based)
|
||||
- `pageNumber` + `totalPages`
|
||||
|
||||
KOReader sync payload stores:
|
||||
|
||||
- `progress` (XPath-like location)
|
||||
- `percentage` (overall progress)
|
||||
|
||||
A direct 1:1 mapping is not guaranteed because page layout differs between engines/devices.
|
||||
|
||||
## DocFragment Index Convention
|
||||
|
||||
KOReader uses **1-based** XPath predicates throughout, following standard XPath conventions.
|
||||
The first EPUB spine item is `DocFragment[1]`, the second is `DocFragment[2]`, and so on.
|
||||
|
||||
CrossPoint stores spine items as 0-based indices internally. The conversion is:
|
||||
|
||||
- **Generating XPath (to KOReader):** `DocFragment[spineIndex + 1]`
|
||||
- **Parsing XPath (from KOReader):** `spineIndex = DocFragment[N] - 1`
|
||||
|
||||
Reference: [koreader/koreader#11585](https://github.com/koreader/koreader/issues/11585) confirms this
|
||||
via a KOReader contributor mapping spine items to DocFragment numbers.
|
||||
|
||||
## Current Strategy
|
||||
|
||||
### CrossPoint -> KOReader
|
||||
|
||||
Implemented in `ProgressMapper::toKOReader`.
|
||||
|
||||
1. Compute overall `percentage` from chapter/page.
|
||||
2. Attempt to compute a real element-level XPath via `ChapterXPathIndexer::findXPathForProgress`.
|
||||
3. If XPath extraction fails, fallback to synthetic chapter path:
|
||||
- `/body/DocFragment[spineIndex + 1]/body`
|
||||
|
||||
### KOReader -> CrossPoint
|
||||
|
||||
Implemented in `ProgressMapper::toCrossPoint`.
|
||||
|
||||
1. Attempt to parse `DocFragment[N]` from incoming XPath; convert N to 0-based `spineIndex = N - 1`.
|
||||
2. If valid, attempt XPath-to-offset mapping via `ChapterXPathIndexer::findProgressForXPath`.
|
||||
3. Convert resolved intra-spine progress to page estimate.
|
||||
4. If XPath path is invalid/unresolvable, fallback to percentage-based chapter/page estimation.
|
||||
|
||||
## ChapterXPathIndexer Design
|
||||
|
||||
The module reparses **one spine XHTML** on demand using Expat and builds temporary anchors:
|
||||
|
||||
Source-of-truth note: XPath anchors are built from the original EPUB spine XHTML bytes (zip item contents), not from CrossPoint's distilled section render cache. This is intentional to preserve KOReader XPath compatibility.
|
||||
|
||||
- anchor: `<xpath, textOffset>`
|
||||
- `textOffset` counts non-whitespace bytes
|
||||
- When multiple anchors exist for the same path, the one with the **smallest** textOffset is used
|
||||
(start of element), not the latest periodic anchor.
|
||||
|
||||
Forward lookup (CrossPoint → XPath): uses `upper_bound` to find the last anchor at or before the
|
||||
target text offset, ensuring the returned XPath corresponds to the element the user is currently
|
||||
inside rather than the next element.
|
||||
|
||||
Matching for reverse lookup:
|
||||
|
||||
1. exact path match — reported as `exact=yes`
|
||||
2. index-insensitive path match (`div[2]` vs `div[3]` tolerated) — reported as `exact=no`
|
||||
3. ancestor fallback — reported as `exact=no`
|
||||
|
||||
If no match is found, caller must fallback to percentage.
|
||||
|
||||
## Memory / Safety Constraints (ESP32-C3)
|
||||
|
||||
The implementation intentionally avoids full DOM storage.
|
||||
|
||||
- Parse one chapter only.
|
||||
- Keep anchors in transient vectors only for duration of call.
|
||||
- Free XML parser and chapter byte buffer on all success/failure paths.
|
||||
- No persistent cache structures are introduced by this module.
|
||||
|
||||
## Known Limitations
|
||||
|
||||
- Page number on reverse mapping is still an estimate (renderer differences).
|
||||
- XPath mapping intentionally uses original spine XHTML while pagination comes from distilled renderer output, so minor roundtrip page drift is expected.
|
||||
- Image-only/low-text chapters may yield coarse anchors.
|
||||
- Extremely malformed XHTML can force fallback behavior.
|
||||
|
||||
## Operational Logging
|
||||
|
||||
`ProgressMapper` logs mapping source in reverse direction:
|
||||
|
||||
- `xpath` when XPath mapping path was used
|
||||
- `percentage` when fallback path was used
|
||||
|
||||
It also logs exactness (`exact=yes/no`) for XPath matches. Note that `exact=yes` is only set for
|
||||
a full path match with correct indices; index-insensitive and ancestor matches always log `exact=no`.
|
||||
@@ -17,6 +17,11 @@ void EpdFont::getTextBounds(const char* string, const int startX, const int star
|
||||
|
||||
int cursorX = startX;
|
||||
const int cursorY = startY;
|
||||
int lastBaseX = startX;
|
||||
int lastBaseAdvance = 0;
|
||||
int lastBaseTop = 0;
|
||||
bool hasBaseGlyph = false;
|
||||
constexpr int MIN_COMBINING_GAP_PX = 1;
|
||||
uint32_t cp;
|
||||
while ((cp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&string)))) {
|
||||
const EpdGlyph* glyph = getGlyph(cp);
|
||||
@@ -30,11 +35,30 @@ void EpdFont::getTextBounds(const char* string, const int startX, const int star
|
||||
continue;
|
||||
}
|
||||
|
||||
*minX = std::min(*minX, cursorX + glyph->left);
|
||||
*maxX = std::max(*maxX, cursorX + glyph->left + glyph->width);
|
||||
*minY = std::min(*minY, cursorY + glyph->top - glyph->height);
|
||||
*maxY = std::max(*maxY, cursorY + glyph->top);
|
||||
cursorX += glyph->advanceX;
|
||||
const bool isCombining = utf8IsCombiningMark(cp);
|
||||
int raiseBy = 0;
|
||||
if (isCombining && hasBaseGlyph) {
|
||||
const int currentGap = glyph->top - glyph->height - lastBaseTop;
|
||||
if (currentGap < MIN_COMBINING_GAP_PX) {
|
||||
raiseBy = MIN_COMBINING_GAP_PX - currentGap;
|
||||
}
|
||||
}
|
||||
|
||||
const int glyphBaseX = (isCombining && hasBaseGlyph) ? (lastBaseX + lastBaseAdvance / 2) : cursorX;
|
||||
const int glyphBaseY = cursorY - raiseBy;
|
||||
|
||||
*minX = std::min(*minX, glyphBaseX + glyph->left);
|
||||
*maxX = std::max(*maxX, glyphBaseX + glyph->left + glyph->width);
|
||||
*minY = std::min(*minY, glyphBaseY + glyph->top - glyph->height);
|
||||
*maxY = std::max(*maxY, glyphBaseY + glyph->top);
|
||||
|
||||
if (!isCombining) {
|
||||
lastBaseX = cursorX;
|
||||
lastBaseAdvance = glyph->advanceX;
|
||||
lastBaseTop = glyph->top;
|
||||
hasBaseGlyph = true;
|
||||
cursorX += glyph->advanceX;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -213,74 +213,69 @@ bool Epub::parseTocNavFile() const {
|
||||
}
|
||||
|
||||
void Epub::parseCssFiles() const {
|
||||
// Maximum CSS file size we'll attempt to parse (uncompressed)
|
||||
// Larger files risk memory exhaustion on ESP32
|
||||
constexpr size_t MAX_CSS_FILE_SIZE = 128 * 1024; // 128KB
|
||||
// Minimum heap required before attempting CSS parsing
|
||||
constexpr size_t MIN_HEAP_FOR_CSS_PARSING = 64 * 1024; // 64KB
|
||||
constexpr size_t MAX_CSS_FILE_SIZE = 128 * 1024;
|
||||
constexpr size_t MIN_HEAP_FOR_CSS_PARSING = 64 * 1024;
|
||||
|
||||
if (cssFiles.empty()) {
|
||||
LOG_DBG("EBP", "No CSS files to parse, but CssParser created for inline styles");
|
||||
}
|
||||
|
||||
// See if we have a cached version of the CSS rules
|
||||
if (!cssParser->hasCache()) {
|
||||
// No cache yet - parse CSS files
|
||||
for (const auto& cssPath : cssFiles) {
|
||||
LOG_DBG("EBP", "Parsing CSS file: %s", cssPath.c_str());
|
||||
LOG_DBG("EBP", "CSS files to parse: %zu", cssFiles.size());
|
||||
|
||||
// Check heap before parsing - CSS parsing allocates heavily
|
||||
const uint32_t freeHeap = ESP.getFreeHeap();
|
||||
if (freeHeap < MIN_HEAP_FOR_CSS_PARSING) {
|
||||
LOG_ERR("EBP", "Insufficient heap for CSS parsing (%u bytes free, need %zu), skipping: %s", freeHeap,
|
||||
MIN_HEAP_FOR_CSS_PARSING, cssPath.c_str());
|
||||
if (cssParser->hasCache()) {
|
||||
LOG_DBG("EBP", "CSS cache exists, skipping parseCssFiles");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const auto& cssPath : cssFiles) {
|
||||
LOG_DBG("EBP", "Parsing CSS file: %s", cssPath.c_str());
|
||||
|
||||
const uint32_t freeHeap = ESP.getFreeHeap();
|
||||
if (freeHeap < MIN_HEAP_FOR_CSS_PARSING) {
|
||||
LOG_ERR("EBP", "Insufficient heap for CSS parsing (%u bytes free, need %zu), skipping: %s", freeHeap,
|
||||
MIN_HEAP_FOR_CSS_PARSING, cssPath.c_str());
|
||||
continue;
|
||||
}
|
||||
|
||||
size_t cssFileSize = 0;
|
||||
if (getItemSize(cssPath, &cssFileSize)) {
|
||||
if (cssFileSize > MAX_CSS_FILE_SIZE) {
|
||||
LOG_ERR("EBP", "CSS file too large (%zu bytes > %zu max), skipping: %s", cssFileSize, MAX_CSS_FILE_SIZE,
|
||||
cssPath.c_str());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Check CSS file size before decompressing - skip files that are too large
|
||||
size_t cssFileSize = 0;
|
||||
if (getItemSize(cssPath, &cssFileSize)) {
|
||||
if (cssFileSize > MAX_CSS_FILE_SIZE) {
|
||||
LOG_ERR("EBP", "CSS file too large (%zu bytes > %zu max), skipping: %s", cssFileSize, MAX_CSS_FILE_SIZE,
|
||||
cssPath.c_str());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract CSS file to temp location
|
||||
const auto tmpCssPath = getCachePath() + "/.tmp.css";
|
||||
FsFile tempCssFile;
|
||||
if (!Storage.openFileForWrite("EBP", tmpCssPath, tempCssFile)) {
|
||||
LOG_ERR("EBP", "Could not create temp CSS file");
|
||||
continue;
|
||||
}
|
||||
if (!readItemContentsToStream(cssPath, tempCssFile, 1024)) {
|
||||
LOG_ERR("EBP", "Could not read CSS file: %s", cssPath.c_str());
|
||||
tempCssFile.close();
|
||||
Storage.remove(tmpCssPath.c_str());
|
||||
continue;
|
||||
}
|
||||
tempCssFile.close();
|
||||
|
||||
// Parse the CSS file
|
||||
if (!Storage.openFileForRead("EBP", tmpCssPath, tempCssFile)) {
|
||||
LOG_ERR("EBP", "Could not open temp CSS file for reading");
|
||||
Storage.remove(tmpCssPath.c_str());
|
||||
continue;
|
||||
}
|
||||
cssParser->loadFromStream(tempCssFile);
|
||||
const auto tmpCssPath = getCachePath() + "/.tmp.css";
|
||||
FsFile tempCssFile;
|
||||
if (!Storage.openFileForWrite("EBP", tmpCssPath, tempCssFile)) {
|
||||
LOG_ERR("EBP", "Could not create temp CSS file");
|
||||
continue;
|
||||
}
|
||||
if (!readItemContentsToStream(cssPath, tempCssFile, 1024)) {
|
||||
LOG_ERR("EBP", "Could not read CSS file: %s", cssPath.c_str());
|
||||
tempCssFile.close();
|
||||
Storage.remove(tmpCssPath.c_str());
|
||||
continue;
|
||||
}
|
||||
tempCssFile.close();
|
||||
|
||||
// Save to cache for next time
|
||||
if (!cssParser->saveToCache()) {
|
||||
LOG_ERR("EBP", "Failed to save CSS rules to cache");
|
||||
if (!Storage.openFileForRead("EBP", tmpCssPath, tempCssFile)) {
|
||||
LOG_ERR("EBP", "Could not open temp CSS file for reading");
|
||||
Storage.remove(tmpCssPath.c_str());
|
||||
continue;
|
||||
}
|
||||
cssParser->clear();
|
||||
|
||||
LOG_DBG("EBP", "Loaded %zu CSS style rules from %zu files", cssParser->ruleCount(), cssFiles.size());
|
||||
cssParser->loadFromStream(tempCssFile);
|
||||
tempCssFile.close();
|
||||
Storage.remove(tmpCssPath.c_str());
|
||||
}
|
||||
|
||||
if (!cssParser->saveToCache()) {
|
||||
LOG_ERR("EBP", "Failed to save CSS rules to cache");
|
||||
}
|
||||
cssParser->clear();
|
||||
|
||||
LOG_DBG("EBP", "Loaded %zu CSS style rules from %zu files", cssParser->ruleCount(), cssFiles.size());
|
||||
}
|
||||
|
||||
// load in the meta data for the epub file
|
||||
@@ -294,14 +289,17 @@ bool Epub::load(const bool buildIfMissing, const bool skipLoadingCss) {
|
||||
|
||||
// Try to load existing cache first
|
||||
if (bookMetadataCache->load()) {
|
||||
if (!skipLoadingCss && !cssParser->hasCache()) {
|
||||
LOG_DBG("EBP", "Warning: CSS rules cache not found, attempting to parse CSS files");
|
||||
// to get CSS file list
|
||||
if (!parseContentOpf(bookMetadataCache->coreMetadata)) {
|
||||
LOG_ERR("EBP", "Could not parse content.opf from cached bookMetadata for CSS files");
|
||||
// continue anyway - book will work without CSS and we'll still load any inline style CSS
|
||||
if (!skipLoadingCss) {
|
||||
if (!cssParser->hasCache() || !cssParser->loadFromCache()) {
|
||||
LOG_DBG("EBP", "CSS rules cache missing or stale, attempting to parse CSS files");
|
||||
cssParser->deleteCache();
|
||||
|
||||
if (!parseContentOpf(bookMetadataCache->coreMetadata)) {
|
||||
LOG_ERR("EBP", "Could not parse content.opf from cached bookMetadata for CSS files");
|
||||
}
|
||||
parseCssFiles();
|
||||
Storage.removeDir((cachePath + "/sections").c_str());
|
||||
}
|
||||
parseCssFiles();
|
||||
}
|
||||
LOG_DBG("EBP", "Loaded ePub: %s", filepath.c_str());
|
||||
return true;
|
||||
@@ -400,8 +398,8 @@ bool Epub::load(const bool buildIfMissing, const bool skipLoadingCss) {
|
||||
}
|
||||
|
||||
if (!skipLoadingCss) {
|
||||
// Parse CSS files after cache reload
|
||||
parseCssFiles();
|
||||
Storage.removeDir((cachePath + "/sections").c_str());
|
||||
}
|
||||
|
||||
LOG_DBG("EBP", "Loaded ePub: %s", filepath.c_str());
|
||||
|
||||
@@ -61,6 +61,49 @@ std::unique_ptr<PageImage> PageImage::deserialize(FsFile& file) {
|
||||
return std::unique_ptr<PageImage>(new PageImage(std::move(ib), xPos, yPos));
|
||||
}
|
||||
|
||||
bool PageImage::isCached() const { return imageBlock->isCached(); }
|
||||
|
||||
void PageImage::renderPlaceholder(GfxRenderer& renderer, const int xOffset, const int yOffset) const {
|
||||
int x = xPos + xOffset;
|
||||
int y = yPos + yOffset;
|
||||
int w = imageBlock->getWidth();
|
||||
int h = imageBlock->getHeight();
|
||||
renderer.fillRect(x, y, w, h, true);
|
||||
if (w > 2 && h > 2) {
|
||||
renderer.fillRect(x + 1, y + 1, w - 2, h - 2, false);
|
||||
}
|
||||
}
|
||||
|
||||
void Page::renderTextOnly(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) const {
|
||||
for (auto& element : elements) {
|
||||
if (element->getTag() == TAG_PageLine) {
|
||||
element->render(renderer, fontId, xOffset, yOffset);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int Page::countUncachedImages() const {
|
||||
int count = 0;
|
||||
for (auto& element : elements) {
|
||||
if (element->getTag() == TAG_PageImage) {
|
||||
auto* img = static_cast<PageImage*>(element.get());
|
||||
if (!img->isCached()) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
void Page::renderImagePlaceholders(GfxRenderer& renderer, const int xOffset, const int yOffset) const {
|
||||
for (auto& element : elements) {
|
||||
if (element->getTag() == TAG_PageImage) {
|
||||
auto* img = static_cast<PageImage*>(element.get());
|
||||
img->renderPlaceholder(renderer, xOffset, yOffset);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PageTableRow
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -80,6 +80,8 @@ class PageImage final : public PageElement {
|
||||
void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) override;
|
||||
bool serialize(FsFile& file) override;
|
||||
PageElementTag getTag() const override { return TAG_PageImage; }
|
||||
bool isCached() const;
|
||||
void renderPlaceholder(GfxRenderer& renderer, int xOffset, int yOffset) const;
|
||||
static std::unique_ptr<PageImage> deserialize(FsFile& file);
|
||||
|
||||
// Helper to get image block dimensions (needed for bounding box calculation)
|
||||
@@ -104,4 +106,8 @@ class Page {
|
||||
// Returns true if page has images and fills out the bounding box coordinates.
|
||||
// If no images, returns false.
|
||||
bool getImageBoundingBox(int& outX, int& outY, int& outWidth, int& outHeight) const;
|
||||
|
||||
void renderTextOnly(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) const;
|
||||
int countUncachedImages() const;
|
||||
void renderImagePlaceholders(GfxRenderer& renderer, int xOffset, int yOffset) const;
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstring>
|
||||
#include <functional>
|
||||
#include <limits>
|
||||
#include <vector>
|
||||
@@ -51,6 +52,80 @@ uint16_t measureWordWidth(const GfxRenderer& renderer, const int fontId, const s
|
||||
return renderer.getTextAdvanceX(fontId, sanitized.c_str(), style);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Direct-mapped word-width cache
|
||||
//
|
||||
// Avoids redundant getTextAdvanceX calls when the same (word, style, fontId)
|
||||
// triple appears across paragraphs. A fixed-size static array is used so
|
||||
// that heap allocation and fragmentation are both zero.
|
||||
//
|
||||
// Eviction policy: hash-direct mapping — a word always occupies the single
|
||||
// slot determined by its hash; a collision simply overwrites that slot.
|
||||
// This gives O(1) lookup (one hash + one memcmp) regardless of how full the
|
||||
// cache is, avoiding the O(n) linear-scan overhead that causes a regression
|
||||
// on corpora with many unique words (e.g. German compound-heavy text).
|
||||
//
|
||||
// Words longer than 23 bytes bypass the cache entirely — they are uncommon,
|
||||
// unlikely to repeat verbatim, and exceed the fixed-width key buffer.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct WordWidthCacheEntry {
|
||||
char word[24]; // NUL-terminated; 23 usable bytes + terminator
|
||||
int fontId;
|
||||
uint16_t width;
|
||||
uint8_t style; // EpdFontFamily::Style narrowed to one byte
|
||||
bool valid; // false = slot empty (BSS-initialised to 0)
|
||||
};
|
||||
|
||||
// Power-of-two size → slot selection via fast bitmask AND.
|
||||
// 128 entries × 32 bytes = 4 KB in BSS; covers typical paragraph vocabulary
|
||||
// with a low collision rate even for German compound-heavy prose.
|
||||
static constexpr uint32_t WORD_WIDTH_CACHE_SIZE = 128;
|
||||
static constexpr uint32_t WORD_WIDTH_CACHE_MASK = WORD_WIDTH_CACHE_SIZE - 1;
|
||||
static WordWidthCacheEntry s_wordWidthCache[WORD_WIDTH_CACHE_SIZE];
|
||||
|
||||
// FNV-1a over the word bytes, then XOR-folded with fontId and style.
|
||||
static uint32_t wordWidthCacheHash(const char* str, const size_t len, const int fontId, const uint8_t style) {
|
||||
uint32_t h = 2166136261u; // FNV offset basis
|
||||
for (size_t i = 0; i < len; ++i) {
|
||||
h ^= static_cast<uint8_t>(str[i]);
|
||||
h *= 16777619u; // FNV prime
|
||||
}
|
||||
h ^= static_cast<uint32_t>(fontId);
|
||||
h *= 16777619u;
|
||||
h ^= style;
|
||||
return h;
|
||||
}
|
||||
|
||||
// Returns the cached width for (word, style, fontId), measuring and caching
|
||||
// on a miss. Appending a hyphen is not supported — those measurements are
|
||||
// word-fragment lookups that will not repeat and must not pollute the cache.
|
||||
static uint16_t cachedMeasureWordWidth(const GfxRenderer& renderer, const int fontId, const std::string& word,
|
||||
const EpdFontFamily::Style style) {
|
||||
const size_t len = word.size();
|
||||
if (len >= 24) {
|
||||
return measureWordWidth(renderer, fontId, word, style);
|
||||
}
|
||||
|
||||
const uint8_t styleByte = static_cast<uint8_t>(style);
|
||||
const char* const wordCStr = word.c_str();
|
||||
|
||||
const uint32_t slot = wordWidthCacheHash(wordCStr, len, fontId, styleByte) & WORD_WIDTH_CACHE_MASK;
|
||||
auto& e = s_wordWidthCache[slot];
|
||||
|
||||
if (e.valid && e.fontId == fontId && e.style == styleByte && memcmp(e.word, wordCStr, len + 1) == 0) {
|
||||
return e.width; // O(1) cache hit
|
||||
}
|
||||
|
||||
const uint16_t w = measureWordWidth(renderer, fontId, word, style);
|
||||
memcpy(e.word, wordCStr, len + 1);
|
||||
e.fontId = fontId;
|
||||
e.width = w;
|
||||
e.style = styleByte;
|
||||
e.valid = true;
|
||||
return w;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle, const bool underline,
|
||||
@@ -100,6 +175,15 @@ void ParsedText::layoutAndExtractLines(const GfxRenderer& renderer, const int fo
|
||||
for (size_t i = 0; i < lineCount; ++i) {
|
||||
extractLine(i, pageWidth, spaceWidth, wordWidths, wordContinues, lineBreakIndices, processLine);
|
||||
}
|
||||
|
||||
// Remove consumed words so size() reflects only remaining words
|
||||
if (lineCount > 0) {
|
||||
const size_t consumed = lineBreakIndices[lineCount - 1];
|
||||
words.erase(words.begin(), words.begin() + consumed);
|
||||
wordStyles.erase(wordStyles.begin(), wordStyles.begin() + consumed);
|
||||
wordContinues.erase(wordContinues.begin(), wordContinues.begin() + consumed);
|
||||
forceBreakAfter.erase(forceBreakAfter.begin(), forceBreakAfter.begin() + consumed);
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<uint16_t> ParsedText::calculateWordWidths(const GfxRenderer& renderer, const int fontId) {
|
||||
@@ -107,7 +191,7 @@ std::vector<uint16_t> ParsedText::calculateWordWidths(const GfxRenderer& rendere
|
||||
wordWidths.reserve(words.size());
|
||||
|
||||
for (size_t i = 0; i < words.size(); ++i) {
|
||||
wordWidths.push_back(measureWordWidth(renderer, fontId, words[i], wordStyles[i]));
|
||||
wordWidths.push_back(cachedMeasureWordWidth(renderer, fontId, words[i], wordStyles[i]));
|
||||
}
|
||||
|
||||
return wordWidths;
|
||||
@@ -219,6 +303,7 @@ std::vector<size_t> ParsedText::computeLineBreaks(const GfxRenderer& renderer, c
|
||||
|
||||
// Stores the index of the word that starts the next line (last_word_index + 1)
|
||||
std::vector<size_t> lineBreakIndices;
|
||||
lineBreakIndices.reserve(totalWordCount / 8 + 1);
|
||||
size_t currentWordIndex = 0;
|
||||
|
||||
while (currentWordIndex < totalWordCount) {
|
||||
@@ -359,6 +444,9 @@ bool ParsedText::hyphenateWordAtIndex(const size_t wordIndex, const int availabl
|
||||
bool chosenNeedsHyphen = true;
|
||||
|
||||
// Iterate over each legal breakpoint and retain the widest prefix that still fits.
|
||||
// Re-use a single string buffer to avoid one heap allocation per candidate breakpoint.
|
||||
std::string prefix;
|
||||
prefix.reserve(word.size());
|
||||
for (const auto& info : breakInfos) {
|
||||
const size_t offset = info.byteOffset;
|
||||
if (offset == 0 || offset >= word.size()) {
|
||||
@@ -366,9 +454,15 @@ bool ParsedText::hyphenateWordAtIndex(const size_t wordIndex, const int availabl
|
||||
}
|
||||
|
||||
const bool needsHyphen = info.requiresInsertedHyphen;
|
||||
const int prefixWidth = measureWordWidth(renderer, fontId, word.substr(0, offset), style, needsHyphen);
|
||||
if (prefixWidth > availableWidth || prefixWidth <= chosenWidth) {
|
||||
continue; // Skip if too wide or not an improvement
|
||||
prefix.assign(word, 0, offset);
|
||||
const int prefixWidth = measureWordWidth(renderer, fontId, prefix, style, needsHyphen);
|
||||
if (prefixWidth > availableWidth) {
|
||||
// breakOffsets returns candidates in ascending byte-offset order, and prefix width is
|
||||
// non-decreasing with offset, so every subsequent candidate will also be too wide.
|
||||
break;
|
||||
}
|
||||
if (prefixWidth <= chosenWidth) {
|
||||
continue;
|
||||
}
|
||||
|
||||
chosenWidth = prefixWidth;
|
||||
@@ -392,11 +486,8 @@ bool ParsedText::hyphenateWordAtIndex(const size_t wordIndex, const int availabl
|
||||
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.
|
||||
const bool originalContinuedToNext = wordContinues[wordIndex];
|
||||
// The original word (now prefix) does NOT continue to remainder (hyphen separates them)
|
||||
wordContinues[wordIndex] = false;
|
||||
wordContinues.insert(wordContinues.begin() + wordIndex + 1, originalContinuedToNext);
|
||||
// Preserve the prefix's attach-to-previous flag; allow a break between prefix and remainder.
|
||||
wordContinues.insert(wordContinues.begin() + wordIndex + 1, false);
|
||||
|
||||
// Forced break belongs to the original whole word; transfer it to the remainder (last part).
|
||||
if (!forceBreakAfter.empty()) {
|
||||
|
||||
@@ -93,6 +93,11 @@ bool renderFromCache(GfxRenderer& renderer, const std::string& cachePath, int x,
|
||||
|
||||
} // namespace
|
||||
|
||||
bool ImageBlock::isCached() const {
|
||||
std::string cachePath = getCachePath(imagePath);
|
||||
return Storage.exists(cachePath.c_str());
|
||||
}
|
||||
|
||||
void ImageBlock::render(GfxRenderer& renderer, const int x, const int y) {
|
||||
LOG_DBG("IMG", "Rendering image at %d,%d: %s (%dx%d)", x, y, imagePath.c_str(), width, height);
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ class ImageBlock final : public Block {
|
||||
int16_t getHeight() const { return height; }
|
||||
|
||||
bool imageExists() const;
|
||||
bool isCached() const;
|
||||
|
||||
BlockType getType() override { return IMAGE_BLOCK; }
|
||||
bool isEmpty() override { return false; }
|
||||
|
||||
@@ -90,6 +90,32 @@ int32_t pngSeekWithHandle(PNGFILE* pFile, int32_t pos) {
|
||||
constexpr size_t PNG_DECODER_APPROX_SIZE = 44 * 1024; // ~42 KB + overhead
|
||||
constexpr size_t MIN_FREE_HEAP_FOR_PNG = PNG_DECODER_APPROX_SIZE + 16 * 1024; // decoder + 16 KB headroom
|
||||
|
||||
// PNGdec keeps TWO scanlines in its internal ucPixels buffer (current + previous)
|
||||
// and each scanline includes a leading filter byte.
|
||||
// Required storage is therefore approximately: 2 * (pitch + 1) + alignment slack.
|
||||
// If PNG_MAX_BUFFERED_PIXELS is smaller than this requirement for a given image,
|
||||
// PNGdec can overrun its internal buffer before our draw callback executes.
|
||||
int bytesPerPixelFromType(int pixelType) {
|
||||
switch (pixelType) {
|
||||
case PNG_PIXEL_TRUECOLOR:
|
||||
return 3;
|
||||
case PNG_PIXEL_GRAY_ALPHA:
|
||||
return 2;
|
||||
case PNG_PIXEL_TRUECOLOR_ALPHA:
|
||||
return 4;
|
||||
case PNG_PIXEL_GRAYSCALE:
|
||||
case PNG_PIXEL_INDEXED:
|
||||
default:
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
int requiredPngInternalBufferBytes(int srcWidth, int pixelType) {
|
||||
// +1 filter byte per scanline, *2 for current+previous lines, +32 for alignment margin.
|
||||
int pitch = srcWidth * bytesPerPixelFromType(pixelType);
|
||||
return ((pitch + 1) * 2) + 32;
|
||||
}
|
||||
|
||||
// Convert entire source line to grayscale with alpha blending to white background.
|
||||
// For indexed PNGs with tRNS chunk, alpha values are stored at palette[768] onwards.
|
||||
// Processing the whole line at once improves cache locality and reduces per-pixel overhead.
|
||||
@@ -304,6 +330,18 @@ bool PngToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath
|
||||
LOG_DBG("PNG", "PNG %dx%d -> %dx%d (scale %.2f), bpp: %d", ctx.srcWidth, ctx.srcHeight, ctx.dstWidth, ctx.dstHeight,
|
||||
ctx.scale, png->getBpp());
|
||||
|
||||
const int pixelType = png->getPixelType();
|
||||
const int requiredInternal = requiredPngInternalBufferBytes(ctx.srcWidth, pixelType);
|
||||
if (requiredInternal > PNG_MAX_BUFFERED_PIXELS) {
|
||||
LOG_ERR("PNG",
|
||||
"PNG row buffer too small: need %d bytes for width=%d type=%d, configured PNG_MAX_BUFFERED_PIXELS=%d",
|
||||
requiredInternal, ctx.srcWidth, pixelType, PNG_MAX_BUFFERED_PIXELS);
|
||||
LOG_ERR("PNG", "Aborting decode to avoid PNGdec internal buffer overflow");
|
||||
png->close();
|
||||
delete png;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (png->getBpp() != 8) {
|
||||
warnUnsupportedFeature("bit depth (" + std::to_string(png->getBpp()) + "bpp)", imagePath);
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ std::string CssParser::normalized(const std::string& s) {
|
||||
}
|
||||
|
||||
// Remove trailing space
|
||||
if (!result.empty() && result.back() == ' ') {
|
||||
while (!result.empty() && (result.back() == ' ' || result.back() == '\n')) {
|
||||
result.pop_back();
|
||||
}
|
||||
return result;
|
||||
@@ -189,10 +189,18 @@ CssTextDecoration CssParser::interpretDecoration(const std::string& val) {
|
||||
}
|
||||
|
||||
CssLength CssParser::interpretLength(const std::string& val) {
|
||||
const std::string v = normalized(val);
|
||||
if (v.empty()) return CssLength{};
|
||||
CssLength result;
|
||||
tryInterpretLength(val, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
bool CssParser::tryInterpretLength(const std::string& val, CssLength& out) {
|
||||
const std::string v = normalized(val);
|
||||
if (v.empty()) {
|
||||
out = CssLength{};
|
||||
return false;
|
||||
}
|
||||
|
||||
// Find where the number ends
|
||||
size_t unitStart = v.size();
|
||||
for (size_t i = 0; i < v.size(); ++i) {
|
||||
const char c = v[i];
|
||||
@@ -205,12 +213,13 @@ CssLength CssParser::interpretLength(const std::string& val) {
|
||||
const std::string numPart = v.substr(0, unitStart);
|
||||
const std::string unitPart = v.substr(unitStart);
|
||||
|
||||
// Parse numeric value
|
||||
char* endPtr = nullptr;
|
||||
const float numericValue = std::strtof(numPart.c_str(), &endPtr);
|
||||
if (endPtr == numPart.c_str()) return CssLength{}; // No number parsed
|
||||
if (endPtr == numPart.c_str()) {
|
||||
out = CssLength{};
|
||||
return false; // No number parsed (e.g. auto, inherit, initial)
|
||||
}
|
||||
|
||||
// Determine unit type (preserve for deferred resolution)
|
||||
auto unit = CssUnit::Pixels;
|
||||
if (unitPart == "em") {
|
||||
unit = CssUnit::Em;
|
||||
@@ -221,9 +230,9 @@ CssLength CssParser::interpretLength(const std::string& val) {
|
||||
} else if (unitPart == "%") {
|
||||
unit = CssUnit::Percent;
|
||||
}
|
||||
// px and unitless default to Pixels
|
||||
|
||||
return CssLength{numericValue, unit};
|
||||
out = CssLength{numericValue, unit};
|
||||
return true;
|
||||
}
|
||||
// Declaration parsing
|
||||
|
||||
@@ -295,9 +304,18 @@ void CssParser::parseDeclarationIntoStyle(const std::string& decl, CssStyle& sty
|
||||
style.defined.paddingTop = style.defined.paddingRight = style.defined.paddingBottom = style.defined.paddingLeft =
|
||||
1;
|
||||
}
|
||||
} else if (propNameBuf == "height") {
|
||||
CssLength len;
|
||||
if (tryInterpretLength(propValueBuf, len)) {
|
||||
style.imageHeight = len;
|
||||
style.defined.imageHeight = 1;
|
||||
}
|
||||
} else if (propNameBuf == "width") {
|
||||
style.width = interpretLength(propValueBuf);
|
||||
style.defined.width = 1;
|
||||
CssLength len;
|
||||
if (tryInterpretLength(propValueBuf, len)) {
|
||||
style.width = len;
|
||||
style.defined.width = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -346,6 +364,17 @@ void CssParser::processRuleBlockWithStyle(const std::string& selectorGroup, cons
|
||||
std::string key = normalized(sel);
|
||||
if (key.empty()) continue;
|
||||
|
||||
// Skip unsupported selector types to reduce memory usage.
|
||||
// We only match: tag, tag.class, .class
|
||||
if (key.find('+') != std::string::npos) continue; // adjacent sibling
|
||||
if (key.find('>') != std::string::npos) continue; // child combinator
|
||||
if (key.find('[') != std::string::npos) continue; // attribute selector
|
||||
if (key.find(':') != std::string::npos) continue; // pseudo selector
|
||||
if (key.find('#') != std::string::npos) continue; // ID selector
|
||||
if (key.find('~') != std::string::npos) continue; // general sibling
|
||||
if (key.find('*') != std::string::npos) continue; // wildcard
|
||||
if (key.find(' ') != std::string::npos) continue; // descendant combinator
|
||||
|
||||
// Skip if this would exceed the rule limit
|
||||
if (rulesBySelector_.size() >= MAX_RULES) {
|
||||
LOG_DBG("CSS", "Reached max rules limit, stopping selector processing");
|
||||
@@ -531,6 +560,7 @@ CssStyle CssParser::resolveStyle(const std::string& tagName, const std::string&
|
||||
result.applyOver(tagIt->second);
|
||||
}
|
||||
|
||||
// TODO: Support combinations of classes (e.g. style on .class1.class2)
|
||||
// 2. Apply class styles (medium priority)
|
||||
if (!classAttr.empty()) {
|
||||
const auto classes = splitWhitespace(classAttr);
|
||||
@@ -544,6 +574,7 @@ CssStyle CssParser::resolveStyle(const std::string& tagName, const std::string&
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Support combinations of classes (e.g. style on p.class1.class2)
|
||||
// 3. Apply element.class styles (higher priority)
|
||||
for (const auto& cls : classes) {
|
||||
std::string combinedKey = tag + "." + normalized(cls);
|
||||
@@ -564,12 +595,15 @@ CssStyle CssParser::parseInlineStyle(const std::string& styleValue) { return par
|
||||
|
||||
// Cache serialization
|
||||
|
||||
// Cache format version - increment when format changes
|
||||
constexpr uint8_t CSS_CACHE_VERSION = 2;
|
||||
// Cache file name (version is CssParser::CSS_CACHE_VERSION)
|
||||
constexpr char rulesCache[] = "/css_rules.cache";
|
||||
|
||||
bool CssParser::hasCache() const { return Storage.exists((cachePath + rulesCache).c_str()); }
|
||||
|
||||
void CssParser::deleteCache() const {
|
||||
if (hasCache()) Storage.remove((cachePath + rulesCache).c_str());
|
||||
}
|
||||
|
||||
bool CssParser::saveToCache() const {
|
||||
if (cachePath.empty()) {
|
||||
return false;
|
||||
@@ -581,7 +615,7 @@ bool CssParser::saveToCache() const {
|
||||
}
|
||||
|
||||
// Write version
|
||||
file.write(CSS_CACHE_VERSION);
|
||||
file.write(CssParser::CSS_CACHE_VERSION);
|
||||
|
||||
// Write rule count
|
||||
const auto ruleCount = static_cast<uint16_t>(rulesBySelector_.size());
|
||||
@@ -616,6 +650,8 @@ bool CssParser::saveToCache() const {
|
||||
writeLength(style.paddingBottom);
|
||||
writeLength(style.paddingLeft);
|
||||
writeLength(style.paddingRight);
|
||||
writeLength(style.imageHeight);
|
||||
writeLength(style.width);
|
||||
|
||||
// Write defined flags as uint16_t
|
||||
uint16_t definedBits = 0;
|
||||
@@ -632,6 +668,8 @@ bool CssParser::saveToCache() const {
|
||||
if (style.defined.paddingBottom) definedBits |= 1 << 10;
|
||||
if (style.defined.paddingLeft) definedBits |= 1 << 11;
|
||||
if (style.defined.paddingRight) definedBits |= 1 << 12;
|
||||
if (style.defined.width) definedBits |= 1 << 13;
|
||||
if (style.defined.imageHeight) definedBits |= 1 << 14;
|
||||
file.write(reinterpret_cast<const uint8_t*>(&definedBits), sizeof(definedBits));
|
||||
}
|
||||
|
||||
@@ -655,9 +693,11 @@ bool CssParser::loadFromCache() {
|
||||
|
||||
// Read and verify version
|
||||
uint8_t version = 0;
|
||||
if (file.read(&version, 1) != 1 || version != CSS_CACHE_VERSION) {
|
||||
LOG_DBG("CSS", "Cache version mismatch (got %u, expected %u)", version, CSS_CACHE_VERSION);
|
||||
if (file.read(&version, 1) != 1 || version != CssParser::CSS_CACHE_VERSION) {
|
||||
LOG_DBG("CSS", "Cache version mismatch (got %u, expected %u), removing stale cache for rebuild", version,
|
||||
CssParser::CSS_CACHE_VERSION);
|
||||
file.close();
|
||||
Storage.remove((cachePath + rulesCache).c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -733,7 +773,8 @@ bool CssParser::loadFromCache() {
|
||||
|
||||
if (!readLength(style.textIndent) || !readLength(style.marginTop) || !readLength(style.marginBottom) ||
|
||||
!readLength(style.marginLeft) || !readLength(style.marginRight) || !readLength(style.paddingTop) ||
|
||||
!readLength(style.paddingBottom) || !readLength(style.paddingLeft) || !readLength(style.paddingRight)) {
|
||||
!readLength(style.paddingBottom) || !readLength(style.paddingLeft) || !readLength(style.paddingRight) ||
|
||||
!readLength(style.imageHeight) || !readLength(style.width)) {
|
||||
rulesBySelector_.clear();
|
||||
file.close();
|
||||
return false;
|
||||
@@ -759,6 +800,8 @@ bool CssParser::loadFromCache() {
|
||||
style.defined.paddingBottom = (definedBits & 1 << 10) != 0;
|
||||
style.defined.paddingLeft = (definedBits & 1 << 11) != 0;
|
||||
style.defined.paddingRight = (definedBits & 1 << 12) != 0;
|
||||
style.defined.width = (definedBits & 1 << 13) != 0;
|
||||
style.defined.imageHeight = (definedBits & 1 << 14) != 0;
|
||||
|
||||
rulesBySelector_[selector] = style;
|
||||
}
|
||||
|
||||
@@ -82,6 +82,11 @@ class CssParser {
|
||||
*/
|
||||
bool hasCache() const;
|
||||
|
||||
/**
|
||||
* Delete CSS rules cache file if it exists
|
||||
*/
|
||||
void deleteCache() const;
|
||||
|
||||
/**
|
||||
* Save parsed CSS rules to a cache file.
|
||||
* @return true if cache was written successfully
|
||||
@@ -91,10 +96,14 @@ class CssParser {
|
||||
/**
|
||||
* Load CSS rules from a cache file.
|
||||
* Clears any existing rules before loading.
|
||||
* Removes stale cache file on version mismatch.
|
||||
* @return true if cache was loaded successfully
|
||||
*/
|
||||
bool loadFromCache();
|
||||
|
||||
// Bump when CSS cache format or rules change; section caches are invalidated when this changes
|
||||
static constexpr uint8_t CSS_CACHE_VERSION = 3;
|
||||
|
||||
private:
|
||||
// Storage: maps normalized selector -> style properties
|
||||
std::unordered_map<std::string, CssStyle> rulesBySelector_;
|
||||
@@ -113,6 +122,7 @@ class CssParser {
|
||||
static CssFontWeight interpretFontWeight(const std::string& val);
|
||||
static CssTextDecoration interpretDecoration(const std::string& val);
|
||||
static CssLength interpretLength(const std::string& val);
|
||||
static bool tryInterpretLength(const std::string& val, CssLength& out);
|
||||
|
||||
// String utilities
|
||||
static std::string normalized(const std::string& s);
|
||||
|
||||
@@ -70,6 +70,7 @@ struct CssPropertyFlags {
|
||||
uint16_t paddingLeft : 1;
|
||||
uint16_t paddingRight : 1;
|
||||
uint16_t width : 1;
|
||||
uint16_t imageHeight : 1;
|
||||
|
||||
CssPropertyFlags()
|
||||
: textAlign(0),
|
||||
@@ -85,18 +86,20 @@ struct CssPropertyFlags {
|
||||
paddingBottom(0),
|
||||
paddingLeft(0),
|
||||
paddingRight(0),
|
||||
width(0) {}
|
||||
width(0),
|
||||
imageHeight(0) {}
|
||||
|
||||
[[nodiscard]] bool anySet() const {
|
||||
return textAlign || fontStyle || fontWeight || textDecoration || textIndent || marginTop || marginBottom ||
|
||||
marginLeft || marginRight || paddingTop || paddingBottom || paddingLeft || paddingRight || width;
|
||||
marginLeft || marginRight || paddingTop || paddingBottom || paddingLeft || paddingRight || width ||
|
||||
imageHeight;
|
||||
}
|
||||
|
||||
void clearAll() {
|
||||
textAlign = fontStyle = fontWeight = textDecoration = textIndent = 0;
|
||||
marginTop = marginBottom = marginLeft = marginRight = 0;
|
||||
paddingTop = paddingBottom = paddingLeft = paddingRight = 0;
|
||||
width = 0;
|
||||
width = imageHeight = 0;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -118,7 +121,8 @@ struct CssStyle {
|
||||
CssLength paddingBottom; // Padding after
|
||||
CssLength paddingLeft; // Padding left
|
||||
CssLength paddingRight; // Padding right
|
||||
CssLength width; // Element width (used for table columns/cells)
|
||||
CssLength width; // Element width (used for table columns/cells and image sizing)
|
||||
CssLength imageHeight; // Height for img (e.g. 2em) -- width derived from aspect ratio when only height set
|
||||
|
||||
CssPropertyFlags defined; // Tracks which properties were explicitly set
|
||||
|
||||
@@ -181,6 +185,10 @@ struct CssStyle {
|
||||
width = base.width;
|
||||
defined.width = 1;
|
||||
}
|
||||
if (base.hasImageHeight()) {
|
||||
imageHeight = base.imageHeight;
|
||||
defined.imageHeight = 1;
|
||||
}
|
||||
}
|
||||
|
||||
[[nodiscard]] bool hasTextAlign() const { return defined.textAlign; }
|
||||
@@ -197,6 +205,7 @@ struct CssStyle {
|
||||
[[nodiscard]] bool hasPaddingLeft() const { return defined.paddingLeft; }
|
||||
[[nodiscard]] bool hasPaddingRight() const { return defined.paddingRight; }
|
||||
[[nodiscard]] bool hasWidth() const { return defined.width; }
|
||||
[[nodiscard]] bool hasImageHeight() const { return defined.imageHeight; }
|
||||
|
||||
void reset() {
|
||||
textAlign = CssTextAlign::Left;
|
||||
@@ -207,6 +216,7 @@ struct CssStyle {
|
||||
marginTop = marginBottom = marginLeft = marginRight = CssLength{};
|
||||
paddingTop = paddingBottom = paddingLeft = paddingRight = CssLength{};
|
||||
width = CssLength{};
|
||||
imageHeight = CssLength{};
|
||||
defined.clearAll();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -106,6 +106,7 @@ bool isAsciiDigit(const uint32_t cp) { return cp >= '0' && cp <= '9'; }
|
||||
|
||||
bool isExplicitHyphen(const uint32_t cp) {
|
||||
switch (cp) {
|
||||
case '/':
|
||||
case '-':
|
||||
case 0x00AD: // soft hyphen
|
||||
case 0x058A: // Armenian hyphen
|
||||
@@ -174,6 +175,213 @@ std::vector<CodepointInfo> collectCodepoints(const std::string& word) {
|
||||
while (*ptr != 0) {
|
||||
const unsigned char* current = ptr;
|
||||
const uint32_t cp = utf8NextCodepoint(&ptr);
|
||||
// If this is a combining diacritic (e.g., U+0301 = acute) and there's
|
||||
// a previous base character that can be composed into a single
|
||||
// precomposed Unicode scalar (Latin-1 / Latin-Extended), do that
|
||||
// composition here. This provides lightweight NFC-like behavior for
|
||||
// common Western European diacritics (acute, grave, circumflex, tilde,
|
||||
// diaeresis, cedilla) without pulling in a full Unicode normalization
|
||||
// library.
|
||||
if (!cps.empty()) {
|
||||
uint32_t prev = cps.back().value;
|
||||
uint32_t composed = 0;
|
||||
switch (cp) {
|
||||
case 0x0300: // grave
|
||||
switch (prev) {
|
||||
case 0x0041:
|
||||
composed = 0x00C0;
|
||||
break; // A -> À
|
||||
case 0x0061:
|
||||
composed = 0x00E0;
|
||||
break; // a -> à
|
||||
case 0x0045:
|
||||
composed = 0x00C8;
|
||||
break; // E -> È
|
||||
case 0x0065:
|
||||
composed = 0x00E8;
|
||||
break; // e -> è
|
||||
case 0x0049:
|
||||
composed = 0x00CC;
|
||||
break; // I -> Ì
|
||||
case 0x0069:
|
||||
composed = 0x00EC;
|
||||
break; // i -> ì
|
||||
case 0x004F:
|
||||
composed = 0x00D2;
|
||||
break; // O -> Ò
|
||||
case 0x006F:
|
||||
composed = 0x00F2;
|
||||
break; // o -> ò
|
||||
case 0x0055:
|
||||
composed = 0x00D9;
|
||||
break; // U -> Ù
|
||||
case 0x0075:
|
||||
composed = 0x00F9;
|
||||
break; // u -> ù
|
||||
default:
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case 0x0301: // acute
|
||||
switch (prev) {
|
||||
case 0x0041:
|
||||
composed = 0x00C1;
|
||||
break; // A -> Á
|
||||
case 0x0061:
|
||||
composed = 0x00E1;
|
||||
break; // a -> á
|
||||
case 0x0045:
|
||||
composed = 0x00C9;
|
||||
break; // E -> É
|
||||
case 0x0065:
|
||||
composed = 0x00E9;
|
||||
break; // e -> é
|
||||
case 0x0049:
|
||||
composed = 0x00CD;
|
||||
break; // I -> Í
|
||||
case 0x0069:
|
||||
composed = 0x00ED;
|
||||
break; // i -> í
|
||||
case 0x004F:
|
||||
composed = 0x00D3;
|
||||
break; // O -> Ó
|
||||
case 0x006F:
|
||||
composed = 0x00F3;
|
||||
break; // o -> ó
|
||||
case 0x0055:
|
||||
composed = 0x00DA;
|
||||
break; // U -> Ú
|
||||
case 0x0075:
|
||||
composed = 0x00FA;
|
||||
break; // u -> ú
|
||||
case 0x0059:
|
||||
composed = 0x00DD;
|
||||
break; // Y -> Ý
|
||||
case 0x0079:
|
||||
composed = 0x00FD;
|
||||
break; // y -> ý
|
||||
default:
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case 0x0302: // circumflex
|
||||
switch (prev) {
|
||||
case 0x0041:
|
||||
composed = 0x00C2;
|
||||
break; // A -> Â
|
||||
case 0x0061:
|
||||
composed = 0x00E2;
|
||||
break; // a -> â
|
||||
case 0x0045:
|
||||
composed = 0x00CA;
|
||||
break; // E -> Ê
|
||||
case 0x0065:
|
||||
composed = 0x00EA;
|
||||
break; // e -> ê
|
||||
case 0x0049:
|
||||
composed = 0x00CE;
|
||||
break; // I -> Î
|
||||
case 0x0069:
|
||||
composed = 0x00EE;
|
||||
break; // i -> î
|
||||
case 0x004F:
|
||||
composed = 0x00D4;
|
||||
break; // O -> Ô
|
||||
case 0x006F:
|
||||
composed = 0x00F4;
|
||||
break; // o -> ô
|
||||
case 0x0055:
|
||||
composed = 0x00DB;
|
||||
break; // U -> Û
|
||||
case 0x0075:
|
||||
composed = 0x00FB;
|
||||
break; // u -> û
|
||||
default:
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case 0x0303: // tilde
|
||||
switch (prev) {
|
||||
case 0x0041:
|
||||
composed = 0x00C3;
|
||||
break; // A -> Ã
|
||||
case 0x0061:
|
||||
composed = 0x00E3;
|
||||
break; // a -> ã
|
||||
case 0x004E:
|
||||
composed = 0x00D1;
|
||||
break; // N -> Ñ
|
||||
case 0x006E:
|
||||
composed = 0x00F1;
|
||||
break; // n -> ñ
|
||||
default:
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case 0x0308: // diaeresis/umlaut
|
||||
switch (prev) {
|
||||
case 0x0041:
|
||||
composed = 0x00C4;
|
||||
break; // A -> Ä
|
||||
case 0x0061:
|
||||
composed = 0x00E4;
|
||||
break; // a -> ä
|
||||
case 0x0045:
|
||||
composed = 0x00CB;
|
||||
break; // E -> Ë
|
||||
case 0x0065:
|
||||
composed = 0x00EB;
|
||||
break; // e -> ë
|
||||
case 0x0049:
|
||||
composed = 0x00CF;
|
||||
break; // I -> Ï
|
||||
case 0x0069:
|
||||
composed = 0x00EF;
|
||||
break; // i -> ï
|
||||
case 0x004F:
|
||||
composed = 0x00D6;
|
||||
break; // O -> Ö
|
||||
case 0x006F:
|
||||
composed = 0x00F6;
|
||||
break; // o -> ö
|
||||
case 0x0055:
|
||||
composed = 0x00DC;
|
||||
break; // U -> Ü
|
||||
case 0x0075:
|
||||
composed = 0x00FC;
|
||||
break; // u -> ü
|
||||
case 0x0059:
|
||||
composed = 0x0178;
|
||||
break; // Y -> Ÿ
|
||||
case 0x0079:
|
||||
composed = 0x00FF;
|
||||
break; // y -> ÿ
|
||||
default:
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case 0x0327: // cedilla
|
||||
switch (prev) {
|
||||
case 0x0043:
|
||||
composed = 0x00C7;
|
||||
break; // C -> Ç
|
||||
case 0x0063:
|
||||
composed = 0x00E7;
|
||||
break; // c -> ç
|
||||
default:
|
||||
break;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if (composed != 0) {
|
||||
cps.back().value = composed;
|
||||
continue; // skip pushing the combining mark itself
|
||||
}
|
||||
}
|
||||
|
||||
cps.push_back({cp, static_cast<size_t>(current - base)});
|
||||
}
|
||||
|
||||
|
||||
@@ -35,13 +35,17 @@ size_t byteOffsetForIndex(const std::vector<CodepointInfo>& cps, const size_t in
|
||||
std::vector<Hyphenator::BreakInfo> buildExplicitBreakInfos(const std::vector<CodepointInfo>& cps) {
|
||||
std::vector<Hyphenator::BreakInfo> breaks;
|
||||
|
||||
// Scan every codepoint looking for explicit/soft hyphen markers that are surrounded by letters.
|
||||
for (size_t i = 1; i + 1 < cps.size(); ++i) {
|
||||
const uint32_t cp = cps[i].value;
|
||||
if (!isExplicitHyphen(cp) || !isAlphabetic(cps[i - 1].value) || !isAlphabetic(cps[i + 1].value)) {
|
||||
if (!isExplicitHyphen(cp)) {
|
||||
continue;
|
||||
}
|
||||
if ((cp == '/' || cp == '-') && cps[i + 1].value == cp) {
|
||||
continue;
|
||||
}
|
||||
if (cp != '/' && cp != '-' && (!isAlphabetic(cps[i - 1].value) || !isAlphabetic(cps[i + 1].value))) {
|
||||
continue;
|
||||
}
|
||||
// Offset points to the next codepoint so rendering starts after the hyphen marker.
|
||||
breaks.push_back({cps[i + 1].byteOffset, isSoftHyphen(cp)});
|
||||
}
|
||||
|
||||
|
||||
@@ -418,18 +418,85 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
||||
if (decoder->getDimensions(cachedImagePath, dims)) {
|
||||
LOG_DBG("EHP", "Image dimensions: %dx%d", dims.width, dims.height);
|
||||
|
||||
// Scale to fit viewport while maintaining aspect ratio
|
||||
int maxWidth = self->viewportWidth;
|
||||
int maxHeight = self->viewportHeight;
|
||||
float scaleX = (dims.width > maxWidth) ? (float)maxWidth / dims.width : 1.0f;
|
||||
float scaleY = (dims.height > maxHeight) ? (float)maxHeight / dims.height : 1.0f;
|
||||
float scale = (scaleX < scaleY) ? scaleX : scaleY;
|
||||
if (scale > 1.0f) scale = 1.0f;
|
||||
int displayWidth = 0;
|
||||
int displayHeight = 0;
|
||||
const float emSize =
|
||||
static_cast<float>(self->renderer.getLineHeight(self->fontId)) * self->lineCompression;
|
||||
CssStyle imgStyle = self->cssParser ? self->cssParser->resolveStyle("img", classAttr) : CssStyle{};
|
||||
if (!styleAttr.empty()) {
|
||||
imgStyle.applyOver(CssParser::parseInlineStyle(styleAttr));
|
||||
}
|
||||
const bool hasCssHeight = imgStyle.hasImageHeight();
|
||||
const bool hasCssWidth = imgStyle.hasWidth();
|
||||
|
||||
int displayWidth = (int)(dims.width * scale);
|
||||
int displayHeight = (int)(dims.height * scale);
|
||||
if (hasCssHeight && hasCssWidth && dims.width > 0 && dims.height > 0) {
|
||||
displayHeight = static_cast<int>(
|
||||
imgStyle.imageHeight.toPixels(emSize, static_cast<float>(self->viewportHeight)) + 0.5f);
|
||||
displayWidth = static_cast<int>(
|
||||
imgStyle.width.toPixels(emSize, static_cast<float>(self->viewportWidth)) + 0.5f);
|
||||
if (displayHeight < 1) displayHeight = 1;
|
||||
if (displayWidth < 1) displayWidth = 1;
|
||||
if (displayWidth > self->viewportWidth || displayHeight > self->viewportHeight) {
|
||||
float scaleX = (displayWidth > self->viewportWidth)
|
||||
? static_cast<float>(self->viewportWidth) / displayWidth
|
||||
: 1.0f;
|
||||
float scaleY = (displayHeight > self->viewportHeight)
|
||||
? static_cast<float>(self->viewportHeight) / displayHeight
|
||||
: 1.0f;
|
||||
float scale = (scaleX < scaleY) ? scaleX : scaleY;
|
||||
displayWidth = static_cast<int>(displayWidth * scale + 0.5f);
|
||||
displayHeight = static_cast<int>(displayHeight * scale + 0.5f);
|
||||
if (displayWidth < 1) displayWidth = 1;
|
||||
if (displayHeight < 1) displayHeight = 1;
|
||||
}
|
||||
LOG_DBG("EHP", "Display size from CSS height+width: %dx%d", displayWidth, displayHeight);
|
||||
} else if (hasCssHeight && !hasCssWidth && dims.width > 0 && dims.height > 0) {
|
||||
displayHeight = static_cast<int>(
|
||||
imgStyle.imageHeight.toPixels(emSize, static_cast<float>(self->viewportHeight)) + 0.5f);
|
||||
if (displayHeight < 1) displayHeight = 1;
|
||||
displayWidth =
|
||||
static_cast<int>(displayHeight * (static_cast<float>(dims.width) / dims.height) + 0.5f);
|
||||
if (displayHeight > self->viewportHeight) {
|
||||
displayHeight = self->viewportHeight;
|
||||
displayWidth =
|
||||
static_cast<int>(displayHeight * (static_cast<float>(dims.width) / dims.height) + 0.5f);
|
||||
if (displayWidth < 1) displayWidth = 1;
|
||||
}
|
||||
if (displayWidth > self->viewportWidth) {
|
||||
displayWidth = self->viewportWidth;
|
||||
displayHeight =
|
||||
static_cast<int>(displayWidth * (static_cast<float>(dims.height) / dims.width) + 0.5f);
|
||||
if (displayHeight < 1) displayHeight = 1;
|
||||
}
|
||||
if (displayWidth < 1) displayWidth = 1;
|
||||
LOG_DBG("EHP", "Display size from CSS height: %dx%d", displayWidth, displayHeight);
|
||||
} else if (hasCssWidth && !hasCssHeight && dims.width > 0 && dims.height > 0) {
|
||||
displayWidth = static_cast<int>(
|
||||
imgStyle.width.toPixels(emSize, static_cast<float>(self->viewportWidth)) + 0.5f);
|
||||
if (displayWidth > self->viewportWidth) displayWidth = self->viewportWidth;
|
||||
if (displayWidth < 1) displayWidth = 1;
|
||||
displayHeight =
|
||||
static_cast<int>(displayWidth * (static_cast<float>(dims.height) / dims.width) + 0.5f);
|
||||
if (displayHeight > self->viewportHeight) {
|
||||
displayHeight = self->viewportHeight;
|
||||
displayWidth =
|
||||
static_cast<int>(displayHeight * (static_cast<float>(dims.width) / dims.height) + 0.5f);
|
||||
if (displayWidth < 1) displayWidth = 1;
|
||||
}
|
||||
if (displayHeight < 1) displayHeight = 1;
|
||||
LOG_DBG("EHP", "Display size from CSS width: %dx%d", displayWidth, displayHeight);
|
||||
} else {
|
||||
int maxWidth = self->viewportWidth;
|
||||
int maxHeight = self->viewportHeight;
|
||||
float scaleX = (dims.width > maxWidth) ? (float)maxWidth / dims.width : 1.0f;
|
||||
float scaleY = (dims.height > maxHeight) ? (float)maxHeight / dims.height : 1.0f;
|
||||
float scale = (scaleX < scaleY) ? scaleX : scaleY;
|
||||
if (scale > 1.0f) scale = 1.0f;
|
||||
|
||||
LOG_DBG("EHP", "Display size: %dx%d (scale %.2f)", displayWidth, displayHeight, scale);
|
||||
displayWidth = (int)(dims.width * scale);
|
||||
displayHeight = (int)(dims.height * scale);
|
||||
LOG_DBG("EHP", "Display size: %dx%d (scale %.2f)", displayWidth, displayHeight, scale);
|
||||
}
|
||||
|
||||
// Create page for image - only break if image won't fit remaining space
|
||||
if (self->currentPage && !self->currentPage->elements.empty() &&
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
#include <Logging.h>
|
||||
#include <Utf8.h>
|
||||
|
||||
#include <cstring>
|
||||
|
||||
const uint8_t* GfxRenderer::getGlyphBitmap(const EpdFontData* fontData, const EpdGlyph* glyph) const {
|
||||
if (fontData->groups != nullptr) {
|
||||
if (!fontDecompressor) {
|
||||
@@ -59,6 +61,132 @@ static inline void rotateCoordinates(const GfxRenderer::Orientation orientation,
|
||||
}
|
||||
}
|
||||
|
||||
enum class TextRotation { None, Rotated90CW, Rotated90CCW };
|
||||
|
||||
// Shared glyph rendering logic for normal and rotated text.
|
||||
// Coordinate mapping and cursor advance direction are selected at compile time via the template parameter.
|
||||
template <TextRotation rotation>
|
||||
static void renderCharImpl(const GfxRenderer& renderer, GfxRenderer::RenderMode renderMode,
|
||||
const EpdFontFamily& fontFamily, const uint32_t cp, int* cursorX, int* cursorY,
|
||||
const bool pixelState, const EpdFontFamily::Style style) {
|
||||
const EpdGlyph* glyph = fontFamily.getGlyph(cp, style);
|
||||
if (!glyph) {
|
||||
glyph = fontFamily.getGlyph(REPLACEMENT_GLYPH, style);
|
||||
}
|
||||
|
||||
if (!glyph) {
|
||||
LOG_ERR("GFX", "No glyph for codepoint %d", cp);
|
||||
return;
|
||||
}
|
||||
|
||||
const EpdFontData* fontData = fontFamily.getData(style);
|
||||
const bool is2Bit = fontData->is2Bit;
|
||||
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 = renderer.getGlyphBitmap(fontData, glyph);
|
||||
|
||||
if (bitmap != nullptr) {
|
||||
int outerBase, innerBase;
|
||||
if constexpr (rotation == TextRotation::Rotated90CW) {
|
||||
outerBase = *cursorX + fontData->ascender - top; // screenX = outerBase + glyphY
|
||||
innerBase = *cursorY - left; // screenY = innerBase - glyphX
|
||||
} else if constexpr (rotation == TextRotation::Rotated90CCW) {
|
||||
outerBase = *cursorX + fontData->advanceY - 1 - fontData->ascender + top; // screenX = outerBase - glyphY
|
||||
innerBase = *cursorY + left; // screenY = innerBase + glyphX
|
||||
} else {
|
||||
outerBase = *cursorY - top; // screenY = outerBase + glyphY
|
||||
innerBase = *cursorX + left; // screenX = innerBase + glyphX
|
||||
}
|
||||
|
||||
if (is2Bit) {
|
||||
int pixelPosition = 0;
|
||||
for (int glyphY = 0; glyphY < height; glyphY++) {
|
||||
int outerCoord;
|
||||
if constexpr (rotation == TextRotation::Rotated90CCW) {
|
||||
outerCoord = outerBase - glyphY;
|
||||
} else {
|
||||
outerCoord = outerBase + glyphY;
|
||||
}
|
||||
for (int glyphX = 0; glyphX < width; glyphX++, pixelPosition++) {
|
||||
int screenX, screenY;
|
||||
if constexpr (rotation == TextRotation::Rotated90CW) {
|
||||
screenX = outerCoord;
|
||||
screenY = innerBase - glyphX;
|
||||
} else if constexpr (rotation == TextRotation::Rotated90CCW) {
|
||||
screenX = outerCoord;
|
||||
screenY = innerBase + glyphX;
|
||||
} else {
|
||||
screenX = innerBase + glyphX;
|
||||
screenY = outerCoord;
|
||||
}
|
||||
|
||||
const uint8_t byte = bitmap[pixelPosition >> 2];
|
||||
const uint8_t bit_index = (3 - (pixelPosition & 3)) * 2;
|
||||
// the direct bit from the font is 0 -> white, 1 -> light gray, 2 -> dark gray, 3 -> black
|
||||
// we swap this to better match the way images and screen think about colors:
|
||||
// 0 -> black, 1 -> dark grey, 2 -> light grey, 3 -> white
|
||||
const uint8_t bmpVal = 3 - ((byte >> bit_index) & 0x3);
|
||||
|
||||
if (renderMode == GfxRenderer::BW && bmpVal < 3) {
|
||||
// Black (also paints over the grays in BW mode)
|
||||
renderer.drawPixel(screenX, screenY, pixelState);
|
||||
} else if (renderMode == GfxRenderer::GRAYSCALE_MSB && (bmpVal == 1 || bmpVal == 2)) {
|
||||
// Light gray (also mark the MSB if it's going to be a dark gray too)
|
||||
// We have to flag pixels in reverse for the gray buffers, as 0 leave alone, 1 update
|
||||
renderer.drawPixel(screenX, screenY, false);
|
||||
} else if (renderMode == GfxRenderer::GRAYSCALE_LSB && bmpVal == 1) {
|
||||
// Dark gray
|
||||
renderer.drawPixel(screenX, screenY, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
int pixelPosition = 0;
|
||||
for (int glyphY = 0; glyphY < height; glyphY++) {
|
||||
int outerCoord;
|
||||
if constexpr (rotation == TextRotation::Rotated90CCW) {
|
||||
outerCoord = outerBase - glyphY;
|
||||
} else {
|
||||
outerCoord = outerBase + glyphY;
|
||||
}
|
||||
for (int glyphX = 0; glyphX < width; glyphX++, pixelPosition++) {
|
||||
int screenX, screenY;
|
||||
if constexpr (rotation == TextRotation::Rotated90CW) {
|
||||
screenX = outerCoord;
|
||||
screenY = innerBase - glyphX;
|
||||
} else if constexpr (rotation == TextRotation::Rotated90CCW) {
|
||||
screenX = outerCoord;
|
||||
screenY = innerBase + glyphX;
|
||||
} else {
|
||||
screenX = innerBase + glyphX;
|
||||
screenY = outerCoord;
|
||||
}
|
||||
|
||||
const uint8_t byte = bitmap[pixelPosition >> 3];
|
||||
const uint8_t bit_index = 7 - (pixelPosition & 7);
|
||||
|
||||
if ((byte >> bit_index) & 1) {
|
||||
renderer.drawPixel(screenX, screenY, pixelState);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!utf8IsCombiningMark(cp)) {
|
||||
if constexpr (rotation == TextRotation::Rotated90CW) {
|
||||
*cursorY -= glyph->advanceX;
|
||||
} else if constexpr (rotation == TextRotation::Rotated90CCW) {
|
||||
*cursorY += glyph->advanceX;
|
||||
} else {
|
||||
*cursorX += glyph->advanceX;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// IMPORTANT: This function is in critical rendering path and is called for every pixel. Please keep it as simple and
|
||||
// efficient as possible.
|
||||
void GfxRenderer::drawPixel(const int x, const int y, const bool state) const {
|
||||
@@ -115,8 +243,13 @@ void GfxRenderer::drawCenteredText(const int fontId, const int y, const char* te
|
||||
|
||||
void GfxRenderer::drawText(const int fontId, const int x, const int y, const char* text, const bool black,
|
||||
const EpdFontFamily::Style style) const {
|
||||
const int yPos = y + getFontAscenderSize(fontId);
|
||||
int yPos = y + getFontAscenderSize(fontId);
|
||||
int xpos = x;
|
||||
int lastBaseX = x;
|
||||
int lastBaseY = yPos;
|
||||
int lastBaseAdvance = 0;
|
||||
int lastBaseTop = 0;
|
||||
bool hasBaseGlyph = false;
|
||||
|
||||
// cannot draw a NULL / empty string
|
||||
if (text == nullptr || *text == '\0') {
|
||||
@@ -129,9 +262,43 @@ void GfxRenderer::drawText(const int fontId, const int x, const int y, const cha
|
||||
return;
|
||||
}
|
||||
const auto& font = fontIt->second;
|
||||
constexpr int MIN_COMBINING_GAP_PX = 1;
|
||||
|
||||
uint32_t cp;
|
||||
while ((cp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&text)))) {
|
||||
if (utf8IsCombiningMark(cp) && hasBaseGlyph) {
|
||||
const EpdGlyph* combiningGlyph = font.getGlyph(cp, style);
|
||||
if (!combiningGlyph) {
|
||||
combiningGlyph = font.getGlyph(REPLACEMENT_GLYPH, style);
|
||||
}
|
||||
|
||||
int raiseBy = 0;
|
||||
if (combiningGlyph) {
|
||||
const int currentGap = combiningGlyph->top - combiningGlyph->height - lastBaseTop;
|
||||
if (currentGap < MIN_COMBINING_GAP_PX) {
|
||||
raiseBy = MIN_COMBINING_GAP_PX - currentGap;
|
||||
}
|
||||
}
|
||||
|
||||
int combiningX = lastBaseX + lastBaseAdvance / 2;
|
||||
int combiningY = lastBaseY - raiseBy;
|
||||
renderChar(font, cp, &combiningX, &combiningY, black, style);
|
||||
continue;
|
||||
}
|
||||
|
||||
const EpdGlyph* glyph = font.getGlyph(cp, style);
|
||||
if (!glyph) {
|
||||
glyph = font.getGlyph(REPLACEMENT_GLYPH, style);
|
||||
}
|
||||
|
||||
if (!utf8IsCombiningMark(cp)) {
|
||||
lastBaseX = xpos;
|
||||
lastBaseY = yPos;
|
||||
lastBaseAdvance = glyph ? glyph->advanceX : 0;
|
||||
lastBaseTop = glyph ? glyph->top : 0;
|
||||
hasBaseGlyph = true;
|
||||
}
|
||||
|
||||
renderChar(font, cp, &xpos, &yPos, black, style);
|
||||
}
|
||||
}
|
||||
@@ -141,15 +308,34 @@ void GfxRenderer::drawLine(int x1, int y1, int x2, int y2, const bool state) con
|
||||
if (y2 < y1) {
|
||||
std::swap(y1, y2);
|
||||
}
|
||||
for (int y = y1; y <= y2; y++) {
|
||||
drawPixel(x1, y, state);
|
||||
// In Portrait/PortraitInverted a logical vertical line maps to a physical horizontal span.
|
||||
switch (orientation) {
|
||||
case Portrait:
|
||||
fillPhysicalHSpan(HalDisplay::DISPLAY_HEIGHT - 1 - x1, y1, y2, state);
|
||||
return;
|
||||
case PortraitInverted:
|
||||
fillPhysicalHSpan(x1, HalDisplay::DISPLAY_WIDTH - 1 - y2, HalDisplay::DISPLAY_WIDTH - 1 - y1, state);
|
||||
return;
|
||||
default:
|
||||
for (int y = y1; y <= y2; y++) drawPixel(x1, y, state);
|
||||
return;
|
||||
}
|
||||
} else if (y1 == y2) {
|
||||
if (x2 < x1) {
|
||||
std::swap(x1, x2);
|
||||
}
|
||||
for (int x = x1; x <= x2; x++) {
|
||||
drawPixel(x, y1, state);
|
||||
// In Landscape a logical horizontal line maps to a physical horizontal span.
|
||||
switch (orientation) {
|
||||
case LandscapeCounterClockwise:
|
||||
fillPhysicalHSpan(y1, x1, x2, state);
|
||||
return;
|
||||
case LandscapeClockwise:
|
||||
fillPhysicalHSpan(HalDisplay::DISPLAY_HEIGHT - 1 - y1, HalDisplay::DISPLAY_WIDTH - 1 - x2,
|
||||
HalDisplay::DISPLAY_WIDTH - 1 - x1, state);
|
||||
return;
|
||||
default:
|
||||
for (int x = x1; x <= x2; x++) drawPixel(x, y1, state);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Bresenham's line algorithm — integer arithmetic only
|
||||
@@ -278,9 +464,80 @@ void GfxRenderer::drawRoundedRect(const int x, const int y, const int width, con
|
||||
}
|
||||
}
|
||||
|
||||
// Write a patterned horizontal span directly into the physical framebuffer with byte-level operations.
|
||||
// Handles partial left/right bytes and fills the aligned middle with memset.
|
||||
// Bit layout: MSB-first (bit 7 = phyX=0, bit 0 = phyX=7); 0 bits = dark pixel, 1 bits = white pixel.
|
||||
void GfxRenderer::fillPhysicalHSpanByte(const int phyY, const int phyX_start, const int phyX_end,
|
||||
const uint8_t patternByte) const {
|
||||
const int cX0 = std::max(phyX_start, 0);
|
||||
const int cX1 = std::min(phyX_end, (int)HalDisplay::DISPLAY_WIDTH - 1);
|
||||
if (cX0 > cX1 || phyY < 0 || phyY >= (int)HalDisplay::DISPLAY_HEIGHT) return;
|
||||
|
||||
uint8_t* const row = frameBuffer + phyY * HalDisplay::DISPLAY_WIDTH_BYTES;
|
||||
const int startByte = cX0 >> 3;
|
||||
const int endByte = cX1 >> 3;
|
||||
const int leftBits = cX0 & 7;
|
||||
const int rightBits = cX1 & 7;
|
||||
|
||||
if (startByte == endByte) {
|
||||
const uint8_t fillMask = (0xFF >> leftBits) & ~(0xFF >> (rightBits + 1));
|
||||
row[startByte] = (row[startByte] & ~fillMask) | (patternByte & fillMask);
|
||||
return;
|
||||
}
|
||||
|
||||
// Left partial byte
|
||||
if (leftBits != 0) {
|
||||
const uint8_t fillMask = 0xFF >> leftBits;
|
||||
row[startByte] = (row[startByte] & ~fillMask) | (patternByte & fillMask);
|
||||
}
|
||||
|
||||
// Full bytes in the middle
|
||||
const int fullStart = (leftBits == 0) ? startByte : startByte + 1;
|
||||
const int fullEnd = (rightBits == 7) ? endByte : endByte - 1;
|
||||
if (fullStart <= fullEnd) {
|
||||
memset(row + fullStart, patternByte, fullEnd - fullStart + 1);
|
||||
}
|
||||
|
||||
// Right partial byte
|
||||
if (rightBits != 7) {
|
||||
const uint8_t fillMask = ~(0xFF >> (rightBits + 1));
|
||||
row[endByte] = (row[endByte] & ~fillMask) | (patternByte & fillMask);
|
||||
}
|
||||
}
|
||||
|
||||
// Thin wrapper: state=true → 0x00 (all dark), false → 0xFF (all white).
|
||||
void GfxRenderer::fillPhysicalHSpan(const int phyY, const int phyX_start, const int phyX_end, const bool state) const {
|
||||
fillPhysicalHSpanByte(phyY, phyX_start, phyX_end, state ? 0x00 : 0xFF);
|
||||
}
|
||||
|
||||
void GfxRenderer::fillRect(const int x, const int y, const int width, const int height, const bool state) const {
|
||||
for (int fillY = y; fillY < y + height; fillY++) {
|
||||
drawLine(x, fillY, x + width - 1, fillY, state);
|
||||
if (width <= 0 || height <= 0) return;
|
||||
|
||||
// For each orientation, one logical dimension maps to a constant physical row, allowing the
|
||||
// perpendicular dimension to be written as a byte-level span — eliminating per-pixel overhead.
|
||||
switch (orientation) {
|
||||
case Portrait:
|
||||
for (int lx = x; lx < x + width; lx++) {
|
||||
fillPhysicalHSpan(HalDisplay::DISPLAY_HEIGHT - 1 - lx, y, y + height - 1, state);
|
||||
}
|
||||
return;
|
||||
case PortraitInverted:
|
||||
for (int lx = x; lx < x + width; lx++) {
|
||||
fillPhysicalHSpan(lx, HalDisplay::DISPLAY_WIDTH - 1 - (y + height - 1), HalDisplay::DISPLAY_WIDTH - 1 - y,
|
||||
state);
|
||||
}
|
||||
return;
|
||||
case LandscapeCounterClockwise:
|
||||
for (int ly = y; ly < y + height; ly++) {
|
||||
fillPhysicalHSpan(ly, x, x + width - 1, state);
|
||||
}
|
||||
return;
|
||||
case LandscapeClockwise:
|
||||
for (int ly = y; ly < y + height; ly++) {
|
||||
fillPhysicalHSpan(HalDisplay::DISPLAY_HEIGHT - 1 - ly, HalDisplay::DISPLAY_WIDTH - 1 - (x + width - 1),
|
||||
HalDisplay::DISPLAY_WIDTH - 1 - x, state);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -317,17 +574,77 @@ void GfxRenderer::fillRectDither(const int x, const int y, const int width, cons
|
||||
fillRect(x, y, width, height, true);
|
||||
} else if (color == Color::White) {
|
||||
fillRect(x, y, width, height, false);
|
||||
} else if (color == Color::LightGray) {
|
||||
for (int fillY = y; fillY < y + height; fillY++) {
|
||||
for (int fillX = x; fillX < x + width; fillX++) {
|
||||
drawPixelDither<Color::LightGray>(fillX, fillY);
|
||||
}
|
||||
}
|
||||
} else if (color == Color::DarkGray) {
|
||||
for (int fillY = y; fillY < y + height; fillY++) {
|
||||
for (int fillX = x; fillX < x + width; fillX++) {
|
||||
drawPixelDither<Color::DarkGray>(fillX, fillY);
|
||||
}
|
||||
// Pattern: dark where (phyX + phyY) % 2 == 0 (alternating checkerboard).
|
||||
// Byte patterns (phyY even / phyY odd):
|
||||
// Portrait / PortraitInverted: 0xAA / 0x55
|
||||
// LandscapeCW / LandscapeCCW: 0x55 / 0xAA
|
||||
switch (orientation) {
|
||||
case Portrait:
|
||||
for (int lx = x; lx < x + width; lx++) {
|
||||
const int phyY = HalDisplay::DISPLAY_HEIGHT - 1 - lx;
|
||||
const uint8_t pb = (phyY % 2 == 0) ? 0xAA : 0x55;
|
||||
fillPhysicalHSpanByte(phyY, y, y + height - 1, pb);
|
||||
}
|
||||
return;
|
||||
case PortraitInverted:
|
||||
for (int lx = x; lx < x + width; lx++) {
|
||||
const int phyY = lx;
|
||||
const uint8_t pb = (phyY % 2 == 0) ? 0xAA : 0x55;
|
||||
fillPhysicalHSpanByte(phyY, HalDisplay::DISPLAY_WIDTH - 1 - (y + height - 1),
|
||||
HalDisplay::DISPLAY_WIDTH - 1 - y, pb);
|
||||
}
|
||||
return;
|
||||
case LandscapeCounterClockwise:
|
||||
for (int ly = y; ly < y + height; ly++) {
|
||||
const int phyY = ly;
|
||||
const uint8_t pb = (phyY % 2 == 0) ? 0x55 : 0xAA;
|
||||
fillPhysicalHSpanByte(phyY, x, x + width - 1, pb);
|
||||
}
|
||||
return;
|
||||
case LandscapeClockwise:
|
||||
for (int ly = y; ly < y + height; ly++) {
|
||||
const int phyY = HalDisplay::DISPLAY_HEIGHT - 1 - ly;
|
||||
const uint8_t pb = (phyY % 2 == 0) ? 0x55 : 0xAA;
|
||||
fillPhysicalHSpanByte(phyY, HalDisplay::DISPLAY_WIDTH - 1 - (x + width - 1),
|
||||
HalDisplay::DISPLAY_WIDTH - 1 - x, pb);
|
||||
}
|
||||
return;
|
||||
}
|
||||
} else if (color == Color::LightGray) {
|
||||
// Pattern: dark where phyX % 2 == 0 && phyY % 2 == 0 (1-in-4 pixels dark).
|
||||
// Rows that would be all-white are skipped entirely.
|
||||
switch (orientation) {
|
||||
case Portrait:
|
||||
for (int lx = x; lx < x + width; lx++) {
|
||||
const int phyY = HalDisplay::DISPLAY_HEIGHT - 1 - lx;
|
||||
if (phyY % 2 == 0) continue;
|
||||
fillPhysicalHSpanByte(phyY, y, y + height - 1, 0x55);
|
||||
}
|
||||
return;
|
||||
case PortraitInverted:
|
||||
for (int lx = x; lx < x + width; lx++) {
|
||||
const int phyY = lx;
|
||||
if (phyY % 2 != 0) continue;
|
||||
fillPhysicalHSpanByte(phyY, HalDisplay::DISPLAY_WIDTH - 1 - (y + height - 1),
|
||||
HalDisplay::DISPLAY_WIDTH - 1 - y, 0xAA);
|
||||
}
|
||||
return;
|
||||
case LandscapeCounterClockwise:
|
||||
for (int ly = y; ly < y + height; ly++) {
|
||||
const int phyY = ly;
|
||||
if (phyY % 2 != 0) continue;
|
||||
fillPhysicalHSpanByte(phyY, x, x + width - 1, 0x55);
|
||||
}
|
||||
return;
|
||||
case LandscapeClockwise:
|
||||
for (int ly = y; ly < y + height; ly++) {
|
||||
const int phyY = HalDisplay::DISPLAY_HEIGHT - 1 - ly;
|
||||
if (phyY % 2 == 0) continue;
|
||||
fillPhysicalHSpanByte(phyY, HalDisplay::DISPLAY_WIDTH - 1 - (x + width - 1),
|
||||
HalDisplay::DISPLAY_WIDTH - 1 - x, 0xAA);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -725,9 +1042,16 @@ void GfxRenderer::fillPolygon(const int* xPoints, const int* yPoints, int numPoi
|
||||
if (startX < 0) startX = 0;
|
||||
if (endX >= getScreenWidth()) endX = getScreenWidth() - 1;
|
||||
|
||||
// Draw horizontal line
|
||||
for (int x = startX; x <= endX; x++) {
|
||||
drawPixel(x, scanY, state);
|
||||
// In Landscape orientations, horizontal scanlines map to physical horizontal spans.
|
||||
if (orientation == LandscapeCounterClockwise) {
|
||||
fillPhysicalHSpan(scanY, startX, endX, state);
|
||||
} else if (orientation == LandscapeClockwise) {
|
||||
fillPhysicalHSpan(HalDisplay::DISPLAY_HEIGHT - 1 - scanY, HalDisplay::DISPLAY_WIDTH - 1 - endX,
|
||||
HalDisplay::DISPLAY_WIDTH - 1 - startX, state);
|
||||
} else {
|
||||
for (int x = startX; x <= endX; x++) {
|
||||
drawPixel(x, scanY, state);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -824,7 +1148,8 @@ int GfxRenderer::getSpaceWidth(const int fontId, const EpdFontFamily::Style styl
|
||||
return 0;
|
||||
}
|
||||
|
||||
return fontIt->second.getGlyph(' ', style)->advanceX;
|
||||
const EpdGlyph* spaceGlyph = fontIt->second.getGlyph(' ', style);
|
||||
return spaceGlyph ? spaceGlyph->advanceX : 0;
|
||||
}
|
||||
|
||||
int GfxRenderer::getTextAdvanceX(const int fontId, const char* text, const EpdFontFamily::Style style) const {
|
||||
@@ -838,7 +1163,12 @@ int GfxRenderer::getTextAdvanceX(const int fontId, const char* text, const EpdFo
|
||||
int width = 0;
|
||||
const auto& font = fontIt->second;
|
||||
while ((cp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&text)))) {
|
||||
width += font.getGlyph(cp, style)->advanceX;
|
||||
if (utf8IsCombiningMark(cp)) {
|
||||
continue;
|
||||
}
|
||||
const EpdGlyph* glyph = font.getGlyph(cp, style);
|
||||
if (!glyph) glyph = font.getGlyph(REPLACEMENT_GLYPH, style);
|
||||
if (glyph) width += glyph->advanceX;
|
||||
}
|
||||
return width;
|
||||
}
|
||||
@@ -887,68 +1217,51 @@ void GfxRenderer::drawTextRotated90CW(const int fontId, const int x, const int y
|
||||
|
||||
const auto& font = fontIt->second;
|
||||
|
||||
// For 90° clockwise rotation:
|
||||
// Original (glyphX, glyphY) -> Rotated (glyphY, -glyphX)
|
||||
// Text reads from bottom to top
|
||||
|
||||
int yPos = y; // Current Y position (decreases as we draw characters)
|
||||
int xPos = x;
|
||||
int yPos = y;
|
||||
int lastBaseX = x;
|
||||
int lastBaseY = y;
|
||||
int lastBaseAdvance = 0;
|
||||
int lastBaseTop = 0;
|
||||
bool hasBaseGlyph = false;
|
||||
constexpr int MIN_COMBINING_GAP_PX = 1;
|
||||
|
||||
uint32_t cp;
|
||||
while ((cp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&text)))) {
|
||||
if (utf8IsCombiningMark(cp) && hasBaseGlyph) {
|
||||
const EpdGlyph* combiningGlyph = font.getGlyph(cp, style);
|
||||
if (!combiningGlyph) {
|
||||
combiningGlyph = font.getGlyph(REPLACEMENT_GLYPH, style);
|
||||
}
|
||||
|
||||
int raiseBy = 0;
|
||||
if (combiningGlyph) {
|
||||
const int currentGap = combiningGlyph->top - combiningGlyph->height - lastBaseTop;
|
||||
if (currentGap < MIN_COMBINING_GAP_PX) {
|
||||
raiseBy = MIN_COMBINING_GAP_PX - currentGap;
|
||||
}
|
||||
}
|
||||
|
||||
int combiningX = lastBaseX - raiseBy;
|
||||
int combiningY = lastBaseY - lastBaseAdvance / 2;
|
||||
renderCharImpl<TextRotation::Rotated90CW>(*this, renderMode, font, cp, &combiningX, &combiningY, black, style);
|
||||
continue;
|
||||
}
|
||||
|
||||
const EpdGlyph* glyph = font.getGlyph(cp, style);
|
||||
if (!glyph) {
|
||||
glyph = font.getGlyph(REPLACEMENT_GLYPH, style);
|
||||
}
|
||||
if (!glyph) {
|
||||
continue;
|
||||
|
||||
if (!utf8IsCombiningMark(cp)) {
|
||||
lastBaseX = xPos;
|
||||
lastBaseY = yPos;
|
||||
lastBaseAdvance = glyph ? glyph->advanceX : 0;
|
||||
lastBaseTop = glyph ? glyph->top : 0;
|
||||
hasBaseGlyph = true;
|
||||
}
|
||||
|
||||
const EpdFontData* fontData = font.getData(style);
|
||||
const int is2Bit = fontData->is2Bit;
|
||||
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 = getGlyphBitmap(fontData, glyph);
|
||||
|
||||
if (bitmap != nullptr) {
|
||||
for (int glyphY = 0; glyphY < height; glyphY++) {
|
||||
for (int glyphX = 0; glyphX < width; glyphX++) {
|
||||
const int pixelPosition = glyphY * width + glyphX;
|
||||
|
||||
// 90° clockwise rotation transformation:
|
||||
// screenX = x + (ascender - top + glyphY)
|
||||
// screenY = yPos - (left + glyphX)
|
||||
const int screenX = x + (fontData->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 up, so decrease Y)
|
||||
yPos -= glyph->advanceX;
|
||||
renderCharImpl<TextRotation::Rotated90CW>(*this, renderMode, font, cp, &xPos, &yPos, black, style);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -959,77 +1272,59 @@ void GfxRenderer::drawTextRotated90CCW(const int fontId, const int x, const int
|
||||
return;
|
||||
}
|
||||
|
||||
if (fontMap.count(fontId) == 0) {
|
||||
const auto fontIt = fontMap.find(fontId);
|
||||
if (fontIt == fontMap.end()) {
|
||||
LOG_ERR("GFX", "Font %d not found", fontId);
|
||||
return;
|
||||
}
|
||||
const auto font = fontMap.at(fontId);
|
||||
|
||||
// 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 auto& font = fontIt->second;
|
||||
|
||||
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)
|
||||
int xPos = x;
|
||||
int yPos = y;
|
||||
int lastBaseX = x;
|
||||
int lastBaseY = y;
|
||||
int lastBaseAdvance = 0;
|
||||
int lastBaseTop = 0;
|
||||
bool hasBaseGlyph = false;
|
||||
constexpr int MIN_COMBINING_GAP_PX = 1;
|
||||
|
||||
uint32_t cp;
|
||||
while ((cp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&text)))) {
|
||||
if (utf8IsCombiningMark(cp) && hasBaseGlyph) {
|
||||
const EpdGlyph* combiningGlyph = font.getGlyph(cp, style);
|
||||
if (!combiningGlyph) {
|
||||
combiningGlyph = font.getGlyph(REPLACEMENT_GLYPH, style);
|
||||
}
|
||||
|
||||
int raiseBy = 0;
|
||||
if (combiningGlyph) {
|
||||
const int currentGap = combiningGlyph->top - combiningGlyph->height - lastBaseTop;
|
||||
if (currentGap < MIN_COMBINING_GAP_PX) {
|
||||
raiseBy = MIN_COMBINING_GAP_PX - currentGap;
|
||||
}
|
||||
}
|
||||
|
||||
int combiningX = lastBaseX + raiseBy;
|
||||
int combiningY = lastBaseY + lastBaseAdvance / 2;
|
||||
renderCharImpl<TextRotation::Rotated90CCW>(*this, renderMode, font, cp, &combiningX, &combiningY, black, style);
|
||||
continue;
|
||||
}
|
||||
|
||||
const EpdGlyph* glyph = font.getGlyph(cp, style);
|
||||
if (!glyph) {
|
||||
glyph = font.getGlyph(REPLACEMENT_GLYPH, style);
|
||||
}
|
||||
if (!glyph) {
|
||||
continue;
|
||||
|
||||
if (!utf8IsCombiningMark(cp)) {
|
||||
lastBaseX = xPos;
|
||||
lastBaseY = yPos;
|
||||
lastBaseAdvance = glyph ? glyph->advanceX : 0;
|
||||
lastBaseTop = glyph ? glyph->top : 0;
|
||||
hasBaseGlyph = true;
|
||||
}
|
||||
|
||||
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;
|
||||
renderCharImpl<TextRotation::Rotated90CCW>(*this, renderMode, font, cp, &xPos, &yPos, black, style);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1094,7 +1389,7 @@ bool GfxRenderer::storeBwBuffer() {
|
||||
* Uses chunked restoration to match chunked storage.
|
||||
*/
|
||||
void GfxRenderer::restoreBwBuffer() {
|
||||
// Check if any all chunks are allocated
|
||||
// Check if all chunks are allocated
|
||||
bool missingChunks = false;
|
||||
for (const auto& bwBufferChunk : bwBufferChunks) {
|
||||
if (!bwBufferChunk) {
|
||||
@@ -1109,13 +1404,6 @@ void GfxRenderer::restoreBwBuffer() {
|
||||
}
|
||||
|
||||
for (size_t i = 0; i < BW_BUFFER_NUM_CHUNKS; i++) {
|
||||
// Check if chunk is missing
|
||||
if (!bwBufferChunks[i]) {
|
||||
LOG_ERR("GFX", "!! BW buffer chunks not stored - this is likely a bug");
|
||||
freeBwBufferChunks();
|
||||
return;
|
||||
}
|
||||
|
||||
const size_t offset = i * BW_BUFFER_CHUNK_SIZE;
|
||||
memcpy(frameBuffer + offset, bwBufferChunks[i], BW_BUFFER_CHUNK_SIZE);
|
||||
}
|
||||
@@ -1136,66 +1424,9 @@ void GfxRenderer::cleanupGrayscaleWithFrameBuffer() const {
|
||||
}
|
||||
}
|
||||
|
||||
void GfxRenderer::renderChar(const EpdFontFamily& fontFamily, const uint32_t cp, int* x, const int* y,
|
||||
const bool pixelState, const EpdFontFamily::Style style) const {
|
||||
const EpdGlyph* glyph = fontFamily.getGlyph(cp, style);
|
||||
if (!glyph) {
|
||||
glyph = fontFamily.getGlyph(REPLACEMENT_GLYPH, style);
|
||||
}
|
||||
|
||||
// no glyph?
|
||||
if (!glyph) {
|
||||
LOG_ERR("GFX", "No glyph for codepoint %d", cp);
|
||||
return;
|
||||
}
|
||||
|
||||
const EpdFontData* fontData = fontFamily.getData(style);
|
||||
const int is2Bit = fontData->is2Bit;
|
||||
const uint8_t width = glyph->width;
|
||||
const uint8_t height = glyph->height;
|
||||
const int left = glyph->left;
|
||||
|
||||
const uint8_t* bitmap = getGlyphBitmap(fontData, glyph);
|
||||
|
||||
if (bitmap != nullptr) {
|
||||
for (int glyphY = 0; glyphY < height; glyphY++) {
|
||||
const int screenY = *y - glyph->top + glyphY;
|
||||
for (int glyphX = 0; glyphX < width; glyphX++) {
|
||||
const int pixelPosition = glyphY * width + glyphX;
|
||||
const int screenX = *x + left + glyphX;
|
||||
|
||||
if (is2Bit) {
|
||||
const uint8_t byte = bitmap[pixelPosition / 4];
|
||||
const uint8_t bit_index = (3 - pixelPosition % 4) * 2;
|
||||
// the direct bit from the font is 0 -> white, 1 -> light gray, 2 -> dark gray, 3 -> black
|
||||
// we swap this to better match the way images and screen think about colors:
|
||||
// 0 -> black, 1 -> dark grey, 2 -> light grey, 3 -> white
|
||||
const uint8_t bmpVal = 3 - (byte >> bit_index) & 0x3;
|
||||
|
||||
if (renderMode == BW && bmpVal < 3) {
|
||||
// Black (also paints over the grays in BW mode)
|
||||
drawPixel(screenX, screenY, pixelState);
|
||||
} else if (renderMode == GRAYSCALE_MSB && (bmpVal == 1 || bmpVal == 2)) {
|
||||
// Light gray (also mark the MSB if it's going to be a dark gray too)
|
||||
// We have to flag pixels in reverse for the gray buffers, as 0 leave alone, 1 update
|
||||
drawPixel(screenX, screenY, false);
|
||||
} else if (renderMode == GRAYSCALE_LSB && bmpVal == 1) {
|
||||
// Dark gray
|
||||
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, pixelState);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
*x += glyph->advanceX;
|
||||
void GfxRenderer::renderChar(const EpdFontFamily& fontFamily, uint32_t cp, int* x, int* y, bool pixelState,
|
||||
EpdFontFamily::Style style) const {
|
||||
renderCharImpl<TextRotation::None>(*this, renderMode, fontFamily, cp, x, y, pixelState, style);
|
||||
}
|
||||
|
||||
void GfxRenderer::getOrientedViewableTRBL(int* outTop, int* outRight, int* outBottom, int* outLeft) const {
|
||||
|
||||
@@ -38,14 +38,21 @@ class GfxRenderer {
|
||||
uint8_t* bwBufferChunks[BW_BUFFER_NUM_CHUNKS] = {nullptr};
|
||||
std::map<int, EpdFontFamily> fontMap;
|
||||
FontDecompressor* fontDecompressor = nullptr;
|
||||
void renderChar(const EpdFontFamily& fontFamily, uint32_t cp, int* x, const int* y, bool pixelState,
|
||||
void renderChar(const EpdFontFamily& fontFamily, uint32_t cp, int* x, int* y, bool pixelState,
|
||||
EpdFontFamily::Style style) const;
|
||||
void freeBwBufferChunks();
|
||||
const uint8_t* getGlyphBitmap(const EpdFontData* fontData, const EpdGlyph* glyph) const;
|
||||
template <Color color>
|
||||
void drawPixelDither(int x, int y) const;
|
||||
template <Color color>
|
||||
void fillArc(int maxRadius, int cx, int cy, int xDir, int yDir) const;
|
||||
// Write a patterned horizontal span directly to the physical framebuffer using byte-level operations.
|
||||
// phyY: physical row; phyX_start/phyX_end: inclusive physical column range.
|
||||
// patternByte is repeated across the span; partial edge bytes are blended with existing content.
|
||||
// Bit layout: MSB-first (bit 7 = phyX=0); 0 bits = dark pixel, 1 bits = white pixel.
|
||||
void fillPhysicalHSpanByte(int phyY, int phyX_start, int phyX_end, uint8_t patternByte) const;
|
||||
// Write a solid horizontal span directly to the physical framebuffer using byte-level operations.
|
||||
// Thin wrapper around fillPhysicalHSpanByte: state=true → 0x00 (dark), false → 0xFF (white).
|
||||
void fillPhysicalHSpan(int phyY, int phyX_start, int phyX_end, bool state) const;
|
||||
|
||||
public:
|
||||
explicit GfxRenderer(HalDisplay& halDisplay)
|
||||
@@ -136,6 +143,9 @@ class GfxRenderer {
|
||||
void restoreBwBuffer(); // Restore and free the stored buffer
|
||||
void cleanupGrayscaleWithFrameBuffer() const;
|
||||
|
||||
// Font helpers
|
||||
const uint8_t* getGlyphBitmap(const EpdFontData* fontData, const EpdGlyph* glyph) const;
|
||||
|
||||
// Low level functions
|
||||
uint8_t* getFrameBuffer() const;
|
||||
static size_t getBufferSize();
|
||||
|
||||
@@ -13,6 +13,7 @@ extern const char* const STRINGS_CZ[];
|
||||
extern const char* const STRINGS_PO[];
|
||||
extern const char* const STRINGS_RU[];
|
||||
extern const char* const STRINGS_SV[];
|
||||
extern const char* const STRINGS_RO[];
|
||||
} // namespace i18n_strings
|
||||
|
||||
// Language enum
|
||||
@@ -25,6 +26,7 @@ enum class Language : uint8_t {
|
||||
PORTUGUESE = 5,
|
||||
RUSSIAN = 6,
|
||||
SWEDISH = 7,
|
||||
ROMANIAN = 8,
|
||||
_COUNT
|
||||
};
|
||||
|
||||
@@ -319,6 +321,7 @@ enum class StrId : uint16_t {
|
||||
STR_GO_TO_PERCENT,
|
||||
STR_GO_HOME_BUTTON,
|
||||
STR_SYNC_PROGRESS,
|
||||
STR_PUSH_AND_SLEEP,
|
||||
STR_DELETE_CACHE,
|
||||
STR_CHAPTER_PREFIX,
|
||||
STR_PAGES_SEPARATOR,
|
||||
@@ -396,6 +399,33 @@ enum class StrId : uint16_t {
|
||||
STR_INDEXING_POPUP,
|
||||
STR_INDEXING_STATUS_TEXT,
|
||||
STR_INDEXING_STATUS_ICON,
|
||||
STR_SYNC_CLOCK,
|
||||
STR_TIME_SYNCED,
|
||||
STR_AUTO_NTP_SYNC,
|
||||
STR_MANAGE_BOOK,
|
||||
STR_ARCHIVE_BOOK,
|
||||
STR_UNARCHIVE_BOOK,
|
||||
STR_DELETE_BOOK,
|
||||
STR_DELETE_CACHE_ONLY,
|
||||
STR_REINDEX_BOOK,
|
||||
STR_BROWSE_ARCHIVE,
|
||||
STR_BOOK_ARCHIVED,
|
||||
STR_BOOK_UNARCHIVED,
|
||||
STR_BOOK_DELETED,
|
||||
STR_CACHE_DELETED,
|
||||
STR_BOOK_REINDEXED,
|
||||
STR_ACTION_FAILED,
|
||||
STR_BACK_TO_BEGINNING,
|
||||
STR_CLOSE_MENU,
|
||||
STR_ADD_SERVER,
|
||||
STR_SERVER_NAME,
|
||||
STR_NO_SERVERS,
|
||||
STR_DELETE_SERVER,
|
||||
STR_DELETE_CONFIRM,
|
||||
STR_OPDS_SERVERS,
|
||||
STR_SAVE_HERE,
|
||||
STR_SELECT_FOLDER,
|
||||
STR_DOWNLOAD_PATH,
|
||||
// Sentinel - must be last
|
||||
_COUNT
|
||||
};
|
||||
@@ -419,6 +449,8 @@ inline const char* const* getStringArray(Language lang) {
|
||||
return i18n_strings::STRINGS_RU;
|
||||
case Language::SWEDISH:
|
||||
return i18n_strings::STRINGS_SV;
|
||||
case Language::ROMANIAN:
|
||||
return i18n_strings::STRINGS_RO;
|
||||
default:
|
||||
return i18n_strings::STRINGS_EN;
|
||||
}
|
||||
|
||||
@@ -15,5 +15,6 @@ extern const char* const STRINGS_CZ[];
|
||||
extern const char* const STRINGS_PO[];
|
||||
extern const char* const STRINGS_RU[];
|
||||
extern const char* const STRINGS_SV[];
|
||||
extern const char* const STRINGS_RO[];
|
||||
|
||||
} // namespace i18n_strings
|
||||
|
||||
@@ -267,7 +267,7 @@ STR_MENU_RECENT_BOOKS: "Nedávné knihy"
|
||||
STR_NO_RECENT_BOOKS: "Žádné nedávné knihy"
|
||||
STR_CALIBRE_DESC: "Používat přenosy bezdrátových zařízení Calibre"
|
||||
STR_FORGET_AND_REMOVE: "Zapomenout síť a odstranit uložené heslo?"
|
||||
STR_FORGET_BUTTON: "Zapomenout na síť"
|
||||
STR_FORGET_BUTTON: "Zapomenout"
|
||||
STR_CALIBRE_STARTING: "Spuštění Calibre..."
|
||||
STR_CALIBRE_SETUP: "Nastavení"
|
||||
STR_CALIBRE_STATUS: "Stav"
|
||||
@@ -341,3 +341,15 @@ STR_INDEXING_DISPLAY: "Zobrazení indexování"
|
||||
STR_INDEXING_POPUP: "Popup"
|
||||
STR_INDEXING_STATUS_TEXT: "Text stavového řádku"
|
||||
STR_INDEXING_STATUS_ICON: "Ikona stavového řádku"
|
||||
STR_SYNC_CLOCK: "Sync Clock"
|
||||
STR_TIME_SYNCED: "Time synced!"
|
||||
STR_AUTO_NTP_SYNC: "Auto Sync on Boot"
|
||||
STR_ADD_SERVER: "Přidat server"
|
||||
STR_SERVER_NAME: "Název serveru"
|
||||
STR_NO_SERVERS: "Žádné OPDS servery nejsou nakonfigurovány"
|
||||
STR_DELETE_SERVER: "Smazat server"
|
||||
STR_DELETE_CONFIRM: "Smazat tento server?"
|
||||
STR_OPDS_SERVERS: "OPDS servery"
|
||||
STR_SAVE_HERE: "Uložit zde"
|
||||
STR_SELECT_FOLDER: "Vybrat složku"
|
||||
STR_DOWNLOAD_PATH: "Cesta ke stažení"
|
||||
|
||||
@@ -267,7 +267,7 @@ STR_MENU_RECENT_BOOKS: "Recent Books"
|
||||
STR_NO_RECENT_BOOKS: "No recent books"
|
||||
STR_CALIBRE_DESC: "Use Calibre wireless device transfers"
|
||||
STR_FORGET_AND_REMOVE: "Forget network and remove saved password?"
|
||||
STR_FORGET_BUTTON: "Forget network"
|
||||
STR_FORGET_BUTTON: "Forget"
|
||||
STR_CALIBRE_STARTING: "Starting Calibre..."
|
||||
STR_CALIBRE_SETUP: "Setup"
|
||||
STR_CALIBRE_STATUS: "Status"
|
||||
@@ -285,6 +285,7 @@ STR_HW_RIGHT_LABEL: "Right (4th button)"
|
||||
STR_GO_TO_PERCENT: "Go to %"
|
||||
STR_GO_HOME_BUTTON: "Go Home"
|
||||
STR_SYNC_PROGRESS: "Sync Reading Progress"
|
||||
STR_PUSH_AND_SLEEP: "Push Progress & Sleep"
|
||||
STR_DELETE_CACHE: "Delete Book Cache"
|
||||
STR_CHAPTER_PREFIX: "Chapter: "
|
||||
STR_PAGES_SEPARATOR: " pages | "
|
||||
@@ -362,3 +363,30 @@ STR_INDEXING_DISPLAY: "Indexing Display"
|
||||
STR_INDEXING_POPUP: "Popup"
|
||||
STR_INDEXING_STATUS_TEXT: "Status Bar Text"
|
||||
STR_INDEXING_STATUS_ICON: "Status Bar Icon"
|
||||
STR_SYNC_CLOCK: "Sync Clock"
|
||||
STR_TIME_SYNCED: "Time synced!"
|
||||
STR_AUTO_NTP_SYNC: "Auto Sync on Boot"
|
||||
STR_MANAGE_BOOK: "Manage Book"
|
||||
STR_ARCHIVE_BOOK: "Archive Book"
|
||||
STR_UNARCHIVE_BOOK: "Unarchive Book"
|
||||
STR_DELETE_BOOK: "Delete Book"
|
||||
STR_DELETE_CACHE_ONLY: "Delete Cache Only"
|
||||
STR_REINDEX_BOOK: "Reindex Book"
|
||||
STR_BROWSE_ARCHIVE: "Browse Archive"
|
||||
STR_BOOK_ARCHIVED: "Book archived"
|
||||
STR_BOOK_UNARCHIVED: "Book unarchived"
|
||||
STR_BOOK_DELETED: "Book deleted"
|
||||
STR_CACHE_DELETED: "Cache deleted"
|
||||
STR_BOOK_REINDEXED: "Book reindexed"
|
||||
STR_ACTION_FAILED: "Action failed"
|
||||
STR_BACK_TO_BEGINNING: "Back to Beginning"
|
||||
STR_CLOSE_MENU: "Close Menu"
|
||||
STR_ADD_SERVER: "Add Server"
|
||||
STR_SERVER_NAME: "Server Name"
|
||||
STR_NO_SERVERS: "No OPDS servers configured"
|
||||
STR_DELETE_SERVER: "Delete Server"
|
||||
STR_DELETE_CONFIRM: "Delete this server?"
|
||||
STR_OPDS_SERVERS: "OPDS Servers"
|
||||
STR_SAVE_HERE: "Save Here"
|
||||
STR_SELECT_FOLDER: "Select Folder"
|
||||
STR_DOWNLOAD_PATH: "Download Path"
|
||||
|
||||
@@ -267,7 +267,7 @@ STR_MENU_RECENT_BOOKS: "Livres récents"
|
||||
STR_NO_RECENT_BOOKS: "Aucun livre récent"
|
||||
STR_CALIBRE_DESC: "Utiliser les transferts sans fil Calibre"
|
||||
STR_FORGET_AND_REMOVE: "Oublier le réseau et supprimer le mot de passe enregistré ?"
|
||||
STR_FORGET_BUTTON: "Oublier le réseau"
|
||||
STR_FORGET_BUTTON: "Oublier"
|
||||
STR_CALIBRE_STARTING: "Démarrage de Calibre..."
|
||||
STR_CALIBRE_SETUP: "Configuration"
|
||||
STR_CALIBRE_STATUS: "Statut"
|
||||
@@ -341,3 +341,15 @@ STR_INDEXING_DISPLAY: "Affichage indexation"
|
||||
STR_INDEXING_POPUP: "Popup"
|
||||
STR_INDEXING_STATUS_TEXT: "Texte barre d'état"
|
||||
STR_INDEXING_STATUS_ICON: "Icône barre d'état"
|
||||
STR_SYNC_CLOCK: "Sync Clock"
|
||||
STR_TIME_SYNCED: "Time synced!"
|
||||
STR_AUTO_NTP_SYNC: "Auto Sync on Boot"
|
||||
STR_ADD_SERVER: "Ajouter un serveur"
|
||||
STR_SERVER_NAME: "Nom du serveur"
|
||||
STR_NO_SERVERS: "Aucun serveur OPDS configuré"
|
||||
STR_DELETE_SERVER: "Supprimer le serveur"
|
||||
STR_DELETE_CONFIRM: "Supprimer ce serveur ?"
|
||||
STR_OPDS_SERVERS: "Serveurs OPDS"
|
||||
STR_SAVE_HERE: "Enregistrer ici"
|
||||
STR_SELECT_FOLDER: "Sélectionner un dossier"
|
||||
STR_DOWNLOAD_PATH: "Chemin de téléchargement"
|
||||
|
||||
@@ -267,7 +267,7 @@ STR_MENU_RECENT_BOOKS: "Zuletzt gelesen"
|
||||
STR_NO_RECENT_BOOKS: "Keine Bücher"
|
||||
STR_CALIBRE_DESC: "Calibre-Übertragung (WLAN)"
|
||||
STR_FORGET_AND_REMOVE: "WLAN entfernen & Passwort löschen?"
|
||||
STR_FORGET_BUTTON: "WLAN entfernen"
|
||||
STR_FORGET_BUTTON: "Entfernen"
|
||||
STR_CALIBRE_STARTING: "Calibre starten…"
|
||||
STR_CALIBRE_SETUP: "Installation"
|
||||
STR_CALIBRE_STATUS: "Status"
|
||||
@@ -341,3 +341,15 @@ STR_INDEXING_DISPLAY: "Indexierungsanzeige"
|
||||
STR_INDEXING_POPUP: "Popup"
|
||||
STR_INDEXING_STATUS_TEXT: "Statusleistentext"
|
||||
STR_INDEXING_STATUS_ICON: "Statusleistensymbol"
|
||||
STR_SYNC_CLOCK: "Sync Clock"
|
||||
STR_TIME_SYNCED: "Time synced!"
|
||||
STR_AUTO_NTP_SYNC: "Auto Sync on Boot"
|
||||
STR_ADD_SERVER: "Server hinzufügen"
|
||||
STR_SERVER_NAME: "Servername"
|
||||
STR_NO_SERVERS: "Keine OPDS-Server konfiguriert"
|
||||
STR_DELETE_SERVER: "Server löschen"
|
||||
STR_DELETE_CONFIRM: "Diesen Server löschen?"
|
||||
STR_OPDS_SERVERS: "OPDS-Server"
|
||||
STR_SAVE_HERE: "Hier speichern"
|
||||
STR_SELECT_FOLDER: "Ordner auswählen"
|
||||
STR_DOWNLOAD_PATH: "Download-Pfad"
|
||||
|
||||
@@ -267,7 +267,7 @@ STR_MENU_RECENT_BOOKS: "Livros recentes"
|
||||
STR_NO_RECENT_BOOKS: "Sem livros recentes"
|
||||
STR_CALIBRE_DESC: "Usar transferências sem fio Calibre"
|
||||
STR_FORGET_AND_REMOVE: "Esquecer a rede e remover a senha salva?"
|
||||
STR_FORGET_BUTTON: "Esquecer rede"
|
||||
STR_FORGET_BUTTON: "Esquecer"
|
||||
STR_CALIBRE_STARTING: "Iniciando Calibre..."
|
||||
STR_CALIBRE_SETUP: "Configuração"
|
||||
STR_CALIBRE_STATUS: "Status"
|
||||
@@ -341,3 +341,15 @@ STR_INDEXING_DISPLAY: "Exibição de indexação"
|
||||
STR_INDEXING_POPUP: "Popup"
|
||||
STR_INDEXING_STATUS_TEXT: "Texto da barra"
|
||||
STR_INDEXING_STATUS_ICON: "Ícone da barra"
|
||||
STR_SYNC_CLOCK: "Sync Clock"
|
||||
STR_TIME_SYNCED: "Time synced!"
|
||||
STR_AUTO_NTP_SYNC: "Auto Sync on Boot"
|
||||
STR_ADD_SERVER: "Adicionar servidor"
|
||||
STR_SERVER_NAME: "Nome do servidor"
|
||||
STR_NO_SERVERS: "Nenhum servidor OPDS configurado"
|
||||
STR_DELETE_SERVER: "Excluir servidor"
|
||||
STR_DELETE_CONFIRM: "Excluir este servidor?"
|
||||
STR_OPDS_SERVERS: "Servidores OPDS"
|
||||
STR_SAVE_HERE: "Salvar aqui"
|
||||
STR_SELECT_FOLDER: "Selecionar pasta"
|
||||
STR_DOWNLOAD_PATH: "Caminho de download"
|
||||
|
||||
330
lib/I18n/translations/romanian.yaml
Normal file
330
lib/I18n/translations/romanian.yaml
Normal file
@@ -0,0 +1,330 @@
|
||||
_language_name: "Română"
|
||||
_language_code: "ROMANIAN"
|
||||
_order: "8"
|
||||
|
||||
STR_CROSSPOINT: "CrossPoint"
|
||||
STR_BOOTING: "PORNEŞTE"
|
||||
STR_SLEEPING: "REPAUS"
|
||||
STR_ENTERING_SLEEP: "Intră în repaus..."
|
||||
STR_BROWSE_FILES: "Răsfoieşte fişierele"
|
||||
STR_FILE_TRANSFER: "Transfer de fişiere"
|
||||
STR_SETTINGS_TITLE: "Setări"
|
||||
STR_CALIBRE_LIBRARY: "Biblioteca Calibre"
|
||||
STR_CONTINUE_READING: "Continuă lectura"
|
||||
STR_NO_OPEN_BOOK: "Nicio carte deschisă"
|
||||
STR_START_READING: "Începeţi lectura"
|
||||
STR_BOOKS: "Cărţi"
|
||||
STR_NO_BOOKS_FOUND: "Nicio carte găsită"
|
||||
STR_SELECT_CHAPTER: "Selectaţi capitolul"
|
||||
STR_NO_CHAPTERS: "Niciun capitol"
|
||||
STR_END_OF_BOOK: "Sfârşitul cărţii"
|
||||
STR_EMPTY_CHAPTER: "Capitol gol"
|
||||
STR_INDEXING: "Indexează..."
|
||||
STR_MEMORY_ERROR: "Eroare de memorie"
|
||||
STR_PAGE_LOAD_ERROR: "Eroare la încărcarea paginii"
|
||||
STR_EMPTY_FILE: "Fişier gol"
|
||||
STR_OUT_OF_BOUNDS: "Eroare: În afara limitelor"
|
||||
STR_LOADING: "Se încarcă..."
|
||||
STR_LOADING_POPUP: "Se încarcă..."
|
||||
STR_LOAD_XTC_FAILED: "Eroare la încărcarea XTC"
|
||||
STR_LOAD_TXT_FAILED: "Eroare la încărcarea TXT"
|
||||
STR_LOAD_EPUB_FAILED: "Eroare la încărcarea EPUB"
|
||||
STR_SD_CARD_ERROR: "Eroare la cardul SD"
|
||||
STR_WIFI_NETWORKS: "Reţele WiFi"
|
||||
STR_NO_NETWORKS: "Nu s-au găsit reţele"
|
||||
STR_NETWORKS_FOUND: "%zu reţele găsite"
|
||||
STR_SCANNING: "Scanează..."
|
||||
STR_CONNECTING: "Se conectează..."
|
||||
STR_CONNECTED: "Conectat!"
|
||||
STR_CONNECTION_FAILED: "Conexiune eşuată"
|
||||
STR_CONNECTION_TIMEOUT: "Timp de conectare depăşit"
|
||||
STR_FORGET_NETWORK: "Uitaţi reţeaua?"
|
||||
STR_SAVE_PASSWORD: "Salvaţi parola?"
|
||||
STR_REMOVE_PASSWORD: "Ştergeţi parola salvată?"
|
||||
STR_PRESS_OK_SCAN: "Apăsaţi OK pentru a scana din nou"
|
||||
STR_PRESS_ANY_CONTINUE: "Apăsaţi orice buton pentru a continua"
|
||||
STR_SELECT_HINT: "STÂNGA/DREAPTA: Selectaţi | OK: Confirmaţi"
|
||||
STR_HOW_CONNECT: "Cum doriţi să vă conectaţi?"
|
||||
STR_JOIN_NETWORK: "Conectaţi-vă la o reţea"
|
||||
STR_CREATE_HOTSPOT: "Creaţi un hotspot"
|
||||
STR_JOIN_DESC: "Conectaţi-vă la o reţea WiFi existentă"
|
||||
STR_HOTSPOT_DESC: "Creaţi un hotspot WiFi"
|
||||
STR_STARTING_HOTSPOT: "Hotspot porneşte..."
|
||||
STR_HOTSPOT_MODE: "Mod Hotspot"
|
||||
STR_CONNECT_WIFI_HINT: "Conectaţi-vă dispozitivul la această reţea WiFi"
|
||||
STR_OPEN_URL_HINT: "Deschideţi acest URL în browserul dvs."
|
||||
STR_OR_HTTP_PREFIX: "sau http://"
|
||||
STR_SCAN_QR_HINT: "sau scanaţi codul QR cu telefonul dvs.:"
|
||||
STR_CALIBRE_WIRELESS: "Calibre Wireless"
|
||||
STR_CALIBRE_WEB_URL: "Calibre URL"
|
||||
STR_CONNECT_WIRELESS: "Conectaţi-vă ca dispozitiv wireless"
|
||||
STR_NETWORK_LEGEND: "* = Criptat | + = Salvat"
|
||||
STR_MAC_ADDRESS: "Adresă MAC:"
|
||||
STR_CHECKING_WIFI: "Verificare WiFi..."
|
||||
STR_ENTER_WIFI_PASSWORD: "Introduceţi parola WiFi"
|
||||
STR_ENTER_TEXT: "Introduceţi textul"
|
||||
STR_TO_PREFIX: "la "
|
||||
STR_CALIBRE_DISCOVERING: "Descoperă Calibre..."
|
||||
STR_CALIBRE_CONNECTING_TO: "Se conectează la "
|
||||
STR_CALIBRE_CONNECTED_TO: "Conectat la "
|
||||
STR_CALIBRE_WAITING_COMMANDS: "Se aşteaptă comenzi..."
|
||||
STR_CONNECTION_FAILED_RETRYING: "(Conexiune eşuată, se reîncearcă)"
|
||||
STR_CALIBRE_DISCONNECTED: "Calibre deconectat"
|
||||
STR_CALIBRE_WAITING_TRANSFER: "Se aşteaptă transfer..."
|
||||
STR_CALIBRE_TRANSFER_HINT: "Dacă transferul eşuează, activaţi\\n'Ignoraţi spaţiul liber' în setările\\nplugin-ului SmartDevice din Calibre."
|
||||
STR_CALIBRE_RECEIVING: "Se primeşte: "
|
||||
STR_CALIBRE_RECEIVED: "Primite: "
|
||||
STR_CALIBRE_WAITING_MORE: "Se aşteaptă mai multe..."
|
||||
STR_CALIBRE_FAILED_CREATE_FILE: "Creare fişier eşuată"
|
||||
STR_CALIBRE_PASSWORD_REQUIRED: "Necesită parolă"
|
||||
STR_CALIBRE_TRANSFER_INTERRUPTED: "Transfer întrerupt"
|
||||
STR_CALIBRE_INSTRUCTION_1: "1) Instalaţi plugin-ul CrossPoint Reader"
|
||||
STR_CALIBRE_INSTRUCTION_2: "2) Fiţi în aceeaşi reţea WiFi"
|
||||
STR_CALIBRE_INSTRUCTION_3: "3) În Calibre: \"Trimiteţi la dispozitiv\""
|
||||
STR_CALIBRE_INSTRUCTION_4: "\"Păstraţi acest ecran deschis în timpul trimiterii\""
|
||||
STR_CAT_DISPLAY: "Ecran"
|
||||
STR_CAT_READER: "Lectură"
|
||||
STR_CAT_CONTROLS: "Controale"
|
||||
STR_CAT_SYSTEM: "Sistem"
|
||||
STR_SLEEP_SCREEN: "Ecran de repaus"
|
||||
STR_SLEEP_COVER_MODE: "Mod ecran de repaus cu copertă"
|
||||
STR_STATUS_BAR: "Bara de stare"
|
||||
STR_HIDE_BATTERY: "Ascunde procentul bateriei"
|
||||
STR_EXTRA_SPACING: "Spaţiere suplimentară între paragrafe"
|
||||
STR_TEXT_AA: "Anti-Aliasing text"
|
||||
STR_SHORT_PWR_BTN: "Apăsare scurtă întrerupător"
|
||||
STR_ORIENTATION: "Orientare lectură"
|
||||
STR_FRONT_BTN_LAYOUT: "Aspect butoane frontale"
|
||||
STR_SIDE_BTN_LAYOUT: "Aspect butoane laterale (lectură)"
|
||||
STR_LONG_PRESS_SKIP: "Sărire capitol la apăsare lungă"
|
||||
STR_FONT_FAMILY: "Familie font lectură"
|
||||
STR_EXT_READER_FONT: "Font lectură extern"
|
||||
STR_EXT_CHINESE_FONT: "Font lectură"
|
||||
STR_EXT_UI_FONT: "Font meniu"
|
||||
STR_FONT_SIZE: "Dimensiune font"
|
||||
STR_LINE_SPACING: "Spaţiere între rânduri"
|
||||
STR_ASCII_LETTER_SPACING: "Spaţiere litere ASCII "
|
||||
STR_ASCII_DIGIT_SPACING: "Spaţiere cifre ASCII"
|
||||
STR_CJK_SPACING: "Spaţiere CJK"
|
||||
STR_COLOR_MODE: "Mod culoare"
|
||||
STR_SCREEN_MARGIN: "Margine ecran lectură"
|
||||
STR_PARA_ALIGNMENT: "Aliniere paragrafe reader"
|
||||
STR_HYPHENATION: "Silabisire"
|
||||
STR_TIME_TO_SLEEP: "Timp până la repaus"
|
||||
STR_REFRESH_FREQ: "Frecvenţă reîmprospătare"
|
||||
STR_CALIBRE_SETTINGS: "Setări Calibre"
|
||||
STR_KOREADER_SYNC: "Sincronizare KOReader"
|
||||
STR_CHECK_UPDATES: "Căutaţi actualizări"
|
||||
STR_LANGUAGE: "Limbă"
|
||||
STR_SELECT_WALLPAPER: "Selectaţi imaginea de fundal"
|
||||
STR_CLEAR_READING_CACHE: "Goliţi cache-ul de lectură"
|
||||
STR_CALIBRE: "Calibre"
|
||||
STR_USERNAME: "Utilizator"
|
||||
STR_PASSWORD: "Parolă"
|
||||
STR_SYNC_SERVER_URL: "URL server sincronizare"
|
||||
STR_DOCUMENT_MATCHING: "Corespondenţă document"
|
||||
STR_AUTHENTICATE: "Autentificare"
|
||||
STR_KOREADER_USERNAME: "Nume utilizator KOReader"
|
||||
STR_KOREADER_PASSWORD: "Parolă KOReader"
|
||||
STR_FILENAME: "Nume fişier"
|
||||
STR_BINARY: "Fişier binar"
|
||||
STR_SET_CREDENTIALS_FIRST: "Vă rugăm să setaţi mai întâi acreditările"
|
||||
STR_WIFI_CONN_FAILED: "Conexiune WiFi eşuată"
|
||||
STR_AUTHENTICATING: "Se autentifică..."
|
||||
STR_AUTH_SUCCESS: "Autentificare reuşită!"
|
||||
STR_KOREADER_AUTH: "Autentificare KOReader"
|
||||
STR_SYNC_READY: "Sincronizare KOReader gata de utilizare"
|
||||
STR_AUTH_FAILED: "Autentificare eşuată"
|
||||
STR_DONE: "Gata"
|
||||
STR_CLEAR_CACHE_WARNING_1: "Aceasta va şterge tot cache-ul de lectură."
|
||||
STR_CLEAR_CACHE_WARNING_2: "Tot progresul de lectură va fi pierdut!"
|
||||
STR_CLEAR_CACHE_WARNING_3: "Cărţile vor trebui reindexate"
|
||||
STR_CLEAR_CACHE_WARNING_4: "când vor fi deschise din nou."
|
||||
STR_CLEARING_CACHE: "Se şterge cache-ul..."
|
||||
STR_CACHE_CLEARED: "Cache şters"
|
||||
STR_ITEMS_REMOVED: "elemente eliminate"
|
||||
STR_FAILED_LOWER: "eşuat"
|
||||
STR_CLEAR_CACHE_FAILED: "ştergerea cache-ului a eşuat"
|
||||
STR_CHECK_SERIAL_OUTPUT: "Verificaţi ieşirea serială pentru detalii"
|
||||
STR_DARK: "Întunecat"
|
||||
STR_LIGHT: "Luminos"
|
||||
STR_CUSTOM: "Personalizat"
|
||||
STR_COVER: "Copertă"
|
||||
STR_NONE_OPT: "Niciunul"
|
||||
STR_FIT: "Potrivit"
|
||||
STR_CROP: "Decupat"
|
||||
STR_NO_PROGRESS: "Fără progres"
|
||||
STR_FULL_OPT: "Complet"
|
||||
STR_NEVER: "Niciodată"
|
||||
STR_IN_READER: "În lectură"
|
||||
STR_ALWAYS: "Întotdeauna"
|
||||
STR_IGNORE: "Ignoră"
|
||||
STR_SLEEP: "Repaus"
|
||||
STR_PAGE_TURN: "Răsfoire pagină"
|
||||
STR_PORTRAIT: "Vertical"
|
||||
STR_LANDSCAPE_CW: "Orizontal dreapta"
|
||||
STR_INVERTED: "Invers"
|
||||
STR_LANDSCAPE_CCW: "Orizontal stânga"
|
||||
STR_FRONT_LAYOUT_BCLR: "Înapoi, Cnfrm, St, Dr"
|
||||
STR_FRONT_LAYOUT_LRBC: "St, Dr, Înapoi, Cnfrm"
|
||||
STR_FRONT_LAYOUT_LBCR: "St, Înapoi, Cnfrm, Dr"
|
||||
STR_PREV_NEXT: "Înainte/Înapoi"
|
||||
STR_NEXT_PREV: "Înapoi/Înainte"
|
||||
STR_BOOKERLY: "Bookerly"
|
||||
STR_NOTO_SANS: "Noto Sans"
|
||||
STR_OPEN_DYSLEXIC: "Open Dyslexic"
|
||||
STR_SMALL: "Mic"
|
||||
STR_MEDIUM: "Mediu"
|
||||
STR_LARGE: "Mare"
|
||||
STR_X_LARGE: "Foarte mare"
|
||||
STR_TIGHT: "Strâns"
|
||||
STR_NORMAL: "Normal"
|
||||
STR_WIDE: "Larg"
|
||||
STR_JUSTIFY: "Aliniere"
|
||||
STR_ALIGN_LEFT: "Stânga"
|
||||
STR_CENTER: "Centru"
|
||||
STR_ALIGN_RIGHT: "Dreapta"
|
||||
STR_MIN_1: "1 min"
|
||||
STR_MIN_5: "5 min"
|
||||
STR_MIN_10: "10 min"
|
||||
STR_MIN_15: "15 min"
|
||||
STR_MIN_30: "30 min"
|
||||
STR_PAGES_1: "1 pagină"
|
||||
STR_PAGES_5: "5 pagini"
|
||||
STR_PAGES_10: "10 pagini"
|
||||
STR_PAGES_15: "15 pagini"
|
||||
STR_PAGES_30: "30 pagini"
|
||||
STR_UPDATE: "Actualizare"
|
||||
STR_CHECKING_UPDATE: "Se verifică actualizările..."
|
||||
STR_NEW_UPDATE: "Nouă actualizare disponibilă!"
|
||||
STR_CURRENT_VERSION: "Versiune curentă: "
|
||||
STR_NEW_VERSION: "Noua versiune: "
|
||||
STR_UPDATING: "Se actualizează..."
|
||||
STR_NO_UPDATE: "Nicio actualizare disponibilă"
|
||||
STR_UPDATE_FAILED: "Actualizare eşuată"
|
||||
STR_UPDATE_COMPLETE: "Actualizare completă"
|
||||
STR_POWER_ON_HINT: "Apăsaţi şi menţineţi apăsat întrerupătorul pentru a porni din nou"
|
||||
STR_EXTERNAL_FONT: "Font extern"
|
||||
STR_BUILTIN_DISABLED: "Încorporat (Dezactivat)"
|
||||
STR_NO_ENTRIES: "Niciun rezultat găsit"
|
||||
STR_DOWNLOADING: "Se descarcă..."
|
||||
STR_DOWNLOAD_FAILED: "Descărcare eşuată"
|
||||
STR_ERROR_MSG: "Eroare:"
|
||||
STR_UNNAMED: "Fără nume"
|
||||
STR_NO_SERVER_URL: "Niciun URL de server configurat"
|
||||
STR_FETCH_FEED_FAILED: "Eşec la preluarea feed-ului"
|
||||
STR_PARSE_FEED_FAILED: "Eşec la analizarea feed-ului"
|
||||
STR_NETWORK_PREFIX: "Reţea: "
|
||||
STR_IP_ADDRESS_PREFIX: "Adresă IP: "
|
||||
STR_SCAN_QR_WIFI_HINT: "sau scanaţi codul QR cu telefonul pentru a vă conecta la Wifi."
|
||||
STR_ERROR_GENERAL_FAILURE: "Eroare: Eşec general"
|
||||
STR_ERROR_NETWORK_NOT_FOUND: "Eroare: Reţea negăsită"
|
||||
STR_ERROR_CONNECTION_TIMEOUT: "Eroare: Timp de conectare depăşit"
|
||||
STR_SD_CARD: "Card SD"
|
||||
STR_BACK: "« Înapoi"
|
||||
STR_EXIT: "« Ieşire"
|
||||
STR_HOME: "« Acasă"
|
||||
STR_SAVE: "« Salvare"
|
||||
STR_SELECT: "Selectează"
|
||||
STR_TOGGLE: "Schimbă"
|
||||
STR_CONFIRM: "Confirmă"
|
||||
STR_CANCEL: "Anulare"
|
||||
STR_CONNECT: "Conectare"
|
||||
STR_OPEN: "Deschidere"
|
||||
STR_DOWNLOAD: "Descarcă"
|
||||
STR_RETRY: "Reîncercare"
|
||||
STR_YES: "Da"
|
||||
STR_NO: "Nu"
|
||||
STR_STATE_ON: "Pornit"
|
||||
STR_STATE_OFF: "Oprit"
|
||||
STR_SET: "Setare"
|
||||
STR_NOT_SET: "Neconfigurat"
|
||||
STR_DIR_LEFT: "Stânga"
|
||||
STR_DIR_RIGHT: "Dreapta"
|
||||
STR_DIR_UP: "Sus"
|
||||
STR_DIR_DOWN: "Jos"
|
||||
STR_CAPS_ON: "CAPS"
|
||||
STR_CAPS_OFF: "caps"
|
||||
STR_OK_BUTTON: "OK"
|
||||
STR_ON_MARKER: "[ON]"
|
||||
STR_SLEEP_COVER_FILTER: "Filtru ecran de repaus"
|
||||
STR_FILTER_CONTRAST: "Contrast"
|
||||
STR_STATUS_BAR_FULL_PERCENT: "Complet cu procentaj"
|
||||
STR_STATUS_BAR_FULL_BOOK: "Complet cu bara de carte"
|
||||
STR_STATUS_BAR_BOOK_ONLY: "Doar bara de carte"
|
||||
STR_STATUS_BAR_FULL_CHAPTER: "Complet cu bara de capitol"
|
||||
STR_UI_THEME: "Tema UI"
|
||||
STR_THEME_CLASSIC: "Clasic"
|
||||
STR_THEME_LYRA: "Lyra"
|
||||
STR_SUNLIGHT_FADING_FIX: "Corecţie estompare lumină"
|
||||
STR_REMAP_FRONT_BUTTONS: "Remapare butoane frontale"
|
||||
STR_OPDS_BROWSER: "Browser OPDS"
|
||||
STR_COVER_CUSTOM: "Copertă + Personalizat"
|
||||
STR_RECENTS: "Recente"
|
||||
STR_MENU_RECENT_BOOKS: "Cărţi recente"
|
||||
STR_NO_RECENT_BOOKS: "Nicio carte recentă"
|
||||
STR_CALIBRE_DESC: "Utilizaţi transferurile wireless ale dispozitivului Calibre"
|
||||
STR_FORGET_AND_REMOVE: "Uitaţi reţeaua şi eliminaţi parola salvată?"
|
||||
STR_FORGET_BUTTON: "Uitaţi"
|
||||
STR_CALIBRE_STARTING: "Pornirea Calibre..."
|
||||
STR_CALIBRE_SETUP: "Configurare"
|
||||
STR_CALIBRE_STATUS: "Stare"
|
||||
STR_CLEAR_BUTTON: "ştergere"
|
||||
STR_DEFAULT_VALUE: "Implicit"
|
||||
STR_REMAP_PROMPT: "Apăsaţi un buton frontal pentru fiecare rol"
|
||||
STR_UNASSIGNED: "Neatribuit"
|
||||
STR_ALREADY_ASSIGNED: "Deja atribuit"
|
||||
STR_REMAP_RESET_HINT: "Buton lateral Sus: Resetaţi la aspectul implicit"
|
||||
STR_REMAP_CANCEL_HINT: "Buton lateral Jos: Anulaţi remaparea"
|
||||
STR_HW_BACK_LABEL: "Înapoi (butonul 1)"
|
||||
STR_HW_CONFIRM_LABEL: "Confirmare (butonul 2)"
|
||||
STR_HW_LEFT_LABEL: "Stânga (butonul 3)"
|
||||
STR_HW_RIGHT_LABEL: "Dreapta (butonul 4)"
|
||||
STR_GO_TO_PERCENT: "Săriţi la %"
|
||||
STR_GO_HOME_BUTTON: "Acasă"
|
||||
STR_SYNC_PROGRESS: "Progres sincronizare"
|
||||
STR_DELETE_CACHE: "Ştergere cache cărţi"
|
||||
STR_CHAPTER_PREFIX: "Capitol: "
|
||||
STR_PAGES_SEPARATOR: " pagini | "
|
||||
STR_BOOK_PREFIX: "Carte: "
|
||||
STR_KBD_SHIFT: "shift"
|
||||
STR_KBD_SHIFT_CAPS: "SHIFT"
|
||||
STR_KBD_LOCK: "LOCK"
|
||||
STR_CALIBRE_URL_HINT: "Pentru Calibre, adăugaţi /opds la URL"
|
||||
STR_PERCENT_STEP_HINT: "Stânga/Dreapta: 1% Sus/Jos: 10%"
|
||||
STR_SYNCING_TIME: "Timp de sincronizare..."
|
||||
STR_CALC_HASH: "Calcularea hash-ului documentului..."
|
||||
STR_HASH_FAILED: "Eşec la calcularea hash-ului documentului"
|
||||
STR_FETCH_PROGRESS: "Preluarea progresului de la distanţă..."
|
||||
STR_UPLOAD_PROGRESS: "Încărcarea progresului..."
|
||||
STR_NO_CREDENTIALS_MSG: "Nicio acreditare configurată"
|
||||
STR_KOREADER_SETUP_HINT: "Configuraţi contul KOReader în setări"
|
||||
STR_PROGRESS_FOUND: "Progres găsit!"
|
||||
STR_REMOTE_LABEL: "Remote:"
|
||||
STR_LOCAL_LABEL: "Local:"
|
||||
STR_PAGE_OVERALL_FORMAT: "Pagina %d, %.2f%% din total"
|
||||
STR_PAGE_TOTAL_OVERALL_FORMAT: "Pagina %d/%d, %.2f%% din total"
|
||||
STR_DEVICE_FROM_FORMAT: " De la: %s"
|
||||
STR_APPLY_REMOTE: "Aplică progresul remote"
|
||||
STR_UPLOAD_LOCAL: "Încărcaţi progresul local"
|
||||
STR_NO_REMOTE_MSG: "Niciun progres remote găsit"
|
||||
STR_UPLOAD_PROMPT: "Încărcaţi poziţia curentă?"
|
||||
STR_UPLOAD_SUCCESS: "Progres încărcat!"
|
||||
STR_SYNC_FAILED_MSG: "Sincronizare eşuată"
|
||||
STR_SECTION_PREFIX: "Secţiune "
|
||||
STR_UPLOAD: "Încărcare"
|
||||
STR_BOOK_S_STYLE: "Stilul cărţii"
|
||||
STR_EMBEDDED_STYLE: "Stil încorporat"
|
||||
STR_OPDS_SERVER_URL: "URL server OPDS"
|
||||
STR_SYNC_CLOCK: "Sync Clock"
|
||||
STR_TIME_SYNCED: "Time synced!"
|
||||
STR_AUTO_NTP_SYNC: "Auto Sync on Boot"
|
||||
STR_ADD_SERVER: "Adaugă server"
|
||||
STR_SERVER_NAME: "Numele serverului"
|
||||
STR_NO_SERVERS: "Niciun server OPDS configurat"
|
||||
STR_DELETE_SERVER: "Șterge serverul"
|
||||
STR_DELETE_CONFIRM: "Ștergi acest server?"
|
||||
STR_OPDS_SERVERS: "Servere OPDS"
|
||||
STR_SAVE_HERE: "Salvează aici"
|
||||
STR_SELECT_FOLDER: "Selectează dosar"
|
||||
STR_DOWNLOAD_PATH: "Cale descărcare"
|
||||
@@ -267,7 +267,7 @@ STR_MENU_RECENT_BOOKS: "Недавние книги"
|
||||
STR_NO_RECENT_BOOKS: "Нет недавних книг"
|
||||
STR_CALIBRE_DESC: "Использовать беспроводную передачу Calibre"
|
||||
STR_FORGET_AND_REMOVE: "Забыть сеть и удалить сохранённый пароль?"
|
||||
STR_FORGET_BUTTON: "Забыть сеть"
|
||||
STR_FORGET_BUTTON: "Забыть"
|
||||
STR_CALIBRE_STARTING: "Запуск Calibre..."
|
||||
STR_CALIBRE_SETUP: "Настройка"
|
||||
STR_CALIBRE_STATUS: "Статус"
|
||||
@@ -341,3 +341,15 @@ STR_INDEXING_DISPLAY: "Отображение индексации"
|
||||
STR_INDEXING_POPUP: "Popup"
|
||||
STR_INDEXING_STATUS_TEXT: "Текст в строке"
|
||||
STR_INDEXING_STATUS_ICON: "Иконка в строке"
|
||||
STR_SYNC_CLOCK: "Sync Clock"
|
||||
STR_TIME_SYNCED: "Time synced!"
|
||||
STR_AUTO_NTP_SYNC: "Auto Sync on Boot"
|
||||
STR_ADD_SERVER: "Добавить сервер"
|
||||
STR_SERVER_NAME: "Имя сервера"
|
||||
STR_NO_SERVERS: "Нет настроенных серверов OPDS"
|
||||
STR_DELETE_SERVER: "Удалить сервер"
|
||||
STR_DELETE_CONFIRM: "Удалить этот сервер?"
|
||||
STR_OPDS_SERVERS: "Серверы OPDS"
|
||||
STR_SAVE_HERE: "Сохранить здесь"
|
||||
STR_SELECT_FOLDER: "Выбрать папку"
|
||||
STR_DOWNLOAD_PATH: "Путь загрузки"
|
||||
|
||||
@@ -267,7 +267,7 @@ STR_MENU_RECENT_BOOKS: "Libros recientes"
|
||||
STR_NO_RECENT_BOOKS: "No hay libros recientes"
|
||||
STR_CALIBRE_DESC: "Utilice las transferencias dispositivos inalámbricos de calibre"
|
||||
STR_FORGET_AND_REMOVE: "Olvidar la red y eliminar la contraseña guardada?"
|
||||
STR_FORGET_BUTTON: "Olvidar la red"
|
||||
STR_FORGET_BUTTON: "Olvidar"
|
||||
STR_CALIBRE_STARTING: "Iniciando calibre..."
|
||||
STR_CALIBRE_SETUP: "Configuración"
|
||||
STR_CALIBRE_STATUS: "Estado"
|
||||
@@ -341,3 +341,15 @@ STR_INDEXING_DISPLAY: "Mostrar indexación"
|
||||
STR_INDEXING_POPUP: "Popup"
|
||||
STR_INDEXING_STATUS_TEXT: "Texto barra estado"
|
||||
STR_INDEXING_STATUS_ICON: "Icono barra estado"
|
||||
STR_SYNC_CLOCK: "Sync Clock"
|
||||
STR_TIME_SYNCED: "Time synced!"
|
||||
STR_AUTO_NTP_SYNC: "Auto Sync on Boot"
|
||||
STR_ADD_SERVER: "Añadir servidor"
|
||||
STR_SERVER_NAME: "Nombre del servidor"
|
||||
STR_NO_SERVERS: "No hay servidores OPDS configurados"
|
||||
STR_DELETE_SERVER: "Eliminar servidor"
|
||||
STR_DELETE_CONFIRM: "¿Eliminar este servidor?"
|
||||
STR_OPDS_SERVERS: "Servidores OPDS"
|
||||
STR_SAVE_HERE: "Guardar aquí"
|
||||
STR_SELECT_FOLDER: "Seleccionar carpeta"
|
||||
STR_DOWNLOAD_PATH: "Ruta de descarga"
|
||||
|
||||
@@ -267,7 +267,7 @@ STR_MENU_RECENT_BOOKS: "Senaste böckerna"
|
||||
STR_NO_RECENT_BOOKS: "Inga senaste böcker"
|
||||
STR_CALIBRE_DESC: "Använd Calibres trådlösa enhetsöverföring"
|
||||
STR_FORGET_AND_REMOVE: "Glöm nätverk och ta bort sparat lösenord?"
|
||||
STR_FORGET_BUTTON: "Glöm nätverk"
|
||||
STR_FORGET_BUTTON: "Glöm"
|
||||
STR_CALIBRE_STARTING: "Starar Calibre…"
|
||||
STR_CALIBRE_SETUP: "Inställning"
|
||||
STR_CALIBRE_STATUS: "Status"
|
||||
@@ -341,3 +341,15 @@ STR_INDEXING_DISPLAY: "Indexeringsvisning"
|
||||
STR_INDEXING_POPUP: "Popup"
|
||||
STR_INDEXING_STATUS_TEXT: "Statusfältstext"
|
||||
STR_INDEXING_STATUS_ICON: "Statusfältsikon"
|
||||
STR_SYNC_CLOCK: "Sync Clock"
|
||||
STR_TIME_SYNCED: "Time synced!"
|
||||
STR_AUTO_NTP_SYNC: "Auto Sync on Boot"
|
||||
STR_ADD_SERVER: "Lägg till server"
|
||||
STR_SERVER_NAME: "Servernamn"
|
||||
STR_NO_SERVERS: "Inga OPDS-servrar konfigurerade"
|
||||
STR_DELETE_SERVER: "Ta bort server"
|
||||
STR_DELETE_CONFIRM: "Ta bort denna server?"
|
||||
STR_OPDS_SERVERS: "OPDS-servrar"
|
||||
STR_SAVE_HERE: "Spara här"
|
||||
STR_SELECT_FOLDER: "Välj mapp"
|
||||
STR_DOWNLOAD_PATH: "Nedladdningssökväg"
|
||||
|
||||
497
lib/KOReaderSync/ChapterXPathIndexer.cpp
Normal file
497
lib/KOReaderSync/ChapterXPathIndexer.cpp
Normal file
@@ -0,0 +1,497 @@
|
||||
#include "ChapterXPathIndexer.h"
|
||||
|
||||
#include <Logging.h>
|
||||
#include <expat.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cstdlib>
|
||||
#include <limits>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
namespace {
|
||||
|
||||
// Anchor used for both mapping directions.
|
||||
// textOffset is counted as visible (non-whitespace) bytes from chapter start.
|
||||
// xpath points to the nearest element path at/near that offset.
|
||||
|
||||
struct XPathAnchor {
|
||||
size_t textOffset = 0;
|
||||
std::string xpath;
|
||||
std::string xpathNoIndex; // precomputed removeIndices(xpath)
|
||||
};
|
||||
|
||||
struct StackNode {
|
||||
std::string tag;
|
||||
int index = 1;
|
||||
bool hasTextAnchor = false;
|
||||
};
|
||||
|
||||
// ParserState is intentionally ephemeral and created per lookup call.
|
||||
// It holds only one spine parse worth of data to avoid retaining structures
|
||||
// that would increase long-lived heap usage on the ESP32-C3.
|
||||
struct ParserState {
|
||||
explicit ParserState(const int spineIndex) : spineIndex(spineIndex) { siblingCounters.emplace_back(); }
|
||||
|
||||
int spineIndex = 0;
|
||||
int skipDepth = -1;
|
||||
size_t totalTextBytes = 0;
|
||||
|
||||
std::vector<StackNode> stack;
|
||||
std::vector<std::unordered_map<std::string, int>> siblingCounters;
|
||||
std::vector<XPathAnchor> anchors;
|
||||
|
||||
std::string baseXPath() const { return "/body/DocFragment[" + std::to_string(spineIndex + 1) + "]/body"; }
|
||||
|
||||
// Canonicalize incoming KOReader XPath before matching:
|
||||
// - remove all whitespace
|
||||
// - lowercase tags
|
||||
// - strip optional trailing /text()
|
||||
// - strip trailing slash
|
||||
static std::string normalizeXPath(const std::string& input) {
|
||||
if (input.empty()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
std::string out;
|
||||
out.reserve(input.size());
|
||||
for (char c : input) {
|
||||
const unsigned char uc = static_cast<unsigned char>(c);
|
||||
if (std::isspace(uc)) {
|
||||
continue;
|
||||
}
|
||||
out.push_back(static_cast<char>(std::tolower(uc)));
|
||||
}
|
||||
|
||||
const std::string textSuffix = "/text()";
|
||||
const size_t textPos = out.rfind(textSuffix);
|
||||
if (textPos != std::string::npos && textPos + textSuffix.size() == out.size()) {
|
||||
out.erase(textPos);
|
||||
}
|
||||
|
||||
while (!out.empty() && out.back() == '/') {
|
||||
out.pop_back();
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
// Remove bracketed numeric predicates so paths can be compared even when
|
||||
// index counters differ between parser implementations.
|
||||
static std::string removeIndices(const std::string& xpath) {
|
||||
std::string out;
|
||||
out.reserve(xpath.size());
|
||||
|
||||
bool inBracket = false;
|
||||
for (char c : xpath) {
|
||||
if (c == '[') {
|
||||
inBracket = true;
|
||||
continue;
|
||||
}
|
||||
if (c == ']') {
|
||||
inBracket = false;
|
||||
continue;
|
||||
}
|
||||
if (!inBracket) {
|
||||
out.push_back(c);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
static int pathDepth(const std::string& xpath) {
|
||||
int depth = 0;
|
||||
for (char c : xpath) {
|
||||
if (c == '/') {
|
||||
depth++;
|
||||
}
|
||||
}
|
||||
return depth;
|
||||
}
|
||||
|
||||
// Resolve a path to the best anchor offset.
|
||||
// If exact node path is not found, progressively trim trailing segments and
|
||||
// match ancestors to obtain a stable approximate location.
|
||||
bool pickBestAnchorByPath(const std::string& targetPath, const bool ignoreIndices, size_t& outTextOffset,
|
||||
bool& outExact) const {
|
||||
if (targetPath.empty() || anchors.empty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const std::string normalizedTarget = ignoreIndices ? removeIndices(targetPath) : targetPath;
|
||||
std::string probe = normalizedTarget;
|
||||
bool exactProbe = true;
|
||||
|
||||
while (!probe.empty()) {
|
||||
int bestDepth = -1;
|
||||
size_t bestOffset = 0;
|
||||
bool found = false;
|
||||
|
||||
for (const auto& anchor : anchors) {
|
||||
const std::string& anchorPath = ignoreIndices ? anchor.xpathNoIndex : anchor.xpath;
|
||||
if (anchorPath == probe) {
|
||||
const int depth = pathDepth(anchorPath);
|
||||
if (!found || depth > bestDepth || (depth == bestDepth && anchor.textOffset < bestOffset)) {
|
||||
found = true;
|
||||
bestDepth = depth;
|
||||
bestOffset = anchor.textOffset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (found) {
|
||||
outTextOffset = bestOffset;
|
||||
outExact = exactProbe;
|
||||
return true;
|
||||
}
|
||||
|
||||
const size_t lastSlash = probe.find_last_of('/');
|
||||
if (lastSlash == std::string::npos || lastSlash == 0) {
|
||||
break;
|
||||
}
|
||||
probe.erase(lastSlash);
|
||||
exactProbe = false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
static std::string toLower(std::string value) {
|
||||
for (char& c : value) {
|
||||
c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// Elements that should not contribute text position anchors.
|
||||
static bool isSkippableTag(const std::string& tag) { return tag == "head" || tag == "script" || tag == "style"; }
|
||||
|
||||
static bool isWhitespaceOnly(const XML_Char* text, const int len) {
|
||||
for (int i = 0; i < len; i++) {
|
||||
if (!std::isspace(static_cast<unsigned char>(text[i]))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Count non-whitespace bytes to keep offsets stable against formatting-only
|
||||
// differences and indentation in source XHTML.
|
||||
static size_t countVisibleBytes(const XML_Char* text, const int len) {
|
||||
size_t count = 0;
|
||||
for (int i = 0; i < len; i++) {
|
||||
if (!std::isspace(static_cast<unsigned char>(text[i]))) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
int bodyDepth() const {
|
||||
for (int i = static_cast<int>(stack.size()) - 1; i >= 0; i--) {
|
||||
if (stack[i].tag == "body") {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
bool insideBody() const { return bodyDepth() >= 0; }
|
||||
|
||||
std::string currentXPath() const {
|
||||
const int bodyIdx = bodyDepth();
|
||||
if (bodyIdx < 0) {
|
||||
return baseXPath();
|
||||
}
|
||||
|
||||
std::string xpath = baseXPath();
|
||||
for (size_t i = static_cast<size_t>(bodyIdx + 1); i < stack.size(); i++) {
|
||||
xpath += "/" + stack[i].tag + "[" + std::to_string(stack[i].index) + "]";
|
||||
}
|
||||
return xpath;
|
||||
}
|
||||
|
||||
// Adds first anchor for an element when text begins and periodic anchors in
|
||||
// longer runs so matching has sufficient granularity without exploding memory.
|
||||
void addAnchorIfNeeded() {
|
||||
if (!insideBody() || stack.empty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!stack.back().hasTextAnchor) {
|
||||
const std::string xpath = currentXPath();
|
||||
anchors.push_back({totalTextBytes, xpath, removeIndices(xpath)});
|
||||
stack.back().hasTextAnchor = true;
|
||||
} else if (anchors.empty() || totalTextBytes - anchors.back().textOffset >= 192) {
|
||||
const std::string xpath = currentXPath();
|
||||
if (anchors.empty() || anchors.back().xpath != xpath) {
|
||||
anchors.push_back({totalTextBytes, xpath, removeIndices(xpath)});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void onStartElement(const XML_Char* rawName) {
|
||||
std::string name = toLower(rawName ? rawName : "");
|
||||
const size_t depth = stack.size();
|
||||
|
||||
if (siblingCounters.size() <= depth) {
|
||||
siblingCounters.resize(depth + 1);
|
||||
}
|
||||
const int siblingIndex = ++siblingCounters[depth][name];
|
||||
|
||||
stack.push_back({name, siblingIndex, false});
|
||||
siblingCounters.emplace_back();
|
||||
|
||||
if (skipDepth < 0 && isSkippableTag(name)) {
|
||||
skipDepth = static_cast<int>(stack.size()) - 1;
|
||||
}
|
||||
}
|
||||
|
||||
void onEndElement() {
|
||||
if (stack.empty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (skipDepth == static_cast<int>(stack.size()) - 1) {
|
||||
skipDepth = -1;
|
||||
}
|
||||
|
||||
stack.pop_back();
|
||||
if (!siblingCounters.empty()) {
|
||||
siblingCounters.pop_back();
|
||||
}
|
||||
}
|
||||
|
||||
void onCharacterData(const XML_Char* text, const int len) {
|
||||
if (skipDepth >= 0 || len <= 0 || !insideBody() || isWhitespaceOnly(text, len)) {
|
||||
return;
|
||||
}
|
||||
|
||||
addAnchorIfNeeded();
|
||||
totalTextBytes += countVisibleBytes(text, len);
|
||||
}
|
||||
|
||||
std::string chooseXPath(const float intraSpineProgress) const {
|
||||
if (anchors.empty()) {
|
||||
return baseXPath();
|
||||
}
|
||||
if (totalTextBytes == 0) {
|
||||
return anchors.front().xpath;
|
||||
}
|
||||
|
||||
const float clampedProgress = std::max(0.0f, std::min(1.0f, intraSpineProgress));
|
||||
const size_t target = static_cast<size_t>(clampedProgress * static_cast<float>(totalTextBytes));
|
||||
|
||||
// upper_bound returns the first anchor strictly after target; step back to get
|
||||
// the last anchor at-or-before target (the element the user is currently inside).
|
||||
auto it = std::upper_bound(anchors.begin(), anchors.end(), target,
|
||||
[](const size_t value, const XPathAnchor& anchor) { return value < anchor.textOffset; });
|
||||
if (it != anchors.begin()) {
|
||||
--it;
|
||||
}
|
||||
return it->xpath;
|
||||
}
|
||||
|
||||
// Convert path -> progress ratio by matching to nearest available anchor.
|
||||
bool chooseProgressForXPath(const std::string& xpath, float& outIntraSpineProgress, bool& outExactMatch) const {
|
||||
if (anchors.empty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const std::string normalized = normalizeXPath(xpath);
|
||||
if (normalized.empty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
size_t matchedOffset = 0;
|
||||
bool exact = false;
|
||||
const char* matchTier = nullptr;
|
||||
|
||||
bool matched = pickBestAnchorByPath(normalized, false, matchedOffset, exact);
|
||||
if (matched) {
|
||||
matchTier = exact ? "exact" : "ancestor";
|
||||
} else {
|
||||
bool exactRaw = false;
|
||||
matched = pickBestAnchorByPath(normalized, true, matchedOffset, exactRaw);
|
||||
if (matched) {
|
||||
exact = false;
|
||||
matchTier = exactRaw ? "index-insensitive" : "index-insensitive-ancestor";
|
||||
}
|
||||
}
|
||||
|
||||
if (!matched) {
|
||||
LOG_DBG("KOX", "Reverse: spine=%d no anchor match for '%s' (%zu anchors)", spineIndex, normalized.c_str(),
|
||||
anchors.size());
|
||||
return false;
|
||||
}
|
||||
|
||||
outExactMatch = exact;
|
||||
if (totalTextBytes == 0) {
|
||||
outIntraSpineProgress = 0.0f;
|
||||
LOG_DBG("KOX", "Reverse: spine=%d %s match offset=%zu -> progress=0.0 (no text)", spineIndex, matchTier,
|
||||
matchedOffset);
|
||||
return true;
|
||||
}
|
||||
|
||||
outIntraSpineProgress = static_cast<float>(matchedOffset) / static_cast<float>(totalTextBytes);
|
||||
outIntraSpineProgress = std::max(0.0f, std::min(1.0f, outIntraSpineProgress));
|
||||
LOG_DBG("KOX", "Reverse: spine=%d %s match offset=%zu/%zu -> progress=%.3f", spineIndex, matchTier, matchedOffset,
|
||||
totalTextBytes, outIntraSpineProgress);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
void XMLCALL onStartElement(void* userData, const XML_Char* name, const XML_Char**) {
|
||||
auto* state = static_cast<ParserState*>(userData);
|
||||
state->onStartElement(name);
|
||||
}
|
||||
|
||||
void XMLCALL onEndElement(void* userData, const XML_Char*) {
|
||||
auto* state = static_cast<ParserState*>(userData);
|
||||
state->onEndElement();
|
||||
}
|
||||
|
||||
void XMLCALL onCharacterData(void* userData, const XML_Char* text, const int len) {
|
||||
auto* state = static_cast<ParserState*>(userData);
|
||||
state->onCharacterData(text, len);
|
||||
}
|
||||
|
||||
void XMLCALL onDefaultHandlerExpand(void* userData, const XML_Char* text, const int len) {
|
||||
// The default handler fires for comments, PIs, DOCTYPE, and entity references.
|
||||
// Only forward entity references (&..;) to avoid skewing text offsets with
|
||||
// non-visible markup.
|
||||
if (len < 3 || text[0] != '&' || text[len - 1] != ';') {
|
||||
return;
|
||||
}
|
||||
for (int i = 1; i < len - 1; ++i) {
|
||||
if (text[i] == '<' || text[i] == '>') {
|
||||
return;
|
||||
}
|
||||
}
|
||||
auto* state = static_cast<ParserState*>(userData);
|
||||
state->onCharacterData(text, len);
|
||||
}
|
||||
|
||||
// Parse one spine item and return a fully populated ParserState.
|
||||
// Returns std::nullopt if validation, I/O, or XML parse fails.
|
||||
static std::optional<ParserState> parseSpineItem(const std::shared_ptr<Epub>& epub, const int spineIndex) {
|
||||
if (!epub || spineIndex < 0 || spineIndex >= epub->getSpineItemsCount()) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
const auto spineItem = epub->getSpineItem(spineIndex);
|
||||
if (spineItem.href.empty()) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
size_t chapterSize = 0;
|
||||
uint8_t* chapterBytes = epub->readItemContentsToBytes(spineItem.href, &chapterSize, false);
|
||||
if (!chapterBytes || chapterSize == 0) {
|
||||
free(chapterBytes);
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
ParserState state(spineIndex);
|
||||
|
||||
XML_Parser parser = XML_ParserCreate(nullptr);
|
||||
if (!parser) {
|
||||
free(chapterBytes);
|
||||
LOG_ERR("KOX", "Failed to allocate XML parser for spine=%d", spineIndex);
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
XML_SetUserData(parser, &state);
|
||||
XML_SetElementHandler(parser, onStartElement, onEndElement);
|
||||
XML_SetCharacterDataHandler(parser, onCharacterData);
|
||||
XML_SetDefaultHandlerExpand(parser, onDefaultHandlerExpand);
|
||||
|
||||
const bool parseOk = XML_Parse(parser, reinterpret_cast<const char*>(chapterBytes), static_cast<int>(chapterSize),
|
||||
XML_TRUE) != XML_STATUS_ERROR;
|
||||
|
||||
if (!parseOk) {
|
||||
LOG_ERR("KOX", "XPath parse failed for spine=%d at line %lu: %s", spineIndex, XML_GetCurrentLineNumber(parser),
|
||||
XML_ErrorString(XML_GetErrorCode(parser)));
|
||||
}
|
||||
|
||||
XML_ParserFree(parser);
|
||||
free(chapterBytes);
|
||||
|
||||
if (!parseOk) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string ChapterXPathIndexer::findXPathForProgress(const std::shared_ptr<Epub>& epub, const int spineIndex,
|
||||
const float intraSpineProgress) {
|
||||
const auto state = parseSpineItem(epub, spineIndex);
|
||||
if (!state) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const std::string result = state->chooseXPath(intraSpineProgress);
|
||||
LOG_DBG("KOX", "Forward: spine=%d progress=%.3f anchors=%zu textBytes=%zu -> %s", spineIndex, intraSpineProgress,
|
||||
state->anchors.size(), state->totalTextBytes, result.c_str());
|
||||
return result;
|
||||
}
|
||||
|
||||
bool ChapterXPathIndexer::findProgressForXPath(const std::shared_ptr<Epub>& epub, const int spineIndex,
|
||||
const std::string& xpath, float& outIntraSpineProgress,
|
||||
bool& outExactMatch) {
|
||||
outIntraSpineProgress = 0.0f;
|
||||
outExactMatch = false;
|
||||
|
||||
if (xpath.empty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const auto state = parseSpineItem(epub, spineIndex);
|
||||
if (!state) {
|
||||
return false;
|
||||
}
|
||||
|
||||
LOG_DBG("KOX", "Reverse: spine=%d anchors=%zu textBytes=%zu for '%s'", spineIndex, state->anchors.size(),
|
||||
state->totalTextBytes, xpath.c_str());
|
||||
return state->chooseProgressForXPath(xpath, outIntraSpineProgress, outExactMatch);
|
||||
}
|
||||
|
||||
bool ChapterXPathIndexer::tryExtractSpineIndexFromXPath(const std::string& xpath, int& outSpineIndex) {
|
||||
outSpineIndex = -1;
|
||||
if (xpath.empty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const std::string normalized = ParserState::normalizeXPath(xpath);
|
||||
const std::string key = "/docfragment[";
|
||||
const size_t pos = normalized.find(key);
|
||||
if (pos == std::string::npos) {
|
||||
LOG_DBG("KOX", "No DocFragment in xpath: '%s'", xpath.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
const size_t start = pos + key.size();
|
||||
size_t end = start;
|
||||
while (end < normalized.size() && std::isdigit(static_cast<unsigned char>(normalized[end]))) {
|
||||
end++;
|
||||
}
|
||||
|
||||
if (end == start || end >= normalized.size() || normalized[end] != ']') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const std::string value = normalized.substr(start, end - start);
|
||||
const long parsed = std::strtol(value.c_str(), nullptr, 10);
|
||||
// KOReader uses 1-based DocFragment indices; convert to 0-based spine index.
|
||||
if (parsed < 1 || parsed > std::numeric_limits<int>::max()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
outSpineIndex = static_cast<int>(parsed) - 1;
|
||||
return true;
|
||||
}
|
||||
67
lib/KOReaderSync/ChapterXPathIndexer.h
Normal file
67
lib/KOReaderSync/ChapterXPathIndexer.h
Normal file
@@ -0,0 +1,67 @@
|
||||
#pragma once
|
||||
|
||||
#include <Epub.h>
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
/**
|
||||
* Lightweight XPath/progress bridge for KOReader sync.
|
||||
*
|
||||
* Why this exists:
|
||||
* - CrossPoint stores reading position as chapter/page.
|
||||
* - KOReader sync uses XPath + percentage.
|
||||
*
|
||||
* This utility reparses exactly one spine XHTML item with Expat and builds
|
||||
* transient text anchors (<xpath, textOffset>) so we can translate in both
|
||||
* directions without keeping a full DOM in memory.
|
||||
*
|
||||
* Design constraints (ESP32-C3):
|
||||
* - No persistent full-book structures.
|
||||
* - Parse-on-demand and free memory immediately.
|
||||
* - Keep fallback behavior deterministic if parsing/matching fails.
|
||||
*/
|
||||
class ChapterXPathIndexer {
|
||||
public:
|
||||
/**
|
||||
* Convert an intra-spine progress ratio to the nearest element-level XPath.
|
||||
*
|
||||
* @param epub Loaded EPUB instance
|
||||
* @param spineIndex Current spine item index
|
||||
* @param intraSpineProgress Position within the spine item [0.0, 1.0]
|
||||
* @return Best matching XPath for KOReader, or empty string on failure
|
||||
*/
|
||||
static std::string findXPathForProgress(const std::shared_ptr<Epub>& epub, int spineIndex, float intraSpineProgress);
|
||||
|
||||
/**
|
||||
* Resolve a KOReader XPath to an intra-spine progress ratio.
|
||||
*
|
||||
* Matching strategy:
|
||||
* 1) exact anchor path match,
|
||||
* 2) index-insensitive path match,
|
||||
* 3) ancestor fallback.
|
||||
*
|
||||
* @param epub Loaded EPUB instance
|
||||
* @param spineIndex Spine item index to parse
|
||||
* @param xpath Incoming KOReader XPath
|
||||
* @param outIntraSpineProgress Resolved position within spine [0.0, 1.0]
|
||||
* @param outExactMatch True only for full exact path match
|
||||
* @return true if any match was resolved; false means caller should fallback
|
||||
*/
|
||||
static bool findProgressForXPath(const std::shared_ptr<Epub>& epub, int spineIndex, const std::string& xpath,
|
||||
float& outIntraSpineProgress, bool& outExactMatch);
|
||||
|
||||
/**
|
||||
* Parse DocFragment index from KOReader-style path segment:
|
||||
* /body/DocFragment[N]/body/...
|
||||
*
|
||||
* KOReader uses 1-based DocFragment indices; N is converted to the 0-based
|
||||
* spine index stored in outSpineIndex (i.e. outSpineIndex = N - 1).
|
||||
*
|
||||
* @param xpath KOReader XPath
|
||||
* @param outSpineIndex 0-based spine index derived from DocFragment[N]
|
||||
* @return true when DocFragment[N] exists and N is a valid integer >= 1
|
||||
* (converted to 0-based outSpineIndex); false otherwise
|
||||
*/
|
||||
static bool tryExtractSpineIndexFromXPath(const std::string& xpath, int& outSpineIndex);
|
||||
};
|
||||
@@ -4,6 +4,8 @@
|
||||
#include <Logging.h>
|
||||
#include <MD5Builder.h>
|
||||
|
||||
#include <functional>
|
||||
|
||||
namespace {
|
||||
// Extract filename from path (everything after last '/')
|
||||
std::string getFilename(const std::string& path) {
|
||||
@@ -15,6 +17,130 @@ std::string getFilename(const std::string& path) {
|
||||
}
|
||||
} // namespace
|
||||
|
||||
std::string KOReaderDocumentId::getCacheFilePath(const std::string& filePath) {
|
||||
// Mirror the Epub cache directory convention so the hash file shares the
|
||||
// same per-book folder as other cached data.
|
||||
return std::string("/.crosspoint/epub_") + std::to_string(std::hash<std::string>{}(filePath)) + "/koreader_docid.txt";
|
||||
}
|
||||
|
||||
std::string KOReaderDocumentId::loadCachedHash(const std::string& cacheFilePath, const size_t fileSize,
|
||||
const std::string& currentFingerprint) {
|
||||
if (!Storage.exists(cacheFilePath.c_str())) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const String content = Storage.readFile(cacheFilePath.c_str());
|
||||
if (content.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
// Format: "<filesize>:<fingerprint>\n<32-char-hex-hash>"
|
||||
const int newlinePos = content.indexOf('\n');
|
||||
if (newlinePos < 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const String header = content.substring(0, newlinePos);
|
||||
const int colonPos = header.indexOf(':');
|
||||
if (colonPos < 0) {
|
||||
LOG_DBG("KODoc", "Hash cache invalidated: header missing fingerprint");
|
||||
return "";
|
||||
}
|
||||
|
||||
const String sizeTok = header.substring(0, colonPos);
|
||||
const String fpTok = header.substring(colonPos + 1);
|
||||
|
||||
// Validate the filesize token – it must consist of ASCII digits and parse
|
||||
// correctly to the expected size.
|
||||
bool digitsOnly = true;
|
||||
for (size_t i = 0; i < sizeTok.length(); ++i) {
|
||||
const char ch = sizeTok[i];
|
||||
if (ch < '0' || ch > '9') {
|
||||
digitsOnly = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!digitsOnly) {
|
||||
LOG_DBG("KODoc", "Hash cache invalidated: size token not numeric ('%s')", sizeTok.c_str());
|
||||
return "";
|
||||
}
|
||||
|
||||
const long parsed = sizeTok.toInt();
|
||||
if (parsed < 0) {
|
||||
LOG_DBG("KODoc", "Hash cache invalidated: size token parse error ('%s')", sizeTok.c_str());
|
||||
return "";
|
||||
}
|
||||
const size_t cachedSize = static_cast<size_t>(parsed);
|
||||
if (cachedSize != fileSize) {
|
||||
LOG_DBG("KODoc", "Hash cache invalidated: file size or fingerprint changed (%zu -> %zu)", cachedSize, fileSize);
|
||||
return "";
|
||||
}
|
||||
|
||||
// Validate stored fingerprint format (8 hex characters)
|
||||
if (fpTok.length() != 8) {
|
||||
LOG_DBG("KODoc", "Hash cache invalidated: bad fingerprint length (%zu)", fpTok.length());
|
||||
return "";
|
||||
}
|
||||
for (size_t i = 0; i < fpTok.length(); ++i) {
|
||||
char c = fpTok[i];
|
||||
bool hex = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F');
|
||||
if (!hex) {
|
||||
LOG_DBG("KODoc", "Hash cache invalidated: non-hex character '%c' in fingerprint", c);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
String currentFpStr(currentFingerprint.c_str());
|
||||
if (fpTok != currentFpStr) {
|
||||
LOG_DBG("KODoc", "Hash cache invalidated: fingerprint changed (%s != %s)", fpTok.c_str(),
|
||||
currentFingerprint.c_str());
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
std::string hash = content.substring(newlinePos + 1).c_str();
|
||||
// Trim any trailing whitespace / line endings
|
||||
while (!hash.empty() && (hash.back() == '\n' || hash.back() == '\r' || hash.back() == ' ')) {
|
||||
hash.pop_back();
|
||||
}
|
||||
|
||||
// Hash must be exactly 32 hex characters.
|
||||
if (hash.size() != 32) {
|
||||
LOG_DBG("KODoc", "Hash cache invalidated: wrong hash length (%zu)", hash.size());
|
||||
return "";
|
||||
}
|
||||
for (char c : hash) {
|
||||
if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'))) {
|
||||
LOG_DBG("KODoc", "Hash cache invalidated: non-hex character '%c' in hash", c);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
LOG_DBG("KODoc", "Hash cache hit: %s", hash.c_str());
|
||||
return hash;
|
||||
}
|
||||
|
||||
void KOReaderDocumentId::saveCachedHash(const std::string& cacheFilePath, const size_t fileSize,
|
||||
const std::string& fingerprint, const std::string& hash) {
|
||||
// Ensure the book's cache directory exists before writing
|
||||
const size_t lastSlash = cacheFilePath.rfind('/');
|
||||
if (lastSlash != std::string::npos) {
|
||||
Storage.ensureDirectoryExists(cacheFilePath.substr(0, lastSlash).c_str());
|
||||
}
|
||||
|
||||
// Format: "<filesize>:<fingerprint>\n<hash>"
|
||||
String content(std::to_string(fileSize).c_str());
|
||||
content += ':';
|
||||
content += fingerprint.c_str();
|
||||
content += '\n';
|
||||
content += hash.c_str();
|
||||
|
||||
if (!Storage.writeFile(cacheFilePath.c_str(), content)) {
|
||||
LOG_DBG("KODoc", "Failed to write hash cache to %s", cacheFilePath.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
std::string KOReaderDocumentId::calculateFromFilename(const std::string& filePath) {
|
||||
const std::string filename = getFilename(filePath);
|
||||
if (filename.empty()) {
|
||||
@@ -49,6 +175,30 @@ std::string KOReaderDocumentId::calculate(const std::string& filePath) {
|
||||
}
|
||||
|
||||
const size_t fileSize = file.fileSize();
|
||||
|
||||
// Compute a lightweight fingerprint from the file's modification time.
|
||||
// The underlying FsFile API provides getModifyDateTime which returns two
|
||||
// packed 16-bit values (date and time). Concatenate these as eight hex
|
||||
// digits to produce the token stored in the cache header.
|
||||
uint16_t date = 0, time = 0;
|
||||
if (!file.getModifyDateTime(&date, &time)) {
|
||||
// If timestamp isn't available for some reason, fall back to a sentinel.
|
||||
date = 0;
|
||||
time = 0;
|
||||
}
|
||||
char fpBuf[9];
|
||||
// two 16-bit numbers => 4 hex digits each
|
||||
sprintf(fpBuf, "%04x%04x", date, time);
|
||||
const std::string fingerprintTok(fpBuf);
|
||||
|
||||
// Return persisted hash if the file size and fingerprint haven't changed.
|
||||
const std::string cacheFilePath = getCacheFilePath(filePath);
|
||||
const std::string cached = loadCachedHash(cacheFilePath, fileSize, fingerprintTok);
|
||||
if (!cached.empty()) {
|
||||
file.close();
|
||||
return cached;
|
||||
}
|
||||
|
||||
LOG_DBG("KODoc", "Calculating hash for file: %s (size: %zu)", filePath.c_str(), fileSize);
|
||||
|
||||
// Initialize MD5 builder
|
||||
@@ -92,5 +242,7 @@ std::string KOReaderDocumentId::calculate(const std::string& filePath) {
|
||||
|
||||
LOG_DBG("KODoc", "Hash calculated: %s (from %zu bytes)", result.c_str(), totalBytesRead);
|
||||
|
||||
saveCachedHash(cacheFilePath, fileSize, fingerprintTok, result);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -42,4 +42,31 @@ class KOReaderDocumentId {
|
||||
|
||||
// Calculate offset for index i: 1024 << (2*i)
|
||||
static size_t getOffset(int i);
|
||||
|
||||
// Hash cache helpers
|
||||
// Returns the path to the per-book cache file that stores the precomputed hash.
|
||||
// Uses the same directory convention as the Epub cache (/.crosspoint/epub_<hash>/).
|
||||
static std::string getCacheFilePath(const std::string& filePath);
|
||||
|
||||
// Returns the cached hash if the file size and fingerprint match, or empty
|
||||
// string on miss/invalidation.
|
||||
//
|
||||
// The fingerprint is derived from the file's modification timestamp. We
|
||||
// call `FsFile::getModifyDateTime` to retrieve two 16‑bit packed values
|
||||
// supplied by the filesystem: one for the date and one for the time. These
|
||||
// are concatenated and represented as eight hexadecimal digits in the form
|
||||
// <date><time> (high 16 bits = packed date, low 16 bits = packed time).
|
||||
//
|
||||
// The resulting string serves as a lightweight change signal; any modification
|
||||
// to the file's mtime will alter the packed date/time combo and invalidate
|
||||
// the cache entry. Since the full document hash is expensive to compute,
|
||||
// using the packed timestamp gives us a quick way to detect modifications
|
||||
// without reading file contents.
|
||||
static std::string loadCachedHash(const std::string& cacheFilePath, size_t fileSize,
|
||||
const std::string& currentFingerprint);
|
||||
|
||||
// Persists the computed hash alongside the file size and fingerprint (the
|
||||
// modification-timestamp token) used to generate it.
|
||||
static void saveCachedHash(const std::string& cacheFilePath, size_t fileSize, const std::string& fingerprint,
|
||||
const std::string& hash);
|
||||
};
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
|
||||
#include <Logging.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
#include "ChapterXPathIndexer.h"
|
||||
|
||||
KOReaderPosition ProgressMapper::toKOReader(const std::shared_ptr<Epub>& epub, const CrossPointPosition& pos) {
|
||||
KOReaderPosition result;
|
||||
|
||||
@@ -16,8 +19,13 @@ KOReaderPosition ProgressMapper::toKOReader(const std::shared_ptr<Epub>& epub, c
|
||||
// Calculate overall book progress (0.0-1.0)
|
||||
result.percentage = epub->calculateProgress(pos.spineIndex, intraSpineProgress);
|
||||
|
||||
// Generate XPath with estimated paragraph position based on page
|
||||
result.xpath = generateXPath(pos.spineIndex, pos.pageNumber, pos.totalPages);
|
||||
// Generate the best available XPath for the current chapter position.
|
||||
// Prefer element-level XPaths from a lightweight XHTML reparse; fall back
|
||||
// to a synthetic chapter-level path if parsing fails.
|
||||
result.xpath = ChapterXPathIndexer::findXPathForProgress(epub, pos.spineIndex, intraSpineProgress);
|
||||
if (result.xpath.empty()) {
|
||||
result.xpath = generateXPath(pos.spineIndex);
|
||||
}
|
||||
|
||||
// Get chapter info for logging
|
||||
const int tocIndex = epub->getTocIndexForSpineIndex(pos.spineIndex);
|
||||
@@ -36,34 +44,69 @@ CrossPointPosition ProgressMapper::toCrossPoint(const std::shared_ptr<Epub>& epu
|
||||
result.pageNumber = 0;
|
||||
result.totalPages = 0;
|
||||
|
||||
const size_t bookSize = epub->getBookSize();
|
||||
if (bookSize == 0) {
|
||||
if (!epub || epub->getSpineItemsCount() <= 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Use percentage-based lookup for both spine and page positioning
|
||||
// XPath parsing is unreliable since CrossPoint doesn't preserve detailed HTML structure
|
||||
const size_t targetBytes = static_cast<size_t>(bookSize * koPos.percentage);
|
||||
|
||||
// Find the spine item that contains this byte position
|
||||
const int spineCount = epub->getSpineItemsCount();
|
||||
bool spineFound = false;
|
||||
for (int i = 0; i < spineCount; i++) {
|
||||
const size_t cumulativeSize = epub->getCumulativeSpineItemSize(i);
|
||||
if (cumulativeSize >= targetBytes) {
|
||||
result.spineIndex = i;
|
||||
spineFound = true;
|
||||
break;
|
||||
|
||||
float resolvedIntraSpineProgress = -1.0f;
|
||||
bool xpathExactMatch = false;
|
||||
bool usedXPathMapping = false;
|
||||
|
||||
int xpathSpineIndex = -1;
|
||||
if (ChapterXPathIndexer::tryExtractSpineIndexFromXPath(koPos.xpath, xpathSpineIndex) && xpathSpineIndex >= 0 &&
|
||||
xpathSpineIndex < spineCount) {
|
||||
float intraFromXPath = 0.0f;
|
||||
if (ChapterXPathIndexer::findProgressForXPath(epub, xpathSpineIndex, koPos.xpath, intraFromXPath,
|
||||
xpathExactMatch)) {
|
||||
result.spineIndex = xpathSpineIndex;
|
||||
resolvedIntraSpineProgress = intraFromXPath;
|
||||
usedXPathMapping = true;
|
||||
}
|
||||
}
|
||||
|
||||
// If no spine item was found (e.g., targetBytes beyond last cumulative size),
|
||||
// default to the last spine item so we map to the end of the book instead of the beginning.
|
||||
if (!spineFound && spineCount > 0) {
|
||||
result.spineIndex = spineCount - 1;
|
||||
if (!usedXPathMapping) {
|
||||
const size_t bookSize = epub->getBookSize();
|
||||
if (bookSize == 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
if (!std::isfinite(koPos.percentage)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const float sanitizedPercentage = std::clamp(koPos.percentage, 0.0f, 1.0f);
|
||||
const size_t targetBytes = static_cast<size_t>(bookSize * sanitizedPercentage);
|
||||
|
||||
bool spineFound = false;
|
||||
for (int i = 0; i < spineCount; i++) {
|
||||
const size_t cumulativeSize = epub->getCumulativeSpineItemSize(i);
|
||||
if (cumulativeSize >= targetBytes) {
|
||||
result.spineIndex = i;
|
||||
spineFound = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!spineFound && spineCount > 0) {
|
||||
result.spineIndex = spineCount - 1;
|
||||
}
|
||||
|
||||
if (result.spineIndex < epub->getSpineItemsCount()) {
|
||||
const size_t prevCumSize = (result.spineIndex > 0) ? epub->getCumulativeSpineItemSize(result.spineIndex - 1) : 0;
|
||||
const size_t currentCumSize = epub->getCumulativeSpineItemSize(result.spineIndex);
|
||||
const size_t spineSize = currentCumSize - prevCumSize;
|
||||
|
||||
if (spineSize > 0) {
|
||||
const size_t bytesIntoSpine = (targetBytes > prevCumSize) ? (targetBytes - prevCumSize) : 0;
|
||||
resolvedIntraSpineProgress = static_cast<float>(bytesIntoSpine) / static_cast<float>(spineSize);
|
||||
resolvedIntraSpineProgress = std::max(0.0f, std::min(1.0f, resolvedIntraSpineProgress));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Estimate page number within the spine item using percentage
|
||||
// Estimate page number within the selected spine item
|
||||
if (result.spineIndex < epub->getSpineItemsCount()) {
|
||||
const size_t prevCumSize = (result.spineIndex > 0) ? epub->getCumulativeSpineItemSize(result.spineIndex - 1) : 0;
|
||||
const size_t currentCumSize = epub->getCumulativeSpineItemSize(result.spineIndex);
|
||||
@@ -91,24 +134,24 @@ CrossPointPosition ProgressMapper::toCrossPoint(const std::shared_ptr<Epub>& epu
|
||||
|
||||
result.totalPages = estimatedTotalPages;
|
||||
|
||||
if (spineSize > 0 && estimatedTotalPages > 0) {
|
||||
const size_t bytesIntoSpine = (targetBytes > prevCumSize) ? (targetBytes - prevCumSize) : 0;
|
||||
const float intraSpineProgress = static_cast<float>(bytesIntoSpine) / static_cast<float>(spineSize);
|
||||
const float clampedProgress = std::max(0.0f, std::min(1.0f, intraSpineProgress));
|
||||
result.pageNumber = static_cast<int>(clampedProgress * estimatedTotalPages);
|
||||
if (estimatedTotalPages > 0 && resolvedIntraSpineProgress >= 0.0f) {
|
||||
const float clampedProgress = std::max(0.0f, std::min(1.0f, resolvedIntraSpineProgress));
|
||||
result.pageNumber = static_cast<int>(clampedProgress * static_cast<float>(estimatedTotalPages));
|
||||
result.pageNumber = std::max(0, std::min(result.pageNumber, estimatedTotalPages - 1));
|
||||
} else if (spineSize > 0 && estimatedTotalPages > 0) {
|
||||
result.pageNumber = 0;
|
||||
}
|
||||
}
|
||||
|
||||
LOG_DBG("ProgressMapper", "KOReader -> CrossPoint: %.2f%% at %s -> spine=%d, page=%d", koPos.percentage * 100,
|
||||
koPos.xpath.c_str(), result.spineIndex, result.pageNumber);
|
||||
LOG_DBG("ProgressMapper", "KOReader -> CrossPoint: %.2f%% at %s -> spine=%d, page=%d (%s, exact=%s)",
|
||||
koPos.percentage * 100, koPos.xpath.c_str(), result.spineIndex, result.pageNumber,
|
||||
usedXPathMapping ? "xpath" : "percentage", xpathExactMatch ? "yes" : "no");
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string ProgressMapper::generateXPath(int spineIndex, int pageNumber, int totalPages) {
|
||||
// Use 0-based DocFragment indices for KOReader
|
||||
// Use a simple xpath pointing to the DocFragment - KOReader will use the percentage for fine positioning within it
|
||||
// Avoid specifying paragraph numbers as they may not exist in the target document
|
||||
return "/body/DocFragment[" + std::to_string(spineIndex) + "]/body";
|
||||
std::string ProgressMapper::generateXPath(int spineIndex) {
|
||||
// Fallback path when element-level XPath extraction is unavailable.
|
||||
// KOReader uses 1-based XPath predicates; spineIndex is 0-based internally.
|
||||
return "/body/DocFragment[" + std::to_string(spineIndex + 1) + "]/body";
|
||||
}
|
||||
|
||||
@@ -27,9 +27,16 @@ struct KOReaderPosition {
|
||||
* CrossPoint tracks position as (spineIndex, pageNumber).
|
||||
* KOReader uses XPath-like strings + percentage.
|
||||
*
|
||||
* Since CrossPoint discards HTML structure during parsing, we generate
|
||||
* synthetic XPath strings based on spine index, using percentage as the
|
||||
* primary sync mechanism.
|
||||
* Forward mapping (CrossPoint -> KOReader):
|
||||
* - Prefer element-level XPath extracted from current spine XHTML.
|
||||
* - Fallback to synthetic chapter XPath if extraction fails.
|
||||
*
|
||||
* Reverse mapping (KOReader -> CrossPoint):
|
||||
* - Prefer incoming XPath (DocFragment + element path) when resolvable.
|
||||
* - Fallback to percentage-based approximation when XPath is missing/invalid.
|
||||
*
|
||||
* This keeps behavior stable on low-memory devices while improving round-trip
|
||||
* sync precision when KOReader provides detailed paths.
|
||||
*/
|
||||
class ProgressMapper {
|
||||
public:
|
||||
@@ -45,8 +52,9 @@ class ProgressMapper {
|
||||
/**
|
||||
* Convert KOReader position to CrossPoint format.
|
||||
*
|
||||
* Note: The returned pageNumber may be approximate since different
|
||||
* rendering settings produce different page counts.
|
||||
* Uses XPath-first resolution when possible and percentage fallback otherwise.
|
||||
* Returned pageNumber can still be approximate because page counts differ
|
||||
* across renderer/font/layout settings.
|
||||
*
|
||||
* @param epub The EPUB book
|
||||
* @param koPos KOReader position
|
||||
@@ -60,8 +68,7 @@ class ProgressMapper {
|
||||
private:
|
||||
/**
|
||||
* Generate XPath for KOReader compatibility.
|
||||
* Format: /body/DocFragment[spineIndex+1]/body
|
||||
* Since CrossPoint doesn't preserve HTML structure, we rely on percentage for positioning.
|
||||
* Fallback format: /body/DocFragment[spineIndex + 1]/body
|
||||
*/
|
||||
static std::string generateXPath(int spineIndex, int pageNumber, int totalPages);
|
||||
static std::string generateXPath(int spineIndex);
|
||||
};
|
||||
|
||||
@@ -9,3 +9,11 @@ uint32_t utf8NextCodepoint(const unsigned char** string);
|
||||
size_t utf8RemoveLastChar(std::string& str);
|
||||
// Truncate string by removing N UTF-8 codepoints from the end.
|
||||
void utf8TruncateChars(std::string& str, size_t numChars);
|
||||
|
||||
// Returns true for Unicode combining diacritical marks that should not advance the cursor.
|
||||
inline bool utf8IsCombiningMark(const uint32_t cp) {
|
||||
return (cp >= 0x0300 && cp <= 0x036F) // Combining Diacritical Marks
|
||||
|| (cp >= 0x1DC0 && cp <= 0x1DFF) // Combining Diacritical Marks Supplement
|
||||
|| (cp >= 0x20D0 && cp <= 0x20FF) // Combining Diacritical Marks for Symbols
|
||||
|| (cp >= 0xFE20 && cp <= 0xFE2F); // Combining Half Marks
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ void HalPowerManager::startDeepSleep(HalGPIO& gpio) const {
|
||||
esp_deep_sleep_start();
|
||||
}
|
||||
|
||||
int HalPowerManager::getBatteryPercentage() const {
|
||||
uint16_t HalPowerManager::getBatteryPercentage() const {
|
||||
static const BatteryMonitor battery = BatteryMonitor(BAT_GPIO0);
|
||||
return battery.readPercentage();
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ class HalPowerManager {
|
||||
void startDeepSleep(HalGPIO& gpio) const;
|
||||
|
||||
// Get battery percentage (range 0-100)
|
||||
int getBatteryPercentage() const;
|
||||
uint16_t getBatteryPercentage() const;
|
||||
|
||||
// RAII lock to prevent low-power mode during critical work (e.g. rendering)
|
||||
class Lock {
|
||||
|
||||
@@ -38,6 +38,8 @@ bool HalStorage::remove(const char* path) { return SDCard.remove(path); }
|
||||
|
||||
bool HalStorage::rmdir(const char* path) { return SDCard.rmdir(path); }
|
||||
|
||||
bool HalStorage::rename(const char* path, const char* newPath) { return SDCard.rename(path, newPath); }
|
||||
|
||||
bool HalStorage::openFileForRead(const char* moduleName, const char* path, FsFile& file) {
|
||||
return SDCard.openFileForRead(moduleName, path, file);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ class HalStorage {
|
||||
bool exists(const char* path);
|
||||
bool remove(const char* path);
|
||||
bool rmdir(const char* path);
|
||||
bool rename(const char* path, const char* newPath);
|
||||
|
||||
bool openFileForRead(const char* moduleName, const char* path, FsFile& file);
|
||||
bool openFileForRead(const char* moduleName, const std::string& path, FsFile& file);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
default_envs = default
|
||||
|
||||
[crosspoint]
|
||||
version = 1.0.0
|
||||
version = 1.1.2
|
||||
|
||||
[base]
|
||||
platform = espressif32 @ 6.12.0
|
||||
@@ -31,9 +31,9 @@ build_flags =
|
||||
-std=gnu++2a
|
||||
# Enable UTF-8 long file names in SdFat
|
||||
-DUSE_UTF8_LONG_NAMES=1
|
||||
# Increase PNG scanline buffer to support up to 800px wide images
|
||||
# Increase PNG scanline buffer to support up to 2048px wide images
|
||||
# Default is (320*4+1)*2=2562, we need more for larger images
|
||||
-DPNG_MAX_BUFFERED_PIXELS=6402
|
||||
-DPNG_MAX_BUFFERED_PIXELS=16416
|
||||
|
||||
build_unflags =
|
||||
-std=gnu++11
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
#pragma once
|
||||
#include <BatteryMonitor.h>
|
||||
|
||||
#define BAT_GPIO0 0 // Battery voltage
|
||||
|
||||
static BatteryMonitor battery(BAT_GPIO0);
|
||||
@@ -144,6 +144,7 @@ uint8_t CrossPointSettings::writeSettings(FsFile& file, bool count_only) const {
|
||||
writer.writeItem(file, timezone);
|
||||
writer.writeItem(file, timezoneOffsetHours);
|
||||
writer.writeItem(file, indexingDisplay);
|
||||
writer.writeItem(file, autoNtpSync);
|
||||
|
||||
return writer.item_count;
|
||||
}
|
||||
@@ -288,6 +289,8 @@ bool CrossPointSettings::loadFromFile() {
|
||||
if (++settingsRead >= fileSettingsCount) break;
|
||||
readAndValidate(inputFile, indexingDisplay, INDEXING_DISPLAY_COUNT);
|
||||
if (++settingsRead >= fileSettingsCount) break;
|
||||
serialization::readPod(inputFile, autoNtpSync);
|
||||
if (++settingsRead >= fileSettingsCount) break;
|
||||
} while (false);
|
||||
|
||||
if (frontButtonMappingRead) {
|
||||
|
||||
@@ -227,6 +227,9 @@ class CrossPointSettings {
|
||||
// Custom timezone offset in hours from UTC (-12 to +14)
|
||||
int8_t timezoneOffsetHours = 0;
|
||||
|
||||
// Automatically sync time via NTP on boot using saved WiFi credentials
|
||||
uint8_t autoNtpSync = 0;
|
||||
|
||||
~CrossPointSettings() = default;
|
||||
|
||||
// Get singleton instance
|
||||
|
||||
203
src/OpdsServerStore.cpp
Normal file
203
src/OpdsServerStore.cpp
Normal file
@@ -0,0 +1,203 @@
|
||||
#include "OpdsServerStore.h"
|
||||
|
||||
#include <ArduinoJson.h>
|
||||
#include <HalStorage.h>
|
||||
#include <Logging.h>
|
||||
#include <base64.h>
|
||||
#include <esp_mac.h>
|
||||
#include <mbedtls/base64.h>
|
||||
|
||||
#include <cstring>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
|
||||
OpdsServerStore OpdsServerStore::instance;
|
||||
|
||||
namespace {
|
||||
constexpr char OPDS_FILE_JSON[] = "/.crosspoint/opds.json";
|
||||
constexpr size_t HW_KEY_LEN = 6;
|
||||
|
||||
const uint8_t* getHwKey() {
|
||||
static uint8_t key[HW_KEY_LEN] = {};
|
||||
static bool initialized = false;
|
||||
if (!initialized) {
|
||||
esp_efuse_mac_get_default(key);
|
||||
initialized = true;
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
void xorTransform(std::string& data) {
|
||||
const uint8_t* key = getHwKey();
|
||||
for (size_t i = 0; i < data.size(); i++) {
|
||||
data[i] ^= key[i % HW_KEY_LEN];
|
||||
}
|
||||
}
|
||||
|
||||
String obfuscateToBase64(const std::string& plaintext) {
|
||||
if (plaintext.empty()) return "";
|
||||
std::string temp = plaintext;
|
||||
xorTransform(temp);
|
||||
return base64::encode(reinterpret_cast<const uint8_t*>(temp.data()), temp.size());
|
||||
}
|
||||
|
||||
std::string deobfuscateFromBase64(const char* encoded, bool* ok) {
|
||||
if (encoded == nullptr || encoded[0] == '\0') {
|
||||
if (ok) *ok = false;
|
||||
return "";
|
||||
}
|
||||
if (ok) *ok = true;
|
||||
size_t encodedLen = strlen(encoded);
|
||||
size_t decodedLen = 0;
|
||||
int ret = mbedtls_base64_decode(nullptr, 0, &decodedLen, reinterpret_cast<const unsigned char*>(encoded), encodedLen);
|
||||
if (ret != 0 && ret != MBEDTLS_ERR_BASE64_BUFFER_TOO_SMALL) {
|
||||
LOG_ERR("OPS", "Base64 decode size query failed (ret=%d)", ret);
|
||||
if (ok) *ok = false;
|
||||
return "";
|
||||
}
|
||||
std::string result(decodedLen, '\0');
|
||||
ret = mbedtls_base64_decode(reinterpret_cast<unsigned char*>(&result[0]), decodedLen, &decodedLen,
|
||||
reinterpret_cast<const unsigned char*>(encoded), encodedLen);
|
||||
if (ret != 0) {
|
||||
LOG_ERR("OPS", "Base64 decode failed (ret=%d)", ret);
|
||||
if (ok) *ok = false;
|
||||
return "";
|
||||
}
|
||||
result.resize(decodedLen);
|
||||
xorTransform(result);
|
||||
return result;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
bool OpdsServerStore::saveToFile() const {
|
||||
Storage.mkdir("/.crosspoint");
|
||||
|
||||
JsonDocument doc;
|
||||
JsonArray arr = doc["servers"].to<JsonArray>();
|
||||
for (const auto& server : servers) {
|
||||
JsonObject obj = arr.add<JsonObject>();
|
||||
obj["name"] = server.name;
|
||||
obj["url"] = server.url;
|
||||
obj["username"] = server.username;
|
||||
obj["password_obf"] = obfuscateToBase64(server.password);
|
||||
obj["download_path"] = server.downloadPath;
|
||||
}
|
||||
|
||||
String json;
|
||||
serializeJson(doc, json);
|
||||
return Storage.writeFile(OPDS_FILE_JSON, json);
|
||||
}
|
||||
|
||||
bool OpdsServerStore::loadFromFile() {
|
||||
if (Storage.exists(OPDS_FILE_JSON)) {
|
||||
String json = Storage.readFile(OPDS_FILE_JSON);
|
||||
if (!json.isEmpty()) {
|
||||
JsonDocument doc;
|
||||
auto error = deserializeJson(doc, json.c_str());
|
||||
if (error) {
|
||||
LOG_ERR("OPS", "JSON parse error: %s", error.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
servers.clear();
|
||||
bool needsResave = false;
|
||||
JsonArray arr = doc["servers"].as<JsonArray>();
|
||||
for (JsonObject obj : arr) {
|
||||
if (servers.size() >= MAX_SERVERS) break;
|
||||
OpdsServer server;
|
||||
server.name = obj["name"] | std::string("");
|
||||
server.url = obj["url"] | std::string("");
|
||||
server.username = obj["username"] | std::string("");
|
||||
|
||||
bool ok = false;
|
||||
server.password = deobfuscateFromBase64(obj["password_obf"] | "", &ok);
|
||||
if (!ok || server.password.empty()) {
|
||||
server.password = obj["password"] | std::string("");
|
||||
if (!server.password.empty()) needsResave = true;
|
||||
}
|
||||
server.downloadPath = obj["download_path"] | std::string("/");
|
||||
servers.push_back(std::move(server));
|
||||
}
|
||||
|
||||
LOG_DBG("OPS", "Loaded %zu OPDS servers from file", servers.size());
|
||||
|
||||
if (needsResave) {
|
||||
LOG_DBG("OPS", "Resaving JSON with obfuscated passwords");
|
||||
saveToFile();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// No opds.json found — attempt one-time migration from the legacy single-server
|
||||
// fields in CrossPointSettings (opdsServerUrl/opdsUsername/opdsPassword).
|
||||
if (migrateFromSettings()) {
|
||||
LOG_DBG("OPS", "Migrated legacy OPDS settings");
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
bool OpdsServerStore::migrateFromSettings() {
|
||||
if (strlen(SETTINGS.opdsServerUrl) == 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
OpdsServer server;
|
||||
server.name = "OPDS Server";
|
||||
server.url = SETTINGS.opdsServerUrl;
|
||||
server.username = SETTINGS.opdsUsername;
|
||||
server.password = SETTINGS.opdsPassword;
|
||||
servers.push_back(std::move(server));
|
||||
|
||||
if (saveToFile()) {
|
||||
SETTINGS.opdsServerUrl[0] = '\0';
|
||||
SETTINGS.opdsUsername[0] = '\0';
|
||||
SETTINGS.opdsPassword[0] = '\0';
|
||||
SETTINGS.saveToFile();
|
||||
LOG_DBG("OPS", "Migrated single-server OPDS config to opds.json");
|
||||
return true;
|
||||
}
|
||||
|
||||
servers.clear();
|
||||
return false;
|
||||
}
|
||||
|
||||
bool OpdsServerStore::addServer(const OpdsServer& server) {
|
||||
if (servers.size() >= MAX_SERVERS) {
|
||||
LOG_DBG("OPS", "Cannot add more servers, limit of %zu reached", MAX_SERVERS);
|
||||
return false;
|
||||
}
|
||||
|
||||
servers.push_back(server);
|
||||
LOG_DBG("OPS", "Added server: %s", server.name.c_str());
|
||||
return saveToFile();
|
||||
}
|
||||
|
||||
bool OpdsServerStore::updateServer(size_t index, const OpdsServer& server) {
|
||||
if (index >= servers.size()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
servers[index] = server;
|
||||
LOG_DBG("OPS", "Updated server: %s", server.name.c_str());
|
||||
return saveToFile();
|
||||
}
|
||||
|
||||
bool OpdsServerStore::removeServer(size_t index) {
|
||||
if (index >= servers.size()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
LOG_DBG("OPS", "Removed server: %s", servers[index].name.c_str());
|
||||
servers.erase(servers.begin() + static_cast<ptrdiff_t>(index));
|
||||
return saveToFile();
|
||||
}
|
||||
|
||||
const OpdsServer* OpdsServerStore::getServer(size_t index) const {
|
||||
if (index >= servers.size()) {
|
||||
return nullptr;
|
||||
}
|
||||
return &servers[index];
|
||||
}
|
||||
52
src/OpdsServerStore.h
Normal file
52
src/OpdsServerStore.h
Normal file
@@ -0,0 +1,52 @@
|
||||
#pragma once
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
struct OpdsServer {
|
||||
std::string name;
|
||||
std::string url;
|
||||
std::string username;
|
||||
std::string password; // Plaintext in memory; obfuscated with hardware key on disk
|
||||
std::string downloadPath = "/";
|
||||
};
|
||||
|
||||
/**
|
||||
* Singleton class for storing OPDS server configurations on the SD card.
|
||||
* Passwords are XOR-obfuscated with the device's unique hardware MAC address
|
||||
* and base64-encoded before writing to JSON.
|
||||
*/
|
||||
class OpdsServerStore {
|
||||
private:
|
||||
static OpdsServerStore instance;
|
||||
std::vector<OpdsServer> servers;
|
||||
|
||||
static constexpr size_t MAX_SERVERS = 8;
|
||||
|
||||
OpdsServerStore() = default;
|
||||
|
||||
public:
|
||||
OpdsServerStore(const OpdsServerStore&) = delete;
|
||||
OpdsServerStore& operator=(const OpdsServerStore&) = delete;
|
||||
|
||||
static OpdsServerStore& getInstance() { return instance; }
|
||||
|
||||
bool saveToFile() const;
|
||||
bool loadFromFile();
|
||||
|
||||
bool addServer(const OpdsServer& server);
|
||||
bool updateServer(size_t index, const OpdsServer& server);
|
||||
bool removeServer(size_t index);
|
||||
|
||||
const std::vector<OpdsServer>& getServers() const { return servers; }
|
||||
const OpdsServer* getServer(size_t index) const;
|
||||
size_t getCount() const { return servers.size(); }
|
||||
bool hasServers() const { return !servers.empty(); }
|
||||
|
||||
/**
|
||||
* Migrate from legacy single-server settings in CrossPointSettings.
|
||||
* Called once during first load if no opds.json exists.
|
||||
*/
|
||||
bool migrateFromSettings();
|
||||
};
|
||||
|
||||
#define OPDS_STORE OpdsServerStore::getInstance()
|
||||
@@ -47,6 +47,11 @@ void RecentBooksStore::removeBook(const std::string& path) {
|
||||
}
|
||||
}
|
||||
|
||||
void RecentBooksStore::clear() {
|
||||
recentBooks.clear();
|
||||
saveToFile();
|
||||
}
|
||||
|
||||
void RecentBooksStore::updateBook(const std::string& path, const std::string& title, const std::string& author,
|
||||
const std::string& coverBmpPath) {
|
||||
auto it =
|
||||
|
||||
@@ -33,6 +33,9 @@ class RecentBooksStore {
|
||||
// Remove a book from the recent list by path
|
||||
void removeBook(const std::string& path);
|
||||
|
||||
// Clear all recent books
|
||||
void clear();
|
||||
|
||||
// Get the list of recent books (most recent first)
|
||||
const std::vector<RecentBook>& getBooks() const { return recentBooks; }
|
||||
|
||||
|
||||
@@ -91,6 +91,8 @@ inline std::vector<SettingInfo> getSettingsList() {
|
||||
{StrId::STR_TZ_UTC, StrId::STR_TZ_EASTERN, StrId::STR_TZ_CENTRAL, StrId::STR_TZ_MOUNTAIN,
|
||||
StrId::STR_TZ_PACIFIC, StrId::STR_TZ_ALASKA, StrId::STR_TZ_HAWAII, StrId::STR_TZ_CUSTOM},
|
||||
"timezone", StrId::STR_CAT_CLOCK),
|
||||
SettingInfo::Toggle(StrId::STR_AUTO_NTP_SYNC, &CrossPointSettings::autoNtpSync, "autoNtpSync",
|
||||
StrId::STR_CAT_CLOCK),
|
||||
|
||||
// --- Reader ---
|
||||
SettingInfo::DynamicEnum(
|
||||
@@ -189,13 +191,5 @@ inline std::vector<SettingInfo> getSettingsList() {
|
||||
KOREADER_STORE.saveToFile();
|
||||
},
|
||||
"koMatchMethod", StrId::STR_KOREADER_SYNC),
|
||||
|
||||
// --- OPDS Browser (web-only, uses CrossPointSettings char arrays) ---
|
||||
SettingInfo::String(StrId::STR_OPDS_SERVER_URL, SETTINGS.opdsServerUrl, sizeof(SETTINGS.opdsServerUrl),
|
||||
"opdsServerUrl", StrId::STR_OPDS_BROWSER),
|
||||
SettingInfo::String(StrId::STR_USERNAME, SETTINGS.opdsUsername, sizeof(SETTINGS.opdsUsername), "opdsUsername",
|
||||
StrId::STR_OPDS_BROWSER),
|
||||
SettingInfo::String(StrId::STR_PASSWORD, SETTINGS.opdsPassword, sizeof(SETTINGS.opdsPassword), "opdsPassword",
|
||||
StrId::STR_OPDS_BROWSER),
|
||||
};
|
||||
}
|
||||
@@ -21,6 +21,11 @@
|
||||
#include "util/BookSettings.h"
|
||||
#include "util/StringUtils.h"
|
||||
|
||||
// Sleep cover refresh strategy when dithered letterbox fill is active:
|
||||
// 1 = Double FAST_REFRESH (clear to white, then render content -- avoids HALF_REFRESH crosstalk)
|
||||
// 0 = Standard HALF_REFRESH (original behavior)
|
||||
#define USE_SLEEP_DOUBLE_FAST_REFRESH 1
|
||||
|
||||
namespace {
|
||||
|
||||
// Number of source pixels along the image edge to average for the dominant color
|
||||
@@ -74,37 +79,6 @@ uint8_t quantizeBayerDither(int gray, int x, int y) {
|
||||
}
|
||||
}
|
||||
|
||||
// Check whether a gray value would produce a dithered mix that crosses the
|
||||
// level-2 / level-3 boundary. This is the ONLY boundary where some dithered
|
||||
// pixels are BLACK (level ≤ 2) and others are WHITE (level 3) in the BW pass,
|
||||
// creating a high-frequency checkerboard that causes e-ink display crosstalk
|
||||
// and washes out adjacent content during HALF_REFRESH.
|
||||
// Gray values 171-254 produce a level-2/level-3 mix via Bayer dithering.
|
||||
bool bayerCrossesBwBoundary(uint8_t gray) { return gray > 170 && gray < 255; }
|
||||
|
||||
// Hash-based block dithering for BW-boundary gray values (171-254).
|
||||
// Each blockSize×blockSize pixel block gets a single uniform level (2 or 3),
|
||||
// determined by a deterministic spatial hash. The proportion of level-3 blocks
|
||||
// approximates the target gray. Unlike Bayer, the pattern is irregular
|
||||
// (noise-like), making it much less visually obvious at the same block size.
|
||||
// The hash is purely spatial (depends only on x, y, blockSize) so it produces
|
||||
// identical levels across BW, LSB, and MSB render passes.
|
||||
static constexpr int BW_DITHER_BLOCK = 2;
|
||||
|
||||
uint8_t hashBlockDither(uint8_t avg, int x, int y) {
|
||||
const int bx = x / BW_DITHER_BLOCK;
|
||||
const int by = y / BW_DITHER_BLOCK;
|
||||
// Fast mixing hash (splitmix32-inspired)
|
||||
uint32_t h = (uint32_t)bx * 2654435761u ^ (uint32_t)by * 2246822519u;
|
||||
h ^= h >> 16;
|
||||
h *= 0x45d9f3bu;
|
||||
h ^= h >> 16;
|
||||
// Proportion of level-3 blocks needed to approximate the target gray
|
||||
const float ratio = (avg - 170.0f) / 85.0f;
|
||||
const uint32_t threshold = (uint32_t)(ratio * 4294967295.0f);
|
||||
return (h < threshold) ? 3 : 2;
|
||||
}
|
||||
|
||||
// --- Edge average cache ---
|
||||
// Caches the computed edge averages alongside the cover BMP so we don't rescan on every sleep.
|
||||
constexpr uint8_t EDGE_CACHE_VERSION = 2;
|
||||
@@ -278,19 +252,6 @@ void drawLetterboxFill(GfxRenderer& renderer, const LetterboxFillData& data, uin
|
||||
|
||||
const bool isSolid = (fillMode == CrossPointSettings::SLEEP_SCREEN_LETTERBOX_FILL::LETTERBOX_SOLID);
|
||||
|
||||
// For DITHERED mode with gray values in 171-254 (the level-2/level-3 BW boundary):
|
||||
// Pixel-level Bayer dithering creates a regular high-frequency checkerboard in
|
||||
// the BW pass that causes e-ink display crosstalk during HALF_REFRESH.
|
||||
//
|
||||
// Solution: HASH-BASED BLOCK DITHERING. Each 2x2 pixel block gets a single
|
||||
// level (2 or 3) determined by a spatial hash, with the proportion of level-3
|
||||
// blocks tuned to approximate the target gray. The 2px minimum run avoids BW
|
||||
// crosstalk, and the irregular hash pattern is much less visible than a regular
|
||||
// Bayer grid at the same block size.
|
||||
const bool hashA = !isSolid && bayerCrossesBwBoundary(data.avgA);
|
||||
const bool hashB = !isSolid && bayerCrossesBwBoundary(data.avgB);
|
||||
|
||||
// For solid mode: snap to nearest e-ink level
|
||||
const uint8_t levelA = isSolid ? snapToEinkLevel(data.avgA) / 85 : 0;
|
||||
const uint8_t levelB = isSolid ? snapToEinkLevel(data.avgB) / 85 : 0;
|
||||
|
||||
@@ -298,13 +259,7 @@ void drawLetterboxFill(GfxRenderer& renderer, const LetterboxFillData& data, uin
|
||||
if (data.letterboxA > 0) {
|
||||
for (int y = 0; y < data.letterboxA; y++)
|
||||
for (int x = 0; x < renderer.getScreenWidth(); x++) {
|
||||
uint8_t lv;
|
||||
if (isSolid)
|
||||
lv = levelA;
|
||||
else if (hashA)
|
||||
lv = hashBlockDither(data.avgA, x, y);
|
||||
else
|
||||
lv = quantizeBayerDither(data.avgA, x, y);
|
||||
const uint8_t lv = isSolid ? levelA : quantizeBayerDither(data.avgA, x, y);
|
||||
renderer.drawPixelGray(x, y, lv);
|
||||
}
|
||||
}
|
||||
@@ -312,13 +267,7 @@ void drawLetterboxFill(GfxRenderer& renderer, const LetterboxFillData& data, uin
|
||||
const int start = renderer.getScreenHeight() - data.letterboxB;
|
||||
for (int y = start; y < renderer.getScreenHeight(); y++)
|
||||
for (int x = 0; x < renderer.getScreenWidth(); x++) {
|
||||
uint8_t lv;
|
||||
if (isSolid)
|
||||
lv = levelB;
|
||||
else if (hashB)
|
||||
lv = hashBlockDither(data.avgB, x, y);
|
||||
else
|
||||
lv = quantizeBayerDither(data.avgB, x, y);
|
||||
const uint8_t lv = isSolid ? levelB : quantizeBayerDither(data.avgB, x, y);
|
||||
renderer.drawPixelGray(x, y, lv);
|
||||
}
|
||||
}
|
||||
@@ -326,13 +275,7 @@ void drawLetterboxFill(GfxRenderer& renderer, const LetterboxFillData& data, uin
|
||||
if (data.letterboxA > 0) {
|
||||
for (int x = 0; x < data.letterboxA; x++)
|
||||
for (int y = 0; y < renderer.getScreenHeight(); y++) {
|
||||
uint8_t lv;
|
||||
if (isSolid)
|
||||
lv = levelA;
|
||||
else if (hashA)
|
||||
lv = hashBlockDither(data.avgA, x, y);
|
||||
else
|
||||
lv = quantizeBayerDither(data.avgA, x, y);
|
||||
const uint8_t lv = isSolid ? levelA : quantizeBayerDither(data.avgA, x, y);
|
||||
renderer.drawPixelGray(x, y, lv);
|
||||
}
|
||||
}
|
||||
@@ -340,13 +283,7 @@ void drawLetterboxFill(GfxRenderer& renderer, const LetterboxFillData& data, uin
|
||||
const int start = renderer.getScreenWidth() - data.letterboxB;
|
||||
for (int x = start; x < renderer.getScreenWidth(); x++)
|
||||
for (int y = 0; y < renderer.getScreenHeight(); y++) {
|
||||
uint8_t lv;
|
||||
if (isSolid)
|
||||
lv = levelB;
|
||||
else if (hashB)
|
||||
lv = hashBlockDither(data.avgB, x, y);
|
||||
else
|
||||
lv = quantizeBayerDither(data.avgB, x, y);
|
||||
const uint8_t lv = isSolid ? levelB : quantizeBayerDither(data.avgB, x, y);
|
||||
renderer.drawPixelGray(x, y, lv);
|
||||
}
|
||||
}
|
||||
@@ -543,35 +480,47 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap, const std::str
|
||||
const bool hasGreyscale = bitmap.hasGreyscale() &&
|
||||
SETTINGS.sleepScreenCoverFilter == CrossPointSettings::SLEEP_SCREEN_COVER_FILTER::NO_FILTER;
|
||||
|
||||
// Draw letterbox fill (BW pass)
|
||||
if (fillData.valid) {
|
||||
drawLetterboxFill(renderer, fillData, fillMode);
|
||||
const bool isInverted =
|
||||
SETTINGS.sleepScreenCoverFilter == CrossPointSettings::SLEEP_SCREEN_COVER_FILTER::INVERTED_BLACK_AND_WHITE;
|
||||
|
||||
#if USE_SLEEP_DOUBLE_FAST_REFRESH
|
||||
const bool useDoubleFast =
|
||||
fillData.valid && fillMode == CrossPointSettings::SLEEP_SCREEN_LETTERBOX_FILL::LETTERBOX_DITHERED;
|
||||
#else
|
||||
const bool useDoubleFast = false;
|
||||
#endif
|
||||
|
||||
if (useDoubleFast) {
|
||||
// Double FAST_REFRESH technique: avoids HALF_REFRESH crosstalk with dithered letterbox.
|
||||
// Pass 1: clear to white baseline
|
||||
renderer.clearScreen();
|
||||
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||
|
||||
// Pass 2: render actual content and display
|
||||
if (fillData.valid) drawLetterboxFill(renderer, fillData, fillMode);
|
||||
renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY);
|
||||
if (isInverted) renderer.invertScreen();
|
||||
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||
} else {
|
||||
// Standard path: single HALF_REFRESH
|
||||
if (fillData.valid) drawLetterboxFill(renderer, fillData, fillMode);
|
||||
renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY);
|
||||
if (isInverted) renderer.invertScreen();
|
||||
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
||||
}
|
||||
|
||||
renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY);
|
||||
|
||||
if (SETTINGS.sleepScreenCoverFilter == CrossPointSettings::SLEEP_SCREEN_COVER_FILTER::INVERTED_BLACK_AND_WHITE) {
|
||||
renderer.invertScreen();
|
||||
}
|
||||
|
||||
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
||||
|
||||
if (hasGreyscale) {
|
||||
bitmap.rewindToData();
|
||||
renderer.clearScreen(0x00);
|
||||
renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB);
|
||||
if (fillData.valid) {
|
||||
drawLetterboxFill(renderer, fillData, fillMode);
|
||||
}
|
||||
if (fillData.valid) drawLetterboxFill(renderer, fillData, fillMode);
|
||||
renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY);
|
||||
renderer.copyGrayscaleLsbBuffers();
|
||||
|
||||
bitmap.rewindToData();
|
||||
renderer.clearScreen(0x00);
|
||||
renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB);
|
||||
if (fillData.valid) {
|
||||
drawLetterboxFill(renderer, fillData, fillMode);
|
||||
}
|
||||
if (fillData.valid) drawLetterboxFill(renderer, fillData, fillMode);
|
||||
renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY);
|
||||
renderer.copyGrayscaleMsbBuffers();
|
||||
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
#include <OpdsStream.h>
|
||||
#include <WiFi.h>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "activities/network/WifiSelectionActivity.h"
|
||||
#include "activities/util/DirectoryPickerActivity.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
#include "network/HttpDownloader.h"
|
||||
@@ -53,6 +53,12 @@ void OpdsBookBrowserActivity::loop() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle directory picker subactivity
|
||||
if (state == BrowserState::PICKING_DIRECTORY) {
|
||||
ActivityWithSubactivity::loop();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle error state - Confirm retries, Back goes back or home
|
||||
if (state == BrowserState::ERROR) {
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
@@ -102,7 +108,7 @@ void OpdsBookBrowserActivity::loop() {
|
||||
if (!entries.empty()) {
|
||||
const auto& entry = entries[selectorIndex];
|
||||
if (entry.type == OpdsEntryType::BOOK) {
|
||||
downloadBook(entry);
|
||||
launchDirectoryPicker(entry);
|
||||
} else {
|
||||
navigateToEntry(entry);
|
||||
}
|
||||
@@ -142,7 +148,8 @@ void OpdsBookBrowserActivity::render(Activity::RenderLock&&) {
|
||||
const auto pageWidth = renderer.getScreenWidth();
|
||||
const auto pageHeight = renderer.getScreenHeight();
|
||||
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, 15, tr(STR_OPDS_BROWSER), true, EpdFontFamily::BOLD);
|
||||
const char* headerTitle = server.name.empty() ? tr(STR_OPDS_BROWSER) : server.name.c_str();
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, 15, headerTitle, true, EpdFontFamily::BOLD);
|
||||
|
||||
if (state == BrowserState::CHECK_WIFI) {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, statusMessage.c_str());
|
||||
@@ -171,7 +178,9 @@ void OpdsBookBrowserActivity::render(Activity::RenderLock&&) {
|
||||
|
||||
if (state == BrowserState::DOWNLOADING) {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 40, tr(STR_DOWNLOADING));
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 10, statusMessage.c_str());
|
||||
const auto maxWidth = pageWidth - 40;
|
||||
auto title = renderer.truncatedText(UI_10_FONT_ID, statusMessage.c_str(), maxWidth);
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 10, title.c_str());
|
||||
if (downloadTotal > 0) {
|
||||
const int barWidth = pageWidth - 100;
|
||||
constexpr int barHeight = 20;
|
||||
@@ -225,22 +234,21 @@ void OpdsBookBrowserActivity::render(Activity::RenderLock&&) {
|
||||
}
|
||||
|
||||
void OpdsBookBrowserActivity::fetchFeed(const std::string& path) {
|
||||
const char* serverUrl = SETTINGS.opdsServerUrl;
|
||||
if (strlen(serverUrl) == 0) {
|
||||
if (server.url.empty()) {
|
||||
state = BrowserState::ERROR;
|
||||
errorMessage = tr(STR_NO_SERVER_URL);
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
std::string url = UrlUtils::buildUrl(serverUrl, path);
|
||||
std::string url = UrlUtils::buildUrl(server.url, path);
|
||||
LOG_DBG("OPDS", "Fetching: %s", url.c_str());
|
||||
|
||||
OpdsParser parser;
|
||||
|
||||
{
|
||||
OpdsParserStream stream{parser};
|
||||
if (!HttpDownloader::fetchUrl(url, stream)) {
|
||||
if (!HttpDownloader::fetchUrl(url, stream, server.username, server.password)) {
|
||||
state = BrowserState::ERROR;
|
||||
errorMessage = tr(STR_FETCH_FEED_FAILED);
|
||||
requestUpdate();
|
||||
@@ -303,36 +311,60 @@ void OpdsBookBrowserActivity::navigateBack() {
|
||||
}
|
||||
}
|
||||
|
||||
void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book) {
|
||||
void OpdsBookBrowserActivity::launchDirectoryPicker(const OpdsEntry& book) {
|
||||
pendingBook = book;
|
||||
state = BrowserState::PICKING_DIRECTORY;
|
||||
requestUpdate();
|
||||
|
||||
enterNewActivity(new DirectoryPickerActivity(
|
||||
renderer, mappedInput, [this](const std::string& dir) { onDirectorySelected(dir); },
|
||||
[this] { onDirectoryPickerCancelled(); }, server.downloadPath));
|
||||
}
|
||||
|
||||
void OpdsBookBrowserActivity::onDirectorySelected(const std::string& directory) {
|
||||
// Copy before exitActivity() destroys the subactivity (and the referenced string)
|
||||
std::string dir = directory;
|
||||
exitActivity();
|
||||
downloadBook(pendingBook, dir);
|
||||
}
|
||||
|
||||
void OpdsBookBrowserActivity::onDirectoryPickerCancelled() {
|
||||
exitActivity();
|
||||
state = BrowserState::BROWSING;
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book, const std::string& directory) {
|
||||
state = BrowserState::DOWNLOADING;
|
||||
statusMessage = book.title;
|
||||
downloadProgress = 0;
|
||||
downloadTotal = 0;
|
||||
requestUpdate();
|
||||
|
||||
// Build full download URL
|
||||
std::string downloadUrl = UrlUtils::buildUrl(SETTINGS.opdsServerUrl, book.href);
|
||||
std::string downloadUrl = UrlUtils::buildUrl(server.url, book.href);
|
||||
|
||||
// Create sanitized filename: "Title - Author.epub" or just "Title.epub" if no author
|
||||
std::string baseName = book.title;
|
||||
if (!book.author.empty()) {
|
||||
baseName += " - " + book.author;
|
||||
}
|
||||
std::string filename = "/" + StringUtils::sanitizeFilename(baseName) + ".epub";
|
||||
std::string dir = directory;
|
||||
if (dir.back() != '/') dir += '/';
|
||||
std::string filename = dir + StringUtils::sanitizeFilename(baseName) + ".epub";
|
||||
|
||||
LOG_DBG("OPDS", "Downloading: %s -> %s", downloadUrl.c_str(), filename.c_str());
|
||||
|
||||
const auto result =
|
||||
HttpDownloader::downloadToFile(downloadUrl, filename, [this](const size_t downloaded, const size_t total) {
|
||||
const auto result = HttpDownloader::downloadToFile(
|
||||
downloadUrl, filename,
|
||||
[this](const size_t downloaded, const size_t total) {
|
||||
downloadProgress = downloaded;
|
||||
downloadTotal = total;
|
||||
requestUpdate();
|
||||
});
|
||||
},
|
||||
server.username, server.password);
|
||||
|
||||
if (result == HttpDownloader::OK) {
|
||||
LOG_DBG("OPDS", "Download complete: %s", filename.c_str());
|
||||
|
||||
// Invalidate any existing cache for this file to prevent stale metadata issues
|
||||
Epub epub(filename, "/.crosspoint");
|
||||
epub.clearCache();
|
||||
LOG_DBG("OPDS", "Cleared cache for: %s", filename.c_str());
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include <vector>
|
||||
|
||||
#include "../ActivityWithSubactivity.h"
|
||||
#include "OpdsServerStore.h"
|
||||
#include "util/ButtonNavigator.h"
|
||||
|
||||
/**
|
||||
@@ -16,17 +17,18 @@
|
||||
class OpdsBookBrowserActivity final : public ActivityWithSubactivity {
|
||||
public:
|
||||
enum class BrowserState {
|
||||
CHECK_WIFI, // Checking WiFi connection
|
||||
WIFI_SELECTION, // WiFi selection subactivity is active
|
||||
LOADING, // Fetching OPDS feed
|
||||
BROWSING, // Displaying entries (navigation or books)
|
||||
DOWNLOADING, // Downloading selected EPUB
|
||||
ERROR // Error state with message
|
||||
CHECK_WIFI, // Checking WiFi connection
|
||||
WIFI_SELECTION, // WiFi selection subactivity is active
|
||||
LOADING, // Fetching OPDS feed
|
||||
BROWSING, // Displaying entries (navigation or books)
|
||||
PICKING_DIRECTORY, // Directory picker subactivity is active
|
||||
DOWNLOADING, // Downloading selected EPUB
|
||||
ERROR // Error state with message
|
||||
};
|
||||
|
||||
explicit OpdsBookBrowserActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
||||
const std::function<void()>& onGoHome)
|
||||
: ActivityWithSubactivity("OpdsBookBrowser", renderer, mappedInput), onGoHome(onGoHome) {}
|
||||
const std::function<void()>& onGoHome, const OpdsServer& server)
|
||||
: ActivityWithSubactivity("OpdsBookBrowser", renderer, mappedInput), onGoHome(onGoHome), server(server) {}
|
||||
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
@@ -46,6 +48,7 @@ class OpdsBookBrowserActivity final : public ActivityWithSubactivity {
|
||||
size_t downloadTotal = 0;
|
||||
|
||||
const std::function<void()> onGoHome;
|
||||
OpdsServer server; // Copied at construction — safe even if the store changes during browsing
|
||||
|
||||
void checkAndConnectWifi();
|
||||
void launchWifiSelection();
|
||||
@@ -53,6 +56,11 @@ class OpdsBookBrowserActivity final : public ActivityWithSubactivity {
|
||||
void fetchFeed(const std::string& path);
|
||||
void navigateToEntry(const OpdsEntry& entry);
|
||||
void navigateBack();
|
||||
void downloadBook(const OpdsEntry& book);
|
||||
void launchDirectoryPicker(const OpdsEntry& book);
|
||||
void onDirectorySelected(const std::string& directory);
|
||||
void onDirectoryPickerCancelled();
|
||||
void downloadBook(const OpdsEntry& book, const std::string& directory);
|
||||
bool preventAutoSleep() override { return true; }
|
||||
|
||||
OpdsEntry pendingBook;
|
||||
};
|
||||
|
||||
115
src/activities/home/BookManageMenuActivity.cpp
Normal file
115
src/activities/home/BookManageMenuActivity.cpp
Normal file
@@ -0,0 +1,115 @@
|
||||
#include "BookManageMenuActivity.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
#include <I18n.h>
|
||||
|
||||
#include "MappedInputManager.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
void BookManageMenuActivity::buildMenuItems() {
|
||||
menuItems.clear();
|
||||
if (archived) {
|
||||
menuItems.push_back({Action::UNARCHIVE, StrId::STR_UNARCHIVE_BOOK});
|
||||
} else {
|
||||
menuItems.push_back({Action::ARCHIVE, StrId::STR_ARCHIVE_BOOK});
|
||||
}
|
||||
menuItems.push_back({Action::DELETE, StrId::STR_DELETE_BOOK});
|
||||
menuItems.push_back({Action::DELETE_CACHE, StrId::STR_DELETE_CACHE_ONLY});
|
||||
menuItems.push_back({Action::REINDEX, StrId::STR_REINDEX_BOOK});
|
||||
}
|
||||
|
||||
void BookManageMenuActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
selectedIndex = 0;
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
void BookManageMenuActivity::onExit() { Activity::onExit(); }
|
||||
|
||||
void BookManageMenuActivity::loop() {
|
||||
// Long-press detection: REINDEX_FULL when long-pressing on the Reindex item
|
||||
if (mappedInput.isPressed(MappedInputManager::Button::Confirm) && mappedInput.getHeldTime() >= LONG_PRESS_MS) {
|
||||
if (!ignoreNextConfirmRelease && selectedIndex < static_cast<int>(menuItems.size()) &&
|
||||
menuItems[selectedIndex].action == Action::REINDEX) {
|
||||
ignoreNextConfirmRelease = true;
|
||||
auto cb = onAction;
|
||||
cb(Action::REINDEX_FULL);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
buttonNavigator.onNext([this] {
|
||||
selectedIndex = ButtonNavigator::nextIndex(selectedIndex, static_cast<int>(menuItems.size()));
|
||||
requestUpdate();
|
||||
});
|
||||
|
||||
buttonNavigator.onPrevious([this] {
|
||||
selectedIndex = ButtonNavigator::previousIndex(selectedIndex, static_cast<int>(menuItems.size()));
|
||||
requestUpdate();
|
||||
});
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
if (ignoreNextConfirmRelease) {
|
||||
ignoreNextConfirmRelease = false;
|
||||
return;
|
||||
}
|
||||
if (selectedIndex < static_cast<int>(menuItems.size())) {
|
||||
auto cb = onAction;
|
||||
cb(menuItems[selectedIndex].action);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
auto cb = onCancel;
|
||||
cb();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void BookManageMenuActivity::render(Activity::RenderLock&&) {
|
||||
renderer.clearScreen();
|
||||
|
||||
const auto pageWidth = renderer.getScreenWidth();
|
||||
const auto pageHeight = renderer.getScreenHeight();
|
||||
|
||||
constexpr int popupMargin = 20;
|
||||
constexpr int lineHeight = 30;
|
||||
constexpr int titleHeight = 40;
|
||||
const int optionCount = static_cast<int>(menuItems.size());
|
||||
const int popupH = titleHeight + popupMargin + lineHeight * optionCount + popupMargin;
|
||||
const int popupW = pageWidth - 60;
|
||||
const int popupX = (pageWidth - popupW) / 2;
|
||||
const int popupY = (pageHeight - popupH) / 2;
|
||||
|
||||
// Popup border and background
|
||||
renderer.fillRect(popupX - 2, popupY - 2, popupW + 4, popupH + 4, true);
|
||||
renderer.fillRect(popupX, popupY, popupW, popupH, false);
|
||||
|
||||
// Title
|
||||
renderer.drawText(UI_12_FONT_ID, popupX + popupMargin, popupY + 8, tr(STR_MANAGE_BOOK), true, EpdFontFamily::BOLD);
|
||||
|
||||
// Divider line
|
||||
const int dividerY = popupY + titleHeight;
|
||||
renderer.fillRect(popupX + 4, dividerY, popupW - 8, 1, true);
|
||||
|
||||
// Menu items
|
||||
const int startY = dividerY + popupMargin / 2;
|
||||
for (int i = 0; i < optionCount; ++i) {
|
||||
const int itemY = startY + i * lineHeight;
|
||||
const bool isSelected = (i == selectedIndex);
|
||||
|
||||
if (isSelected) {
|
||||
renderer.fillRect(popupX + 2, itemY, popupW - 4, lineHeight, true);
|
||||
}
|
||||
|
||||
renderer.drawText(UI_10_FONT_ID, popupX + popupMargin, itemY, I18N.get(menuItems[i].labelId), !isSelected);
|
||||
}
|
||||
|
||||
// Button hints
|
||||
const auto labels = mappedInput.mapLabels(tr(STR_CANCEL), tr(STR_SELECT), tr(STR_DIR_UP), tr(STR_DIR_DOWN));
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
|
||||
renderer.displayBuffer();
|
||||
}
|
||||
61
src/activities/home/BookManageMenuActivity.h
Normal file
61
src/activities/home/BookManageMenuActivity.h
Normal file
@@ -0,0 +1,61 @@
|
||||
#pragma once
|
||||
|
||||
#include <I18n.h>
|
||||
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "../Activity.h"
|
||||
#include "util/ButtonNavigator.h"
|
||||
|
||||
class BookManageMenuActivity final : public Activity {
|
||||
public:
|
||||
enum class Action {
|
||||
ARCHIVE,
|
||||
UNARCHIVE,
|
||||
DELETE,
|
||||
DELETE_CACHE,
|
||||
REINDEX,
|
||||
REINDEX_FULL,
|
||||
};
|
||||
|
||||
explicit BookManageMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
||||
const std::string& bookPath, bool isArchived,
|
||||
const std::function<void(Action)>& onAction,
|
||||
const std::function<void()>& onCancel,
|
||||
bool initialSkipRelease = false)
|
||||
: Activity("BookManageMenu", renderer, mappedInput),
|
||||
bookPath(bookPath),
|
||||
archived(isArchived),
|
||||
ignoreNextConfirmRelease(initialSkipRelease),
|
||||
onAction(onAction),
|
||||
onCancel(onCancel) {
|
||||
buildMenuItems();
|
||||
}
|
||||
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
void render(Activity::RenderLock&&) override;
|
||||
|
||||
private:
|
||||
struct MenuItem {
|
||||
Action action;
|
||||
StrId labelId;
|
||||
};
|
||||
|
||||
std::string bookPath;
|
||||
bool archived;
|
||||
std::vector<MenuItem> menuItems;
|
||||
int selectedIndex = 0;
|
||||
ButtonNavigator buttonNavigator;
|
||||
|
||||
bool ignoreNextConfirmRelease;
|
||||
static constexpr unsigned long LONG_PRESS_MS = 700;
|
||||
|
||||
const std::function<void(Action)> onAction;
|
||||
const std::function<void()> onCancel;
|
||||
|
||||
void buildMenuItems();
|
||||
};
|
||||
@@ -13,13 +13,15 @@
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
#include "Battery.h"
|
||||
#include "BookManageMenuActivity.h"
|
||||
#include "CrossPointSettings.h"
|
||||
#include "CrossPointState.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "OpdsServerStore.h"
|
||||
#include "RecentBooksStore.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
#include "util/BookManager.h"
|
||||
#include "util/StringUtils.h"
|
||||
|
||||
int HomeActivity::getMenuItemCount() const {
|
||||
@@ -27,7 +29,7 @@ int HomeActivity::getMenuItemCount() const {
|
||||
if (!recentBooks.empty()) {
|
||||
count += recentBooks.size();
|
||||
}
|
||||
if (hasOpdsUrl) {
|
||||
if (hasOpdsServers) {
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
@@ -125,10 +127,9 @@ void HomeActivity::loadRecentCovers(int coverHeight) {
|
||||
}
|
||||
|
||||
void HomeActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
ActivityWithSubactivity::onEnter();
|
||||
|
||||
// Check if OPDS browser URL is configured
|
||||
hasOpdsUrl = strlen(SETTINGS.opdsServerUrl) > 0;
|
||||
hasOpdsServers = OPDS_STORE.hasServers();
|
||||
|
||||
selectorIndex = 0;
|
||||
|
||||
@@ -140,7 +141,7 @@ void HomeActivity::onEnter() {
|
||||
}
|
||||
|
||||
void HomeActivity::onExit() {
|
||||
Activity::onExit();
|
||||
ActivityWithSubactivity::onExit();
|
||||
|
||||
// Free the stored cover buffer if any
|
||||
freeCoverBuffer();
|
||||
@@ -189,6 +190,11 @@ void HomeActivity::freeCoverBuffer() {
|
||||
}
|
||||
|
||||
void HomeActivity::loop() {
|
||||
if (subActivity) {
|
||||
subActivity->loop();
|
||||
return;
|
||||
}
|
||||
|
||||
const int menuCount = getMenuItemCount();
|
||||
|
||||
buttonNavigator.onNext([this, menuCount] {
|
||||
@@ -201,17 +207,42 @@ void HomeActivity::loop() {
|
||||
requestUpdate();
|
||||
});
|
||||
|
||||
// Long-press Confirm: manage menu for recent books, or browse archive for Browse Files
|
||||
if (mappedInput.isPressed(MappedInputManager::Button::Confirm) && mappedInput.getHeldTime() >= LONG_PRESS_MS &&
|
||||
!ignoreNextConfirmRelease) {
|
||||
if (selectorIndex < static_cast<int>(recentBooks.size())) {
|
||||
// Long-press on a recent book → manage menu
|
||||
ignoreNextConfirmRelease = true;
|
||||
openManageMenu(recentBooks[selectorIndex].path);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if Browse Files is selected
|
||||
const int menuSelectedIndex = selectorIndex - static_cast<int>(recentBooks.size());
|
||||
if (menuSelectedIndex == 0) {
|
||||
// Long-press on Browse Files → go to archive folder
|
||||
ignoreNextConfirmRelease = true;
|
||||
onMyLibraryOpenWithPath("/.archive", true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
if (ignoreNextConfirmRelease) {
|
||||
ignoreNextConfirmRelease = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate dynamic indices based on which options are available
|
||||
int idx = 0;
|
||||
int menuSelectedIndex = selectorIndex - static_cast<int>(recentBooks.size());
|
||||
const int myLibraryIdx = idx++;
|
||||
const int recentsIdx = idx++;
|
||||
const int opdsLibraryIdx = hasOpdsUrl ? idx++ : -1;
|
||||
const int opdsLibraryIdx = hasOpdsServers ? idx++ : -1;
|
||||
const int fileTransferIdx = idx++;
|
||||
const int settingsIdx = idx;
|
||||
|
||||
if (selectorIndex < recentBooks.size()) {
|
||||
if (selectorIndex < static_cast<int>(recentBooks.size())) {
|
||||
onSelectBook(recentBooks[selectorIndex].path);
|
||||
} else if (menuSelectedIndex == myLibraryIdx) {
|
||||
onMyLibraryOpen();
|
||||
@@ -246,7 +277,7 @@ void HomeActivity::render(Activity::RenderLock&&) {
|
||||
tr(STR_SETTINGS_TITLE)};
|
||||
std::vector<UIIcon> menuIcons = {Folder, Recent, Transfer, Settings};
|
||||
|
||||
if (hasOpdsUrl) {
|
||||
if (hasOpdsServers) {
|
||||
// Insert OPDS Browser after My Library
|
||||
menuItems.insert(menuItems.begin() + 2, tr(STR_OPDS_BROWSER));
|
||||
menuIcons.insert(menuIcons.begin() + 2, Library);
|
||||
@@ -274,3 +305,53 @@ void HomeActivity::render(Activity::RenderLock&&) {
|
||||
loadRecentCovers(metrics.homeCoverHeight);
|
||||
}
|
||||
}
|
||||
|
||||
void HomeActivity::openManageMenu(const std::string& bookPath) {
|
||||
const bool isArchived = BookManager::isArchived(bookPath);
|
||||
const std::string capturedPath = bookPath;
|
||||
enterNewActivity(new BookManageMenuActivity(
|
||||
renderer, mappedInput, capturedPath, isArchived,
|
||||
[this, capturedPath](BookManageMenuActivity::Action action) {
|
||||
exitActivity();
|
||||
bool success = false;
|
||||
switch (action) {
|
||||
case BookManageMenuActivity::Action::ARCHIVE:
|
||||
success = BookManager::archiveBook(capturedPath);
|
||||
break;
|
||||
case BookManageMenuActivity::Action::UNARCHIVE:
|
||||
success = BookManager::unarchiveBook(capturedPath);
|
||||
break;
|
||||
case BookManageMenuActivity::Action::DELETE:
|
||||
success = BookManager::deleteBook(capturedPath);
|
||||
break;
|
||||
case BookManageMenuActivity::Action::DELETE_CACHE:
|
||||
success = BookManager::deleteBookCache(capturedPath);
|
||||
break;
|
||||
case BookManageMenuActivity::Action::REINDEX:
|
||||
success = BookManager::reindexBook(capturedPath, false);
|
||||
break;
|
||||
case BookManageMenuActivity::Action::REINDEX_FULL:
|
||||
success = BookManager::reindexBook(capturedPath, true);
|
||||
break;
|
||||
}
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
GUI.drawPopup(renderer, success ? tr(STR_DONE) : tr(STR_ACTION_FAILED));
|
||||
}
|
||||
requestUpdateAndWait();
|
||||
// Fully reset recent books state so the home screen reloads cleanly
|
||||
recentBooks.clear();
|
||||
recentsLoaded = false;
|
||||
recentsLoading = false;
|
||||
coverRendered = false;
|
||||
freeCoverBuffer();
|
||||
selectorIndex = 0;
|
||||
firstRenderDone = false;
|
||||
requestUpdate();
|
||||
},
|
||||
[this] {
|
||||
exitActivity();
|
||||
requestUpdate();
|
||||
},
|
||||
true));
|
||||
}
|
||||
|
||||
@@ -2,26 +2,32 @@
|
||||
#include <functional>
|
||||
#include <vector>
|
||||
|
||||
#include "../Activity.h"
|
||||
#include "../ActivityWithSubactivity.h"
|
||||
#include "./MyLibraryActivity.h"
|
||||
#include "util/ButtonNavigator.h"
|
||||
|
||||
struct RecentBook;
|
||||
struct Rect;
|
||||
|
||||
class HomeActivity final : public Activity {
|
||||
class HomeActivity final : public ActivityWithSubactivity {
|
||||
ButtonNavigator buttonNavigator;
|
||||
int selectorIndex = 0;
|
||||
bool recentsLoading = false;
|
||||
bool recentsLoaded = false;
|
||||
bool firstRenderDone = false;
|
||||
bool hasOpdsUrl = false;
|
||||
bool hasOpdsServers = false;
|
||||
bool coverRendered = false; // Track if cover has been rendered once
|
||||
bool coverBufferStored = false; // Track if cover buffer is stored
|
||||
uint8_t* coverBuffer = nullptr; // HomeActivity's own buffer for cover image
|
||||
std::vector<RecentBook> recentBooks;
|
||||
|
||||
// Long-press state
|
||||
bool ignoreNextConfirmRelease = false;
|
||||
static constexpr unsigned long LONG_PRESS_MS = 700;
|
||||
|
||||
const std::function<void(const std::string& path)> onSelectBook;
|
||||
const std::function<void()> onMyLibraryOpen;
|
||||
const std::function<void(const std::string& path, bool initialSkipRelease)> onMyLibraryOpenWithPath;
|
||||
const std::function<void()> onRecentsOpen;
|
||||
const std::function<void()> onSettingsOpen;
|
||||
const std::function<void()> onFileTransferOpen;
|
||||
@@ -33,16 +39,20 @@ class HomeActivity final : public Activity {
|
||||
void freeCoverBuffer(); // Free the stored cover buffer
|
||||
void loadRecentBooks(int maxBooks);
|
||||
void loadRecentCovers(int coverHeight);
|
||||
void openManageMenu(const std::string& bookPath);
|
||||
|
||||
public:
|
||||
explicit HomeActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
||||
const std::function<void(const std::string& path)>& onSelectBook,
|
||||
const std::function<void()>& onMyLibraryOpen, const std::function<void()>& onRecentsOpen,
|
||||
const std::function<void()>& onMyLibraryOpen,
|
||||
const std::function<void(const std::string& path, bool initialSkipRelease)>& onMyLibraryOpenWithPath,
|
||||
const std::function<void()>& onRecentsOpen,
|
||||
const std::function<void()>& onSettingsOpen, const std::function<void()>& onFileTransferOpen,
|
||||
const std::function<void()>& onOpdsBrowserOpen)
|
||||
: Activity("Home", renderer, mappedInput),
|
||||
: ActivityWithSubactivity("Home", renderer, mappedInput),
|
||||
onSelectBook(onSelectBook),
|
||||
onMyLibraryOpen(onMyLibraryOpen),
|
||||
onMyLibraryOpenWithPath(onMyLibraryOpenWithPath),
|
||||
onRecentsOpen(onRecentsOpen),
|
||||
onSettingsOpen(onSettingsOpen),
|
||||
onFileTransferOpen(onFileTransferOpen),
|
||||
|
||||
@@ -4,69 +4,17 @@
|
||||
#include <HalStorage.h>
|
||||
#include <I18n.h>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include "BookManageMenuActivity.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
#include "util/BookManager.h"
|
||||
#include "util/StringUtils.h"
|
||||
|
||||
namespace {
|
||||
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) {
|
||||
// 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';
|
||||
});
|
||||
}
|
||||
|
||||
void MyLibraryActivity::loadFiles() {
|
||||
files.clear();
|
||||
|
||||
@@ -92,18 +40,18 @@ void MyLibraryActivity::loadFiles() {
|
||||
auto filename = std::string(name);
|
||||
if (StringUtils::checkFileExtension(filename, ".epub") || StringUtils::checkFileExtension(filename, ".xtch") ||
|
||||
StringUtils::checkFileExtension(filename, ".xtc") || StringUtils::checkFileExtension(filename, ".txt") ||
|
||||
StringUtils::checkFileExtension(filename, ".md")) {
|
||||
StringUtils::checkFileExtension(filename, ".md") || StringUtils::checkFileExtension(filename, ".bmp")) {
|
||||
files.emplace_back(filename);
|
||||
}
|
||||
}
|
||||
file.close();
|
||||
}
|
||||
root.close();
|
||||
sortFileList(files);
|
||||
StringUtils::sortFileList(files);
|
||||
}
|
||||
|
||||
void MyLibraryActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
ActivityWithSubactivity::onEnter();
|
||||
|
||||
loadFiles();
|
||||
selectorIndex = 0;
|
||||
@@ -112,11 +60,26 @@ void MyLibraryActivity::onEnter() {
|
||||
}
|
||||
|
||||
void MyLibraryActivity::onExit() {
|
||||
Activity::onExit();
|
||||
ActivityWithSubactivity::onExit();
|
||||
files.clear();
|
||||
}
|
||||
|
||||
void MyLibraryActivity::loop() {
|
||||
if (subActivity) {
|
||||
subActivity->loop();
|
||||
return;
|
||||
}
|
||||
|
||||
// Deferred open: wait for Confirm release before navigating to avoid stale event in reader
|
||||
if (!pendingOpenPath.empty()) {
|
||||
if (!mappedInput.isPressed(MappedInputManager::Button::Confirm)) {
|
||||
std::string path = std::move(pendingOpenPath);
|
||||
pendingOpenPath.clear();
|
||||
onSelectBook(path);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Long press BACK (1s+) goes to root folder
|
||||
if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= GO_HOME_MS &&
|
||||
basepath != "/") {
|
||||
@@ -128,7 +91,28 @@ void MyLibraryActivity::loop() {
|
||||
|
||||
const int pageItems = UITheme::getInstance().getNumberOfItemsPerPage(renderer, true, false, true, false);
|
||||
|
||||
// In archive context: long-press = unarchive+open, short-press = manage menu
|
||||
// Outside archive: long-press = manage menu, short-press = open
|
||||
const bool inArchive = isInArchive();
|
||||
const bool isBookFile = !files.empty() && selectorIndex < files.size() && files[selectorIndex].back() != '/';
|
||||
|
||||
if (mappedInput.isPressed(MappedInputManager::Button::Confirm) && mappedInput.getHeldTime() >= LONG_PRESS_MS &&
|
||||
!ignoreNextConfirmRelease && isBookFile) {
|
||||
ignoreNextConfirmRelease = true;
|
||||
const std::string fullPath = (basepath.back() == '/' ? basepath : basepath + "/") + files[selectorIndex];
|
||||
if (inArchive) {
|
||||
unarchiveAndOpen(fullPath);
|
||||
} else {
|
||||
openManageMenu(fullPath);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
if (ignoreNextConfirmRelease) {
|
||||
ignoreNextConfirmRelease = false;
|
||||
return;
|
||||
}
|
||||
if (files.empty()) {
|
||||
return;
|
||||
}
|
||||
@@ -139,6 +123,9 @@ void MyLibraryActivity::loop() {
|
||||
loadFiles();
|
||||
selectorIndex = 0;
|
||||
requestUpdate();
|
||||
} else if (inArchive) {
|
||||
const std::string fullPath = basepath + files[selectorIndex];
|
||||
openManageMenu(fullPath);
|
||||
} else {
|
||||
onSelectBook(basepath + files[selectorIndex]);
|
||||
return;
|
||||
@@ -196,6 +183,15 @@ std::string getFileName(std::string filename) {
|
||||
return filename.substr(0, pos);
|
||||
}
|
||||
|
||||
std::string getFileExtension(std::string filename) {
|
||||
if (filename.back() == '/') {
|
||||
return "";
|
||||
}
|
||||
const auto pos = filename.rfind('.');
|
||||
if (pos == std::string::npos) return "";
|
||||
return filename.substr(pos);
|
||||
}
|
||||
|
||||
void MyLibraryActivity::render(Activity::RenderLock&&) {
|
||||
renderer.clearScreen();
|
||||
|
||||
@@ -214,7 +210,8 @@ void MyLibraryActivity::render(Activity::RenderLock&&) {
|
||||
GUI.drawList(
|
||||
renderer, Rect{0, contentTop, pageWidth, contentHeight}, files.size(), selectorIndex,
|
||||
[this](int index) { return getFileName(files[index]); }, nullptr,
|
||||
[this](int index) { return UITheme::getFileIcon(files[index]); });
|
||||
[this](int index) { return UITheme::getFileIcon(files[index]); },
|
||||
[this](int index) { return getFileExtension(files[index]); }, false);
|
||||
}
|
||||
|
||||
// Help text
|
||||
@@ -225,6 +222,94 @@ void MyLibraryActivity::render(Activity::RenderLock&&) {
|
||||
renderer.displayBuffer();
|
||||
}
|
||||
|
||||
void MyLibraryActivity::openManageMenu(const std::string& bookPath) {
|
||||
const bool isArchived = BookManager::isArchived(bookPath);
|
||||
const bool fromLongPress = !isInArchive();
|
||||
const std::string capturedPath = bookPath;
|
||||
enterNewActivity(new BookManageMenuActivity(
|
||||
renderer, mappedInput, capturedPath, isArchived,
|
||||
[this, capturedPath](BookManageMenuActivity::Action action) {
|
||||
exitActivity();
|
||||
bool success = false;
|
||||
switch (action) {
|
||||
case BookManageMenuActivity::Action::ARCHIVE:
|
||||
success = BookManager::archiveBook(capturedPath);
|
||||
break;
|
||||
case BookManageMenuActivity::Action::UNARCHIVE:
|
||||
success = BookManager::unarchiveBook(capturedPath);
|
||||
break;
|
||||
case BookManageMenuActivity::Action::DELETE:
|
||||
success = BookManager::deleteBook(capturedPath);
|
||||
break;
|
||||
case BookManageMenuActivity::Action::DELETE_CACHE:
|
||||
success = BookManager::deleteBookCache(capturedPath);
|
||||
break;
|
||||
case BookManageMenuActivity::Action::REINDEX:
|
||||
success = BookManager::reindexBook(capturedPath, false);
|
||||
break;
|
||||
case BookManageMenuActivity::Action::REINDEX_FULL:
|
||||
success = BookManager::reindexBook(capturedPath, true);
|
||||
break;
|
||||
}
|
||||
if (success && BookManager::isArchived(capturedPath) &&
|
||||
(action == BookManageMenuActivity::Action::UNARCHIVE ||
|
||||
action == BookManageMenuActivity::Action::DELETE)) {
|
||||
BookManager::cleanupEmptyArchiveDirs(capturedPath);
|
||||
}
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
GUI.drawPopup(renderer, success ? tr(STR_DONE) : tr(STR_ACTION_FAILED));
|
||||
}
|
||||
requestUpdateAndWait();
|
||||
loadFiles();
|
||||
if (files.empty() && isInArchive() && basepath != "/.archive") {
|
||||
// Current directory was removed; navigate up to nearest existing ancestor
|
||||
while (basepath.length() > std::string("/.archive").length()) {
|
||||
auto slash = basepath.find_last_of('/');
|
||||
if (slash == std::string::npos || slash == 0) break;
|
||||
basepath = basepath.substr(0, slash);
|
||||
loadFiles();
|
||||
if (!files.empty() || basepath == "/.archive") break;
|
||||
}
|
||||
selectorIndex = 0;
|
||||
} else if (selectorIndex >= files.size() && !files.empty()) {
|
||||
selectorIndex = files.size() - 1;
|
||||
}
|
||||
requestUpdate();
|
||||
},
|
||||
[this] {
|
||||
exitActivity();
|
||||
requestUpdate();
|
||||
},
|
||||
fromLongPress));
|
||||
}
|
||||
|
||||
bool MyLibraryActivity::isInArchive() const { return basepath.rfind("/.archive", 0) == 0; }
|
||||
|
||||
void MyLibraryActivity::unarchiveAndOpen(const std::string& bookPath) {
|
||||
std::string unarchivedPath;
|
||||
if (BookManager::unarchiveBook(bookPath, &unarchivedPath)) {
|
||||
BookManager::cleanupEmptyArchiveDirs(bookPath);
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
GUI.drawPopup(renderer, tr(STR_BOOK_UNARCHIVED));
|
||||
}
|
||||
requestUpdateAndWait();
|
||||
pendingOpenPath = unarchivedPath;
|
||||
} else {
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
GUI.drawPopup(renderer, tr(STR_ACTION_FAILED));
|
||||
}
|
||||
requestUpdateAndWait();
|
||||
loadFiles();
|
||||
if (selectorIndex >= files.size() && !files.empty()) {
|
||||
selectorIndex = files.size() - 1;
|
||||
}
|
||||
requestUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
size_t MyLibraryActivity::findEntry(const std::string& name) const {
|
||||
for (size_t i = 0; i < files.size(); i++)
|
||||
if (files[i] == name) return i;
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "../Activity.h"
|
||||
#include "../ActivityWithSubactivity.h"
|
||||
#include "RecentBooksStore.h"
|
||||
#include "util/ButtonNavigator.h"
|
||||
|
||||
class MyLibraryActivity final : public Activity {
|
||||
class MyLibraryActivity final : public ActivityWithSubactivity {
|
||||
private:
|
||||
ButtonNavigator buttonNavigator;
|
||||
|
||||
@@ -17,6 +17,13 @@ class MyLibraryActivity final : public Activity {
|
||||
std::string basepath = "/";
|
||||
std::vector<std::string> files;
|
||||
|
||||
// Long-press state
|
||||
bool ignoreNextConfirmRelease = false;
|
||||
static constexpr unsigned long LONG_PRESS_MS = 700;
|
||||
|
||||
// Deferred open: wait for Confirm release before navigating to book
|
||||
std::string pendingOpenPath;
|
||||
|
||||
// Callbacks
|
||||
const std::function<void(const std::string& path)> onSelectBook;
|
||||
const std::function<void()> onGoHome;
|
||||
@@ -24,14 +31,19 @@ class MyLibraryActivity final : public Activity {
|
||||
// Data loading
|
||||
void loadFiles();
|
||||
size_t findEntry(const std::string& name) const;
|
||||
bool isInArchive() const;
|
||||
|
||||
void openManageMenu(const std::string& bookPath);
|
||||
void unarchiveAndOpen(const std::string& bookPath);
|
||||
|
||||
public:
|
||||
explicit MyLibraryActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
||||
const std::function<void()>& onGoHome,
|
||||
const std::function<void(const std::string& path)>& onSelectBook,
|
||||
std::string initialPath = "/")
|
||||
: Activity("MyLibrary", renderer, mappedInput),
|
||||
std::string initialPath = "/", bool initialSkipRelease = false)
|
||||
: ActivityWithSubactivity("MyLibrary", renderer, mappedInput),
|
||||
basepath(initialPath.empty() ? "/" : std::move(initialPath)),
|
||||
ignoreNextConfirmRelease(initialSkipRelease),
|
||||
onSelectBook(onSelectBook),
|
||||
onGoHome(onGoHome) {}
|
||||
void onEnter() override;
|
||||
|
||||
@@ -6,10 +6,12 @@
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include "BookManageMenuActivity.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "RecentBooksStore.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
#include "util/BookManager.h"
|
||||
#include "util/StringUtils.h"
|
||||
|
||||
namespace {
|
||||
@@ -31,7 +33,7 @@ void RecentBooksActivity::loadRecentBooks() {
|
||||
}
|
||||
|
||||
void RecentBooksActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
ActivityWithSubactivity::onEnter();
|
||||
|
||||
// Load data
|
||||
loadRecentBooks();
|
||||
@@ -41,14 +43,33 @@ void RecentBooksActivity::onEnter() {
|
||||
}
|
||||
|
||||
void RecentBooksActivity::onExit() {
|
||||
Activity::onExit();
|
||||
ActivityWithSubactivity::onExit();
|
||||
recentBooks.clear();
|
||||
}
|
||||
|
||||
void RecentBooksActivity::loop() {
|
||||
if (subActivity) {
|
||||
subActivity->loop();
|
||||
return;
|
||||
}
|
||||
|
||||
const int pageItems = UITheme::getInstance().getNumberOfItemsPerPage(renderer, true, false, true, true);
|
||||
|
||||
// Long-press Confirm: open manage menu
|
||||
if (mappedInput.isPressed(MappedInputManager::Button::Confirm) && mappedInput.getHeldTime() >= LONG_PRESS_MS &&
|
||||
!ignoreNextConfirmRelease) {
|
||||
if (!recentBooks.empty() && selectorIndex < static_cast<int>(recentBooks.size())) {
|
||||
ignoreNextConfirmRelease = true;
|
||||
openManageMenu(recentBooks[selectorIndex].path);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
if (ignoreNextConfirmRelease) {
|
||||
ignoreNextConfirmRelease = false;
|
||||
return;
|
||||
}
|
||||
if (!recentBooks.empty() && selectorIndex < static_cast<int>(recentBooks.size())) {
|
||||
LOG_DBG("RBA", "Selected recent book: %s", recentBooks[selectorIndex].path.c_str());
|
||||
onSelectBook(recentBooks[selectorIndex].path);
|
||||
@@ -111,3 +132,49 @@ void RecentBooksActivity::render(Activity::RenderLock&&) {
|
||||
|
||||
renderer.displayBuffer();
|
||||
}
|
||||
|
||||
void RecentBooksActivity::openManageMenu(const std::string& bookPath) {
|
||||
const bool isArchived = BookManager::isArchived(bookPath);
|
||||
const std::string capturedPath = bookPath;
|
||||
enterNewActivity(new BookManageMenuActivity(
|
||||
renderer, mappedInput, capturedPath, isArchived,
|
||||
[this, capturedPath](BookManageMenuActivity::Action action) {
|
||||
exitActivity();
|
||||
bool success = false;
|
||||
switch (action) {
|
||||
case BookManageMenuActivity::Action::ARCHIVE:
|
||||
success = BookManager::archiveBook(capturedPath);
|
||||
break;
|
||||
case BookManageMenuActivity::Action::UNARCHIVE:
|
||||
success = BookManager::unarchiveBook(capturedPath);
|
||||
break;
|
||||
case BookManageMenuActivity::Action::DELETE:
|
||||
success = BookManager::deleteBook(capturedPath);
|
||||
break;
|
||||
case BookManageMenuActivity::Action::DELETE_CACHE:
|
||||
success = BookManager::deleteBookCache(capturedPath);
|
||||
break;
|
||||
case BookManageMenuActivity::Action::REINDEX:
|
||||
success = BookManager::reindexBook(capturedPath, false);
|
||||
break;
|
||||
case BookManageMenuActivity::Action::REINDEX_FULL:
|
||||
success = BookManager::reindexBook(capturedPath, true);
|
||||
break;
|
||||
}
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
GUI.drawPopup(renderer, success ? tr(STR_DONE) : tr(STR_ACTION_FAILED));
|
||||
}
|
||||
requestUpdateAndWait();
|
||||
loadRecentBooks();
|
||||
if (selectorIndex >= static_cast<int>(recentBooks.size()) && !recentBooks.empty()) {
|
||||
selectorIndex = recentBooks.size() - 1;
|
||||
}
|
||||
requestUpdate();
|
||||
},
|
||||
[this] {
|
||||
exitActivity();
|
||||
requestUpdate();
|
||||
},
|
||||
true));
|
||||
}
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "../Activity.h"
|
||||
#include "../ActivityWithSubactivity.h"
|
||||
#include "RecentBooksStore.h"
|
||||
#include "util/ButtonNavigator.h"
|
||||
|
||||
class RecentBooksActivity final : public Activity {
|
||||
class RecentBooksActivity final : public ActivityWithSubactivity {
|
||||
private:
|
||||
ButtonNavigator buttonNavigator;
|
||||
|
||||
@@ -18,18 +18,23 @@ class RecentBooksActivity final : public Activity {
|
||||
// Recent tab state
|
||||
std::vector<RecentBook> recentBooks;
|
||||
|
||||
// Long-press state
|
||||
bool ignoreNextConfirmRelease = false;
|
||||
static constexpr unsigned long LONG_PRESS_MS = 700;
|
||||
|
||||
// Callbacks
|
||||
const std::function<void(const std::string& path)> onSelectBook;
|
||||
const std::function<void()> onGoHome;
|
||||
|
||||
// Data loading
|
||||
void loadRecentBooks();
|
||||
void openManageMenu(const std::string& bookPath);
|
||||
|
||||
public:
|
||||
explicit RecentBooksActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
||||
const std::function<void()>& onGoHome,
|
||||
const std::function<void(const std::string& path)>& onSelectBook)
|
||||
: Activity("RecentBooks", renderer, mappedInput), onSelectBook(onSelectBook), onGoHome(onGoHome) {}
|
||||
: ActivityWithSubactivity("RecentBooks", renderer, mappedInput), onSelectBook(onSelectBook), onGoHome(onGoHome) {}
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
#include "MappedInputManager.h"
|
||||
#include "NetworkModeSelectionActivity.h"
|
||||
#include "WifiSelectionActivity.h"
|
||||
#include "util/BootNtpSync.h"
|
||||
#include "activities/network/CalibreConnectActivity.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
@@ -35,6 +36,8 @@ constexpr uint16_t DNS_PORT = 53;
|
||||
void CrossPointWebServerActivity::onEnter() {
|
||||
ActivityWithSubactivity::onEnter();
|
||||
|
||||
BootNtpSync::cancel();
|
||||
|
||||
LOG_DBG("WEBACT", "Free heap at onEnter: %d bytes", ESP.getFreeHeap());
|
||||
|
||||
// Reset state
|
||||
|
||||
@@ -12,11 +12,14 @@
|
||||
#include "activities/util/KeyboardEntryActivity.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
#include "util/BootNtpSync.h"
|
||||
#include "util/TimeSync.h"
|
||||
|
||||
void WifiSelectionActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
|
||||
BootNtpSync::cancel();
|
||||
|
||||
// Load saved WiFi credentials - SD card operations need lock as we use SPI
|
||||
// for both
|
||||
{
|
||||
|
||||
74
src/activities/reader/EndOfBookMenuActivity.cpp
Normal file
74
src/activities/reader/EndOfBookMenuActivity.cpp
Normal file
@@ -0,0 +1,74 @@
|
||||
#include "EndOfBookMenuActivity.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
#include <I18n.h>
|
||||
|
||||
#include "MappedInputManager.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
void EndOfBookMenuActivity::buildMenuItems() {
|
||||
menuItems.clear();
|
||||
menuItems.push_back({Action::ARCHIVE, StrId::STR_ARCHIVE_BOOK});
|
||||
menuItems.push_back({Action::DELETE, StrId::STR_DELETE_BOOK});
|
||||
menuItems.push_back({Action::TABLE_OF_CONTENTS, StrId::STR_TABLE_OF_CONTENTS});
|
||||
menuItems.push_back({Action::BACK_TO_BEGINNING, StrId::STR_BACK_TO_BEGINNING});
|
||||
menuItems.push_back({Action::CLOSE_BOOK, StrId::STR_CLOSE_BOOK});
|
||||
menuItems.push_back({Action::CLOSE_MENU, StrId::STR_CLOSE_MENU});
|
||||
}
|
||||
|
||||
void EndOfBookMenuActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
selectedIndex = 0;
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
void EndOfBookMenuActivity::onExit() { Activity::onExit(); }
|
||||
|
||||
void EndOfBookMenuActivity::loop() {
|
||||
buttonNavigator.onNext([this] {
|
||||
selectedIndex = ButtonNavigator::nextIndex(selectedIndex, static_cast<int>(menuItems.size()));
|
||||
requestUpdate();
|
||||
});
|
||||
|
||||
buttonNavigator.onPrevious([this] {
|
||||
selectedIndex = ButtonNavigator::previousIndex(selectedIndex, static_cast<int>(menuItems.size()));
|
||||
requestUpdate();
|
||||
});
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
if (selectedIndex < static_cast<int>(menuItems.size())) {
|
||||
auto cb = onAction;
|
||||
cb(menuItems[selectedIndex].action);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
auto cb = onAction;
|
||||
cb(Action::CLOSE_MENU);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void EndOfBookMenuActivity::render(Activity::RenderLock&&) {
|
||||
renderer.clearScreen();
|
||||
|
||||
const auto pageWidth = renderer.getScreenWidth();
|
||||
const auto pageHeight = renderer.getScreenHeight();
|
||||
auto metrics = UITheme::getInstance().getMetrics();
|
||||
|
||||
GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, tr(STR_END_OF_BOOK));
|
||||
|
||||
const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
|
||||
const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing;
|
||||
|
||||
GUI.drawList(
|
||||
renderer, Rect{0, contentTop, pageWidth, contentHeight}, static_cast<int>(menuItems.size()), selectedIndex,
|
||||
[this](int index) { return std::string(I18N.get(menuItems[index].labelId)); });
|
||||
|
||||
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SELECT), tr(STR_DIR_UP), tr(STR_DIR_DOWN));
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
|
||||
renderer.displayBuffer();
|
||||
}
|
||||
47
src/activities/reader/EndOfBookMenuActivity.h
Normal file
47
src/activities/reader/EndOfBookMenuActivity.h
Normal file
@@ -0,0 +1,47 @@
|
||||
#pragma once
|
||||
|
||||
#include <I18n.h>
|
||||
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "../Activity.h"
|
||||
#include "util/ButtonNavigator.h"
|
||||
|
||||
class EndOfBookMenuActivity final : public Activity {
|
||||
public:
|
||||
enum class Action {
|
||||
ARCHIVE,
|
||||
DELETE,
|
||||
TABLE_OF_CONTENTS,
|
||||
BACK_TO_BEGINNING,
|
||||
CLOSE_BOOK,
|
||||
CLOSE_MENU,
|
||||
};
|
||||
|
||||
explicit EndOfBookMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& bookPath,
|
||||
const std::function<void(Action)>& onAction)
|
||||
: Activity("EndOfBookMenu", renderer, mappedInput), bookPath(bookPath), onAction(onAction) {
|
||||
buildMenuItems();
|
||||
}
|
||||
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
void render(Activity::RenderLock&&) override;
|
||||
|
||||
private:
|
||||
struct MenuItem {
|
||||
Action action;
|
||||
StrId labelId;
|
||||
};
|
||||
|
||||
std::string bookPath;
|
||||
std::vector<MenuItem> menuItems;
|
||||
int selectedIndex = 0;
|
||||
ButtonNavigator buttonNavigator;
|
||||
const std::function<void(Action)> onAction;
|
||||
|
||||
void buildMenuItems();
|
||||
};
|
||||
@@ -13,19 +13,18 @@
|
||||
#include "EpubReaderBookmarkSelectionActivity.h"
|
||||
#include "EpubReaderChapterSelectionActivity.h"
|
||||
#include "EpubReaderPercentSelectionActivity.h"
|
||||
#include "EndOfBookMenuActivity.h"
|
||||
#include "KOReaderCredentialStore.h"
|
||||
#include "KOReaderSyncActivity.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "RecentBooksStore.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
#include "util/BookManager.h"
|
||||
#include "util/BookmarkStore.h"
|
||||
#include "util/Dictionary.h"
|
||||
|
||||
// Image refresh optimization strategy:
|
||||
// 0 = Use double FAST_REFRESH technique (default, feels snappier)
|
||||
// 1 = Use displayWindow() for partial refresh (experimental)
|
||||
#define USE_IMAGE_DOUBLE_FAST_REFRESH 0
|
||||
extern void enterDeepSleep();
|
||||
|
||||
namespace {
|
||||
// pagesPerRefresh now comes from SETTINGS.getRefreshFrequency()
|
||||
@@ -213,6 +212,18 @@ void EpubReaderActivity::loop() {
|
||||
}
|
||||
return; // Don't access 'this' after callback
|
||||
}
|
||||
if (pendingSleep) {
|
||||
pendingSleep = false;
|
||||
exitActivity();
|
||||
enterDeepSleep();
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (pendingSleep) {
|
||||
pendingSleep = false;
|
||||
enterDeepSleep();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -225,6 +236,52 @@ void EpubReaderActivity::loop() {
|
||||
return; // Don't access 'this' after callback
|
||||
}
|
||||
|
||||
// Deferred end-of-book menu (set in render() to avoid deadlock)
|
||||
if (pendingEndOfBookMenu) {
|
||||
pendingEndOfBookMenu = false;
|
||||
endOfBookMenuOpened = true;
|
||||
const std::string path = epub->getPath();
|
||||
enterNewActivity(new EndOfBookMenuActivity(
|
||||
renderer, mappedInput, path, [this](EndOfBookMenuActivity::Action action) {
|
||||
exitActivity();
|
||||
switch (action) {
|
||||
case EndOfBookMenuActivity::Action::ARCHIVE:
|
||||
if (epub) BookManager::archiveBook(epub->getPath());
|
||||
pendingGoHome = true;
|
||||
break;
|
||||
case EndOfBookMenuActivity::Action::DELETE:
|
||||
if (epub) BookManager::deleteBook(epub->getPath());
|
||||
pendingGoHome = true;
|
||||
break;
|
||||
case EndOfBookMenuActivity::Action::TABLE_OF_CONTENTS:
|
||||
endOfBookMenuOpened = false;
|
||||
currentSpineIndex = epub->getSpineItemsCount() - 1;
|
||||
nextPageNumber = UINT16_MAX;
|
||||
section.reset();
|
||||
openChapterSelection();
|
||||
break;
|
||||
case EndOfBookMenuActivity::Action::BACK_TO_BEGINNING:
|
||||
currentSpineIndex = 0;
|
||||
nextPageNumber = 0;
|
||||
section.reset();
|
||||
endOfBookMenuOpened = false;
|
||||
requestUpdate();
|
||||
break;
|
||||
case EndOfBookMenuActivity::Action::CLOSE_BOOK:
|
||||
pendingGoHome = true;
|
||||
break;
|
||||
case EndOfBookMenuActivity::Action::CLOSE_MENU:
|
||||
currentSpineIndex = epub->getSpineItemsCount() - 1;
|
||||
nextPageNumber = UINT16_MAX;
|
||||
section.reset();
|
||||
endOfBookMenuOpened = false;
|
||||
requestUpdate();
|
||||
break;
|
||||
}
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip button processing after returning from subactivity
|
||||
// This prevents stale button release events from triggering actions
|
||||
// We wait until: (1) all relevant buttons are released, AND (2) wasReleased events have been cleared
|
||||
@@ -269,7 +326,7 @@ void EpubReaderActivity::loop() {
|
||||
exitActivity();
|
||||
enterNewActivity(new EpubReaderMenuActivity(
|
||||
this->renderer, this->mappedInput, epub->getTitle(), currentPage, totalPages, bookProgressPercent,
|
||||
SETTINGS.orientation, SETTINGS.fontSize, hasDictionary, isBookmarked, epub->getCachePath(),
|
||||
SETTINGS.orientation, SETTINGS.fontSize, hasDictionary, isBookmarked, epub->getCachePath(), epub->getPath(),
|
||||
[this](const uint8_t orientation, const uint8_t fontSize) { onReaderMenuBack(orientation, fontSize); },
|
||||
[this](EpubReaderMenuActivity::MenuAction action) { onReaderMenuConfirm(action); }));
|
||||
}
|
||||
@@ -304,10 +361,11 @@ void EpubReaderActivity::loop() {
|
||||
return;
|
||||
}
|
||||
|
||||
// any botton press when at end of the book goes back to the last page
|
||||
// any button press when at end of the book goes back to the last page
|
||||
if (currentSpineIndex > 0 && currentSpineIndex >= epub->getSpineItemsCount()) {
|
||||
currentSpineIndex = epub->getSpineItemsCount() - 1;
|
||||
nextPageNumber = UINT16_MAX;
|
||||
endOfBookMenuOpened = false;
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
@@ -717,6 +775,36 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
|
||||
pendingGoHome = true;
|
||||
break;
|
||||
}
|
||||
case EpubReaderMenuActivity::MenuAction::ARCHIVE_BOOK: {
|
||||
if (epub) {
|
||||
BookManager::archiveBook(epub->getPath());
|
||||
}
|
||||
pendingGoHome = true;
|
||||
break;
|
||||
}
|
||||
case EpubReaderMenuActivity::MenuAction::DELETE_BOOK: {
|
||||
if (epub) {
|
||||
BookManager::deleteBook(epub->getPath());
|
||||
}
|
||||
pendingGoHome = true;
|
||||
break;
|
||||
}
|
||||
case EpubReaderMenuActivity::MenuAction::MANAGE_BOOK:
|
||||
break;
|
||||
case EpubReaderMenuActivity::MenuAction::REINDEX_BOOK: {
|
||||
if (epub) {
|
||||
BookManager::reindexBook(epub->getPath(), false);
|
||||
}
|
||||
pendingGoHome = true;
|
||||
break;
|
||||
}
|
||||
case EpubReaderMenuActivity::MenuAction::REINDEX_BOOK_FULL: {
|
||||
if (epub) {
|
||||
BookManager::reindexBook(epub->getPath(), true);
|
||||
}
|
||||
pendingGoHome = true;
|
||||
break;
|
||||
}
|
||||
case EpubReaderMenuActivity::MenuAction::SYNC: {
|
||||
if (KOREADER_STORE.hasCredentials()) {
|
||||
const int currentPage = section ? section->currentPage : 0;
|
||||
@@ -740,6 +828,28 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
|
||||
}
|
||||
break;
|
||||
}
|
||||
case EpubReaderMenuActivity::MenuAction::PUSH_AND_SLEEP: {
|
||||
if (KOREADER_STORE.hasCredentials()) {
|
||||
const int cp = section ? section->currentPage : 0;
|
||||
const int tp = section ? section->pageCount : 0;
|
||||
exitActivity();
|
||||
enterNewActivity(new KOReaderSyncActivity(
|
||||
renderer, mappedInput, epub, epub->getPath(), currentSpineIndex, cp, tp,
|
||||
[this]() {
|
||||
// Push failed -- sleep anyway (silent failure)
|
||||
pendingSleep = true;
|
||||
},
|
||||
[this](int, int) {
|
||||
// Push succeeded -- sleep
|
||||
pendingSleep = true;
|
||||
},
|
||||
KOReaderSyncActivity::SyncMode::PUSH_ONLY));
|
||||
} else {
|
||||
// No credentials -- just sleep
|
||||
pendingSleep = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
// Handled locally in the menu activity (cycle on Confirm, never dispatched here)
|
||||
case EpubReaderMenuActivity::MenuAction::ROTATE_SCREEN:
|
||||
case EpubReaderMenuActivity::MenuAction::TOGGLE_FONT_SIZE:
|
||||
@@ -812,11 +922,11 @@ void EpubReaderActivity::render(Activity::RenderLock&& lock) {
|
||||
currentSpineIndex = epub->getSpineItemsCount();
|
||||
}
|
||||
|
||||
// Show end of book screen
|
||||
// End of book — defer menu creation to loop() to avoid deadlock (render holds the lock)
|
||||
if (currentSpineIndex == epub->getSpineItemsCount()) {
|
||||
renderer.clearScreen();
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, 300, tr(STR_END_OF_BOOK), true, EpdFontFamily::BOLD);
|
||||
renderer.displayBuffer();
|
||||
if (!endOfBookMenuOpened) {
|
||||
pendingEndOfBookMenu = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1022,13 +1132,16 @@ void EpubReaderActivity::saveProgress(int spineIndex, int currentPage, int pageC
|
||||
void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int orientedMarginTop,
|
||||
const int orientedMarginRight, const int orientedMarginBottom,
|
||||
const int orientedMarginLeft) {
|
||||
// Determine if this page needs special image handling
|
||||
bool pageHasImages = page->hasImages();
|
||||
bool useAntiAliasing = SETTINGS.textAntiAliasing;
|
||||
// Force special handling for pages with images when anti-aliasing is on
|
||||
bool imagePageWithAA = page->hasImages() && SETTINGS.textAntiAliasing;
|
||||
|
||||
// Force half refresh for pages with images when anti-aliasing is on,
|
||||
// as grayscale tones require half refresh to display correctly
|
||||
bool forceFullRefresh = pageHasImages && useAntiAliasing;
|
||||
if (page->countUncachedImages() > 0) {
|
||||
page->renderTextOnly(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
|
||||
page->renderImagePlaceholders(renderer, orientedMarginLeft, orientedMarginTop);
|
||||
renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
|
||||
renderer.displayBuffer();
|
||||
renderer.clearScreen();
|
||||
}
|
||||
|
||||
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
|
||||
|
||||
@@ -1048,42 +1161,26 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
|
||||
}
|
||||
|
||||
renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
|
||||
|
||||
// Check if half-refresh is needed (either entering Reader or pages counter reached)
|
||||
if (pagesUntilFullRefresh <= 1) {
|
||||
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
||||
pagesUntilFullRefresh = SETTINGS.getRefreshFrequency();
|
||||
} else if (forceFullRefresh) {
|
||||
// OPTIMIZATION: For image pages with anti-aliasing, use fast double-refresh technique
|
||||
// to reduce perceived lag. Only when pagesUntilFullRefresh > 1 (screen already clean).
|
||||
if (imagePageWithAA) {
|
||||
// Double FAST_REFRESH with selective image blanking (pablohc's technique):
|
||||
// HALF_REFRESH sets particles too firmly for the grayscale LUT to adjust.
|
||||
// Instead, blank only the image area and do two fast refreshes.
|
||||
int imgX, imgY, imgW, imgH;
|
||||
if (page->getImageBoundingBox(imgX, imgY, imgW, imgH)) {
|
||||
int screenX = imgX + orientedMarginLeft;
|
||||
int screenY = imgY + orientedMarginTop;
|
||||
LOG_DBG("ERS", "Image page: fast double-refresh (page bbox: %d,%d %dx%d, screen: %d,%d %dx%d)", imgX, imgY, imgW,
|
||||
imgH, screenX, screenY, imgW, imgH);
|
||||
renderer.fillRect(imgX + orientedMarginLeft, imgY + orientedMarginTop, imgW, imgH, false);
|
||||
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||
|
||||
#if USE_IMAGE_DOUBLE_FAST_REFRESH == 0
|
||||
// Method A: Fill blank area + two FAST_REFRESH operations
|
||||
renderer.fillRect(screenX, screenY, imgW, imgH, false);
|
||||
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
|
||||
renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
|
||||
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||
#else
|
||||
// Method B (experimental): Use displayWindow() for partial refresh
|
||||
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
|
||||
renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
|
||||
renderer.displayWindow(screenX, screenY, imgW, imgH, HalDisplay::FAST_REFRESH);
|
||||
#endif
|
||||
} else {
|
||||
LOG_DBG("ERS", "Image page but no bbox, using standard half refresh");
|
||||
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
||||
}
|
||||
pagesUntilFullRefresh--;
|
||||
// Double FAST_REFRESH handles ghosting for image pages; don't count toward full refresh cadence
|
||||
} else if (pagesUntilFullRefresh <= 1) {
|
||||
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
||||
pagesUntilFullRefresh = SETTINGS.getRefreshFrequency();
|
||||
} else {
|
||||
// Normal page without images, or images without anti-aliasing
|
||||
renderer.displayBuffer();
|
||||
pagesUntilFullRefresh--;
|
||||
}
|
||||
|
||||
@@ -22,11 +22,14 @@ class EpubReaderActivity final : public ActivityWithSubactivity {
|
||||
float pendingSpineProgress = 0.0f;
|
||||
bool pendingSubactivityExit = false; // Defer subactivity exit to avoid use-after-free
|
||||
bool pendingGoHome = false; // Defer go home to avoid race condition with display task
|
||||
bool pendingSleep = false; // Defer deep sleep until after push-and-sleep completes
|
||||
bool skipNextButtonCheck = false; // Skip button processing for one frame after subactivity exit
|
||||
bool ignoreNextConfirmRelease = false; // Suppress short-press after long-press Confirm
|
||||
volatile bool loadingSection = false; // True during the entire !section block (read from main loop)
|
||||
bool silentIndexingActive = false; // True while silently pre-indexing the next chapter
|
||||
int preIndexedNextSpine = -1; // Spine index already pre-indexed (prevents re-render loop)
|
||||
bool endOfBookMenuOpened = false; // Guard to prevent repeated opening of EndOfBookMenuActivity
|
||||
bool pendingEndOfBookMenu = false; // Deferred: open EndOfBookMenuActivity from loop(), not render()
|
||||
const std::function<void()> onGoBack;
|
||||
const std::function<void()> onGoHome;
|
||||
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
#include <GfxRenderer.h>
|
||||
#include <I18n.h>
|
||||
|
||||
#include "../home/BookManageMenuActivity.h"
|
||||
#include "CrossPointSettings.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
#include "util/BookManager.h"
|
||||
|
||||
void EpubReaderMenuActivity::onEnter() {
|
||||
ActivityWithSubactivity::onEnter();
|
||||
@@ -116,6 +118,42 @@ void EpubReaderMenuActivity::loop() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedAction == MenuAction::MANAGE_BOOK) {
|
||||
const bool isArchived = BookManager::isArchived(bookFilePath);
|
||||
enterNewActivity(new BookManageMenuActivity(
|
||||
renderer, mappedInput, bookFilePath, isArchived,
|
||||
[this](BookManageMenuActivity::Action action) {
|
||||
exitActivity();
|
||||
auto cb = onAction;
|
||||
switch (action) {
|
||||
case BookManageMenuActivity::Action::ARCHIVE:
|
||||
cb(MenuAction::ARCHIVE_BOOK);
|
||||
break;
|
||||
case BookManageMenuActivity::Action::DELETE:
|
||||
cb(MenuAction::DELETE_BOOK);
|
||||
break;
|
||||
case BookManageMenuActivity::Action::DELETE_CACHE:
|
||||
cb(MenuAction::DELETE_CACHE);
|
||||
break;
|
||||
case BookManageMenuActivity::Action::REINDEX:
|
||||
cb(MenuAction::REINDEX_BOOK);
|
||||
break;
|
||||
case BookManageMenuActivity::Action::REINDEX_FULL:
|
||||
cb(MenuAction::REINDEX_BOOK_FULL);
|
||||
break;
|
||||
case BookManageMenuActivity::Action::UNARCHIVE:
|
||||
// Unarchive from within reader is unusual but handle gracefully
|
||||
cb(MenuAction::GO_HOME);
|
||||
break;
|
||||
}
|
||||
},
|
||||
[this] {
|
||||
exitActivity();
|
||||
requestUpdate();
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Capture the callback and action locally
|
||||
auto actionCallback = onAction;
|
||||
|
||||
|
||||
@@ -27,13 +27,20 @@ class EpubReaderMenuActivity final : public ActivityWithSubactivity {
|
||||
GO_TO_PERCENT,
|
||||
GO_HOME,
|
||||
SYNC,
|
||||
PUSH_AND_SLEEP,
|
||||
DELETE_CACHE,
|
||||
MANAGE_BOOK,
|
||||
ARCHIVE_BOOK,
|
||||
DELETE_BOOK,
|
||||
REINDEX_BOOK,
|
||||
REINDEX_BOOK_FULL,
|
||||
};
|
||||
|
||||
explicit EpubReaderMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& title,
|
||||
const int currentPage, const int totalPages, const int bookProgressPercent,
|
||||
const uint8_t currentOrientation, const uint8_t currentFontSize,
|
||||
const bool hasDictionary, const bool isBookmarked, const std::string& bookCachePath,
|
||||
const std::string& bookFilePath,
|
||||
const std::function<void(uint8_t, uint8_t)>& onBack,
|
||||
const std::function<void(MenuAction)>& onAction)
|
||||
: ActivityWithSubactivity("EpubReaderMenu", renderer, mappedInput),
|
||||
@@ -42,6 +49,7 @@ class EpubReaderMenuActivity final : public ActivityWithSubactivity {
|
||||
pendingOrientation(currentOrientation),
|
||||
pendingFontSize(currentFontSize),
|
||||
bookCachePath(bookCachePath),
|
||||
bookFilePath(bookFilePath),
|
||||
currentPage(currentPage),
|
||||
totalPages(totalPages),
|
||||
bookProgressPercent(bookProgressPercent),
|
||||
@@ -75,6 +83,7 @@ class EpubReaderMenuActivity final : public ActivityWithSubactivity {
|
||||
StrId::STR_LANDSCAPE_CCW};
|
||||
const std::vector<StrId> fontSizeLabels = {StrId::STR_SMALL, StrId::STR_MEDIUM, StrId::STR_LARGE, StrId::STR_X_LARGE};
|
||||
std::string bookCachePath;
|
||||
std::string bookFilePath;
|
||||
// Letterbox fill override: 0xFF = Default (use global), 0 = Dithered, 1 = Solid, 2 = None
|
||||
uint8_t pendingLetterboxFill = BookSettings::USE_GLOBAL;
|
||||
static constexpr int LETTERBOX_FILL_OPTION_COUNT = 4; // Default + 3 modes
|
||||
@@ -132,7 +141,8 @@ class EpubReaderMenuActivity final : public ActivityWithSubactivity {
|
||||
items.push_back({MenuAction::GO_TO_PERCENT, StrId::STR_GO_TO_PERCENT});
|
||||
items.push_back({MenuAction::GO_HOME, StrId::STR_CLOSE_BOOK});
|
||||
items.push_back({MenuAction::SYNC, StrId::STR_SYNC_PROGRESS});
|
||||
items.push_back({MenuAction::DELETE_CACHE, StrId::STR_DELETE_CACHE});
|
||||
items.push_back({MenuAction::PUSH_AND_SLEEP, StrId::STR_PUSH_AND_SLEEP});
|
||||
items.push_back({MenuAction::MANAGE_BOOK, StrId::STR_MANAGE_BOOK});
|
||||
return items;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
#include "activities/network/WifiSelectionActivity.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
#include "util/BootNtpSync.h"
|
||||
#include "util/TimeSync.h"
|
||||
|
||||
void KOReaderSyncActivity::onWifiSelectionComplete(const bool success) {
|
||||
@@ -43,6 +44,12 @@ void KOReaderSyncActivity::onWifiSelectionComplete(const bool success) {
|
||||
performSync();
|
||||
}
|
||||
|
||||
void KOReaderSyncActivity::deferFinish(bool success) {
|
||||
RenderLock lock(*this);
|
||||
pendingFinishSuccess = success;
|
||||
pendingFinish = true;
|
||||
}
|
||||
|
||||
void KOReaderSyncActivity::performSync() {
|
||||
// Calculate document hash based on user's preferred method
|
||||
if (KOREADER_STORE.getMatchMethod() == DocumentMatchMethod::FILENAME) {
|
||||
@@ -51,6 +58,10 @@ void KOReaderSyncActivity::performSync() {
|
||||
documentHash = KOReaderDocumentId::calculate(epubPath);
|
||||
}
|
||||
if (documentHash.empty()) {
|
||||
if (syncMode == SyncMode::PUSH_ONLY) {
|
||||
deferFinish(false);
|
||||
return;
|
||||
}
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
state = SYNC_FAILED;
|
||||
@@ -62,6 +73,11 @@ void KOReaderSyncActivity::performSync() {
|
||||
|
||||
LOG_DBG("KOSync", "Document hash: %s", documentHash.c_str());
|
||||
|
||||
if (syncMode == SyncMode::PUSH_ONLY) {
|
||||
performUpload();
|
||||
return;
|
||||
}
|
||||
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
statusMessage = tr(STR_FETCH_PROGRESS);
|
||||
@@ -136,6 +152,10 @@ void KOReaderSyncActivity::performUpload() {
|
||||
const auto result = KOReaderSyncClient::updateProgress(progress);
|
||||
|
||||
if (result != KOReaderSyncClient::OK) {
|
||||
if (syncMode == SyncMode::PUSH_ONLY) {
|
||||
deferFinish(false);
|
||||
return;
|
||||
}
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
state = SYNC_FAILED;
|
||||
@@ -145,6 +165,11 @@ void KOReaderSyncActivity::performUpload() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (syncMode == SyncMode::PUSH_ONLY) {
|
||||
deferFinish(true);
|
||||
return;
|
||||
}
|
||||
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
state = UPLOAD_COMPLETE;
|
||||
@@ -155,6 +180,8 @@ void KOReaderSyncActivity::performUpload() {
|
||||
void KOReaderSyncActivity::onEnter() {
|
||||
ActivityWithSubactivity::onEnter();
|
||||
|
||||
BootNtpSync::cancel();
|
||||
|
||||
// Check for credentials first
|
||||
if (!KOREADER_STORE.hasCredentials()) {
|
||||
state = NO_CREDENTIALS;
|
||||
@@ -331,6 +358,27 @@ void KOReaderSyncActivity::loop() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (syncMode == SyncMode::PUSH_ONLY) {
|
||||
bool ready = false;
|
||||
bool success = false;
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
if (pendingFinish) {
|
||||
pendingFinish = false;
|
||||
ready = true;
|
||||
success = pendingFinishSuccess;
|
||||
}
|
||||
}
|
||||
if (ready) {
|
||||
if (success) {
|
||||
onSyncComplete(currentSpineIndex, currentPage);
|
||||
} else {
|
||||
onCancel();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (state == NO_CREDENTIALS || state == SYNC_FAILED || state == UPLOAD_COMPLETE) {
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
|
||||
onCancel();
|
||||
|
||||
@@ -15,18 +15,21 @@
|
||||
* 1. Connect to WiFi (if not connected)
|
||||
* 2. Calculate document hash
|
||||
* 3. Fetch remote progress
|
||||
* 4. Show comparison and options (Apply/Upload)
|
||||
* 4. Show comparison and options (Apply/Upload) or skip when SyncMode::PUSH_ONLY
|
||||
* 5. Apply or upload progress
|
||||
*/
|
||||
class KOReaderSyncActivity final : public ActivityWithSubactivity {
|
||||
public:
|
||||
enum class SyncMode { INTERACTIVE, PUSH_ONLY };
|
||||
|
||||
using OnCancelCallback = std::function<void()>;
|
||||
using OnSyncCompleteCallback = std::function<void(int newSpineIndex, int newPageNumber)>;
|
||||
|
||||
explicit KOReaderSyncActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
||||
const std::shared_ptr<Epub>& epub, const std::string& epubPath, int currentSpineIndex,
|
||||
int currentPage, int totalPagesInSpine, OnCancelCallback onCancel,
|
||||
OnSyncCompleteCallback onSyncComplete)
|
||||
OnSyncCompleteCallback onSyncComplete,
|
||||
SyncMode syncMode = SyncMode::INTERACTIVE)
|
||||
: ActivityWithSubactivity("KOReaderSync", renderer, mappedInput),
|
||||
epub(epub),
|
||||
epubPath(epubPath),
|
||||
@@ -37,7 +40,8 @@ class KOReaderSyncActivity final : public ActivityWithSubactivity {
|
||||
remotePosition{},
|
||||
localProgress{},
|
||||
onCancel(std::move(onCancel)),
|
||||
onSyncComplete(std::move(onSyncComplete)) {}
|
||||
onSyncComplete(std::move(onSyncComplete)),
|
||||
syncMode(syncMode) {}
|
||||
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
@@ -82,6 +86,11 @@ class KOReaderSyncActivity final : public ActivityWithSubactivity {
|
||||
OnCancelCallback onCancel;
|
||||
OnSyncCompleteCallback onSyncComplete;
|
||||
|
||||
SyncMode syncMode;
|
||||
bool pendingFinish = false;
|
||||
bool pendingFinishSuccess = false;
|
||||
|
||||
void deferFinish(bool success);
|
||||
void onWifiSelectionComplete(bool success);
|
||||
void performSync();
|
||||
void performUpload();
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#include "TxtReaderActivity.h"
|
||||
#include "Xtc.h"
|
||||
#include "XtcReaderActivity.h"
|
||||
#include "activities/util/BmpViewerActivity.h"
|
||||
#include "activities/util/FullScreenMessageActivity.h"
|
||||
#include "util/StringUtils.h"
|
||||
|
||||
@@ -29,6 +30,8 @@ bool ReaderActivity::isTxtFile(const std::string& path) {
|
||||
StringUtils::checkFileExtension(path, ".md"); // Treat .md as txt files (until we have a markdown reader)
|
||||
}
|
||||
|
||||
bool ReaderActivity::isBmpFile(const std::string& path) { return StringUtils::checkFileExtension(path, ".bmp"); }
|
||||
|
||||
std::unique_ptr<Epub> ReaderActivity::loadEpub(const std::string& path) {
|
||||
if (!Storage.exists(path.c_str())) {
|
||||
LOG_ERR("READER", "File does not exist: %s", path.c_str());
|
||||
@@ -104,6 +107,12 @@ void ReaderActivity::onGoToTxtReader(std::unique_ptr<Txt> txt) {
|
||||
renderer, mappedInput, std::move(txt), [this, txtPath] { goToLibrary(txtPath); }, [this] { onGoBack(); }));
|
||||
}
|
||||
|
||||
void ReaderActivity::onGoToBmpViewer(const std::string& path) {
|
||||
currentBookPath = path;
|
||||
exitActivity();
|
||||
enterNewActivity(new BmpViewerActivity(renderer, mappedInput, path, [this, path] { goToLibrary(path); }));
|
||||
}
|
||||
|
||||
void ReaderActivity::onEnter() {
|
||||
ActivityWithSubactivity::onEnter();
|
||||
|
||||
@@ -114,6 +123,11 @@ void ReaderActivity::onEnter() {
|
||||
|
||||
currentBookPath = initialBookPath;
|
||||
|
||||
if (isBmpFile(initialBookPath)) {
|
||||
onGoToBmpViewer(initialBookPath);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isXtcFile(initialBookPath)) {
|
||||
auto xtc = loadXtc(initialBookPath);
|
||||
if (!xtc) {
|
||||
|
||||
@@ -18,12 +18,14 @@ class ReaderActivity final : public ActivityWithSubactivity {
|
||||
static std::unique_ptr<Txt> loadTxt(const std::string& path);
|
||||
static bool isXtcFile(const std::string& path);
|
||||
static bool isTxtFile(const std::string& path);
|
||||
static bool isBmpFile(const std::string& path);
|
||||
|
||||
static std::string extractFolderPath(const std::string& filePath);
|
||||
void goToLibrary(const std::string& fromBookPath = "");
|
||||
void onGoToEpubReader(std::unique_ptr<Epub> epub);
|
||||
void onGoToXtcReader(std::unique_ptr<Xtc> xtc);
|
||||
void onGoToTxtReader(std::unique_ptr<Txt> txt);
|
||||
void onGoToBmpViewer(const std::string& path);
|
||||
|
||||
public:
|
||||
explicit ReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string initialBookPath,
|
||||
|
||||
@@ -9,10 +9,12 @@
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "CrossPointState.h"
|
||||
#include "EndOfBookMenuActivity.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "RecentBooksStore.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
#include "util/BookManager.h"
|
||||
|
||||
namespace {
|
||||
constexpr unsigned long goHomeMs = 1000;
|
||||
@@ -153,10 +155,45 @@ void TxtReaderActivity::loop() {
|
||||
|
||||
if (prevTriggered && currentPage > 0) {
|
||||
currentPage--;
|
||||
endOfBookMenuOpened = false;
|
||||
requestUpdate();
|
||||
} else if (nextTriggered && currentPage < totalPages - 1) {
|
||||
currentPage++;
|
||||
requestUpdate();
|
||||
} else if (nextTriggered && currentPage == totalPages - 1 && !endOfBookMenuOpened) {
|
||||
// At last page and trying to advance → show end of book menu
|
||||
endOfBookMenuOpened = true;
|
||||
const std::string path = txt->getPath();
|
||||
enterNewActivity(new EndOfBookMenuActivity(
|
||||
renderer, mappedInput, path, [this](EndOfBookMenuActivity::Action action) {
|
||||
exitActivity();
|
||||
switch (action) {
|
||||
case EndOfBookMenuActivity::Action::ARCHIVE:
|
||||
if (txt) BookManager::archiveBook(txt->getPath());
|
||||
if (onGoHome) onGoHome();
|
||||
return;
|
||||
case EndOfBookMenuActivity::Action::DELETE:
|
||||
if (txt) BookManager::deleteBook(txt->getPath());
|
||||
if (onGoHome) onGoHome();
|
||||
return;
|
||||
case EndOfBookMenuActivity::Action::TABLE_OF_CONTENTS:
|
||||
endOfBookMenuOpened = false;
|
||||
requestUpdate();
|
||||
break;
|
||||
case EndOfBookMenuActivity::Action::BACK_TO_BEGINNING:
|
||||
currentPage = 0;
|
||||
endOfBookMenuOpened = false;
|
||||
requestUpdate();
|
||||
break;
|
||||
case EndOfBookMenuActivity::Action::CLOSE_BOOK:
|
||||
if (onGoHome) onGoHome();
|
||||
return;
|
||||
case EndOfBookMenuActivity::Action::CLOSE_MENU:
|
||||
endOfBookMenuOpened = false;
|
||||
requestUpdate();
|
||||
break;
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ class TxtReaderActivity final : public ActivityWithSubactivity {
|
||||
int totalPages = 1;
|
||||
int pagesUntilFullRefresh = 0;
|
||||
|
||||
bool endOfBookMenuOpened = false;
|
||||
const std::function<void()> onGoBack;
|
||||
const std::function<void()> onGoHome;
|
||||
|
||||
|
||||
@@ -15,11 +15,13 @@
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "CrossPointState.h"
|
||||
#include "EndOfBookMenuActivity.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "RecentBooksStore.h"
|
||||
#include "XtcReaderChapterSelectionActivity.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
#include "util/BookManager.h"
|
||||
|
||||
namespace {
|
||||
constexpr unsigned long skipPageMs = 700;
|
||||
@@ -104,6 +106,60 @@ void XtcReaderActivity::loop() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Deferred end-of-book menu (set in render() to avoid deadlock)
|
||||
if (pendingEndOfBookMenu) {
|
||||
pendingEndOfBookMenu = false;
|
||||
endOfBookMenuOpened = true;
|
||||
const std::string path = xtc->getPath();
|
||||
enterNewActivity(new EndOfBookMenuActivity(
|
||||
renderer, mappedInput, path, [this](EndOfBookMenuActivity::Action action) {
|
||||
exitActivity();
|
||||
switch (action) {
|
||||
case EndOfBookMenuActivity::Action::ARCHIVE:
|
||||
if (xtc) BookManager::archiveBook(xtc->getPath());
|
||||
if (onGoHome) onGoHome();
|
||||
break;
|
||||
case EndOfBookMenuActivity::Action::DELETE:
|
||||
if (xtc) BookManager::deleteBook(xtc->getPath());
|
||||
if (onGoHome) onGoHome();
|
||||
break;
|
||||
case EndOfBookMenuActivity::Action::TABLE_OF_CONTENTS:
|
||||
endOfBookMenuOpened = false;
|
||||
currentPage = xtc->getPageCount() - 1;
|
||||
if (xtc && xtc->hasChapters() && !xtc->getChapters().empty()) {
|
||||
enterNewActivity(new XtcReaderChapterSelectionActivity(
|
||||
renderer, mappedInput, xtc, currentPage,
|
||||
[this] {
|
||||
exitActivity();
|
||||
requestUpdate();
|
||||
},
|
||||
[this](const uint32_t newPage) {
|
||||
currentPage = newPage;
|
||||
exitActivity();
|
||||
requestUpdate();
|
||||
}));
|
||||
} else {
|
||||
requestUpdate();
|
||||
}
|
||||
break;
|
||||
case EndOfBookMenuActivity::Action::BACK_TO_BEGINNING:
|
||||
currentPage = 0;
|
||||
endOfBookMenuOpened = false;
|
||||
requestUpdate();
|
||||
break;
|
||||
case EndOfBookMenuActivity::Action::CLOSE_BOOK:
|
||||
if (onGoHome) onGoHome();
|
||||
break;
|
||||
case EndOfBookMenuActivity::Action::CLOSE_MENU:
|
||||
currentPage = xtc->getPageCount() - 1;
|
||||
endOfBookMenuOpened = false;
|
||||
requestUpdate();
|
||||
break;
|
||||
}
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// Enter chapter selection activity
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
if (xtc && xtc->hasChapters() && !xtc->getChapters().empty()) {
|
||||
@@ -155,6 +211,7 @@ void XtcReaderActivity::loop() {
|
||||
// Handle end of book
|
||||
if (currentPage >= xtc->getPageCount()) {
|
||||
currentPage = xtc->getPageCount() - 1;
|
||||
endOfBookMenuOpened = false;
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
@@ -183,12 +240,11 @@ void XtcReaderActivity::render(Activity::RenderLock&&) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Bounds check
|
||||
// End of book — defer menu creation to loop() to avoid deadlock (render holds the lock)
|
||||
if (currentPage >= xtc->getPageCount()) {
|
||||
// Show end of book screen
|
||||
renderer.clearScreen();
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, 300, tr(STR_END_OF_BOOK), true, EpdFontFamily::BOLD);
|
||||
renderer.displayBuffer();
|
||||
if (!endOfBookMenuOpened) {
|
||||
pendingEndOfBookMenu = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ class XtcReaderActivity final : public ActivityWithSubactivity {
|
||||
uint32_t currentPage = 0;
|
||||
int pagesUntilFullRefresh = 0;
|
||||
|
||||
bool endOfBookMenuOpened = false;
|
||||
bool pendingEndOfBookMenu = false;
|
||||
const std::function<void()> onGoBack;
|
||||
const std::function<void()> onGoHome;
|
||||
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
#include "CalibreSettingsActivity.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
#include <I18n.h>
|
||||
|
||||
#include <cstring>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "activities/util/KeyboardEntryActivity.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
namespace {
|
||||
constexpr int MENU_ITEMS = 3;
|
||||
const StrId menuNames[MENU_ITEMS] = {StrId::STR_CALIBRE_WEB_URL, StrId::STR_USERNAME, StrId::STR_PASSWORD};
|
||||
} // namespace
|
||||
|
||||
void CalibreSettingsActivity::onEnter() {
|
||||
ActivityWithSubactivity::onEnter();
|
||||
|
||||
selectedIndex = 0;
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
void CalibreSettingsActivity::onExit() { ActivityWithSubactivity::onExit(); }
|
||||
|
||||
void CalibreSettingsActivity::loop() {
|
||||
if (subActivity) {
|
||||
subActivity->loop();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
|
||||
onBack();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
|
||||
handleSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle navigation
|
||||
buttonNavigator.onNext([this] {
|
||||
selectedIndex = (selectedIndex + 1) % MENU_ITEMS;
|
||||
requestUpdate();
|
||||
});
|
||||
|
||||
buttonNavigator.onPrevious([this] {
|
||||
selectedIndex = (selectedIndex + MENU_ITEMS - 1) % MENU_ITEMS;
|
||||
requestUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
void CalibreSettingsActivity::handleSelection() {
|
||||
if (selectedIndex == 0) {
|
||||
// OPDS Server URL
|
||||
exitActivity();
|
||||
enterNewActivity(new KeyboardEntryActivity(
|
||||
renderer, mappedInput, tr(STR_CALIBRE_WEB_URL), SETTINGS.opdsServerUrl,
|
||||
127, // maxLength
|
||||
false, // not password
|
||||
[this](const std::string& url) {
|
||||
strncpy(SETTINGS.opdsServerUrl, url.c_str(), sizeof(SETTINGS.opdsServerUrl) - 1);
|
||||
SETTINGS.opdsServerUrl[sizeof(SETTINGS.opdsServerUrl) - 1] = '\0';
|
||||
SETTINGS.saveToFile();
|
||||
exitActivity();
|
||||
requestUpdate();
|
||||
},
|
||||
[this]() {
|
||||
exitActivity();
|
||||
requestUpdate();
|
||||
}));
|
||||
} else if (selectedIndex == 1) {
|
||||
// Username
|
||||
exitActivity();
|
||||
enterNewActivity(new KeyboardEntryActivity(
|
||||
renderer, mappedInput, tr(STR_USERNAME), SETTINGS.opdsUsername,
|
||||
63, // maxLength
|
||||
false, // not password
|
||||
[this](const std::string& username) {
|
||||
strncpy(SETTINGS.opdsUsername, username.c_str(), sizeof(SETTINGS.opdsUsername) - 1);
|
||||
SETTINGS.opdsUsername[sizeof(SETTINGS.opdsUsername) - 1] = '\0';
|
||||
SETTINGS.saveToFile();
|
||||
exitActivity();
|
||||
requestUpdate();
|
||||
},
|
||||
[this]() {
|
||||
exitActivity();
|
||||
requestUpdate();
|
||||
}));
|
||||
} else if (selectedIndex == 2) {
|
||||
// Password
|
||||
exitActivity();
|
||||
enterNewActivity(new KeyboardEntryActivity(
|
||||
renderer, mappedInput, tr(STR_PASSWORD), SETTINGS.opdsPassword,
|
||||
63, // maxLength
|
||||
false, // not password mode
|
||||
[this](const std::string& password) {
|
||||
strncpy(SETTINGS.opdsPassword, password.c_str(), sizeof(SETTINGS.opdsPassword) - 1);
|
||||
SETTINGS.opdsPassword[sizeof(SETTINGS.opdsPassword) - 1] = '\0';
|
||||
SETTINGS.saveToFile();
|
||||
exitActivity();
|
||||
requestUpdate();
|
||||
},
|
||||
[this]() {
|
||||
exitActivity();
|
||||
requestUpdate();
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
void CalibreSettingsActivity::render(Activity::RenderLock&&) {
|
||||
renderer.clearScreen();
|
||||
|
||||
auto metrics = UITheme::getInstance().getMetrics();
|
||||
const auto pageWidth = renderer.getScreenWidth();
|
||||
const auto pageHeight = renderer.getScreenHeight();
|
||||
GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, tr(STR_OPDS_BROWSER));
|
||||
GUI.drawSubHeader(renderer, Rect{0, metrics.topPadding + metrics.headerHeight, pageWidth, metrics.tabBarHeight},
|
||||
tr(STR_CALIBRE_URL_HINT));
|
||||
|
||||
const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing + metrics.tabBarHeight;
|
||||
const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing * 2;
|
||||
GUI.drawList(
|
||||
renderer, Rect{0, contentTop, pageWidth, contentHeight}, static_cast<int>(MENU_ITEMS),
|
||||
static_cast<int>(selectedIndex), [](int index) { return std::string(I18N.get(menuNames[index])); }, nullptr,
|
||||
nullptr,
|
||||
[this](int index) {
|
||||
// Draw status for each setting
|
||||
if (index == 0) {
|
||||
return (strlen(SETTINGS.opdsServerUrl) > 0) ? std::string(SETTINGS.opdsServerUrl)
|
||||
: std::string(tr(STR_NOT_SET));
|
||||
} else if (index == 1) {
|
||||
return (strlen(SETTINGS.opdsUsername) > 0) ? std::string(SETTINGS.opdsUsername)
|
||||
: std::string(tr(STR_NOT_SET));
|
||||
} else if (index == 2) {
|
||||
return (strlen(SETTINGS.opdsPassword) > 0) ? std::string("******") : std::string(tr(STR_NOT_SET));
|
||||
}
|
||||
return std::string(tr(STR_NOT_SET));
|
||||
},
|
||||
true);
|
||||
|
||||
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SELECT), tr(STR_DIR_UP), tr(STR_DIR_DOWN));
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
|
||||
renderer.displayBuffer();
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <functional>
|
||||
|
||||
#include "activities/ActivityWithSubactivity.h"
|
||||
#include "util/ButtonNavigator.h"
|
||||
|
||||
/**
|
||||
* Submenu for OPDS Browser settings.
|
||||
* Shows OPDS Server URL and HTTP authentication options.
|
||||
*/
|
||||
class CalibreSettingsActivity final : public ActivityWithSubactivity {
|
||||
public:
|
||||
explicit CalibreSettingsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
||||
const std::function<void()>& onBack)
|
||||
: ActivityWithSubactivity("CalibreSettings", renderer, mappedInput), onBack(onBack) {}
|
||||
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
void render(Activity::RenderLock&&) override;
|
||||
|
||||
private:
|
||||
ButtonNavigator buttonNavigator;
|
||||
|
||||
size_t selectedIndex = 0;
|
||||
const std::function<void()> onBack;
|
||||
void handleSelection();
|
||||
};
|
||||
@@ -6,6 +6,7 @@
|
||||
#include <Logging.h>
|
||||
|
||||
#include "MappedInputManager.h"
|
||||
#include "RecentBooksStore.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
@@ -94,8 +95,8 @@ void ClearCacheActivity::clearCache() {
|
||||
file.getName(name, sizeof(name));
|
||||
String itemName(name);
|
||||
|
||||
// Only delete directories starting with epub_ or xtc_
|
||||
if (file.isDirectory() && (itemName.startsWith("epub_") || itemName.startsWith("xtc_"))) {
|
||||
if (file.isDirectory() &&
|
||||
(itemName.startsWith("epub_") || itemName.startsWith("xtc_") || itemName.startsWith("txt_"))) {
|
||||
String fullPath = "/.crosspoint/" + itemName;
|
||||
LOG_DBG("CLEAR_CACHE", "Removing cache: %s", fullPath.c_str());
|
||||
|
||||
@@ -113,6 +114,9 @@ void ClearCacheActivity::clearCache() {
|
||||
}
|
||||
root.close();
|
||||
|
||||
// Clear recents since all cached data (covers, progress) is gone
|
||||
RECENT_BOOKS.clear();
|
||||
|
||||
LOG_DBG("CLEAR_CACHE", "Cache cleared: %d removed, %d failed", clearedCount, failedCount);
|
||||
|
||||
state = SUCCESS;
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
#include "KOReaderSyncClient.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "activities/network/WifiSelectionActivity.h"
|
||||
#include "util/BootNtpSync.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
@@ -53,6 +54,8 @@ void KOReaderAuthActivity::performAuthentication() {
|
||||
void KOReaderAuthActivity::onEnter() {
|
||||
ActivityWithSubactivity::onEnter();
|
||||
|
||||
BootNtpSync::cancel();
|
||||
|
||||
// Turn on WiFi
|
||||
WiFi.mode(WIFI_STA);
|
||||
|
||||
|
||||
152
src/activities/settings/NtpSyncActivity.cpp
Normal file
152
src/activities/settings/NtpSyncActivity.cpp
Normal file
@@ -0,0 +1,152 @@
|
||||
#include "NtpSyncActivity.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
#include <I18n.h>
|
||||
#include <Logging.h>
|
||||
#include <WiFi.h>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "activities/network/WifiSelectionActivity.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
#include "util/BootNtpSync.h"
|
||||
#include "util/TimeSync.h"
|
||||
|
||||
static constexpr unsigned long AUTO_DISMISS_MS = 5000;
|
||||
|
||||
void NtpSyncActivity::onWifiSelectionComplete(const bool success) {
|
||||
exitActivity();
|
||||
|
||||
if (!success) {
|
||||
LOG_ERR("NTP", "WiFi connection failed, exiting");
|
||||
goBack();
|
||||
return;
|
||||
}
|
||||
|
||||
LOG_DBG("NTP", "WiFi connected, starting NTP sync");
|
||||
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
state = SYNCING;
|
||||
}
|
||||
requestUpdateAndWait();
|
||||
|
||||
const bool synced = TimeSync::waitForNtpSync(8000);
|
||||
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
state = synced ? SUCCESS : FAILED;
|
||||
if (synced) {
|
||||
successTimestamp = millis();
|
||||
}
|
||||
}
|
||||
requestUpdate();
|
||||
|
||||
if (synced) {
|
||||
LOG_DBG("NTP", "Time synced successfully");
|
||||
} else {
|
||||
LOG_ERR("NTP", "NTP sync timed out");
|
||||
}
|
||||
}
|
||||
|
||||
void NtpSyncActivity::onEnter() {
|
||||
ActivityWithSubactivity::onEnter();
|
||||
|
||||
BootNtpSync::cancel();
|
||||
LOG_DBG("NTP", "Turning on WiFi...");
|
||||
WiFi.mode(WIFI_STA);
|
||||
|
||||
LOG_DBG("NTP", "Launching WifiSelectionActivity...");
|
||||
enterNewActivity(new WifiSelectionActivity(renderer, mappedInput,
|
||||
[this](const bool connected) { onWifiSelectionComplete(connected); }));
|
||||
}
|
||||
|
||||
void NtpSyncActivity::onExit() {
|
||||
ActivityWithSubactivity::onExit();
|
||||
|
||||
TimeSync::stopNtpSync();
|
||||
WiFi.disconnect(false);
|
||||
delay(100);
|
||||
WiFi.mode(WIFI_OFF);
|
||||
delay(100);
|
||||
}
|
||||
|
||||
void NtpSyncActivity::render(Activity::RenderLock&&) {
|
||||
if (subActivity) {
|
||||
return;
|
||||
}
|
||||
|
||||
auto metrics = UITheme::getInstance().getMetrics();
|
||||
const auto pageWidth = renderer.getScreenWidth();
|
||||
const auto pageHeight = renderer.getScreenHeight();
|
||||
|
||||
renderer.clearScreen();
|
||||
|
||||
GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, tr(STR_SYNC_CLOCK));
|
||||
|
||||
const auto lineHeight = renderer.getLineHeight(UI_10_FONT_ID);
|
||||
const auto centerY = (pageHeight - lineHeight) / 2;
|
||||
|
||||
if (state == SYNCING) {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, centerY, tr(STR_SYNCING_TIME));
|
||||
} else if (state == SUCCESS) {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, centerY, tr(STR_TIME_SYNCED), true, EpdFontFamily::BOLD);
|
||||
|
||||
time_t now = time(nullptr);
|
||||
struct tm* t = localtime(&now);
|
||||
if (t != nullptr && t->tm_year > 100) {
|
||||
char timeBuf[32];
|
||||
if (SETTINGS.clockFormat == CrossPointSettings::CLOCK_24H) {
|
||||
snprintf(timeBuf, sizeof(timeBuf), "%02d:%02d", t->tm_hour, t->tm_min);
|
||||
} else {
|
||||
int hour12 = t->tm_hour % 12;
|
||||
if (hour12 == 0) hour12 = 12;
|
||||
snprintf(timeBuf, sizeof(timeBuf), "%d:%02d %s", hour12, t->tm_min, t->tm_hour >= 12 ? "PM" : "AM");
|
||||
}
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, centerY + lineHeight + metrics.verticalSpacing, timeBuf);
|
||||
}
|
||||
|
||||
const unsigned long elapsed = millis() - successTimestamp;
|
||||
const int remaining = static_cast<int>((AUTO_DISMISS_MS - elapsed + 999) / 1000);
|
||||
char backLabel[32];
|
||||
snprintf(backLabel, sizeof(backLabel), "%s (%d)", tr(STR_BACK), remaining > 0 ? remaining : 1);
|
||||
const auto labels = mappedInput.mapLabels(backLabel, "", "", "");
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
} else if (state == FAILED) {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, centerY, tr(STR_SYNC_FAILED_MSG), true, EpdFontFamily::BOLD);
|
||||
|
||||
const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", "");
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
}
|
||||
|
||||
renderer.displayBuffer();
|
||||
}
|
||||
|
||||
void NtpSyncActivity::loop() {
|
||||
if (subActivity) {
|
||||
subActivity->loop();
|
||||
return;
|
||||
}
|
||||
|
||||
if (state == SUCCESS) {
|
||||
const unsigned long elapsed = millis() - successTimestamp;
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back) || elapsed >= AUTO_DISMISS_MS) {
|
||||
goBack();
|
||||
return;
|
||||
}
|
||||
const int currentSecond = static_cast<int>(elapsed / 1000);
|
||||
if (currentSecond != lastCountdownSecond) {
|
||||
lastCountdownSecond = currentSecond;
|
||||
requestUpdate();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (state == FAILED) {
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
|
||||
goBack();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
24
src/activities/settings/NtpSyncActivity.h
Normal file
24
src/activities/settings/NtpSyncActivity.h
Normal file
@@ -0,0 +1,24 @@
|
||||
#pragma once
|
||||
|
||||
#include "activities/ActivityWithSubactivity.h"
|
||||
|
||||
class NtpSyncActivity : public ActivityWithSubactivity {
|
||||
enum State { WIFI_SELECTION, SYNCING, SUCCESS, FAILED };
|
||||
|
||||
const std::function<void()> goBack;
|
||||
State state = WIFI_SELECTION;
|
||||
unsigned long successTimestamp = 0;
|
||||
int lastCountdownSecond = -1;
|
||||
|
||||
void onWifiSelectionComplete(bool success);
|
||||
|
||||
public:
|
||||
explicit NtpSyncActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
||||
const std::function<void()>& goBack)
|
||||
: ActivityWithSubactivity("NtpSync", renderer, mappedInput), goBack(goBack) {}
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
void render(Activity::RenderLock&&) override;
|
||||
bool preventAutoSleep() override { return state == SYNCING; }
|
||||
};
|
||||
131
src/activities/settings/OpdsServerListActivity.cpp
Normal file
131
src/activities/settings/OpdsServerListActivity.cpp
Normal file
@@ -0,0 +1,131 @@
|
||||
#include "OpdsServerListActivity.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
#include <I18n.h>
|
||||
|
||||
#include "MappedInputManager.h"
|
||||
#include "OpdsServerStore.h"
|
||||
#include "OpdsSettingsActivity.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
int OpdsServerListActivity::getItemCount() const {
|
||||
int count = static_cast<int>(OPDS_STORE.getCount());
|
||||
// In settings mode, append a virtual "Add Server" item; in picker mode, only show real servers
|
||||
if (!isPickerMode()) {
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
void OpdsServerListActivity::onEnter() {
|
||||
ActivityWithSubactivity::onEnter();
|
||||
|
||||
// Reload from disk in case servers were added/removed by a subactivity or the web UI
|
||||
OPDS_STORE.loadFromFile();
|
||||
selectedIndex = 0;
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
void OpdsServerListActivity::onExit() { ActivityWithSubactivity::onExit(); }
|
||||
|
||||
void OpdsServerListActivity::loop() {
|
||||
if (subActivity) {
|
||||
subActivity->loop();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
|
||||
onBack();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
|
||||
handleSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
const int itemCount = getItemCount();
|
||||
if (itemCount > 0) {
|
||||
buttonNavigator.onNext([this, itemCount] {
|
||||
selectedIndex = ButtonNavigator::nextIndex(selectedIndex, itemCount);
|
||||
requestUpdate();
|
||||
});
|
||||
|
||||
buttonNavigator.onPrevious([this, itemCount] {
|
||||
selectedIndex = ButtonNavigator::previousIndex(selectedIndex, itemCount);
|
||||
requestUpdate();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void OpdsServerListActivity::handleSelection() {
|
||||
const auto serverCount = static_cast<int>(OPDS_STORE.getCount());
|
||||
|
||||
if (isPickerMode()) {
|
||||
// Picker mode: selecting a server triggers the callback instead of opening the editor
|
||||
if (selectedIndex < serverCount) {
|
||||
onServerSelected(static_cast<size_t>(selectedIndex));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Settings mode: open editor for selected server, or create a new one
|
||||
auto onEditDone = [this] {
|
||||
exitActivity();
|
||||
selectedIndex = 0;
|
||||
requestUpdate();
|
||||
};
|
||||
|
||||
if (selectedIndex < serverCount) {
|
||||
exitActivity();
|
||||
enterNewActivity(new OpdsSettingsActivity(renderer, mappedInput, onEditDone, selectedIndex));
|
||||
} else {
|
||||
exitActivity();
|
||||
enterNewActivity(new OpdsSettingsActivity(renderer, mappedInput, onEditDone, -1));
|
||||
}
|
||||
}
|
||||
|
||||
void OpdsServerListActivity::render(Activity::RenderLock&&) {
|
||||
renderer.clearScreen();
|
||||
|
||||
const auto& metrics = UITheme::getInstance().getMetrics();
|
||||
const auto pageWidth = renderer.getScreenWidth();
|
||||
const auto pageHeight = renderer.getScreenHeight();
|
||||
|
||||
GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, tr(STR_OPDS_SERVERS));
|
||||
|
||||
const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
|
||||
const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing * 2;
|
||||
const int itemCount = getItemCount();
|
||||
|
||||
if (itemCount == 0) {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, tr(STR_NO_SERVERS));
|
||||
} else {
|
||||
const auto& servers = OPDS_STORE.getServers();
|
||||
const auto serverCount = static_cast<int>(servers.size());
|
||||
|
||||
// Primary label: server name (falling back to URL if unnamed).
|
||||
// Secondary label: server URL (shown as subtitle when name is set).
|
||||
GUI.drawList(
|
||||
renderer, Rect{0, contentTop, pageWidth, contentHeight}, itemCount, selectedIndex,
|
||||
[&servers, serverCount](int index) {
|
||||
if (index < serverCount) {
|
||||
const auto& server = servers[index];
|
||||
return server.name.empty() ? server.url : server.name;
|
||||
}
|
||||
return std::string(I18n::getInstance().get(StrId::STR_ADD_SERVER));
|
||||
},
|
||||
[&servers, serverCount](int index) {
|
||||
if (index < serverCount && !servers[index].name.empty()) {
|
||||
return servers[index].url;
|
||||
}
|
||||
return std::string("");
|
||||
});
|
||||
}
|
||||
|
||||
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SELECT), tr(STR_DIR_UP), tr(STR_DIR_DOWN));
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
|
||||
renderer.displayBuffer();
|
||||
}
|
||||
41
src/activities/settings/OpdsServerListActivity.h
Normal file
41
src/activities/settings/OpdsServerListActivity.h
Normal file
@@ -0,0 +1,41 @@
|
||||
#pragma once
|
||||
|
||||
#include <functional>
|
||||
|
||||
#include "activities/ActivityWithSubactivity.h"
|
||||
#include "util/ButtonNavigator.h"
|
||||
|
||||
/**
|
||||
* Activity showing the list of configured OPDS servers.
|
||||
* Allows adding new servers and editing/deleting existing ones.
|
||||
* Used from Settings and also as a server picker from the home screen.
|
||||
*/
|
||||
class OpdsServerListActivity final : public ActivityWithSubactivity {
|
||||
public:
|
||||
using OnServerSelected = std::function<void(size_t serverIndex)>;
|
||||
|
||||
/**
|
||||
* @param onBack Called when user presses Back
|
||||
* @param onServerSelected If set, acts as a picker: selecting a server calls this instead of opening editor.
|
||||
*/
|
||||
explicit OpdsServerListActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
||||
const std::function<void()>& onBack, OnServerSelected onServerSelected = nullptr)
|
||||
: ActivityWithSubactivity("OpdsServerList", renderer, mappedInput),
|
||||
onBack(onBack),
|
||||
onServerSelected(std::move(onServerSelected)) {}
|
||||
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
void render(Activity::RenderLock&&) override;
|
||||
|
||||
private:
|
||||
ButtonNavigator buttonNavigator;
|
||||
int selectedIndex = 0;
|
||||
const std::function<void()> onBack;
|
||||
OnServerSelected onServerSelected;
|
||||
|
||||
bool isPickerMode() const { return onServerSelected != nullptr; }
|
||||
int getItemCount() const;
|
||||
void handleSelection();
|
||||
};
|
||||
219
src/activities/settings/OpdsSettingsActivity.cpp
Normal file
219
src/activities/settings/OpdsSettingsActivity.cpp
Normal file
@@ -0,0 +1,219 @@
|
||||
#include "OpdsSettingsActivity.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
#include <I18n.h>
|
||||
|
||||
#include <cstring>
|
||||
|
||||
#include "MappedInputManager.h"
|
||||
#include "OpdsServerStore.h"
|
||||
#include "activities/util/DirectoryPickerActivity.h"
|
||||
#include "activities/util/KeyboardEntryActivity.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
namespace {
|
||||
// Editable fields: Name, URL, Username, Password, Download Path.
|
||||
// Existing servers also show a Delete option (BASE_ITEMS + 1).
|
||||
constexpr int BASE_ITEMS = 5;
|
||||
} // namespace
|
||||
|
||||
int OpdsSettingsActivity::getMenuItemCount() const {
|
||||
return isNewServer ? BASE_ITEMS : BASE_ITEMS + 1; // +1 for Delete
|
||||
}
|
||||
|
||||
void OpdsSettingsActivity::onEnter() {
|
||||
ActivityWithSubactivity::onEnter();
|
||||
|
||||
selectedIndex = 0;
|
||||
isNewServer = (serverIndex < 0);
|
||||
|
||||
if (!isNewServer) {
|
||||
const auto* server = OPDS_STORE.getServer(static_cast<size_t>(serverIndex));
|
||||
if (server) {
|
||||
editServer = *server;
|
||||
} else {
|
||||
// Server was deleted between navigation and entering this screen — treat as new
|
||||
isNewServer = true;
|
||||
serverIndex = -1;
|
||||
}
|
||||
}
|
||||
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
void OpdsSettingsActivity::onExit() { ActivityWithSubactivity::onExit(); }
|
||||
|
||||
void OpdsSettingsActivity::loop() {
|
||||
if (subActivity) {
|
||||
subActivity->loop();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
|
||||
onBack();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
|
||||
handleSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
const int menuItems = getMenuItemCount();
|
||||
buttonNavigator.onNext([this, menuItems] {
|
||||
selectedIndex = (selectedIndex + 1) % menuItems;
|
||||
requestUpdate();
|
||||
});
|
||||
|
||||
buttonNavigator.onPrevious([this, menuItems] {
|
||||
selectedIndex = (selectedIndex + menuItems - 1) % menuItems;
|
||||
requestUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
void OpdsSettingsActivity::saveServer() {
|
||||
if (isNewServer) {
|
||||
OPDS_STORE.addServer(editServer);
|
||||
// After the first field is saved, promote to an existing server so
|
||||
// subsequent field edits update in-place rather than creating duplicates.
|
||||
isNewServer = false;
|
||||
serverIndex = static_cast<int>(OPDS_STORE.getCount()) - 1;
|
||||
} else {
|
||||
OPDS_STORE.updateServer(static_cast<size_t>(serverIndex), editServer);
|
||||
}
|
||||
}
|
||||
|
||||
void OpdsSettingsActivity::handleSelection() {
|
||||
if (selectedIndex == 0) {
|
||||
// Server Name
|
||||
exitActivity();
|
||||
enterNewActivity(new KeyboardEntryActivity(
|
||||
renderer, mappedInput, tr(STR_SERVER_NAME), editServer.name, 63, false,
|
||||
[this](const std::string& name) {
|
||||
editServer.name = name;
|
||||
saveServer();
|
||||
exitActivity();
|
||||
requestUpdate();
|
||||
},
|
||||
[this]() {
|
||||
exitActivity();
|
||||
requestUpdate();
|
||||
}));
|
||||
} else if (selectedIndex == 1) {
|
||||
// Server URL
|
||||
exitActivity();
|
||||
enterNewActivity(new KeyboardEntryActivity(
|
||||
renderer, mappedInput, tr(STR_OPDS_SERVER_URL), editServer.url, 127, false,
|
||||
[this](const std::string& url) {
|
||||
editServer.url = url;
|
||||
saveServer();
|
||||
exitActivity();
|
||||
requestUpdate();
|
||||
},
|
||||
[this]() {
|
||||
exitActivity();
|
||||
requestUpdate();
|
||||
}));
|
||||
} else if (selectedIndex == 2) {
|
||||
// Username
|
||||
exitActivity();
|
||||
enterNewActivity(new KeyboardEntryActivity(
|
||||
renderer, mappedInput, tr(STR_USERNAME), editServer.username, 63, false,
|
||||
[this](const std::string& username) {
|
||||
editServer.username = username;
|
||||
saveServer();
|
||||
exitActivity();
|
||||
requestUpdate();
|
||||
},
|
||||
[this]() {
|
||||
exitActivity();
|
||||
requestUpdate();
|
||||
}));
|
||||
} else if (selectedIndex == 3) {
|
||||
// Password
|
||||
exitActivity();
|
||||
enterNewActivity(new KeyboardEntryActivity(
|
||||
renderer, mappedInput, tr(STR_PASSWORD), editServer.password, 63, false,
|
||||
[this](const std::string& password) {
|
||||
editServer.password = password;
|
||||
saveServer();
|
||||
exitActivity();
|
||||
requestUpdate();
|
||||
},
|
||||
[this]() {
|
||||
exitActivity();
|
||||
requestUpdate();
|
||||
}));
|
||||
} else if (selectedIndex == 4) {
|
||||
// Download Path
|
||||
exitActivity();
|
||||
enterNewActivity(new DirectoryPickerActivity(
|
||||
renderer, mappedInput,
|
||||
[this](const std::string& path) {
|
||||
std::string dir = path;
|
||||
editServer.downloadPath = dir;
|
||||
saveServer();
|
||||
exitActivity();
|
||||
requestUpdate();
|
||||
},
|
||||
[this]() {
|
||||
exitActivity();
|
||||
requestUpdate();
|
||||
},
|
||||
editServer.downloadPath));
|
||||
} else if (selectedIndex == 5 && !isNewServer) {
|
||||
// Delete server
|
||||
OPDS_STORE.removeServer(static_cast<size_t>(serverIndex));
|
||||
onBack();
|
||||
}
|
||||
}
|
||||
|
||||
void OpdsSettingsActivity::render(Activity::RenderLock&&) {
|
||||
renderer.clearScreen();
|
||||
|
||||
const auto& metrics = UITheme::getInstance().getMetrics();
|
||||
const auto pageWidth = renderer.getScreenWidth();
|
||||
const auto pageHeight = renderer.getScreenHeight();
|
||||
const char* header = isNewServer ? tr(STR_ADD_SERVER) : tr(STR_OPDS_BROWSER);
|
||||
GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, header);
|
||||
GUI.drawSubHeader(renderer, Rect{0, metrics.topPadding + metrics.headerHeight, pageWidth, metrics.tabBarHeight},
|
||||
tr(STR_CALIBRE_URL_HINT));
|
||||
|
||||
const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing + metrics.tabBarHeight;
|
||||
const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing * 2;
|
||||
const int menuItems = getMenuItemCount();
|
||||
|
||||
const StrId fieldNames[] = {StrId::STR_SERVER_NAME, StrId::STR_OPDS_SERVER_URL, StrId::STR_USERNAME,
|
||||
StrId::STR_PASSWORD, StrId::STR_DOWNLOAD_PATH};
|
||||
|
||||
GUI.drawList(
|
||||
renderer, Rect{0, contentTop, pageWidth, contentHeight}, menuItems, static_cast<int>(selectedIndex),
|
||||
[this, &fieldNames](int index) {
|
||||
if (index < BASE_ITEMS) {
|
||||
return std::string(I18N.get(fieldNames[index]));
|
||||
}
|
||||
return std::string(tr(STR_DELETE_SERVER));
|
||||
},
|
||||
nullptr, nullptr,
|
||||
[this](int index) {
|
||||
if (index == 0) {
|
||||
return editServer.name.empty() ? std::string(tr(STR_NOT_SET)) : editServer.name;
|
||||
} else if (index == 1) {
|
||||
return editServer.url.empty() ? std::string(tr(STR_NOT_SET)) : editServer.url;
|
||||
} else if (index == 2) {
|
||||
return editServer.username.empty() ? std::string(tr(STR_NOT_SET)) : editServer.username;
|
||||
} else if (index == 3) {
|
||||
return editServer.password.empty() ? std::string(tr(STR_NOT_SET)) : std::string("******");
|
||||
} else if (index == 4) {
|
||||
return editServer.downloadPath;
|
||||
}
|
||||
return std::string("");
|
||||
},
|
||||
true);
|
||||
|
||||
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SELECT), tr(STR_DIR_UP), tr(STR_DIR_DOWN));
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
|
||||
renderer.displayBuffer();
|
||||
}
|
||||
40
src/activities/settings/OpdsSettingsActivity.h
Normal file
40
src/activities/settings/OpdsSettingsActivity.h
Normal file
@@ -0,0 +1,40 @@
|
||||
#pragma once
|
||||
|
||||
#include <functional>
|
||||
|
||||
#include "OpdsServerStore.h"
|
||||
#include "activities/ActivityWithSubactivity.h"
|
||||
#include "util/ButtonNavigator.h"
|
||||
|
||||
/**
|
||||
* Edit screen for a single OPDS server.
|
||||
* Shows Name, URL, Username, Password fields and a Delete option.
|
||||
* Used for both adding new servers and editing existing ones.
|
||||
*/
|
||||
class OpdsSettingsActivity final : public ActivityWithSubactivity {
|
||||
public:
|
||||
/**
|
||||
* @param serverIndex Index into OpdsServerStore, or -1 for a new server
|
||||
*/
|
||||
explicit OpdsSettingsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
||||
const std::function<void()>& onBack, int serverIndex = -1)
|
||||
: ActivityWithSubactivity("OpdsSettings", renderer, mappedInput), onBack(onBack), serverIndex(serverIndex) {}
|
||||
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
void render(Activity::RenderLock&&) override;
|
||||
|
||||
private:
|
||||
ButtonNavigator buttonNavigator;
|
||||
|
||||
size_t selectedIndex = 0;
|
||||
const std::function<void()> onBack;
|
||||
int serverIndex;
|
||||
OpdsServer editServer;
|
||||
bool isNewServer = false;
|
||||
|
||||
int getMenuItemCount() const;
|
||||
void handleSelection();
|
||||
void saveServer();
|
||||
};
|
||||
@@ -9,6 +9,7 @@
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
#include "network/OtaUpdater.h"
|
||||
#include "util/BootNtpSync.h"
|
||||
|
||||
void OtaUpdateActivity::onWifiSelectionComplete(const bool success) {
|
||||
exitActivity();
|
||||
@@ -58,6 +59,8 @@ void OtaUpdateActivity::onWifiSelectionComplete(const bool success) {
|
||||
void OtaUpdateActivity::onEnter() {
|
||||
ActivityWithSubactivity::onEnter();
|
||||
|
||||
BootNtpSync::cancel();
|
||||
|
||||
// Turn on WiFi immediately
|
||||
LOG_DBG("OTA", "Turning on WiFi...");
|
||||
WiFi.mode(WIFI_STA);
|
||||
|
||||
@@ -7,12 +7,13 @@
|
||||
#include <cstdlib>
|
||||
|
||||
#include "ButtonRemapActivity.h"
|
||||
#include "CalibreSettingsActivity.h"
|
||||
#include "OpdsServerListActivity.h"
|
||||
#include "ClearCacheActivity.h"
|
||||
#include "CrossPointSettings.h"
|
||||
#include "KOReaderSettingsActivity.h"
|
||||
#include "LanguageSelectActivity.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "NtpSyncActivity.h"
|
||||
#include "OtaUpdateActivity.h"
|
||||
#include "SetTimeActivity.h"
|
||||
#include "SetTimezoneOffsetActivity.h"
|
||||
@@ -201,7 +202,7 @@ void SettingsActivity::toggleCurrentSetting() {
|
||||
enterSubActivity(new KOReaderSettingsActivity(renderer, mappedInput, onComplete));
|
||||
break;
|
||||
case SettingAction::OPDSBrowser:
|
||||
enterSubActivity(new CalibreSettingsActivity(renderer, mappedInput, onComplete));
|
||||
enterSubActivity(new OpdsServerListActivity(renderer, mappedInput, onComplete));
|
||||
break;
|
||||
case SettingAction::Network:
|
||||
enterSubActivity(new WifiSelectionActivity(renderer, mappedInput, onCompleteBool, false));
|
||||
@@ -221,6 +222,9 @@ void SettingsActivity::toggleCurrentSetting() {
|
||||
case SettingAction::SetTimezoneOffset:
|
||||
enterSubActivity(new SetTimezoneOffsetActivity(renderer, mappedInput, onComplete));
|
||||
break;
|
||||
case SettingAction::SyncClock:
|
||||
enterSubActivity(new NtpSyncActivity(renderer, mappedInput, onComplete));
|
||||
break;
|
||||
case SettingAction::None:
|
||||
// Do nothing
|
||||
break;
|
||||
@@ -245,7 +249,8 @@ void SettingsActivity::rebuildClockActions() {
|
||||
[](const SettingInfo& s) { return s.type == SettingType::ACTION; }),
|
||||
clockSettings.end());
|
||||
|
||||
// Always add Set Time
|
||||
// Always add Sync Clock and Set Time
|
||||
clockSettings.push_back(SettingInfo::Action(StrId::STR_SYNC_CLOCK, SettingAction::SyncClock));
|
||||
clockSettings.push_back(SettingInfo::Action(StrId::STR_SET_TIME, SettingAction::SetTime));
|
||||
|
||||
// Only add Set UTC Offset when timezone is set to Custom
|
||||
|
||||
@@ -23,6 +23,7 @@ enum class SettingAction {
|
||||
Language,
|
||||
SetTime,
|
||||
SetTimezoneOffset,
|
||||
SyncClock,
|
||||
};
|
||||
|
||||
struct SettingInfo {
|
||||
|
||||
72
src/activities/util/BmpViewerActivity.cpp
Normal file
72
src/activities/util/BmpViewerActivity.cpp
Normal file
@@ -0,0 +1,72 @@
|
||||
#include "BmpViewerActivity.h"
|
||||
|
||||
#include <HalDisplay.h>
|
||||
#include <HalStorage.h>
|
||||
#include <I18n.h>
|
||||
|
||||
#include "Bitmap.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
void BmpViewerActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
|
||||
const auto pageWidth = renderer.getScreenWidth();
|
||||
const auto pageHeight = renderer.getScreenHeight();
|
||||
|
||||
// Show loading indicator while BMP is parsed
|
||||
renderer.clearScreen();
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, (pageHeight - renderer.getLineHeight(UI_10_FONT_ID)) / 2,
|
||||
tr(STR_LOADING), true, EpdFontFamily::BOLD);
|
||||
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||
|
||||
FsFile file;
|
||||
if (!Storage.openFileForRead("BMP", filePath, file)) {
|
||||
LOG_ERR("BMP", "Failed to open file: %s", filePath.c_str());
|
||||
loadFailed = true;
|
||||
return;
|
||||
}
|
||||
|
||||
Bitmap bitmap(file, true);
|
||||
if (bitmap.parseHeaders() != BmpReaderError::Ok) {
|
||||
LOG_ERR("BMP", "Failed to parse BMP headers: %s", filePath.c_str());
|
||||
file.close();
|
||||
loadFailed = true;
|
||||
return;
|
||||
}
|
||||
|
||||
LOG_DBG("BMP", "Loaded %s (%d x %d)", filePath.c_str(), bitmap.getWidth(), bitmap.getHeight());
|
||||
|
||||
// Compute centered position; drawBitmap handles aspect-ratio-preserving scaling
|
||||
const float ratio = static_cast<float>(bitmap.getWidth()) / static_cast<float>(bitmap.getHeight());
|
||||
const float screenRatio = static_cast<float>(pageWidth) / static_cast<float>(pageHeight);
|
||||
int x, y;
|
||||
if (ratio > screenRatio) {
|
||||
x = 0;
|
||||
y = std::round((static_cast<float>(pageHeight) - static_cast<float>(pageWidth) / ratio) / 2);
|
||||
} else {
|
||||
x = std::round((static_cast<float>(pageWidth) - static_cast<float>(pageHeight) * ratio) / 2);
|
||||
y = 0;
|
||||
}
|
||||
|
||||
renderer.clearScreen();
|
||||
renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight);
|
||||
file.close();
|
||||
|
||||
const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", "");
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
|
||||
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
||||
}
|
||||
|
||||
void BmpViewerActivity::loop() {
|
||||
if (loadFailed) {
|
||||
loadFailed = false;
|
||||
onGoBack();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
onGoBack();
|
||||
}
|
||||
}
|
||||
21
src/activities/util/BmpViewerActivity.h
Normal file
21
src/activities/util/BmpViewerActivity.h
Normal file
@@ -0,0 +1,21 @@
|
||||
#pragma once
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
|
||||
#include "../Activity.h"
|
||||
|
||||
class BmpViewerActivity final : public Activity {
|
||||
std::string filePath;
|
||||
const std::function<void()> onGoBack;
|
||||
bool loadFailed = false;
|
||||
|
||||
public:
|
||||
explicit BmpViewerActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string path,
|
||||
std::function<void()> onGoBack)
|
||||
: Activity("BmpViewer", renderer, mappedInput),
|
||||
filePath(std::move(path)),
|
||||
onGoBack(std::move(onGoBack)) {}
|
||||
void onEnter() override;
|
||||
void loop() override;
|
||||
};
|
||||
166
src/activities/util/DirectoryPickerActivity.cpp
Normal file
166
src/activities/util/DirectoryPickerActivity.cpp
Normal file
@@ -0,0 +1,166 @@
|
||||
#include "DirectoryPickerActivity.h"
|
||||
|
||||
#include <HalStorage.h>
|
||||
#include <I18n.h>
|
||||
|
||||
#include <cstring>
|
||||
|
||||
#include "MappedInputManager.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
#include "util/StringUtils.h"
|
||||
|
||||
void DirectoryPickerActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
|
||||
basepath = initialPath;
|
||||
if (basepath.empty()) basepath = "/";
|
||||
|
||||
// Validate the initial path exists; fall back to root if not
|
||||
auto dir = Storage.open(basepath.c_str());
|
||||
if (!dir || !dir.isDirectory()) {
|
||||
if (dir) dir.close();
|
||||
basepath = "/";
|
||||
} else {
|
||||
dir.close();
|
||||
}
|
||||
|
||||
selectorIndex = 0;
|
||||
loadDirectories();
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
void DirectoryPickerActivity::onExit() {
|
||||
directories.clear();
|
||||
Activity::onExit();
|
||||
}
|
||||
|
||||
void DirectoryPickerActivity::loadDirectories() {
|
||||
directories.clear();
|
||||
|
||||
auto root = Storage.open(basepath.c_str());
|
||||
if (!root || !root.isDirectory()) {
|
||||
if (root) root.close();
|
||||
return;
|
||||
}
|
||||
|
||||
root.rewindDirectory();
|
||||
|
||||
char name[256];
|
||||
for (auto file = root.openNextFile(); file; file = root.openNextFile()) {
|
||||
file.getName(name, sizeof(name));
|
||||
if (name[0] == '.' || strcmp(name, "System Volume Information") == 0) {
|
||||
file.close();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (file.isDirectory()) {
|
||||
directories.emplace_back(std::string(name) + "/");
|
||||
}
|
||||
file.close();
|
||||
}
|
||||
root.close();
|
||||
StringUtils::sortFileList(directories);
|
||||
}
|
||||
|
||||
void DirectoryPickerActivity::loop() {
|
||||
// Absorb the Confirm release from the parent activity that launched us
|
||||
if (waitForConfirmRelease) {
|
||||
if (!mappedInput.isPressed(MappedInputManager::Button::Confirm)) {
|
||||
waitForConfirmRelease = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Index 0 = "Save Here", indices 1..N = directory entries
|
||||
const int totalItems = 1 + static_cast<int>(directories.size());
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
if (selectorIndex == 0) {
|
||||
onSelect(basepath);
|
||||
} else {
|
||||
const auto& dirName = directories[selectorIndex - 1];
|
||||
// Strip trailing '/'
|
||||
std::string folderName = dirName.substr(0, dirName.length() - 1);
|
||||
basepath = (basepath.back() == '/' ? basepath : basepath + "/") + folderName;
|
||||
selectorIndex = 0;
|
||||
loadDirectories();
|
||||
requestUpdate();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
if (basepath == "/") {
|
||||
onCancel();
|
||||
} else {
|
||||
auto slash = basepath.find_last_of('/');
|
||||
basepath = (slash == 0) ? "/" : basepath.substr(0, slash);
|
||||
selectorIndex = 0;
|
||||
loadDirectories();
|
||||
requestUpdate();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
buttonNavigator.onNextRelease([this, totalItems] {
|
||||
selectorIndex = ButtonNavigator::nextIndex(selectorIndex, totalItems);
|
||||
requestUpdate();
|
||||
});
|
||||
|
||||
buttonNavigator.onPreviousRelease([this, totalItems] {
|
||||
selectorIndex = ButtonNavigator::previousIndex(selectorIndex, totalItems);
|
||||
requestUpdate();
|
||||
});
|
||||
|
||||
const int pageItems = UITheme::getInstance().getNumberOfItemsPerPage(renderer, true, false, true, false);
|
||||
|
||||
buttonNavigator.onNextContinuous([this, totalItems, pageItems] {
|
||||
selectorIndex = ButtonNavigator::nextPageIndex(selectorIndex, totalItems, pageItems);
|
||||
requestUpdate();
|
||||
});
|
||||
|
||||
buttonNavigator.onPreviousContinuous([this, totalItems, pageItems] {
|
||||
selectorIndex = ButtonNavigator::previousPageIndex(selectorIndex, totalItems, pageItems);
|
||||
requestUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
void DirectoryPickerActivity::render(Activity::RenderLock&&) {
|
||||
renderer.clearScreen();
|
||||
|
||||
const auto& metrics = UITheme::getInstance().getMetrics();
|
||||
const auto pageWidth = renderer.getScreenWidth();
|
||||
const auto pageHeight = renderer.getScreenHeight();
|
||||
|
||||
std::string folderName = (basepath == "/") ? tr(STR_SD_CARD) : basepath.substr(basepath.rfind('/') + 1);
|
||||
GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, tr(STR_SELECT_FOLDER));
|
||||
|
||||
const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
|
||||
const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing;
|
||||
|
||||
const int totalItems = 1 + static_cast<int>(directories.size());
|
||||
|
||||
GUI.drawList(
|
||||
renderer, Rect{0, contentTop, pageWidth, contentHeight}, totalItems, selectorIndex,
|
||||
[this](int index) -> std::string {
|
||||
if (index == 0) {
|
||||
std::string label = std::string(tr(STR_SAVE_HERE)) + " (" + basepath + ")";
|
||||
return label;
|
||||
}
|
||||
// Strip trailing '/' for display
|
||||
const auto& dir = directories[index - 1];
|
||||
return dir.substr(0, dir.length() - 1);
|
||||
},
|
||||
nullptr,
|
||||
[this](int index) -> UIIcon {
|
||||
return (index == 0) ? UIIcon::File : UIIcon::Folder;
|
||||
});
|
||||
|
||||
const char* backLabel = (basepath == "/") ? tr(STR_CANCEL) : tr(STR_BACK);
|
||||
const char* confirmLabel = (selectorIndex == 0) ? tr(STR_SAVE_HERE) : tr(STR_OPEN);
|
||||
const auto labels = mappedInput.mapLabels(backLabel, confirmLabel, tr(STR_DIR_UP), tr(STR_DIR_DOWN));
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
|
||||
renderer.displayBuffer();
|
||||
}
|
||||
44
src/activities/util/DirectoryPickerActivity.h
Normal file
44
src/activities/util/DirectoryPickerActivity.h
Normal file
@@ -0,0 +1,44 @@
|
||||
#pragma once
|
||||
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "../Activity.h"
|
||||
#include "util/ButtonNavigator.h"
|
||||
|
||||
/**
|
||||
* Directory picker subactivity for selecting a save location on the SD card.
|
||||
* Shows only directories and a "Save Here" option at index 0.
|
||||
* Navigating into a subdirectory updates the current path; Back goes up.
|
||||
* Pressing Back at root calls onCancel.
|
||||
*/
|
||||
class DirectoryPickerActivity final : public Activity {
|
||||
public:
|
||||
explicit DirectoryPickerActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
||||
std::function<void(const std::string& path)> onSelect,
|
||||
std::function<void()> onCancel,
|
||||
std::string initialPath = "/")
|
||||
: Activity("DirectoryPicker", renderer, mappedInput),
|
||||
initialPath(std::move(initialPath)),
|
||||
onSelect(std::move(onSelect)),
|
||||
onCancel(std::move(onCancel)) {}
|
||||
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
void render(RenderLock&&) override;
|
||||
|
||||
private:
|
||||
ButtonNavigator buttonNavigator;
|
||||
std::string initialPath;
|
||||
std::string basepath = "/";
|
||||
std::vector<std::string> directories;
|
||||
int selectorIndex = 0;
|
||||
bool waitForConfirmRelease = true;
|
||||
|
||||
std::function<void(const std::string& path)> onSelect;
|
||||
std::function<void()> onCancel;
|
||||
|
||||
void loadDirectories();
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "BaseTheme.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
#include <HalPowerManager.h>
|
||||
#include <HalStorage.h>
|
||||
#include <Logging.h>
|
||||
#include <Utf8.h>
|
||||
@@ -9,7 +10,6 @@
|
||||
#include <ctime>
|
||||
#include <string>
|
||||
|
||||
#include "Battery.h"
|
||||
#include "CrossPointSettings.h"
|
||||
#include "I18n.h"
|
||||
#include "RecentBooksStore.h"
|
||||
@@ -45,11 +45,110 @@ void drawBatteryIcon(const GfxRenderer& renderer, int x, int y, int battWidth, i
|
||||
|
||||
renderer.fillRect(x + 2, y + 2, filledWidth, rectHeight - 4);
|
||||
}
|
||||
// Truncate a string with "..." to fit within maxWidth.
|
||||
std::string truncateWithEllipsis(const GfxRenderer& renderer, int fontId, const std::string& text, int maxWidth) {
|
||||
std::string truncated = text;
|
||||
std::string withEllipsis = truncated + "...";
|
||||
while (!truncated.empty() && renderer.getTextWidth(fontId, withEllipsis.c_str()) > maxWidth) {
|
||||
utf8RemoveLastChar(truncated);
|
||||
withEllipsis = truncated + "...";
|
||||
}
|
||||
return truncated.empty() ? std::string("...") : withEllipsis;
|
||||
}
|
||||
|
||||
// Text wrapping with 3-tier break logic:
|
||||
// 1) Preferred delimiters: " -- ", " - ", en-dash, em-dash (title-author separator)
|
||||
// 2) Word boundaries: last space or hyphen that fits
|
||||
// 3) Character-level fallback for long unbroken tokens
|
||||
// The last allowed line is truncated with "..." if it overflows.
|
||||
std::vector<std::string> wrapTextToLines(const GfxRenderer& renderer, int fontId, const std::string& text, int maxWidth,
|
||||
int maxLines) {
|
||||
std::vector<std::string> lines;
|
||||
if (text.empty() || maxWidth <= 0 || maxLines <= 0) return lines;
|
||||
|
||||
if (renderer.getTextWidth(fontId, text.c_str()) <= maxWidth) {
|
||||
lines.push_back(text);
|
||||
return lines;
|
||||
}
|
||||
|
||||
if (maxLines == 1) {
|
||||
lines.push_back(truncateWithEllipsis(renderer, fontId, text, maxWidth));
|
||||
return lines;
|
||||
}
|
||||
|
||||
// Tier 1: Try preferred delimiters (last occurrence to maximize line 1 content).
|
||||
// \xe2\x80\x93 = en-dash, \xe2\x80\x94 = em-dash
|
||||
static const char* const preferredDelimiters[] = {" -- ", " - ", " \xe2\x80\x93 ", " \xe2\x80\x94 "};
|
||||
for (const char* delim : preferredDelimiters) {
|
||||
size_t delimLen = strlen(delim);
|
||||
auto pos = text.rfind(delim);
|
||||
if (pos != std::string::npos && pos > 0) {
|
||||
std::string firstPart = text.substr(0, pos);
|
||||
if (renderer.getTextWidth(fontId, firstPart.c_str()) <= maxWidth) {
|
||||
lines.push_back(firstPart);
|
||||
std::string remainder = text.substr(pos + delimLen);
|
||||
if (renderer.getTextWidth(fontId, remainder.c_str()) > maxWidth) {
|
||||
lines.push_back(truncateWithEllipsis(renderer, fontId, remainder, maxWidth));
|
||||
} else {
|
||||
lines.push_back(remainder);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 2 & 3: Word-boundary wrapping with character-level fallback.
|
||||
std::string currentLine;
|
||||
const unsigned char* ptr = reinterpret_cast<const unsigned char*>(text.c_str());
|
||||
std::string lineAtBreak;
|
||||
const unsigned char* ptrAtBreak = nullptr;
|
||||
|
||||
while (*ptr != 0) {
|
||||
const unsigned char* charStart = ptr;
|
||||
uint32_t cp = utf8NextCodepoint(&ptr);
|
||||
std::string nextChar(reinterpret_cast<const char*>(charStart), static_cast<size_t>(ptr - charStart));
|
||||
std::string candidate = currentLine + nextChar;
|
||||
|
||||
if (renderer.getTextWidth(fontId, candidate.c_str()) <= maxWidth) {
|
||||
currentLine = candidate;
|
||||
if (cp == ' ' || cp == '-') {
|
||||
lineAtBreak = currentLine;
|
||||
ptrAtBreak = ptr;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Overflow
|
||||
if (static_cast<int>(lines.size()) < maxLines - 1 && !currentLine.empty()) {
|
||||
if (ptrAtBreak != nullptr) {
|
||||
std::string line = lineAtBreak;
|
||||
while (!line.empty() && line.back() == ' ') line.pop_back();
|
||||
lines.push_back(line);
|
||||
ptr = ptrAtBreak;
|
||||
while (*ptr == ' ') ++ptr;
|
||||
currentLine.clear();
|
||||
} else {
|
||||
lines.push_back(currentLine);
|
||||
currentLine = nextChar;
|
||||
}
|
||||
lineAtBreak.clear();
|
||||
ptrAtBreak = nullptr;
|
||||
} else {
|
||||
lines.push_back(truncateWithEllipsis(renderer, fontId, currentLine, maxWidth));
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
|
||||
if (!currentLine.empty()) {
|
||||
lines.push_back(currentLine);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void BaseTheme::drawBatteryLeft(const GfxRenderer& renderer, Rect rect, const bool showPercentage) const {
|
||||
// Left aligned: icon on left, percentage on right (reader mode)
|
||||
const uint16_t percentage = battery.readPercentage();
|
||||
const uint16_t percentage = powerManager.getBatteryPercentage();
|
||||
const int y = rect.y + 6;
|
||||
|
||||
if (showPercentage) {
|
||||
@@ -64,7 +163,7 @@ void BaseTheme::drawBatteryLeft(const GfxRenderer& renderer, Rect rect, const bo
|
||||
void BaseTheme::drawBatteryRight(const GfxRenderer& renderer, Rect rect, const bool showPercentage) const {
|
||||
// Right aligned: percentage on left, icon on right (UI headers)
|
||||
// rect.x is already positioned for the icon (drawHeader calculated it)
|
||||
const uint16_t percentage = battery.readPercentage();
|
||||
const uint16_t percentage = powerManager.getBatteryPercentage();
|
||||
const int y = rect.y + 6;
|
||||
|
||||
if (showPercentage) {
|
||||
@@ -193,25 +292,36 @@ void BaseTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount,
|
||||
int rowHeight =
|
||||
(rowSubtitle != nullptr) ? BaseMetrics::values.listWithSubtitleRowHeight : BaseMetrics::values.listRowHeight;
|
||||
int pageItems = rect.height / rowHeight;
|
||||
int contentWidth = rect.width - 5;
|
||||
auto font = (rowSubtitle != nullptr) ? UI_12_FONT_ID : UI_10_FONT_ID;
|
||||
|
||||
const int totalPages = (itemCount + pageItems - 1) / pageItems;
|
||||
// Detect if selected row's title overflows and needs 2-line expansion
|
||||
bool selectedExpands = false;
|
||||
if (selectedIndex >= 0 && rowSubtitle == nullptr && rowValue != nullptr) {
|
||||
int titleTextWidth = contentWidth - BaseMetrics::values.contentSidePadding * 2 - 60;
|
||||
auto selTitle = rowTitle(selectedIndex);
|
||||
if (renderer.getTextWidth(font, selTitle.c_str()) > titleTextWidth) {
|
||||
selectedExpands = true;
|
||||
}
|
||||
}
|
||||
|
||||
const int effectivePageItems = selectedExpands ? std::max(1, pageItems - 1) : pageItems;
|
||||
const int totalPages = (itemCount + effectivePageItems - 1) / effectivePageItems;
|
||||
if (totalPages > 1) {
|
||||
constexpr int indicatorWidth = 20;
|
||||
constexpr int arrowSize = 6;
|
||||
constexpr int margin = 15; // Offset from right edge
|
||||
constexpr int margin = 15;
|
||||
|
||||
const int centerX = rect.x + rect.width - indicatorWidth / 2 - margin;
|
||||
const int indicatorTop = rect.y; // Offset to avoid overlapping side button hints
|
||||
const int indicatorTop = rect.y;
|
||||
const int indicatorBottom = rect.y + rect.height - arrowSize;
|
||||
|
||||
// Draw up arrow at top (^) - narrow point at top, wide base at bottom
|
||||
for (int i = 0; i < arrowSize; ++i) {
|
||||
const int lineWidth = 1 + i * 2;
|
||||
const int startX = centerX - i;
|
||||
renderer.drawLine(startX, indicatorTop + i, startX + lineWidth - 1, indicatorTop + i);
|
||||
}
|
||||
|
||||
// Draw down arrow at bottom (v) - wide base at top, narrow point at bottom
|
||||
for (int i = 0; i < arrowSize; ++i) {
|
||||
const int lineWidth = 1 + (arrowSize - 1 - i) * 2;
|
||||
const int startX = centerX - (arrowSize - 1 - i);
|
||||
@@ -220,37 +330,89 @@ void BaseTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount,
|
||||
}
|
||||
}
|
||||
|
||||
// Draw selection
|
||||
int contentWidth = rect.width - 5;
|
||||
if (selectedIndex >= 0) {
|
||||
renderer.fillRect(0, rect.y + selectedIndex % pageItems * rowHeight - 2, rect.width, rowHeight);
|
||||
}
|
||||
// Draw all items
|
||||
const auto pageStartIndex = selectedIndex / pageItems * pageItems;
|
||||
for (int i = pageStartIndex; i < itemCount && i < pageStartIndex + pageItems; i++) {
|
||||
const int itemY = rect.y + (i % pageItems) * rowHeight;
|
||||
int textWidth = contentWidth - BaseMetrics::values.contentSidePadding * 2 - (rowValue != nullptr ? 60 : 0);
|
||||
|
||||
// Draw name
|
||||
auto itemName = rowTitle(i);
|
||||
auto font = (rowSubtitle != nullptr) ? UI_12_FONT_ID : UI_10_FONT_ID;
|
||||
auto item = renderer.truncatedText(font, itemName.c_str(), textWidth);
|
||||
renderer.drawText(font, rect.x + BaseMetrics::values.contentSidePadding, itemY, item.c_str(), i != selectedIndex);
|
||||
|
||||
if (rowSubtitle != nullptr) {
|
||||
// Draw subtitle
|
||||
std::string subtitleText = rowSubtitle(i);
|
||||
auto subtitle = renderer.truncatedText(UI_10_FONT_ID, subtitleText.c_str(), textWidth);
|
||||
renderer.drawText(UI_10_FONT_ID, rect.x + BaseMetrics::values.contentSidePadding, itemY + 30, subtitle.c_str(),
|
||||
i != selectedIndex);
|
||||
// Compute page start: use effective page items but prevent backward leak
|
||||
int pageStartIndex;
|
||||
if (selectedExpands) {
|
||||
int rawStart = selectedIndex / effectivePageItems * effectivePageItems;
|
||||
int originalStart = selectedIndex / pageItems * pageItems;
|
||||
pageStartIndex = std::max(rawStart, originalStart);
|
||||
if (selectedIndex >= pageStartIndex + effectivePageItems) {
|
||||
pageStartIndex = selectedIndex - effectivePageItems + 1;
|
||||
}
|
||||
if (pageStartIndex > 0 && pageStartIndex == originalStart
|
||||
&& selectedIndex < pageStartIndex + effectivePageItems - 1) {
|
||||
int checkWidth = contentWidth - BaseMetrics::values.contentSidePadding * 2 - 60;
|
||||
auto prevTitle = rowTitle(pageStartIndex - 1);
|
||||
if (renderer.getTextWidth(font, prevTitle.c_str()) > checkWidth) {
|
||||
pageStartIndex--;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
pageStartIndex = selectedIndex / pageItems * pageItems;
|
||||
// Include previous page's boundary item if it would need expansion when selected,
|
||||
// so it doesn't vanish when navigating from it to the current page.
|
||||
if (pageStartIndex > 0 && selectedIndex < pageStartIndex + pageItems - 1) {
|
||||
int checkWidth = contentWidth - BaseMetrics::values.contentSidePadding * 2 - 60;
|
||||
auto prevTitle = rowTitle(pageStartIndex - 1);
|
||||
if (renderer.getTextWidth(font, prevTitle.c_str()) > checkWidth) {
|
||||
pageStartIndex--;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (rowValue != nullptr) {
|
||||
// Draw value
|
||||
std::string valueText = rowValue(i);
|
||||
const auto valueTextWidth = renderer.getTextWidth(UI_10_FONT_ID, valueText.c_str());
|
||||
renderer.drawText(UI_10_FONT_ID, rect.x + contentWidth - BaseMetrics::values.contentSidePadding - valueTextWidth,
|
||||
itemY, valueText.c_str(), i != selectedIndex);
|
||||
// Draw selection highlight
|
||||
if (selectedIndex >= 0) {
|
||||
int selRowsBeforeOnPage = selectedIndex - pageStartIndex;
|
||||
int selY = rect.y + selRowsBeforeOnPage * rowHeight - 2;
|
||||
int selHeight = selectedExpands ? 2 * rowHeight : rowHeight;
|
||||
renderer.fillRect(0, selY, rect.width, selHeight);
|
||||
}
|
||||
|
||||
// Draw all items
|
||||
int yPos = rect.y;
|
||||
for (int i = pageStartIndex; i < itemCount && i < pageStartIndex + effectivePageItems; i++) {
|
||||
const bool isExpanded = (selectedExpands && i == selectedIndex);
|
||||
|
||||
auto itemName = rowTitle(i);
|
||||
|
||||
if (isExpanded) {
|
||||
int wrapWidth = contentWidth - BaseMetrics::values.contentSidePadding * 2;
|
||||
auto lines = wrapTextToLines(renderer, font, itemName, wrapWidth, 2);
|
||||
|
||||
for (size_t l = 0; l < lines.size(); ++l) {
|
||||
renderer.drawText(font, rect.x + BaseMetrics::values.contentSidePadding,
|
||||
yPos + static_cast<int>(l) * rowHeight, lines[l].c_str(), false);
|
||||
}
|
||||
|
||||
if (rowValue != nullptr) {
|
||||
std::string valueText = rowValue(i);
|
||||
const auto valueTextWidth = renderer.getTextWidth(UI_10_FONT_ID, valueText.c_str());
|
||||
renderer.drawText(UI_10_FONT_ID,
|
||||
rect.x + contentWidth - BaseMetrics::values.contentSidePadding - valueTextWidth,
|
||||
yPos + rowHeight, valueText.c_str(), false);
|
||||
}
|
||||
yPos += 2 * rowHeight;
|
||||
} else {
|
||||
int textWidth = contentWidth - BaseMetrics::values.contentSidePadding * 2 - (rowValue != nullptr ? 60 : 0);
|
||||
auto item = renderer.truncatedText(font, itemName.c_str(), textWidth);
|
||||
renderer.drawText(font, rect.x + BaseMetrics::values.contentSidePadding, yPos, item.c_str(),
|
||||
i != selectedIndex);
|
||||
|
||||
if (rowSubtitle != nullptr) {
|
||||
std::string subtitleText = rowSubtitle(i);
|
||||
auto subtitle = renderer.truncatedText(UI_10_FONT_ID, subtitleText.c_str(), textWidth);
|
||||
renderer.drawText(UI_10_FONT_ID, rect.x + BaseMetrics::values.contentSidePadding, yPos + 30,
|
||||
subtitle.c_str(), i != selectedIndex);
|
||||
}
|
||||
|
||||
if (rowValue != nullptr) {
|
||||
std::string valueText = rowValue(i);
|
||||
const auto valueTextWidth = renderer.getTextWidth(UI_10_FONT_ID, valueText.c_str());
|
||||
renderer.drawText(UI_10_FONT_ID,
|
||||
rect.x + contentWidth - BaseMetrics::values.contentSidePadding - valueTextWidth, yPos,
|
||||
valueText.c_str(), i != selectedIndex);
|
||||
}
|
||||
yPos += rowHeight;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -365,14 +527,52 @@ void BaseTheme::drawTabBar(const GfxRenderer& renderer, const Rect rect, const s
|
||||
void BaseTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector<RecentBook>& recentBooks,
|
||||
const int selectorIndex, bool& coverRendered, bool& coverBufferStored,
|
||||
bool& bufferRestored, std::function<bool()> storeCoverBuffer) const {
|
||||
// --- Top "book" card for the current title (selectorIndex == 0) ---
|
||||
const int bookWidth = rect.width / 2;
|
||||
const int bookHeight = rect.height;
|
||||
const int bookX = (rect.width - bookWidth) / 2;
|
||||
const int bookY = rect.y;
|
||||
const bool hasContinueReading = !recentBooks.empty();
|
||||
const bool bookSelected = hasContinueReading && selectorIndex == 0;
|
||||
|
||||
// --- Top "book" card for the current title (selectorIndex == 0) ---
|
||||
// Adapt width to cover image aspect ratio; fall back to half screen when no cover
|
||||
const int baseHeight = rect.height;
|
||||
|
||||
int bookWidth;
|
||||
bool hasCoverImage = false;
|
||||
|
||||
if (hasContinueReading && !recentBooks[0].coverBmpPath.empty()) {
|
||||
const std::string coverBmpPath =
|
||||
UITheme::getCoverThumbPath(recentBooks[0].coverBmpPath, BaseMetrics::values.homeCoverHeight);
|
||||
|
||||
FsFile file;
|
||||
if (Storage.openFileForRead("HOME", coverBmpPath, file)) {
|
||||
Bitmap bitmap(file);
|
||||
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
|
||||
hasCoverImage = true;
|
||||
const int imgWidth = bitmap.getWidth();
|
||||
const int imgHeight = bitmap.getHeight();
|
||||
|
||||
if (imgWidth > 0 && imgHeight > 0) {
|
||||
const float aspectRatio = static_cast<float>(imgWidth) / static_cast<float>(imgHeight);
|
||||
bookWidth = static_cast<int>(baseHeight * aspectRatio);
|
||||
|
||||
const int maxWidth = static_cast<int>(rect.width * 0.9f);
|
||||
if (bookWidth > maxWidth) {
|
||||
bookWidth = maxWidth;
|
||||
}
|
||||
} else {
|
||||
bookWidth = rect.width / 2;
|
||||
}
|
||||
}
|
||||
file.close();
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasCoverImage) {
|
||||
bookWidth = rect.width / 2;
|
||||
}
|
||||
|
||||
const int bookX = rect.x + (rect.width - bookWidth) / 2;
|
||||
const int bookY = rect.y;
|
||||
const int bookHeight = baseHeight;
|
||||
|
||||
// Bookmark dimensions (used in multiple places)
|
||||
const int bookmarkWidth = bookWidth / 8;
|
||||
const int bookmarkHeight = bookHeight / 5;
|
||||
@@ -394,29 +594,9 @@ void BaseTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std:
|
||||
Bitmap bitmap(file);
|
||||
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
|
||||
LOG_DBG("THEME", "Rendering bmp");
|
||||
// Calculate position to center image within the book card
|
||||
int coverX, coverY;
|
||||
|
||||
if (bitmap.getWidth() > bookWidth || bitmap.getHeight() > bookHeight) {
|
||||
const float imgRatio = static_cast<float>(bitmap.getWidth()) / static_cast<float>(bitmap.getHeight());
|
||||
const float boxRatio = static_cast<float>(bookWidth) / static_cast<float>(bookHeight);
|
||||
renderer.drawBitmap(bitmap, bookX, bookY, bookWidth, bookHeight);
|
||||
|
||||
if (imgRatio > boxRatio) {
|
||||
coverX = bookX;
|
||||
coverY = bookY + (bookHeight - static_cast<int>(bookWidth / imgRatio)) / 2;
|
||||
} else {
|
||||
coverX = bookX + (bookWidth - static_cast<int>(bookHeight * imgRatio)) / 2;
|
||||
coverY = bookY;
|
||||
}
|
||||
} else {
|
||||
coverX = bookX + (bookWidth - bitmap.getWidth()) / 2;
|
||||
coverY = bookY + (bookHeight - bitmap.getHeight()) / 2;
|
||||
}
|
||||
|
||||
// Draw the cover image centered within the book card
|
||||
renderer.drawBitmap(bitmap, coverX, coverY, bookWidth, bookHeight);
|
||||
|
||||
// Draw border around the card
|
||||
renderer.drawRect(bookX, bookY, bookWidth, bookHeight);
|
||||
|
||||
// No bookmark ribbon when cover is shown - it would just cover the art
|
||||
@@ -597,7 +777,7 @@ void BaseTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std:
|
||||
|
||||
const int boxWidth = maxTextWidth + boxPadding * 2;
|
||||
const int boxHeight = totalTextHeight + boxPadding * 2;
|
||||
const int boxX = (rect.width - boxWidth) / 2;
|
||||
const int boxX = rect.x + (rect.width - boxWidth) / 2;
|
||||
const int boxY = titleYStart - boxPadding;
|
||||
|
||||
// Draw box (inverted when selected: black box instead of white)
|
||||
@@ -640,7 +820,7 @@ void BaseTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std:
|
||||
constexpr int continuePadding = 6;
|
||||
const int continueBoxWidth = continueTextWidth + continuePadding * 2;
|
||||
const int continueBoxHeight = renderer.getLineHeight(UI_10_FONT_ID) + continuePadding;
|
||||
const int continueBoxX = (rect.width - continueBoxWidth) / 2;
|
||||
const int continueBoxX = rect.x + (rect.width - continueBoxWidth) / 2;
|
||||
const int continueBoxY = continueY - continuePadding / 2;
|
||||
renderer.fillRect(continueBoxX, continueBoxY, continueBoxWidth, continueBoxHeight, bookSelected);
|
||||
renderer.drawRect(continueBoxX, continueBoxY, continueBoxWidth, continueBoxHeight, !bookSelected);
|
||||
|
||||
@@ -82,7 +82,7 @@ constexpr ThemeMetrics values = {.batteryWidth = 15,
|
||||
.tabBarHeight = 50,
|
||||
.scrollBarWidth = 4,
|
||||
.scrollBarRightOffset = 5,
|
||||
.homeTopPadding = 20,
|
||||
.homeTopPadding = 40,
|
||||
.homeCoverHeight = 400,
|
||||
.homeCoverTileHeight = 400,
|
||||
.homeRecentBooksCount = 1,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "LyraTheme.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
#include <HalPowerManager.h>
|
||||
#include <HalStorage.h>
|
||||
#include <I18n.h>
|
||||
#include <Utf8.h>
|
||||
@@ -10,7 +11,6 @@
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "Battery.h"
|
||||
#include "CrossPointSettings.h"
|
||||
#include "RecentBooksStore.h"
|
||||
#include "components/UITheme.h"
|
||||
@@ -84,11 +84,100 @@ const uint8_t* iconForName(UIIcon icon, int size) {
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
std::string truncateWithEllipsis(const GfxRenderer& renderer, int fontId, const std::string& text, int maxWidth) {
|
||||
std::string truncated = text;
|
||||
std::string withEllipsis = truncated + "...";
|
||||
while (!truncated.empty() && renderer.getTextWidth(fontId, withEllipsis.c_str()) > maxWidth) {
|
||||
utf8RemoveLastChar(truncated);
|
||||
withEllipsis = truncated + "...";
|
||||
}
|
||||
return truncated.empty() ? std::string("...") : withEllipsis;
|
||||
}
|
||||
|
||||
std::vector<std::string> wrapTextToLines(const GfxRenderer& renderer, int fontId, const std::string& text, int maxWidth,
|
||||
int maxLines) {
|
||||
std::vector<std::string> lines;
|
||||
if (text.empty() || maxWidth <= 0 || maxLines <= 0) return lines;
|
||||
|
||||
if (renderer.getTextWidth(fontId, text.c_str()) <= maxWidth) {
|
||||
lines.push_back(text);
|
||||
return lines;
|
||||
}
|
||||
|
||||
if (maxLines == 1) {
|
||||
lines.push_back(truncateWithEllipsis(renderer, fontId, text, maxWidth));
|
||||
return lines;
|
||||
}
|
||||
|
||||
static const char* const preferredDelimiters[] = {" -- ", " - ", " \xe2\x80\x93 ", " \xe2\x80\x94 "};
|
||||
for (const char* delim : preferredDelimiters) {
|
||||
size_t delimLen = strlen(delim);
|
||||
auto pos = text.rfind(delim);
|
||||
if (pos != std::string::npos && pos > 0) {
|
||||
std::string firstPart = text.substr(0, pos);
|
||||
if (renderer.getTextWidth(fontId, firstPart.c_str()) <= maxWidth) {
|
||||
lines.push_back(firstPart);
|
||||
std::string remainder = text.substr(pos + delimLen);
|
||||
if (renderer.getTextWidth(fontId, remainder.c_str()) > maxWidth) {
|
||||
lines.push_back(truncateWithEllipsis(renderer, fontId, remainder, maxWidth));
|
||||
} else {
|
||||
lines.push_back(remainder);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::string currentLine;
|
||||
const unsigned char* ptr = reinterpret_cast<const unsigned char*>(text.c_str());
|
||||
std::string lineAtBreak;
|
||||
const unsigned char* ptrAtBreak = nullptr;
|
||||
|
||||
while (*ptr != 0) {
|
||||
const unsigned char* charStart = ptr;
|
||||
uint32_t cp = utf8NextCodepoint(&ptr);
|
||||
std::string nextChar(reinterpret_cast<const char*>(charStart), static_cast<size_t>(ptr - charStart));
|
||||
std::string candidate = currentLine + nextChar;
|
||||
|
||||
if (renderer.getTextWidth(fontId, candidate.c_str()) <= maxWidth) {
|
||||
currentLine = candidate;
|
||||
if (cp == ' ' || cp == '-') {
|
||||
lineAtBreak = currentLine;
|
||||
ptrAtBreak = ptr;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (static_cast<int>(lines.size()) < maxLines - 1 && !currentLine.empty()) {
|
||||
if (ptrAtBreak != nullptr) {
|
||||
std::string line = lineAtBreak;
|
||||
while (!line.empty() && line.back() == ' ') line.pop_back();
|
||||
lines.push_back(line);
|
||||
ptr = ptrAtBreak;
|
||||
while (*ptr == ' ') ++ptr;
|
||||
currentLine.clear();
|
||||
} else {
|
||||
lines.push_back(currentLine);
|
||||
currentLine = nextChar;
|
||||
}
|
||||
lineAtBreak.clear();
|
||||
ptrAtBreak = nullptr;
|
||||
} else {
|
||||
lines.push_back(truncateWithEllipsis(renderer, fontId, currentLine, maxWidth));
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
|
||||
if (!currentLine.empty()) {
|
||||
lines.push_back(currentLine);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void LyraTheme::drawBatteryLeft(const GfxRenderer& renderer, Rect rect, const bool showPercentage) const {
|
||||
// Left aligned: icon on left, percentage on right (reader mode)
|
||||
const uint16_t percentage = battery.readPercentage();
|
||||
const uint16_t percentage = powerManager.getBatteryPercentage();
|
||||
const int y = rect.y + 6;
|
||||
const int battWidth = LyraMetrics::values.batteryWidth;
|
||||
|
||||
@@ -125,7 +214,7 @@ void LyraTheme::drawBatteryLeft(const GfxRenderer& renderer, Rect rect, const bo
|
||||
|
||||
void LyraTheme::drawBatteryRight(const GfxRenderer& renderer, Rect rect, const bool showPercentage) const {
|
||||
// Right aligned: percentage on left, icon on right (UI headers)
|
||||
const uint16_t percentage = battery.readPercentage();
|
||||
const uint16_t percentage = powerManager.getBatteryPercentage();
|
||||
const int y = rect.y + 6;
|
||||
const int battWidth = LyraMetrics::values.batteryWidth;
|
||||
|
||||
@@ -278,13 +367,35 @@ void LyraTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount,
|
||||
(rowSubtitle != nullptr) ? LyraMetrics::values.listWithSubtitleRowHeight : LyraMetrics::values.listRowHeight;
|
||||
int pageItems = rect.height / rowHeight;
|
||||
|
||||
const int totalPages = (itemCount + pageItems - 1) / pageItems;
|
||||
// Detect if selected row's title overflows and needs 2-line expansion
|
||||
bool selectedExpands = false;
|
||||
if (selectedIndex >= 0 && rowSubtitle == nullptr && rowValue != nullptr) {
|
||||
int prelTotalPages = (itemCount + pageItems - 1) / pageItems;
|
||||
int prelContentWidth =
|
||||
rect.width -
|
||||
(prelTotalPages > 1 ? (LyraMetrics::values.scrollBarWidth + LyraMetrics::values.scrollBarRightOffset) : 1);
|
||||
int prelTextWidth = prelContentWidth - LyraMetrics::values.contentSidePadding * 2 - hPaddingInSelection * 2;
|
||||
if (rowIcon != nullptr) prelTextWidth -= listIconSize + hPaddingInSelection;
|
||||
|
||||
auto selTitle = rowTitle(selectedIndex);
|
||||
auto selValue = rowValue(selectedIndex);
|
||||
int selValueWidth = 0;
|
||||
if (!selValue.empty()) {
|
||||
selValue = renderer.truncatedText(UI_10_FONT_ID, selValue.c_str(), maxListValueWidth);
|
||||
selValueWidth = renderer.getTextWidth(UI_10_FONT_ID, selValue.c_str()) + hPaddingInSelection;
|
||||
}
|
||||
if (renderer.getTextWidth(UI_10_FONT_ID, selTitle.c_str()) > prelTextWidth - selValueWidth) {
|
||||
selectedExpands = true;
|
||||
}
|
||||
}
|
||||
|
||||
const int effectivePageItems = selectedExpands ? std::max(1, pageItems - 1) : pageItems;
|
||||
const int totalPages = (itemCount + effectivePageItems - 1) / effectivePageItems;
|
||||
if (totalPages > 1) {
|
||||
const int scrollAreaHeight = rect.height;
|
||||
|
||||
// Draw scroll bar
|
||||
const int scrollBarHeight = (scrollAreaHeight * pageItems) / itemCount;
|
||||
const int currentPage = selectedIndex / pageItems;
|
||||
const int scrollBarHeight = (scrollAreaHeight * effectivePageItems) / itemCount;
|
||||
const int currentPage = selectedIndex / effectivePageItems;
|
||||
const int scrollBarY = rect.y + ((scrollAreaHeight - scrollBarHeight) * currentPage) / (totalPages - 1);
|
||||
const int scrollBarX = rect.x + rect.width - LyraMetrics::values.scrollBarRightOffset;
|
||||
renderer.drawLine(scrollBarX, rect.y, scrollBarX, rect.y + scrollAreaHeight, true);
|
||||
@@ -292,19 +403,71 @@ void LyraTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount,
|
||||
scrollBarHeight, true);
|
||||
}
|
||||
|
||||
// Draw selection
|
||||
int contentWidth =
|
||||
rect.width -
|
||||
(totalPages > 1 ? (LyraMetrics::values.scrollBarWidth + LyraMetrics::values.scrollBarRightOffset) : 1);
|
||||
|
||||
// Compute page start: use effective page items but prevent backward leak
|
||||
int pageStartIndex;
|
||||
if (selectedExpands) {
|
||||
int rawStart = selectedIndex / effectivePageItems * effectivePageItems;
|
||||
int originalStart = selectedIndex / pageItems * pageItems;
|
||||
pageStartIndex = std::max(rawStart, originalStart);
|
||||
if (selectedIndex >= pageStartIndex + effectivePageItems) {
|
||||
pageStartIndex = selectedIndex - effectivePageItems + 1;
|
||||
}
|
||||
if (pageStartIndex > 0 && pageStartIndex == originalStart
|
||||
&& selectedIndex < pageStartIndex + effectivePageItems - 1) {
|
||||
int checkTextWidth = contentWidth - LyraMetrics::values.contentSidePadding * 2 - hPaddingInSelection * 2;
|
||||
if (rowIcon != nullptr) checkTextWidth -= listIconSize + hPaddingInSelection;
|
||||
auto prevTitle = rowTitle(pageStartIndex - 1);
|
||||
int prevValueWidth = 0;
|
||||
if (rowValue != nullptr) {
|
||||
auto prevValue = rowValue(pageStartIndex - 1);
|
||||
prevValue = renderer.truncatedText(UI_10_FONT_ID, prevValue.c_str(), maxListValueWidth);
|
||||
if (!prevValue.empty()) {
|
||||
prevValueWidth = renderer.getTextWidth(UI_10_FONT_ID, prevValue.c_str()) + hPaddingInSelection;
|
||||
}
|
||||
}
|
||||
if (renderer.getTextWidth(UI_10_FONT_ID, prevTitle.c_str()) > checkTextWidth - prevValueWidth) {
|
||||
pageStartIndex--;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
pageStartIndex = selectedIndex / pageItems * pageItems;
|
||||
// Include previous page's boundary item if it would need expansion when selected,
|
||||
// so it doesn't vanish when navigating from it to the current page.
|
||||
if (pageStartIndex > 0 && selectedIndex < pageStartIndex + pageItems - 1) {
|
||||
int checkTextWidth = contentWidth - LyraMetrics::values.contentSidePadding * 2 - hPaddingInSelection * 2;
|
||||
if (rowIcon != nullptr) checkTextWidth -= listIconSize + hPaddingInSelection;
|
||||
auto prevTitle = rowTitle(pageStartIndex - 1);
|
||||
int prevValueWidth = 0;
|
||||
if (rowValue != nullptr) {
|
||||
auto prevValue = rowValue(pageStartIndex - 1);
|
||||
prevValue = renderer.truncatedText(UI_10_FONT_ID, prevValue.c_str(), maxListValueWidth);
|
||||
if (!prevValue.empty()) {
|
||||
prevValueWidth = renderer.getTextWidth(UI_10_FONT_ID, prevValue.c_str()) + hPaddingInSelection;
|
||||
}
|
||||
}
|
||||
if (renderer.getTextWidth(UI_10_FONT_ID, prevTitle.c_str()) > checkTextWidth - prevValueWidth) {
|
||||
pageStartIndex--;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Draw selection highlight
|
||||
if (selectedIndex >= 0) {
|
||||
renderer.fillRoundedRect(LyraMetrics::values.contentSidePadding, rect.y + selectedIndex % pageItems * rowHeight,
|
||||
contentWidth - LyraMetrics::values.contentSidePadding * 2, rowHeight, cornerRadius,
|
||||
int selRowsBeforeOnPage = selectedIndex - pageStartIndex;
|
||||
int selY = rect.y + selRowsBeforeOnPage * rowHeight;
|
||||
int selHeight = selectedExpands ? 2 * rowHeight : rowHeight;
|
||||
renderer.fillRoundedRect(LyraMetrics::values.contentSidePadding, selY,
|
||||
contentWidth - LyraMetrics::values.contentSidePadding * 2, selHeight, cornerRadius,
|
||||
Color::LightGray);
|
||||
}
|
||||
|
||||
int textX = rect.x + LyraMetrics::values.contentSidePadding + hPaddingInSelection;
|
||||
int textWidth = contentWidth - LyraMetrics::values.contentSidePadding * 2 - hPaddingInSelection * 2;
|
||||
int iconSize;
|
||||
int iconSize = listIconSize;
|
||||
if (rowIcon != nullptr) {
|
||||
iconSize = (rowSubtitle != nullptr) ? mainMenuIconSize : listIconSize;
|
||||
textX += iconSize + hPaddingInSelection;
|
||||
@@ -312,52 +475,78 @@ void LyraTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount,
|
||||
}
|
||||
|
||||
// Draw all items
|
||||
const auto pageStartIndex = selectedIndex / pageItems * pageItems;
|
||||
int iconY = (rowSubtitle != nullptr) ? 16 : 10;
|
||||
for (int i = pageStartIndex; i < itemCount && i < pageStartIndex + pageItems; i++) {
|
||||
const int itemY = rect.y + (i % pageItems) * rowHeight;
|
||||
int rowTextWidth = textWidth;
|
||||
int yPos = rect.y;
|
||||
for (int i = pageStartIndex; i < itemCount && i < pageStartIndex + effectivePageItems; i++) {
|
||||
const bool isExpanded = (selectedExpands && i == selectedIndex);
|
||||
|
||||
// Draw name
|
||||
int valueWidth = 0;
|
||||
std::string valueText = "";
|
||||
std::string valueText;
|
||||
if (rowValue != nullptr) {
|
||||
valueText = rowValue(i);
|
||||
valueText = renderer.truncatedText(UI_10_FONT_ID, valueText.c_str(), maxListValueWidth);
|
||||
valueWidth = renderer.getTextWidth(UI_10_FONT_ID, valueText.c_str()) + hPaddingInSelection;
|
||||
rowTextWidth -= valueWidth;
|
||||
if (!valueText.empty()) {
|
||||
valueWidth = renderer.getTextWidth(UI_10_FONT_ID, valueText.c_str()) + hPaddingInSelection;
|
||||
}
|
||||
}
|
||||
|
||||
auto itemName = rowTitle(i);
|
||||
auto item = renderer.truncatedText(UI_10_FONT_ID, itemName.c_str(), rowTextWidth);
|
||||
renderer.drawText(UI_10_FONT_ID, textX, itemY + 7, item.c_str(), true);
|
||||
|
||||
if (rowIcon != nullptr) {
|
||||
UIIcon icon = rowIcon(i);
|
||||
const uint8_t* iconBitmap = iconForName(icon, iconSize);
|
||||
if (iconBitmap != nullptr) {
|
||||
renderer.drawIcon(iconBitmap, rect.x + LyraMetrics::values.contentSidePadding + hPaddingInSelection,
|
||||
itemY + iconY, iconSize, iconSize);
|
||||
}
|
||||
}
|
||||
if (isExpanded) {
|
||||
int wrapWidth = textWidth;
|
||||
auto lines = wrapTextToLines(renderer, UI_10_FONT_ID, itemName, wrapWidth, 2);
|
||||
|
||||
if (rowSubtitle != nullptr) {
|
||||
// Draw subtitle
|
||||
std::string subtitleText = rowSubtitle(i);
|
||||
auto subtitle = renderer.truncatedText(SMALL_FONT_ID, subtitleText.c_str(), rowTextWidth);
|
||||
renderer.drawText(SMALL_FONT_ID, textX, itemY + 30, subtitle.c_str(), true);
|
||||
}
|
||||
|
||||
// Draw value
|
||||
if (!valueText.empty()) {
|
||||
if (i == selectedIndex && highlightValue) {
|
||||
renderer.fillRoundedRect(
|
||||
contentWidth - LyraMetrics::values.contentSidePadding - hPaddingInSelection - valueWidth, itemY,
|
||||
valueWidth + hPaddingInSelection, rowHeight, cornerRadius, Color::Black);
|
||||
for (size_t l = 0; l < lines.size(); ++l) {
|
||||
renderer.drawText(UI_10_FONT_ID, textX, yPos + 7 + static_cast<int>(l) * rowHeight, lines[l].c_str(), true);
|
||||
}
|
||||
|
||||
renderer.drawText(UI_10_FONT_ID, rect.x + contentWidth - LyraMetrics::values.contentSidePadding - valueWidth,
|
||||
itemY + 6, valueText.c_str(), !(i == selectedIndex && highlightValue));
|
||||
if (rowIcon != nullptr) {
|
||||
UIIcon icon = rowIcon(i);
|
||||
const uint8_t* iconBitmap = iconForName(icon, iconSize);
|
||||
if (iconBitmap != nullptr) {
|
||||
renderer.drawIcon(iconBitmap, rect.x + LyraMetrics::values.contentSidePadding + hPaddingInSelection,
|
||||
yPos + iconY, iconSize, iconSize);
|
||||
}
|
||||
}
|
||||
|
||||
if (!valueText.empty()) {
|
||||
renderer.drawText(UI_10_FONT_ID,
|
||||
rect.x + contentWidth - LyraMetrics::values.contentSidePadding - valueWidth,
|
||||
yPos + rowHeight + 7, valueText.c_str(), true);
|
||||
}
|
||||
yPos += 2 * rowHeight;
|
||||
} else {
|
||||
int rowTextWidth = textWidth - valueWidth;
|
||||
|
||||
auto item = renderer.truncatedText(UI_10_FONT_ID, itemName.c_str(), rowTextWidth);
|
||||
renderer.drawText(UI_10_FONT_ID, textX, yPos + 7, item.c_str(), true);
|
||||
|
||||
if (rowIcon != nullptr) {
|
||||
UIIcon icon = rowIcon(i);
|
||||
const uint8_t* iconBitmap = iconForName(icon, iconSize);
|
||||
if (iconBitmap != nullptr) {
|
||||
renderer.drawIcon(iconBitmap, rect.x + LyraMetrics::values.contentSidePadding + hPaddingInSelection,
|
||||
yPos + iconY, iconSize, iconSize);
|
||||
}
|
||||
}
|
||||
|
||||
if (rowSubtitle != nullptr) {
|
||||
std::string subtitleText = rowSubtitle(i);
|
||||
auto subtitle = renderer.truncatedText(SMALL_FONT_ID, subtitleText.c_str(), rowTextWidth);
|
||||
renderer.drawText(SMALL_FONT_ID, textX, yPos + 30, subtitle.c_str(), true);
|
||||
}
|
||||
|
||||
if (!valueText.empty()) {
|
||||
if (i == selectedIndex && highlightValue) {
|
||||
renderer.fillRoundedRect(
|
||||
contentWidth - LyraMetrics::values.contentSidePadding - hPaddingInSelection - valueWidth, yPos,
|
||||
valueWidth + hPaddingInSelection, rowHeight, cornerRadius, Color::Black);
|
||||
}
|
||||
renderer.drawText(UI_10_FONT_ID,
|
||||
rect.x + contentWidth - LyraMetrics::values.contentSidePadding - valueWidth, yPos + 6,
|
||||
valueText.c_str(), !(i == selectedIndex && highlightValue));
|
||||
}
|
||||
yPos += rowHeight;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -380,7 +569,8 @@ void LyraTheme::drawButtonHints(GfxRenderer& renderer, const char* btn1, const c
|
||||
const int x = buttonPositions[i];
|
||||
if (labels[i] != nullptr && labels[i][0] != '\0') {
|
||||
// Draw the filled background and border for a FULL-sized button
|
||||
renderer.fillRect(x, pageHeight - buttonY, buttonWidth, buttonHeight, false);
|
||||
renderer.fillRoundedRect(x, pageHeight - buttonY, buttonWidth, buttonHeight, cornerRadius, true, true, false,
|
||||
false, Color::White);
|
||||
renderer.drawRoundedRect(x, pageHeight - buttonY, buttonWidth, buttonHeight, 1, cornerRadius, true, true, false,
|
||||
false, true);
|
||||
const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, labels[i]);
|
||||
@@ -388,7 +578,8 @@ void LyraTheme::drawButtonHints(GfxRenderer& renderer, const char* btn1, const c
|
||||
renderer.drawText(SMALL_FONT_ID, textX, pageHeight - buttonY + textYOffset, labels[i]);
|
||||
} else {
|
||||
// Draw the filled background and border for a SMALL-sized button
|
||||
renderer.fillRect(x, pageHeight - smallButtonHeight, buttonWidth, smallButtonHeight, false);
|
||||
renderer.fillRoundedRect(x, pageHeight - smallButtonHeight, buttonWidth, smallButtonHeight, cornerRadius, true,
|
||||
true, false, false, Color::White);
|
||||
renderer.drawRoundedRect(x, pageHeight - smallButtonHeight, buttonWidth, smallButtonHeight, 1, cornerRadius, true,
|
||||
true, false, false, true);
|
||||
}
|
||||
|
||||
49
src/main.cpp
49
src/main.cpp
@@ -15,11 +15,11 @@
|
||||
#include <cstring>
|
||||
#include <ctime>
|
||||
|
||||
#include "Battery.h"
|
||||
#include "CrossPointSettings.h"
|
||||
#include "CrossPointState.h"
|
||||
#include "KOReaderCredentialStore.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "OpdsServerStore.h"
|
||||
#include "RecentBooksStore.h"
|
||||
#include "activities/boot_sleep/BootActivity.h"
|
||||
#include "activities/boot_sleep/SleepActivity.h"
|
||||
@@ -29,10 +29,12 @@
|
||||
#include "activities/home/RecentBooksActivity.h"
|
||||
#include "activities/network/CrossPointWebServerActivity.h"
|
||||
#include "activities/reader/ReaderActivity.h"
|
||||
#include "activities/settings/OpdsServerListActivity.h"
|
||||
#include "activities/settings/SettingsActivity.h"
|
||||
#include "activities/util/FullScreenMessageActivity.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
#include "util/BootNtpSync.h"
|
||||
#include "util/ButtonNavigator.h"
|
||||
|
||||
HalDisplay display;
|
||||
@@ -211,6 +213,7 @@ void waitForPowerRelease() {
|
||||
|
||||
// Enter deep sleep mode
|
||||
void enterDeepSleep() {
|
||||
powerManager.setPowerSaving(false);
|
||||
APP_STATE.lastSleepFromReader = currentActivity && currentActivity->isReaderActivity();
|
||||
APP_STATE.saveToFile();
|
||||
exitActivity();
|
||||
@@ -224,12 +227,13 @@ void enterDeepSleep() {
|
||||
}
|
||||
|
||||
void onGoHome();
|
||||
void onGoToMyLibraryWithPath(const std::string& path);
|
||||
void onGoToMyLibraryWithPath(const std::string& path, bool initialSkipRelease = false);
|
||||
void onGoToRecentBooks();
|
||||
void onGoToReader(const std::string& initialEpubPath) {
|
||||
const std::string bookPath = initialEpubPath; // Copy before exitActivity() invalidates the reference
|
||||
exitActivity();
|
||||
enterNewActivity(
|
||||
new ReaderActivity(renderer, mappedInputManager, initialEpubPath, onGoHome, onGoToMyLibraryWithPath));
|
||||
enterNewActivity(new ReaderActivity(renderer, mappedInputManager, bookPath, onGoHome,
|
||||
[](const std::string& p) { onGoToMyLibraryWithPath(p); }));
|
||||
}
|
||||
|
||||
void onGoToFileTransfer() {
|
||||
@@ -252,20 +256,32 @@ void onGoToRecentBooks() {
|
||||
enterNewActivity(new RecentBooksActivity(renderer, mappedInputManager, onGoHome, onGoToReader));
|
||||
}
|
||||
|
||||
void onGoToMyLibraryWithPath(const std::string& path) {
|
||||
void onGoToMyLibraryWithPath(const std::string& path, bool initialSkipRelease) {
|
||||
exitActivity();
|
||||
enterNewActivity(new MyLibraryActivity(renderer, mappedInputManager, onGoHome, onGoToReader, path));
|
||||
enterNewActivity(new MyLibraryActivity(renderer, mappedInputManager, onGoHome, onGoToReader, path, initialSkipRelease));
|
||||
}
|
||||
|
||||
void onGoToBrowser() {
|
||||
exitActivity();
|
||||
enterNewActivity(new OpdsBookBrowserActivity(renderer, mappedInputManager, onGoHome));
|
||||
const auto& servers = OPDS_STORE.getServers();
|
||||
if (servers.size() == 1) {
|
||||
enterNewActivity(new OpdsBookBrowserActivity(renderer, mappedInputManager, onGoHome, servers[0]));
|
||||
} else {
|
||||
enterNewActivity(new OpdsServerListActivity(renderer, mappedInputManager, onGoHome, [](size_t serverIndex) {
|
||||
const auto* server = OPDS_STORE.getServer(serverIndex);
|
||||
if (server) {
|
||||
exitActivity();
|
||||
enterNewActivity(new OpdsBookBrowserActivity(renderer, mappedInputManager, onGoHome, *server));
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
void onGoHome() {
|
||||
exitActivity();
|
||||
enterNewActivity(new HomeActivity(renderer, mappedInputManager, onGoToReader, onGoToMyLibrary, onGoToRecentBooks,
|
||||
onGoToSettings, onGoToFileTransfer, onGoToBrowser));
|
||||
enterNewActivity(new HomeActivity(renderer, mappedInputManager, onGoToReader, onGoToMyLibrary,
|
||||
onGoToMyLibraryWithPath, onGoToRecentBooks, onGoToSettings, onGoToFileTransfer,
|
||||
onGoToBrowser));
|
||||
}
|
||||
|
||||
void setupDisplayAndFonts() {
|
||||
@@ -340,6 +356,8 @@ void setup() {
|
||||
|
||||
I18N.loadSettings();
|
||||
KOREADER_STORE.loadFromFile();
|
||||
OPDS_STORE.loadFromFile();
|
||||
BootNtpSync::start();
|
||||
UITheme::getInstance().reload();
|
||||
ButtonNavigator::setMappedInputManager(mappedInputManager);
|
||||
|
||||
@@ -457,14 +475,23 @@ void loop() {
|
||||
// Refresh screen when the displayed minute changes (clock in header)
|
||||
if (SETTINGS.clockFormat != CrossPointSettings::CLOCK_OFF && currentActivity) {
|
||||
static int lastRenderedMinute = -1;
|
||||
static bool sawInvalidTime = false;
|
||||
time_t now = time(nullptr);
|
||||
struct tm* t = localtime(&now);
|
||||
if (t != nullptr && t->tm_year > 100) {
|
||||
const int currentMinute = t->tm_hour * 60 + t->tm_min;
|
||||
if (lastRenderedMinute >= 0 && currentMinute != lastRenderedMinute) {
|
||||
if (lastRenderedMinute < 0) {
|
||||
lastRenderedMinute = currentMinute;
|
||||
if (sawInvalidTime) {
|
||||
// Time just became valid (e.g. background NTP sync completed)
|
||||
currentActivity->requestUpdate();
|
||||
}
|
||||
} else if (currentMinute != lastRenderedMinute) {
|
||||
currentActivity->requestUpdate();
|
||||
lastRenderedMinute = currentMinute;
|
||||
}
|
||||
lastRenderedMinute = currentMinute;
|
||||
} else {
|
||||
sawInvalidTime = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
#include <algorithm>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "OpdsServerStore.h"
|
||||
#include "SettingsList.h"
|
||||
#include "html/FilesPageHtml.generated.h"
|
||||
#include "html/HomePageHtml.generated.h"
|
||||
@@ -156,6 +157,11 @@ void CrossPointWebServer::begin() {
|
||||
server->on("/api/settings", HTTP_GET, [this] { handleGetSettings(); });
|
||||
server->on("/api/settings", HTTP_POST, [this] { handlePostSettings(); });
|
||||
|
||||
// OPDS server management endpoints
|
||||
server->on("/api/opds", HTTP_GET, [this] { handleGetOpdsServers(); });
|
||||
server->on("/api/opds", HTTP_POST, [this] { handlePostOpdsServer(); });
|
||||
server->on("/api/opds/delete", HTTP_POST, [this] { handleDeleteOpdsServer(); });
|
||||
|
||||
server->onNotFound([this] { handleNotFound(); });
|
||||
LOG_DBG("WEB", "[MEM] Free heap after route setup: %d bytes", ESP.getFreeHeap());
|
||||
|
||||
@@ -1157,6 +1163,116 @@ void CrossPointWebServer::handlePostSettings() {
|
||||
server->send(200, "text/plain", String("Applied ") + String(applied) + " setting(s)");
|
||||
}
|
||||
|
||||
// ---- OPDS Server Management API ----
|
||||
|
||||
void CrossPointWebServer::handleGetOpdsServers() const {
|
||||
const auto& servers = OPDS_STORE.getServers();
|
||||
|
||||
server->setContentLength(CONTENT_LENGTH_UNKNOWN);
|
||||
server->send(200, "application/json", "");
|
||||
server->sendContent("[");
|
||||
|
||||
constexpr size_t outputSize = 512;
|
||||
char output[outputSize];
|
||||
bool first = true;
|
||||
|
||||
for (size_t i = 0; i < servers.size(); i++) {
|
||||
JsonDocument doc;
|
||||
doc["name"] = servers[i].name;
|
||||
doc["url"] = servers[i].url;
|
||||
doc["username"] = servers[i].username;
|
||||
doc["hasPassword"] = !servers[i].password.empty();
|
||||
|
||||
const size_t written = serializeJson(doc, output, outputSize);
|
||||
if (written >= outputSize) continue;
|
||||
|
||||
if (!first) server->sendContent(",");
|
||||
server->sendContent(output);
|
||||
first = false;
|
||||
}
|
||||
|
||||
server->sendContent("]");
|
||||
LOG_DBG("WEB", "Served OPDS servers API (%zu servers)", servers.size());
|
||||
}
|
||||
|
||||
void CrossPointWebServer::handlePostOpdsServer() {
|
||||
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;
|
||||
}
|
||||
|
||||
OpdsServer opdsServer;
|
||||
opdsServer.name = doc["name"] | std::string("");
|
||||
opdsServer.url = doc["url"] | std::string("");
|
||||
opdsServer.username = doc["username"] | std::string("");
|
||||
|
||||
bool hasPasswordField = doc["password"].is<const char*>() || doc["password"].is<std::string>();
|
||||
std::string password = doc["password"] | std::string("");
|
||||
|
||||
if (doc["index"].is<int>()) {
|
||||
int idx = doc["index"].as<int>();
|
||||
if (idx < 0 || idx >= static_cast<int>(OPDS_STORE.getCount())) {
|
||||
server->send(400, "text/plain", "Invalid server index");
|
||||
return;
|
||||
}
|
||||
if (!hasPasswordField) {
|
||||
const auto* existing = OPDS_STORE.getServer(static_cast<size_t>(idx));
|
||||
if (existing) password = existing->password;
|
||||
}
|
||||
opdsServer.password = password;
|
||||
OPDS_STORE.updateServer(static_cast<size_t>(idx), opdsServer);
|
||||
LOG_DBG("WEB", "Updated OPDS server at index %d", idx);
|
||||
} else {
|
||||
opdsServer.password = password;
|
||||
if (!OPDS_STORE.addServer(opdsServer)) {
|
||||
server->send(400, "text/plain", "Cannot add server (limit reached)");
|
||||
return;
|
||||
}
|
||||
LOG_DBG("WEB", "Added new OPDS server: %s", opdsServer.name.c_str());
|
||||
}
|
||||
|
||||
server->send(200, "text/plain", "OK");
|
||||
}
|
||||
|
||||
// Uses POST (not HTTP DELETE) because ESP32 WebServer doesn't support DELETE with body.
|
||||
void CrossPointWebServer::handleDeleteOpdsServer() {
|
||||
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;
|
||||
}
|
||||
|
||||
if (!doc["index"].is<int>()) {
|
||||
server->send(400, "text/plain", "Missing index");
|
||||
return;
|
||||
}
|
||||
|
||||
int idx = doc["index"].as<int>();
|
||||
if (idx < 0 || idx >= static_cast<int>(OPDS_STORE.getCount())) {
|
||||
server->send(400, "text/plain", "Invalid server index");
|
||||
return;
|
||||
}
|
||||
|
||||
OPDS_STORE.removeServer(static_cast<size_t>(idx));
|
||||
LOG_DBG("WEB", "Deleted OPDS server at index %d", idx);
|
||||
server->send(200, "text/plain", "OK");
|
||||
}
|
||||
|
||||
// WebSocket callback trampoline
|
||||
void CrossPointWebServer::wsEventCallback(uint8_t num, WStype_t type, uint8_t* payload, size_t length) {
|
||||
if (wsInstance) {
|
||||
|
||||
@@ -105,4 +105,9 @@ class CrossPointWebServer {
|
||||
void handleSettingsPage() const;
|
||||
void handleGetSettings() const;
|
||||
void handlePostSettings();
|
||||
|
||||
// OPDS server handlers
|
||||
void handleGetOpdsServers() const;
|
||||
void handlePostOpdsServer();
|
||||
void handleDeleteOpdsServer();
|
||||
};
|
||||
|
||||
@@ -9,12 +9,49 @@
|
||||
|
||||
#include <cstring>
|
||||
#include <memory>
|
||||
#include <utility>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "util/UrlUtils.h"
|
||||
|
||||
bool HttpDownloader::fetchUrl(const std::string& url, Stream& outContent) {
|
||||
// Use WiFiClientSecure for HTTPS, regular WiFiClient for HTTP
|
||||
namespace {
|
||||
class FileWriteStream final : public Stream {
|
||||
public:
|
||||
FileWriteStream(FsFile& file, size_t total, HttpDownloader::ProgressCallback progress)
|
||||
: file_(file), total_(total), progress_(std::move(progress)) {}
|
||||
|
||||
size_t write(uint8_t byte) override { return write(&byte, 1); }
|
||||
|
||||
size_t write(const uint8_t* buffer, size_t size) override {
|
||||
const size_t written = file_.write(buffer, size);
|
||||
if (written != size) {
|
||||
writeOk_ = false;
|
||||
}
|
||||
downloaded_ += written;
|
||||
if (progress_ && total_ > 0) {
|
||||
progress_(downloaded_, total_);
|
||||
}
|
||||
return written;
|
||||
}
|
||||
|
||||
int available() override { return 0; }
|
||||
int read() override { return -1; }
|
||||
int peek() override { return -1; }
|
||||
void flush() override { file_.flush(); }
|
||||
|
||||
size_t downloaded() const { return downloaded_; }
|
||||
bool ok() const { return writeOk_; }
|
||||
|
||||
private:
|
||||
FsFile& file_;
|
||||
size_t total_;
|
||||
size_t downloaded_ = 0;
|
||||
bool writeOk_ = true;
|
||||
HttpDownloader::ProgressCallback progress_;
|
||||
};
|
||||
} // namespace
|
||||
|
||||
bool HttpDownloader::fetchUrl(const std::string& url, Stream& outContent, const std::string& username,
|
||||
const std::string& password) {
|
||||
std::unique_ptr<WiFiClient> client;
|
||||
if (UrlUtils::isHttpsUrl(url)) {
|
||||
auto* secureClient = new WiFiClientSecure();
|
||||
@@ -31,9 +68,8 @@ bool HttpDownloader::fetchUrl(const std::string& url, Stream& outContent) {
|
||||
http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS);
|
||||
http.addHeader("User-Agent", "CrossPoint-ESP32-" CROSSPOINT_VERSION);
|
||||
|
||||
// Add Basic HTTP auth if credentials are configured
|
||||
if (strlen(SETTINGS.opdsUsername) > 0 && strlen(SETTINGS.opdsPassword) > 0) {
|
||||
std::string credentials = std::string(SETTINGS.opdsUsername) + ":" + SETTINGS.opdsPassword;
|
||||
if (!username.empty() && !password.empty()) {
|
||||
std::string credentials = username + ":" + password;
|
||||
String encoded = base64::encode(credentials.c_str());
|
||||
http.addHeader("Authorization", "Basic " + encoded);
|
||||
}
|
||||
@@ -53,9 +89,10 @@ bool HttpDownloader::fetchUrl(const std::string& url, Stream& outContent) {
|
||||
return true;
|
||||
}
|
||||
|
||||
bool HttpDownloader::fetchUrl(const std::string& url, std::string& outContent) {
|
||||
bool HttpDownloader::fetchUrl(const std::string& url, std::string& outContent, const std::string& username,
|
||||
const std::string& password) {
|
||||
StreamString stream;
|
||||
if (!fetchUrl(url, stream)) {
|
||||
if (!fetchUrl(url, stream, username, password)) {
|
||||
return false;
|
||||
}
|
||||
outContent = stream.c_str();
|
||||
@@ -63,8 +100,8 @@ bool HttpDownloader::fetchUrl(const std::string& url, std::string& outContent) {
|
||||
}
|
||||
|
||||
HttpDownloader::DownloadError HttpDownloader::downloadToFile(const std::string& url, const std::string& destPath,
|
||||
ProgressCallback progress) {
|
||||
// Use WiFiClientSecure for HTTPS, regular WiFiClient for HTTP
|
||||
ProgressCallback progress, const std::string& username,
|
||||
const std::string& password) {
|
||||
std::unique_ptr<WiFiClient> client;
|
||||
if (UrlUtils::isHttpsUrl(url)) {
|
||||
auto* secureClient = new WiFiClientSecure();
|
||||
@@ -82,9 +119,8 @@ HttpDownloader::DownloadError HttpDownloader::downloadToFile(const std::string&
|
||||
http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS);
|
||||
http.addHeader("User-Agent", "CrossPoint-ESP32-" CROSSPOINT_VERSION);
|
||||
|
||||
// Add Basic HTTP auth if credentials are configured
|
||||
if (strlen(SETTINGS.opdsUsername) > 0 && strlen(SETTINGS.opdsPassword) > 0) {
|
||||
std::string credentials = std::string(SETTINGS.opdsUsername) + ":" + SETTINGS.opdsPassword;
|
||||
if (!username.empty() && !password.empty()) {
|
||||
std::string credentials = username + ":" + password;
|
||||
String encoded = base64::encode(credentials.c_str());
|
||||
http.addHeader("Authorization", "Basic " + encoded);
|
||||
}
|
||||
@@ -96,8 +132,13 @@ HttpDownloader::DownloadError HttpDownloader::downloadToFile(const std::string&
|
||||
return HTTP_ERROR;
|
||||
}
|
||||
|
||||
const size_t contentLength = http.getSize();
|
||||
LOG_DBG("HTTP", "Content-Length: %zu", contentLength);
|
||||
const int64_t reportedLength = http.getSize();
|
||||
const size_t contentLength = reportedLength > 0 ? static_cast<size_t>(reportedLength) : 0;
|
||||
if (contentLength > 0) {
|
||||
LOG_DBG("HTTP", "Content-Length: %zu", contentLength);
|
||||
} else {
|
||||
LOG_DBG("HTTP", "Content-Length: unknown");
|
||||
}
|
||||
|
||||
// Remove existing file if present
|
||||
if (Storage.exists(destPath.c_str())) {
|
||||
@@ -112,56 +153,29 @@ HttpDownloader::DownloadError HttpDownloader::downloadToFile(const std::string&
|
||||
return FILE_ERROR;
|
||||
}
|
||||
|
||||
// Get the stream for chunked reading
|
||||
WiFiClient* stream = http.getStreamPtr();
|
||||
if (!stream) {
|
||||
LOG_ERR("HTTP", "Failed to get stream");
|
||||
file.close();
|
||||
Storage.remove(destPath.c_str());
|
||||
http.end();
|
||||
return HTTP_ERROR;
|
||||
}
|
||||
|
||||
// Download in chunks
|
||||
uint8_t buffer[DOWNLOAD_CHUNK_SIZE];
|
||||
size_t downloaded = 0;
|
||||
// Let HTTPClient handle chunked decoding and stream body bytes into the file.
|
||||
const size_t total = contentLength > 0 ? contentLength : 0;
|
||||
|
||||
while (http.connected() && (contentLength == 0 || downloaded < contentLength)) {
|
||||
const size_t available = stream->available();
|
||||
if (available == 0) {
|
||||
delay(1);
|
||||
continue;
|
||||
}
|
||||
|
||||
const size_t toRead = available < DOWNLOAD_CHUNK_SIZE ? available : DOWNLOAD_CHUNK_SIZE;
|
||||
const size_t bytesRead = stream->readBytes(buffer, toRead);
|
||||
|
||||
if (bytesRead == 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
const size_t written = file.write(buffer, bytesRead);
|
||||
if (written != bytesRead) {
|
||||
LOG_ERR("HTTP", "Write failed: wrote %zu of %zu bytes", written, bytesRead);
|
||||
file.close();
|
||||
Storage.remove(destPath.c_str());
|
||||
http.end();
|
||||
return FILE_ERROR;
|
||||
}
|
||||
|
||||
downloaded += bytesRead;
|
||||
|
||||
if (progress && total > 0) {
|
||||
progress(downloaded, total);
|
||||
}
|
||||
}
|
||||
FileWriteStream fileStream(file, total, progress);
|
||||
http.writeToStream(&fileStream);
|
||||
|
||||
file.close();
|
||||
http.end();
|
||||
|
||||
const size_t downloaded = fileStream.downloaded();
|
||||
LOG_DBG("HTTP", "Downloaded %zu bytes", downloaded);
|
||||
|
||||
if (!fileStream.ok()) {
|
||||
LOG_ERR("HTTP", "Write failed during download");
|
||||
Storage.remove(destPath.c_str());
|
||||
return FILE_ERROR;
|
||||
}
|
||||
|
||||
if (contentLength == 0 && downloaded == 0) {
|
||||
LOG_ERR("HTTP", "Download failed: no data received");
|
||||
Storage.remove(destPath.c_str());
|
||||
return HTTP_ERROR;
|
||||
}
|
||||
|
||||
// Verify download size if known
|
||||
if (contentLength > 0 && downloaded != contentLength) {
|
||||
LOG_ERR("HTTP", "Size mismatch: got %zu, expected %zu", downloaded, contentLength);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user