Compare commits

..
Author SHA1 Message Date
Justin Mitchell 79f5657fb2 Add configurable default home screen action
Themes can now specify an initialAction in home screen config to set the default selected action when entering home normally. Falls back to this theme setting when no explicit action is requested, but explicit firmware navigation still takes precedence.
2026-06-28 02:38:53 -04:00
Justin Mitchell 48aa3c8e02 Add SD theme system
Adds installable SD-card themes with manifest downloads, theme registry parsing, themed home/chrome/settings/file browser support, FreeInk layout integration, theme documentation, and layout tests.
2026-06-28 01:44:38 -04:00
Bastian cbaa498ccc docs: adding quick resume option and quick resume on timeout to userguide (#2425) 2026-06-27 22:40:21 +03:00
Justin Mitchell ebebc6f202 chore: migrate from open-x4-sdk to freeink-sdk (#2449)
This PR moves us from the xteink openx4 SDK to the freeink sdk from
https://freeink.org. Out of the box there are NO changes needed in the
firmware to support this swap, it all magically works as is. However as
we support more than just the x3/x4 devices, this sdk allows us to pass
env vars into the build commands to include support for other devices.
As support for new hardware such as touch screens and bluetooth are
added the xteink builds decide at compile time if the libraries are used
or not. For example right now the freeinkui and icons libraries are in
the platform.io file but as they are not used anywhere, they won't be
included in the final build. Once the touch branch and sd themes branch
are merged in this sdk is required for them to function correctly. All
the docs for freeink are available at freeink.org/docs. x4/x3 is a
single binary build unlike other devices that will build unique binaries
for each device. Eventually we will want to remove a lot of the manual
isx3 type stuff from our firmware and go through the boardsupport api
the sdk provides as it will generalize everything into one common system
that any device can support. The upcoming touch branch does a lot of
this for us but this initial PR is JUST to get the sdk swapped over
without any code changes to show seamless integration without any
regressions.
2026-06-27 21:22:58 +03:00
Julia 970b2c6ca1 chore: release 1.4.1 (#2447)
Compile Release / build-release (push) Canceled after 0s
## Improvements

* Moved the File Manager breadcrumb into the Contents card header for a
cleaner, more consistent interface.
* Updated the Wireless Transfer section of the User Guide with clearer
instructions.
* Battery status bar indicator no longer changes position when adding or
removing bookmarks.

## Performance

* Optimized path normalization for faster file handling.
* Significantly improved bookmark rendering by removing unnecessary
XPath lookups.
* Optimized dithered rectangle drawing (fillRectDither) using a
byte-aligned rendering implementation, improving display performance on
supported devices.

## Bug Fixes

* Fixed an issue where the Inverted Orientation label was incorrectly
combined with the Color Filter label for Geman localization.
* Fixed excessive ghosting on the X3 cover screen during sleep
2026-06-26 17:40:04 -04:00
Julia Nguyen b6ce599b20 fix: address release review feedback 2026-06-26 16:22:20 -04:00
Juliaandcoderabbitai[bot] f54eab2725 fix: typo in STR_ORIENTATION_INVERTED Spanish translation
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-06-26 16:13:19 -04:00
Justin Mitchell 0d4c9ab91b Bump version to 1.4.1 2026-06-26 15:48:19 -04:00
Julia 0daa9db243 fix: keep status bar indicators stable when toggling bookmarks (#2444) 2026-06-26 19:26:33 +03:00
Uri TauberandRyan Mercado a2f2eea79e perf: Optimize fillRectDither with Byte-Aligned fillRectImpl (#2270)
## Summary

* **What is the goal of this PR?**
Replace the pixel-by-pixel `fillRectDither` implementation with a new
byte-aligned `fillRectImpl` that eliminates per-pixel
`rotateCoordinates` calls and Read-Modify-Write bitwise loops, yielding
a significant rendering speedup on ESP32 E-ink framebuffers.

Ported from @rhythmerc's crosspoint-reader fork commit 27ea625.

* **What changes are included?**

- **`GfxRenderer.cpp` — `fillRectDither` refactor:** The existing
`if/else if` chain is replaced with a `switch` statement that delegates
each `Color` case to the new `fillRectImpl<Color>()` template,
eliminating runtime branching.

- **`GfxRenderer.cpp` — new `fillRectImpl<C>()` template:** Core of the
optimization. Key behaviors:
    - Clips the rectangle in logical space upfront.
- Rotates only **2 opposing corner points** (top-left and bottom-right)
into physical framebuffer space instead of rotating every pixel
individually.
- Derives physical-space `byteStart`/`byteEnd` and precomputes
`headMask` / `tailMask` for MSB-first partial-byte boundaries,
performing RMW only on the edge bytes.
- **Solid fills (`Black` / `White`):** Uses `memset` for all interior
full-byte runs per row — no per-pixel writes.
- **Dithered fills (`LightGray` / `DarkGray`):** Precomputes both parity
variants of `blackMask` (even/odd `py`) **outside** the row loop,
eliminating the previously re-evaluated 8-bit construction loop on every
physical row. Interior full bytes are then written with a single
`memset(whiteMask)`.
- Uses `if constexpr` throughout to dispatch on `Color` at compile time,
generating zero runtime branches per template instantiation.

- **`GfxRenderer.h`:** Declares the new private `fillRectImpl<Color>()`
template method with an explanatory doc-comment.

- **Explicit template instantiations** added for all four active `Color`
variants (`Black`, `White`, `LightGray`, `DarkGray`).

## Additional Context

* **Performance:** The primary motivation is ESP32 E-ink framebuffer
performance. The old path called `rotateCoordinates` and did a full RMW
for every single pixel in the rectangle. The new path calls
`rotateCoordinates` exactly **twice** per fill regardless of rectangle
size, then operates at byte granularity — a complexity reduction from
O(W×H) coordinate transforms to O(1).
* **Dither correctness:** The `blackMask` precomputation relies on the
dither pattern having period 2 in both logical X and Y, which makes the
per-row byte pattern repeat with period 2 in `py`. Reviewers should
verify the `lxBase`/`lyBase` derivations for all four orientations
(`Portrait`, `PortraitInverted`, `LandscapeClockwise`,
`LandscapeCounterClockwise`) match the inverse of `rotateCoordinates`.
* **Edge case — single-byte rows:** When `byteStart == byteEnd`, the
head and tail masks are ANDed together into a single `rectMask` to avoid
double-masking the same byte. This path should be tested with narrow
rectangles (width < 8px).
* **No behavioral change for `Color::Clear`:** The `Clear` case exits
early via `if constexpr` and is a no-op, matching the original behavior.

---

### AI Usage

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

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

---------

Co-authored-by: Ryan Mercado <rmercado@firstdollar.com>
2026-06-26 12:16:25 -04:00
Ankit 86a9b9c4a2 docs: update user guide wireless transfer section (#2369)
Co-authored-by: Uri Tauber <uritaube@gmail.com>
Closes #1407
2026-06-26 08:25:52 +03:00
Julia Nguyen ba44978ac4 Merge branch 'master' into develop 2026-06-25 17:51:56 -04:00
Pietro Campagnano 555f76da88 feat: move file manager breadcrumb into contents card header (#2430) 2026-06-26 00:45:43 +03:00
Justin Mitchell 0a57c0a5a7 Fix X3 display ghosting on cover screen transitions
Force display resync on X3 when HALF refresh is requested to clear prior content before rendering. Add grayscale preconditioning for X3's UC81xx controller to even out single-pixel dithering artifacts that appear as speckle with its turbo BW waveform.
2026-06-25 17:42:19 -04:00
Uri Tauber a09aef0889 fix: Optimize Bookmark Rendering by Removing XPath Lookup (#2417)
## Summary

fix #2414 

### Root Cause

`updateBookmarkFlag()` was introduced in commit 1db1442 and is executed
on every render (every page turn). The function calls:

`ProgressMapper::toSavedProgress()`
→ `ChapterXPathResolver::findXPathForProgress()`

This path decompresses the current EPUB section content twice:

1. To count visible characters.
2. To resolve the corresponding XPath.

For larger sections (e.g. ~133 KB decompressed content), this adds
approximately **1 second of I/O overhead per page turn**, with the cost
increasing as chapter size grows.

### Fix

`updateBookmarkFlag()` only needs to determine whether a bookmark falls
within the currently displayed page range.

The required information is already available during rendering:

* `currentPage`
* `section->pageCount`
* `currentSpineIndex`

Instead of converting the current location to a saved progress object
(and resolving an XPath), the implementation now computes the current
page's progress range directly and compares bookmark percentages against
that range.

This is effectively the same percentage-based matching logic already
used as a fallback in `bookmarkMatchesProgress()` when XPath matching is
unavailable.

---

### AI Usage

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

Did you use AI tools to help write this code? _**< PARTIALLY >**_
2026-06-25 09:20:24 -04:00
Bastian 8626d69f46 fix: small translation changes for german (#2420) 2026-06-25 09:29:05 +03:00
Uri Tauber fc89e57e69 perf: optimise normalisePath (#2162) 2026-06-25 09:01:50 +03:00
Julia 487613b082 fix: split inverted orientation label from color filter label (#2421)
## Summary

* **What is the goal of this PR?** 
* Fix the “Inverted” translation so reader orientation and color/filter
inversion can use separate labels.
* **What changes are included?**
* Adds `STR_ORIENTATION_INVERTED` for the inverted portrait orientation
option.
* Updates the reader orientation setting to use
`STR_ORIENTATION_INVERTED` instead of reusing `STR_INVERTED`.
* Leaves `STR_INVERTED` for the sleep cover filter and tilt page-turn
mode
  * Adds the new orientation string across all 26 locale YAML files.

## Additional Context

* The original issue was found by a user in German, where `STR_INVERTED`
was translated as `Hochformat 180°`, which made sense for orientation
but not for color filters.
* This is a UI-label-only change. It does not change persisted
orientation values or settings behavior.
* Reviewer note: non-English wording may still benefit from
native-speaker review, especially for the new orientation-specific
labels and to verify the interchangeable usage between inverted color
and inverted tilt page turn direction.
---

### AI Usage

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

Did you use AI tools to help write this code? _**< YES >**_
2026-06-25 00:47:06 -04:00
JuliaandHusam Younis 8d5b119644 fix: sync master into develop (#2423)
Sync develop branch with master

Co-authored-by: Husam Younis <youhusam@gmail.com>
2026-06-24 22:07:13 -04:00
Husam Younis 7271c00d35 feat: Allow statusbar clock to be on the left (#2359) 2026-06-25 00:23:23 +03:00
84 changed files with 6810 additions and 490 deletions
+4 -3
View File
@@ -1,3 +1,4 @@
[submodule "open-x4-sdk"] [submodule "freeink-sdk"]
path = open-x4-sdk path = freeink-sdk
url = https://github.com/crosspoint-reader/community-sdk.git url = https://github.com/Free-Ink/freeink-sdk.git
branch = main
+2 -2
View File
@@ -7,7 +7,7 @@ Mission: Provide a lightweight, high-performance reading experience focused on E
* Role: Senior Embedded Systems Engineer (ESP-IDF/Arduino-ESP32 specialized). * Role: Senior Embedded Systems Engineer (ESP-IDF/Arduino-ESP32 specialized).
* Primary Constraint: 380KB RAM is the hard ceiling. Stability is non-negotiable. * Primary Constraint: 380KB RAM is the hard ceiling. Stability is non-negotiable.
* Evidence-Based Reasoning: Before proposing a change, you MUST cite the specific file path and line numbers that justify the modification. * Evidence-Based Reasoning: Before proposing a change, you MUST cite the specific file path and line numbers that justify the modification.
* Anti-Hallucination: Do not assume the existence of libraries or ESP-IDF functions. If you are unsure of an API's availability for the ESP32-C3 RISC-V target, check the open-x4-sdk or official docs first. * Anti-Hallucination: Do not assume the existence of libraries or ESP-IDF functions. If you are unsure of an API's availability for the ESP32-C3 RISC-V target, check the freeink-sdk source or the FreeInk SDK docs (https://freeink.org/llms.txt for an LLM-readable index) first.
* No Unfounded Claims: Do not claim performance gains or memory savings without explaining the technical mechanism (e.g., DRAM vs IRAM usage). * No Unfounded Claims: Do not claim performance gains or memory savings without explaining the technical mechanism (e.g., DRAM vs IRAM usage).
* Resource Justification: You must justify any new heap allocation (new, malloc, std::vector) or explain why a stack/static alternative was rejected. * Resource Justification: You must justify any new heap allocation (new, malloc, std::vector) or explain why a stack/static alternative was rejected.
* Verification: After suggesting a fix, instruct the user on how to verify it (e.g., monitoring heap via Serial or checking a specific cache file). * Verification: After suggesting a fix, instruct the user on how to verify it (e.g., monitoring heap via Serial or checking a specific cache file).
@@ -127,7 +127,7 @@ These flags in `platformio.ini` fundamentally affect firmware behavior:
* lib/hal/: Hardware Abstraction Layer (HalDisplay, HalGPIO, HalStorage) * lib/hal/: Hardware Abstraction Layer (HalDisplay, HalGPIO, HalStorage)
* lib/I18n/: Internationalization (translations in `translations/*.yaml`, generated string tables) * lib/I18n/: Internationalization (translations in `translations/*.yaml`, generated string tables)
* src/activities/: UI logic using the Activity Lifecycle (onEnter, loop, onExit) * src/activities/: UI logic using the Activity Lifecycle (onEnter, loop, onExit)
* open-x4-sdk/: Low-level SDK (EInkDisplay, InputManager, BatteryMonitor, SDCardManager) * freeink-sdk/: Low-level SDK (EInkDisplay, InputManager, BatteryMonitor, SDCardManager)
* .crosspoint/: SD-based binary cache for EPUB metadata and pre-rendered layout sections * .crosspoint/: SD-based binary cache for EPUB metadata and pre-rendered layout sections
### Hardware Abstraction Layer (HAL) ### Hardware Abstraction Layer (HAL)
+18
View File
@@ -131,6 +131,23 @@ Convert your own TTF/OTF files into `.cpfont` files that load from the SD card.
Conversion runs the firmware repo's `lib/EpdFont/scripts/fontconvert_sdcard.py` script unmodified, so output matches a local host build. Conversion runs the firmware repo's `lib/EpdFont/scripts/fontconvert_sdcard.py` script unmodified, so output matches a local host build.
## Custom SD-card themes
Downloadable themes are packaged in the tools repo under `../crosspoint-tools/public/themes/<theme-id>/`. Each theme folder must contain a `theme.json`; optional assets such as generated BMP icons live beside it, usually under `icons/`.
See [SD-card theme creation](./docs/theme-creation.md) for the full JSON format, device-specific overrides, icon generation, CrossInk extension fields, and packaging rules.
After adding or changing a hosted theme, regenerate the download manifest:
```bash
python3 scripts/generate-theme-manifest.py \
--root ../crosspoint-tools/public/themes \
--base-url http://crosspointreader.com/themes \
--output ../crosspoint-tools/public/themes/themes.json
```
The script scans every theme folder, includes every file in each package, and writes size and CRC32 values used by the device downloader. Commit changed theme package files and the regenerated `themes.json` in `crosspoint-tools`.
--- ---
## Documentation ## Documentation
@@ -138,6 +155,7 @@ Conversion runs the firmware repo's `lib/EpdFont/scripts/fontconvert_sdcard.py`
- [User Guide](./USER_GUIDE.md) - [User Guide](./USER_GUIDE.md)
- [Web server usage](./docs/webserver.md) - [Web server usage](./docs/webserver.md)
- [Web server endpoints](./docs/webserver-endpoints.md) - [Web server endpoints](./docs/webserver-endpoints.md)
- [SD-card theme creation](./docs/theme-creation.md)
- [Project scope](./SCOPE.md) - [Project scope](./SCOPE.md)
- [Contributing docs](./docs/contributing/README.md) - [Contributing docs](./docs/contributing/README.md)
+37 -10
View File
@@ -122,19 +122,43 @@ A **Wi-Fi signal strength indicator** (dBm) is displayed on-screen during joined
CrossPoint supports sending books from Calibre using the CrossPoint Reader device plugin. CrossPoint supports sending books from Calibre using the CrossPoint Reader device plugin.
1. Install the plugin in Calibre: #### Installing the Plugin in Calibre
- Head to https://github.com/crosspoint-reader/calibre-plugins/releases to download the latest version of the crosspoint_reader plugin.
- Download the zip file.
- Open Calibre → Preferences → Plugins → Load plugin from file → Select the zip file.
2. On the device: File Transfer -> Calibre Wireless, then join a network. If you don't already have the plugin installed:
3. Make sure your computer is on the same Wi-Fi network. 1. Head to https://github.com/crosspoint-reader/calibre-plugins/releases to download the latest version of the crosspoint_reader plugin.
2. Download the zip file.
3. Open Calibre → Preferences → Plugins → Load plugin from file → Select the zip file.
4. Restart Calibre.
4. In Calibre, click "Send to device" to transfer books. #### Configuring the CrossPoint Plugin in Calibre
1. In Calibre select Preferences.
2. In the Preferences dialog select Plugins.
3. In Plugins search for "crosspoint".
4. Click on "Customize plugin".
5. Update the value for "Host" to match the IP for your device.
6. Leave the other settings as they are.
7. [optional] Modify the "Upload path" to point to a subfolder other than the root "/" folder. Enter this as a path relative to the root folder. Example: `/mybooks`
8. Restart Calibre.
<img width="420" height="385" alt="Image" src="https://github.com/user-attachments/assets/01fc7e33-a9a7-48ba-9e26-2e68d1f9daec" />
#### Uploading Books
To upload a book using the CrossPoint plugin in Calibre:
1. On the device: File Transfer -> Calibre Wireless, then join a network.
2. Select one or more books.
3. Right-click on that selection.
4. Select "Send to Device" > "Send to main memory"
The CrossPoint plugin will connect to your device, create a folder for the book's author in the root folder (or the folder you configured for the plugin), then copy the book into that folder.
<img width="783" height="310" alt="Image" src="https://github.com/user-attachments/assets/741b0909-2e1d-4f16-8af0-2c43fbda5ce6" />
#### Removing a Book
Books cannot be removed from your device through Calibre. Use the web interface instead.
### 3.6 Settings ### 3.6 Settings
@@ -150,6 +174,7 @@ The Settings screen allows you to configure the device's behavior. There are a f
- "Cover" - The book cover image (Note: this is experimental and may not work as expected) - "Cover" - The book cover image (Note: this is experimental and may not work as expected)
- "None" - A blank screen - "None" - A blank screen
- "Cover + Custom" - The book cover image while actively reading, falls back to "Custom" behavior otherwise - "Cover + Custom" - The book cover image while actively reading, falls back to "Custom" behavior otherwise
- "Quick resume" - The text of the last page read will be displayed on the sleep screen and a moon icon is shown on the edge of the screen. Waking up the device will return to the same page of the opened book. This is useful for quickly resuming reading without waiting for the device to fully wake up and load the book.
- **Sleep Screen Cover Mode**: How to display the book cover when "Cover" sleep screen is selected: - **Sleep Screen Cover Mode**: How to display the book cover when "Cover" sleep screen is selected:
@@ -162,6 +187,8 @@ The Settings screen allows you to configure the device's behavior. There are a f
- "Contrast" - The image will be displayed as a black & white image without grayscale conversion - "Contrast" - The image will be displayed as a black & white image without grayscale conversion
- "Inverted" - The image will be inverted as in white & black and will be displayed without grayscale conversion - "Inverted" - The image will be inverted as in white & black and will be displayed without grayscale conversion
- **Quick Resume on Timeout**: Whether to enable the "Quick Resume" sleep screen when the device goes to sleep due to inactivity (System > Time to Sleep). This is useful for quickly resuming reading without waiting for the device to fully wake up and load the book. This overwrites the Sleep Screen Cover Mode when enabled.
- **Status Bar**: Configure the status bar displayed while reading: - **Status Bar**: Configure the status bar displayed while reading:
- "None" - No status bar - "None" - No status bar
+2 -2
View File
@@ -4,7 +4,7 @@
.DESCRIPTION .DESCRIPTION
Formats all C/C++ source and header files in the repository, excluding Formats all C/C++ source and header files in the repository, excluding
generated, vendored, and build directories (open-x4-sdk, builtinFonts, generated, vendored, and build directories (freeink-sdk, builtinFonts,
hyphenation tries, uzlib, .pio, *.generated.h). hyphenation tries, uzlib, .pio, *.generated.h).
The clang-format binary path is resolved once and cached in The clang-format binary path is resolved once and cached in
@@ -92,7 +92,7 @@ function Resolve-ClangFormat {
$clangFormat = Resolve-ClangFormat $clangFormat = Resolve-ClangFormat
$exclude = @( $exclude = @(
'open-x4-sdk' 'freeink-sdk'
'lib\EpdFont\builtinFonts' 'lib\EpdFont\builtinFonts'
'lib\Epub\Epub\hyphenation\generated' 'lib\Epub\Epub\hyphenation\generated'
'lib\uzlib' 'lib\uzlib'
+3 -3
View File
@@ -8,7 +8,7 @@ At a high level, it is firmware that uses an activity-driven application archite
```mermaid ```mermaid
graph TD graph TD
A[Hardware: ESP32-C3 + SD + E-ink + Buttons] --> B[open-x4-sdk] A[Hardware: ESP32-C3 + SD + E-ink + Buttons] --> B[freeink-sdk]
B --> C[lib/hal wrappers] B --> C[lib/hal wrappers]
C --> D[src/main.cpp runtime loop] C --> D[src/main.cpp runtime loop]
D --> E[Activities layer] D --> E[Activities layer]
@@ -195,10 +195,10 @@ When editing related source assets, regenerate via normal build steps/scripts.
- `src/`: app orchestration, settings/state, and activity implementations - `src/`: app orchestration, settings/state, and activity implementations
- `src/network/`: web server and OTA/update networking - `src/network/`: web server and OTA/update networking
- `src/components/`: theming and shared UI components - `src/components/`: theming and shared UI components
- `lib/hal/`: hardware abstraction wrappers around open-x4-sdk - `lib/hal/`: hardware abstraction wrappers around freeink-sdk
- `lib/Epub/`: EPUB parser, layout, CSS handling, and hyphenation - `lib/Epub/`: EPUB parser, layout, CSS handling, and hyphenation
- `lib/`: supporting libraries (fonts, text, filesystem helpers, etc.) - `lib/`: supporting libraries (fonts, text, filesystem helpers, etc.)
- `open-x4-sdk/`: hardware SDK submodule (display, input, storage, battery) - `freeink-sdk/`: hardware SDK submodule (display, input, storage, battery). Docs: https://freeink.org/docs
- `docs/`: user and technical documentation - `docs/`: user and technical documentation
## Embedded constraints that shape design ## Embedded constraints that shape design
+889
View File
@@ -0,0 +1,889 @@
# SD-card theme creation
CrossPoint ships one built-in base theme, Lyra. Additional themes live on the SD card and are selected from Settings. A downloaded theme is just a folder containing a `theme.json` and optional assets such as 1-bit BMP icons.
CrossPoint ignores unknown JSON fields. Other readers, such as CrossInk, can add their own fields under a namespaced object like `extensions.crossink` without breaking CrossPoint.
## Folder layout
Manual install paths:
```text
/.themes/<theme-id>/theme.json # hidden folder used by the downloader
/themes/<theme-id>/theme.json # visible folder for manual installs
```
Hosted theme packages live in the tools repo under:
```text
../crosspoint-tools/public/themes/<theme-id>/theme.json
../crosspoint-tools/public/themes/<theme-id>/icons/*.bmp
```
Theme ids must be path-safe: letters, numbers, `-`, and `_` only. Spaces are not accepted because ids are used in folder names, URLs, and settings.
## Minimal theme
```json
{
"schema": 1,
"id": "my-theme",
"name": "My Theme",
"description": "Short user-facing description shown in the downloader.",
"inherits": "lyra",
"metrics": {
"homeTopPadding": 48,
"menuRowHeight": 42
},
"components": {
"homeMenu": {
"font": "medium",
"style": "regular",
"centeredText": true,
"selectionStyle": "underline",
"showIcons": false
}
},
"devices": {
"x3": {
"constraints": {
"screenWidth": 480,
"screenHeight": 800,
"frontButtons": 4,
"sideButtons": "up-down"
}
},
"x4": {
"constraints": {
"screenWidth": 480,
"screenHeight": 800,
"frontButtons": 0,
"sideButtons": "up-down"
}
}
}
}
```
Top-level fields:
- `schema`: currently `1`.
- `id`: stable id used for settings, folder name, and downloads.
- `name`: display name shown in Settings and the downloader.
- `description`: short downloader text.
- `inherits`: `lyra` for normal SD themes. `classic` is accepted for manually installed themes that intentionally build from the Classic renderer.
- `metrics`: layout numbers shared across screens.
- `components`: style rules for themeable UI surfaces.
- `assets.icons`: optional icon file map.
- `devices`: optional per-device overrides keyed by `x3` or `x4`.
- `requires`: optional metadata for other tooling. CrossPoint currently ignores it.
- `extensions`: optional namespaced metadata for other firmware/apps. CrossPoint currently ignores it.
## Device overrides
The active device id is `x3` or `x4`. Any supported field under `devices.<device-id>` overrides the top-level value:
```json
{
"metrics": {
"homeCoverHeight": 300
},
"components": {
"homeRecents": {
"maxBooks": 3
}
},
"devices": {
"x3": {
"metrics": {
"homeCoverHeight": 280
},
"components": {
"homeRecents": {
"maxBooks": 3
}
}
}
}
}
```
Use `constraints` to document intended screen and button assumptions for builders and compatible apps:
```json
"constraints": {
"screenWidth": 480,
"screenHeight": 800,
"frontButtons": 4,
"sideButtons": "up-down"
}
```
CrossPoint parses these constraints but does not reject themes when they do not match.
## Metrics
Metrics tune global spacing and layout. Any omitted metric keeps Lyra's default.
Common home/list metrics:
- `topPadding`: top inset above normal page headers.
- `headerHeight`: default header band height for non-home screens.
- `verticalSpacing`: default vertical gap between major screen regions.
- `contentSidePadding`: left/right inset used by default list and menu renderers.
- `listRowHeight`: row height for single-line lists.
- `listWithSubtitleRowHeight`: row height for two-line lists such as Recent Books.
- `menuRowHeight`: height of one home menu tile. In `launcherGrid`, this is used by `drawButtonMenu` inside each grid cell; it is not the gap between grid cells.
- `menuSpacing`: vertical spacing between items when rendering a plain one-column home menu. It does not affect `launcherGrid`, because each grid cell is rendered as a one-item menu.
- `tabSpacing`: spacing between tab labels.
- `tabBarHeight`: height of the settings tab bar.
- `scrollBarWidth`: list scrollbar width.
- `scrollBarRightOffset`: list scrollbar inset from the right edge.
- `homeTopPadding`: top inset before the home cover/recent-books area in the legacy home renderer.
- `homeCoverHeight`: cover image height used by home recents.
- `homeCoverTileHeight`: total home recents tile/slot height, including cover title space when applicable.
- `homeRecentBooksCount`: number of recent books to request/render on home.
- `homeContinueReadingInMenu`: whether Continue Reading is part of the home launcher/menu actions.
- `homeShowContinueReadingHeader`: whether the current book title can appear in the home header.
- `homeMenuTopOffset`: legacy/manual home menu offset below the cover area. SD `screens.home.layout` themes should prefer explicit layout slots such as `carouselMenuGap`.
- `buttonHintsHeight`: bottom button-hint band height.
- `sideButtonHintsWidth`: side button-hint band width.
Other supported metric groups:
- Battery: `batteryWidth`, `batteryHeight`, `batteryBarHeight`
- Reader progress/status: `progressBarHeight`, `progressBarMarginTop`, `statusBarHorizontalMargin`, `statusBarVerticalMargin`
- Keyboard: `keyboardKeyWidth`, `keyboardKeyHeight`, `keyboardKeySpacing`, `keyboardBottomKeyHeight`, `keyboardBottomKeySpacing`, `keyboardBottomAligned`, `keyboardCenteredText`, `keyboardVerticalOffset`, `keyboardTextFieldWidthPercent`, `keyboardWidthPercent`, `keyboardKeyCornerRadius`, `keyboardFillUnselected`, `keyboardOutlineAllUnselected`, `keyboardDrawSpecialOutlineWhenUnselected`, `keyboardSecondaryLabelRightPadding`, `keyboardSecondaryLabelTopPadding`, `keyboardMinArrowHeadSize`
- Popups: `popupTopOffsetRatio`, `popupMarginX`, `popupMarginY`, `popupFrameThickness`, `popupCornerRadius`, `popupTextBold`, `popupTextInverted`, `popupTextBaselineOffsetY`, `popupProgressBarHeight`, `popupProgressDrawOutline`, `popupProgressClampPercent`, `popupProgressFillInverted`, `popupProgressOutlineInverted`
- Text fields: `textFieldHorizontalPadding`, `textFieldNormalThickness`, `textFieldCursorThickness`, `textFieldLineEndOffset`
## Screen Layouts
Themes can define `screens.<screen>.layout` to place UI regions with the SDK row/column layout system. This is the preferred path for new SD themes.
Each layout node can contain:
- `id`: slot name used by widgets or firmware renderers.
- `axis`: `column` stacks children top-to-bottom; `row` lays children left-to-right.
- `gap`: pixels inserted between this node's direct children.
- `slots`: child layout nodes.
- `fixed`: exact pixel size along the parent axis.
- `flex`: proportional size after fixed children and gaps are subtracted.
- `token`: named size from `metrics`, such as `menuRow`, `recents`, `buttons`, `header`, `row`, `subtitleRow`, or `gap`.
Example:
```json
"screens": {
"home": {
"navigation": "linear",
"layout": {
"axis": "column",
"gap": 0,
"slots": [
{
"id": "header",
"fixed": 40,
"axis": "row",
"gap": 4,
"slots": [
{ "id": "homeClock", "fixed": 52 },
{ "id": "homeTitle", "flex": 1 },
{ "id": "homeBattery", "fixed": 66 }
]
},
{ "id": "recents", "fixed": 340 },
{ "id": "carouselMenuGap", "fixed": 36 },
{ "id": "launchers", "fixed": 192 },
{ "id": "homeSpacer", "flex": 1 },
{ "id": "buttons", "fixed": 40 }
]
}
}
}
```
Important layout rules:
- A parent layout's `gap` only affects its direct child slots.
- `fixed` and `flex` decide how much space a slot receives. They do not decide how a widget draws inside that slot.
- Widget-specific `gap` fields control spacing inside that widget.
- Named spacer slots such as `carouselMenuGap` and `homeSpacer` do not draw anything unless a widget targets them. They are useful for placing visible regions without manual `x`/`y` coordinates.
- If a screen layout is invalid or missing required slots, CrossPoint falls back to the built-in Lyra-safe layout for that screen.
Home `navigation` modes:
- `linear`: default. Front/side navigation buttons all move through the visible home actions as one ordered list.
- `splitAxis`: front left/right move through launcher actions; side up/down move through recent-book actions. Bottom button hints show Left/Right.
- `carousel`: front left/right move through recent-book actions; side up/down move through launcher actions. Use this when left/right should stay inside a cover carousel and up/down should enter or leave the launcher menu.
Home `initialAction` can optionally choose the default selected action when entering home normally:
```json
"initialAction": "reader:recent"
```
Supported values match launcher `action` values. Explicit firmware navigation, such as returning to Settings from a settings submenu, still overrides this default.
### Layouts vs widgets
Layouts only create named rectangles. They do not choose whether a screen is a list, cover grid, carousel, or any other presentation.
Widgets choose what renders inside those rectangles. This keeps themes explicit and prevents firmware from guessing a grid just because a screen has a `list` slot.
For `screens.recentBooks`, use:
- No `recentBooks` screen: use the built-in Lyra recent-books screen.
- `layout` only, or a `list` widget: use the normal themed recent-books list in the `list` slot.
- A `coverGrid` widget: use FreeInkUI's cover-grid component in the target slot.
Minimal themed list example:
```json
"recentBooks": {
"layout": {
"axis": "column",
"gap": 8,
"slots": [
{ "id": "header", "fixed": 48 },
{ "id": "list", "flex": 1 },
{ "id": "buttons", "fixed": 40 }
]
},
"widgets": [
{ "slot": "list", "type": "list" }
]
}
```
Cover-grid screen example:
```json
"recentBooks": {
"layout": {
"axis": "column",
"gap": 16,
"slots": [
{ "id": "header", "fixed": 48 },
{ "id": "list", "flex": 1 },
{ "id": "buttons", "fixed": 40 }
]
},
"widgets": [
{
"slot": "list",
"type": "coverGrid",
"columns": 3,
"rowGap": 36,
"coverWidth": 92,
"coverHeight": 132,
"rowHeight": 172,
"labelLines": 2,
"selectionStyle": "coverFrame"
}
]
}
```
Do not use screen-level `coverGrid`. Cover-grid settings belong on a widget with `type: "coverGrid"`.
### Home widgets
Home layouts use `screens.home.widgets` to map slot rectangles to visible content.
Supported widget types:
- `clock`: draws the clock when the device has RTC support. On devices without clock support, the slot stays empty.
- `headerTitle`: draws the normal home/header title.
- `battery`: draws the battery indicator.
- `recents`: draws the configured home recents component.
- `recentCoverGrid`: draws recent books with FreeInkUI's `coverGrid` component.
- `launcherList`: draws actions as one vertical menu inside its slot.
- `launcherGrid`: draws actions in a row/column grid inside its slot.
- `buttonHints`: draws bottom button hints.
`launcherGrid` fields:
- `slot`: slot id to render into.
- `presentation`: optional presentation style. Use `iconTabs` for icon-only launcher tabs with outlined unselected cells and filled selected cells.
- `columns`: number of grid columns.
- `rows`: optional fixed row count. If omitted, rows are derived from visible launcher count and columns.
- `gap`: pixels between grid cells, both horizontally and vertically.
- `items`: launcher actions. Each item accepts `text`, `icon`, and `action`.
All home widgets also support visual placement fields:
- `layer`: draw order. Lower layers draw first; higher layers paint on top. Widgets with the same layer keep JSON order.
- `offsetX`: moves the widget right after layout. Negative values move left.
- `offsetY`: moves the widget down after layout. Negative values move up.
- `bleed`: expands the widget draw rectangle outside its slot without changing layout. Use either a single number or `{ "top": 0, "right": 0, "bottom": 0, "left": 0 }`.
- `inset`: shrinks the widget draw rectangle inside its slot without changing layout. Use either a single number or `{ "top": 0, "right": 0, "bottom": 0, "left": 0 }`.
Example overlap:
```json
{
"slot": "recents",
"type": "recents",
"layer": 0,
"bleed": { "bottom": 24 }
},
{
"slot": "launchers",
"type": "launcherGrid",
"layer": 10,
"offsetY": -12,
"columns": 2,
"gap": 24
}
```
That keeps the structural row/column layout intact, but lets the launcher grid visually overlap the recents area by 12 pixels.
`buttonHints` widget fields:
- `labels.confirm`
- `labels.previous`
- `labels.next`
- `labels.back`
Button-hint labels are localized semantic tokens, not literal UI strings. Supported tokens are `default`, `empty`, `back`, `home`, `select`, `confirm`, `open`, `toggle`, `up`, `down`, `left`, and `right`. `default` uses the firmware fallback for that navigation mode; `empty` renders no label for that button.
Example carousel hints:
```json
{
"slot": "buttons",
"type": "buttonHints",
"labels": {
"confirm": "select",
"previous": "left",
"next": "right"
}
}
```
When `components.buttonHints.layout` is `shapes` or `icons`, these same localized tokens render as button shapes/icons where supported.
Example icon tabs:
```json
{
"slot": "tabs",
"type": "launcherGrid",
"presentation": "iconTabs",
"columns": 5,
"rows": 1,
"gap": 6,
"iconSize": 32,
"selectedRadius": 5,
"items": [
{ "icon": "folder", "action": "activity:fileBrowser" },
{ "icon": "recent", "action": "activity:recentBooks" },
{ "icon": "library", "action": "activity:opds" }
]
}
```
Launcher actions:
- `activity:fileBrowser`
- `activity:recentBooks`
- `activity:opds`
- `activity:fileTransfer`
- `activity:settings`
- `activity:reader`
For `launcherGrid`, the final cell height is:
```text
(slot height - gap * (rows - 1)) / rows
```
Then each cell calls the themed home menu renderer with one item. That means:
- Increase the widget `gap` to create more visible space between grid items.
- Increase the launcher slot `fixed` height if larger gaps need more total room.
- Use `menuRowHeight` to tune the selectable tile/text/icon band inside each cell.
- Do not expect `menuSpacing` to change `launcherGrid` spacing.
For a 3-row launcher grid with `menuRowHeight: 48` and `gap: 24`, use a launcher slot near:
```text
3 * 48 + 2 * 24 = 192
```
`recentCoverGrid` / recent-books `coverGrid` widget fields:
- `slot`: slot id to render into.
- `columns`: grid columns.
- `rows`: grid rows.
- `gap`: horizontal pixels between cells. Also used vertically when `rowGap` is omitted.
- `rowGap`: vertical pixels between cover-grid rows.
- `cellInset`: optional padding inside each cover-grid cell, before the cover and label are drawn.
- `labelInset`: optional padding inside the title label area. Use `{ "left": 5, "right": 5 }` to keep two-line titles away from cell edges.
- `coverWidth`: rendered cover width.
- `coverHeight`: rendered cover height and thumbnail size to generate.
- `placeholderIconSize`: maximum icon size for the missing-cover placeholder.
- `rowHeight`: height of each cell row, including label space.
- `labelHeight`: title label area below each cover. Use `0` to hide titles.
- `labelGap`: vertical pixels between the cover and title label block.
- `labelLines`: maximum title lines to render. Increase `rowHeight` when this is greater than `1`.
- `selectionStyle`: `fill`, `outline`, `coverFrame`, or `none`. Prefer `coverFrame` for cover grids because it frames only the thumbnail and does not depend on title wrapping.
- `startIndex`: first recent-book index to show. Use `2` when a featured area already uses the first two books.
These cover-grid widgets use FreeInkUI's `coverGrid` for layout, labels, cell styling, and selected state. CrossPoint supplies a cover painter callback so SD-card thumbnails render from the existing recent-book cache.
Cover widgets can use different visual `coverWidth` and `coverHeight` values on different screens. CrossPoint still generates and reads one largest-needed thumbnail height for the active theme, then scales/crops it into each widget. That keeps the same book cover available on home and recent-books instead of requiring separate BMPs per widget.
`featuredBookCard` fields:
- `coverWidth`, `coverHeight`: rendered cover size and thumbnail height to generate.
- `placeholderIconSize`: maximum icon size for the missing-cover placeholder.
- `coverGap`: horizontal gap between the cover and title/author text.
- `titleGap`: vertical gap below the Continue Reading label before the book card starts.
- `startIndex`: recent-book index to show.
## Components
### Fonts
Most components accept:
```json
"font": "large",
"style": "bold"
```
Supported `font` values are `small`, `medium`, and `large`.
Semantic aliases are also accepted:
- `chrome`, `caption`: same as `small`.
- `body`, `label`: same as `medium`.
- `title`, `display`: same as `large`.
Supported `style` values are `regular` and `bold`.
### Home recents
`components.homeRecents` controls the home cover area.
Supported types:
- `default`: Lyra default.
- `none`: no cover area.
- `cover-strip`: one or more cover slots.
Example:
```json
"homeRecents": {
"type": "cover-strip",
"maxBooks": 3,
"wrap": true,
"selectionLineWidth": 3,
"inactiveSelectionLineWidth": 1,
"selectionCornerRadius": 6,
"slots": [
{
"book": "previous",
"x": "padding",
"y": "center",
"height": 210,
"widthPercent": 62
},
{
"book": "selected",
"x": "center",
"y": "top",
"height": 280,
"widthPercent": 62,
"selected": true,
"title": {
"enabled": true,
"font": "large",
"style": "bold",
"maxLines": 2,
"offsetY": 12
}
},
{
"book": "next",
"x": "right-padding",
"y": "center",
"height": 210,
"widthPercent": 62
}
]
}
```
Slot fields:
- `book`: `selected`, `previous`, `next`, or `index`.
- `bookIndex`: zero-based index when `book` is `index`.
- `x`: `padding`, `center`, or `right-padding`.
- `y`: `top` or `center`.
- `height`: requested thumbnail height. CrossPoint generates/cache-misses thumbnails at requested sizes.
- `widthPercent`: cover width as a percent of the slot height.
- `xOffset`, `yOffset`: positional adjustments.
- `selected`: whether this slot receives the active selection outline.
- `title`: optional book title under the cover.
CrossPoint currently reads up to five cover slots.
Cover slots with `selected: true` draw after unselected slots, so selected covers appear in front. Within each group, slots draw in the same order they appear in JSON. For a carousel where the side covers sit behind the middle cover, mark the middle slot as `selected: true`.
Use `xOffset` and `yOffset` for small relative adjustments after `x`/`y` placement has been resolved:
- Positive `xOffset` moves a cover right.
- Negative `xOffset` moves a cover left.
- Positive `yOffset` moves a cover down.
- Negative `yOffset` moves a cover up.
Example carousel layering:
```json
"slots": [
{
"book": "previous",
"x": "padding",
"y": "center",
"height": 225,
"widthPercent": 62,
"xOffset": 32
},
{
"book": "next",
"x": "right-padding",
"y": "center",
"height": 225,
"widthPercent": 62,
"xOffset": -32
},
{
"book": "selected",
"x": "center",
"y": "top",
"height": 300,
"widthPercent": 62,
"selected": true
}
]
```
In that example, the side covers are pushed toward the center, and the selected cover is drawn in the foreground.
### Home menu
`components.homeMenu` styles the home menu options.
Supported fields:
- `font`, `style`, `bold`
- `centeredText`
- `centerVertically`
- `showIcons`
- `panelWidth`
- `drawPanel`
- `panelCornerRadius`
- `selectionStyle`: `fill`, `outline`, `triangle`, `underline`, or `pill`
- `selectionCornerRadius`
- `selectionInset`
- `selectedTextInverted`
- `selectionFillBlack`
- `rowPaddingX`
- `textInsetX`
### Lists
`components.list` styles Settings, Browse, Recent Books, and similar list rows.
Supported fields:
- `font`, `style`, `bold`
- `subtitleFontId`
- `valueFontId`
- `showIcons`
- `iconSize`
- `textGap`
- `selectionStyle`: `fill`, `outline`, or `underline`
- `selectionCornerRadius`
- `selectionFill`
- `selectionOutline`
- `selectedTextInverted`
- `rowBackgrounds`
- `centerSingleLineRows`
- `subtitleRowAutoHeight`
- `centerValueVertically`
- `rowSidePadding`
- `rowGap`
- `textInsetX`
- `selectionInsetX`
- `selectionInsetY`
- `titleOffsetY`
- `subtitleOffsetY`
- `subtitleTopPadding`
- `subtitleBottomPadding`
- `subtitleInterLineGap`
- `valueOffsetY`
- `subtitleValueOffsetY`
- `iconOffsetY`
### Header
`components.header` styles page headers.
Supported fields:
- `font`, `style`, `bold`
- `centeredTitle`
- `showDivider`
- `titleOffsetY`
- `batteryOffsetY`
### Tab bar
`components.tabBar` styles tabs.
Supported fields:
- `font`, `style`, `bold`
- `equalWidth`
- `selectionStyle`: `fill` or `underline`
- `selectedCornerRadius`
- `selectedTextInverted`
- `drawDivider`
- `horizontalInset`
### Button hints
`components.buttonHints` styles bottom and side button hints.
Supported fields:
- `font`, `style`, `bold`
- `layout`: `buttons`, `groups`, `shapes`, or `icons`
- `buttonWidth`
- `smallButtonHeight`
- `cornerRadius`
- `fill`
- `outline`
- `drawEmpty`
- `shapes`
- `sidePadding`
- `groupGap`
- `bottomMargin`
- `innerPadding`
- `shapeSize`
- `textOffsetY`
Use `layout: "shapes"` or `layout: "icons"` for icon-only arrows/circle/square hints.
### Reader chrome
`screens.reader.chrome` styles the reader status lane. Reader chrome still uses `screens.reader.layout` slots for placement; the chrome object controls how those slots draw.
Battery fields:
- `style`: `icon` or `bar`.
- `width`: battery glyph width in pixels.
- `height`: battery glyph height in pixels.
- `offsetY`: vertical adjustment applied after the battery is positioned in its slot. Positive values move it down; negative values move it up.
- `track`: background/track style for bar batteries: `none`, `hairline`, `outline`, or `dither`.
- `fill`: fill style for bar batteries: `solid`, `dither`, or `segments`.
- `direction`: fill direction: `left-to-right`, `right-to-left`, `center-out`, `bottom-to-top`, or `top-to-bottom`.
- `orientation`: `horizontal` or `vertical`. Vertical is also implied by `bottom-to-top` and `top-to-bottom`.
- `caps`: `square` or `pixel`. `pixel` trims the four filled corners for a softer e-ink cap.
- `segments`: number of filled blocks when `fill` is `segments`.
- `segmentGap`: pixels between segments.
- `radius`: rounded-rect radius for bar track/fill/segments. Keep this small for thin e-ink bars; `0` is square.
- `showPercentage`: whether reader chrome may draw the battery percentage when the global setting allows it.
Example:
```json
"screens": {
"reader": {
"layout": {
"axis": "row",
"gap": 8,
"slots": [
{ "id": "bookmark", "fixed": 18 },
{ "id": "battery", "fixed": 38 },
{ "id": "title", "flex": 1 },
{ "id": "clock", "fixed": 42 },
{ "id": "progress", "fixed": 82 }
]
},
"chrome": {
"battery": {
"style": "bar",
"width": 38,
"height": 3,
"offsetY": 1,
"track": "none",
"fill": "solid",
"direction": "left-to-right",
"radius": 0,
"showPercentage": false
}
}
}
}
```
## Icons
Icons are optional. If both `homeMenu.showIcons` and `list.showIcons` are false, omit `assets.icons` and the icon files to reduce download size and heap use.
Supported icon keys:
- `folder`, `folder24`
- `text`, `text24`
- `image`, `image24`
- `book`, `book24`
- `file`, `file24`
- `recent`
- `settings`, `settings2`
- `transfer`
- `library`
- `wifi`
- `hotspot`
- `bookmark`
Generate firmware-matching 1-bit BMP icons:
```bash
python3 scripts/generate-theme-icons.py \
--icons src/components/icons \
--themes ../crosspoint-tools/public/themes
```
The script writes rotated BMP files into each `../crosspoint-tools/public/themes/<theme-id>/icons/` folder.
Reference them from `theme.json`:
```json
"assets": {
"icons": {
"folder": "icons/folder.bmp",
"book": "icons/book.bmp",
"settings": "icons/settings2.bmp"
}
}
```
## CrossInk and extension fields
CrossPoint only consumes the fields documented above. Unknown fields are ignored, so theme authors can include extra data for compatible apps and firmware.
Put app-specific fields under `extensions.<namespace>`:
```json
{
"schema": 1,
"id": "crossink-stats",
"name": "CrossInk Stats",
"inherits": "lyra",
"components": {
"homeRecents": {
"type": "cover-strip",
"maxBooks": 1
}
},
"extensions": {
"crossink": {
"schema": 1,
"readingStats": {
"enabled": true,
"placement": "home-footer",
"font": "small",
"style": "regular",
"show": [
"currentStreak",
"readingTime",
"pagesRead",
"percentComplete"
],
"labels": {
"currentStreak": "streak",
"readingTime": "reading",
"pagesRead": "pages"
}
}
}
}
}
```
Recommended extension rules:
- Keep CrossPoint layout fields in `metrics`, `components`, `assets`, and `devices`.
- Keep CrossInk-only fields under `extensions.crossink`.
- Add an extension-local `schema` when the app-specific format may evolve.
- Prefer declarative fields such as `placement`, `font`, `show`, and `labels` over code-like strings.
- Keep extension data compact. CrossPoint ignores it, but it is still parsed transiently when discovering themes.
- Do not put required CrossPoint behavior only in an extension field. CrossPoint will not read it.
CrossInk can also use `requires` for compatibility metadata:
```json
"requires": {
"crosspoint": {
"schema": 1,
"modules": ["cover-strip"]
},
"crossink": {
"schema": 1,
"modules": ["reading-stats"]
}
}
```
CrossPoint currently treats `requires` as metadata.
## Package manifest
After adding or changing hosted themes, regenerate `themes.json` in `crosspoint-tools`:
```bash
python3 scripts/generate-theme-manifest.py \
--root ../crosspoint-tools/public/themes \
--base-url http://crosspointreader.com/themes \
--output ../crosspoint-tools/public/themes/themes.json
```
The manifest generator:
- scans every `../crosspoint-tools/public/themes/<theme-id>/theme.json`
- includes every file in each theme folder
- writes per-file `size` and `crc32`
- writes the theme `id`, `name`, `description`, and `totalSize`
Commit the theme files and the regenerated manifest together in `crosspoint-tools`.
## Validation checklist
Before publishing:
```bash
for f in ../crosspoint-tools/public/themes/themes.json ../crosspoint-tools/public/themes/*/theme.json; do
python3 -m json.tool "$f" >/dev/null
done
python3 scripts/generate-theme-manifest.py \
--root ../crosspoint-tools/public/themes \
--base-url http://crosspointreader.com/themes \
--output ../crosspoint-tools/public/themes/themes.json
pio run -e gh_release
```
On device:
1. Download the theme from Settings -> UI Theme -> Download Themes.
2. Exit the downloader and let the device silently restart to clear WiFi/TLS heap.
3. Return to Settings -> UI Theme and select the downloaded theme.
4. Check Home, Settings, Browse, Recent Books, button hints, tabs, popups, keyboard, and reader menus.
Submodule
+1
Submodule freeink-sdk added at 329a4bebef
+22 -14
View File
@@ -3,6 +3,7 @@
#include <algorithm> #include <algorithm>
#include <cctype> #include <cctype>
#include <cstring> #include <cstring>
#include <string_view>
#include <vector> #include <vector>
namespace FsHelpers { namespace FsHelpers {
@@ -36,12 +37,14 @@ std::string decodeUriEscapes(const std::string& path) {
} }
std::string normalisePath(const std::string& path) { std::string normalisePath(const std::string& path) {
std::vector<std::string> components; std::vector<std::string_view> components;
std::string component; components.reserve(8); // Eight nested folders is more than we might expect
for (const auto c : path) { size_t start = 0;
if (c == '/') { for (size_t i = 0; i <= path.length(); ++i) {
if (!component.empty()) { if (i == path.length() || path[i] == '/') {
if (i > start) {
std::string_view component(path.data() + start, i - start);
if (component == "..") { if (component == "..") {
if (!components.empty()) { if (!components.empty()) {
components.pop_back(); components.pop_back();
@@ -49,23 +52,28 @@ std::string normalisePath(const std::string& path) {
} else { } else {
components.push_back(component); components.push_back(component);
} }
component.clear();
} }
} else { start = i + 1;
component += c;
} }
} }
if (!component.empty()) { if (components.empty()) {
components.push_back(component); return "";
}
size_t total_len = 0;
for (const auto& c : components) {
total_len += c.length() + 1;
} }
std::string result; std::string result;
for (const auto& c : components) { result.reserve(total_len - 1);
if (!result.empty()) {
result += "/"; for (size_t i = 0; i < components.size(); ++i) {
if (i > 0) {
result += '/';
} }
result += c; result.append(components[i].data(), components[i].length());
} }
return result; return result;
+199 -16
View File
@@ -8,6 +8,7 @@
#include <Utf8.h> #include <Utf8.h>
#include <algorithm> #include <algorithm>
#include <cassert>
#include "FontCacheManager.h" #include "FontCacheManager.h"
@@ -661,8 +662,10 @@ void GfxRenderer::drawRoundedRect(const int x, const int y, const int width, con
} }
void GfxRenderer::fillRect(const int x, const int y, const int width, const int height, const bool state) const { 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++) { if (state) {
drawLine(x, fillY, x + width - 1, fillY, state); fillRectImpl<Color::Black>(x, y, width, height);
} else {
fillRectImpl<Color::White>(x, y, width, height);
} }
} }
@@ -694,26 +697,194 @@ void GfxRenderer::drawPixelDither<Color::DarkGray>(const int x, const int y) con
} }
void GfxRenderer::fillRectDither(const int x, const int y, const int width, const int height, Color color) const { void GfxRenderer::fillRectDither(const int x, const int y, const int width, const int height, Color color) const {
if (color == Color::Clear) { switch (color) {
} else if (color == Color::Black) { case Color::Clear:
fillRect(x, y, width, height, true); break;
} else if (color == Color::White) { case Color::Black:
fillRect(x, y, width, height, false); fillRectImpl<Color::Black>(x, y, width, height);
} else if (color == Color::LightGray) { break;
for (int fillY = y; fillY < y + height; fillY++) { case Color::White:
for (int fillX = x; fillX < x + width; fillX++) { fillRectImpl<Color::White>(x, y, width, height);
drawPixelDither<Color::LightGray>(fillX, fillY); break;
case Color::LightGray:
fillRectImpl<Color::LightGray>(x, y, width, height);
break;
case Color::DarkGray:
fillRectImpl<Color::DarkGray>(x, y, width, height);
break;
}
}
template <Color C>
void GfxRenderer::fillRectImpl(const int x, const int y, const int width, const int height) const {
if constexpr (C == Color::Clear) return;
if (width <= 0 || height <= 0) return;
if (fontCacheManager_ && fontCacheManager_->isScanning()) return;
// Clip in logical space.
const int screenW = getScreenWidth();
const int screenH = getScreenHeight();
const int lx0 = std::max(0, x);
const int ly0 = std::max(0, y);
const int lx1 = std::min(screenW, x + width);
const int ly1 = std::min(screenH, y + height);
if (lx0 >= lx1 || ly0 >= ly1) return;
// Rotate the two opposing logical corners into physical-framebuffer space.
// The bounding rect in physical space is the rect we need to fill — rotation
// is rigid (no shear/stretch) so the bbox of the two corners IS the rect.
int paX, paY, pbX, pbY;
rotateCoordinates(orientation, lx0, ly0, &paX, &paY, panelWidth, panelHeight);
rotateCoordinates(orientation, lx1 - 1, ly1 - 1, &pbX, &pbY, panelWidth, panelHeight);
const int phyX0 = std::min(paX, pbX);
const int phyX1 = std::max(paX, pbX); // inclusive
int phyY0 = std::min(paY, pbY);
int phyY1 = std::max(paY, pbY);
// Strip mode: clip Y range to the active band and redirect writes.
uint8_t* target = getWriteTarget();
const int originY = getWriteOriginY();
const int writeRows = getWriteRows();
phyY0 = std::max(phyY0, originY);
phyY1 = std::min(phyY1, originY + writeRows - 1);
if (phyY0 > phyY1) return;
// Bit/byte layout: MSB-first within a byte, so phyX → bit (7 - (phyX & 7)).
// Head and tail masks cover only the in-rect bits of the first/last byte.
const int byteStart = phyX0 >> 3;
const int byteEnd = phyX1 >> 3; // inclusive
const uint8_t headMask = static_cast<uint8_t>(0xFFu >> (phyX0 & 7));
const uint8_t tailMask = static_cast<uint8_t>(0xFFu << (7 - (phyX1 & 7)));
const int32_t panelStride = static_cast<int32_t>(panelWidthBytes);
if constexpr (C == Color::Black || C == Color::White) {
// Solid fill. Framebuffer: 0 = black, 1 = white.
const uint8_t fillByte = (C == Color::Black) ? 0x00u : 0xFFu;
for (int py = phyY0; py <= phyY1; ++py) {
uint8_t* row = target + static_cast<int32_t>(py - originY) * panelStride;
if (byteStart == byteEnd) {
const uint8_t mask = headMask & tailMask;
if constexpr (C == Color::Black) {
row[byteStart] &= static_cast<uint8_t>(~mask);
} else {
row[byteStart] |= mask;
}
} else {
if constexpr (C == Color::Black) {
row[byteStart] &= static_cast<uint8_t>(~headMask);
if (byteEnd > byteStart + 1) {
memset(row + byteStart + 1, fillByte, byteEnd - byteStart - 1);
}
row[byteEnd] &= static_cast<uint8_t>(~tailMask);
} else {
row[byteStart] |= headMask;
if (byteEnd > byteStart + 1) {
memset(row + byteStart + 1, fillByte, byteEnd - byteStart - 1);
}
row[byteEnd] |= tailMask;
}
} }
} }
} else if (color == Color::DarkGray) { } else {
for (int fillY = y; fillY < y + height; fillY++) { // Dither (LightGray / DarkGray). Both patterns have period 2 in logical
for (int fillX = x; fillX < x + width; fillX++) { // (x, y), so per physical row we precompute one byte that represents the
drawPixelDither<Color::DarkGray>(fillX, fillY); // pattern across an 8-pixel stretch — every full byte in the row uses
// that same value.
//
// dlxPerPhyX / dlyPerPhyX: how logical (x, y) change as phyX increments
// along a physical row. Derived from inverting rotateCoordinates.
int dlxPerPhyX = 0, dlyPerPhyX = 0;
switch (orientation) {
case Portrait:
dlxPerPhyX = 0;
dlyPerPhyX = 1;
break;
case PortraitInverted:
dlxPerPhyX = 0;
dlyPerPhyX = -1;
break;
case LandscapeClockwise:
dlxPerPhyX = -1;
dlyPerPhyX = 0;
break;
case LandscapeCounterClockwise:
dlxPerPhyX = 1;
dlyPerPhyX = 0;
break;
}
// The dither pattern has period 2 in logical space, and each orientation
// maps py to logical coords with a fixed parity relationship. The
// blackMask byte therefore repeats with period 2 in py. Precompute both
// variants outside the row loop to eliminate the per-row switch + 8-bit
// construction loop.
uint8_t blackMasks[2];
for (int parityIdx = 0; parityIdx < 2; ++parityIdx) {
const int samplePy = phyY0 + parityIdx;
int lxBase = 0, lyBase = 0;
switch (orientation) {
case Portrait:
lxBase = panelHeight - 1 - samplePy;
lyBase = byteStart * 8;
break;
case PortraitInverted:
lxBase = samplePy;
lyBase = panelWidth - 1 - byteStart * 8;
break;
case LandscapeClockwise:
lxBase = panelWidth - 1 - byteStart * 8;
lyBase = panelHeight - 1 - samplePy;
break;
case LandscapeCounterClockwise:
lxBase = byteStart * 8;
lyBase = samplePy;
break;
}
uint8_t mask = 0;
for (int b = 0; b < 8; ++b) {
const int lx = lxBase + b * dlxPerPhyX;
const int ly = lyBase + b * dlyPerPhyX;
bool isBlack;
if constexpr (C == Color::LightGray) {
isBlack = ((lx & 1) == 0) && ((ly & 1) == 0);
} else { // DarkGray
isBlack = (((lx + ly) & 1) == 0);
}
if (isBlack) mask |= static_cast<uint8_t>(1u << (7 - b));
}
blackMasks[samplePy & 1] = mask;
}
for (int py = phyY0; py <= phyY1; ++py) {
const uint8_t blackMask = blackMasks[py & 1];
const uint8_t whiteMask = static_cast<uint8_t>(~blackMask);
// Dither writes BOTH inks (the slow path called drawPixel for every
// pixel — setting or clearing — so we must do the same). Inside the
// rect mask: write whiteMask (1s where white, 0s where black). Outside
// the rect mask: leave the framebuffer untouched.
uint8_t* row = target + static_cast<int32_t>(py - originY) * panelStride;
if (byteStart == byteEnd) {
const uint8_t rectMask = headMask & tailMask;
row[byteStart] = static_cast<uint8_t>((row[byteStart] & ~rectMask) | (rectMask & whiteMask));
} else {
row[byteStart] = static_cast<uint8_t>((row[byteStart] & ~headMask) | (headMask & whiteMask));
if (byteEnd > byteStart + 1) {
// Period 2, so every full byte in this row is exactly whiteMask.
memset(row + byteStart + 1, whiteMask, byteEnd - byteStart - 1);
}
row[byteEnd] = static_cast<uint8_t>((row[byteEnd] & ~tailMask) | (tailMask & whiteMask));
} }
} }
} }
} }
template void GfxRenderer::fillRectImpl<Color::Black>(int, int, int, int) const;
template void GfxRenderer::fillRectImpl<Color::White>(int, int, int, int) const;
template void GfxRenderer::fillRectImpl<Color::LightGray>(int, int, int, int) const;
template void GfxRenderer::fillRectImpl<Color::DarkGray>(int, int, int, int) const;
void GfxRenderer::maskRoundedRectOutsideCorners(const int x, const int y, const int width, const int height, void GfxRenderer::maskRoundedRectOutsideCorners(const int x, const int y, const int width, const int height,
const int radius, const Color color) const { const int radius, const Color color) const {
if (radius <= 0 || color == Color::Clear) { if (radius <= 0 || color == Color::Clear) {
@@ -885,7 +1056,19 @@ void GfxRenderer::drawImage(const uint8_t bitmap[], const int x, const int y, co
} }
void GfxRenderer::drawIcon(const uint8_t bitmap[], const int x, const int y, const int width, const int height) const { void GfxRenderer::drawIcon(const uint8_t bitmap[], const int x, const int y, const int width, const int height) const {
display.drawImageTransparent(bitmap, y, getScreenWidth() - width - x, height, width); if (bitmap == nullptr || width <= 0 || height <= 0) return;
assert(width == height);
const int bytesPerRow = (width + 7) / 8;
for (int sourceY = 0; sourceY < height; ++sourceY) {
for (int sourceX = 0; sourceX < width; ++sourceX) {
const uint8_t rowByte = bitmap[sourceY * bytesPerRow + sourceX / 8];
const bool background = (rowByte >> (7 - (sourceX % 8))) & 0x01;
if (background) continue;
drawPixel(x + height - 1 - sourceY, y + sourceX, true);
}
}
} }
void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, const int maxWidth, const int maxHeight, void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, const int maxWidth, const int maxHeight,
+6
View File
@@ -81,6 +81,12 @@ class GfxRenderer {
void drawPixelDither(int x, int y) const; void drawPixelDither(int x, int y) const;
template <Color color> template <Color color>
void fillArc(int maxRadius, int cx, int cy, int xDir, int yDir) const; void fillArc(int maxRadius, int cx, int cy, int xDir, int yDir) const;
// Byte-aligned, orientation-specialized rectangle fill. Rotates the rect's
// two opposing corners into physical-framebuffer space once, then walks each
// physical row with head-mask / middle memset / tail-mask byte writes — no
// per-pixel rotation, no per-pixel RMW.
template <Color color>
void fillRectImpl(int x, int y, int width, int height) const;
public: public:
explicit GfxRenderer(HalDisplay& halDisplay) explicit GfxRenderer(HalDisplay& halDisplay)
+2
View File
@@ -126,6 +126,7 @@ STR_PAGE_TURN: "Перагортванне"
STR_PORTRAIT: "Партрэт" STR_PORTRAIT: "Партрэт"
STR_LANDSCAPE_CW: "Ландшафт (CW)" STR_LANDSCAPE_CW: "Ландшафт (CW)"
STR_INVERTED: "Інверсія" STR_INVERTED: "Інверсія"
STR_ORIENTATION_INVERTED: "Партрэт 180°"
STR_LANDSCAPE_CCW: "Ландшафт (CCW)" STR_LANDSCAPE_CCW: "Ландшафт (CCW)"
STR_PREV_NEXT: "Назад/Наперад" STR_PREV_NEXT: "Назад/Наперад"
STR_NEXT_PREV: "Наперад/Назад" STR_NEXT_PREV: "Наперад/Назад"
@@ -299,3 +300,4 @@ STR_SLEEP_TIMER_STEP_HINT: "Улева/Управа: 1 хв Уверх/Уніз
STR_AUTO_TURN_ENABLED: "Аўтаперагортванне: " STR_AUTO_TURN_ENABLED: "Аўтаперагортванне: "
STR_AUTO_TURN_PAGES_PER_MIN: "Аўтаперагортванне (старонак за хвіліну)" STR_AUTO_TURN_PAGES_PER_MIN: "Аўтаперагортванне (старонак за хвіліну)"
STR_TILT_PAGE_TURN: "Перагортванне нахілам" STR_TILT_PAGE_TURN: "Перагортванне нахілам"
STR_MANAGE_THEMES: "Кіраванне тэмамі"
+2
View File
@@ -136,6 +136,7 @@ STR_PAGE_TURN: "Canvi de pàgina"
STR_PORTRAIT: "Vertical" STR_PORTRAIT: "Vertical"
STR_LANDSCAPE_CW: "Horitzontal horari" STR_LANDSCAPE_CW: "Horitzontal horari"
STR_INVERTED: "Invertit" STR_INVERTED: "Invertit"
STR_ORIENTATION_INVERTED: "Vertical 180°"
STR_LANDSCAPE_CCW: "Horitzontal antihorari" STR_LANDSCAPE_CCW: "Horitzontal antihorari"
STR_PREV_NEXT: "Anterior/Següent" STR_PREV_NEXT: "Anterior/Següent"
STR_NEXT_PREV: "Següent/Anterior" STR_NEXT_PREV: "Següent/Anterior"
@@ -382,3 +383,4 @@ STR_FIRMWARE_WRITE_FAILED: "Ha fallat l'escriptura del firmware"
STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "No apagueu el dispositiu!" STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "No apagueu el dispositiu!"
STR_RECOVERY_MODE: "Mode de recuperació" STR_RECOVERY_MODE: "Mode de recuperació"
STR_RECOVERY_MODE_HINT: "Poseu firmware.bin a l'arrel de la targeta SD i seleccioneu-lo" STR_RECOVERY_MODE_HINT: "Poseu firmware.bin a l'arrel de la targeta SD i seleccioneu-lo"
STR_MANAGE_THEMES: "Gestiona els temes"
+2
View File
@@ -131,6 +131,7 @@ STR_PAGE_TURN: "Otáčení stránek"
STR_PORTRAIT: "Na výšku" STR_PORTRAIT: "Na výšku"
STR_LANDSCAPE_CW: "Na šířku po směru hod. ručiček" STR_LANDSCAPE_CW: "Na šířku po směru hod. ručiček"
STR_INVERTED: "Invertovaný" STR_INVERTED: "Invertovaný"
STR_ORIENTATION_INVERTED: "Na výšku 180°"
STR_LANDSCAPE_CCW: "Na šířku proti směru hod. ručiček" STR_LANDSCAPE_CCW: "Na šířku proti směru hod. ručiček"
STR_PREV_NEXT: "Předchozí/Další" STR_PREV_NEXT: "Předchozí/Další"
STR_NEXT_PREV: "Další/Předchozí" STR_NEXT_PREV: "Další/Předchozí"
@@ -274,3 +275,4 @@ STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
STR_SLEEP_NEVER: "Nikdy" STR_SLEEP_NEVER: "Nikdy"
STR_SLEEP_TIMER_STEP_HINT: "Vlevo/Vpravo: 1 min Nahoru/Dolů: 5 min" STR_SLEEP_TIMER_STEP_HINT: "Vlevo/Vpravo: 1 min Nahoru/Dolů: 5 min"
STR_TILT_PAGE_TURN: "Otáčení stránek nakloněním" STR_TILT_PAGE_TURN: "Otáčení stránek nakloněním"
STR_MANAGE_THEMES: "Spravovat motivy"
+2
View File
@@ -136,6 +136,7 @@ STR_PAGE_TURN: "Sideskift"
STR_PORTRAIT: "Portræt" STR_PORTRAIT: "Portræt"
STR_LANDSCAPE_CW: "Liggende med uret" STR_LANDSCAPE_CW: "Liggende med uret"
STR_INVERTED: "Inverteret" STR_INVERTED: "Inverteret"
STR_ORIENTATION_INVERTED: "Portræt 180°"
STR_LANDSCAPE_CCW: "Liggende mod uret" STR_LANDSCAPE_CCW: "Liggende mod uret"
STR_PREV_NEXT: "Forrige/Næste" STR_PREV_NEXT: "Forrige/Næste"
STR_NEXT_PREV: "Næste/Forrige" STR_NEXT_PREV: "Næste/Forrige"
@@ -302,3 +303,4 @@ STR_SCREENSHOT_BUTTON: "Tag skærmbillede"
STR_AUTO_TURN_ENABLED: "Automatisk sidevendning aktiveret: " STR_AUTO_TURN_ENABLED: "Automatisk sidevendning aktiveret: "
STR_AUTO_TURN_PAGES_PER_MIN: "Automatisk vending (sider per minut)" STR_AUTO_TURN_PAGES_PER_MIN: "Automatisk vending (sider per minut)"
STR_TILT_PAGE_TURN: "Vip for at vende side" STR_TILT_PAGE_TURN: "Vip for at vende side"
STR_MANAGE_THEMES: "Administrer temaer"
+3 -1
View File
@@ -135,7 +135,8 @@ STR_SLEEP: "Slaap"
STR_PAGE_TURN: "Pagina omslaan" STR_PAGE_TURN: "Pagina omslaan"
STR_PORTRAIT: "Staand" STR_PORTRAIT: "Staand"
STR_LANDSCAPE_CW: "Liggend (rechtsom)" STR_LANDSCAPE_CW: "Liggend (rechtsom)"
STR_INVERTED: "Omgekeerd" STR_INVERTED: "Geïnverteerd"
STR_ORIENTATION_INVERTED: "Staand 180°"
STR_LANDSCAPE_CCW: "Liggend (linksom)" STR_LANDSCAPE_CCW: "Liggend (linksom)"
STR_PREV_NEXT: "Vorige/Volgende" STR_PREV_NEXT: "Vorige/Volgende"
STR_NEXT_PREV: "Volgende/Vorige" STR_NEXT_PREV: "Volgende/Vorige"
@@ -302,3 +303,4 @@ STR_SCREENSHOT_BUTTON: "Screenshot maken"
STR_AUTO_TURN_ENABLED: "Automatisch omslaan ingeschakeld: " STR_AUTO_TURN_ENABLED: "Automatisch omslaan ingeschakeld: "
STR_AUTO_TURN_PAGES_PER_MIN: "Autom. omslaan (pagina's per minuut)" STR_AUTO_TURN_PAGES_PER_MIN: "Autom. omslaan (pagina's per minuut)"
STR_TILT_PAGE_TURN: "Kantel om te bladeren" STR_TILT_PAGE_TURN: "Kantel om te bladeren"
STR_MANAGE_THEMES: "Thema's beheren"
+2
View File
@@ -139,6 +139,7 @@ STR_FORCE_REFRESH: "Refresh Screen"
STR_PORTRAIT: "Portrait" STR_PORTRAIT: "Portrait"
STR_LANDSCAPE_CW: "Landscape CW" STR_LANDSCAPE_CW: "Landscape CW"
STR_INVERTED: "Inverted" STR_INVERTED: "Inverted"
STR_ORIENTATION_INVERTED: "Portrait 180°"
STR_LANDSCAPE_CCW: "Landscape CCW" STR_LANDSCAPE_CCW: "Landscape CCW"
STR_PREV_NEXT: "Prev/Next" STR_PREV_NEXT: "Prev/Next"
STR_NEXT_PREV: "Next/Prev" STR_NEXT_PREV: "Next/Prev"
@@ -349,6 +350,7 @@ STR_INSTALLED: "Installed"
STR_DOWNLOAD_ALL: "Download All" STR_DOWNLOAD_ALL: "Download All"
STR_UPDATE_ALL: "Update All" STR_UPDATE_ALL: "Update All"
STR_UPDATE_AVAILABLE: "Update" STR_UPDATE_AVAILABLE: "Update"
STR_MANAGE_THEMES: "Manage Themes"
STR_CRASH_TITLE: "System Crash" STR_CRASH_TITLE: "System Crash"
STR_CRASH_DESCRIPTION: "A detailed report was saved to crash_report.txt. Please include this file in your bug report." STR_CRASH_DESCRIPTION: "A detailed report was saved to crash_report.txt. Please include this file in your bug report."
STR_CRASH_REASON: "Crash reason:" STR_CRASH_REASON: "Crash reason:"
+3 -1
View File
@@ -130,7 +130,8 @@ STR_SLEEP: "Lepotila"
STR_PAGE_TURN: "Sivunkääntö" STR_PAGE_TURN: "Sivunkääntö"
STR_PORTRAIT: "Pysty" STR_PORTRAIT: "Pysty"
STR_LANDSCAPE_CW: "Vaaka myötäpäivään" STR_LANDSCAPE_CW: "Vaaka myötäpäivään"
STR_INVERTED: "Käännetty" STR_INVERTED: "Käänteinen"
STR_ORIENTATION_INVERTED: "Pysty 180°"
STR_LANDSCAPE_CCW: "Vaaka vastapäivään" STR_LANDSCAPE_CCW: "Vaaka vastapäivään"
STR_PREV_NEXT: "Edell/Seur" STR_PREV_NEXT: "Edell/Seur"
STR_NEXT_PREV: "Seur/Edell" STR_NEXT_PREV: "Seur/Edell"
@@ -272,3 +273,4 @@ STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
STR_SLEEP_NEVER: "Ei koskaan" STR_SLEEP_NEVER: "Ei koskaan"
STR_SLEEP_TIMER_STEP_HINT: "Vasen/Oikea: 1 min Ylös/Alas: 5 min" STR_SLEEP_TIMER_STEP_HINT: "Vasen/Oikea: 1 min Ylös/Alas: 5 min"
STR_TILT_PAGE_TURN: "Sivunkääntö kallistamalla" STR_TILT_PAGE_TURN: "Sivunkääntö kallistamalla"
STR_MANAGE_THEMES: "Hallinnoi teemoja"
+2
View File
@@ -136,6 +136,7 @@ STR_PAGE_TURN: "Page suivante"
STR_PORTRAIT: "Portrait" STR_PORTRAIT: "Portrait"
STR_LANDSCAPE_CW: "Paysage" STR_LANDSCAPE_CW: "Paysage"
STR_INVERTED: "Inversé" STR_INVERTED: "Inversé"
STR_ORIENTATION_INVERTED: "Portrait 180°"
STR_LANDSCAPE_CCW: "Paysage inversé" STR_LANDSCAPE_CCW: "Paysage inversé"
STR_PREV_NEXT: "Préc/Suiv" STR_PREV_NEXT: "Préc/Suiv"
STR_NEXT_PREV: "Suiv/Préc" STR_NEXT_PREV: "Suiv/Préc"
@@ -303,3 +304,4 @@ STR_SCREENSHOT_BUTTON: "Capture d'écran"
STR_AUTO_TURN_ENABLED: "Tourne-page auto : " STR_AUTO_TURN_ENABLED: "Tourne-page auto : "
STR_AUTO_TURN_PAGES_PER_MIN: "Tourne-page auto (pages par minute)" STR_AUTO_TURN_PAGES_PER_MIN: "Tourne-page auto (pages par minute)"
STR_TILT_PAGE_TURN: "Tourner par inclinaison" STR_TILT_PAGE_TURN: "Tourner par inclinaison"
STR_MANAGE_THEMES: "Gérer les thèmes"
+6 -3
View File
@@ -70,7 +70,7 @@ STR_ORIENTATION: "Leseausrichtung"
STR_SIDE_BTN_LAYOUT: "Seitliche Tasten (Lesen)" STR_SIDE_BTN_LAYOUT: "Seitliche Tasten (Lesen)"
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Vordere Tasten ausrichten" STR_FRONT_BTN_FOLLOW_ORIENTATION: "Vordere Tasten ausrichten"
STR_LONG_PRESS_BEHAVIOR: "Verhalten bei langem Tastendruck" STR_LONG_PRESS_BEHAVIOR: "Verhalten bei langem Tastendruck"
STR_LONG_PRESS_BEHAVIOR_OFF: "AUS" STR_LONG_PRESS_BEHAVIOR_OFF: "Aus"
STR_LONG_PRESS_BEHAVIOR_SKIP: "Kapitel überspringen" STR_LONG_PRESS_BEHAVIOR_SKIP: "Kapitel überspringen"
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Ausrichtung ändern" STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Ausrichtung ändern"
STR_FONT_PREVIEW_TEXT: "Victor jagt zwölf Boxkämpfer quer über den großen Sylter Deich" STR_FONT_PREVIEW_TEXT: "Victor jagt zwölf Boxkämpfer quer über den großen Sylter Deich"
@@ -79,8 +79,9 @@ STR_FONT_SIZE: "Schriftgröße"
STR_LINE_SPACING: "Lese-Zeilenabstand" STR_LINE_SPACING: "Lese-Zeilenabstand"
STR_SCREEN_MARGIN: "Lese-Seitenränder" STR_SCREEN_MARGIN: "Lese-Seitenränder"
STR_PARA_ALIGNMENT: "Lese-Absatzausrichtung" STR_PARA_ALIGNMENT: "Lese-Absatzausrichtung"
STR_LONG_PRESS_MENU: "Menütaste lang drücken"
STR_HYPHENATION: "Silbentrennung" STR_HYPHENATION: "Silbentrennung"
STR_TIME_TO_SLEEP: "Standby nach" STR_TIME_TO_SLEEP: "Standby-Modus nach"
STR_REFRESH_FREQ: "Anti-Ghosting nach" STR_REFRESH_FREQ: "Anti-Ghosting nach"
STR_KOREADER_SYNC: "KOReader-Synchr." STR_KOREADER_SYNC: "KOReader-Synchr."
STR_CHECK_UPDATES: "Nach Updates suchen" STR_CHECK_UPDATES: "Nach Updates suchen"
@@ -129,7 +130,8 @@ STR_PAGE_TURN: "Umblättern"
STR_FORCE_REFRESH: "Bildschirm regenerieren" STR_FORCE_REFRESH: "Bildschirm regenerieren"
STR_PORTRAIT: "Hochformat" STR_PORTRAIT: "Hochformat"
STR_LANDSCAPE_CW: "Querformat rechts" STR_LANDSCAPE_CW: "Querformat rechts"
STR_INVERTED: "Hochformat 180°" STR_INVERTED: "Invertiert"
STR_ORIENTATION_INVERTED: "Hochformat 180°"
STR_LANDSCAPE_CCW: "Querformat links" STR_LANDSCAPE_CCW: "Querformat links"
STR_PREV_NEXT: "Zurück/Weiter" STR_PREV_NEXT: "Zurück/Weiter"
STR_NEXT_PREV: "Weiter/Zurück" STR_NEXT_PREV: "Weiter/Zurück"
@@ -378,3 +380,4 @@ STR_FIRMWARE_WRITE_FAILED: "Schreiben der Firmware-Datei ist fehlgeschlagen"
STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "Nicht ausschalten!" STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "Nicht ausschalten!"
STR_RECOVERY_MODE: "Wiederherstellungsmodus" STR_RECOVERY_MODE: "Wiederherstellungsmodus"
STR_RECOVERY_MODE_HINT: "Lege firmware.bin im SD-Kartenwurzelverzeichnis ab und wähle es aus" STR_RECOVERY_MODE_HINT: "Lege firmware.bin im SD-Kartenwurzelverzeichnis ab und wähle es aus"
STR_MANAGE_THEMES: "Designs verwalten"
+3 -1
View File
@@ -134,7 +134,8 @@ STR_PAGE_TURN: "העברת דף"
STR_FORCE_REFRESH: "רענון מסך מלא" STR_FORCE_REFRESH: "רענון מסך מלא"
STR_PORTRAIT: "לאורך" STR_PORTRAIT: "לאורך"
STR_LANDSCAPE_CW: "לרוחב (ימינה)" STR_LANDSCAPE_CW: "לרוחב (ימינה)"
STR_INVERTED: "הפוך" STR_INVERTED: יפוך צבעים"
STR_ORIENTATION_INVERTED: "לאורך 180°"
STR_LANDSCAPE_CCW: "לרוחב (שמאלה)" STR_LANDSCAPE_CCW: "לרוחב (שמאלה)"
STR_PREV_NEXT: "הקודם/הבא" STR_PREV_NEXT: "הקודם/הבא"
STR_NEXT_PREV: "הבא/הקודם" STR_NEXT_PREV: "הבא/הקודם"
@@ -383,6 +384,7 @@ STR_BOOKMARK_REMOVED: "הסימנייה הוסרה"
STR_QUICK_RESUME: "חזרה מהירה" STR_QUICK_RESUME: "חזרה מהירה"
STR_REMOVE_FROM_RECENTS: "להסיר מרשימת הספרים האחרונים?" STR_REMOVE_FROM_RECENTS: "להסיר מרשימת הספרים האחרונים?"
STR_CONFIRM_DELETE_BOOKMARK: "למחוק סימנייה זו?" STR_CONFIRM_DELETE_BOOKMARK: "למחוק סימנייה זו?"
STR_MANAGE_THEMES: "ניהול ערכות נושא"
STR_LONG_PRESS_MENU: "לחיצה ארוכה על אישור" STR_LONG_PRESS_MENU: "לחיצה ארוכה על אישור"
STR_KOSYNC: "KOSync" STR_KOSYNC: "KOSync"
STR_BOOKMARK_OPTION: "סימנייה" STR_BOOKMARK_OPTION: "סימנייה"
+3 -1
View File
@@ -132,7 +132,8 @@ STR_SLEEP: "Alvás"
STR_PAGE_TURN: "Lapozás" STR_PAGE_TURN: "Lapozás"
STR_PORTRAIT: "Álló" STR_PORTRAIT: "Álló"
STR_LANDSCAPE_CW: "Fekvő jobbra" STR_LANDSCAPE_CW: "Fekvő jobbra"
STR_INVERTED: "Fordított" STR_INVERTED: "Invertált"
STR_ORIENTATION_INVERTED: "Álló 180°"
STR_LANDSCAPE_CCW: "Fekvő balra" STR_LANDSCAPE_CCW: "Fekvő balra"
STR_PREV_NEXT: "Előző/Következő" STR_PREV_NEXT: "Előző/Következő"
STR_NEXT_PREV: "Következő/Előző" STR_NEXT_PREV: "Következő/Előző"
@@ -299,3 +300,4 @@ STR_SCREENSHOT_BUTTON: "Képernyőkép készítése"
STR_AUTO_TURN_ENABLED: "Automatikus lapozás bekapcsolva: " STR_AUTO_TURN_ENABLED: "Automatikus lapozás bekapcsolva: "
STR_AUTO_TURN_PAGES_PER_MIN: "Automatikus lapozás (oldal/perc)" STR_AUTO_TURN_PAGES_PER_MIN: "Automatikus lapozás (oldal/perc)"
STR_TILT_PAGE_TURN: "Döntéses lapozás" STR_TILT_PAGE_TURN: "Döntéses lapozás"
STR_MANAGE_THEMES: "Témák kezelése"
+4 -1
View File
@@ -137,7 +137,8 @@ STR_PAGE_TURN: "Cambio pagina"
STR_FORCE_REFRESH: "Refresh" STR_FORCE_REFRESH: "Refresh"
STR_PORTRAIT: "Verticale" STR_PORTRAIT: "Verticale"
STR_LANDSCAPE_CW: "Orizzontale Dx" STR_LANDSCAPE_CW: "Orizzontale Dx"
STR_INVERTED: "Capovolto" STR_INVERTED: "Invertito"
STR_ORIENTATION_INVERTED: "Verticale 180°"
STR_LANDSCAPE_CCW: "Orizzontale Sx" STR_LANDSCAPE_CCW: "Orizzontale Sx"
STR_PREV_NEXT: "Prec/Succ" STR_PREV_NEXT: "Prec/Succ"
STR_NEXT_PREV: "Succ/Prec" STR_NEXT_PREV: "Succ/Prec"
@@ -375,6 +376,8 @@ STR_CLOCK_SYNCING: "Sincronizzazione con il server NTP..."
STR_CLOCK_SYNC_FAIL: "Sincronizzazione non riuscita" STR_CLOCK_SYNC_FAIL: "Sincronizzazione non riuscita"
STR_CLOCK_SYNC_NOW: "Sincronizza l'orologio adesso" STR_CLOCK_SYNC_NOW: "Sincronizza l'orologio adesso"
STR_CLOCK_SYNC_NO_WIFI: "Wi-Fi non connesso" STR_CLOCK_SYNC_NO_WIFI: "Wi-Fi non connesso"
STR_HOLD_CONFIRM_TO_DELETE: "Tieni premuto Conferma per cancellare"
STR_MANAGE_THEMES: "Gestisci temi"
STR_HOLD_OPEN_TO_DELETE: "Tieni premuto Apri per eliminare" STR_HOLD_OPEN_TO_DELETE: "Tieni premuto Apri per eliminare"
STR_NEXT_FIELD: "Succ." STR_NEXT_FIELD: "Succ."
STR_CURRENT_TIME: "Ora attuale: " STR_CURRENT_TIME: "Ora attuale: "
+3 -1
View File
@@ -126,7 +126,8 @@ STR_SLEEP: "Ұйқы"
STR_PAGE_TURN: "Бет аудару" STR_PAGE_TURN: "Бет аудару"
STR_PORTRAIT: "Тік бағдар" STR_PORTRAIT: "Тік бағдар"
STR_LANDSCAPE_CW: "Көлденең (сағат бағытымен)" STR_LANDSCAPE_CW: "Көлденең (сағат бағытымен)"
STR_INVERTED: "Төңкерілген" STR_INVERTED: "Инверсия"
STR_ORIENTATION_INVERTED: "Тік бағдар 180°"
STR_LANDSCAPE_CCW: "Көлденең (сағат тіліне қарсы)" STR_LANDSCAPE_CCW: "Көлденең (сағат тіліне қарсы)"
STR_PREV_NEXT: "Алдыңғы/Келесі" STR_PREV_NEXT: "Алдыңғы/Келесі"
STR_NEXT_PREV: "Келесі/Алдыңғы" STR_NEXT_PREV: "Келесі/Алдыңғы"
@@ -298,3 +299,4 @@ STR_SCREENSHOT_BUTTON: "Скриншот түсіру"
STR_AUTO_TURN_ENABLED: "Автоматты бет аудару қосулы: " STR_AUTO_TURN_ENABLED: "Автоматты бет аудару қосулы: "
STR_AUTO_TURN_PAGES_PER_MIN: "Автоматты бет аудару (минутына бет саны)" STR_AUTO_TURN_PAGES_PER_MIN: "Автоматты бет аудару (минутына бет саны)"
STR_TILT_PAGE_TURN: "Еңкейту арқылы бет аудару" STR_TILT_PAGE_TURN: "Еңкейту арқылы бет аудару"
STR_MANAGE_THEMES: "Тақырыптарды басқару"
+3 -1
View File
@@ -132,7 +132,8 @@ STR_SLEEP: "Miegas"
STR_PAGE_TURN: "Versti psl." STR_PAGE_TURN: "Versti psl."
STR_PORTRAIT: "Stačias" STR_PORTRAIT: "Stačias"
STR_LANDSCAPE_CW: "Gulsčias (P)" STR_LANDSCAPE_CW: "Gulsčias (P)"
STR_INVERTED: "Apverstas" STR_INVERTED: "Invertuotas"
STR_ORIENTATION_INVERTED: "Stačias 180°"
STR_LANDSCAPE_CCW: "Gulsčias (A)" STR_LANDSCAPE_CCW: "Gulsčias (A)"
STR_PREV_NEXT: "Atgal/Pirmyn" STR_PREV_NEXT: "Atgal/Pirmyn"
STR_NEXT_PREV: "Pirmyn/Atgal" STR_NEXT_PREV: "Pirmyn/Atgal"
@@ -299,3 +300,4 @@ STR_SCREENSHOT_BUTTON: "Ekrano nuotrauka"
STR_AUTO_TURN_ENABLED: "Auto-vertimas: " STR_AUTO_TURN_ENABLED: "Auto-vertimas: "
STR_AUTO_TURN_PAGES_PER_MIN: "Auto-vertimas (psl/min)" STR_AUTO_TURN_PAGES_PER_MIN: "Auto-vertimas (psl/min)"
STR_TILT_PAGE_TURN: "Puslapio vertimas pakreipiant" STR_TILT_PAGE_TURN: "Puslapio vertimas pakreipiant"
STR_MANAGE_THEMES: "Tvarkyti temas"
+3 -1
View File
@@ -136,7 +136,8 @@ STR_PAGE_TURN: "Nast. str."
STR_FORCE_REFRESH: "Odśwież ekran" STR_FORCE_REFRESH: "Odśwież ekran"
STR_PORTRAIT: "Pionowo" STR_PORTRAIT: "Pionowo"
STR_LANDSCAPE_CW: "Poziomo P" STR_LANDSCAPE_CW: "Poziomo P"
STR_INVERTED: "Odwrócony" STR_INVERTED: "Inwersja"
STR_ORIENTATION_INVERTED: "Pionowo 180°"
STR_LANDSCAPE_CCW: "Poziomo L" STR_LANDSCAPE_CCW: "Poziomo L"
STR_PREV_NEXT: "Poprz./Nast." STR_PREV_NEXT: "Poprz./Nast."
STR_NEXT_PREV: "Nast./Poprz." STR_NEXT_PREV: "Nast./Poprz."
@@ -359,3 +360,4 @@ STR_FIRMWARE_WRITE_FAILED: "Zapis oprogramowania nieudany"
STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "Nie wyłączać!" STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "Nie wyłączać!"
STR_RECOVERY_MODE: "Tryb przywracania" STR_RECOVERY_MODE: "Tryb przywracania"
STR_RECOVERY_MODE_HINT: "Umieść firmware.bin w głównym katalogu karty SD i wybierz go" STR_RECOVERY_MODE_HINT: "Umieść firmware.bin w głównym katalogu karty SD i wybierz go"
STR_MANAGE_THEMES: "Zarządzaj motywami"
+2
View File
@@ -131,6 +131,7 @@ STR_PAGE_TURN: "Virar página"
STR_PORTRAIT: "Retrato" STR_PORTRAIT: "Retrato"
STR_LANDSCAPE_CW: "Paisagem H" STR_LANDSCAPE_CW: "Paisagem H"
STR_INVERTED: "Invertido" STR_INVERTED: "Invertido"
STR_ORIENTATION_INVERTED: "Retrato 180°"
STR_LANDSCAPE_CCW: "Paisagem AH" STR_LANDSCAPE_CCW: "Paisagem AH"
STR_PREV_NEXT: "Ant/Próx" STR_PREV_NEXT: "Ant/Próx"
STR_NEXT_PREV: "Próx/Ant" STR_NEXT_PREV: "Próx/Ant"
@@ -274,3 +275,4 @@ STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
STR_SLEEP_NEVER: "Nunca" STR_SLEEP_NEVER: "Nunca"
STR_SLEEP_TIMER_STEP_HINT: "Esq/Dir: 1 min Cima/Baixo: 5 min" STR_SLEEP_TIMER_STEP_HINT: "Esq/Dir: 1 min Cima/Baixo: 5 min"
STR_TILT_PAGE_TURN: "Virar página por inclinação" STR_TILT_PAGE_TURN: "Virar página por inclinação"
STR_MANAGE_THEMES: "Gerenciar temas"
+2
View File
@@ -136,6 +136,7 @@ STR_PAGE_TURN: "Răsfoire pagină"
STR_PORTRAIT: "Vertical" STR_PORTRAIT: "Vertical"
STR_LANDSCAPE_CW: "Orizontal dreapta" STR_LANDSCAPE_CW: "Orizontal dreapta"
STR_INVERTED: "Invers" STR_INVERTED: "Invers"
STR_ORIENTATION_INVERTED: "Vertical 180°"
STR_LANDSCAPE_CCW: "Orizontal stânga" STR_LANDSCAPE_CCW: "Orizontal stânga"
STR_PREV_NEXT: "Înainte/Înapoi" STR_PREV_NEXT: "Înainte/Înapoi"
STR_NEXT_PREV: "Înapoi/Înainte" STR_NEXT_PREV: "Înapoi/Înainte"
@@ -302,3 +303,4 @@ STR_SCREENSHOT_BUTTON: "Captură ecran"
STR_AUTO_TURN_ENABLED: "Răsfoire automată: " STR_AUTO_TURN_ENABLED: "Răsfoire automată: "
STR_AUTO_TURN_PAGES_PER_MIN: "Pagini pe minut" STR_AUTO_TURN_PAGES_PER_MIN: "Pagini pe minut"
STR_TILT_PAGE_TURN: "Întoarcere pagină prin înclinare" STR_TILT_PAGE_TURN: "Întoarcere pagină prin înclinare"
STR_MANAGE_THEMES: "Gestionează temele"
+2
View File
@@ -140,6 +140,7 @@ STR_FORCE_REFRESH: "Обновление экрана"
STR_PORTRAIT: "Портрет" STR_PORTRAIT: "Портрет"
STR_LANDSCAPE_CW: "Ландшафт (CW)" STR_LANDSCAPE_CW: "Ландшафт (CW)"
STR_INVERTED: "Инверсия" STR_INVERTED: "Инверсия"
STR_ORIENTATION_INVERTED: "Портрет 180°"
STR_LANDSCAPE_CCW: "Ландшафт (CCW)" STR_LANDSCAPE_CCW: "Ландшафт (CCW)"
STR_PREV_NEXT: "Назад/Вперёд" STR_PREV_NEXT: "Назад/Вперёд"
STR_NEXT_PREV: "Вперёд/Назад" STR_NEXT_PREV: "Вперёд/Назад"
@@ -382,3 +383,4 @@ STR_FIRMWARE_WRITE_FAILED: "Ошибка записи прошивки"
STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "Не выключайте питание!" STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "Не выключайте питание!"
STR_RECOVERY_MODE: "Режим восстановления" STR_RECOVERY_MODE: "Режим восстановления"
STR_RECOVERY_MODE_HINT: "Поместите firmware.bin в корень SD-карты и выберите его" STR_RECOVERY_MODE_HINT: "Поместите firmware.bin в корень SD-карты и выберите его"
STR_MANAGE_THEMES: "Управление темами"
+2 -1
View File
@@ -137,7 +137,8 @@ STR_PAGE_TURN: "Otáčanie stránok"
STR_FORCE_REFRESH: "Obnoviť obrazovku" STR_FORCE_REFRESH: "Obnoviť obrazovku"
STR_PORTRAIT: "Na výšku" STR_PORTRAIT: "Na výšku"
STR_LANDSCAPE_CW: "Na šírku v smere hodinových ručičiek" STR_LANDSCAPE_CW: "Na šírku v smere hodinových ručičiek"
STR_INVERTED: "Obrátený" STR_INVERTED: "Invertovaný"
STR_ORIENTATION_INVERTED: "Na výšku 180°"
STR_LANDSCAPE_CCW: "Na šírku proti smeru hodinových ručičiek" STR_LANDSCAPE_CCW: "Na šírku proti smeru hodinových ručičiek"
STR_PREV_NEXT: "Predchádzajúca/Nasledujúca" STR_PREV_NEXT: "Predchádzajúca/Nasledujúca"
STR_NEXT_PREV: "Nasledujúca/Predchádzajúca" STR_NEXT_PREV: "Nasledujúca/Predchádzajúca"
+3 -1
View File
@@ -132,7 +132,8 @@ STR_SLEEP: "Spanje"
STR_PAGE_TURN: "Obračanje strani" STR_PAGE_TURN: "Obračanje strani"
STR_PORTRAIT: "Pokončno" STR_PORTRAIT: "Pokončno"
STR_LANDSCAPE_CW: "Ležeče (v smeri urinega kazalca)" STR_LANDSCAPE_CW: "Ležeče (v smeri urinega kazalca)"
STR_INVERTED: "Obrnjeno" STR_INVERTED: "Invertirano"
STR_ORIENTATION_INVERTED: "Pokončno 180°"
STR_LANDSCAPE_CCW: "Ležeče (proti smeri urinega kazalca)" STR_LANDSCAPE_CCW: "Ležeče (proti smeri urinega kazalca)"
STR_PREV_NEXT: "Nazaj/Naprej" STR_PREV_NEXT: "Nazaj/Naprej"
STR_NEXT_PREV: "Naprej/Nazaj" STR_NEXT_PREV: "Naprej/Nazaj"
@@ -299,3 +300,4 @@ STR_SCREENSHOT_BUTTON: "Naredi posnetek zaslona"
STR_AUTO_TURN_ENABLED: "Samodejno obračanje: " STR_AUTO_TURN_ENABLED: "Samodejno obračanje: "
STR_AUTO_TURN_PAGES_PER_MIN: "Samodejno obračanje (strani na minuto)" STR_AUTO_TURN_PAGES_PER_MIN: "Samodejno obračanje (strani na minuto)"
STR_TILT_PAGE_TURN: "Obračanje s priklonom" STR_TILT_PAGE_TURN: "Obračanje s priklonom"
STR_MANAGE_THEMES: "Upravljanje tem"
+2
View File
@@ -138,6 +138,7 @@ STR_FORCE_REFRESH: "Refrescar pant."
STR_PORTRAIT: "Vertical" STR_PORTRAIT: "Vertical"
STR_LANDSCAPE_CW: "Horizontal (horario)" STR_LANDSCAPE_CW: "Horizontal (horario)"
STR_INVERTED: "Invertido" STR_INVERTED: "Invertido"
STR_ORIENTATION_INVERTED: "Al revés"
STR_LANDSCAPE_CCW: "Horizontal (antihorario)" STR_LANDSCAPE_CCW: "Horizontal (antihorario)"
STR_PREV_NEXT: "Ant./Sig." STR_PREV_NEXT: "Ant./Sig."
STR_NEXT_PREV: "Sig./Ant." STR_NEXT_PREV: "Sig./Ant."
@@ -382,3 +383,4 @@ STR_FIRMWARE_WRITE_FAILED: "Falló la escritura del firmware"
STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "¡No apague el dispositivo!" STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "¡No apague el dispositivo!"
STR_RECOVERY_MODE: "Modo de recuperación" STR_RECOVERY_MODE: "Modo de recuperación"
STR_RECOVERY_MODE_HINT: "Ponga firmware.bin en la raíz de la tarj. SD y selecciónelo" STR_RECOVERY_MODE_HINT: "Ponga firmware.bin en la raíz de la tarj. SD y selecciónelo"
STR_MANAGE_THEMES: "Gestionar temas"
+2
View File
@@ -138,6 +138,7 @@ STR_FORCE_REFRESH: "Uppdatera skärmen"
STR_PORTRAIT: "Porträtt" STR_PORTRAIT: "Porträtt"
STR_LANDSCAPE_CW: "Landskap medurs" STR_LANDSCAPE_CW: "Landskap medurs"
STR_INVERTED: "Inverterad" STR_INVERTED: "Inverterad"
STR_ORIENTATION_INVERTED: "Porträtt 180°"
STR_LANDSCAPE_CCW: "Landskap moturs" STR_LANDSCAPE_CCW: "Landskap moturs"
STR_PREV_NEXT: "Förra/Nästa" STR_PREV_NEXT: "Förra/Nästa"
STR_NEXT_PREV: "Nästa/Förra" STR_NEXT_PREV: "Nästa/Förra"
@@ -379,3 +380,4 @@ STR_FIRMWARE_WRITE_FAILED: "Skrivning till firmware misslyckades"
STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "Stäng inte av!" STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "Stäng inte av!"
STR_RECOVERY_MODE: "Återställningsläge" STR_RECOVERY_MODE: "Återställningsläge"
STR_RECOVERY_MODE_HINT: "Placera firmware.bin i SD-kortroten och välj den" STR_RECOVERY_MODE_HINT: "Placera firmware.bin i SD-kortroten och välj den"
STR_MANAGE_THEMES: "Hantera teman"
+3 -1
View File
@@ -130,7 +130,8 @@ STR_SLEEP: "Uyku"
STR_PAGE_TURN: "Sayfa Çevirme" STR_PAGE_TURN: "Sayfa Çevirme"
STR_PORTRAIT: "Dikey" STR_PORTRAIT: "Dikey"
STR_LANDSCAPE_CW: "Yatay (Saat Yönü)" STR_LANDSCAPE_CW: "Yatay (Saat Yönü)"
STR_INVERTED: "Ters" STR_INVERTED: "Negatif"
STR_ORIENTATION_INVERTED: "Dikey 180°"
STR_LANDSCAPE_CCW: "Yatay (Saat Yönü Tersi)" STR_LANDSCAPE_CCW: "Yatay (Saat Yönü Tersi)"
STR_PREV_NEXT: "Önceki/Sonraki" STR_PREV_NEXT: "Önceki/Sonraki"
STR_NEXT_PREV: "Sonraki/Önceki" STR_NEXT_PREV: "Sonraki/Önceki"
@@ -302,3 +303,4 @@ STR_SELECTED: "Seçili"
STR_SHOW: "Göster" STR_SHOW: "Göster"
STR_TITLE: "Başlık" STR_TITLE: "Başlık"
STR_TILT_PAGE_TURN: "Eğerek sayfa çevirme" STR_TILT_PAGE_TURN: "Eğerek sayfa çevirme"
STR_MANAGE_THEMES: "Temaları Yönet"
+3 -1
View File
@@ -137,7 +137,8 @@ STR_PAGE_TURN: "Наст. сторінка"
STR_FORCE_REFRESH: "Оновити екран" STR_FORCE_REFRESH: "Оновити екран"
STR_PORTRAIT: "Книжкова" STR_PORTRAIT: "Книжкова"
STR_LANDSCAPE_CW: "Альбом. за год." STR_LANDSCAPE_CW: "Альбом. за год."
STR_INVERTED: "Перевернутий" STR_INVERTED: "Інверсія"
STR_ORIENTATION_INVERTED: "Книжкова 180°"
STR_LANDSCAPE_CCW: "Альбом. проти год." STR_LANDSCAPE_CCW: "Альбом. проти год."
STR_PREV_NEXT: "Попер/Наст" STR_PREV_NEXT: "Попер/Наст"
STR_NEXT_PREV: "Наст/Попер" STR_NEXT_PREV: "Наст/Попер"
@@ -379,3 +380,4 @@ STR_FIRMWARE_WRITE_FAILED: "Помилка запису прошивки"
STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "Не вимикайте пристрій!" STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "Не вимикайте пристрій!"
STR_RECOVERY_MODE: "Режим відновлення" STR_RECOVERY_MODE: "Режим відновлення"
STR_RECOVERY_MODE_HINT: "Помістіть firmware.bin у корінь SD-карти та виберіть його" STR_RECOVERY_MODE_HINT: "Помістіть firmware.bin у корінь SD-карти та виберіть його"
STR_MANAGE_THEMES: "Керування темами"
+2
View File
@@ -141,6 +141,7 @@ STR_PAGE_TURN: "Canvi de pàgina"
STR_PORTRAIT: "Vertical" STR_PORTRAIT: "Vertical"
STR_LANDSCAPE_CW: "Horitzontal horari" STR_LANDSCAPE_CW: "Horitzontal horari"
STR_INVERTED: "Invertit" STR_INVERTED: "Invertit"
STR_ORIENTATION_INVERTED: "Vertical 180°"
STR_LANDSCAPE_CCW: "Horitzontal antihorari" STR_LANDSCAPE_CCW: "Horitzontal antihorari"
STR_PREV_NEXT: "Anterior/Següent" STR_PREV_NEXT: "Anterior/Següent"
STR_NEXT_PREV: "Següent/Anterior" STR_NEXT_PREV: "Següent/Anterior"
@@ -382,3 +383,4 @@ STR_FIRMWARE_WRITE_FAILED: "Ha fallat l'escriptura del firmware"
STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "No apagueu el dispositiu!" STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "No apagueu el dispositiu!"
STR_RECOVERY_MODE: "Mode de recuperació" STR_RECOVERY_MODE: "Mode de recuperació"
STR_RECOVERY_MODE_HINT: "Poseu firmware.bin a l'arrel de la targeta SD i seleccioneu-lo" STR_RECOVERY_MODE_HINT: "Poseu firmware.bin a l'arrel de la targeta SD i seleccioneu-lo"
STR_MANAGE_THEMES: "Gestiona els temes"
+2 -1
View File
@@ -137,7 +137,8 @@ STR_PAGE_TURN: "Lật trang"
STR_FORCE_REFRESH: "Làm tươi màn hình" STR_FORCE_REFRESH: "Làm tươi màn hình"
STR_PORTRAIT: "Dọc" STR_PORTRAIT: "Dọc"
STR_LANDSCAPE_CW: "Ngang (thuận)" STR_LANDSCAPE_CW: "Ngang (thuận)"
STR_INVERTED: "Lật ngược" STR_INVERTED: "Đảo màu"
STR_ORIENTATION_INVERTED: "Dọc 180°"
STR_LANDSCAPE_CCW: "Ngang (ngược)" STR_LANDSCAPE_CCW: "Ngang (ngược)"
STR_PREV_NEXT: "Trước/Sau" STR_PREV_NEXT: "Trước/Sau"
STR_NEXT_PREV: "Sau/Trước" STR_NEXT_PREV: "Sau/Trước"
+11
View File
@@ -82,6 +82,17 @@ void HalDisplay::copyGrayscaleBuffers(const uint8_t* lsbBuffer, const uint8_t* m
} }
void HalDisplay::displayGrayscaleBase(RefreshMode fallback, bool turnOffScreen) { void HalDisplay::displayGrayscaleBase(RefreshMode fallback, bool turnOffScreen) {
// X3: a HALF fallback means the caller wants a clean base (e.g. the sleep
// cover, a full-screen swap from arbitrary prior content). Without this, the
// X3 grayscale base takes its gentle differential happy path and the prior
// home/reader frame ghosts through the soft aa_pre_bw_mid waveform. Forcing a
// resync makes displayGrayscaleBase clear first, matching displayBuffer(HALF).
// The reader's FAST path is deliberately left on the differential path so
// per-page grayscale stays cheap.
if (gpio.deviceIsX3() && fallback == RefreshMode::HALF_REFRESH) {
einkDisplay.requestResync(1);
}
einkDisplay.displayGrayscaleBase(convertRefreshMode(fallback), turnOffScreen); einkDisplay.displayGrayscaleBase(convertRefreshMode(fallback), turnOffScreen);
} }
+3 -3
View File
@@ -55,9 +55,9 @@ class HalDisplay {
void preconditionGrayscale(uint16_t x, uint16_t y, uint16_t w, uint16_t h); void preconditionGrayscale(uint16_t x, uint16_t y, uint16_t w, uint16_t h);
// Display the framebuffer as the base frame for a grayscale overlay that // Display the framebuffer as the base frame for a grayscale overlay that
// follows. X3 uses the OEM differential base waveform ("AA-pre-BW(mid)"); // follows. On X3, HALF fallback first requests a resync to match
// other panels display normally with `fallback` mode (previous behavior). // displayBuffer(HALF); FAST fallback keeps the OEM differential base waveform
// Deliberately does NOT force the X3 resync that displayBuffer(HALF) does. // ("AA-pre-BW(mid)"). Other panels display normally with `fallback` mode.
void displayGrayscaleBase(RefreshMode fallback = HALF_REFRESH, bool turnOffScreen = false); void displayGrayscaleBase(RefreshMode fallback = HALF_REFRESH, bool turnOffScreen = false);
void copyGrayscaleBuffers(const uint8_t* lsbBuffer, const uint8_t* msbBuffer); void copyGrayscaleBuffers(const uint8_t* lsbBuffer, const uint8_t* msbBuffer);
Submodule open-x4-sdk deleted from 198ad26721
+11 -5
View File
@@ -4,7 +4,7 @@ build_cache_dir = .cache
extra_configs = platformio.local.ini extra_configs = platformio.local.ini
[crosspoint] [crosspoint]
version = 1.4.0 version = 1.4.1
[base] [base]
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.37/platform-espressif32.zip platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.37/platform-espressif32.zip
@@ -35,6 +35,8 @@ build_flags =
# Increase PNG scanline buffer to support up to 2048px 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 # Default is (320*4+1)*2=2562, we need more for larger images
-DPNG_MAX_BUFFERED_PIXELS=16416 -DPNG_MAX_BUFFERED_PIXELS=16416
-DFREEINK_DEVICE_X4=1
-DFREEINK_DEVICE_X3=1
-Wno-bidi-chars -Wno-bidi-chars
-Wl,--wrap=panic_print_backtrace,--wrap=panic_abort,--wrap=bootloader_common_check_efuse_blk_validity -Wl,--wrap=panic_print_backtrace,--wrap=panic_abort,--wrap=bootloader_common_check_efuse_blk_validity
-fno-exceptions -fno-exceptions
@@ -57,10 +59,14 @@ extra_scripts =
; Libraries ; Libraries
lib_deps = lib_deps =
BatteryMonitor=symlink://open-x4-sdk/libs/hardware/BatteryMonitor BatteryMonitor=symlink://freeink-sdk/libs/hardware/BatteryMonitor
InputManager=symlink://open-x4-sdk/libs/hardware/InputManager InputManager=symlink://freeink-sdk/libs/hardware/InputManager
EInkDisplay=symlink://open-x4-sdk/libs/display/EInkDisplay EInkDisplay=symlink://freeink-sdk/libs/display/FreeInkDisplay
SDCardManager=symlink://open-x4-sdk/libs/hardware/SDCardManager SDCardManager=symlink://freeink-sdk/libs/hardware/SDCardManager
BoardConfig=symlink://freeink-sdk/libs/hardware/BoardConfig
PowerManager=symlink://freeink-sdk/libs/hardware/PowerManager
FreeInkUI=symlink://freeink-sdk/libs/ui/FreeInkUI
Icons=symlink://freeink-sdk/libs/assets/Icons
bblanchon/ArduinoJson @ 7.4.2 bblanchon/ArduinoJson @ 7.4.2
ricmoo/QRCode @ 0.0.1 ricmoo/QRCode @ 0.0.1
bitbank2/PNGdec @ 1.1.6 bitbank2/PNGdec @ 1.1.6
+117
View File
@@ -0,0 +1,117 @@
#!/usr/bin/env python3
"""Export compiled 1-bit UI icon headers as BMP assets for SD themes."""
import argparse
import re
import struct
from pathlib import Path
ICON_HEADERS = [
"book.h",
"book24.h",
"bookmark.h",
"cover.h",
"file24.h",
"folder.h",
"folder24.h",
"hotspot.h",
"image24.h",
"library.h",
"recent.h",
"settings2.h",
"text24.h",
"transfer.h",
"wifi.h",
]
def parse_icon_header(path: Path):
text = path.read_text()
size_match = re.search(r"//\s*size:\s*(\d+)x(\d+)", text)
if not size_match:
raise ValueError(f"missing size comment in {path}")
width = int(size_match.group(1))
height = int(size_match.group(2))
bitmap_match = re.search(r"static\s+const\s+uint8_t\s+\w+\s*\[\]\s*=\s*\{(?P<body>.*?)\};", text, re.DOTALL)
if not bitmap_match:
raise ValueError(f"missing bitmap data in {path}")
bitmap_body = bitmap_match.group("body")
values = [int(m.group(1), 16) for m in re.finditer(r"0x([0-9A-Fa-f]{2})", bitmap_body)]
expected = ((width + 7) // 8) * height
if len(values) != expected:
raise ValueError(f"{path}: expected {expected} bytes, found {len(values)}")
return width, height, bytes(values)
def get_bit(bitmap: bytes, width: int, x: int, y: int) -> int:
stride = (width + 7) // 8
return (bitmap[y * stride + x // 8] >> (7 - (x % 8))) & 1
def set_bit(buf: bytearray, width: int, x: int, y: int, value: int):
stride = (width + 7) // 8
if value:
buf[y * stride + x // 8] |= 1 << (7 - (x % 8))
def rotate_1bit_cw(width: int, height: int, bitmap: bytes):
rotated_width = height
rotated_height = width
rotated = bytearray(((rotated_width + 7) // 8) * rotated_height)
for y in range(height):
for x in range(width):
set_bit(rotated, rotated_width, height - 1 - y, x, get_bit(bitmap, width, x, y))
return rotated_width, rotated_height, bytes(rotated)
def write_1bit_bmp(path: Path, width: int, height: int, bitmap: bytes):
src_stride = (width + 7) // 8
dst_stride = ((width + 31) // 32) * 4
pixel_bytes = dst_stride * height
pixel_offset = 14 + 40 + 8
file_size = pixel_offset + pixel_bytes
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("wb") as out:
# BITMAPFILEHEADER
out.write(b"BM")
out.write(struct.pack("<IHHI", file_size, 0, 0, pixel_offset))
# BITMAPINFOHEADER. Negative height stores rows top-down.
out.write(struct.pack("<IiiHHIIiiII", 40, width, -height, 1, 1, 0, pixel_bytes, 0, 0, 2, 0))
# Palette index 0 = black, index 1 = white. Existing icon arrays use 1s
# for white/transparent background and 0s for ink.
out.write(bytes([0, 0, 0, 0, 255, 255, 255, 0]))
for y in range(height):
row = bitmap[y * src_stride : (y + 1) * src_stride]
out.write(row)
out.write(b"\x00" * (dst_stride - src_stride))
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--icons", default="src/components/icons")
parser.add_argument("--themes", default="../crosspoint-tools/public/themes")
args = parser.parse_args()
icon_root = Path(args.icons)
theme_root = Path(args.themes)
parsed = []
for header in ICON_HEADERS:
icon_path = icon_root / header
width, height, data = parse_icon_header(icon_path)
width, height, data = rotate_1bit_cw(width, height, data)
parsed.append((icon_path.stem, width, height, data))
for theme_dir in sorted(theme_root.iterdir()):
if not theme_dir.is_dir() or not (theme_dir / "theme.json").exists():
continue
for name, width, height, data in parsed:
write_1bit_bmp(theme_dir / "icons" / f"{name}.bmp", width, height, data)
if __name__ == "__main__":
main()
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env python3
"""Generate a themes.json manifest from SD theme package folders."""
import argparse
import json
import zlib
from pathlib import Path
def safe_theme_dirs(root: Path):
for child in sorted(root.iterdir()):
if not child.is_dir() or child.name.startswith(".") or child.name.startswith("_"):
continue
theme_json = child / "theme.json"
if theme_json.exists():
yield child
def build_manifest(root: Path, base_url: str):
themes = []
for theme_dir in safe_theme_dirs(root):
theme_doc = json.loads((theme_dir / "theme.json").read_text(encoding="utf-8"))
files = []
total = 0
for file_path in sorted(p for p in theme_dir.rglob("*") if p.is_file()):
rel = file_path.relative_to(theme_dir).as_posix()
data = file_path.read_bytes()
total += len(data)
files.append(
{
"path": rel,
"url": f"{theme_dir.name}/{rel}",
"size": len(data),
"crc32": zlib.crc32(data) & 0xFFFFFFFF,
}
)
themes.append(
{
"id": theme_doc["id"],
"name": theme_doc.get("name", theme_doc["id"]),
"version": theme_doc.get("version", 1),
"description": theme_doc.get("description", ""),
"files": files,
"totalSize": total,
}
)
return {"version": 1, "baseUrl": base_url, "themes": themes}
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--root", default="../crosspoint-tools/public/themes")
parser.add_argument("--base-url", required=True)
parser.add_argument("--output", default="../crosspoint-tools/public/themes/themes.json")
args = parser.parse_args()
manifest = build_manifest(Path(args.root), args.base_url)
output = Path(args.output)
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
if __name__ == "__main__":
main()
+6 -2
View File
@@ -65,6 +65,8 @@ class CrossPointSettings {
XTC_STATUS_BAR_MODE_COUNT XTC_STATUS_BAR_MODE_COUNT
}; };
enum STATUS_BAR_CLOCK_MODE { STATUS_BAR_CLOCK_HIDE = 0, STATUS_BAR_CLOCK_RIGHT = 1, STATUS_BAR_CLOCK_LEFT = 2 };
enum ORIENTATION { enum ORIENTATION {
PORTRAIT = 0, // 480x800 logical coordinates (current default) PORTRAIT = 0, // 480x800 logical coordinates (current default)
LANDSCAPE_CW = 1, // 800x480 logical coordinates, rotated 180° (swap top/bottom) LANDSCAPE_CW = 1, // 800x480 logical coordinates, rotated 180° (swap top/bottom)
@@ -159,7 +161,7 @@ class CrossPointSettings {
}; };
// UI Theme // UI Theme
enum UI_THEME { CLASSIC = 0, LYRA = 1, LYRA_3_COVERS = 2, ROUNDEDRAFF = 3 }; enum UI_THEME { CLASSIC = 0, LYRA = 1, LYRA_3_COVERS = 2, ROUNDEDRAFF = 3, UI_THEME_COUNT = 4 };
// Image rendering in EPUB reader // Image rendering in EPUB reader
enum IMAGE_RENDERING { IMAGES_DISPLAY = 0, IMAGES_PLACEHOLDER = 1, IMAGES_SUPPRESS = 2, IMAGE_RENDERING_COUNT }; enum IMAGE_RENDERING { IMAGES_DISPLAY = 0, IMAGES_PLACEHOLDER = 1, IMAGES_SUPPRESS = 2, IMAGE_RENDERING_COUNT };
@@ -188,7 +190,7 @@ class CrossPointSettings {
uint8_t statusBarBattery = 1; uint8_t statusBarBattery = 1;
uint8_t xtcStatusBarMode = XTC_STATUS_BAR_HIDE; uint8_t xtcStatusBarMode = XTC_STATUS_BAR_HIDE;
// Clock display in status bar (X3 only, requires DS3231 RTC) // Clock display in status bar (X3 only, requires DS3231 RTC)
uint8_t statusBarClock = 0; uint8_t statusBarClock = STATUS_BAR_CLOCK_HIDE;
// Clock UTC offset in quarter-hour steps, biased by 48 so it fits in uint8_t. // Clock UTC offset in quarter-hour steps, biased by 48 so it fits in uint8_t.
// Value 48 = UTC+0, 0 = UTC-12:00, 104 = UTC+14:00. // Value 48 = UTC+0, 0 = UTC-12:00, 104 = UTC+14:00.
// Quarter-hour granularity supports oddball zones like Nepal (+5:45) and Chatham (+12:45). // Quarter-hour granularity supports oddball zones like Nepal (+5:45) and Chatham (+12:45).
@@ -252,6 +254,8 @@ class CrossPointSettings {
uint8_t focusReadingEnabled = 0; uint8_t focusReadingEnabled = 0;
// SD card font family name (empty = use built-in fontFamily) // SD card font family name (empty = use built-in fontFamily)
char sdFontFamilyName[32] = ""; char sdFontFamilyName[32] = "";
// SD card UI theme id/name (empty = use built-in Lyra)
char sdThemeName[32] = "";
// Show hidden files/directories (starting with '.') in the file browser (0 = hidden, 1 = show) // Show hidden files/directories (starting with '.') in the file browser (0 = hidden, 1 = show)
uint8_t showHiddenFiles = 0; uint8_t showHiddenFiles = 0;
// Remove a book from the Recent Books list when its End-of-Book screen is reached (0 = off, 1 = on) // Remove a book from the Recent Books list when its End-of-Book screen is reached (0 = off, 1 = on)
+13
View File
@@ -144,10 +144,16 @@ bool JsonSettingsIO::saveSettings(const CrossPointSettings& s, const char* path)
doc["frontButtonRight"] = s.frontButtonRight; doc["frontButtonRight"] = s.frontButtonRight;
// Font family — uses dynamic getter/setter in SettingsList so the generic loop skips it. // Font family — uses dynamic getter/setter in SettingsList so the generic loop skips it.
doc["fontFamily"] = s.fontFamily; doc["fontFamily"] = s.fontFamily;
// UI theme — uses dynamic getter/setter in SettingsList so the generic loop skips it.
doc["uiTheme"] = s.uiTheme;
// SD card font family name — not in SettingsList, save manually // SD card font family name — not in SettingsList, save manually
if (s.sdFontFamilyName[0] != '\0') { if (s.sdFontFamilyName[0] != '\0') {
doc["sdFontFamilyName"] = s.sdFontFamilyName; doc["sdFontFamilyName"] = s.sdFontFamilyName;
} }
// SD card UI theme id/name — dynamic setting, save manually.
if (s.sdThemeName[0] != '\0') {
doc["sdThemeName"] = s.sdThemeName;
}
// Language -- managed by LanguageSelectActivity, not in SettingsList. // Language -- managed by LanguageSelectActivity, not in SettingsList.
// Stored as ISO code string ("EN", "DE", ...) for stability across enum reorders. // Stored as ISO code string ("EN", "DE", ...) for stability across enum reorders.
@@ -246,10 +252,17 @@ bool JsonSettingsIO::loadSettings(CrossPointSettings& s, const char* json, bool*
// Font family — uses dynamic getter/setter in SettingsList so the generic loop skips it. // Font family — uses dynamic getter/setter in SettingsList so the generic loop skips it.
const uint8_t storedFontFamily = doc["fontFamily"] | (uint8_t)0; const uint8_t storedFontFamily = doc["fontFamily"] | (uint8_t)0;
s.fontFamily = clamp(storedFontFamily, CrossPointSettings::BUILTIN_FONT_COUNT, 0); s.fontFamily = clamp(storedFontFamily, CrossPointSettings::BUILTIN_FONT_COUNT, 0);
// UI theme — uses dynamic getter/setter in SettingsList so the generic loop skips it.
s.uiTheme = clamp(doc["uiTheme"] | (uint8_t)CrossPointSettings::LYRA, (uint8_t)CrossPointSettings::UI_THEME_COUNT,
(uint8_t)CrossPointSettings::LYRA);
// SD card font family name — not in SettingsList, load manually // SD card font family name — not in SettingsList, load manually
const char* sfn = doc["sdFontFamilyName"] | ""; const char* sfn = doc["sdFontFamilyName"] | "";
strncpy(s.sdFontFamilyName, sfn, sizeof(s.sdFontFamilyName) - 1); strncpy(s.sdFontFamilyName, sfn, sizeof(s.sdFontFamilyName) - 1);
s.sdFontFamilyName[sizeof(s.sdFontFamilyName) - 1] = '\0'; s.sdFontFamilyName[sizeof(s.sdFontFamilyName) - 1] = '\0';
// SD card UI theme id/name — not in SettingsList, load manually.
const char* stn = doc["sdThemeName"] | "";
strncpy(s.sdThemeName, stn, sizeof(s.sdThemeName) - 1);
s.sdThemeName[sizeof(s.sdThemeName) - 1] = '\0';
if (storedFontFamily == CrossPointSettings::LEGACY_OPENDYSLEXIC && s.sdFontFamilyName[0] == '\0') { if (storedFontFamily == CrossPointSettings::LEGACY_OPENDYSLEXIC && s.sdFontFamilyName[0] == '\0') {
s.fontFamily = CrossPointSettings::NOTOSERIF; s.fontFamily = CrossPointSettings::NOTOSERIF;
strncpy(s.sdFontFamilyName, "OpenDyslexic", sizeof(s.sdFontFamilyName) - 1); strncpy(s.sdFontFamilyName, "OpenDyslexic", sizeof(s.sdFontFamilyName) - 1);
+75 -8
View File
@@ -13,6 +13,7 @@
#include "CrossPointSettings.h" #include "CrossPointSettings.h"
#include "KOReaderCredentialStore.h" #include "KOReaderCredentialStore.h"
#include "activities/settings/SettingsActivity.h" #include "activities/settings/SettingsActivity.h"
#include "components/themes/SdCardThemeRegistry.h"
// Build the font family setting dynamically. When registry is non-null, SD card fonts // Build the font family setting dynamically. When registry is non-null, SD card fonts
// are appended after the built-in fonts. Otherwise only built-in fonts are listed. // are appended after the built-in fonts. Otherwise only built-in fonts are listed.
@@ -90,6 +91,63 @@ inline SettingInfo buildFontFamilySetting(const SdCardFontRegistry* registry) {
return s; return s;
} }
// Build the UI theme setting dynamically. Firmware themes keep their existing
// indexes; SD card themes are appended after them.
inline SettingInfo buildUiThemeSetting(const SdCardThemeRegistry* registry) {
std::vector<std::string> allStringValues;
allStringValues.push_back(I18N.get(StrId::STR_THEME_CLASSIC));
allStringValues.push_back(I18N.get(StrId::STR_THEME_LYRA));
allStringValues.push_back(I18N.get(StrId::STR_THEME_LYRA_EXTENDED));
allStringValues.push_back(I18N.get(StrId::STR_THEME_ROUNDEDRAFF));
std::vector<std::string> sdThemeIds;
if (registry) {
const auto& themes = registry->getThemes();
sdThemeIds.reserve(themes.size());
for (const auto& theme : themes) {
allStringValues.push_back(theme.name);
sdThemeIds.push_back(theme.id);
}
}
SettingInfo s;
s.nameId = StrId::STR_UI_THEME;
s.type = SettingType::ENUM;
s.enumStringValues = std::move(allStringValues);
s.key = "uiTheme";
s.category = StrId::STR_CAT_DISPLAY;
s.valueGetter = [sdThemeIds]() -> uint8_t {
if (SETTINGS.sdThemeName[0] != '\0') {
for (int i = 0; i < static_cast<int>(sdThemeIds.size()); i++) {
if (sdThemeIds[i] == SETTINGS.sdThemeName) {
return static_cast<uint8_t>(CrossPointSettings::UI_THEME_COUNT + i);
}
}
}
return SETTINGS.uiTheme < CrossPointSettings::UI_THEME_COUNT ? SETTINGS.uiTheme : CrossPointSettings::LYRA;
};
s.valueSetter = [sdThemeIds](uint8_t v) {
if (v < CrossPointSettings::UI_THEME_COUNT) {
SETTINGS.uiTheme = v;
SETTINGS.sdThemeName[0] = '\0';
return;
}
SETTINGS.uiTheme = CrossPointSettings::UI_THEME::LYRA;
const int sdIdx = v - CrossPointSettings::UI_THEME_COUNT;
if (sdIdx < static_cast<int>(sdThemeIds.size())) {
strncpy(SETTINGS.sdThemeName, sdThemeIds[sdIdx].c_str(), sizeof(SETTINGS.sdThemeName) - 1);
SETTINGS.sdThemeName[sizeof(SETTINGS.sdThemeName) - 1] = '\0';
} else {
SETTINGS.sdThemeName[0] = '\0';
}
};
return s;
}
// Shared settings list used by both the device settings UI and the web settings API. // Shared settings list used by both the device settings UI and the web settings API.
// Each entry has a key (for JSON API) and category (for grouping). // Each entry has a key (for JSON API) and category (for grouping).
// ACTION-type entries and entries without a key are device-only. // ACTION-type entries and entries without a key are device-only.
@@ -99,7 +157,8 @@ inline SettingInfo buildFontFamilySetting(const SdCardFontRegistry* registry) {
// SdCardFontRegistry is supplied AND has SD card fonts installed, the // SdCardFontRegistry is supplied AND has SD card fonts installed, the
// font-family entry is replaced in a per-call copy with a registry-aware // font-family entry is replaced in a per-call copy with a registry-aware
// version. Callers without SD fonts pay only a vector copy. // version. Callers without SD fonts pay only a vector copy.
inline std::vector<SettingInfo> getSettingsList(const SdCardFontRegistry* registry = nullptr) { inline std::vector<SettingInfo> getSettingsList(const SdCardFontRegistry* fontRegistry = nullptr,
const SdCardThemeRegistry* themeRegistry = nullptr) {
static const std::vector<SettingInfo> baseList = [] { static const std::vector<SettingInfo> baseList = [] {
std::vector<SettingInfo> v = { std::vector<SettingInfo> v = {
// --- Display --- // --- Display ---
@@ -151,9 +210,10 @@ inline std::vector<SettingInfo> getSettingsList(const SdCardFontRegistry* regist
StrId::STR_CAT_READER), StrId::STR_CAT_READER),
SettingInfo::Toggle(StrId::STR_HYPHENATION, &CrossPointSettings::hyphenationEnabled, "hyphenationEnabled", SettingInfo::Toggle(StrId::STR_HYPHENATION, &CrossPointSettings::hyphenationEnabled, "hyphenationEnabled",
StrId::STR_CAT_READER), StrId::STR_CAT_READER),
SettingInfo::Enum(StrId::STR_ORIENTATION, &CrossPointSettings::orientation, SettingInfo::Enum(
{StrId::STR_PORTRAIT, StrId::STR_LANDSCAPE_CW, StrId::STR_INVERTED, StrId::STR_LANDSCAPE_CCW}, StrId::STR_ORIENTATION, &CrossPointSettings::orientation,
"orientation", StrId::STR_CAT_READER), {StrId::STR_PORTRAIT, StrId::STR_LANDSCAPE_CW, StrId::STR_ORIENTATION_INVERTED, StrId::STR_LANDSCAPE_CCW},
"orientation", StrId::STR_CAT_READER),
SettingInfo::Toggle(StrId::STR_EXTRA_SPACING, &CrossPointSettings::extraParagraphSpacing, SettingInfo::Toggle(StrId::STR_EXTRA_SPACING, &CrossPointSettings::extraParagraphSpacing,
"extraParagraphSpacing", StrId::STR_CAT_READER), "extraParagraphSpacing", StrId::STR_CAT_READER),
SettingInfo::Toggle(StrId::STR_TEXT_AA, &CrossPointSettings::textAntiAliasing, "textAntiAliasing", SettingInfo::Toggle(StrId::STR_TEXT_AA, &CrossPointSettings::textAntiAliasing, "textAntiAliasing",
@@ -244,8 +304,9 @@ inline std::vector<SettingInfo> getSettingsList(const SdCardFontRegistry* regist
StrId::STR_CUSTOMISE_STATUS_BAR), StrId::STR_CUSTOMISE_STATUS_BAR),
// Clock entries (web settings only; device UI uses ClockOffsetActivity for the offset). // Clock entries (web settings only; device UI uses ClockOffsetActivity for the offset).
// Range 0..104 = quarter-hour steps from UTC-12:00 to UTC+14:00, biased by 48. // Range 0..104 = quarter-hour steps from UTC-12:00 to UTC+14:00, biased by 48.
SettingInfo::Toggle(StrId::STR_CLOCK, &CrossPointSettings::statusBarClock, "statusBarClock", SettingInfo::Enum(StrId::STR_CLOCK, &CrossPointSettings::statusBarClock,
StrId::STR_CUSTOMISE_STATUS_BAR), {StrId::STR_HIDE, StrId::STR_DIR_LEFT, StrId::STR_DIR_RIGHT}, "statusBarClock",
StrId::STR_CUSTOMISE_STATUS_BAR),
SettingInfo::Value(StrId::STR_CLOCK_UTC_OFFSET, &CrossPointSettings::clockUtcOffsetQ, {0, 104, 1}, SettingInfo::Value(StrId::STR_CLOCK_UTC_OFFSET, &CrossPointSettings::clockUtcOffsetQ, {0, 104, 1},
"clockUtcOffsetQ", StrId::STR_CUSTOMISE_STATUS_BAR), "clockUtcOffsetQ", StrId::STR_CUSTOMISE_STATUS_BAR),
SettingInfo::Enum(StrId::STR_CLOCK_FORMAT, &CrossPointSettings::clockFormat, SettingInfo::Enum(StrId::STR_CLOCK_FORMAT, &CrossPointSettings::clockFormat,
@@ -272,10 +333,16 @@ inline std::vector<SettingInfo> getSettingsList(const SdCardFontRegistry* regist
}(); }();
std::vector<SettingInfo> v = baseList; std::vector<SettingInfo> v = baseList;
if (registry && registry->getFamilyCount() > 0) { {
auto it = std::find_if(v.begin(), v.end(), [](const SettingInfo& s) { return s.nameId == StrId::STR_UI_THEME; });
if (it != v.end()) {
*it = buildUiThemeSetting(themeRegistry);
}
}
if (fontRegistry && fontRegistry->getFamilyCount() > 0) {
auto it = std::find_if(v.begin(), v.end(), [](const SettingInfo& s) { return s.nameId == StrId::STR_FONT_FAMILY; }); auto it = std::find_if(v.begin(), v.end(), [](const SettingInfo& s) { return s.nameId == StrId::STR_FONT_FAMILY; });
if (it != v.end()) { if (it != v.end()) {
*it = buildFontFamilySetting(registry); *it = buildFontFamilySetting(fontRegistry);
} }
} }
return v; return v;
+128
View File
@@ -0,0 +1,128 @@
#include "ThemeInstaller.h"
#include <HalStorage.h>
#include <Logging.h>
#include <cctype>
#include <cstdio>
#include <cstring>
#include "CrossPointSettings.h"
ThemeInstaller::ThemeInstaller(SdCardThemeRegistry& registry) : registry_(registry) {}
bool ThemeInstaller::isValidThemeId(const char* id) {
if (id == nullptr || id[0] == '\0') return false;
if (strstr(id, "..") != nullptr || strchr(id, '/') != nullptr || strchr(id, '\\') != nullptr) return false;
for (const char* p = id; *p; ++p) {
const char c = *p;
if (!std::isalnum(static_cast<unsigned char>(c)) && c != '-' && c != '_') return false;
}
return true;
}
bool ThemeInstaller::isValidRelativePath(const char* path) {
if (path == nullptr || path[0] == '\0' || path[0] == '/') return false;
if (strstr(path, "..") != nullptr || strchr(path, '\\') != nullptr) return false;
bool segmentHasChar = false;
for (const char* p = path; *p; ++p) {
const char c = *p;
if (c == '/') {
if (!segmentHasChar) return false;
segmentHasChar = false;
continue;
}
if (!std::isalnum(static_cast<unsigned char>(c)) && c != '-' && c != '_' && c != '.') return false;
segmentHasChar = true;
}
return segmentHasChar;
}
bool ThemeInstaller::ensureThemeDir(const char* themeId) {
if (!isValidThemeId(themeId)) return false;
const char* root = SdCardThemeRegistry::findThemeRoot(themeId);
if (!root) root = SdCardThemeRegistry::defaultWriteRoot();
if (!Storage.exists(root) && !Storage.mkdir(root)) {
LOG_ERR("THEME", "Failed to create themes dir: %s", root);
return false;
}
char dirPath[180];
const int written = snprintf(dirPath, sizeof(dirPath), "%s/%s", root, themeId);
if (written < 0 || static_cast<size_t>(written) >= sizeof(dirPath)) {
LOG_ERR("THEME", "Theme dir path too long: %s", themeId);
return false;
}
if (!Storage.exists(dirPath) && !Storage.mkdir(dirPath)) {
LOG_ERR("THEME", "Failed to create theme dir: %s", dirPath);
return false;
}
return true;
}
bool ThemeInstaller::ensureParentDirs(const char* fullPath) {
if (!fullPath) return false;
char dir[180];
const int written = snprintf(dir, sizeof(dir), "%s", fullPath);
if (written < 0 || static_cast<size_t>(written) >= sizeof(dir)) {
LOG_ERR("THEME", "Theme parent path too long");
return false;
}
char* slash = strrchr(dir, '/');
if (!slash) return true;
*slash = '\0';
return Storage.ensureDirectoryExists(dir);
}
bool ThemeInstaller::validateThemeFile(const char* path) {
HalFile file;
if (!Storage.openFileForRead("THEME", path, file)) return false;
const bool ok = file.fileSize() > 0;
file.close();
return ok;
}
bool ThemeInstaller::buildThemePath(const char* themeId, const char* relativePath, char* outBuf, size_t outBufSize) {
if (!themeId || !relativePath || !outBuf || outBufSize == 0) return false;
const char* root = SdCardThemeRegistry::findThemeRoot(themeId);
if (!root) root = SdCardThemeRegistry::defaultWriteRoot();
const int written = snprintf(outBuf, outBufSize, "%s/%s/%s", root, themeId, relativePath);
if (written < 0 || static_cast<size_t>(written) >= outBufSize) {
LOG_ERR("THEME", "Theme file path too long: %s/%s", themeId, relativePath);
return false;
}
return true;
}
ThemeInstaller::Error ThemeInstaller::deleteTheme(const char* themeId) {
if (!isValidThemeId(themeId)) return Error::INVALID_THEME_ID;
const char* roots[] = {SdCardThemeRegistry::THEMES_DIR_HIDDEN, SdCardThemeRegistry::THEMES_DIR_VISIBLE};
for (const char* root : roots) {
char dirPath[180];
const int written = snprintf(dirPath, sizeof(dirPath), "%s/%s", root, themeId);
if (written < 0 || static_cast<size_t>(written) >= sizeof(dirPath)) {
LOG_ERR("THEME", "Theme dir path too long: %s", themeId);
return Error::INVALID_THEME_ID;
}
if (!Storage.exists(dirPath)) continue;
if (!Storage.removeDir(dirPath)) {
LOG_ERR("THEME", "Failed to remove theme dir: %s", dirPath);
return Error::SD_WRITE_ERROR;
}
}
if (strcmp(SETTINGS.sdThemeName, themeId) == 0) {
SETTINGS.sdThemeName[0] = '\0';
SETTINGS.uiTheme = CrossPointSettings::LYRA;
SETTINGS.saveToFile();
}
return Error::OK;
}
void ThemeInstaller::refreshRegistry() { registry_.discover(); }
bool ThemeInstaller::isThemeInstalled(const char* themeId) const { return registry_.findTheme(themeId) != nullptr; }
+30
View File
@@ -0,0 +1,30 @@
#pragma once
#include <cstddef>
#include "components/themes/SdCardThemeRegistry.h"
class ThemeInstaller {
public:
enum class Error {
OK,
INVALID_THEME_ID,
INVALID_FILE,
SD_WRITE_ERROR,
};
explicit ThemeInstaller(SdCardThemeRegistry& registry);
static bool isValidThemeId(const char* id);
static bool isValidRelativePath(const char* path);
bool ensureThemeDir(const char* themeId);
bool ensureParentDirs(const char* fullPath);
bool validateThemeFile(const char* path);
static bool buildThemePath(const char* themeId, const char* relativePath, char* outBuf, size_t outBufSize);
Error deleteTheme(const char* themeId);
void refreshRegistry();
bool isThemeInstalled(const char* themeId) const;
private:
SdCardThemeRegistry& registry_;
};
+5
View File
@@ -9,6 +9,7 @@
#include "boot_sleep/BootActivity.h" #include "boot_sleep/BootActivity.h"
#include "boot_sleep/SleepActivity.h" #include "boot_sleep/SleepActivity.h"
#include "browser/OpdsBookBrowserActivity.h" #include "browser/OpdsBookBrowserActivity.h"
#include "components/UITheme.h"
#include "home/CrashActivity.h" #include "home/CrashActivity.h"
#include "home/FileBrowserActivity.h" #include "home/FileBrowserActivity.h"
#include "home/HomeActivity.h" #include "home/HomeActivity.h"
@@ -170,6 +171,7 @@ void ActivityManager::replaceActivity(std::unique_ptr<Activity>&& newActivity) {
} }
void ActivityManager::goToFileTransfer() { void ActivityManager::goToFileTransfer() {
UITheme::getInstance().releaseSdThemeAssetMemory();
replaceActivity(std::make_unique<CrossPointWebServerActivity>(renderer, mappedInput)); replaceActivity(std::make_unique<CrossPointWebServerActivity>(renderer, mappedInput));
} }
@@ -184,6 +186,7 @@ void ActivityManager::goToRecentBooks() {
} }
void ActivityManager::goToBrowser() { void ActivityManager::goToBrowser() {
UITheme::getInstance().releaseSdThemeAssetMemory();
const auto& servers = OPDS_STORE.getServers(); const auto& servers = OPDS_STORE.getServers();
// Skip the server picker when there's only one server configured // Skip the server picker when there's only one server configured
if (servers.size() == 1) { if (servers.size() == 1) {
@@ -194,6 +197,7 @@ void ActivityManager::goToBrowser() {
} }
void ActivityManager::goToReader(std::string path) { void ActivityManager::goToReader(std::string path) {
UITheme::getInstance().releaseSdThemeAssetMemory();
replaceActivity(std::make_unique<ReaderActivity>(renderer, mappedInput, std::move(path))); replaceActivity(std::make_unique<ReaderActivity>(renderer, mappedInput, std::move(path)));
} }
@@ -223,6 +227,7 @@ void ActivityManager::goHome(HomeMenuItem initialMenuItem) {
initialMenuItem = HomeMenuItem::SETTINGS_MENU; initialMenuItem = HomeMenuItem::SETTINGS_MENU;
} }
} }
UITheme::getInstance().reload();
replaceActivity(std::make_unique<HomeActivity>(renderer, mappedInput, initialMenuItem)); replaceActivity(std::make_unique<HomeActivity>(renderer, mappedInput, initialMenuItem));
} }
void ActivityManager::goToCrashReport() { replaceActivity(std::make_unique<CrashActivity>(renderer, mappedInput)); } void ActivityManager::goToCrashReport() { replaceActivity(std::make_unique<CrashActivity>(renderer, mappedInput)); }
+48 -15
View File
@@ -7,6 +7,7 @@
#include <Memory.h> #include <Memory.h>
#include <algorithm> #include <algorithm>
#include <vector>
#include "CrossPointSettings.h" #include "CrossPointSettings.h"
#include "MappedInputManager.h" #include "MappedInputManager.h"
@@ -355,30 +356,60 @@ void FileBrowserActivity::render(RenderLock&&) {
(mode == Mode::PickFirmware) (mode == Mode::PickFirmware)
? std::string(tr(STR_SELECT_FIRMWARE_FILE)) ? std::string(tr(STR_SELECT_FIRMWARE_FILE))
: ((basepath == "/") ? std::string(tr(STR_SD_CARD)) : basepath.substr(basepath.rfind('/') + 1)); : ((basepath == "/") ? std::string(tr(STR_SD_CARD)) : basepath.substr(basepath.rfind('/') + 1));
GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, folderName.c_str());
const ThemeScreenSpec* screenSpec = UITheme::getInstance().getScreenSpec(ThemeScreenKind::FileBrowser);
ThemeLayoutSlots slots;
Rect headerRect{0, metrics.topPadding, pageWidth, metrics.headerHeight};
Rect listRect;
Rect pathRect;
Rect buttonsRect{0, pageHeight - metrics.buttonHintsHeight, pageWidth, metrics.buttonHintsHeight};
const int pathLineHeight = renderer.getLineHeight(SMALL_FONT_ID); const int pathLineHeight = renderer.getLineHeight(SMALL_FONT_ID);
const int pathReserved = pathLineHeight + metrics.verticalSpacing; const int pathReserved = pathLineHeight + metrics.verticalSpacing;
const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing; if (screenSpec != nullptr) {
const int contentHeight = layoutThemeSlots(screenSpec->layout, Rect{0, 0, pageWidth, pageHeight}, metrics, slots);
pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing - pathReserved; headerRect = normalizeThemeHeaderSlot(findThemeSlot(slots, "header"), metrics);
if (files.empty()) { listRect = findThemeSlot(slots, "list");
pathRect = findThemeSlot(slots, "path");
buttonsRect = findThemeSlot(slots, "buttons");
if (listRect.width <= 0 || listRect.height <= 0) {
LOG_ERR("FileBrowser", "Invalid SD file layout: slots=%d; using built-in layout", static_cast<int>(slots.size()));
screenSpec = nullptr;
}
}
if (screenSpec == nullptr) {
const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
const int contentHeight =
pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing - pathReserved;
headerRect = Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight};
listRect = Rect{0, contentTop, pageWidth, contentHeight};
pathRect = Rect{metrics.contentSidePadding,
pageHeight - metrics.buttonHintsHeight - metrics.verticalSpacing - pathLineHeight,
pageWidth - metrics.contentSidePadding * 2, pathLineHeight};
buttonsRect = Rect{0, pageHeight - metrics.buttonHintsHeight, pageWidth, metrics.buttonHintsHeight};
}
if (headerRect.width > 0 && headerRect.height > 0) {
GUI.drawHeader(renderer, headerRect, folderName.c_str());
}
if (listRect.width <= 0 || listRect.height <= 0) {
// Malformed theme layout: no list slot to draw into.
} else if (files.empty()) {
const char* emptyMsg = (mode == Mode::PickFirmware) ? tr(STR_NO_BIN_FILES) : tr(STR_NO_FILES_FOUND); const char* emptyMsg = (mode == Mode::PickFirmware) ? tr(STR_NO_BIN_FILES) : tr(STR_NO_FILES_FOUND);
renderer.drawText(UI_10_FONT_ID, metrics.contentSidePadding, contentTop + 20, emptyMsg); renderer.drawText(UI_10_FONT_ID, metrics.contentSidePadding, listRect.y + 20, emptyMsg);
} else { } else {
GUI.drawList( GUI.drawList(
renderer, Rect{0, contentTop, pageWidth, contentHeight}, files.size(), selectorIndex, renderer, listRect, files.size(), selectorIndex, [this](int index) { return getFileName(files[index]); },
[this](int index) { return getFileName(files[index]); }, nullptr, 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); [this](int index) { return getFileExtension(files[index]); }, false);
} }
// Full path display // Full path display
{ if (pathRect.width > 0 && pathRect.height > 0) {
const int pathY = pageHeight - metrics.buttonHintsHeight - metrics.verticalSpacing - pathLineHeight; const int pathY = pathRect.y;
const int separatorY = pathY - metrics.verticalSpacing / 2; const int separatorY = pathRect.y - metrics.verticalSpacing / 2;
renderer.drawLine(0, separatorY, pageWidth - 1, separatorY, 3, true); renderer.drawLine(0, separatorY, pageWidth - 1, separatorY, 3, true);
const int pathMaxWidth = pageWidth - metrics.contentSidePadding * 2; const int pathMaxWidth = pathRect.width;
// Left-truncate so the deepest directory is always visible // Left-truncate so the deepest directory is always visible
const char* pathStr = basepath.c_str(); const char* pathStr = basepath.c_str();
const char* pathDisplay = pathStr; const char* pathDisplay = pathStr;
@@ -397,7 +428,7 @@ void FileBrowserActivity::render(RenderLock&&) {
snprintf(leftTruncBuf, sizeof(leftTruncBuf), "%s%s", ellipsis, p); snprintf(leftTruncBuf, sizeof(leftTruncBuf), "%s%s", ellipsis, p);
pathDisplay = leftTruncBuf; pathDisplay = leftTruncBuf;
} }
renderer.drawText(SMALL_FONT_ID, metrics.contentSidePadding, pathY, pathDisplay); renderer.drawText(SMALL_FONT_ID, pathRect.x, pathY, pathDisplay);
} }
// Help text // Help text
@@ -408,7 +439,9 @@ void FileBrowserActivity::render(RenderLock&&) {
const char* confirmLabel = files.empty() ? "" : (selectingFirmwareFile ? tr(STR_SELECT) : tr(STR_OPEN)); const char* confirmLabel = files.empty() ? "" : (selectingFirmwareFile ? tr(STR_SELECT) : tr(STR_OPEN));
const auto labels = mappedInput.mapLabels(backLabel, confirmLabel, files.empty() ? "" : tr(STR_DIR_UP), const auto labels = mappedInput.mapLabels(backLabel, confirmLabel, files.empty() ? "" : tr(STR_DIR_UP),
files.empty() ? "" : tr(STR_DIR_DOWN)); files.empty() ? "" : tr(STR_DIR_DOWN));
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); if (buttonsRect.width > 0 && buttonsRect.height > 0) {
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
}
renderer.displayBuffer(); renderer.displayBuffer();
} }
+250 -74
View File
@@ -1,34 +1,40 @@
#include "HomeActivity.h" #include "HomeActivity.h"
#include <Bitmap.h>
#include <Epub.h> #include <Epub.h>
#include <FsHelpers.h> #include <FsHelpers.h>
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <HalStorage.h> #include <HalStorage.h>
#include <I18n.h> #include <I18n.h>
#include <Utf8.h> #include <Memory.h>
#include <Xtc.h> #include <Xtc.h>
#include <cstring> #include <algorithm>
#include <vector> #include <vector>
#include "CrossPointSettings.h"
#include "CrossPointState.h"
#include "MappedInputManager.h" #include "MappedInputManager.h"
#include "OpdsServerStore.h" #include "OpdsServerStore.h"
#include "RecentBooksStore.h" #include "RecentBooksStore.h"
#include "components/UITheme.h" #include "components/UITheme.h"
#include "fontIds.h"
int HomeActivity::getMenuItemCount() const { void HomeActivity::buildHomeActions(std::vector<ThemeHomeActionEntry>& actions) const {
int count = 4; // File Browser, Recents, File transfer, Settings buildThemeHomeActions(UITheme::getInstance().getHomeScreenSpec(), recentBooks, hasOpdsServers, actions);
if (!recentBooks.empty()) { }
count += recentBooks.size();
} const std::vector<ThemeHomeActionEntry>& HomeActivity::refreshHomeActions() {
if (hasOpdsServers) { buildHomeActions(homeActions);
count++; return homeActions;
} }
return count;
int HomeActivity::getMenuItemCount() { return static_cast<int>(refreshHomeActions().size()); }
bool HomeActivity::storeCoverBufferCallback(void* userData) {
auto* activity = static_cast<HomeActivity*>(userData);
return activity != nullptr && activity->storeCoverBuffer();
}
bool HomeActivity::restoreCoverBufferCallback(void* userData) {
auto* activity = static_cast<HomeActivity*>(userData);
return activity != nullptr && activity->restoreCoverBuffer();
} }
void HomeActivity::loadRecentBooks(int maxBooks) { void HomeActivity::loadRecentBooks(int maxBooks) {
@@ -51,7 +57,7 @@ void HomeActivity::loadRecentBooks(int maxBooks) {
} }
} }
void HomeActivity::loadRecentCovers(int coverHeight) { void HomeActivity::loadRecentCovers(const std::vector<int>& coverHeights) {
recentsLoading = true; recentsLoading = true;
bool showingLoading = false; bool showingLoading = false;
Rect popupRect; Rect popupRect;
@@ -59,8 +65,16 @@ void HomeActivity::loadRecentCovers(int coverHeight) {
int progress = 0; int progress = 0;
for (RecentBook& book : recentBooks) { for (RecentBook& book : recentBooks) {
if (!book.coverBmpPath.empty()) { if (!book.coverBmpPath.empty()) {
std::string coverPath = UITheme::getCoverThumbPath(book.coverBmpPath, coverHeight); bool hasMissingThumb = false;
if (!Storage.exists(coverPath.c_str())) { for (const int coverHeight : coverHeights) {
std::string coverPath = UITheme::getCoverThumbPath(book.coverBmpPath, coverHeight);
if (!Storage.exists(coverPath.c_str())) {
hasMissingThumb = true;
break;
}
}
if (hasMissingThumb) {
// If epub, try to load the metadata for title/author and cover // If epub, try to load the metadata for title/author and cover
if (FsHelpers::hasEpubExtension(book.path)) { if (FsHelpers::hasEpubExtension(book.path)) {
Epub epub(book.path, "/.crosspoint"); Epub epub(book.path, "/.crosspoint");
@@ -73,7 +87,13 @@ void HomeActivity::loadRecentCovers(int coverHeight) {
popupRect = GUI.drawPopup(renderer, tr(STR_LOADING_POPUP)); popupRect = GUI.drawPopup(renderer, tr(STR_LOADING_POPUP));
} }
GUI.fillPopupProgress(renderer, popupRect, 10 + progress * (90 / recentBooks.size())); GUI.fillPopupProgress(renderer, popupRect, 10 + progress * (90 / recentBooks.size()));
bool success = epub.generateThumbBmp(coverHeight); bool success = true;
for (const int coverHeight : coverHeights) {
std::string coverPath = UITheme::getCoverThumbPath(book.coverBmpPath, coverHeight);
if (!Storage.exists(coverPath.c_str())) {
success = epub.generateThumbBmp(coverHeight) && success;
}
}
if (!success) { if (!success) {
RECENT_BOOKS.updateBook(book.path, book.title, book.author, ""); RECENT_BOOKS.updateBook(book.path, book.title, book.author, "");
book.coverBmpPath = ""; book.coverBmpPath = "";
@@ -90,7 +110,13 @@ void HomeActivity::loadRecentCovers(int coverHeight) {
popupRect = GUI.drawPopup(renderer, tr(STR_LOADING_POPUP)); popupRect = GUI.drawPopup(renderer, tr(STR_LOADING_POPUP));
} }
GUI.fillPopupProgress(renderer, popupRect, 10 + progress * (90 / recentBooks.size())); GUI.fillPopupProgress(renderer, popupRect, 10 + progress * (90 / recentBooks.size()));
bool success = xtc.generateThumbBmp(coverHeight); bool success = true;
for (const int coverHeight : coverHeights) {
std::string coverPath = UITheme::getCoverThumbPath(book.coverBmpPath, coverHeight);
if (!Storage.exists(coverPath.c_str())) {
success = xtc.generateThumbBmp(coverHeight) && success;
}
}
if (!success) { if (!success) {
RECENT_BOOKS.updateBook(book.path, book.title, book.author, ""); RECENT_BOOKS.updateBook(book.path, book.title, book.author, "");
book.coverBmpPath = ""; book.coverBmpPath = "";
@@ -115,9 +141,46 @@ void HomeActivity::onEnter() {
const auto& metrics = UITheme::getInstance().getMetrics(); const auto& metrics = UITheme::getInstance().getMetrics();
loadRecentBooks(metrics.homeRecentBooksCount); loadRecentBooks(metrics.homeRecentBooksCount);
LOG_DBG("HOME", "Loaded %d/%d recent book(s) for home theme", static_cast<int>(recentBooks.size()),
metrics.homeRecentBooksCount);
const auto base = static_cast<int>(recentBooks.size()); const auto& actions = refreshHomeActions();
selectorIndex = initialMenuItem == HomeMenuItem::NONE ? 0 : base + menuItemToIndex(initialMenuItem, hasOpdsServers); const ThemeHomeScreenSpec* homeSpec = UITheme::getInstance().getHomeScreenSpec();
selectorIndex = 0;
bool hasWantedAction = initialMenuItem != HomeMenuItem::NONE;
const auto wantedAction = [this]() {
switch (initialMenuItem) {
case HomeMenuItem::RECENTS:
return ThemeHomeAction::RecentBooks;
case HomeMenuItem::OPDS_BROWSER:
return ThemeHomeAction::OpdsBrowser;
case HomeMenuItem::FILE_TRANSFER:
return ThemeHomeAction::FileTransfer;
case HomeMenuItem::SETTINGS_MENU:
return ThemeHomeAction::Settings;
case HomeMenuItem::FILE_BROWSER:
case HomeMenuItem::NONE:
default:
return ThemeHomeAction::FileBrowser;
}
}();
ThemeHomeAction selectedEntryAction = wantedAction;
if (!hasWantedAction && homeSpec != nullptr && homeSpec->hasInitialAction) {
hasWantedAction = true;
selectedEntryAction = homeSpec->initialAction;
}
if (hasWantedAction) {
for (int i = 0; i < static_cast<int>(actions.size()); ++i) {
if (actions[i].action == selectedEntryAction) {
selectorIndex = i;
break;
}
}
}
coverSelectorIndex = !recentBooks.empty() && selectorIndex < static_cast<int>(actions.size()) &&
actions[selectorIndex].action == ThemeHomeAction::RecentBook
? actions[selectorIndex].value
: 0;
// Trigger first update // Trigger first update
requestUpdate(); requestUpdate();
@@ -137,72 +200,123 @@ bool HomeActivity::storeCoverBuffer() {
freeCoverBuffer(); freeCoverBuffer();
const size_t needed = renderer.getRegionByteSize(coverRectX, coverRectY, coverRectW, coverRectH); const size_t needed = renderer.getRegionByteSize(coverRectX, coverRectY, coverRectW, coverRectH);
if (needed == 0) return false; if (needed == 0) return false;
coverBuffer = static_cast<uint8_t*>(malloc(needed)); coverBuffer = makeUniqueNoThrow<uint8_t[]>(needed);
if (!coverBuffer) { if (!coverBuffer) {
LOG_ERR("HOME", "OOM: cover buffer (%u bytes)", (unsigned)needed); LOG_ERR("HOME", "OOM: cover buffer (%u bytes)", (unsigned)needed);
return false; return false;
} }
coverBufferSize = needed; coverBufferSize = needed;
if (!renderer.copyRegionToBuffer(coverRectX, coverRectY, coverRectW, coverRectH, coverBuffer, coverBufferSize)) { if (!renderer.copyRegionToBuffer(coverRectX, coverRectY, coverRectW, coverRectH, coverBuffer.get(),
free(coverBuffer); coverBufferSize)) {
coverBuffer = nullptr; coverBuffer.reset();
coverBufferSize = 0; coverBufferSize = 0;
return false; return false;
} }
coverBufferSelectorIndex = coverSelectorIndex;
const auto& actions = refreshHomeActions();
coverBufferStripSelected = selectorIndex >= 0 && selectorIndex < static_cast<int>(actions.size()) &&
actions[selectorIndex].action == ThemeHomeAction::RecentBook;
return true; return true;
} }
bool HomeActivity::restoreCoverBuffer() { bool HomeActivity::restoreCoverBuffer() {
if (!coverBuffer || coverRectW <= 0 || coverRectH <= 0) return false; if (!coverBuffer || coverRectW <= 0 || coverRectH <= 0) return false;
return renderer.copyBufferToRegion(coverRectX, coverRectY, coverRectW, coverRectH, coverBuffer, coverBufferSize); return renderer.copyBufferToRegion(coverRectX, coverRectY, coverRectW, coverRectH, coverBuffer.get(),
coverBufferSize);
} }
void HomeActivity::freeCoverBuffer() { void HomeActivity::freeCoverBuffer() {
if (coverBuffer) { coverBuffer.reset();
free(coverBuffer);
coverBuffer = nullptr;
}
coverBufferSize = 0; coverBufferSize = 0;
coverBufferStored = false; coverBufferStored = false;
coverBufferSelectorIndex = -1;
coverBufferStripSelected = false;
} }
void HomeActivity::loop() { void HomeActivity::loop() {
const int menuCount = getMenuItemCount(); const int menuCount = getMenuItemCount();
const ThemeHomeScreenSpec* homeSpec = UITheme::getInstance().getHomeScreenSpec();
buttonNavigator.onNext([this, menuCount] { auto updateCoverSelection = [this]() {
selectorIndex = ButtonNavigator::nextIndex(selectorIndex, menuCount); const auto& actions = refreshHomeActions();
requestUpdate(); if (selectorIndex < static_cast<int>(actions.size()) &&
}); actions[selectorIndex].action == ThemeHomeAction::RecentBook) {
coverSelectorIndex = actions[selectorIndex].value;
}
};
buttonNavigator.onPrevious([this, menuCount] { auto moveWithin = [this, &updateCoverSelection](bool wantRecentBook, int delta) {
selectorIndex = ButtonNavigator::previousIndex(selectorIndex, menuCount); const auto& actions = refreshHomeActions();
if (actions.empty()) return;
navigationIndices.clear();
navigationIndices.reserve(actions.size());
for (int i = 0; i < static_cast<int>(actions.size()); ++i) {
if ((actions[i].action == ThemeHomeAction::RecentBook) == wantRecentBook) {
navigationIndices.push_back(i);
}
}
if (navigationIndices.empty()) return;
auto current = std::find(navigationIndices.begin(), navigationIndices.end(), selectorIndex);
int groupIndex = current == navigationIndices.end() ? (delta > 0 ? -1 : 0)
: static_cast<int>(current - navigationIndices.begin());
groupIndex =
(groupIndex + delta + static_cast<int>(navigationIndices.size())) % static_cast<int>(navigationIndices.size());
selectorIndex = navigationIndices[groupIndex];
updateCoverSelection();
requestUpdate(); requestUpdate();
}); };
if (homeSpec != nullptr && homeSpec->navigation == ThemeHomeNavigationMode::SplitAxis) {
buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Right}, [moveWithin] { moveWithin(false, 1); });
buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Left}, [moveWithin] { moveWithin(false, -1); });
buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Down}, [moveWithin] { moveWithin(true, 1); });
buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Up}, [moveWithin] { moveWithin(true, -1); });
} else if (homeSpec != nullptr && homeSpec->navigation == ThemeHomeNavigationMode::CarouselAxis) {
buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Right}, [moveWithin] { moveWithin(true, 1); });
buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Left}, [moveWithin] { moveWithin(true, -1); });
buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Down}, [moveWithin] { moveWithin(false, 1); });
buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Up}, [moveWithin] { moveWithin(false, -1); });
} else {
buttonNavigator.onNext([this, menuCount, &updateCoverSelection] {
selectorIndex = ButtonNavigator::nextIndex(selectorIndex, menuCount);
updateCoverSelection();
requestUpdate();
});
buttonNavigator.onPrevious([this, menuCount, &updateCoverSelection] {
selectorIndex = ButtonNavigator::previousIndex(selectorIndex, menuCount);
updateCoverSelection();
requestUpdate();
});
}
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (selectorIndex < recentBooks.size()) { const auto& actions = refreshHomeActions();
onSelectBook(recentBooks[selectorIndex].path); if (selectorIndex < 0 || selectorIndex >= static_cast<int>(actions.size())) return;
} else { const auto& entry = actions[selectorIndex];
const int menuIndex = selectorIndex - static_cast<int>(recentBooks.size()); switch (entry.action) {
switch (indexToMenuItem(menuIndex, hasOpdsServers)) { case ThemeHomeAction::RecentBook:
case HomeMenuItem::FILE_BROWSER: if (entry.value >= 0 && entry.value < static_cast<int>(recentBooks.size()))
onFileBrowserOpen(); onSelectBook(recentBooks[entry.value].path);
break; break;
case HomeMenuItem::RECENTS: case ThemeHomeAction::RecentBooks:
onRecentsOpen(); onRecentsOpen();
break; break;
case HomeMenuItem::OPDS_BROWSER: case ThemeHomeAction::OpdsBrowser:
onOpdsBrowserOpen(); onOpdsBrowserOpen();
break; break;
case HomeMenuItem::FILE_TRANSFER: case ThemeHomeAction::FileTransfer:
onFileTransferOpen(); onFileTransferOpen();
break; break;
case HomeMenuItem::SETTINGS_MENU: case ThemeHomeAction::Settings:
onSettingsOpen(); onSettingsOpen();
break; break;
default: case ThemeHomeAction::FileBrowser:
break; default:
} onFileBrowserOpen();
break;
} }
} }
} }
@@ -211,24 +325,86 @@ void HomeActivity::render(RenderLock&&) {
const auto& metrics = UITheme::getInstance().getMetrics(); const auto& metrics = UITheme::getInstance().getMetrics();
const auto pageWidth = renderer.getScreenWidth(); const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = renderer.getScreenHeight(); const auto pageHeight = renderer.getScreenHeight();
constexpr int coverCacheBleed = 12;
const ThemeHomeScreenSpec* homeSpec = UITheme::getInstance().getHomeScreenSpec();
if (homeSpec != nullptr) {
const auto& actions = refreshHomeActions();
ThemeHomeRenderContext context{renderer,
mappedInput,
metrics,
*homeSpec,
recentBooks,
actions,
hasOpdsServers,
selectorIndex,
coverSelectorIndex,
coverRendered,
coverBufferStored,
coverBufferSelectorIndex,
coverBufferStripSelected,
coverRectX,
coverRectY,
coverRectW,
coverRectH,
this,
&HomeActivity::storeCoverBufferCallback,
&HomeActivity::restoreCoverBufferCallback};
if (renderThemeHome(context)) {
if (!firstRenderDone) {
firstRenderDone = true;
requestUpdate();
} else if (!recentsLoaded && !recentsLoading && !UITheme::getInstance().getHomeCoverThumbHeights().empty()) {
recentsLoading = true;
loadRecentCovers(UITheme::getInstance().getHomeCoverThumbHeights());
}
return;
}
}
const bool hasCoverArea = metrics.homeCoverTileHeight > 0 && metrics.homeCoverHeight > 0;
renderer.clearScreen(); renderer.clearScreen();
bool bufferRestored = coverBufferStored && restoreCoverBuffer();
GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.homeTopPadding},
metrics.homeContinueReadingInMenu && !recentBooks.empty() ? recentBooks[0].title.c_str() : nullptr);
// Record the tile rect so storeCoverBuffer (called from the theme) knows // Record the tile rect so storeCoverBuffer (called from the theme) knows
// which sub-region of the framebuffer to snapshot. ~16 KB in Portrait // which sub-region of the framebuffer to snapshot. Include a small bleed
// instead of the 48 KB full framebuffer the previous bind captured. // because cover-strip themes can draw selection outlines just outside the
// nominal cover tile.
coverRectX = 0; coverRectX = 0;
coverRectY = metrics.homeTopPadding; coverRectY = hasCoverArea ? std::max(0, metrics.homeTopPadding - coverCacheBleed) : 0;
coverRectW = pageWidth; coverRectW = pageWidth;
coverRectH = metrics.homeCoverTileHeight; coverRectH = hasCoverArea
? std::min(pageHeight - coverRectY,
metrics.homeCoverTileHeight + (metrics.homeTopPadding - coverRectY) + coverCacheBleed)
: 0;
GUI.drawRecentBookCover(renderer, Rect{0, metrics.homeTopPadding, pageWidth, metrics.homeCoverTileHeight}, GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.homeTopPadding},
recentBooks, selectorIndex, coverRendered, coverBufferStored, bufferRestored, metrics.homeContinueReadingInMenu && metrics.homeShowContinueReadingHeader && !recentBooks.empty()
std::bind(&HomeActivity::storeCoverBuffer, this)); ? recentBooks[std::min(coverSelectorIndex, static_cast<int>(recentBooks.size()) - 1)].title.c_str()
: nullptr);
const bool selectorSensitiveCoverCache = GUI.homeCoverCacheDependsOnSelector();
const bool coverStripSelected = metrics.homeContinueReadingInMenu
? selectorIndex == 0 && !recentBooks.empty()
: selectorIndex < static_cast<int>(recentBooks.size());
const bool coverCacheMatches = !selectorSensitiveCoverCache || (coverBufferSelectorIndex == coverSelectorIndex &&
coverBufferStripSelected == coverStripSelected);
if (hasCoverArea && coverBufferStored && !coverCacheMatches) {
freeCoverBuffer();
coverRendered = false;
}
bool bufferRestored = hasCoverArea && coverBufferStored && coverCacheMatches && restoreCoverBuffer();
if (hasCoverArea) {
GUI.drawRecentBookCover(
renderer, Rect{0, metrics.homeTopPadding, pageWidth, metrics.homeCoverTileHeight}, recentBooks,
coverSelectorIndex, coverRendered, coverBufferStored, bufferRestored, [this]() { return storeCoverBuffer(); },
coverStripSelected);
} else {
coverRendered = false;
coverBufferStored = false;
bufferRestored = false;
}
// Build menu items dynamically // Build menu items dynamically
std::vector<const char*> menuItems = {tr(STR_BROWSE_FILES), tr(STR_MENU_RECENT_BOOKS), tr(STR_FILE_TRANSFER), std::vector<const char*> menuItems = {tr(STR_BROWSE_FILES), tr(STR_MENU_RECENT_BOOKS), tr(STR_FILE_TRANSFER),
@@ -264,9 +440,9 @@ void HomeActivity::render(RenderLock&&) {
if (!firstRenderDone) { if (!firstRenderDone) {
firstRenderDone = true; firstRenderDone = true;
requestUpdate(); requestUpdate();
} else if (!recentsLoaded && !recentsLoading) { } else if (!recentsLoaded && !recentsLoading && !UITheme::getInstance().getHomeCoverThumbHeights().empty()) {
recentsLoading = true; recentsLoading = true;
loadRecentCovers(metrics.homeCoverHeight); loadRecentCovers(UITheme::getInstance().getHomeCoverThumbHeights());
} }
} }
+20 -36
View File
@@ -1,25 +1,28 @@
#pragma once #pragma once
#include <functional> #include <memory>
#include <vector> #include <vector>
#include "./FileBrowserActivity.h" #include "./FileBrowserActivity.h"
#include "./ThemeHomeRenderer.h"
#include "activities/Activity.h" #include "activities/Activity.h"
#include "util/ButtonNavigator.h" #include "util/ButtonNavigator.h"
struct RecentBook; struct RecentBook;
struct Rect;
class HomeActivity final : public Activity { class HomeActivity final : public Activity {
ButtonNavigator buttonNavigator; ButtonNavigator buttonNavigator;
int selectorIndex = 0; int selectorIndex = 0;
int coverSelectorIndex = 0;
bool recentsLoading = false; bool recentsLoading = false;
bool recentsLoaded = false; bool recentsLoaded = false;
bool firstRenderDone = false; bool firstRenderDone = false;
bool hasOpdsServers = false; bool hasOpdsServers = false;
bool coverRendered = false; // Track if cover has been rendered once bool coverRendered = false;
bool coverBufferStored = false; // Track if cover buffer is stored bool coverBufferStored = false;
uint8_t* coverBuffer = nullptr; // HomeActivity's own buffer for cover image std::unique_ptr<uint8_t[]> coverBuffer;
size_t coverBufferSize = 0; // Bytes allocated to coverBuffer size_t coverBufferSize = 0;
int coverBufferSelectorIndex = -1;
bool coverBufferStripSelected = false;
// Logical rect last passed to drawRecentBookCover. The cover snapshot only // Logical rect last passed to drawRecentBookCover. The cover snapshot only
// needs to cover this region, not the entire framebuffer, so we cache the // needs to cover this region, not the entire framebuffer, so we cache the
// tile instead of all 48 KB. Set in render() before the call. // tile instead of all 48 KB. Set in render() before the call.
@@ -28,33 +31,10 @@ class HomeActivity final : public Activity {
int coverRectW = 0; int coverRectW = 0;
int coverRectH = 0; int coverRectH = 0;
std::vector<RecentBook> recentBooks; std::vector<RecentBook> recentBooks;
std::vector<ThemeHomeActionEntry> homeActions;
std::vector<int> navigationIndices;
const HomeMenuItem initialMenuItem; const HomeMenuItem initialMenuItem;
// Convert HomeMenuItem to menu index (used in onEnter)
static int menuItemToIndex(HomeMenuItem item, bool hasOpdsUrl) {
int i = 0;
if (item == HomeMenuItem::FILE_BROWSER) return i;
++i;
if (item == HomeMenuItem::RECENTS) return i;
++i;
if (item == HomeMenuItem::OPDS_BROWSER) return hasOpdsUrl ? i : 0;
if (hasOpdsUrl) ++i;
if (item == HomeMenuItem::FILE_TRANSFER) return i;
++i;
if (item == HomeMenuItem::SETTINGS_MENU) return i;
return 0;
}
// Convert menu index to HomeMenuItem (used in loop)
static HomeMenuItem indexToMenuItem(int idx, bool hasOpdsUrl) {
int i = 0;
if (idx == i++) return HomeMenuItem::FILE_BROWSER;
if (idx == i++) return HomeMenuItem::RECENTS;
if (hasOpdsUrl && idx == i++) return HomeMenuItem::OPDS_BROWSER;
if (idx == i++) return HomeMenuItem::FILE_TRANSFER;
if (idx == i) return HomeMenuItem::SETTINGS_MENU;
return HomeMenuItem::NONE;
}
void onSelectBook(const std::string& path); void onSelectBook(const std::string& path);
void onFileBrowserOpen(); void onFileBrowserOpen();
void onRecentsOpen(); void onRecentsOpen();
@@ -62,12 +42,16 @@ class HomeActivity final : public Activity {
void onFileTransferOpen(); void onFileTransferOpen();
void onOpdsBrowserOpen(); void onOpdsBrowserOpen();
int getMenuItemCount() const; void buildHomeActions(std::vector<ThemeHomeActionEntry>& actions) const;
bool storeCoverBuffer(); // Store frame buffer for cover image const std::vector<ThemeHomeActionEntry>& refreshHomeActions();
bool restoreCoverBuffer(); // Restore frame buffer from stored cover int getMenuItemCount();
void freeCoverBuffer(); // Free the stored cover buffer static bool storeCoverBufferCallback(void* userData);
static bool restoreCoverBufferCallback(void* userData);
bool storeCoverBuffer();
bool restoreCoverBuffer();
void freeCoverBuffer();
void loadRecentBooks(int maxBooks); void loadRecentBooks(int maxBooks);
void loadRecentCovers(int coverHeight); void loadRecentCovers(const std::vector<int>& coverHeights);
public: public:
explicit HomeActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, explicit HomeActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
@@ -0,0 +1,91 @@
#include "RecentBookCoverPainter.h"
#include <Bitmap.h>
#include <GfxRenderer.h>
#include <HalStorage.h>
#include <algorithm>
#include "RecentBooksStore.h"
#include "components/UITheme.h"
#include "components/icons/cover.h"
namespace {
constexpr int kCoverIconSourceSize = 32;
void drawScaledCoverIcon(const GfxRenderer& renderer, int x, int y, int size) {
if (size <= 0) return;
constexpr int bytesPerRow = kCoverIconSourceSize / 8;
for (int destY = 0; destY < size; ++destY) {
const int sourceY = destY * kCoverIconSourceSize / size;
for (int destX = 0; destX < size; ++destX) {
const int sourceX = destX * kCoverIconSourceSize / size;
const uint8_t rowByte = CoverIcon[sourceY * bytesPerRow + sourceX / 8];
const bool background = (rowByte >> (7 - (sourceX % 8))) & 0x01;
if (background) continue;
renderer.drawPixel(x + size - 1 - destY, y + destX, true);
}
}
}
} // namespace
void drawDefaultRecentCover(const GfxRenderer& renderer, freeink::ui::Rect rect, int placeholderIconSize) {
renderer.fillRect(rect.x, rect.y, rect.width, rect.height, false);
const freeink::ui::Rect coverRect = rect;
renderer.drawRect(coverRect.x, coverRect.y, coverRect.width, coverRect.height, true);
renderer.fillRect(coverRect.x, coverRect.y + coverRect.height / 3, coverRect.width, 2 * coverRect.height / 3, true);
const int whiteBandHeight = std::max(1, coverRect.height / 3);
const int maxIconSize = std::max(1, std::min({coverRect.width - 12, whiteBandHeight - 4, coverRect.height - 12}));
const int iconSize = std::min(placeholderIconSize > 0 ? placeholderIconSize : 32, maxIconSize);
drawScaledCoverIcon(renderer, coverRect.x + std::max(0, (coverRect.width - iconSize) / 2),
coverRect.y + std::max(0, (whiteBandHeight - iconSize) / 2), iconSize);
}
bool paintRecentBookCoverByIndex(freeink::ui::Rect rect, int bookIndex, void* userData) {
auto* data = static_cast<RecentBookCoverPainterData*>(userData);
if (data == nullptr || data->renderer == nullptr || data->books == nullptr) return false;
if (bookIndex < 0 || bookIndex >= static_cast<int>(data->books->size())) return false;
const RecentBook& book = (*data->books)[bookIndex];
if (book.coverBmpPath.empty()) {
drawDefaultRecentCover(*data->renderer, rect, data->placeholderIconSize);
return true;
}
const int thumbHeight = data->coverHeight > 0 ? data->coverHeight : rect.height;
const std::string coverBmpPath = UITheme::getCoverThumbPath(book.coverBmpPath, thumbHeight);
HalFile file;
if (!Storage.openFileForRead("HOME", coverBmpPath, file)) {
drawDefaultRecentCover(*data->renderer, rect, data->placeholderIconSize);
return true;
}
Bitmap bitmap(file);
if (bitmap.parseHeaders() != BmpReaderError::Ok) {
drawDefaultRecentCover(*data->renderer, rect, data->placeholderIconSize);
return true;
}
data->renderer->fillRect(rect.x, rect.y, rect.width, rect.height, false);
float cropX = 0.0f;
float cropY = 0.0f;
const float bitmapAspect = static_cast<float>(bitmap.getWidth()) / static_cast<float>(bitmap.getHeight());
const float targetAspect = static_cast<float>(rect.width) / static_cast<float>(rect.height);
if (bitmapAspect > targetAspect) {
cropX = std::max(0.0f, 1.0f - targetAspect / bitmapAspect);
} else if (bitmapAspect < targetAspect) {
cropY = std::max(0.0f, 1.0f - bitmapAspect / targetAspect);
}
data->renderer->drawBitmap(bitmap, rect.x, rect.y, rect.width, rect.height, cropX, cropY);
return true;
}
bool paintRecentCoverGridCover(freeink::ui::DrawTarget&, freeink::ui::Rect rect, const freeink::ui::CoverGridItem& item,
uint16_t, void* userData) {
return paintRecentBookCoverByIndex(rect, item.actionValue, userData);
}
bool paintBookCardCover(freeink::ui::DrawTarget&, freeink::ui::Rect rect, const freeink::ui::BookCardProps& props,
void* userData) {
return paintRecentBookCoverByIndex(rect, props.value, userData);
}
@@ -0,0 +1,22 @@
#pragma once
#include <FreeInkUI.h>
#include <vector>
class GfxRenderer;
struct RecentBook;
struct RecentBookCoverPainterData {
const GfxRenderer* renderer = nullptr;
const std::vector<RecentBook>* books = nullptr;
int coverHeight = 0;
int placeholderIconSize = 0;
};
void drawDefaultRecentCover(const GfxRenderer& renderer, freeink::ui::Rect rect, int placeholderIconSize = 0);
bool paintRecentBookCoverByIndex(freeink::ui::Rect rect, int bookIndex, void* userData);
bool paintRecentCoverGridCover(freeink::ui::DrawTarget& target, freeink::ui::Rect rect,
const freeink::ui::CoverGridItem& item, uint16_t index, void* userData);
bool paintBookCardCover(freeink::ui::DrawTarget& target, freeink::ui::Rect rect,
const freeink::ui::BookCardProps& props, void* userData);
+165 -13
View File
@@ -1,13 +1,17 @@
#include "RecentBooksActivity.h" #include "RecentBooksActivity.h"
#include <FreeInkUIGfxRenderer.h>
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <HalStorage.h> #include <HalStorage.h>
#include <I18n.h> #include <I18n.h>
#include <Logging.h>
#include <algorithm> #include <algorithm>
#include <memory> #include <memory>
#include <vector>
#include "MappedInputManager.h" #include "MappedInputManager.h"
#include "RecentBookCoverPainter.h"
#include "RecentBooksStore.h" #include "RecentBooksStore.h"
#include "activities/util/ConfirmationActivity.h" #include "activities/util/ConfirmationActivity.h"
#include "components/UITheme.h" #include "components/UITheme.h"
@@ -16,10 +20,118 @@
namespace { namespace {
// Hold threshold for the long-press "remove from list" action (firmware convention). // Hold threshold for the long-press "remove from list" action (firmware convention).
constexpr unsigned long LONG_PRESS_MS = 1000; constexpr unsigned long LONG_PRESS_MS = 1000;
struct RecentBooksRects {
Rect header;
Rect list;
Rect buttons;
bool themed = false;
};
struct RecentBooksCoverGridItemProviderData {
const std::vector<RecentBook>* recentBooks = nullptr;
};
freeink::ui::CoverGridItem provideRecentBooksCoverGridItem(uint16_t index, void* userData) {
auto* data = static_cast<RecentBooksCoverGridItemProviderData*>(userData);
if (data == nullptr || data->recentBooks == nullptr || index >= data->recentBooks->size()) return {};
return freeink::ui::coverGridItem((*data->recentBooks)[index].title.c_str(), index);
}
const ThemeCoverGridWidgetSpec* recentBooksCoverGridWidget(const ThemeScreenSpec* screenSpec) {
if (screenSpec == nullptr) return nullptr;
const auto it = std::find_if(screenSpec->widgets.begin(), screenSpec->widgets.end(),
[](const auto& widget) { return widget.type == ThemeScreenWidgetType::CoverGrid; });
return it == screenSpec->widgets.end() ? nullptr : &it->coverGrid;
}
ThemeCoverGridWidgetSpec normalizedRecentBooksCoverGridSpec(const ThemeCoverGridWidgetSpec& source) {
ThemeCoverGridWidgetSpec spec = source;
if (!spec.configured) {
spec.columns = 3;
spec.gap = 14;
spec.rowGap = 20;
spec.coverWidth = 92;
spec.coverHeight = 132;
spec.rowHeight = 172;
spec.labelHeight = 34;
spec.labelLines = 2;
spec.selectedRadius = 0;
spec.selectionStyle = ThemeWidgetSelectionStyle::CoverFrame;
spec.cellInset.top = 5;
spec.labelInset.left = 5;
spec.labelInset.right = 5;
}
spec.columns = std::max(1, spec.columns);
spec.rowGap = spec.rowGap >= 0 ? spec.rowGap : std::max(0, spec.gap);
spec.coverHeight = spec.coverHeight > 0 ? spec.coverHeight : 132;
spec.coverWidth = spec.coverWidth > 0 ? spec.coverWidth : std::max(1, spec.coverHeight * 62 / 100);
spec.placeholderIconSize = std::max(0, spec.placeholderIconSize);
spec.labelHeight = std::max(0, spec.labelHeight);
spec.labelGap = std::max(0, spec.labelGap);
spec.labelLines = std::max(1, std::min(3, spec.labelLines));
spec.rowHeight = spec.rowHeight > 0 ? spec.rowHeight : spec.coverHeight + spec.labelHeight + 6;
return spec;
}
RecentBooksRects resolveRecentBooksRects(GfxRenderer& renderer, const ThemeMetrics& metrics,
const ThemeScreenSpec*& screenSpec) {
const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = renderer.getScreenHeight();
RecentBooksRects rects;
if (screenSpec != nullptr) {
ThemeLayoutSlots slots;
layoutThemeSlots(screenSpec->layout, Rect{0, 0, pageWidth, pageHeight}, metrics, slots);
rects.header = normalizeThemeHeaderSlot(findThemeSlot(slots, "header"), metrics);
rects.list = findThemeSlot(slots, "list");
rects.buttons = findThemeSlot(slots, "buttons");
if (rects.list.width > 0 && rects.list.height > 0) {
rects.themed = true;
return rects;
}
LOG_ERR("RecentBooks", "Invalid SD recent layout: slots=%d; using built-in layout", static_cast<int>(slots.size()));
screenSpec = nullptr;
}
const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing;
rects.header = Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight};
rects.list = Rect{0, contentTop, pageWidth, contentHeight};
rects.buttons = Rect{0, pageHeight - metrics.buttonHintsHeight, pageWidth, metrics.buttonHintsHeight};
return rects;
}
int recentBooksCoverGridPageItems(Rect listRect, const ThemeCoverGridWidgetSpec& spec) {
return std::max<int>(1, freeink::ui::coverGridVisibleCells(
freeink::ui::makeRect(listRect.x, listRect.y, listRect.width, listRect.height),
std::min<int>(std::max(1, spec.columns), 12), freeink::ui::clampI16(spec.rowHeight, 1),
freeink::ui::clampI16(spec.rowGap)));
}
freeink::ui::Insets toFreeInkInsets(const ThemeEdgeInsets& insets) {
return freeink::ui::makeInsets(insets.top, insets.right, insets.bottom, insets.left);
}
freeink::ui::StyleSet recentBooksGridStyles(const ThemeCoverGridWidgetSpec& spec) {
return freeink::ui::selectedOutlineListRowStyles(spec.selectedRadius);
}
} // namespace } // namespace
void RecentBooksActivity::loadRecentBooks() { recentBooks = RECENT_BOOKS.getBooks(); } void RecentBooksActivity::loadRecentBooks() { recentBooks = RECENT_BOOKS.getBooks(); }
int RecentBooksActivity::getPageItems() {
auto& theme = UITheme::getInstance();
const ThemeScreenSpec* screenSpec = theme.getScreenSpec(ThemeScreenKind::RecentBooks);
const auto rects = resolveRecentBooksRects(renderer, theme.getMetrics(), screenSpec);
if (rects.themed) {
const ThemeCoverGridWidgetSpec* grid = recentBooksCoverGridWidget(screenSpec);
if (grid != nullptr) return recentBooksCoverGridPageItems(rects.list, normalizedRecentBooksCoverGridSpec(*grid));
}
return theme.getNumberOfItemsPerPage(renderer, true, false, true, true);
}
void RecentBooksActivity::onEnter() { void RecentBooksActivity::onEnter() {
Activity::onEnter(); Activity::onEnter();
@@ -42,7 +154,7 @@ void RecentBooksActivity::onExit() {
} }
void RecentBooksActivity::loop() { void RecentBooksActivity::loop() {
const int pageItems = UITheme::getInstance().getNumberOfItemsPerPage(renderer, true, false, true, true); const int pageItems = getPageItems();
// After a long-press has fired, swallow input until Confirm is physically released // After a long-press has fired, swallow input until Confirm is physically released
// (so the release doesn't also open the book; re-arm only once the button is up). // (so the release doesn't also open the book; re-arm only once the button is up).
@@ -124,28 +236,68 @@ void RecentBooksActivity::promptRemoveBook(const std::string& path, const std::s
void RecentBooksActivity::render(RenderLock&&) { void RecentBooksActivity::render(RenderLock&&) {
renderer.clearScreen(); renderer.clearScreen();
const auto pageWidth = renderer.getScreenWidth(); auto& theme = UITheme::getInstance();
const auto pageHeight = renderer.getScreenHeight(); const auto& metrics = theme.getMetrics();
const auto& metrics = UITheme::getInstance().getMetrics(); const ThemeScreenSpec* screenSpec = theme.getScreenSpec(ThemeScreenKind::RecentBooks);
const auto rects = resolveRecentBooksRects(renderer, metrics, screenSpec);
const ThemeCoverGridWidgetSpec* coverGridWidget = rects.themed ? recentBooksCoverGridWidget(screenSpec) : nullptr;
GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, tr(STR_MENU_RECENT_BOOKS)); if (rects.header.width > 0 && rects.header.height > 0) {
GUI.drawHeader(renderer, rects.header, tr(STR_MENU_RECENT_BOOKS));
const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing; }
const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing;
// Recent tab // Recent tab
if (recentBooks.empty()) { if (rects.list.width <= 0 || rects.list.height <= 0) {
renderer.drawText(UI_10_FONT_ID, metrics.contentSidePadding, contentTop + 20, tr(STR_NO_RECENT_BOOKS)); // Malformed theme layout: no list slot to draw into.
} else if (recentBooks.empty()) {
renderer.drawText(UI_10_FONT_ID, metrics.contentSidePadding, rects.list.y + 20, tr(STR_NO_RECENT_BOOKS));
} else if (coverGridWidget != nullptr) {
#if FREEINK_HAVE_GFX_RENDERER
const auto gridSpec = normalizedRecentBooksCoverGridSpec(*coverGridWidget);
freeink::ui::GfxRendererFrame<> ui(renderer, SMALL_FONT_ID, UI_10_FONT_ID, UI_12_FONT_ID);
RecentBookCoverPainterData painterData{
&renderer, &recentBooks, UITheme::getInstance().getRecentBooksCoverThumbHeight(), gridSpec.placeholderIconSize};
RecentBooksCoverGridItemProviderData itemProviderData{&recentBooks};
const int pageItems = recentBooksCoverGridPageItems(rects.list, gridSpec);
freeink::ui::CoverGridProps props;
props.itemProvider = provideRecentBooksCoverGridItem;
props.itemProviderUserData = &itemProviderData;
props.count = static_cast<uint16_t>(std::min<size_t>(recentBooks.size(), 65535));
props.topIndex = freeink::ui::coverGridTopIndexFor(
static_cast<uint16_t>(selectorIndex), static_cast<uint16_t>(std::min<size_t>(recentBooks.size(), 65535)),
std::min<int>(std::max(1, gridSpec.columns), 12), static_cast<uint16_t>(pageItems));
props.selectedIndex = static_cast<int16_t>(selectorIndex);
props.columns = static_cast<uint8_t>(std::min(std::max(1, gridSpec.columns), 12));
props.gap = freeink::ui::clampI16(gridSpec.gap);
props.rowGap = freeink::ui::clampI16(gridSpec.rowGap);
props.cellInset = toFreeInkInsets(gridSpec.cellInset);
props.labelInset = toFreeInkInsets(gridSpec.labelInset);
props.coverSize = freeink::ui::makeSize(gridSpec.coverWidth, gridSpec.coverHeight);
props.rowHeight = freeink::ui::clampI16(gridSpec.rowHeight, 1);
props.labelHeight = freeink::ui::clampI16(gridSpec.labelHeight);
props.labelGap = freeink::ui::clampI16(gridSpec.labelGap);
props.titleText.font = freeink::ui::GfxRendererTarget::FONT_SMALL;
props.titleText.maxLines = static_cast<uint8_t>(std::max(1, std::min(3, gridSpec.labelLines)));
props.cellStyles = recentBooksGridStyles(gridSpec);
props.selectionIndicator = freeink::ui::CoverGridSelectionIndicator::CoverFrame;
props.selectedCoverFrameRadius = freeink::ui::clampRadius(gridSpec.selectedRadius);
props.coverPainter = paintRecentCoverGridCover;
props.coverPainterUserData = &painterData;
freeink::ui::coverGrid(
ui.frame, freeink::ui::makeRect(rects.list.x, rects.list.y, rects.list.width, rects.list.height), props);
#endif
} else { } else {
GUI.drawList( GUI.drawList(
renderer, Rect{0, contentTop, pageWidth, contentHeight}, recentBooks.size(), selectorIndex, renderer, rects.list, recentBooks.size(), selectorIndex, [this](int index) { return recentBooks[index].title; },
[this](int index) { return recentBooks[index].title; }, [this](int index) { return recentBooks[index].author; }, [this](int index) { return recentBooks[index].author; },
[this](int index) { return UITheme::getFileIcon(recentBooks[index].path); }); [this](int index) { return UITheme::getFileIcon(recentBooks[index].path); });
} }
// Help text // Help text
const auto labels = mappedInput.mapLabels(tr(STR_HOME), tr(STR_OPEN), tr(STR_DIR_UP), tr(STR_DIR_DOWN)); const auto labels = mappedInput.mapLabels(tr(STR_HOME), tr(STR_OPEN), tr(STR_DIR_UP), tr(STR_DIR_DOWN));
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); if (rects.buttons.width > 0 && rects.buttons.height > 0) {
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
}
renderer.displayBuffer(); renderer.displayBuffer();
} }
@@ -24,6 +24,7 @@ class RecentBooksActivity final : public Activity {
// Data loading // Data loading
void loadRecentBooks(); void loadRecentBooks();
int getPageItems();
// Show an OK/Cancel prompt to remove the given book from the Recent Books list. // Show an OK/Cancel prompt to remove the given book from the Recent Books list.
void promptRemoveBook(const std::string& path, const std::string& title); void promptRemoveBook(const std::string& path, const std::string& title);
+561
View File
@@ -0,0 +1,561 @@
#include "ThemeHomeRenderer.h"
#include <FreeInkUIGfxRenderer.h>
#include <GfxRenderer.h>
#include <HalClock.h>
#include <I18n.h>
#include <Logging.h>
#include <algorithm>
#include <array>
#include <cstring>
#include <vector>
#include "CrossPointSettings.h"
#include "MappedInputManager.h"
#include "RecentBookCoverPainter.h"
#include "RecentBooksStore.h"
#include "components/UITheme.h"
#include "components/icons/book.h"
#include "components/icons/folder.h"
#include "components/icons/library.h"
#include "components/icons/recent.h"
#include "components/icons/settings2.h"
#include "components/icons/transfer.h"
#include "fontIds.h"
namespace {
constexpr int kCoverCacheBleed = 12;
const char* defaultLauncherLabel(ThemeHomeAction action) {
switch (action) {
case ThemeHomeAction::RecentBooks:
return tr(STR_MENU_RECENT_BOOKS);
case ThemeHomeAction::OpdsBrowser:
return tr(STR_OPDS_BROWSER);
case ThemeHomeAction::FileTransfer:
return tr(STR_FILE_TRANSFER);
case ThemeHomeAction::Settings:
return tr(STR_SETTINGS_TITLE);
case ThemeHomeAction::RecentBook:
return tr(STR_CONTINUE_READING);
case ThemeHomeAction::FileBrowser:
default:
return tr(STR_BROWSE_FILES);
}
}
const char* buttonHintLabel(ThemeButtonHintLabel label, const char* fallback) {
switch (label) {
case ThemeButtonHintLabel::Empty:
return "";
case ThemeButtonHintLabel::Back:
return tr(STR_BACK);
case ThemeButtonHintLabel::Home:
return tr(STR_HOME);
case ThemeButtonHintLabel::Select:
return tr(STR_SELECT);
case ThemeButtonHintLabel::Confirm:
return tr(STR_CONFIRM);
case ThemeButtonHintLabel::Open:
return tr(STR_OPEN);
case ThemeButtonHintLabel::Toggle:
return tr(STR_TOGGLE);
case ThemeButtonHintLabel::Up:
return tr(STR_DIR_UP);
case ThemeButtonHintLabel::Down:
return tr(STR_DIR_DOWN);
case ThemeButtonHintLabel::Left:
return tr(STR_DIR_LEFT);
case ThemeButtonHintLabel::Right:
return tr(STR_DIR_RIGHT);
case ThemeButtonHintLabel::Default:
default:
return fallback;
}
}
UIIcon defaultLauncherIcon(ThemeHomeAction action) {
switch (action) {
case ThemeHomeAction::RecentBooks:
return UIIcon::Recent;
case ThemeHomeAction::OpdsBrowser:
return UIIcon::Library;
case ThemeHomeAction::FileTransfer:
return UIIcon::Transfer;
case ThemeHomeAction::Settings:
return UIIcon::Settings;
case ThemeHomeAction::RecentBook:
return UIIcon::Book;
case ThemeHomeAction::FileBrowser:
default:
return UIIcon::Folder;
}
}
std::string homeHeaderTitle(const ThemeMetrics& metrics, const std::vector<RecentBook>& recentBooks,
const int coverSelectorIndex) {
if (metrics.homeContinueReadingInMenu && metrics.homeShowContinueReadingHeader && !recentBooks.empty()) {
return recentBooks[std::min(coverSelectorIndex, static_cast<int>(recentBooks.size()) - 1)].title;
}
return "";
}
Rect placedWidgetRect(Rect slot, const ThemeHomeWidgetSpec& widget) {
slot.x += widget.offsetX - widget.bleed.left;
slot.y += widget.offsetY - widget.bleed.top;
slot.width += widget.bleed.left + widget.bleed.right;
slot.height += widget.bleed.top + widget.bleed.bottom;
slot.x += widget.inset.left;
slot.y += widget.inset.top;
slot.width -= widget.inset.left + widget.inset.right;
slot.height -= widget.inset.top + widget.inset.bottom;
return slot;
}
freeink::ui::Insets toFreeInkInsets(const ThemeEdgeInsets& insets) {
return freeink::ui::makeInsets(insets.top, insets.right, insets.bottom, insets.left);
}
freeink::ui::StyleSet widgetSelectionStyles(ThemeWidgetSelectionStyle selectionStyle, int selectedRadius) {
if (selectionStyle == ThemeWidgetSelectionStyle::Outline) {
return freeink::ui::selectedOutlineListRowStyles(selectedRadius);
}
if (selectionStyle == ThemeWidgetSelectionStyle::None) return freeink::ui::selectedPlainListRowStyles();
return freeink::ui::defaultListRowStyles();
}
freeink::ui::CoverGridSelectionIndicator coverGridSelectionIndicator(ThemeWidgetSelectionStyle selectionStyle) {
return selectionStyle == ThemeWidgetSelectionStyle::CoverFrame ? freeink::ui::CoverGridSelectionIndicator::CoverFrame
: freeink::ui::CoverGridSelectionIndicator::Cell;
}
const uint8_t* homeTabIcon(UIIcon icon) {
switch (icon) {
case UIIcon::Folder:
return FolderIcon;
case UIIcon::Book:
return BookIcon;
case UIIcon::Recent:
return RecentIcon;
case UIIcon::Library:
return LibraryIcon;
case UIIcon::Transfer:
return TransferIcon;
case UIIcon::Settings:
return Settings2Icon;
default:
return nullptr;
}
}
struct HomeIconTabPainterData {
const GfxRenderer* renderer = nullptr;
const ThemeHomeLauncherSpec* const* launchers = nullptr;
size_t launcherCount = 0;
};
struct HomeCoverGridItemProviderData {
const std::vector<RecentBook>* recentBooks = nullptr;
int startIndex = 0;
};
freeink::ui::CoverGridItem provideHomeCoverGridItem(uint16_t index, void* userData) {
auto* data = static_cast<HomeCoverGridItemProviderData*>(userData);
if (data == nullptr || data->recentBooks == nullptr) return {};
const int bookIndex = data->startIndex + static_cast<int>(index);
if (bookIndex < 0 || bookIndex >= static_cast<int>(data->recentBooks->size())) return {};
return freeink::ui::coverGridItem((*data->recentBooks)[bookIndex].title.c_str(), bookIndex);
}
bool paintHomeIconTab(freeink::ui::DrawTarget&, freeink::ui::Rect rect, const freeink::ui::TabItem& tab, uint8_t,
void* userData) {
auto* data = static_cast<HomeIconTabPainterData*>(userData);
if (data == nullptr || data->renderer == nullptr || data->launchers == nullptr) return false;
const int index = tab.value;
if (index < 0 || index >= static_cast<int>(data->launcherCount)) return false;
const auto& launcher = *data->launchers[index];
const uint8_t* icon =
homeTabIcon(launcher.icon == UIIcon::None ? defaultLauncherIcon(launcher.action) : launcher.icon);
if (icon == nullptr) return false;
data->renderer->drawIcon(icon, rect.x, rect.y, rect.width, rect.height);
return true;
}
struct WidgetRenderEntry {
const ThemeHomeWidgetSpec* widget;
int actionOffset;
size_t order;
};
struct WidgetRenderEntries {
std::array<WidgetRenderEntry, kMaxThemeWidgets> items;
size_t count = 0;
void push(const WidgetRenderEntry& entry) {
if (count >= items.size()) return;
items[count++] = entry;
}
};
bool themeHomeActionVisible(ThemeHomeAction action, bool hasOpdsServers, bool hasRecentBooks) {
if (action == ThemeHomeAction::OpdsBrowser) return hasOpdsServers;
if (action == ThemeHomeAction::RecentBook) return hasRecentBooks;
return true;
}
WidgetRenderEntries buildRenderEntries(const ThemeHomeScreenSpec& spec, const std::vector<RecentBook>& recentBooks,
bool hasOpdsServers) {
WidgetRenderEntries entries;
int nextActionOffset = 0;
for (size_t i = 0; i < spec.widgets.size(); ++i) {
const auto& widget = spec.widgets[i];
const int widgetActionOffset = nextActionOffset;
if (widget.type == ThemeHomeWidgetType::Recents) {
nextActionOffset += static_cast<int>(recentBooks.size());
} else if (widget.type == ThemeHomeWidgetType::FeaturedBookCard) {
if (std::max(0, widget.featured.startIndex) < static_cast<int>(recentBooks.size())) ++nextActionOffset;
} else if (widget.type == ThemeHomeWidgetType::RecentCoverGrid) {
const int maxItems = widget.coverGrid.rows > 0 ? widget.coverGrid.rows * std::max(1, widget.coverGrid.columns)
: static_cast<int>(recentBooks.size());
const int startIndex = std::max(0, widget.coverGrid.startIndex);
nextActionOffset += std::min({std::max(0, static_cast<int>(recentBooks.size()) - startIndex), maxItems,
static_cast<int>(kMaxThemeCoverGridItems)});
} else if (widget.type == ThemeHomeWidgetType::LauncherList || widget.type == ThemeHomeWidgetType::LauncherGrid) {
nextActionOffset += static_cast<int>(
std::count_if(widget.launcher.items.begin(), widget.launcher.items.end(), [&](const auto& launcher) {
return themeHomeActionVisible(launcher.action, hasOpdsServers, !recentBooks.empty());
}));
}
entries.push(WidgetRenderEntry{&widget, widgetActionOffset, i});
}
std::stable_sort(entries.items.begin(), entries.items.begin() + entries.count, [](const auto& a, const auto& b) {
if (a.widget->layer != b.widget->layer) return a.widget->layer < b.widget->layer;
return a.order < b.order;
});
return entries;
}
} // namespace
void buildThemeHomeActions(const ThemeHomeScreenSpec* spec, const std::vector<RecentBook>& recentBooks,
bool hasOpdsServers, std::vector<ThemeHomeActionEntry>& actions) {
actions.clear();
if (spec != nullptr) {
for (const auto& widget : spec->widgets) {
if (widget.type == ThemeHomeWidgetType::Recents) {
for (int i = 0; i < static_cast<int>(recentBooks.size()); ++i) {
actions.push_back(ThemeHomeActionEntry{ThemeHomeAction::RecentBook, i});
}
} else if (widget.type == ThemeHomeWidgetType::FeaturedBookCard) {
const int index = std::max(0, widget.featured.startIndex);
if (index < static_cast<int>(recentBooks.size())) {
actions.push_back(ThemeHomeActionEntry{ThemeHomeAction::RecentBook, index});
}
} else if (widget.type == ThemeHomeWidgetType::RecentCoverGrid) {
const int maxItems = widget.coverGrid.rows > 0 ? widget.coverGrid.rows * std::max(1, widget.coverGrid.columns)
: static_cast<int>(recentBooks.size());
const int startIndex = std::max(0, widget.coverGrid.startIndex);
for (int i = 0; startIndex + i < static_cast<int>(recentBooks.size()) && i < maxItems &&
i < static_cast<int>(kMaxThemeCoverGridItems);
++i) {
actions.push_back(ThemeHomeActionEntry{ThemeHomeAction::RecentBook, startIndex + i});
}
} else if (widget.type == ThemeHomeWidgetType::LauncherList || widget.type == ThemeHomeWidgetType::LauncherGrid) {
for (const auto& launcher : widget.launcher.items) {
if (themeHomeActionVisible(launcher.action, hasOpdsServers, !recentBooks.empty())) {
actions.push_back(ThemeHomeActionEntry{launcher.action, 0});
}
}
}
}
if (!actions.empty()) return;
}
for (int i = 0; i < static_cast<int>(recentBooks.size()); ++i) {
actions.push_back(ThemeHomeActionEntry{ThemeHomeAction::RecentBook, i});
}
actions.push_back(ThemeHomeActionEntry{ThemeHomeAction::FileBrowser, 0});
actions.push_back(ThemeHomeActionEntry{ThemeHomeAction::RecentBooks, 0});
if (hasOpdsServers) actions.push_back(ThemeHomeActionEntry{ThemeHomeAction::OpdsBrowser, 0});
actions.push_back(ThemeHomeActionEntry{ThemeHomeAction::FileTransfer, 0});
actions.push_back(ThemeHomeActionEntry{ThemeHomeAction::Settings, 0});
}
bool renderThemeHome(ThemeHomeRenderContext& ctx) {
const auto pageWidth = ctx.renderer.getScreenWidth();
const auto pageHeight = ctx.renderer.getScreenHeight();
ThemeLayoutSlots slots;
layoutThemeSlots(ctx.spec.layout, Rect{0, 0, pageWidth, pageHeight}, ctx.metrics, slots);
if (slots.empty()) {
const auto& layout = ctx.spec.layout;
const auto& first = layout.children.empty() ? layout : layout.children.front();
LOG_ERR("HOME",
"SD home layout emitted no slots: page=%dx%d children=%d firstId=%s firstType=%d firstSize=%d firstFlex=%d",
pageWidth, pageHeight, static_cast<int>(layout.children.size()), first.id.c_str(),
static_cast<int>(first.sizeType), first.size, first.flex);
}
const bool sdHomeUsable = !slots.empty() && !ctx.actions.empty();
if (!sdHomeUsable) {
LOG_ERR("HOME", "Invalid SD home layout: widgets=%d slots=%d actions=%d; using built-in layout",
static_cast<int>(ctx.spec.widgets.size()), static_cast<int>(slots.size()),
static_cast<int>(ctx.actions.size()));
return false;
}
ctx.renderer.clearScreen();
ctx.coverRectX = 0;
ctx.coverRectY = 0;
ctx.coverRectW = 0;
ctx.coverRectH = 0;
const auto renderWidgets = buildRenderEntries(ctx.spec, ctx.recentBooks, ctx.hasOpdsServers);
for (size_t renderIndex = 0; renderIndex < renderWidgets.count; ++renderIndex) {
const auto& entry = renderWidgets.items[renderIndex];
const auto& widget = *entry.widget;
Rect slot = placedWidgetRect(findThemeSlot(slots, widget.slot), widget);
if (slot.width <= 0 || slot.height <= 0) continue;
if (widget.type == ThemeHomeWidgetType::Header) {
const auto title = homeHeaderTitle(ctx.metrics, ctx.recentBooks, ctx.coverSelectorIndex);
GUI.drawHeader(ctx.renderer, slot, title.empty() ? nullptr : title.c_str());
} else if (widget.type == ThemeHomeWidgetType::HeaderTitle) {
const auto title = homeHeaderTitle(ctx.metrics, ctx.recentBooks, ctx.coverSelectorIndex);
if (!title.empty()) {
const auto truncated = ctx.renderer.truncatedText(UI_10_FONT_ID, title.c_str(), slot.width);
const int textWidth = ctx.renderer.getTextWidth(UI_10_FONT_ID, truncated.c_str());
ctx.renderer.drawText(UI_10_FONT_ID, slot.x + std::max(0, (slot.width - textWidth) / 2),
slot.y + std::max(0, (slot.height - ctx.renderer.getLineHeight(UI_10_FONT_ID)) / 2),
truncated.c_str());
}
} else if (widget.type == ThemeHomeWidgetType::Battery) {
const bool showBatteryPercentage =
SETTINGS.hideBatteryPercentage != CrossPointSettings::HIDE_BATTERY_PERCENTAGE::HIDE_ALWAYS;
const int batteryX = slot.x + std::max(0, slot.width - ctx.metrics.batteryWidth);
GUI.drawBatteryRight(ctx.renderer, Rect{batteryX, slot.y, ctx.metrics.batteryWidth, ctx.metrics.batteryHeight},
showBatteryPercentage);
} else if (widget.type == ThemeHomeWidgetType::Clock) {
if (halClock.isAvailable()) {
char timeBuf[9];
if (halClock.formatTime(timeBuf, sizeof(timeBuf), SETTINGS.clockUtcOffsetQ, SETTINGS.clockFormat == 1)) {
auto clockText = ctx.renderer.truncatedText(SMALL_FONT_ID, timeBuf, slot.width);
const int textWidth = ctx.renderer.getTextWidth(SMALL_FONT_ID, clockText.c_str());
ctx.renderer.drawText(SMALL_FONT_ID, slot.x + std::max(0, (slot.width - textWidth) / 2), slot.y,
clockText.c_str());
}
}
} else if (widget.type == ThemeHomeWidgetType::Recents) {
const bool hasCoverArea = slot.height > 0 && ctx.metrics.homeCoverHeight > 0;
ctx.coverRectX = 0;
ctx.coverRectY = std::max(0, slot.y - kCoverCacheBleed);
ctx.coverRectW = pageWidth;
ctx.coverRectH =
std::min(pageHeight - ctx.coverRectY, slot.height + (slot.y - ctx.coverRectY) + kCoverCacheBleed);
const bool selectorSensitiveCoverCache = GUI.homeCoverCacheDependsOnSelector();
const bool coverStripSelected = ctx.selectorIndex >= entry.actionOffset &&
ctx.selectorIndex < entry.actionOffset + static_cast<int>(ctx.recentBooks.size());
if (coverStripSelected) {
ctx.coverSelectorIndex = ctx.actions[ctx.selectorIndex].value;
}
const bool coverCacheMatches =
!selectorSensitiveCoverCache || (ctx.coverBufferSelectorIndex == ctx.coverSelectorIndex &&
ctx.coverBufferStripSelected == coverStripSelected);
if (hasCoverArea && ctx.coverBufferStored && !coverCacheMatches) {
ctx.coverBufferStored = false;
ctx.coverRendered = false;
}
bool bufferRestored = hasCoverArea && ctx.coverBufferStored && coverCacheMatches &&
ctx.restoreCoverBuffer != nullptr && ctx.restoreCoverBuffer(ctx.coverBufferUserData);
if (hasCoverArea) {
GUI.drawRecentBookCover(
ctx.renderer, slot, ctx.recentBooks, ctx.coverSelectorIndex, ctx.coverRendered, ctx.coverBufferStored,
bufferRestored,
[store = ctx.storeCoverBuffer, userData = ctx.coverBufferUserData]() {
return store != nullptr && store(userData);
},
coverStripSelected);
}
} else if (widget.type == ThemeHomeWidgetType::FeaturedBookCard) {
const int bookIndex = std::max(0, widget.featured.startIndex);
if (bookIndex < static_cast<int>(ctx.recentBooks.size())) {
const bool selected = ctx.selectorIndex >= entry.actionOffset && ctx.selectorIndex < entry.actionOffset + 1 &&
ctx.actions[ctx.selectorIndex].value == bookIndex;
ctx.renderer.drawText(UI_10_FONT_ID, slot.x, slot.y, tr(STR_CONTINUE_READING), true, EpdFontFamily::BOLD);
#if FREEINK_HAVE_GFX_RENDERER
const int labelH = ctx.renderer.getLineHeight(UI_10_FONT_ID) + std::max(0, widget.featured.titleGap);
const int coverHeight =
widget.featured.coverHeight > 0 ? widget.featured.coverHeight : std::max(1, slot.height - labelH - 8);
const int coverWidth =
widget.featured.coverWidth > 0 ? widget.featured.coverWidth : std::max(1, coverHeight * 62 / 100);
freeink::ui::GfxRendererFrame<> ui(ctx.renderer, SMALL_FONT_ID, UI_10_FONT_ID, UI_12_FONT_ID);
RecentBookCoverPainterData painterData{&ctx.renderer, &ctx.recentBooks,
UITheme::getInstance().getHomeCoverThumbHeight(),
widget.featured.placeholderIconSize};
freeink::ui::BookCardProps props;
props.title = ctx.recentBooks[bookIndex].title.c_str();
props.author = ctx.recentBooks[bookIndex].author.c_str();
props.progressMax = 0;
props.value = static_cast<int16_t>(bookIndex);
props.state = selected ? freeink::ui::StateSelected : freeink::ui::StateNormal;
props.coverSize = freeink::ui::makeSize(coverWidth, coverHeight);
props.padding = freeink::ui::makeInsets(0);
props.gap = freeink::ui::clampI16(widget.featured.coverGap);
props.titleText.font = freeink::ui::GfxRendererTarget::FONT_TITLE;
props.titleText.maxLines = 2;
props.authorText.font = freeink::ui::GfxRendererTarget::FONT_BODY;
props.centerTextVertically = true;
props.selectionIndicator = freeink::ui::BookCardSelectionIndicator::CoverFrame;
props.selectedCoverFrameRadius = freeink::ui::clampRadius(widget.featured.selectedRadius);
props.coverPainter = paintBookCardCover;
props.coverPainterUserData = &painterData;
freeink::ui::StyleSet styles =
widgetSelectionStyles(ThemeWidgetSelectionStyle::Outline, widget.featured.selectedRadius);
styles.normal.background = freeink::ui::Paint::solid(freeink::ui::Color::White);
props.styles = styles;
const int cardH = std::min(std::max(1, slot.height - labelH), coverHeight);
freeink::ui::bookCard(ui.frame, freeink::ui::makeRect(slot.x, slot.y + labelH, slot.width, cardH), props);
#endif
}
} else if (widget.type == ThemeHomeWidgetType::RecentCoverGrid) {
const int columns = std::max(1, widget.coverGrid.columns);
const int rows = widget.coverGrid.rows > 0
? widget.coverGrid.rows
: std::max(1, (static_cast<int>(ctx.recentBooks.size()) + columns - 1) / columns);
const int startIndex = std::max(0, widget.coverGrid.startIndex);
const int maxItems = std::min({std::max(0, static_cast<int>(ctx.recentBooks.size()) - startIndex), rows * columns,
static_cast<int>(kMaxThemeCoverGridItems)});
if (maxItems > 0) {
const int selectedLocal =
ctx.selectorIndex >= entry.actionOffset && ctx.selectorIndex < entry.actionOffset + maxItems
? ctx.actions[ctx.selectorIndex].value - startIndex
: -1;
const int coverHeight =
widget.coverGrid.coverHeight > 0
? widget.coverGrid.coverHeight
: std::max(1, (slot.height - std::max(0, widget.coverGrid.gap) * (rows - 1)) / rows -
widget.coverGrid.labelHeight);
const int coverWidth =
widget.coverGrid.coverWidth > 0 ? widget.coverGrid.coverWidth : std::max(1, coverHeight * 62 / 100);
const int rowHeight = widget.coverGrid.rowHeight > 0
? widget.coverGrid.rowHeight
: coverHeight + std::max(0, widget.coverGrid.labelHeight) + 6;
#if FREEINK_HAVE_GFX_RENDERER
freeink::ui::GfxRendererFrame<> ui(ctx.renderer, SMALL_FONT_ID, UI_10_FONT_ID, UI_12_FONT_ID);
RecentBookCoverPainterData painterData{&ctx.renderer, &ctx.recentBooks,
UITheme::getInstance().getHomeCoverThumbHeight(),
widget.coverGrid.placeholderIconSize};
HomeCoverGridItemProviderData itemProviderData{&ctx.recentBooks, startIndex};
freeink::ui::CoverGridProps props;
props.itemProvider = provideHomeCoverGridItem;
props.itemProviderUserData = &itemProviderData;
props.count = static_cast<uint16_t>(maxItems);
props.selectedIndex = static_cast<int16_t>(selectedLocal);
props.columns = static_cast<uint8_t>(std::min(columns, 12));
props.gap = freeink::ui::clampI16(widget.coverGrid.gap);
props.rowGap =
freeink::ui::clampI16(widget.coverGrid.rowGap >= 0 ? widget.coverGrid.rowGap : widget.coverGrid.gap);
props.cellInset = toFreeInkInsets(widget.coverGrid.cellInset);
props.labelInset = toFreeInkInsets(widget.coverGrid.labelInset);
props.coverSize = freeink::ui::makeSize(coverWidth, coverHeight);
props.rowHeight = freeink::ui::clampI16(rowHeight, 1);
props.labelHeight = freeink::ui::clampI16(widget.coverGrid.labelHeight);
props.labelGap = freeink::ui::clampI16(widget.coverGrid.labelGap);
props.titleText.font = freeink::ui::GfxRendererTarget::FONT_SMALL;
props.titleText.maxLines = static_cast<uint8_t>(std::max(1, std::min(3, widget.coverGrid.labelLines)));
props.cellStyles = widgetSelectionStyles(widget.coverGrid.selectionStyle, widget.coverGrid.selectedRadius);
props.selectionIndicator = coverGridSelectionIndicator(widget.coverGrid.selectionStyle);
props.selectedCoverFrameRadius = freeink::ui::clampRadius(widget.coverGrid.selectedRadius);
props.coverPainter = paintRecentCoverGridCover;
props.coverPainterUserData = &painterData;
freeink::ui::coverGrid(ui.frame, freeink::ui::makeRect(slot.x, slot.y, slot.width, slot.height), props);
#endif
}
} else if (widget.type == ThemeHomeWidgetType::LauncherList || widget.type == ThemeHomeWidgetType::LauncherGrid) {
std::array<const ThemeHomeLauncherSpec*, kMaxThemeLauncherItems> launchers;
size_t launcherCount = 0;
for (const auto& launcher : widget.launcher.items) {
if (themeHomeActionVisible(launcher.action, ctx.hasOpdsServers, !ctx.recentBooks.empty())) {
if (launcherCount < launchers.size()) launchers[launcherCount++] = &launcher;
}
}
const int selectedLocal = ctx.selectorIndex >= entry.actionOffset &&
ctx.selectorIndex < entry.actionOffset + static_cast<int>(launcherCount)
? ctx.selectorIndex - entry.actionOffset
: -1;
if (widget.launcher.presentation == ThemeLauncherPresentation::IconTabs) {
#if FREEINK_HAVE_GFX_RENDERER
std::array<freeink::ui::TabItem, kMaxThemeLauncherItems> items;
size_t itemCount = 0;
for (int i = 0; i < static_cast<int>(launcherCount); ++i) {
items[itemCount++] = freeink::ui::tabItem(i, selectedLocal == i);
}
freeink::ui::GfxRendererFrame<> ui(ctx.renderer, SMALL_FONT_ID, UI_10_FONT_ID, UI_12_FONT_ID);
freeink::ui::StyleSet styles = freeink::ui::outlinedButtonStyles(widget.launcher.selectedRadius);
HomeIconTabPainterData painterData{&ctx.renderer, launchers.data(), launcherCount};
freeink::ui::TabBarProps props;
props.tabs = items.data();
props.count = static_cast<uint8_t>(std::min<size_t>(itemCount, 255));
props.tabStyles = styles;
props.gap = freeink::ui::clampI16(widget.launcher.gap);
props.iconSize = freeink::ui::clampI16(widget.launcher.iconSize, 1);
props.tabInset = freeink::ui::makeInsets(4);
props.iconPainter = paintHomeIconTab;
props.iconPainterUserData = &painterData;
freeink::ui::tabBar(ui.frame, freeink::ui::makeRect(slot.x, slot.y, slot.width, slot.height), props);
#endif
} else if (widget.type == ThemeHomeWidgetType::LauncherGrid) {
const int columns = std::max(1, widget.launcher.columns);
const int rows = widget.launcher.rows > 0
? widget.launcher.rows
: std::max(1, (static_cast<int>(launcherCount) + columns - 1) / columns);
const int gap = std::max(0, widget.launcher.gap);
const int cellW = std::max(1, (slot.width - gap * (columns - 1)) / columns);
const int cellH = std::max(1, (slot.height - gap * (rows - 1)) / rows);
for (int i = 0; i < static_cast<int>(launcherCount); ++i) {
const int col = i % columns;
const int row = i / columns;
if (row >= rows) break;
Rect cell{slot.x + col * (cellW + gap), slot.y + row * (cellH + gap),
col == columns - 1 ? slot.x + slot.width - (slot.x + col * (cellW + gap)) : cellW, cellH};
GUI.drawButtonMenu(
ctx.renderer, cell, 1, selectedLocal == i ? 0 : -1,
[&launchers, i](int) {
return launchers[i]->text.empty() ? std::string(defaultLauncherLabel(launchers[i]->action))
: launchers[i]->text;
},
[&launchers, i](int) {
return launchers[i]->icon == UIIcon::None ? defaultLauncherIcon(launchers[i]->action)
: launchers[i]->icon;
});
}
} else {
GUI.drawButtonMenu(
ctx.renderer, slot, static_cast<int>(launcherCount), selectedLocal,
[&launchers](int index) {
return launchers[index]->text.empty() ? std::string(defaultLauncherLabel(launchers[index]->action))
: launchers[index]->text;
},
[&launchers](int index) {
return launchers[index]->icon == UIIcon::None ? defaultLauncherIcon(launchers[index]->action)
: launchers[index]->icon;
});
}
} else if (widget.type == ThemeHomeWidgetType::ButtonHints) {
const bool horizontalBottomHints = ctx.spec.navigation == ThemeHomeNavigationMode::SplitAxis ||
ctx.spec.navigation == ThemeHomeNavigationMode::CarouselAxis;
const auto labels = ctx.mappedInput.mapLabels(
buttonHintLabel(widget.buttonHints.back, ""), buttonHintLabel(widget.buttonHints.confirm, tr(STR_SELECT)),
buttonHintLabel(widget.buttonHints.previous, horizontalBottomHints ? tr(STR_DIR_LEFT) : tr(STR_DIR_UP)),
buttonHintLabel(widget.buttonHints.next, horizontalBottomHints ? tr(STR_DIR_RIGHT) : tr(STR_DIR_DOWN)));
GUI.drawButtonHints(ctx.renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
}
}
ctx.renderer.displayBuffer();
return true;
}
+44
View File
@@ -0,0 +1,44 @@
#pragma once
#include <vector>
#include "components/themes/ThemeLayout.h"
class GfxRenderer;
class MappedInputManager;
struct RecentBook;
struct ThemeHomeActionEntry {
ThemeHomeAction action = ThemeHomeAction::FileBrowser;
int value = 0;
};
using ThemeHomeBufferCallback = bool (*)(void*);
void buildThemeHomeActions(const ThemeHomeScreenSpec* spec, const std::vector<RecentBook>& recentBooks,
bool hasOpdsServers, std::vector<ThemeHomeActionEntry>& actions);
struct ThemeHomeRenderContext {
GfxRenderer& renderer;
MappedInputManager& mappedInput;
const ThemeMetrics& metrics;
const ThemeHomeScreenSpec& spec;
const std::vector<RecentBook>& recentBooks;
const std::vector<ThemeHomeActionEntry>& actions;
bool hasOpdsServers = false;
int selectorIndex = 0;
int& coverSelectorIndex;
bool& coverRendered;
bool& coverBufferStored;
int coverBufferSelectorIndex = -1;
bool coverBufferStripSelected = false;
int& coverRectX;
int& coverRectY;
int& coverRectW;
int& coverRectH;
void* coverBufferUserData = nullptr;
ThemeHomeBufferCallback storeCoverBuffer = nullptr;
ThemeHomeBufferCallback restoreCoverBuffer = nullptr;
};
bool renderThemeHome(ThemeHomeRenderContext& ctx);
+15 -8
View File
@@ -83,9 +83,10 @@ ProgressRange getPageProgressRange(const std::shared_ptr<Epub>& epub, const int
return {epub->calculateProgress(spineIndex, start), epub->calculateProgress(spineIndex, end)}; return {epub->calculateProgress(spineIndex, start), epub->calculateProgress(spineIndex, end)};
} }
bool bookmarkMatchesProgress(const BookmarkEntry& bookmark, const SavedProgressPosition& progress, bool bookmarkMatchesProgress(const BookmarkEntry& bookmark, const int spineIndex, const int page, const int pageCount,
const ProgressRange& pageRange) { const ProgressRange& pageRange) {
if (bookmark.xpath == progress.xpath) { if (bookmark.computedSpineIndex == spineIndex && bookmark.computedChapterPageCount == pageCount &&
bookmark.computedChapterProgress == page) {
return true; return true;
} }
@@ -1086,6 +1087,11 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
if (!scratch) { if (!scratch) {
LOG_ERR("ERS", "OOM: grayscale strip scratch (%d bytes); skipping AA this page", gwBytes * STRIP_ROWS); LOG_ERR("ERS", "OOM: grayscale strip scratch (%d bytes); skipping AA this page", gwBytes * STRIP_ROWS);
} else { } else {
// [#2190] Headroom probe: tiled scratch is ~8 KB here; the full-frame
// alternative would need ~52 KB total (chunked at 8 KB). Compare free vs
// ~52 KB and largest_block vs 8 KB to see if X3 could afford full-frame.
LOG_INF("ERS", "Grayscale heap @render: free=%u largest_block=%u scratch=%d", (unsigned)ESP.getFreeHeap(),
(unsigned)ESP.getMaxAllocHeap(), gwBytes * STRIP_ROWS);
// Bands may be streamed in any order: X4 windows each via setRamArea, X3 // Bands may be streamed in any order: X4 windows each via setRamArea, X3
// via PTL. // via PTL.
renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB); renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB);
@@ -1311,10 +1317,12 @@ void EpubReaderActivity::addBookmark() {
const ProgressRange pageRange = getPageProgressRange(epub, currentSpineIndex, currentPage, pageCount); const ProgressRange pageRange = getPageProgressRange(epub, currentSpineIndex, currentPage, pageCount);
const size_t bookmarkCountBeforeToggle = cachedBookmarks.size(); const size_t bookmarkCountBeforeToggle = cachedBookmarks.size();
cachedBookmarks.erase( cachedBookmarks.erase(std::remove_if(cachedBookmarks.begin(), cachedBookmarks.end(),
std::remove_if(cachedBookmarks.begin(), cachedBookmarks.end(), [&](const BookmarkEntry& b) {
[&](const BookmarkEntry& b) { return bookmarkMatchesProgress(b, progress, pageRange); }), return bookmarkMatchesProgress(b, currentSpineIndex, currentPage, pageCount,
cachedBookmarks.end()); pageRange);
}),
cachedBookmarks.end());
if (cachedBookmarks.size() != bookmarkCountBeforeToggle) { if (cachedBookmarks.size() != bookmarkCountBeforeToggle) {
bookmarkRemoved = true; bookmarkRemoved = true;
currentPageBookmarked = false; currentPageBookmarked = false;
@@ -1350,11 +1358,10 @@ void EpubReaderActivity::updateBookmarkFlag() {
currentPageBookmarked = false; currentPageBookmarked = false;
return; return;
} }
SavedProgressPosition progress = ProgressMapper::toSavedProgress(epub, getCurrentPosition());
const ProgressRange pageRange = const ProgressRange pageRange =
getPageProgressRange(epub, currentSpineIndex, section->currentPage, section->pageCount); getPageProgressRange(epub, currentSpineIndex, section->currentPage, section->pageCount);
currentPageBookmarked = std::any_of(cachedBookmarks.begin(), cachedBookmarks.end(), [&](const BookmarkEntry& b) { currentPageBookmarked = std::any_of(cachedBookmarks.begin(), cachedBookmarks.end(), [&](const BookmarkEntry& b) {
return bookmarkMatchesProgress(b, progress, pageRange); return bookmarkMatchesProgress(b, currentSpineIndex, section->currentPage, section->pageCount, pageRange);
}); });
} }
+120 -42
View File
@@ -21,6 +21,7 @@
#include "SdFirmwareUpdateActivity.h" #include "SdFirmwareUpdateActivity.h"
#include "SettingsList.h" #include "SettingsList.h"
#include "StatusBarSettingsActivity.h" #include "StatusBarSettingsActivity.h"
#include "ThemeDownloadActivity.h"
#include "activities/network/WifiSelectionActivity.h" #include "activities/network/WifiSelectionActivity.h"
#include "activities/util/IntervalSelectionActivity.h" #include "activities/util/IntervalSelectionActivity.h"
#include "components/UITheme.h" #include "components/UITheme.h"
@@ -38,8 +39,9 @@ void SettingsActivity::rebuildSettingsLists() {
// Pick up any fonts uploaded/deleted over the web server since the last // Pick up any fonts uploaded/deleted over the web server since the last
// reader activity ran — otherwise the font-family picker shows stale list. // reader activity ran — otherwise the font-family picker shows stale list.
sdFontSystem.refreshIfDirty(); sdFontSystem.refreshIfDirty();
UITheme::getInstance().refreshRegistry();
for (auto& setting : getSettingsList(&sdFontSystem.registry())) { for (auto& setting : getSettingsList(&sdFontSystem.registry(), &UITheme::getInstance().registry())) {
if (setting.category == StrId::STR_NONE_OPT) continue; if (setting.category == StrId::STR_NONE_OPT) continue;
if (setting.category == StrId::STR_CAT_DISPLAY) { if (setting.category == StrId::STR_CAT_DISPLAY) {
displaySettings.push_back(setting); displaySettings.push_back(setting);
@@ -55,6 +57,11 @@ void SettingsActivity::rebuildSettingsLists() {
systemSettings.push_back(setting); systemSettings.push_back(setting);
} }
} }
// getSettingsList copies the SD theme names/ids into the UI theme setting.
// Keeping the full parsed SD theme registry alive while child activities
// like the font downloader run leaves less contiguous heap for TLS/header
// parsing.
UITheme::getInstance().registry().clear();
// Append device-only ACTION items // Append device-only ACTION items
controlsSettings.insert(controlsSettings.begin(), controlsSettings.insert(controlsSettings.begin(),
@@ -66,6 +73,10 @@ void SettingsActivity::rebuildSettingsLists() {
systemSettings.push_back(SettingInfo::Action(StrId::STR_CHECK_UPDATES, SettingAction::CheckForUpdates)); systemSettings.push_back(SettingInfo::Action(StrId::STR_CHECK_UPDATES, SettingAction::CheckForUpdates));
systemSettings.push_back(SettingInfo::Action(StrId::STR_SD_FIRMWARE_UPDATE, SettingAction::SdFirmwareUpdate)); systemSettings.push_back(SettingInfo::Action(StrId::STR_SD_FIRMWARE_UPDATE, SettingAction::SdFirmwareUpdate));
systemSettings.push_back(SettingInfo::Action(StrId::STR_LANGUAGE, SettingAction::Language)); systemSettings.push_back(SettingInfo::Action(StrId::STR_LANGUAGE, SettingAction::Language));
auto themeSettingIt = std::find_if(displaySettings.begin(), displaySettings.end(),
[](const SettingInfo& setting) { return setting.nameId == StrId::STR_UI_THEME; });
displaySettings.insert(themeSettingIt == displaySettings.end() ? displaySettings.end() : themeSettingIt + 1,
SettingInfo::Action(StrId::STR_MANAGE_THEMES, SettingAction::DownloadThemes));
// Insert "Manage Fonts" right after the font family setting so users discover it naturally // Insert "Manage Fonts" right after the font family setting so users discover it naturally
readerSettings.insert(readerSettings.begin() + 1, readerSettings.insert(readerSettings.begin() + 1,
SettingInfo::Action(StrId::STR_MANAGE_FONTS, SettingAction::DownloadFonts)); SettingInfo::Action(StrId::STR_MANAGE_FONTS, SettingAction::DownloadFonts));
@@ -89,6 +100,19 @@ void SettingsActivity::rebuildSettingsLists() {
settingsCount = static_cast<int>(currentSettings->size()); settingsCount = static_cast<int>(currentSettings->size());
} }
void SettingsActivity::releaseSettingsLists() {
displaySettings.clear();
readerSettings.clear();
controlsSettings.clear();
systemSettings.clear();
displaySettings.shrink_to_fit();
readerSettings.shrink_to_fit();
controlsSettings.shrink_to_fit();
systemSettings.shrink_to_fit();
currentSettings = nullptr;
settingsCount = 0;
}
void SettingsActivity::onEnter() { void SettingsActivity::onEnter() {
Activity::onEnter(); Activity::onEnter();
@@ -191,6 +215,7 @@ void SettingsActivity::toggleCurrentSetting() {
const auto& setting = (*currentSettings)[selectedSetting]; const auto& setting = (*currentSettings)[selectedSetting];
const bool sleepScreenChanged = setting.valuePtr == &CrossPointSettings::sleepScreen; const bool sleepScreenChanged = setting.valuePtr == &CrossPointSettings::sleepScreen;
const bool quickResumeTimeoutChanged = setting.valuePtr == &CrossPointSettings::quickResumeSleepScreen; const bool quickResumeTimeoutChanged = setting.valuePtr == &CrossPointSettings::quickResumeSleepScreen;
const bool themeChanged = setting.nameId == StrId::STR_UI_THEME;
if (setting.nameId == StrId::STR_TIME_TO_SLEEP) { if (setting.nameId == StrId::STR_TIME_TO_SLEEP) {
openSleepTimeoutPicker(); openSleepTimeoutPicker();
@@ -255,9 +280,22 @@ void SettingsActivity::toggleCurrentSetting() {
startActivityForResult(std::make_unique<SdFirmwareUpdateActivity>(renderer, mappedInput), resultHandler); startActivityForResult(std::make_unique<SdFirmwareUpdateActivity>(renderer, mappedInput), resultHandler);
break; break;
case SettingAction::DownloadFonts: case SettingAction::DownloadFonts:
releaseSettingsLists();
UITheme::getInstance().releaseSdThemeAssetMemory();
startActivityForResult(std::make_unique<FontDownloadActivity>(renderer, mappedInput), startActivityForResult(std::make_unique<FontDownloadActivity>(renderer, mappedInput),
[this](const ActivityResult&) { [this](const ActivityResult&) {
SETTINGS.saveToFile(); SETTINGS.saveToFile();
UITheme::getInstance().reload();
rebuildSettingsLists();
});
break;
case SettingAction::DownloadThemes:
releaseSettingsLists();
UITheme::getInstance().releaseSdThemeAssetMemory();
startActivityForResult(std::make_unique<ThemeDownloadActivity>(renderer, mappedInput),
[this](const ActivityResult&) {
SETTINGS.saveToFile();
UITheme::getInstance().reload();
rebuildSettingsLists(); rebuildSettingsLists();
}); });
break; break;
@@ -275,8 +313,14 @@ void SettingsActivity::toggleCurrentSetting() {
syncQuickResumeTimeoutForSleepScreen(sleepScreenChanged, quickResumeTimeoutChanged); syncQuickResumeTimeoutForSleepScreen(sleepScreenChanged, quickResumeTimeoutChanged);
SETTINGS.saveToFile(); SETTINGS.saveToFile();
if (themeChanged) {
UITheme::getInstance().reload();
}
rebuildSettingsLists(); rebuildSettingsLists();
selectedSettingIndex = std::min(selectedSettingIndex, settingsCount); selectedSettingIndex = std::min(selectedSettingIndex, settingsCount);
if (themeChanged) {
requestUpdate();
}
} }
void SettingsActivity::syncQuickResumeTimeoutForSleepScreen(bool sleepScreenChanged, bool quickResumeTimeoutChanged) { void SettingsActivity::syncQuickResumeTimeoutForSleepScreen(bool sleepScreenChanged, bool quickResumeTimeoutChanged) {
@@ -326,58 +370,90 @@ void SettingsActivity::render(RenderLock&&) {
const auto& metrics = UITheme::getInstance().getMetrics(); const auto& metrics = UITheme::getInstance().getMetrics();
GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, tr(STR_SETTINGS_TITLE), const ThemeScreenSpec* screenSpec = UITheme::getInstance().getScreenSpec(ThemeScreenKind::Settings);
CROSSPOINT_VERSION); ThemeLayoutSlots slots;
Rect headerRect{0, metrics.topPadding, pageWidth, metrics.headerHeight};
Rect tabsRect{0, metrics.topPadding + metrics.headerHeight, pageWidth, metrics.tabBarHeight};
Rect listRect{0, metrics.topPadding + metrics.headerHeight + metrics.tabBarHeight + metrics.verticalSpacing,
pageWidth,
pageHeight - (metrics.topPadding + metrics.headerHeight + metrics.tabBarHeight +
metrics.buttonHintsHeight + metrics.verticalSpacing * 2)};
Rect buttonsRect{0, pageHeight - metrics.buttonHintsHeight, pageWidth, metrics.buttonHintsHeight};
if (screenSpec != nullptr) {
layoutThemeSlots(screenSpec->layout, Rect{0, 0, pageWidth, pageHeight}, metrics, slots);
headerRect = normalizeThemeHeaderSlot(findThemeSlot(slots, "header"), metrics);
tabsRect = findThemeSlot(slots, "tabs");
listRect = findThemeSlot(slots, "list");
buttonsRect = findThemeSlot(slots, "buttons");
if (listRect.width <= 0 || listRect.height <= 0) {
LOG_ERR("Settings", "Invalid SD settings layout: slots=%d; using built-in layout",
static_cast<int>(slots.size()));
screenSpec = nullptr;
}
}
if (screenSpec == nullptr) {
headerRect = Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight};
tabsRect = Rect{0, metrics.topPadding + metrics.headerHeight, pageWidth, metrics.tabBarHeight};
listRect =
Rect{0, metrics.topPadding + metrics.headerHeight + metrics.tabBarHeight + metrics.verticalSpacing, pageWidth,
pageHeight - (metrics.topPadding + metrics.headerHeight + metrics.tabBarHeight +
metrics.buttonHintsHeight + metrics.verticalSpacing * 2)};
buttonsRect = Rect{0, pageHeight - metrics.buttonHintsHeight, pageWidth, metrics.buttonHintsHeight};
}
if (headerRect.width > 0 && headerRect.height > 0) {
GUI.drawHeader(renderer, headerRect, tr(STR_SETTINGS_TITLE), CROSSPOINT_VERSION);
}
std::vector<TabInfo> tabs; std::vector<TabInfo> tabs;
tabs.reserve(categoryCount); tabs.reserve(categoryCount);
for (int i = 0; i < categoryCount; i++) { for (int i = 0; i < categoryCount; i++) {
tabs.push_back({I18N.get(categoryNames[i]), selectedCategoryIndex == i}); tabs.push_back({I18N.get(categoryNames[i]), selectedCategoryIndex == i});
} }
GUI.drawTabBar(renderer, Rect{0, metrics.topPadding + metrics.headerHeight, pageWidth, metrics.tabBarHeight}, tabs, if (tabsRect.width > 0 && tabsRect.height > 0) {
selectedSettingIndex == 0); GUI.drawTabBar(renderer, tabsRect, tabs, selectedSettingIndex == 0);
}
const auto& settings = *currentSettings; const auto& settings = *currentSettings;
GUI.drawList( if (listRect.width > 0 && listRect.height > 0) {
renderer, GUI.drawList(
Rect{0, metrics.topPadding + metrics.headerHeight + metrics.tabBarHeight + metrics.verticalSpacing, pageWidth, renderer, listRect, settingsCount, selectedSettingIndex - 1,
pageHeight - (metrics.topPadding + metrics.headerHeight + metrics.tabBarHeight + metrics.buttonHintsHeight + [&settings](int index) { return std::string(I18N.get(settings[index].nameId)); }, nullptr, nullptr,
metrics.verticalSpacing * 2)}, [&settings](int i) {
settingsCount, selectedSettingIndex - 1, const auto& setting = settings[i];
[&settings](int index) { return std::string(I18N.get(settings[index].nameId)); }, nullptr, nullptr, std::string valueText = "";
[&settings](int i) { if (setting.type == SettingType::TOGGLE && setting.valuePtr != nullptr) {
const auto& setting = settings[i]; const bool value = SETTINGS.*(setting.valuePtr);
std::string valueText = ""; valueText = value ? tr(STR_STATE_ON) : tr(STR_STATE_OFF);
if (setting.type == SettingType::TOGGLE && setting.valuePtr != nullptr) { } else if (setting.type == SettingType::ENUM && setting.valuePtr != nullptr) {
const bool value = SETTINGS.*(setting.valuePtr); const uint8_t value = SETTINGS.*(setting.valuePtr);
valueText = value ? tr(STR_STATE_ON) : tr(STR_STATE_OFF);
} else if (setting.type == SettingType::ENUM && setting.valuePtr != nullptr) {
const uint8_t value = SETTINGS.*(setting.valuePtr);
valueText = I18N.get(setting.enumValues[value]);
} else if (setting.type == SettingType::ENUM && setting.valueGetter) {
const uint8_t value = setting.valueGetter();
if (!setting.enumStringValues.empty() && value < setting.enumStringValues.size()) {
valueText = setting.enumStringValues[value];
} else if (value < setting.enumValues.size()) {
valueText = I18N.get(setting.enumValues[value]); valueText = I18N.get(setting.enumValues[value]);
} } else if (setting.type == SettingType::ENUM && setting.valueGetter) {
} else if (setting.type == SettingType::VALUE && setting.valuePtr != nullptr) { const uint8_t value = setting.valueGetter();
if (setting.nameId == StrId::STR_TIME_TO_SLEEP) { if (!setting.enumStringValues.empty() && value < setting.enumStringValues.size()) {
char valueBuffer[32]; valueText = setting.enumStringValues[value];
if (SETTINGS.sleepTimeoutMinutes >= CrossPointSettings::SLEEP_TIMEOUT_NEVER_MINUTES) { } else if (value < setting.enumValues.size()) {
valueText = tr(STR_SLEEP_NEVER); valueText = I18N.get(setting.enumValues[value]);
}
} else if (setting.type == SettingType::VALUE && setting.valuePtr != nullptr) {
if (setting.nameId == StrId::STR_TIME_TO_SLEEP) {
char valueBuffer[32];
if (SETTINGS.sleepTimeoutMinutes >= CrossPointSettings::SLEEP_TIMEOUT_NEVER_MINUTES) {
valueText = tr(STR_SLEEP_NEVER);
} else {
snprintf(valueBuffer, sizeof(valueBuffer), tr(STR_SLEEP_TIMER_VALUE_FORMAT),
static_cast<unsigned int>(SETTINGS.*(setting.valuePtr)));
valueText = valueBuffer;
}
} else { } else {
snprintf(valueBuffer, sizeof(valueBuffer), tr(STR_SLEEP_TIMER_VALUE_FORMAT), valueText = std::to_string(SETTINGS.*(setting.valuePtr));
static_cast<unsigned int>(SETTINGS.*(setting.valuePtr)));
valueText = valueBuffer;
} }
} else {
valueText = std::to_string(SETTINGS.*(setting.valuePtr));
} }
} return valueText;
return valueText; },
}, true);
true); }
// Draw help text // Draw help text
const auto confirmLabel = const auto confirmLabel =
@@ -387,7 +463,9 @@ void SettingsActivity::render(RenderLock&&) {
? tr(STR_SELECT) ? tr(STR_SELECT)
: tr(STR_TOGGLE)); : tr(STR_TOGGLE));
const auto labels = mappedInput.mapLabels(tr(STR_BACK), confirmLabel, tr(STR_DIR_UP), tr(STR_DIR_DOWN)); const auto labels = mappedInput.mapLabels(tr(STR_BACK), confirmLabel, tr(STR_DIR_UP), tr(STR_DIR_DOWN));
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); if (buttonsRect.width > 0 && buttonsRect.height > 0) {
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
}
// Always use standard refresh for settings screen // Always use standard refresh for settings screen
renderer.displayBuffer(); renderer.displayBuffer();
@@ -23,6 +23,7 @@ enum class SettingAction {
SdFirmwareUpdate, SdFirmwareUpdate,
Language, Language,
DownloadFonts, DownloadFonts,
DownloadThemes,
}; };
struct SettingInfo { struct SettingInfo {
@@ -166,6 +167,7 @@ class SettingsActivity final : public Activity {
void toggleCurrentSetting(); void toggleCurrentSetting();
void openSleepTimeoutPicker(); void openSleepTimeoutPicker();
void rebuildSettingsLists(); void rebuildSettingsLists();
void releaseSettingsLists();
void syncQuickResumeTimeoutForSleepScreen(bool sleepScreenChanged, bool quickResumeTimeoutChanged); void syncQuickResumeTimeoutForSleepScreen(bool sleepScreenChanged, bool quickResumeTimeoutChanged);
public: public:
@@ -77,6 +77,9 @@ const StrId titleNames[TITLE_ITEMS] = {StrId::STR_BOOK, StrId::STR_CHAPTER, StrI
constexpr int XTC_STATUS_BAR_ITEMS = 3; constexpr int XTC_STATUS_BAR_ITEMS = 3;
const StrId xtcStatusBarNames[XTC_STATUS_BAR_ITEMS] = {StrId::STR_HIDE, StrId::STR_BOTTOM, StrId::STR_TOP}; const StrId xtcStatusBarNames[XTC_STATUS_BAR_ITEMS] = {StrId::STR_HIDE, StrId::STR_BOTTOM, StrId::STR_TOP};
constexpr int STATUS_BAR_CLOCK_ITEMS = 3;
const StrId statusBarClockNames[STATUS_BAR_CLOCK_ITEMS] = {StrId::STR_HIDE, StrId::STR_DIR_RIGHT, StrId::STR_DIR_LEFT};
const int verticalPreviewPadding = 50; const int verticalPreviewPadding = 50;
const int verticalPreviewTextPadding = 40; const int verticalPreviewTextPadding = 40;
} // namespace } // namespace
@@ -112,6 +115,10 @@ void StatusBarSettingsActivity::onEnter() {
SETTINGS.clockFormat = 0; SETTINGS.clockFormat = 0;
} }
if (SETTINGS.statusBarClock >= STATUS_BAR_CLOCK_ITEMS) {
SETTINGS.statusBarClock = CrossPointSettings::STATUS_BAR_CLOCK_MODE::STATUS_BAR_CLOCK_HIDE;
}
requestUpdate(); requestUpdate();
} }
@@ -176,7 +183,7 @@ void StatusBarSettingsActivity::handleSelection() {
SETTINGS.xtcStatusBarMode = (SETTINGS.xtcStatusBarMode + 1) % XTC_STATUS_BAR_ITEMS; SETTINGS.xtcStatusBarMode = (SETTINGS.xtcStatusBarMode + 1) % XTC_STATUS_BAR_ITEMS;
break; break;
case ITEM_CLOCK: case ITEM_CLOCK:
SETTINGS.statusBarClock = (SETTINGS.statusBarClock + 1) % 2; SETTINGS.statusBarClock = (SETTINGS.statusBarClock + 1) % STATUS_BAR_CLOCK_ITEMS;
break; break;
case ITEM_CLOCK_FORMAT: case ITEM_CLOCK_FORMAT:
SETTINGS.clockFormat = (SETTINGS.clockFormat + 1) % CLOCK_FORMAT_ITEMS; SETTINGS.clockFormat = (SETTINGS.clockFormat + 1) % CLOCK_FORMAT_ITEMS;
@@ -225,7 +232,7 @@ void StatusBarSettingsActivity::render(RenderLock&&) {
case ITEM_XTC_STATUS_BAR: case ITEM_XTC_STATUS_BAR:
return I18N.get(xtcStatusBarNames[SETTINGS.xtcStatusBarMode]); return I18N.get(xtcStatusBarNames[SETTINGS.xtcStatusBarMode]);
case ITEM_CLOCK: case ITEM_CLOCK:
return SETTINGS.statusBarClock ? tr(STR_SHOW) : tr(STR_HIDE); return I18N.get(statusBarClockNames[SETTINGS.statusBarClock]);
case ITEM_CLOCK_FORMAT: { case ITEM_CLOCK_FORMAT: {
const uint8_t fmt = SETTINGS.clockFormat < CLOCK_FORMAT_ITEMS ? SETTINGS.clockFormat : 0; const uint8_t fmt = SETTINGS.clockFormat < CLOCK_FORMAT_ITEMS ? SETTINGS.clockFormat : 0;
return std::string(I18N.get(clockFormatNames[fmt])); return std::string(I18N.get(clockFormatNames[fmt]));
@@ -0,0 +1,584 @@
#include "ThemeDownloadActivity.h"
#include <ArduinoJson.h>
#include <GfxRenderer.h>
#include <HalStorage.h>
#include <I18n.h>
#include <Logging.h>
#include <WiFi.h>
#include <esp_rom_crc.h>
#include <cstring>
#include "MappedInputManager.h"
#include "SilentRestart.h"
#include "activities/network/WifiSelectionActivity.h"
#include "activities/util/ConfirmationActivity.h"
#include "components/UITheme.h"
#include "fontIds.h"
#include "network/HttpDownloader.h"
ThemeDownloadActivity::ThemeDownloadActivity(GfxRenderer& renderer, MappedInputManager& mappedInput)
: Activity("ThemeDownload", renderer, mappedInput), themeInstaller_(UITheme::getInstance().registry()) {}
void ThemeDownloadActivity::onEnter() {
Activity::onEnter();
WiFi.mode(WIFI_STA);
startActivityForResult(std::make_unique<WifiSelectionActivity>(renderer, mappedInput),
[this](const ActivityResult& result) { onWifiSelectionComplete(!result.isCancelled); });
}
void ThemeDownloadActivity::onExit() {
Activity::onExit();
if (WiFi.getMode() != WIFI_MODE_NULL) {
WiFi.disconnect(false);
delay(30);
silentRestart();
}
}
void ThemeDownloadActivity::onWifiSelectionComplete(const bool success) {
if (!success) {
finish();
return;
}
{
RenderLock lock(*this);
state_ = LOADING_MANIFEST;
downloadingThemeIndex_ = -1;
}
requestUpdateAndWait();
if (!fetchAndParseManifest()) {
RenderLock lock(*this);
state_ = ERROR;
return;
}
{
RenderLock lock(*this);
state_ = THEME_LIST;
selectedIndex_ = 0;
}
}
bool ThemeDownloadActivity::fetchAndParseManifest() {
static constexpr const char* MANIFEST_TMP = "/themes_manifest.tmp";
auto result = HttpDownloader::downloadToFile(THEME_MANIFEST_URL, MANIFEST_TMP, nullptr);
if (result != HttpDownloader::OK) {
LOG_ERR("THEME", "Failed to fetch manifest from %s", THEME_MANIFEST_URL);
errorMessage_ = tr(STR_DOWNLOAD_FAILED);
Storage.remove(MANIFEST_TMP);
return false;
}
HalFile manifestFile;
if (!Storage.openFileForRead("THEME", MANIFEST_TMP, manifestFile)) {
Storage.remove(MANIFEST_TMP);
errorMessage_ = tr(STR_DOWNLOAD_FAILED);
return false;
}
JsonDocument doc;
DeserializationError err = deserializeJson(doc, manifestFile);
manifestFile.close();
Storage.remove(MANIFEST_TMP);
if (err) {
LOG_ERR("THEME", "Manifest parse error: %s", err.c_str());
errorMessage_ = tr(STR_DOWNLOAD_FAILED);
return false;
}
const int version = doc["version"] | 0;
if (version != THEMES_MANIFEST_VERSION) {
LOG_ERR("THEME", "Unsupported manifest version: %d", version);
errorMessage_ = tr(STR_DOWNLOAD_FAILED);
return false;
}
baseUrl_ = doc["baseUrl"] | "";
while (!baseUrl_.empty() && baseUrl_.back() == '/') {
baseUrl_.pop_back();
}
if (!baseUrl_.empty()) {
baseUrl_ += "/";
}
themes_.clear();
themeInstaller_.refreshRegistry();
JsonArray themesArr = doc["themes"].as<JsonArray>();
themes_.reserve(themesArr.size());
for (JsonObject tObj : themesArr) {
ManifestTheme theme;
theme.id = tObj["id"] | "";
theme.name = tObj["name"] | theme.id;
theme.description = tObj["description"] | "";
theme.version = tObj["version"] | 0;
if (!ThemeInstaller::isValidThemeId(theme.id.c_str())) {
errorMessage_ = tr(STR_DOWNLOAD_FAILED);
return false;
}
JsonArray filesArr = tObj["files"].as<JsonArray>();
theme.files.reserve(filesArr.size());
for (JsonObject fileObj : filesArr) {
ManifestFile file;
file.path = fileObj["path"] | fileObj["name"] | "";
file.url = fileObj["url"] | file.path;
file.size = fileObj["size"] | 0;
if (!ThemeInstaller::isValidRelativePath(file.path.c_str()) ||
!ThemeInstaller::isValidRelativePath(file.url.c_str()) || !fileObj["crc32"].is<uint32_t>()) {
errorMessage_ = tr(STR_DOWNLOAD_FAILED);
return false;
}
file.crc32 = fileObj["crc32"].as<uint32_t>();
theme.totalSize += file.size;
theme.files.push_back(std::move(file));
}
theme.installed = themeInstaller_.isThemeInstalled(theme.id.c_str());
if (theme.installed) {
// Primary update signal: the manifest declares a newer theme version than
// the copy installed on the SD card.
const auto* installed = UITheme::getInstance().registry().findTheme(theme.id);
const int installedVersion = installed != nullptr ? installed->version : 0;
if (theme.version > installedVersion) {
theme.hasUpdate = true;
} else {
// Safety net: even at the same version, re-offer if a file is missing or
// its size no longer matches (catches a corrupted or partial install).
for (const auto& file : theme.files) {
char path[180];
if (!ThemeInstaller::buildThemePath(theme.id.c_str(), file.path.c_str(), path, sizeof(path))) {
errorMessage_ = tr(STR_DOWNLOAD_FAILED);
return false;
}
HalFile f;
if (Storage.openFileForRead("THEME", path, f)) {
const size_t actual = f.fileSize();
f.close();
if (actual != file.size) {
theme.hasUpdate = true;
break;
}
} else {
theme.hasUpdate = true;
break;
}
}
}
}
themes_.push_back(std::move(theme));
}
UITheme::getInstance().registry().clear();
LOG_DBG("THEME", "Manifest loaded: %zu themes", themes_.size());
return true;
}
void ThemeDownloadActivity::downloadAll() {
cancelRequested_ = false;
for (auto& theme : themes_) {
if (theme.installed) continue;
downloadTheme(theme);
if (state_ == ERROR || cancelRequested_) return;
}
RenderLock lock(*this);
state_ = COMPLETE;
}
void ThemeDownloadActivity::updateAll() {
cancelRequested_ = false;
for (auto& theme : themes_) {
if (!theme.hasUpdate) continue;
downloadTheme(theme);
if (state_ == ERROR || cancelRequested_) return;
}
RenderLock lock(*this);
state_ = COMPLETE;
}
bool ThemeDownloadActivity::showDownloadAllRow() const {
for (const auto& t : themes_) {
if (!t.installed) return true;
}
return false;
}
bool ThemeDownloadActivity::showUpdateAllRow() const {
for (const auto& t : themes_) {
if (t.hasUpdate) return true;
}
return false;
}
int ThemeDownloadActivity::specialRowCount() const {
return (showDownloadAllRow() ? 1 : 0) + (showUpdateAllRow() ? 1 : 0);
}
bool ThemeDownloadActivity::isDownloadAllRow(int index) const { return showDownloadAllRow() && index == 0; }
bool ThemeDownloadActivity::isUpdateAllRow(int index) const {
return showUpdateAllRow() && index == (showDownloadAllRow() ? 1 : 0);
}
int ThemeDownloadActivity::listItemCount() const {
return themes_.empty() ? 0 : static_cast<int>(themes_.size()) + specialRowCount();
}
size_t ThemeDownloadActivity::totalDownloadSize() const {
size_t total = 0;
for (const auto& t : themes_) {
if (!t.installed) total += t.totalSize;
}
return total;
}
size_t ThemeDownloadActivity::totalUpdateSize() const {
size_t total = 0;
for (const auto& t : themes_) {
if (t.hasUpdate) total += t.totalSize;
}
return total;
}
bool ThemeDownloadActivity::computeFileCrc32(const char* path, uint32_t& outCrc) {
HalFile f;
if (!Storage.openFileForRead("THEME", path, f)) return false;
constexpr size_t BUF_SIZE = 128;
uint8_t buf[BUF_SIZE];
uint32_t crc = 0;
while (f.available()) {
const int n = f.read(buf, BUF_SIZE);
if (n <= 0) break;
crc = esp_rom_crc32_le(crc, buf, static_cast<uint32_t>(n));
}
f.close();
outCrc = crc;
return true;
}
void ThemeDownloadActivity::downloadTheme(ManifestTheme& theme) {
{
RenderLock lock(*this);
state_ = DOWNLOADING;
downloadingThemeIndex_ = static_cast<int>(&theme - themes_.data());
fileProgress_ = 0;
fileTotal_ = 0;
cancelRequested_ = false;
}
requestUpdateAndWait();
if (!themeInstaller_.ensureThemeDir(theme.id.c_str())) {
RenderLock lock(*this);
state_ = ERROR;
errorMessage_ = tr(STR_DOWNLOAD_FAILED);
return;
}
for (size_t i = 0; i < theme.files.size(); i++) {
const auto& file = theme.files[i];
{
RenderLock lock(*this);
fileProgress_ = 0;
fileTotal_ = file.size;
}
requestUpdateAndWait();
char destPath[180];
if (!ThemeInstaller::buildThemePath(theme.id.c_str(), file.path.c_str(), destPath, sizeof(destPath))) {
RenderLock lock(*this);
state_ = ERROR;
errorMessage_ = tr(STR_DOWNLOAD_FAILED);
return;
}
if (!themeInstaller_.ensureParentDirs(destPath)) {
RenderLock lock(*this);
state_ = ERROR;
errorMessage_ = tr(STR_DOWNLOAD_FAILED);
return;
}
std::string url = baseUrl_ + file.url;
auto result = HttpDownloader::downloadToFile(
url, destPath,
[this](size_t downloaded, size_t total) {
fileProgress_ = downloaded;
fileTotal_ = total;
mappedInput.update();
if (mappedInput.isPressed(MappedInputManager::Button::Back) ||
mappedInput.wasPressed(MappedInputManager::Button::Back)) {
cancelRequested_ = true;
}
requestUpdate(true);
},
&cancelRequested_);
if (result == HttpDownloader::ABORTED) {
themeInstaller_.deleteTheme(theme.id.c_str());
theme.installed = false;
theme.hasUpdate = false;
RenderLock lock(*this);
state_ = THEME_LIST;
return;
}
if (result != HttpDownloader::OK) {
themeInstaller_.deleteTheme(theme.id.c_str());
theme.installed = false;
theme.hasUpdate = false;
RenderLock lock(*this);
state_ = ERROR;
errorMessage_ = std::string(tr(STR_DOWNLOAD_FAILED)) + ": " + file.path;
return;
}
uint32_t actualCrc = 0;
if (!computeFileCrc32(destPath, actualCrc) || actualCrc != file.crc32 ||
!themeInstaller_.validateThemeFile(destPath)) {
themeInstaller_.deleteTheme(theme.id.c_str());
theme.installed = false;
theme.hasUpdate = false;
RenderLock lock(*this);
state_ = ERROR;
errorMessage_ = tr(STR_DOWNLOAD_FAILED);
return;
}
currentFileIndex_++;
}
theme.installed = true;
theme.hasUpdate = false;
RenderLock lock(*this);
state_ = COMPLETE;
}
void ThemeDownloadActivity::promptDeleteSelectedTheme() {
const int pendingDeleteThemeIndex = themeIndexFromList(selectedIndex_);
if (pendingDeleteThemeIndex < 0 || pendingDeleteThemeIndex >= static_cast<int>(themes_.size())) return;
const auto& theme = themes_[pendingDeleteThemeIndex];
startActivityForResult(std::make_unique<ConfirmationActivity>(renderer, mappedInput, tr(STR_DELETE), theme.name),
[this](const ActivityResult& result) { onDeleteConfirmationResult(result); });
}
void ThemeDownloadActivity::onDeleteConfirmationResult(const ActivityResult& result) {
if (result.isCancelled) {
requestUpdate();
return;
}
auto& theme = themes_[themeIndexFromList(selectedIndex_)];
if (themeInstaller_.deleteTheme(theme.id.c_str()) != ThemeInstaller::Error::OK) {
RenderLock lock(*this);
state_ = ERROR;
errorMessage_ = tr(STR_DOWNLOAD_FAILED);
} else {
theme.installed = false;
theme.hasUpdate = false;
}
requestUpdate();
}
bool ThemeDownloadActivity::isSelectedThemeDeletable() const {
if (isDownloadAllRow(selectedIndex_) || isUpdateAllRow(selectedIndex_)) return false;
if (selectedIndex_ < specialRowCount() || selectedIndex_ >= listItemCount()) return false;
const auto& theme = themes_[themeIndexFromList(selectedIndex_)];
return theme.installed && !theme.hasUpdate;
}
void ThemeDownloadActivity::loop() {
if (state_ == THEME_LIST) {
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
finish();
return;
}
const int listSize = listItemCount();
const int pageItems = UITheme::getNumberOfItemsPerPage(renderer, true, false, true, false);
buttonNavigator_.onNextRelease([this, listSize] {
selectedIndex_ = ButtonNavigator::nextIndex(selectedIndex_, listSize);
requestUpdate();
});
buttonNavigator_.onPreviousRelease([this, listSize] {
selectedIndex_ = ButtonNavigator::previousIndex(selectedIndex_, listSize);
requestUpdate();
});
buttonNavigator_.onNextContinuous([this, listSize, pageItems] {
selectedIndex_ = ButtonNavigator::nextPageIndex(selectedIndex_, listSize, pageItems);
requestUpdate();
});
buttonNavigator_.onPreviousContinuous([this, listSize, pageItems] {
selectedIndex_ = ButtonNavigator::previousPageIndex(selectedIndex_, listSize, pageItems);
requestUpdate();
});
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm) && !themes_.empty()) {
if (isDownloadAllRow(selectedIndex_)) {
currentFileIndex_ = 0;
currentFileTotal_ = 0;
for (const auto& t : themes_) {
if (!t.installed) currentFileTotal_ += t.files.size();
}
downloadAll();
} else if (isUpdateAllRow(selectedIndex_)) {
currentFileIndex_ = 0;
currentFileTotal_ = 0;
for (const auto& t : themes_) {
if (t.hasUpdate) currentFileTotal_ += t.files.size();
}
updateAll();
} else {
auto& theme = themes_[themeIndexFromList(selectedIndex_)];
if (!theme.installed || theme.hasUpdate) {
currentFileIndex_ = 0;
currentFileTotal_ = theme.files.size();
downloadTheme(theme);
} else {
promptDeleteSelectedTheme();
return;
}
}
requestUpdateAndWait();
}
} else if (state_ == COMPLETE) {
if (mappedInput.wasPressed(MappedInputManager::Button::Back) ||
mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
RenderLock lock(*this);
state_ = THEME_LIST;
requestUpdate();
}
} else if (state_ == ERROR) {
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
RenderLock lock(*this);
state_ = THEME_LIST;
requestUpdate();
} else if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
if (downloadingThemeIndex_ >= 0 && downloadingThemeIndex_ < static_cast<int>(themes_.size())) {
downloadTheme(themes_[downloadingThemeIndex_]);
requestUpdateAndWait();
} else {
{
RenderLock lock(*this);
state_ = LOADING_MANIFEST;
errorMessage_.clear();
}
requestUpdateAndWait();
if (!fetchAndParseManifest()) {
RenderLock lock(*this);
state_ = ERROR;
} else {
RenderLock lock(*this);
state_ = THEME_LIST;
selectedIndex_ = 0;
}
requestUpdate();
}
}
}
}
std::string ThemeDownloadActivity::formatSize(size_t bytes) {
char buf[32];
if (bytes >= 1024 * 1024) {
snprintf(buf, sizeof(buf), "%.1f MB", static_cast<double>(bytes) / (1024.0 * 1024.0));
} else if (bytes >= 1024) {
snprintf(buf, sizeof(buf), "%.0f KB", static_cast<double>(bytes) / 1024.0);
} else {
snprintf(buf, sizeof(buf), "%zu B", bytes);
}
return buf;
}
void ThemeDownloadActivity::render(RenderLock&&) {
const 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_MANAGE_THEMES));
const auto lineHeight = renderer.getLineHeight(UI_10_FONT_ID);
const auto contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
const auto centerY = (pageHeight - lineHeight) / 2;
if (state_ == LOADING_MANIFEST) {
renderer.drawCenteredText(UI_10_FONT_ID, centerY, tr(STR_LOADING));
} else if (state_ == THEME_LIST) {
if (themes_.empty()) {
renderer.drawCenteredText(UI_10_FONT_ID, centerY, tr(STR_DOWNLOAD_FAILED));
const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", "");
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
} else {
GUI.drawList(
renderer,
Rect{0, contentTop, pageWidth, pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing},
listItemCount(), selectedIndex_,
[this](int index) -> std::string {
if (isDownloadAllRow(index))
return std::string(tr(STR_DOWNLOAD_ALL)) + " (" + formatSize(totalDownloadSize()) + ")";
if (isUpdateAllRow(index))
return std::string(tr(STR_UPDATE_ALL)) + " (" + formatSize(totalUpdateSize()) + ")";
return themes_[themeIndexFromList(index)].name;
},
[this](int index) -> std::string {
if (isDownloadAllRow(index) || isUpdateAllRow(index)) return "";
return themes_[themeIndexFromList(index)].description;
},
nullptr,
[this](int index) -> std::string {
if (isDownloadAllRow(index) || isUpdateAllRow(index)) return "";
const auto& t = themes_[themeIndexFromList(index)];
if (t.hasUpdate) return tr(STR_UPDATE_AVAILABLE);
if (t.installed) return tr(STR_INSTALLED);
return "";
},
true,
[this](int index) -> bool {
if (isDownloadAllRow(index) || isUpdateAllRow(index)) return false;
const auto& t = themes_[themeIndexFromList(index)];
return t.installed && !t.hasUpdate;
});
const auto labels = mappedInput.mapLabels(tr(STR_BACK),
isSelectedThemeDeletable() ? tr(STR_DELETE)
: isUpdateAllRow(selectedIndex_) ? tr(STR_UPDATE)
: tr(STR_DOWNLOAD),
tr(STR_DIR_UP), tr(STR_DIR_DOWN));
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
}
} else if (state_ == DOWNLOADING) {
const auto& theme = themes_[downloadingThemeIndex_];
std::string statusText = std::string(tr(STR_DOWNLOADING)) + " " + theme.name + " (" +
std::to_string(currentFileIndex_ + 1) + "/" + std::to_string(currentFileTotal_) + ")";
renderer.drawCenteredText(UI_10_FONT_ID, centerY - lineHeight, statusText.c_str());
float progress = 0;
if (fileTotal_ > 0) progress = static_cast<float>(fileProgress_) / static_cast<float>(fileTotal_);
GUI.drawProgressBar(renderer,
Rect{metrics.contentSidePadding, centerY + metrics.verticalSpacing,
pageWidth - metrics.contentSidePadding * 2, metrics.progressBarHeight},
static_cast<int>(progress * 100), 100);
const auto labels = mappedInput.mapLabels(tr(STR_CANCEL), "", "", "");
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
} else if (state_ == COMPLETE) {
renderer.drawCenteredText(UI_10_FONT_ID, centerY, tr(STR_INSTALLED), true, EpdFontFamily::BOLD);
const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", "");
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
} else if (state_ == ERROR) {
renderer.drawCenteredText(UI_10_FONT_ID, centerY - lineHeight, tr(STR_DOWNLOAD_FAILED), true, EpdFontFamily::BOLD);
if (!errorMessage_.empty())
renderer.drawCenteredText(UI_10_FONT_ID, centerY + metrics.verticalSpacing, errorMessage_.c_str());
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_RETRY), "", "");
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
}
renderer.displayBuffer();
}
@@ -0,0 +1,93 @@
#pragma once
#include <string>
#include <vector>
#include "ThemeInstaller.h"
#include "activities/Activity.h"
#include "util/ButtonNavigator.h"
#define THEMES_MANIFEST_VERSION 1
#define THEME_ROOT_URL "http://crosspointreader.com/themes"
#ifndef THEME_MANIFEST_URL
#define THEME_MANIFEST_URL THEME_ROOT_URL "/themes.json"
#endif
class ThemeDownloadActivity : public Activity {
public:
explicit ThemeDownloadActivity(GfxRenderer& renderer, MappedInputManager& mappedInput);
void onEnter() override;
void onExit() override;
void loop() override;
void render(RenderLock&&) override;
bool preventAutoSleep() override {
return state_ == LOADING_MANIFEST || state_ == DOWNLOADING || state_ == COMPLETE || state_ == ERROR;
}
bool skipLoopDelay() override { return true; }
private:
enum State {
WIFI_SELECTION,
LOADING_MANIFEST,
THEME_LIST,
DOWNLOADING,
COMPLETE,
ERROR,
};
struct ManifestFile {
std::string path;
std::string url;
size_t size = 0;
uint32_t crc32 = 0;
};
struct ManifestTheme {
std::string id;
std::string name;
std::string description;
int version = 0;
std::vector<ManifestFile> files;
size_t totalSize = 0;
bool installed = false;
bool hasUpdate = false;
};
State state_ = WIFI_SELECTION;
ThemeInstaller themeInstaller_;
ButtonNavigator buttonNavigator_;
std::string baseUrl_;
std::vector<ManifestTheme> themes_;
int selectedIndex_ = 0;
size_t currentFileIndex_ = 0;
size_t currentFileTotal_ = 0;
size_t fileProgress_ = 0;
size_t fileTotal_ = 0;
int downloadingThemeIndex_ = -1;
std::string errorMessage_;
bool cancelRequested_ = false;
void onWifiSelectionComplete(bool success);
bool fetchAndParseManifest();
void downloadTheme(ManifestTheme& theme);
void downloadAll();
void updateAll();
static bool computeFileCrc32(const char* path, uint32_t& outCrc);
bool showDownloadAllRow() const;
bool showUpdateAllRow() const;
int specialRowCount() const;
bool isDownloadAllRow(int index) const;
bool isUpdateAllRow(int index) const;
bool isSelectedThemeDeletable() const;
void promptDeleteSelectedTheme();
void onDeleteConfirmationResult(const ActivityResult& result);
int themeIndexFromList(int listIndex) const { return listIndex - specialRowCount(); }
int listItemCount() const;
size_t totalDownloadSize() const;
size_t totalUpdateSize() const;
static std::string formatSize(size_t bytes);
};
+292 -15
View File
@@ -2,12 +2,13 @@
#include <FsHelpers.h> #include <FsHelpers.h>
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <HalGPIO.h>
#include <Logging.h> #include <Logging.h>
#include <algorithm>
#include <cmath>
#include <memory> #include <memory>
#include "MappedInputManager.h"
#include "RecentBooksStore.h"
#include "components/themes/BaseTheme.h" #include "components/themes/BaseTheme.h"
#include "components/themes/lyra/Lyra3CoversTheme.h" #include "components/themes/lyra/Lyra3CoversTheme.h"
#include "components/themes/lyra/LyraTheme.h" #include "components/themes/lyra/LyraTheme.h"
@@ -15,39 +16,314 @@
UITheme UITheme::instance; UITheme UITheme::instance;
namespace {
// Round a pixel dimension by a scale factor.
int sp(int v, float s) { return static_cast<int>(std::lround(v * s)); }
} // namespace
// Density scale is 1.0 here (button devices); touch builds override this to wire
// in the per-board profile. Resolution scaling composes with it (see reload()).
float UITheme::uiScale() { return 1.0f; }
// Unified theme metric scaling. Two independent, composable inputs:
// res - resolution ratio: this panel's pixels vs the theme's design
// resolution. Applies to EVERY pixel field, including the home cover
// and reader chrome, because more/fewer pixels means the whole layout
// scales proportionally and still fits by construction.
// density - per-board UI/touch-target scale (uiScale: 1.0 on button devices,
// >1 on high-density touch boards). Same pixel count, so it applies
// ONLY to chrome that can grow into spare space, and is deliberately
// withheld from fit-constrained elements (home cover, reader status/
// progress bars) that would overflow the fixed panel if enlarged.
// Effective factor per field is res (always) times density (where eligible).
// Counts, percents, ratios, and bools are never scaled. Degrades exactly: with
// density==1 this is pure resolution scaling; with res==1, pure density scaling.
ThemeMetrics scaleThemeMetrics(const ThemeMetrics& b, float res, float density) {
static_assert(sizeof(ThemeMetrics) == THEME_METRICS_SIZEOF,
"ThemeMetrics changed: review scaleThemeMetrics() and update THEME_METRICS_SIZEOF");
ThemeMetrics m = b;
const float full = res * density; // resolution + density-eligible chrome
if (res == 1.0f && density == 1.0f) return m;
m.batteryWidth = sp(b.batteryWidth, full);
m.batteryHeight = sp(b.batteryHeight, full);
m.topPadding = sp(b.topPadding, full);
m.batteryBarHeight = sp(b.batteryBarHeight, full);
m.headerHeight = sp(b.headerHeight, full);
m.verticalSpacing = sp(b.verticalSpacing, full);
m.previewPadding = sp(b.previewPadding, full); // previewHeightPercent is a percent: not scaled
m.contentSidePadding = sp(b.contentSidePadding, full);
m.listRowHeight = sp(b.listRowHeight, full);
m.listWithSubtitleRowHeight = sp(b.listWithSubtitleRowHeight, full);
m.menuRowHeight = sp(b.menuRowHeight, full);
m.menuSpacing = sp(b.menuSpacing, full);
m.tabSpacing = sp(b.tabSpacing, full);
m.tabBarHeight = sp(b.tabBarHeight, full);
m.scrollBarWidth = sp(b.scrollBarWidth, full);
m.scrollBarRightOffset = sp(b.scrollBarRightOffset, full);
m.homeTopPadding = sp(b.homeTopPadding, full);
// Fit-constrained: resolution only, never density (would push the menu off the
// fixed-height home screen). homeRecentBooksCount/bools are not scaled.
m.homeCoverHeight = sp(b.homeCoverHeight, res);
m.homeCoverTileHeight = sp(b.homeCoverTileHeight, res);
m.homeMenuTopOffset = sp(b.homeMenuTopOffset, full);
m.buttonHintsHeight = sp(b.buttonHintsHeight, full);
m.sideButtonHintsWidth = sp(b.sideButtonHintsWidth, full);
// Reader chrome (compact, uses the un-remapped SMALL font): resolution only,
// never density, so it does not eat reading area on high-density boards.
m.progressBarHeight = sp(b.progressBarHeight, res);
m.progressBarMarginTop = sp(b.progressBarMarginTop, res);
m.statusBarHorizontalMargin = sp(b.statusBarHorizontalMargin, res);
m.statusBarVerticalMargin = sp(b.statusBarVerticalMargin, res);
m.keyboardKeyWidth = sp(b.keyboardKeyWidth, full);
m.keyboardKeyHeight = sp(b.keyboardKeyHeight, full);
m.keyboardKeySpacing = sp(b.keyboardKeySpacing, full);
m.keyboardBottomKeyHeight = sp(b.keyboardBottomKeyHeight, full);
m.keyboardBottomKeySpacing = sp(b.keyboardBottomKeySpacing, full);
m.keyboardVerticalOffset = sp(b.keyboardVerticalOffset, full);
// keyboardTextFieldWidthPercent / keyboardWidthPercent are percents: not scaled
m.keyboardKeyCornerRadius = sp(b.keyboardKeyCornerRadius, full);
m.keyboardSecondaryLabelRightPadding = sp(b.keyboardSecondaryLabelRightPadding, full);
m.keyboardSecondaryLabelTopPadding = sp(b.keyboardSecondaryLabelTopPadding, full);
m.keyboardMinArrowHeadSize = sp(b.keyboardMinArrowHeadSize, full);
// popupTopOffsetRatio is a ratio: not scaled
m.popupMarginX = sp(b.popupMarginX, full);
m.popupMarginY = sp(b.popupMarginY, full);
m.popupFrameThickness = sp(b.popupFrameThickness, full);
m.popupCornerRadius = sp(b.popupCornerRadius, full);
m.popupTextBaselineOffsetY = sp(b.popupTextBaselineOffsetY, full);
m.popupProgressBarHeight = sp(b.popupProgressBarHeight, full);
m.textFieldHorizontalPadding = sp(b.textFieldHorizontalPadding, full);
m.textFieldNormalThickness = sp(b.textFieldNormalThickness, full);
m.textFieldCursorThickness = sp(b.textFieldCursorThickness, full);
m.textFieldLineEndOffset = sp(b.textFieldLineEndOffset, full);
return m;
}
namespace {
// Scale the cover-strip slot geometry. Covers are fit-constrained, so like
// homeCover they take the resolution factor only, never density. widthPercent is
// a ratio of the cover height and stays unscaled; only pixel offsets/heights move.
void scaleHomeRecents(ThemeHomeRecentsSpec& spec, float res) {
if (res == 1.0f) return;
spec.panelCornerRadius = sp(spec.panelCornerRadius, res);
spec.panelInsetX = sp(spec.panelInsetX, res);
spec.selectionCornerRadius = sp(spec.selectionCornerRadius, res);
for (auto& slot : spec.slots) {
slot.height = sp(slot.height, res);
slot.xOffset = sp(slot.xOffset, res);
slot.yOffset = sp(slot.yOffset, res);
slot.title.offsetY = sp(slot.title.offsetY, res);
}
}
// Resolution scale = (device native portrait panel) / (theme's declared design
// resolution). Uniform min(width,height) ratio: no axis distortion, content fits
// the tighter axis. Returns 1.0 when constraints are missing (back-compat no-op).
float resolutionScale(const SdThemeDeviceConstraints& design) {
if (design.screenWidth <= 0 || design.screenHeight <= 0) return 1.0f;
// Native portrait dimensions are fixed per device (X3 528x792, X4 480x800).
const int actualW = gpio.deviceIsX3() ? 528 : 480;
const int actualH = gpio.deviceIsX3() ? 792 : 800;
const float wRatio = static_cast<float>(actualW) / static_cast<float>(design.screenWidth);
const float hRatio = static_cast<float>(actualH) / static_cast<float>(design.screenHeight);
return std::min(wRatio, hRatio);
}
} // namespace
UITheme::UITheme() { UITheme::UITheme() {
auto themeType = static_cast<CrossPointSettings::UI_THEME>(SETTINGS.uiTheme); auto themeType = static_cast<CrossPointSettings::UI_THEME>(SETTINGS.uiTheme);
setTheme(themeType); setTheme(themeType);
} }
void UITheme::clearSdThemeState() {
currentSdMetrics = ThemeMetrics{};
currentSdHomeRecents = ThemeHomeRecentsSpec{};
currentSdButtonMenu = ThemeButtonMenuSpec{};
currentSdList = ThemeListSpec{};
currentSdButtonHints = ThemeButtonHintsSpec{};
currentSdTabBar = ThemeTabBarSpec{};
currentSdHeader = ThemeHeaderSpec{};
currentSdHomeScreen = ThemeHomeScreenSpec{};
currentSdFileBrowserScreen = ThemeScreenSpec{};
currentSdRecentBooksScreen = ThemeScreenSpec{};
currentSdSettingsScreen = ThemeScreenSpec{};
currentSdReaderScreen = ThemeScreenSpec{};
currentSdReaderChrome = ThemeReaderChromeSpec{};
currentSdThemePath.clear();
currentSdIcons.clear();
}
void UITheme::refreshRegistry() { themeRegistry.discover(); }
void UITheme::releaseSdThemeAssetMemory() {
// Keep active SD theme backing storage intact; currentTheme may hold pointers
// into it. This only releases discovered theme metadata that can be rebuilt.
themeRegistry.clear();
}
std::vector<int> UITheme::getHomeCoverThumbHeights() const {
std::vector<int> heights;
heights.reserve(1 + currentSdHomeRecents.slots.size());
auto addHeight = [&heights](int height) {
if (height > 0 && std::find(heights.begin(), heights.end(), height) == heights.end()) {
heights.push_back(height);
}
};
addHeight(currentMetrics->homeCoverHeight);
if (currentSdHomeRecents.type == ThemeHomeRecentsType::CoverStrip) {
for (const auto& slot : currentSdHomeRecents.slots) {
addHeight(slot.height);
}
}
if (currentSdHomeScreen.enabled) {
for (const auto& widget : currentSdHomeScreen.widgets) {
if (widget.type == ThemeHomeWidgetType::FeaturedBookCard) {
addHeight(widget.featured.coverHeight);
}
if (widget.type == ThemeHomeWidgetType::RecentCoverGrid) {
addHeight(widget.coverGrid.coverHeight);
}
}
}
if (currentSdRecentBooksScreen.enabled) {
for (const auto& widget : currentSdRecentBooksScreen.widgets) {
if (widget.type == ThemeScreenWidgetType::CoverGrid) {
addHeight(widget.coverGrid.coverHeight);
}
}
}
if (heights.empty()) return {};
return {*std::max_element(heights.begin(), heights.end())};
}
int UITheme::getHomeCoverThumbHeight() const {
const auto heights = getHomeCoverThumbHeights();
return heights.empty() ? 0 : heights.front();
}
int UITheme::getRecentBooksCoverThumbHeight() const { return getHomeCoverThumbHeight(); }
const ThemeScreenSpec* UITheme::getScreenSpec(ThemeScreenKind screen) const {
switch (screen) {
case ThemeScreenKind::FileBrowser:
return currentSdFileBrowserScreen.enabled ? &currentSdFileBrowserScreen : nullptr;
case ThemeScreenKind::RecentBooks:
return currentSdRecentBooksScreen.enabled ? &currentSdRecentBooksScreen : nullptr;
case ThemeScreenKind::Settings:
return currentSdSettingsScreen.enabled ? &currentSdSettingsScreen : nullptr;
case ThemeScreenKind::Reader:
return currentSdReaderScreen.enabled ? &currentSdReaderScreen : nullptr;
case ThemeScreenKind::Home:
default:
return nullptr;
}
}
void UITheme::reload() { void UITheme::reload() {
auto themeType = static_cast<CrossPointSettings::UI_THEME>(SETTINGS.uiTheme); if (SETTINGS.sdThemeName[0] != '\0') {
setTheme(themeType); const SdCardThemeInfo* themeInfo = themeRegistry.findTheme(SETTINGS.sdThemeName);
if (themeInfo == nullptr) {
refreshRegistry();
themeInfo = themeRegistry.findTheme(SETTINGS.sdThemeName);
}
if (themeInfo == nullptr) {
LOG_ERR("UI", "SD theme not found: %s (falling back to built-in theme)", SETTINGS.sdThemeName);
themeRegistry.clear();
SETTINGS.sdThemeName[0] = '\0';
SETTINGS.saveToFile();
setTheme(static_cast<CrossPointSettings::UI_THEME>(SETTINGS.uiTheme));
return;
}
LOG_DBG("UI", "Using SD theme: %s recentsType=%d count=%d slots=%d", themeInfo->id.c_str(),
static_cast<int>(themeInfo->homeRecents.type), themeInfo->metrics.homeRecentBooksCount,
static_cast<int>(themeInfo->homeRecents.slots.size()));
// Adapt the theme (authored at its declared design resolution) to this panel:
// resolution ratio for everything, plus the per-board density scale for chrome.
const float res = resolutionScale(themeInfo->constraints);
LOG_DBG("UI", "Theme scale: res %d.%03d density %d.%03d (design %dx%d)", static_cast<int>(res),
static_cast<int>(res * 1000) % 1000, static_cast<int>(uiScale()), static_cast<int>(uiScale() * 1000) % 1000,
themeInfo->constraints.screenWidth, themeInfo->constraints.screenHeight);
currentSdMetrics = scaleThemeMetrics(themeInfo->metrics, res, uiScale());
currentSdHomeRecents = themeInfo->homeRecents;
scaleHomeRecents(currentSdHomeRecents, res);
currentSdButtonMenu = themeInfo->buttonMenu;
currentSdList = themeInfo->list;
currentSdButtonHints = themeInfo->buttonHints;
currentSdTabBar = themeInfo->tabBar;
currentSdHeader = themeInfo->header;
currentSdHomeScreen = themeInfo->homeScreen;
currentSdFileBrowserScreen = themeInfo->fileBrowserScreen;
currentSdRecentBooksScreen = themeInfo->recentBooksScreen;
currentSdSettingsScreen = themeInfo->settingsScreen;
currentSdReaderScreen = themeInfo->readerScreen;
currentSdReaderChrome = themeInfo->readerChrome;
currentSdThemePath = themeInfo->path;
currentSdIcons = themeInfo->icons;
const bool inheritsClassic = themeInfo->inherits == "classic";
themeRegistry.clear();
if (inheritsClassic) {
currentTheme = std::make_unique<BaseTheme>();
currentMetrics = &currentSdMetrics;
return;
}
const ThemeHomeRecentsSpec* homeRecents =
currentSdHomeRecents.type != ThemeHomeRecentsType::Default ? &currentSdHomeRecents : nullptr;
const ThemeButtonMenuSpec* buttonMenu = currentSdButtonMenu.enabled ? &currentSdButtonMenu : nullptr;
const ThemeListSpec* list = currentSdList.enabled ? &currentSdList : nullptr;
const ThemeButtonHintsSpec* buttonHints = currentSdButtonHints.enabled ? &currentSdButtonHints : nullptr;
const ThemeTabBarSpec* tabBar = currentSdTabBar.enabled ? &currentSdTabBar : nullptr;
const ThemeHeaderSpec* header = currentSdHeader.enabled ? &currentSdHeader : nullptr;
currentTheme = std::make_unique<LyraTheme>(&currentSdMetrics, homeRecents, buttonMenu, list, buttonHints, tabBar,
header, currentSdThemePath.c_str(), &currentSdIcons);
currentMetrics = &currentSdMetrics;
return;
}
setTheme(static_cast<CrossPointSettings::UI_THEME>(SETTINGS.uiTheme));
} }
void UITheme::setTheme(CrossPointSettings::UI_THEME type) { void UITheme::setTheme(CrossPointSettings::UI_THEME type) {
std::unique_ptr<BaseTheme> nextTheme;
const ThemeMetrics* nextMetrics = &LyraMetrics::values;
switch (type) { switch (type) {
case CrossPointSettings::UI_THEME::CLASSIC: case CrossPointSettings::UI_THEME::CLASSIC:
LOG_DBG("UI", "Using Classic theme"); LOG_DBG("UI", "Using Classic theme");
currentTheme = std::make_unique<BaseTheme>(); nextTheme = std::make_unique<BaseTheme>();
currentMetrics = &BaseMetrics::values; nextMetrics = &BaseMetrics::values;
break; break;
case CrossPointSettings::UI_THEME::LYRA: case CrossPointSettings::UI_THEME::LYRA:
LOG_DBG("UI", "Using Lyra theme"); LOG_DBG("UI", "Using Lyra theme");
currentTheme = std::make_unique<LyraTheme>(); nextTheme = std::make_unique<LyraTheme>();
currentMetrics = &LyraMetrics::values; nextMetrics = &LyraMetrics::values;
break; break;
case CrossPointSettings::UI_THEME::ROUNDEDRAFF: case CrossPointSettings::UI_THEME::ROUNDEDRAFF:
LOG_DBG("UI", "Using RoundedRaff theme"); LOG_DBG("UI", "Using RoundedRaff theme");
currentTheme = std::make_unique<RoundedRaffTheme>(); nextTheme = std::make_unique<RoundedRaffTheme>();
currentMetrics = &RoundedRaffMetrics::values; nextMetrics = &RoundedRaffMetrics::values;
break; break;
case CrossPointSettings::UI_THEME::LYRA_3_COVERS: case CrossPointSettings::UI_THEME::LYRA_3_COVERS:
LOG_DBG("UI", "Using Lyra 3 Covers theme"); LOG_DBG("UI", "Using Lyra 3 Covers theme");
currentTheme = std::make_unique<Lyra3CoversTheme>(); nextTheme = std::make_unique<Lyra3CoversTheme>();
currentMetrics = &Lyra3CoversMetrics::values; nextMetrics = &Lyra3CoversMetrics::values;
break;
default:
LOG_DBG("UI", "Using Lyra theme");
nextTheme = std::make_unique<LyraTheme>();
nextMetrics = &LyraMetrics::values;
break; break;
} }
currentTheme = std::move(nextTheme);
currentMetrics = nextMetrics;
clearSdThemeState();
themeRegistry.clear();
} }
int UITheme::getNumberOfItemsPerPage(const GfxRenderer& renderer, bool hasHeader, bool hasTabBar, bool hasButtonHints, int UITheme::getNumberOfItemsPerPage(const GfxRenderer& renderer, bool hasHeader, bool hasTabBar, bool hasButtonHints,
@@ -131,9 +407,10 @@ int UITheme::getStatusBarHeight() {
const ThemeMetrics& metrics = UITheme::getInstance().getMetrics(); const ThemeMetrics& metrics = UITheme::getInstance().getMetrics();
// Add status bar margin // Add status bar margin
const bool showStatusBar = SETTINGS.statusBarChapterPageCount || SETTINGS.statusBarBookProgressPercentage || const bool showStatusBar =
SETTINGS.statusBarTitle != CrossPointSettings::STATUS_BAR_TITLE::HIDE_TITLE || SETTINGS.statusBarChapterPageCount || SETTINGS.statusBarBookProgressPercentage ||
SETTINGS.statusBarBattery; SETTINGS.statusBarTitle != CrossPointSettings::STATUS_BAR_TITLE::HIDE_TITLE || SETTINGS.statusBarBattery ||
SETTINGS.statusBarClock != CrossPointSettings::STATUS_BAR_CLOCK_MODE::STATUS_BAR_CLOCK_HIDE;
const bool showProgressBar = const bool showProgressBar =
SETTINGS.statusBarProgressBar != CrossPointSettings::STATUS_BAR_PROGRESS_BAR::HIDE_PROGRESS; SETTINGS.statusBarProgressBar != CrossPointSettings::STATUS_BAR_PROGRESS_BAR::HIDE_PROGRESS;
return (showStatusBar ? (metrics.statusBarVerticalMargin) : 0) + return (showStatusBar ? (metrics.statusBarVerticalMargin) : 0) +
+43
View File
@@ -4,9 +4,11 @@
#include <functional> #include <functional>
#include <memory> #include <memory>
#include <vector>
#include "CrossPointSettings.h" #include "CrossPointSettings.h"
#include "components/themes/BaseTheme.h" #include "components/themes/BaseTheme.h"
#include "components/themes/SdCardThemeRegistry.h"
class UITheme { class UITheme {
// Static instance // Static instance
@@ -18,12 +20,28 @@ class UITheme {
const ThemeMetrics& getMetrics() const { return *currentMetrics; } const ThemeMetrics& getMetrics() const { return *currentMetrics; }
const BaseTheme& getTheme() const { return *currentTheme; } const BaseTheme& getTheme() const { return *currentTheme; }
int getHomeCoverThumbHeight() const;
std::vector<int> getHomeCoverThumbHeights() const;
int getRecentBooksCoverThumbHeight() const;
const ThemeHomeScreenSpec* getHomeScreenSpec() const {
return currentSdHomeScreen.enabled ? &currentSdHomeScreen : nullptr;
}
const ThemeScreenSpec* getScreenSpec(ThemeScreenKind screen) const;
const ThemeReaderChromeSpec* getReaderChromeSpec() const {
return currentSdReaderChrome.battery.enabled ? &currentSdReaderChrome : nullptr;
}
SdCardThemeRegistry& registry() { return themeRegistry; }
void refreshRegistry();
void releaseSdThemeAssetMemory();
Rect getScreenSafeArea(const GfxRenderer& renderer, bool hasFrontButtonHints = false, Rect getScreenSafeArea(const GfxRenderer& renderer, bool hasFrontButtonHints = false,
bool hasSideButtonHints = false); bool hasSideButtonHints = false);
static void drawCenteredText(const GfxRenderer& renderer, Rect screen, int fontId, int y, const char* text, static void drawCenteredText(const GfxRenderer& renderer, Rect screen, int fontId, int y, const char* text,
bool black = true, EpdFontFamily::Style style = EpdFontFamily::REGULAR); bool black = true, EpdFontFamily::Style style = EpdFontFamily::REGULAR);
void reload(); void reload();
void setTheme(CrossPointSettings::UI_THEME type); void setTheme(CrossPointSettings::UI_THEME type);
// Per-board UI/touch-target density scale: 1.0 on button devices, >1 on
// high-density touch boards (wired to the board profile on touch builds).
static float uiScale();
static int getNumberOfItemsPerPage(const GfxRenderer& renderer, bool hasHeader, bool hasTabBar, bool hasButtonHints, static int getNumberOfItemsPerPage(const GfxRenderer& renderer, bool hasHeader, bool hasTabBar, bool hasButtonHints,
bool hasSubtitle, int extraReservedHeight = 0); bool hasSubtitle, int extraReservedHeight = 0);
static std::string getCoverThumbPath(std::string coverBmpPath, int coverHeight); static std::string getCoverThumbPath(std::string coverBmpPath, int coverHeight);
@@ -32,9 +50,34 @@ class UITheme {
static int getProgressBarHeight(); static int getProgressBarHeight();
private: private:
void clearSdThemeState();
const ThemeMetrics* currentMetrics; const ThemeMetrics* currentMetrics;
ThemeMetrics currentSdMetrics;
ThemeHomeRecentsSpec currentSdHomeRecents;
ThemeButtonMenuSpec currentSdButtonMenu;
ThemeListSpec currentSdList;
ThemeButtonHintsSpec currentSdButtonHints;
ThemeTabBarSpec currentSdTabBar;
ThemeHeaderSpec currentSdHeader;
ThemeHomeScreenSpec currentSdHomeScreen;
ThemeScreenSpec currentSdFileBrowserScreen;
ThemeScreenSpec currentSdRecentBooksScreen;
ThemeScreenSpec currentSdSettingsScreen;
ThemeScreenSpec currentSdReaderScreen;
ThemeReaderChromeSpec currentSdReaderChrome;
std::string currentSdThemePath;
ThemeIconMap currentSdIcons;
std::unique_ptr<BaseTheme> currentTheme; std::unique_ptr<BaseTheme> currentTheme;
SdCardThemeRegistry themeRegistry;
}; };
// Unified theme metric scaling (definition + field classification in UITheme.cpp).
// res - resolution ratio (panel pixels vs theme design resolution)
// density - per-board UI density (UITheme::uiScale())
// Applies res to every pixel field; density additionally to non-fit-constrained
// chrome. Degrades to either factor alone when the other is 1.0.
ThemeMetrics scaleThemeMetrics(const ThemeMetrics& base, float res, float density);
// Helper macro to access current theme // Helper macro to access current theme
#define GUI UITheme::getInstance().getTheme() #define GUI UITheme::getInstance().getTheme()
+233 -38
View File
@@ -1,5 +1,6 @@
#include "BaseTheme.h" #include "BaseTheme.h"
#include <FreeInkUIGfxRenderer.h>
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <HalClock.h> #include <HalClock.h>
#include <HalPowerManager.h> #include <HalPowerManager.h>
@@ -14,6 +15,7 @@
#include "RecentBooksStore.h" #include "RecentBooksStore.h"
#include "components/UITheme.h" #include "components/UITheme.h"
#include "components/icons/bookmark.h" #include "components/icons/bookmark.h"
#include "components/themes/ThemeLayout.h"
#include "fontIds.h" #include "fontIds.h"
// Internal constants // Internal constants
@@ -43,6 +45,71 @@ void drawBookmarkStatusIcon(const GfxRenderer& renderer, const int x, const int
} }
} }
std::string readerProgressText(const float bookProgress, const int currentPage, const int pageCount) {
char progressStr[32];
if (SETTINGS.statusBarBookProgressPercentage && SETTINGS.statusBarChapterPageCount) {
snprintf(progressStr, sizeof(progressStr), "%d/%d %.0f%%", currentPage, pageCount, bookProgress);
} else if (SETTINGS.statusBarBookProgressPercentage) {
snprintf(progressStr, sizeof(progressStr), "%.0f%%", bookProgress);
} else if (SETTINGS.statusBarChapterPageCount) {
snprintf(progressStr, sizeof(progressStr), "%d/%d", currentPage, pageCount);
} else {
progressStr[0] = '\0';
}
return progressStr;
}
freeink::ui::BatteryBarTrack toFreeInkBatteryTrack(ThemeBatteryBarTrack value) {
switch (value) {
case ThemeBatteryBarTrack::Hairline:
return freeink::ui::BatteryBarTrack::Hairline;
case ThemeBatteryBarTrack::Outline:
return freeink::ui::BatteryBarTrack::Outline;
case ThemeBatteryBarTrack::Dither:
return freeink::ui::BatteryBarTrack::Dither;
case ThemeBatteryBarTrack::None:
default:
return freeink::ui::BatteryBarTrack::None;
}
}
freeink::ui::BatteryBarFill toFreeInkBatteryFill(ThemeBatteryBarFill value) {
switch (value) {
case ThemeBatteryBarFill::Dither:
return freeink::ui::BatteryBarFill::Dither;
case ThemeBatteryBarFill::Segments:
return freeink::ui::BatteryBarFill::Segments;
case ThemeBatteryBarFill::Solid:
default:
return freeink::ui::BatteryBarFill::Solid;
}
}
freeink::ui::BatteryBarDirection toFreeInkBatteryDirection(ThemeBatteryBarDirection value) {
switch (value) {
case ThemeBatteryBarDirection::RightToLeft:
return freeink::ui::BatteryBarDirection::RightToLeft;
case ThemeBatteryBarDirection::CenterOut:
return freeink::ui::BatteryBarDirection::CenterOut;
case ThemeBatteryBarDirection::BottomToTop:
return freeink::ui::BatteryBarDirection::BottomToTop;
case ThemeBatteryBarDirection::TopToBottom:
return freeink::ui::BatteryBarDirection::TopToBottom;
case ThemeBatteryBarDirection::LeftToRight:
default:
return freeink::ui::BatteryBarDirection::LeftToRight;
}
}
freeink::ui::BatteryBarCaps toFreeInkBatteryCaps(ThemeBatteryBarCaps value) {
return value == ThemeBatteryBarCaps::Pixel ? freeink::ui::BatteryBarCaps::Pixel : freeink::ui::BatteryBarCaps::Square;
}
freeink::ui::BatteryBarOrientation toFreeInkBatteryOrientation(ThemeBatteryBarOrientation value) {
return value == ThemeBatteryBarOrientation::Vertical ? freeink::ui::BatteryBarOrientation::Vertical
: freeink::ui::BatteryBarOrientation::Horizontal;
}
} // namespace } // namespace
void BaseTheme::drawBatteryOutline(const GfxRenderer& renderer, int x, int y, int battWidth, int rectHeight) { void BaseTheme::drawBatteryOutline(const GfxRenderer& renderer, int x, int y, int battWidth, int rectHeight) {
@@ -56,7 +123,7 @@ void BaseTheme::drawBatteryOutline(const GfxRenderer& renderer, int x, int y, in
renderer.drawLine(x + battWidth - 2, y + 1, x + battWidth - 2, y + rectHeight - 2); renderer.drawLine(x + battWidth - 2, y + 1, x + battWidth - 2, y + rectHeight - 2);
renderer.drawPixel(x + battWidth - 1, y + 3); renderer.drawPixel(x + battWidth - 1, y + 3);
renderer.drawPixel(x + battWidth - 1, y + rectHeight - 4); renderer.drawPixel(x + battWidth - 1, y + rectHeight - 4);
renderer.drawLine(x + battWidth - 0, y + 4, x + battWidth - 0, y + rectHeight - 5); renderer.drawLine(x + battWidth - 1, y + 4, x + battWidth - 1, y + rectHeight - 5);
} }
void BaseTheme::drawBatteryLightningBolt(const GfxRenderer& renderer, int boltX, int boltY) { void BaseTheme::drawBatteryLightningBolt(const GfxRenderer& renderer, int boltX, int boltY) {
@@ -435,10 +502,12 @@ void BaseTheme::drawTabBar(const GfxRenderer& renderer, const Rect rect, const s
// Draw the "Recent Book" cover card on the home screen // Draw the "Recent Book" cover card on the home screen
// TODO: Refactor method to make it cleaner, split into smaller methods // TODO: Refactor method to make it cleaner, split into smaller methods
void BaseTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector<RecentBook>& recentBooks, void BaseTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector<RecentBook>& recentBooks,
const int selectorIndex, bool& coverRendered, bool& coverBufferStored, const int coverSelectorIndex, bool& coverRendered, bool& coverBufferStored,
bool& bufferRestored, std::function<bool()> storeCoverBuffer) const { bool& bufferRestored, std::function<bool()> storeCoverBuffer,
bool coverStripSelected) const {
(void)coverSelectorIndex;
const bool hasContinueReading = !recentBooks.empty(); const bool hasContinueReading = !recentBooks.empty();
const bool bookSelected = hasContinueReading && selectorIndex == 0; const bool bookSelected = hasContinueReading && coverStripSelected;
// --- Top "book" card for the current title (selectorIndex == 0) --- // --- Top "book" card for the current title (selectorIndex == 0) ---
// When there's no cover image, use fixed size (half screen) // When there's no cover image, use fixed size (half screen)
@@ -691,6 +760,113 @@ void BaseTheme::drawButtonMenu(GfxRenderer& renderer, Rect rect, int buttonCount
} }
} }
void drawReaderBattery(const GfxRenderer& renderer, Rect rect, const bool showPercentage) {
const ThemeReaderChromeSpec* chrome = UITheme::getInstance().getReaderChromeSpec();
if (chrome == nullptr || chrome->battery.style == ThemeBatteryIndicatorStyle::Icon) {
const ThemeMetrics& metrics = UITheme::getInstance().getMetrics();
const bool effectiveShowPercentage = showPercentage && (chrome == nullptr || chrome->battery.showPercentage);
const int iconWidth = chrome != nullptr && chrome->battery.width > 0 ? chrome->battery.width : metrics.batteryWidth;
const int iconHeight =
chrome != nullptr && chrome->battery.height > 0 ? chrome->battery.height : metrics.batteryHeight;
const int offsetY = chrome != nullptr ? chrome->battery.offsetY : 0;
GUI.drawBatteryLeft(renderer, Rect{rect.x, rect.y + offsetY, iconWidth, iconHeight}, effectiveShowPercentage);
return;
}
#if FREEINK_HAVE_GFX_RENDERER
const int barWidth = chrome->battery.width > 0 ? chrome->battery.width : rect.width;
const int barHeight = chrome->battery.height > 0 ? chrome->battery.height : std::max(3, rect.height / 3);
const uint16_t percentage = powerManager.getBatteryPercentage();
const bool effectiveShowPercentage = showPercentage && chrome->battery.showPercentage;
freeink::ui::GfxRendererFrame<> ui(renderer, SMALL_FONT_ID, UI_10_FONT_ID, UI_12_FONT_ID);
freeink::ui::BatteryIndicatorProps props;
props.percent = static_cast<uint8_t>(std::min<uint16_t>(percentage, 100));
props.charging = gpio.isUsbConnected();
props.style = freeink::ui::BatteryIndicatorStyle::Bar;
props.glyphWidth = freeink::ui::clampI16(barWidth);
props.glyphHeight = freeink::ui::clampI16(barHeight);
props.barTrack = toFreeInkBatteryTrack(chrome->battery.track);
props.barFill = toFreeInkBatteryFill(chrome->battery.fill);
props.barDirection = toFreeInkBatteryDirection(chrome->battery.direction);
props.barCaps = toFreeInkBatteryCaps(chrome->battery.caps);
props.barOrientation = toFreeInkBatteryOrientation(chrome->battery.orientation);
props.barSegments = static_cast<uint8_t>(std::max(0, std::min(24, chrome->battery.segments)));
props.barSegmentGap = freeink::ui::clampI16(chrome->battery.segmentGap);
props.barRadius = freeink::ui::clampRadius(chrome->battery.radius);
const Rect barRect{rect.x, rect.y + std::max(0, (rect.height - barHeight) / 2) + chrome->battery.offsetY, barWidth,
barHeight};
freeink::ui::batteryIndicator(ui.frame, freeink::ui::makeRect(barRect.x, barRect.y, barRect.width, barRect.height),
props);
if (effectiveShowPercentage) {
const auto percentageText = std::to_string(percentage) + "%";
renderer.drawText(SMALL_FONT_ID, rect.x + barWidth + BaseTheme::batteryPercentSpacing, rect.y,
percentageText.c_str());
}
#else
GUI.drawBatteryLeft(renderer, rect, showPercentage);
#endif
}
bool drawThemedReaderStatusLane(const GfxRenderer& renderer, Rect laneRect, const float bookProgress,
const int currentPage, const int pageCount, const std::string& title,
const int textYOffset, const bool isPageBookmarked) {
const ThemeScreenSpec* readerSpec = UITheme::getInstance().getScreenSpec(ThemeScreenKind::Reader);
if (readerSpec == nullptr || !readerSpec->enabled) return false;
const ThemeMetrics& metrics = UITheme::getInstance().getMetrics();
ThemeLayoutSlots slots;
layoutThemeSlots(readerSpec->layout, laneRect, metrics, slots);
const auto progress = readerProgressText(bookProgress, currentPage, pageCount);
const bool showBatteryPercentage =
SETTINGS.hideBatteryPercentage == CrossPointSettings::HIDE_BATTERY_PERCENTAGE::HIDE_NEVER;
Rect bookmarkRect = findThemeSlot(slots, "bookmark");
if (isPageBookmarked && bookmarkRect.width > 0 && bookmarkRect.height > 0) {
drawBookmarkStatusIcon(renderer, bookmarkRect.x,
bookmarkRect.y + std::max(0, (bookmarkRect.height - bookmarkStatusIconHeight) / 2));
}
Rect batteryRect = findThemeSlot(slots, "battery");
if (SETTINGS.statusBarBattery && batteryRect.width > 0 && batteryRect.height > 0) {
drawReaderBattery(renderer, batteryRect, showBatteryPercentage);
}
Rect clockRect = findThemeSlot(slots, "clock");
if (SETTINGS.statusBarClock && halClock.isAvailable() && clockRect.width > 0 && clockRect.height > 0) {
char timeBuf[9];
if (halClock.formatTime(timeBuf, sizeof(timeBuf), SETTINGS.clockUtcOffsetQ, SETTINGS.clockFormat == 1)) {
auto clockText = renderer.truncatedText(SMALL_FONT_ID, timeBuf, clockRect.width);
const int clockWidth = renderer.getTextWidth(SMALL_FONT_ID, clockText.c_str());
renderer.drawText(SMALL_FONT_ID, clockRect.x + std::max(0, (clockRect.width - clockWidth) / 2),
clockRect.y + std::max(0, (clockRect.height - renderer.getLineHeight(SMALL_FONT_ID)) / 2),
clockText.c_str());
}
}
Rect progressRect = findThemeSlot(slots, "progress");
if (!progress.empty() && progressRect.width > 0 && progressRect.height > 0) {
auto progressText = renderer.truncatedText(SMALL_FONT_ID, progress.c_str(), progressRect.width);
const int progressWidth = renderer.getTextWidth(SMALL_FONT_ID, progressText.c_str());
renderer.drawText(SMALL_FONT_ID, progressRect.x + std::max(0, progressRect.width - progressWidth),
progressRect.y + std::max(0, (progressRect.height - renderer.getLineHeight(SMALL_FONT_ID)) / 2),
progressText.c_str());
}
Rect titleRect = findThemeSlot(slots, "title");
if (!title.empty() && titleRect.width > 0 && titleRect.height > 0) {
titleRect.y -= textYOffset;
const auto titleText = renderer.truncatedText(SMALL_FONT_ID, title.c_str(), titleRect.width);
const int titleWidth = renderer.getTextWidth(SMALL_FONT_ID, titleText.c_str());
renderer.drawText(SMALL_FONT_ID, titleRect.x + std::max(0, (titleRect.width - titleWidth) / 2),
titleRect.y + std::max(0, (titleRect.height - renderer.getLineHeight(SMALL_FONT_ID)) / 2),
titleText.c_str());
}
return true;
}
Rect BaseTheme::drawPopup(const GfxRenderer& renderer, const char* message) const { Rect BaseTheme::drawPopup(const GfxRenderer& renderer, const char* message) const {
const auto& metrics = UITheme::getInstance().getMetrics(); const auto& metrics = UITheme::getInstance().getMetrics();
const int marginX = metrics.popupMarginX; const int marginX = metrics.popupMarginX;
@@ -759,25 +935,22 @@ void BaseTheme::drawStatusBar(GfxRenderer& renderer, const float bookProgress, c
// Draw Progress Text // Draw Progress Text
const auto screenHeight = renderer.getScreenHeight(); const auto screenHeight = renderer.getScreenHeight();
auto textY = screenHeight - UITheme::getInstance().getStatusBarHeight() - orientedMarginBottom - paddingBottom - 4; auto textY = screenHeight - UITheme::getInstance().getStatusBarHeight() - orientedMarginBottom - paddingBottom - 4;
int progressTextWidth = 0;
if (SETTINGS.statusBarBookProgressPercentage || SETTINGS.statusBarChapterPageCount) { const int leftClusterX = metrics.statusBarHorizontalMargin + orientedMarginLeft + 1;
const int rightClusterX = renderer.getScreenWidth() - metrics.statusBarHorizontalMargin - orientedMarginRight;
int leftClusterWidth = 0;
int rightClusterWidth = 0;
const ThemeScreenSpec* readerSpec = UITheme::getInstance().getScreenSpec(ThemeScreenKind::Reader);
const bool hasThemedLane = readerSpec != nullptr && readerSpec->enabled;
if (!hasThemedLane && (SETTINGS.statusBarBookProgressPercentage || SETTINGS.statusBarChapterPageCount)) {
// Right aligned text for progress counter // Right aligned text for progress counter
char progressStr[32]; const auto progressStr = readerProgressText(bookProgress, currentPage, pageCount);
if (SETTINGS.statusBarBookProgressPercentage && SETTINGS.statusBarChapterPageCount) { int progressTextWidth = renderer.getTextWidth(SMALL_FONT_ID, progressStr.c_str());
snprintf(progressStr, sizeof(progressStr), "%d/%d %.0f%%", currentPage, pageCount, bookProgress); renderer.drawText(SMALL_FONT_ID, rightClusterX - progressTextWidth, textY, progressStr.c_str());
} else if (SETTINGS.statusBarBookProgressPercentage) {
snprintf(progressStr, sizeof(progressStr), "%.0f%%", bookProgress);
} else {
snprintf(progressStr, sizeof(progressStr), "%d/%d", currentPage, pageCount);
}
progressTextWidth = renderer.getTextWidth(SMALL_FONT_ID, progressStr); rightClusterWidth += progressTextWidth;
renderer.drawText(
SMALL_FONT_ID,
renderer.getScreenWidth() - metrics.statusBarHorizontalMargin - orientedMarginRight - progressTextWidth, textY,
progressStr);
} }
// Draw Progress Bar // Draw Progress Bar
@@ -800,39 +973,62 @@ void BaseTheme::drawStatusBar(GfxRenderer& renderer, const float bookProgress, c
renderer.fillRect(barMarginLeft, progressBarY, barWidth, barHeight, true); renderer.fillRect(barMarginLeft, progressBarY, barWidth, barHeight, true);
} }
// Draw Bookmark const Rect themedLaneRect{leftClusterX, textY, std::max(0, rightClusterX - leftClusterX),
const int leftClusterX = metrics.statusBarHorizontalMargin + orientedMarginLeft + 1; std::max(renderer.getLineHeight(SMALL_FONT_ID), metrics.statusBarVerticalMargin)};
const bool showBookmarkIcon = showStatusBarTextLane && isPageBookmarked; if (hasThemedLane && drawThemedReaderStatusLane(renderer, themedLaneRect, bookProgress, currentPage, pageCount, title,
const int bookmarkReserveWidth = showBookmarkIcon ? (bookmarkStatusIconWidth + bookmarkStatusIconGap) : 0; textYOffset, isPageBookmarked)) {
if (showBookmarkIcon) { return;
const int bookmarkY = textY + 5;
drawBookmarkStatusIcon(renderer, leftClusterX, bookmarkY);
} }
// Draw Battery // Draw Battery
const bool showBatteryPercentage = const bool showBatteryPercentage =
SETTINGS.hideBatteryPercentage == CrossPointSettings::HIDE_BATTERY_PERCENTAGE::HIDE_NEVER; SETTINGS.hideBatteryPercentage == CrossPointSettings::HIDE_BATTERY_PERCENTAGE::HIDE_NEVER;
int leftClusterWidth = bookmarkReserveWidth;
if (SETTINGS.statusBarBattery) { if (SETTINGS.statusBarBattery) {
GUI.drawBatteryLeft(renderer, const ThemeReaderChromeSpec* chrome = UITheme::getInstance().getReaderChromeSpec();
Rect{leftClusterX + bookmarkReserveWidth, textY, metrics.batteryWidth, metrics.batteryHeight}, const int batteryVisualWidth =
showBatteryPercentage); chrome != nullptr && chrome->battery.width > 0 ? chrome->battery.width : metrics.batteryWidth;
leftClusterWidth += showBatteryPercentage ? 50 : 20; drawReaderBattery(renderer, Rect{leftClusterX + leftClusterWidth, textY, batteryVisualWidth, metrics.batteryHeight},
showBatteryPercentage);
int batteryWidth = batteryVisualWidth;
if (showBatteryPercentage && (chrome == nullptr || chrome->battery.showPercentage)) {
const uint16_t percentage = powerManager.getBatteryPercentage();
// width of icon + spacing + text for layout purposes
batteryWidth +=
batteryPercentSpacing + renderer.getTextWidth(SMALL_FONT_ID, (std::to_string(percentage) + "%").c_str());
}
leftClusterWidth += batteryWidth;
} }
// Draw Clock (X3 only — DS3231 RTC) // Draw Clock (X3 only — DS3231 RTC)
int clockTextWidth = 0;
if (SETTINGS.statusBarClock && halClock.isAvailable()) { if (SETTINGS.statusBarClock && halClock.isAvailable()) {
char timeBuf[9]; char timeBuf[9];
if (halClock.formatTime(timeBuf, sizeof(timeBuf), SETTINGS.clockUtcOffsetQ, SETTINGS.clockFormat == 1)) { if (halClock.formatTime(timeBuf, sizeof(timeBuf), SETTINGS.clockUtcOffsetQ, SETTINGS.clockFormat == 1)) {
clockTextWidth = renderer.getTextWidth(SMALL_FONT_ID, timeBuf); int clockTextWidth = renderer.getTextWidth(SMALL_FONT_ID, timeBuf);
// Position to the left of the progress text (with a small gap) int clockX = 0;
const int clockX = renderer.getScreenWidth() - metrics.statusBarHorizontalMargin - orientedMarginRight - // Position to the left or right of the progress text (with a small gap)
progressTextWidth - (progressTextWidth > 0 ? 10 : 0) - clockTextWidth; if (SETTINGS.statusBarClock == CrossPointSettings::STATUS_BAR_CLOCK_LEFT) {
clockX = leftClusterX + leftClusterWidth + (leftClusterWidth > 0 ? 10 : 0);
leftClusterWidth += clockTextWidth + 10;
} else if (SETTINGS.statusBarClock == CrossPointSettings::STATUS_BAR_CLOCK_RIGHT) {
clockX = rightClusterX - rightClusterWidth - (rightClusterWidth > 0 ? 10 : 0) - clockTextWidth;
rightClusterWidth += clockTextWidth + 10;
}
renderer.drawText(SMALL_FONT_ID, clockX, textY, timeBuf); renderer.drawText(SMALL_FONT_ID, clockX, textY, timeBuf);
} }
} }
// Draw Bookmark
if (showStatusBarTextLane && isPageBookmarked) {
const int bookmarkGap = leftClusterWidth > 0 ? bookmarkStatusIconGap : 0;
const int bookmarkX = leftClusterX + leftClusterWidth + bookmarkGap;
const int bookmarkY = textY + 5;
drawBookmarkStatusIcon(renderer, bookmarkX, bookmarkY);
leftClusterWidth += bookmarkStatusIconWidth + bookmarkGap;
}
// Draw Title // Draw Title
if (!title.empty()) { if (!title.empty()) {
textY -= textYOffset; textY -= textYOffset;
@@ -842,8 +1038,7 @@ void BaseTheme::drawStatusBar(GfxRenderer& renderer, const float bookProgress, c
renderer.getScreenWidth() - (metrics.statusBarHorizontalMargin * 2) - orientedMarginLeft - orientedMarginRight; renderer.getScreenWidth() - (metrics.statusBarHorizontalMargin * 2) - orientedMarginLeft - orientedMarginRight;
const int titleMarginLeft = leftClusterWidth + 30; const int titleMarginLeft = leftClusterWidth + 30;
const int clockReserve = clockTextWidth > 0 ? (clockTextWidth + 10) : 0; const int titleMarginRight = rightClusterWidth + 30;
const int titleMarginRight = progressTextWidth + clockReserve + 30;
// Attempt to center title on the screen, but if title is too wide then later we will center it within the // Attempt to center title on the screen, but if title is too wide then later we will center it within the
// available space. // available space.
+180 -2
View File
@@ -3,6 +3,7 @@
#include <cstddef> #include <cstddef>
#include <cstdint> #include <cstdint>
#include <functional> #include <functional>
#include <map>
#include <string> #include <string>
#include <vector> #include <vector>
@@ -52,6 +53,7 @@ struct ThemeMetrics {
int homeCoverTileHeight; int homeCoverTileHeight;
int homeRecentBooksCount; int homeRecentBooksCount;
bool homeContinueReadingInMenu; bool homeContinueReadingInMenu;
bool homeShowContinueReadingHeader;
int homeMenuTopOffset; int homeMenuTopOffset;
int buttonHintsHeight; int buttonHintsHeight;
@@ -100,8 +102,181 @@ struct ThemeMetrics {
int textFieldLineEndOffset; int textFieldLineEndOffset;
}; };
// Guard for scaleThemeMetrics() (UITheme.cpp): every pixel field there is scaled
// explicitly, so the static_assert there fails when a ThemeMetrics field is added
// or removed. When it trips, classify the new field (scale it or document why not
// in scaleThemeMetrics) and update this size.
inline constexpr unsigned THEME_METRICS_SIZEOF = 224;
enum class ThemeHomeRecentsType { Default, None, CoverStrip };
enum class ThemeBookRef { Previous, Selected, Next, Index };
enum class ThemeSlotX { Padding, Center, RightPadding };
enum class ThemeSlotY { Top, Center };
enum class ThemeMenuSelectionStyle { Fill, Outline, Triangle, Underline, Pill };
enum class ThemeButtonHintsStyle { Buttons, Shapes, Groups };
enum class ThemeBatteryIndicatorStyle { Icon, Bar };
enum class ThemeBatteryBarTrack { None, Hairline, Outline, Dither };
enum class ThemeBatteryBarFill { Solid, Dither, Segments };
enum class ThemeBatteryBarDirection { LeftToRight, RightToLeft, CenterOut, BottomToTop, TopToBottom };
enum class ThemeBatteryBarCaps { Square, Pixel };
enum class ThemeBatteryBarOrientation { Horizontal, Vertical };
struct ThemeTitleSpec {
bool enabled = false;
int fontId = 12;
bool bold = true;
int maxLines = 2;
int offsetY = 12;
// When true, the title may span the full carousel area width (centered on the
// area, not the cover) instead of being constrained to the cover width.
bool fullWidth = false;
};
struct ThemeCoverSlotSpec {
ThemeBookRef book = ThemeBookRef::Selected;
int bookIndex = 0;
ThemeSlotX x = ThemeSlotX::Center;
ThemeSlotY y = ThemeSlotY::Top;
int height = 300;
int widthPercent = 62;
int xOffset = 0;
int yOffset = 0;
bool selected = false;
ThemeTitleSpec title;
};
struct ThemeHomeRecentsSpec {
ThemeHomeRecentsType type = ThemeHomeRecentsType::Default;
int maxBooks = 1;
bool wrap = false;
bool drawPanel = false;
int panelCornerRadius = 6;
int panelInsetX = 0;
int selectionLineWidth = 3;
int inactiveSelectionLineWidth = 0;
int selectionCornerRadius = 6;
std::vector<ThemeCoverSlotSpec> slots;
};
struct ThemeButtonMenuSpec {
bool enabled = false;
int fontId = 12;
bool bold = false;
bool centeredText = false;
bool centerVertically = false;
bool showIcons = true;
int panelWidth = 0;
bool drawPanel = false;
int panelCornerRadius = 3;
ThemeMenuSelectionStyle selectionStyle = ThemeMenuSelectionStyle::Fill;
int selectionCornerRadius = 6;
int selectionInset = 16;
bool selectedTextInverted = false;
bool selectionFillBlack = false;
int rowPaddingX = 16;
int textInsetX = 16;
};
struct ThemeListSpec {
bool enabled = false;
int fontId = 10;
bool bold = false;
int subtitleFontId = 0;
int valueFontId = 0;
bool showIcons = true;
int iconSize = 0;
int textGap = 8;
ThemeMenuSelectionStyle selectionStyle = ThemeMenuSelectionStyle::Fill;
int selectionCornerRadius = 6;
bool selectionFill = true;
bool selectionOutline = false;
bool selectedTextInverted = false;
bool rowBackgrounds = false;
bool centerSingleLineRows = false;
bool subtitleRowAutoHeight = false;
bool centerValueVertically = false;
int rowSidePadding = 0;
int rowGap = 0;
int textInsetX = 8;
int selectionInsetX = 0;
int selectionInsetY = 0;
int titleOffsetY = 7;
int subtitleOffsetY = 30;
int subtitleTopPadding = 10;
int subtitleBottomPadding = 10;
int subtitleInterLineGap = 4;
int valueOffsetY = 6;
int subtitleValueOffsetY = 16;
int iconOffsetY = 0;
};
struct ThemeButtonHintsSpec {
bool enabled = false;
int fontId = 0;
bool bold = false;
int buttonWidth = 80;
int smallButtonHeight = 15;
int cornerRadius = 6;
bool fill = true;
bool outline = true;
bool drawEmpty = true;
bool shapes = false;
ThemeButtonHintsStyle style = ThemeButtonHintsStyle::Buttons;
int sidePadding = 20;
int groupGap = 10;
int bottomMargin = 10;
int innerPadding = 16;
int shapeSize = 18;
int textOffsetY = 7;
};
struct ThemeTabBarSpec {
bool enabled = false;
int fontId = 10;
bool bold = false;
bool equalWidth = false;
ThemeMenuSelectionStyle selectionStyle = ThemeMenuSelectionStyle::Fill;
int selectedCornerRadius = 6;
bool selectedTextInverted = true;
bool drawDivider = true;
int horizontalInset = 2;
};
struct ThemeHeaderSpec {
bool enabled = false;
int fontId = 12;
bool bold = true;
bool centeredTitle = false;
bool showDivider = true;
int titleOffsetY = 0;
int batteryOffsetY = 5;
};
struct ThemeReaderBatterySpec {
bool enabled = false;
ThemeBatteryIndicatorStyle style = ThemeBatteryIndicatorStyle::Icon;
int width = 0;
int height = 0;
int offsetY = 0;
ThemeBatteryBarTrack track = ThemeBatteryBarTrack::None;
ThemeBatteryBarFill fill = ThemeBatteryBarFill::Solid;
ThemeBatteryBarDirection direction = ThemeBatteryBarDirection::LeftToRight;
ThemeBatteryBarCaps caps = ThemeBatteryBarCaps::Square;
ThemeBatteryBarOrientation orientation = ThemeBatteryBarOrientation::Horizontal;
int segments = 0;
int segmentGap = 1;
int radius = 0;
bool showPercentage = true;
};
struct ThemeReaderChromeSpec {
ThemeReaderBatterySpec battery;
};
enum UIIcon { None = 0, Folder, Text, Image, Book, File, Recent, Settings, Transfer, Library, Wifi, Hotspot, Bookmark }; enum UIIcon { None = 0, Folder, Text, Image, Book, File, Recent, Settings, Transfer, Library, Wifi, Hotspot, Bookmark };
using ThemeIconMap = std::map<UIIcon, std::string>;
enum class KeyboardKeyType { Normal, Shift, Mode, Space, Del, Ok, Disabled }; enum class KeyboardKeyType { Normal, Shift, Mode, Space, Del, Ok, Disabled };
// Default theme implementation (Classic Theme) // Default theme implementation (Classic Theme)
@@ -130,6 +305,7 @@ constexpr ThemeMetrics values = {.batteryWidth = 15,
.homeCoverTileHeight = 400, .homeCoverTileHeight = 400,
.homeRecentBooksCount = 1, .homeRecentBooksCount = 1,
.homeContinueReadingInMenu = false, .homeContinueReadingInMenu = false,
.homeShowContinueReadingHeader = true,
.homeMenuTopOffset = 10, .homeMenuTopOffset = 10,
.buttonHintsHeight = 40, .buttonHintsHeight = 40,
.sideButtonHintsWidth = 30, .sideButtonHintsWidth = 30,
@@ -201,11 +377,13 @@ class BaseTheme {
virtual void drawTabBar(const GfxRenderer& renderer, Rect rect, const std::vector<TabInfo>& tabs, virtual void drawTabBar(const GfxRenderer& renderer, Rect rect, const std::vector<TabInfo>& tabs,
bool selected) const; bool selected) const;
virtual void drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector<RecentBook>& recentBooks, virtual void drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector<RecentBook>& recentBooks,
const int selectorIndex, bool& coverRendered, bool& coverBufferStored, const int coverSelectorIndex, bool& coverRendered, bool& coverBufferStored,
bool& bufferRestored, std::function<bool()> storeCoverBuffer) const; bool& bufferRestored, std::function<bool()> storeCoverBuffer,
bool coverStripSelected = true) const;
virtual void drawButtonMenu(GfxRenderer& renderer, Rect rect, int buttonCount, int selectedIndex, virtual void drawButtonMenu(GfxRenderer& renderer, Rect rect, int buttonCount, int selectedIndex,
const std::function<std::string(int index)>& buttonLabel, const std::function<std::string(int index)>& buttonLabel,
const std::function<UIIcon(int index)>& rowIcon) const; const std::function<UIIcon(int index)>& rowIcon) const;
virtual bool homeCoverCacheDependsOnSelector() const { return true; }
virtual Rect drawPopup(const GfxRenderer& renderer, const char* message) const; virtual Rect drawPopup(const GfxRenderer& renderer, const char* message) const;
virtual void fillPopupProgress(const GfxRenderer& renderer, const Rect& layout, const int progress) const; virtual void fillPopupProgress(const GfxRenderer& renderer, const Rect& layout, const int progress) const;
void drawStatusBar(GfxRenderer& renderer, const float bookProgress, const int currentPage, const int pageCount, void drawStatusBar(GfxRenderer& renderer, const float bookProgress, const int currentPage, const int pageCount,
@@ -0,0 +1,953 @@
#include "SdCardThemeRegistry.h"
#include <ArduinoJson.h>
#include <HalGPIO.h>
#include <HalStorage.h>
#include <Logging.h>
#include <algorithm>
#include <cctype>
#include <cstring>
#include "CrossPointSettings.h"
#include "ThemeInstaller.h"
#include "components/themes/lyra/LyraTheme.h"
#include "fontIds.h"
namespace {
constexpr int THEME_SCHEMA_VERSION = 1;
constexpr size_t MAX_PERSISTED_THEME_ID_LENGTH = sizeof(SETTINGS.sdThemeName) - 1;
void applyMetricOverrides(JsonObjectConst obj, ThemeMetrics& metrics) {
if (obj.isNull()) return;
#define APPLY_INT_FIELD(name) metrics.name = obj[#name] | metrics.name
#define APPLY_BOOL_FIELD(name) metrics.name = obj[#name] | metrics.name
APPLY_INT_FIELD(batteryWidth);
APPLY_INT_FIELD(batteryHeight);
APPLY_INT_FIELD(topPadding);
APPLY_INT_FIELD(batteryBarHeight);
APPLY_INT_FIELD(headerHeight);
APPLY_INT_FIELD(verticalSpacing);
APPLY_INT_FIELD(contentSidePadding);
APPLY_INT_FIELD(listRowHeight);
APPLY_INT_FIELD(listWithSubtitleRowHeight);
APPLY_INT_FIELD(menuRowHeight);
APPLY_INT_FIELD(menuSpacing);
APPLY_INT_FIELD(tabSpacing);
APPLY_INT_FIELD(tabBarHeight);
APPLY_INT_FIELD(scrollBarWidth);
APPLY_INT_FIELD(scrollBarRightOffset);
APPLY_INT_FIELD(homeTopPadding);
APPLY_INT_FIELD(homeCoverHeight);
APPLY_INT_FIELD(homeCoverTileHeight);
APPLY_INT_FIELD(homeRecentBooksCount);
APPLY_BOOL_FIELD(homeContinueReadingInMenu);
APPLY_BOOL_FIELD(homeShowContinueReadingHeader);
APPLY_INT_FIELD(homeMenuTopOffset);
APPLY_INT_FIELD(buttonHintsHeight);
APPLY_INT_FIELD(sideButtonHintsWidth);
APPLY_INT_FIELD(progressBarHeight);
APPLY_INT_FIELD(progressBarMarginTop);
APPLY_INT_FIELD(statusBarHorizontalMargin);
APPLY_INT_FIELD(statusBarVerticalMargin);
APPLY_INT_FIELD(keyboardKeyWidth);
APPLY_INT_FIELD(keyboardKeyHeight);
APPLY_INT_FIELD(keyboardKeySpacing);
APPLY_INT_FIELD(keyboardBottomKeyHeight);
APPLY_INT_FIELD(keyboardBottomKeySpacing);
APPLY_BOOL_FIELD(keyboardBottomAligned);
APPLY_BOOL_FIELD(keyboardCenteredText);
APPLY_INT_FIELD(keyboardVerticalOffset);
APPLY_INT_FIELD(keyboardTextFieldWidthPercent);
APPLY_INT_FIELD(keyboardWidthPercent);
APPLY_INT_FIELD(keyboardKeyCornerRadius);
APPLY_BOOL_FIELD(keyboardFillUnselected);
APPLY_BOOL_FIELD(keyboardOutlineAllUnselected);
APPLY_BOOL_FIELD(keyboardDrawSpecialOutlineWhenUnselected);
APPLY_INT_FIELD(keyboardSecondaryLabelRightPadding);
APPLY_INT_FIELD(keyboardSecondaryLabelTopPadding);
APPLY_INT_FIELD(keyboardMinArrowHeadSize);
metrics.popupTopOffsetRatio = obj["popupTopOffsetRatio"] | metrics.popupTopOffsetRatio;
APPLY_INT_FIELD(popupMarginX);
APPLY_INT_FIELD(popupMarginY);
APPLY_INT_FIELD(popupFrameThickness);
APPLY_INT_FIELD(popupCornerRadius);
APPLY_BOOL_FIELD(popupTextBold);
APPLY_BOOL_FIELD(popupTextInverted);
APPLY_INT_FIELD(popupTextBaselineOffsetY);
APPLY_INT_FIELD(popupProgressBarHeight);
APPLY_BOOL_FIELD(popupProgressDrawOutline);
APPLY_BOOL_FIELD(popupProgressClampPercent);
APPLY_BOOL_FIELD(popupProgressFillInverted);
APPLY_BOOL_FIELD(popupProgressOutlineInverted);
APPLY_INT_FIELD(textFieldHorizontalPadding);
APPLY_INT_FIELD(textFieldNormalThickness);
APPLY_INT_FIELD(textFieldCursorThickness);
APPLY_INT_FIELD(textFieldLineEndOffset);
#undef APPLY_BOOL_FIELD
#undef APPLY_INT_FIELD
}
ThemeSlotX parseSlotX(const char* value) {
if (value == nullptr) return ThemeSlotX::Center;
if (strcmp(value, "padding") == 0) return ThemeSlotX::Padding;
if (strcmp(value, "right-padding") == 0) return ThemeSlotX::RightPadding;
return ThemeSlotX::Center;
}
ThemeSlotY parseSlotY(const char* value) {
if (value == nullptr) return ThemeSlotY::Top;
if (strcmp(value, "center") == 0 || strcmp(value, "centerY") == 0) return ThemeSlotY::Center;
return ThemeSlotY::Top;
}
ThemeBookRef parseBookRef(const char* value) {
if (value == nullptr) return ThemeBookRef::Selected;
if (strcmp(value, "previous") == 0) return ThemeBookRef::Previous;
if (strcmp(value, "next") == 0) return ThemeBookRef::Next;
if (strcmp(value, "index") == 0) return ThemeBookRef::Index;
return ThemeBookRef::Selected;
}
ThemeBatteryBarTrack parseBatteryBarTrack(const char* value, ThemeBatteryBarTrack fallback) {
if (value == nullptr) return fallback;
if (strcmp(value, "hairline") == 0) return ThemeBatteryBarTrack::Hairline;
if (strcmp(value, "outline") == 0) return ThemeBatteryBarTrack::Outline;
if (strcmp(value, "dither") == 0) return ThemeBatteryBarTrack::Dither;
if (strcmp(value, "none") == 0) return ThemeBatteryBarTrack::None;
return fallback;
}
ThemeBatteryBarFill parseBatteryBarFill(const char* value, ThemeBatteryBarFill fallback) {
if (value == nullptr) return fallback;
if (strcmp(value, "dither") == 0) return ThemeBatteryBarFill::Dither;
if (strcmp(value, "segments") == 0) return ThemeBatteryBarFill::Segments;
if (strcmp(value, "solid") == 0) return ThemeBatteryBarFill::Solid;
return fallback;
}
ThemeBatteryBarDirection parseBatteryBarDirection(const char* value, ThemeBatteryBarDirection fallback) {
if (value == nullptr) return fallback;
if (strcmp(value, "right-to-left") == 0) return ThemeBatteryBarDirection::RightToLeft;
if (strcmp(value, "center-out") == 0) return ThemeBatteryBarDirection::CenterOut;
if (strcmp(value, "bottom-to-top") == 0) return ThemeBatteryBarDirection::BottomToTop;
if (strcmp(value, "top-to-bottom") == 0) return ThemeBatteryBarDirection::TopToBottom;
if (strcmp(value, "left-to-right") == 0) return ThemeBatteryBarDirection::LeftToRight;
return fallback;
}
ThemeBatteryBarCaps parseBatteryBarCaps(const char* value, ThemeBatteryBarCaps fallback) {
if (value == nullptr) return fallback;
if (strcmp(value, "pixel") == 0) return ThemeBatteryBarCaps::Pixel;
if (strcmp(value, "square") == 0) return ThemeBatteryBarCaps::Square;
return fallback;
}
ThemeBatteryBarOrientation parseBatteryBarOrientation(const char* value, ThemeBatteryBarOrientation fallback) {
if (value == nullptr) return fallback;
if (strcmp(value, "vertical") == 0) return ThemeBatteryBarOrientation::Vertical;
if (strcmp(value, "horizontal") == 0) return ThemeBatteryBarOrientation::Horizontal;
return fallback;
}
ThemeMenuSelectionStyle parseMenuSelectionStyle(const char* value, ThemeMenuSelectionStyle fallback) {
if (value == nullptr) return fallback;
if (strcmp(value, "outline") == 0) return ThemeMenuSelectionStyle::Outline;
if (strcmp(value, "triangle") == 0) return ThemeMenuSelectionStyle::Triangle;
if (strcmp(value, "underline") == 0) return ThemeMenuSelectionStyle::Underline;
if (strcmp(value, "pill") == 0) return ThemeMenuSelectionStyle::Pill;
if (strcmp(value, "fill") == 0) return ThemeMenuSelectionStyle::Fill;
return fallback;
}
int parseThemeFontName(const char* value, int fallback) {
if (value == nullptr) return fallback;
if (strcmp(value, "small") == 0 || strcmp(value, "chrome") == 0 || strcmp(value, "caption") == 0) {
return SMALL_FONT_ID;
}
if (strcmp(value, "medium") == 0 || strcmp(value, "body") == 0 || strcmp(value, "label") == 0) {
return UI_10_FONT_ID;
}
if (strcmp(value, "large") == 0 || strcmp(value, "title") == 0 || strcmp(value, "display") == 0) {
return UI_12_FONT_ID;
}
return fallback;
}
int parseThemeFontId(JsonObjectConst obj, int fallback) {
const char* font = obj["font"].as<const char*>();
if (font != nullptr) return parseThemeFontName(font, fallback);
return obj["fontId"] | fallback;
}
void parseTitleSpec(JsonObjectConst obj, ThemeTitleSpec& title) {
if (obj.isNull()) return;
title.enabled = obj["enabled"] | true;
title.fontId = parseThemeFontId(obj, title.fontId);
title.bold = obj["bold"] | title.bold;
title.maxLines = obj["maxLines"] | title.maxLines;
title.offsetY = obj["offsetY"] | title.offsetY;
title.fullWidth = obj["fullWidth"] | title.fullWidth;
const char* style = obj["style"].as<const char*>();
if (style != nullptr) {
title.bold = strcmp(style, "bold") == 0;
}
}
void parseCoverSlot(JsonObjectConst obj, ThemeCoverSlotSpec& slot) {
if (obj.isNull()) return;
slot.book = parseBookRef(obj["book"].as<const char*>());
slot.bookIndex = obj["bookIndex"] | slot.bookIndex;
slot.x = parseSlotX(obj["x"].as<const char*>());
slot.y = parseSlotY(obj["y"].as<const char*>());
slot.height = obj["height"] | slot.height;
slot.widthPercent = obj["widthPercent"] | slot.widthPercent;
slot.xOffset = obj["xOffset"] | slot.xOffset;
slot.yOffset = obj["yOffset"] | slot.yOffset;
slot.selected = obj["selected"] | slot.selected;
parseTitleSpec(obj["title"].as<JsonObjectConst>(), slot.title);
}
void parseHomeRecentsSpec(JsonObjectConst obj, ThemeHomeRecentsSpec& spec) {
if (obj.isNull()) return;
const char* type = obj["type"].as<const char*>();
if (type != nullptr) {
if (strcmp(type, "cover-strip") == 0) {
spec.type = ThemeHomeRecentsType::CoverStrip;
} else if (strcmp(type, "none") == 0) {
spec.type = ThemeHomeRecentsType::None;
}
}
spec.maxBooks = obj["maxBooks"] | spec.maxBooks;
spec.wrap = obj["wrap"] | spec.wrap;
spec.drawPanel = obj["drawPanel"] | spec.drawPanel;
spec.panelCornerRadius = obj["panelCornerRadius"] | spec.panelCornerRadius;
spec.panelInsetX = obj["panelInsetX"] | spec.panelInsetX;
spec.selectionLineWidth = obj["selectionLineWidth"] | spec.selectionLineWidth;
spec.inactiveSelectionLineWidth = obj["inactiveSelectionLineWidth"] | spec.inactiveSelectionLineWidth;
spec.selectionCornerRadius = obj["selectionCornerRadius"] | spec.selectionCornerRadius;
JsonArrayConst slots = obj["slots"].as<JsonArrayConst>();
if (!slots.isNull()) {
if (spec.type == ThemeHomeRecentsType::Default) {
spec.type = ThemeHomeRecentsType::CoverStrip;
}
spec.slots.clear();
for (JsonObjectConst slotObj : slots) {
if (spec.slots.size() >= 5) break;
ThemeCoverSlotSpec slot;
parseCoverSlot(slotObj, slot);
spec.slots.push_back(slot);
}
}
}
void applyFontSpec(JsonObjectConst obj, int& fontId, bool& bold) {
fontId = parseThemeFontId(obj, fontId);
bold = obj["bold"] | bold;
const char* style = obj["style"].as<const char*>();
if (style != nullptr) {
bold = strcmp(style, "bold") == 0;
}
}
void parseButtonMenuSpec(JsonObjectConst obj, ThemeButtonMenuSpec& spec) {
if (obj.isNull()) return;
spec.enabled = true;
applyFontSpec(obj, spec.fontId, spec.bold);
spec.centeredText = obj["centeredText"] | spec.centeredText;
spec.centerVertically = obj["centerVertically"] | spec.centerVertically;
spec.showIcons = obj["showIcons"] | spec.showIcons;
spec.panelWidth = obj["panelWidth"] | spec.panelWidth;
spec.drawPanel = obj["drawPanel"] | spec.drawPanel;
spec.panelCornerRadius = obj["panelCornerRadius"] | spec.panelCornerRadius;
spec.selectionCornerRadius = obj["selectionCornerRadius"] | spec.selectionCornerRadius;
spec.selectionInset = obj["selectionInset"] | spec.selectionInset;
spec.selectedTextInverted = obj["selectedTextInverted"] | spec.selectedTextInverted;
spec.selectionFillBlack = obj["selectionFillBlack"] | spec.selectionFillBlack;
spec.selectionStyle = parseMenuSelectionStyle(obj["selectionStyle"].as<const char*>(), spec.selectionStyle);
spec.rowPaddingX = obj["rowPaddingX"] | spec.rowPaddingX;
spec.textInsetX = obj["textInsetX"] | spec.textInsetX;
}
void parseListSpec(JsonObjectConst obj, ThemeListSpec& spec) {
if (obj.isNull()) return;
spec.enabled = true;
applyFontSpec(obj, spec.fontId, spec.bold);
spec.subtitleFontId = obj["subtitleFontId"] | spec.subtitleFontId;
spec.valueFontId = obj["valueFontId"] | spec.valueFontId;
spec.showIcons = obj["showIcons"] | spec.showIcons;
spec.iconSize = obj["iconSize"] | spec.iconSize;
spec.textGap = obj["textGap"] | spec.textGap;
spec.selectionStyle = parseMenuSelectionStyle(obj["selectionStyle"].as<const char*>(), spec.selectionStyle);
spec.selectionCornerRadius = obj["selectionCornerRadius"] | spec.selectionCornerRadius;
spec.selectionFill = obj["selectionFill"] | spec.selectionFill;
spec.selectionOutline = obj["selectionOutline"] | spec.selectionOutline;
spec.selectedTextInverted = obj["selectedTextInverted"] | spec.selectedTextInverted;
spec.rowBackgrounds = obj["rowBackgrounds"] | spec.rowBackgrounds;
spec.centerSingleLineRows = obj["centerSingleLineRows"] | spec.centerSingleLineRows;
spec.subtitleRowAutoHeight = obj["subtitleRowAutoHeight"] | spec.subtitleRowAutoHeight;
spec.centerValueVertically = obj["centerValueVertically"] | spec.centerValueVertically;
spec.rowSidePadding = obj["rowSidePadding"] | spec.rowSidePadding;
spec.rowGap = obj["rowGap"] | spec.rowGap;
spec.textInsetX = obj["textInsetX"] | spec.textInsetX;
spec.selectionInsetX = obj["selectionInsetX"] | spec.selectionInsetX;
spec.selectionInsetY = obj["selectionInsetY"] | spec.selectionInsetY;
spec.titleOffsetY = obj["titleOffsetY"] | spec.titleOffsetY;
spec.subtitleOffsetY = obj["subtitleOffsetY"] | spec.subtitleOffsetY;
spec.subtitleTopPadding = obj["subtitleTopPadding"] | spec.subtitleTopPadding;
spec.subtitleBottomPadding = obj["subtitleBottomPadding"] | spec.subtitleBottomPadding;
spec.subtitleInterLineGap = obj["subtitleInterLineGap"] | spec.subtitleInterLineGap;
spec.valueOffsetY = obj["valueOffsetY"] | spec.valueOffsetY;
spec.subtitleValueOffsetY = obj["subtitleValueOffsetY"] | spec.subtitleValueOffsetY;
spec.iconOffsetY = obj["iconOffsetY"] | spec.iconOffsetY;
if (spec.subtitleFontId == 0) spec.subtitleFontId = SMALL_FONT_ID;
if (spec.valueFontId == 0) spec.valueFontId = spec.fontId;
}
void parseButtonHintsSpec(JsonObjectConst obj, ThemeButtonHintsSpec& spec) {
if (obj.isNull()) return;
spec.enabled = true;
applyFontSpec(obj, spec.fontId, spec.bold);
spec.buttonWidth = obj["buttonWidth"] | spec.buttonWidth;
spec.smallButtonHeight = obj["smallButtonHeight"] | spec.smallButtonHeight;
spec.cornerRadius = obj["cornerRadius"] | spec.cornerRadius;
spec.fill = obj["fill"] | spec.fill;
spec.outline = obj["outline"] | spec.outline;
spec.drawEmpty = obj["drawEmpty"] | spec.drawEmpty;
spec.shapes = obj["shapes"] | spec.shapes;
const char* hintLayout = obj["layout"].as<const char*>();
if (hintLayout != nullptr) {
if (strcmp(hintLayout, "shapes") == 0 || strcmp(hintLayout, "icons") == 0) {
spec.style = ThemeButtonHintsStyle::Shapes;
spec.shapes = true;
} else if (strcmp(hintLayout, "groups") == 0) {
spec.style = ThemeButtonHintsStyle::Groups;
spec.shapes = false;
} else {
spec.style = ThemeButtonHintsStyle::Buttons;
}
} else if (spec.shapes) {
spec.style = ThemeButtonHintsStyle::Shapes;
}
spec.sidePadding = obj["sidePadding"] | spec.sidePadding;
spec.groupGap = obj["groupGap"] | spec.groupGap;
spec.bottomMargin = obj["bottomMargin"] | spec.bottomMargin;
spec.innerPadding = obj["innerPadding"] | spec.innerPadding;
spec.shapeSize = obj["shapeSize"] | spec.shapeSize;
spec.textOffsetY = obj["textOffsetY"] | spec.textOffsetY;
if (spec.fontId == 0) spec.fontId = SMALL_FONT_ID;
}
void parseTabBarSpec(JsonObjectConst obj, ThemeTabBarSpec& spec) {
if (obj.isNull()) return;
spec.enabled = true;
applyFontSpec(obj, spec.fontId, spec.bold);
spec.equalWidth = obj["equalWidth"] | spec.equalWidth;
spec.selectionStyle = parseMenuSelectionStyle(obj["selectionStyle"].as<const char*>(), spec.selectionStyle);
spec.selectedCornerRadius = obj["selectedCornerRadius"] | spec.selectedCornerRadius;
spec.selectedTextInverted = obj["selectedTextInverted"] | spec.selectedTextInverted;
spec.drawDivider = obj["drawDivider"] | spec.drawDivider;
spec.horizontalInset = obj["horizontalInset"] | spec.horizontalInset;
}
void parseHeaderSpec(JsonObjectConst obj, ThemeHeaderSpec& spec) {
if (obj.isNull()) return;
spec.enabled = true;
applyFontSpec(obj, spec.fontId, spec.bold);
spec.centeredTitle = obj["centeredTitle"] | spec.centeredTitle;
spec.showDivider = obj["showDivider"] | spec.showDivider;
spec.titleOffsetY = obj["titleOffsetY"] | spec.titleOffsetY;
spec.batteryOffsetY = obj["batteryOffsetY"] | spec.batteryOffsetY;
}
void parseReaderChromeSpec(JsonObjectConst obj, ThemeReaderChromeSpec& spec) {
if (obj.isNull()) return;
JsonObjectConst battery = obj["battery"].as<JsonObjectConst>();
if (!battery.isNull()) {
spec.battery.enabled = battery["enabled"] | true;
const char* style = battery["style"].as<const char*>();
if (style != nullptr) {
if (strcmp(style, "bar") == 0) {
spec.battery.style = ThemeBatteryIndicatorStyle::Bar;
} else {
spec.battery.style = ThemeBatteryIndicatorStyle::Icon;
}
}
spec.battery.width = battery["width"] | spec.battery.width;
spec.battery.height = battery["height"] | spec.battery.height;
spec.battery.offsetY = battery["offsetY"] | spec.battery.offsetY;
spec.battery.track = parseBatteryBarTrack(battery["track"].as<const char*>(), spec.battery.track);
spec.battery.fill = parseBatteryBarFill(battery["fill"].as<const char*>(), spec.battery.fill);
spec.battery.direction = parseBatteryBarDirection(battery["direction"].as<const char*>(), spec.battery.direction);
spec.battery.caps = parseBatteryBarCaps(battery["caps"].as<const char*>(), spec.battery.caps);
spec.battery.orientation =
parseBatteryBarOrientation(battery["orientation"].as<const char*>(), spec.battery.orientation);
spec.battery.segments = battery["segments"] | spec.battery.segments;
spec.battery.segmentGap = battery["segmentGap"] | spec.battery.segmentGap;
spec.battery.radius = battery["radius"] | spec.battery.radius;
spec.battery.showPercentage = battery["showPercentage"] | spec.battery.showPercentage;
}
}
bool iconForKey(const char* key, UIIcon& out) {
if (key == nullptr) return false;
if (strcmp(key, "folder") == 0 || strcmp(key, "folder24") == 0)
out = UIIcon::Folder;
else if (strcmp(key, "text") == 0 || strcmp(key, "text24") == 0)
out = UIIcon::Text;
else if (strcmp(key, "image") == 0 || strcmp(key, "image24") == 0)
out = UIIcon::Image;
else if (strcmp(key, "book") == 0 || strcmp(key, "book24") == 0)
out = UIIcon::Book;
else if (strcmp(key, "file") == 0 || strcmp(key, "file24") == 0)
out = UIIcon::File;
else if (strcmp(key, "recent") == 0)
out = UIIcon::Recent;
else if (strcmp(key, "settings") == 0 || strcmp(key, "settings2") == 0)
out = UIIcon::Settings;
else if (strcmp(key, "transfer") == 0)
out = UIIcon::Transfer;
else if (strcmp(key, "library") == 0)
out = UIIcon::Library;
else if (strcmp(key, "wifi") == 0)
out = UIIcon::Wifi;
else if (strcmp(key, "hotspot") == 0)
out = UIIcon::Hotspot;
else if (strcmp(key, "bookmark") == 0)
out = UIIcon::Bookmark;
else
return false;
return true;
}
void parseIconMap(JsonObjectConst obj, ThemeIconMap& icons) {
if (obj.isNull()) return;
for (JsonPairConst kv : obj) {
UIIcon icon = UIIcon::None;
const char* path = kv.value().as<const char*>();
if (iconForKey(kv.key().c_str(), icon) && path != nullptr && ThemeInstaller::isValidRelativePath(path)) {
if (strstr(kv.key().c_str(), "24") != nullptr && icons.find(icon) != icons.end()) continue;
icons[icon] = path;
}
}
}
ThemeLayoutAxis parseLayoutAxis(const char* value) {
if (value != nullptr && strcmp(value, "row") == 0) return ThemeLayoutAxis::Row;
return ThemeLayoutAxis::Column;
}
ThemeLayoutSizeType parseLayoutSizeType(const char* value) {
if (value == nullptr) return ThemeLayoutSizeType::Flex;
if (strcmp(value, "fixed") == 0) return ThemeLayoutSizeType::Fixed;
if (strcmp(value, "token") == 0) return ThemeLayoutSizeType::Token;
return ThemeLayoutSizeType::Flex;
}
void parseLayoutNode(JsonObjectConst obj, ThemeLayoutNode& out, int depth = 0) {
if (obj.isNull() || depth > 5) return;
const char* id = obj["id"].as<const char*>();
if (id == nullptr) id = obj["slot"].as<const char*>();
if (id != nullptr) out.id = id;
out.axis = parseLayoutAxis(obj["axis"].as<const char*>());
out.gap = obj["gap"] | out.gap;
if (obj["fixed"].is<int>()) {
out.sizeType = ThemeLayoutSizeType::Fixed;
out.size = obj["fixed"] | out.size;
} else if (obj["size"].is<int>()) {
out.sizeType = ThemeLayoutSizeType::Fixed;
out.size = obj["size"] | out.size;
} else if (obj["size"].is<const char*>()) {
out.sizeType = ThemeLayoutSizeType::Token;
out.sizeToken = obj["size"] | "";
} else if (obj["flex"].is<int>()) {
out.sizeType = ThemeLayoutSizeType::Flex;
out.flex = std::max(1, obj["flex"] | out.flex);
} else {
out.sizeType = parseLayoutSizeType(obj["type"].as<const char*>());
}
JsonArrayConst children = obj["children"].as<JsonArrayConst>();
if (children.isNull()) children = obj["slots"].as<JsonArrayConst>();
if (!children.isNull()) {
out.children.clear();
for (JsonObjectConst childObj : children) {
if (out.children.size() >= 12) break;
ThemeLayoutNode child;
child.sizeType = ThemeLayoutSizeType::Flex;
parseLayoutNode(childObj, child, depth + 1);
out.children.push_back(child);
}
}
}
ThemeHomeWidgetType parseHomeWidgetType(const char* value) {
if (value == nullptr) return ThemeHomeWidgetType::LauncherList;
if (strcmp(value, "header") == 0) return ThemeHomeWidgetType::Header;
if (strcmp(value, "headerTitle") == 0 || strcmp(value, "title") == 0) return ThemeHomeWidgetType::HeaderTitle;
if (strcmp(value, "battery") == 0) return ThemeHomeWidgetType::Battery;
if (strcmp(value, "clock") == 0) return ThemeHomeWidgetType::Clock;
if (strcmp(value, "recents") == 0 || strcmp(value, "coverCarousel") == 0 || strcmp(value, "recentBook") == 0) {
return ThemeHomeWidgetType::Recents;
}
if (strcmp(value, "featuredBookCard") == 0 || strcmp(value, "bookCard") == 0) {
return ThemeHomeWidgetType::FeaturedBookCard;
}
if (strcmp(value, "recentCoverGrid") == 0 || strcmp(value, "coverGrid") == 0) {
return ThemeHomeWidgetType::RecentCoverGrid;
}
if (strcmp(value, "launcherGrid") == 0 || strcmp(value, "grid") == 0) return ThemeHomeWidgetType::LauncherGrid;
if (strcmp(value, "launcherTabs") == 0 || strcmp(value, "iconTabs") == 0) return ThemeHomeWidgetType::LauncherGrid;
if (strcmp(value, "buttonHints") == 0 || strcmp(value, "buttons") == 0) return ThemeHomeWidgetType::ButtonHints;
return ThemeHomeWidgetType::LauncherList;
}
ThemeScreenWidgetType parseScreenWidgetType(const char* value) {
if (value != nullptr && (strcmp(value, "coverGrid") == 0 || strcmp(value, "recentCoverGrid") == 0)) {
return ThemeScreenWidgetType::CoverGrid;
}
return ThemeScreenWidgetType::List;
}
ThemeLauncherPresentation parseLauncherPresentation(const char* value, ThemeLauncherPresentation fallback) {
if (value == nullptr) return fallback;
if (strcmp(value, "iconTabs") == 0 || strcmp(value, "tabs") == 0 || strcmp(value, "icon-only") == 0) {
return ThemeLauncherPresentation::IconTabs;
}
if (strcmp(value, "menu") == 0) return ThemeLauncherPresentation::Menu;
return fallback;
}
ThemeWidgetSelectionStyle parseWidgetSelectionStyle(const char* value, ThemeWidgetSelectionStyle fallback) {
if (value == nullptr) return fallback;
if (strcmp(value, "outline") == 0) return ThemeWidgetSelectionStyle::Outline;
if (strcmp(value, "coverFrame") == 0 || strcmp(value, "cover-frame") == 0 || strcmp(value, "coverOutline") == 0 ||
strcmp(value, "cover-outline") == 0) {
return ThemeWidgetSelectionStyle::CoverFrame;
}
if (strcmp(value, "none") == 0) return ThemeWidgetSelectionStyle::None;
if (strcmp(value, "fill") == 0) return ThemeWidgetSelectionStyle::Fill;
return fallback;
}
ThemeHomeNavigationMode parseHomeNavigationMode(const char* value, ThemeHomeNavigationMode fallback) {
if (value == nullptr) return fallback;
if (strcmp(value, "splitAxis") == 0 || strcmp(value, "split-axis") == 0) {
return ThemeHomeNavigationMode::SplitAxis;
}
if (strcmp(value, "carousel") == 0 || strcmp(value, "carouselAxis") == 0 || strcmp(value, "carousel-axis") == 0 ||
strcmp(value, "coverHorizontal") == 0 || strcmp(value, "cover-horizontal") == 0) {
return ThemeHomeNavigationMode::CarouselAxis;
}
if (strcmp(value, "linear") == 0) return ThemeHomeNavigationMode::Linear;
return fallback;
}
void parseEdgeInsets(JsonObjectConst obj, const char* key, ThemeEdgeInsets& out) {
if (obj[key].is<int>()) {
const int value = obj[key] | 0;
out = ThemeEdgeInsets{value, value, value, value};
return;
}
JsonObjectConst insets = obj[key].as<JsonObjectConst>();
if (insets.isNull()) return;
out.top = insets["top"] | out.top;
out.right = insets["right"] | out.right;
out.bottom = insets["bottom"] | out.bottom;
out.left = insets["left"] | out.left;
}
ThemeHomeAction parseHomeAction(const char* value) {
if (value == nullptr) return ThemeHomeAction::FileBrowser;
if (strcmp(value, "activity:recentBooks") == 0 || strcmp(value, "recentBooks") == 0) {
return ThemeHomeAction::RecentBooks;
}
if (strcmp(value, "activity:opds") == 0 || strcmp(value, "opds") == 0) return ThemeHomeAction::OpdsBrowser;
if (strcmp(value, "activity:fileTransfer") == 0 || strcmp(value, "fileTransfer") == 0) {
return ThemeHomeAction::FileTransfer;
}
if (strcmp(value, "activity:settings") == 0 || strcmp(value, "settings") == 0) return ThemeHomeAction::Settings;
if (strcmp(value, "reader:recent") == 0 || strcmp(value, "recentBook") == 0) return ThemeHomeAction::RecentBook;
return ThemeHomeAction::FileBrowser;
}
void parseLauncherWidgetSpec(JsonObjectConst obj, const char* type, ThemeHomeLauncherWidgetSpec& out) {
out.presentation = parseLauncherPresentation(obj["presentation"].as<const char*>(), out.presentation);
if (type != nullptr && (strcmp(type, "launcherTabs") == 0 || strcmp(type, "iconTabs") == 0)) {
out.presentation = ThemeLauncherPresentation::IconTabs;
}
out.columns = std::max(1, std::min(12, obj["columns"] | out.columns));
out.rows = std::max(0, std::min(12, obj["rows"] | out.rows));
out.gap = obj["gap"] | out.gap;
out.iconSize = obj["iconSize"] | out.iconSize;
out.selectedRadius = obj["selectedRadius"] | out.selectedRadius;
JsonArrayConst items = obj["items"].as<JsonArrayConst>();
if (items.isNull()) return;
out.items.clear();
for (JsonObjectConst itemObj : items) {
if (out.items.size() >= kMaxThemeLauncherItems) break;
ThemeHomeLauncherSpec launcher;
launcher.text = itemObj["text"] | "";
UIIcon icon = UIIcon::None;
if (iconForKey(itemObj["icon"].as<const char*>(), icon)) launcher.icon = icon;
launcher.action = parseHomeAction(itemObj["action"].as<const char*>());
out.items.push_back(launcher);
}
}
void parseFeaturedBookWidgetSpec(JsonObjectConst obj, ThemeFeaturedBookWidgetSpec& out) {
out.startIndex = obj["startIndex"] | out.startIndex;
out.coverWidth = obj["coverWidth"] | out.coverWidth;
out.coverHeight = obj["coverHeight"] | out.coverHeight;
out.coverGap = obj["coverGap"] | out.coverGap;
out.titleGap = obj["titleGap"] | out.titleGap;
out.selectedRadius = obj["selectedRadius"] | out.selectedRadius;
out.placeholderIconSize = obj["placeholderIconSize"] | out.placeholderIconSize;
}
void parseCoverGridWidgetSpec(JsonObjectConst obj, ThemeCoverGridWidgetSpec& out) {
if (obj.isNull()) return;
out.configured = true;
out.columns = std::max(1, std::min(12, obj["columns"] | out.columns));
out.rows = std::max(0, std::min(12, obj["rows"] | out.rows));
out.gap = obj["gap"] | out.gap;
out.rowGap = obj["rowGap"] | out.rowGap;
out.coverWidth = obj["coverWidth"] | out.coverWidth;
out.coverHeight = obj["coverHeight"] | out.coverHeight;
out.placeholderIconSize = obj["placeholderIconSize"] | out.placeholderIconSize;
out.rowHeight = obj["rowHeight"] | out.rowHeight;
out.labelHeight = obj["labelHeight"] | out.labelHeight;
out.labelGap = obj["labelGap"] | out.labelGap;
out.labelLines = obj["labelLines"] | out.labelLines;
out.startIndex = obj["startIndex"] | out.startIndex;
out.selectedRadius = obj["selectedRadius"] | out.selectedRadius;
out.selectionStyle = parseWidgetSelectionStyle(obj["selectionStyle"].as<const char*>(), out.selectionStyle);
parseEdgeInsets(obj, "cellInset", out.cellInset);
parseEdgeInsets(obj, "labelInset", out.labelInset);
}
ThemeButtonHintLabel parseButtonHintLabel(const char* value) {
if (value == nullptr || value[0] == '\0' || strcmp(value, "default") == 0) return ThemeButtonHintLabel::Default;
if (strcmp(value, "none") == 0 || strcmp(value, "empty") == 0) return ThemeButtonHintLabel::Empty;
if (strcmp(value, "back") == 0) return ThemeButtonHintLabel::Back;
if (strcmp(value, "home") == 0) return ThemeButtonHintLabel::Home;
if (strcmp(value, "select") == 0) return ThemeButtonHintLabel::Select;
if (strcmp(value, "confirm") == 0) return ThemeButtonHintLabel::Confirm;
if (strcmp(value, "open") == 0) return ThemeButtonHintLabel::Open;
if (strcmp(value, "toggle") == 0) return ThemeButtonHintLabel::Toggle;
if (strcmp(value, "up") == 0) return ThemeButtonHintLabel::Up;
if (strcmp(value, "down") == 0) return ThemeButtonHintLabel::Down;
if (strcmp(value, "left") == 0) return ThemeButtonHintLabel::Left;
if (strcmp(value, "right") == 0) return ThemeButtonHintLabel::Right;
return ThemeButtonHintLabel::Default;
}
void parseButtonHintsWidgetSpec(JsonObjectConst obj, ThemeButtonHintsWidgetSpec& out) {
JsonObjectConst labels = obj["labels"].as<JsonObjectConst>();
if (!labels.isNull()) {
out.back = parseButtonHintLabel(labels["back"].as<const char*>());
out.confirm = parseButtonHintLabel(labels["confirm"].as<const char*>());
out.previous = parseButtonHintLabel(labels["previous"].as<const char*>());
out.next = parseButtonHintLabel(labels["next"].as<const char*>());
}
if (!obj["back"].isNull()) out.back = parseButtonHintLabel(obj["back"].as<const char*>());
if (!obj["confirm"].isNull()) out.confirm = parseButtonHintLabel(obj["confirm"].as<const char*>());
if (!obj["previous"].isNull()) out.previous = parseButtonHintLabel(obj["previous"].as<const char*>());
if (!obj["next"].isNull()) out.next = parseButtonHintLabel(obj["next"].as<const char*>());
}
void parseHomeWidget(JsonObjectConst obj, ThemeHomeWidgetSpec& out) {
if (obj.isNull()) return;
out.slot = obj["slot"] | out.slot.c_str();
const char* type = obj["type"].as<const char*>();
out.type = parseHomeWidgetType(type);
parseLauncherWidgetSpec(obj, type, out.launcher);
parseFeaturedBookWidgetSpec(obj, out.featured);
parseCoverGridWidgetSpec(obj, out.coverGrid);
parseButtonHintsWidgetSpec(obj, out.buttonHints);
out.layer = obj["layer"] | out.layer;
out.offsetX = obj["offsetX"] | out.offsetX;
out.offsetY = obj["offsetY"] | out.offsetY;
parseEdgeInsets(obj, "bleed", out.bleed);
parseEdgeInsets(obj, "inset", out.inset);
}
void parseHomeScreenSpec(JsonObjectConst obj, ThemeHomeScreenSpec& out) {
if (obj.isNull()) return;
JsonObjectConst layoutObj = obj["layout"].as<JsonObjectConst>();
JsonArrayConst widgets = obj["widgets"].as<JsonArrayConst>();
if (layoutObj.isNull() || widgets.isNull()) return;
out.enabled = true;
out.navigation = parseHomeNavigationMode(obj["navigation"].as<const char*>(), out.navigation);
const char* initialAction = obj["initialAction"].as<const char*>();
if (initialAction != nullptr) {
out.hasInitialAction = true;
out.initialAction = parseHomeAction(initialAction);
}
out.layout = ThemeLayoutNode{};
out.layout.id = "root";
out.layout.sizeType = ThemeLayoutSizeType::Flex;
parseLayoutNode(layoutObj, out.layout);
out.widgets.clear();
for (JsonObjectConst widgetObj : widgets) {
if (out.widgets.size() >= kMaxThemeWidgets) break;
ThemeHomeWidgetSpec widget;
parseHomeWidget(widgetObj, widget);
out.widgets.push_back(widget);
}
}
void parseScreenSpec(JsonObjectConst obj, ThemeScreenSpec& out) {
if (obj.isNull()) return;
JsonObjectConst layoutObj = obj["layout"].as<JsonObjectConst>();
if (layoutObj.isNull()) return;
out.enabled = true;
out.layout = ThemeLayoutNode{};
out.layout.id = "root";
out.layout.sizeType = ThemeLayoutSizeType::Flex;
parseLayoutNode(layoutObj, out.layout);
out.widgets.clear();
JsonArrayConst widgets = obj["widgets"].as<JsonArrayConst>();
if (widgets.isNull()) return;
for (JsonObjectConst widgetObj : widgets) {
if (out.widgets.size() >= kMaxThemeWidgets) break;
ThemeScreenSpec::Widget widget;
widget.slot = widgetObj["slot"] | widget.slot.c_str();
widget.type = parseScreenWidgetType(widgetObj["type"].as<const char*>());
if (widget.type == ThemeScreenWidgetType::CoverGrid) parseCoverGridWidgetSpec(widgetObj, widget.coverGrid);
out.widgets.push_back(widget);
}
}
void applyTokenSizeOverrides(JsonObjectConst obj, ThemeMetrics& metrics) {
if (obj.isNull()) return;
metrics.headerHeight = obj["header"] | metrics.headerHeight;
metrics.listRowHeight = obj["row"] | metrics.listRowHeight;
metrics.listWithSubtitleRowHeight = obj["rowSubtitle"] | metrics.listWithSubtitleRowHeight;
metrics.menuRowHeight = obj["menuRow"] | metrics.menuRowHeight;
metrics.buttonHintsHeight = obj["footer"] | obj["buttonHints"] | metrics.buttonHintsHeight;
metrics.progressBarHeight = obj["progress"] | metrics.progressBarHeight;
}
ThemeMetrics defaultMetrics() { return LyraMetrics::values; }
} // namespace
const char* SdCardThemeRegistry::activeDeviceId() { return gpio.deviceIsX3() ? "x3" : "x4"; }
bool SdCardThemeRegistry::isSafeId(const char* value) {
if (value == nullptr || value[0] == '\0') return false;
if (strstr(value, "..") != nullptr || strchr(value, '/') != nullptr || strchr(value, '\\') != nullptr) return false;
for (const char* p = value; *p != '\0'; ++p) {
const auto c = static_cast<unsigned char>(*p);
if (std::iscntrl(c)) return false;
}
return true;
}
bool SdCardThemeRegistry::isSafeThemeId(const char* value) {
if (value == nullptr || value[0] == '\0') return false;
if (strlen(value) > MAX_PERSISTED_THEME_ID_LENGTH) return false;
if (strstr(value, "..") != nullptr || strchr(value, '/') != nullptr || strchr(value, '\\') != nullptr) return false;
for (const char* p = value; *p != '\0'; ++p) {
const char c = *p;
if (!std::isalnum(static_cast<unsigned char>(c)) && c != '-' && c != '_') return false;
}
return true;
}
bool SdCardThemeRegistry::parseThemeJson(const char* themeDirPath, SdCardThemeInfo& out) {
char jsonPath[180];
snprintf(jsonPath, sizeof(jsonPath), "%s/theme.json", themeDirPath);
HalFile file;
if (!Storage.openFileForRead("THREG", jsonPath, file)) {
return false;
}
JsonDocument doc;
DeserializationError err = deserializeJson(doc, file);
file.close();
if (err) {
LOG_ERR("THREG", "Theme JSON parse error in %s: %s", jsonPath, err.c_str());
return false;
}
const int schema = doc["schema"] | 0;
if (schema != THEME_SCHEMA_VERSION) {
LOG_ERR("THREG", "Unsupported theme schema %d in %s", schema, jsonPath);
return false;
}
const char* id = doc["id"] | "";
const char* name = doc["name"] | id;
if (!isSafeThemeId(id) || !isSafeId(name)) {
LOG_ERR("THREG", "Invalid theme id/name in %s", jsonPath);
return false;
}
const char* deviceId = activeDeviceId();
JsonObject deviceObj = doc["devices"][deviceId].as<JsonObject>();
const char* inherits = deviceObj["inherits"] | doc["inherits"] | "lyra";
out.id = id;
out.name = name;
out.version = doc["version"] | 0;
out.path = themeDirPath;
out.inherits = inherits;
out.metrics = defaultMetrics();
parseHomeRecentsSpec(doc["components"]["homeRecents"].as<JsonObjectConst>(), out.homeRecents);
parseHomeRecentsSpec(deviceObj["components"]["homeRecents"].as<JsonObjectConst>(), out.homeRecents);
parseButtonMenuSpec(doc["components"]["homeMenu"].as<JsonObjectConst>(), out.buttonMenu);
parseButtonMenuSpec(deviceObj["components"]["homeMenu"].as<JsonObjectConst>(), out.buttonMenu);
parseListSpec(doc["components"]["list"].as<JsonObjectConst>(), out.list);
parseListSpec(deviceObj["components"]["list"].as<JsonObjectConst>(), out.list);
parseButtonHintsSpec(doc["components"]["buttonHints"].as<JsonObjectConst>(), out.buttonHints);
parseButtonHintsSpec(deviceObj["components"]["buttonHints"].as<JsonObjectConst>(), out.buttonHints);
parseTabBarSpec(doc["components"]["tabBar"].as<JsonObjectConst>(), out.tabBar);
parseTabBarSpec(deviceObj["components"]["tabBar"].as<JsonObjectConst>(), out.tabBar);
parseHeaderSpec(doc["components"]["header"].as<JsonObjectConst>(), out.header);
parseHeaderSpec(deviceObj["components"]["header"].as<JsonObjectConst>(), out.header);
applyMetricOverrides(doc["metrics"].as<JsonObjectConst>(), out.metrics);
applyMetricOverrides(deviceObj["metrics"].as<JsonObjectConst>(), out.metrics);
applyTokenSizeOverrides(doc["tokens"]["size"].as<JsonObjectConst>(), out.metrics);
applyTokenSizeOverrides(deviceObj["tokens"]["size"].as<JsonObjectConst>(), out.metrics);
parseHomeScreenSpec(doc["screens"]["home"].as<JsonObjectConst>(), out.homeScreen);
parseHomeScreenSpec(deviceObj["screens"]["home"].as<JsonObjectConst>(), out.homeScreen);
parseScreenSpec(doc["screens"]["fileBrowser"].as<JsonObjectConst>(), out.fileBrowserScreen);
parseScreenSpec(deviceObj["screens"]["fileBrowser"].as<JsonObjectConst>(), out.fileBrowserScreen);
parseScreenSpec(doc["screens"]["recentBooks"].as<JsonObjectConst>(), out.recentBooksScreen);
parseScreenSpec(deviceObj["screens"]["recentBooks"].as<JsonObjectConst>(), out.recentBooksScreen);
parseScreenSpec(doc["screens"]["settings"].as<JsonObjectConst>(), out.settingsScreen);
parseScreenSpec(deviceObj["screens"]["settings"].as<JsonObjectConst>(), out.settingsScreen);
parseScreenSpec(doc["screens"]["reader"].as<JsonObjectConst>(), out.readerScreen);
parseScreenSpec(deviceObj["screens"]["reader"].as<JsonObjectConst>(), out.readerScreen);
parseReaderChromeSpec(doc["screens"]["reader"]["chrome"].as<JsonObjectConst>(), out.readerChrome);
parseReaderChromeSpec(deviceObj["screens"]["reader"]["chrome"].as<JsonObjectConst>(), out.readerChrome);
if ((out.buttonMenu.enabled && out.buttonMenu.showIcons) || (out.list.enabled && out.list.showIcons)) {
parseIconMap(doc["assets"]["icons"].as<JsonObjectConst>(), out.icons);
parseIconMap(deviceObj["assets"]["icons"].as<JsonObjectConst>(), out.icons);
}
if (out.homeRecents.type == ThemeHomeRecentsType::CoverStrip) {
out.metrics.homeRecentBooksCount = std::max(1, out.homeRecents.maxBooks);
} else if (out.homeRecents.type == ThemeHomeRecentsType::None) {
out.metrics.homeCoverHeight = 0;
out.metrics.homeCoverTileHeight = 0;
}
if (out.homeScreen.enabled) {
for (const auto& widget : out.homeScreen.widgets) {
if (widget.type == ThemeHomeWidgetType::RecentCoverGrid) {
const int rows = widget.coverGrid.rows > 0 ? widget.coverGrid.rows : 1;
out.metrics.homeRecentBooksCount =
std::max(out.metrics.homeRecentBooksCount,
std::max(0, widget.coverGrid.startIndex) + rows * std::max(1, widget.coverGrid.columns));
}
}
}
out.constraints.screenWidth = deviceObj["constraints"]["screenWidth"] | doc["constraints"]["screenWidth"] | 0;
out.constraints.screenHeight = deviceObj["constraints"]["screenHeight"] | doc["constraints"]["screenHeight"] | 0;
return true;
}
void SdCardThemeRegistry::scanRoot(const char* rootPath, std::vector<SdCardThemeInfo>& out) {
HalFile root = Storage.open(rootPath);
if (!root) {
LOG_DBG("THREG", "Themes directory not found: %s", rootPath);
return;
}
if (!root.isDirectory()) {
LOG_ERR("THREG", "Themes path is not a directory: %s", rootPath);
return;
}
char nameBuffer[128];
while (true) {
HalFile entry = root.openNextFile();
if (!entry) break;
if (!entry.isDirectory()) {
entry.close();
continue;
}
entry.getName(nameBuffer, sizeof(nameBuffer));
entry.close();
if (nameBuffer[0] == '.' || nameBuffer[0] == '_') continue;
if (!isSafeThemeId(nameBuffer)) continue;
char themeDirPath[180];
snprintf(themeDirPath, sizeof(themeDirPath), "%s/%s", rootPath, nameBuffer);
SdCardThemeInfo info;
if (!parseThemeJson(themeDirPath, info)) continue;
bool exists = false;
for (const auto& theme : out) {
if (theme.id == info.id) {
exists = true;
break;
}
}
if (exists) continue;
LOG_DBG("THREG", "Found theme: %s (%s)", info.name.c_str(), info.path.c_str());
out.push_back(std::move(info));
}
}
bool SdCardThemeRegistry::discover() {
themes_.clear();
themes_.reserve(8);
scanRoot(THEMES_DIR_HIDDEN, themes_);
scanRoot(THEMES_DIR_VISIBLE, themes_);
std::sort(themes_.begin(), themes_.end(),
[](const SdCardThemeInfo& a, const SdCardThemeInfo& b) { return a.name < b.name; });
if (static_cast<int>(themes_.size()) > MAX_SD_THEMES) {
themes_.resize(MAX_SD_THEMES);
}
LOG_DBG("THREG", "Discovery complete: %d themes", static_cast<int>(themes_.size()));
return !themes_.empty();
}
void SdCardThemeRegistry::clear() {
themes_.clear();
themes_.shrink_to_fit();
}
const SdCardThemeInfo* SdCardThemeRegistry::findTheme(const std::string& id) const {
auto it = std::find_if(themes_.begin(), themes_.end(),
[&](const SdCardThemeInfo& theme) { return theme.id == id || theme.name == id; });
return it == themes_.end() ? nullptr : &*it;
}
const char* SdCardThemeRegistry::findThemeRoot(const char* themeId) {
if (!isSafeThemeId(themeId)) return nullptr;
char path[180];
snprintf(path, sizeof(path), "%s/%s", THEMES_DIR_HIDDEN, themeId);
if (Storage.exists(path)) return THEMES_DIR_HIDDEN;
snprintf(path, sizeof(path), "%s/%s", THEMES_DIR_VISIBLE, themeId);
if (Storage.exists(path)) return THEMES_DIR_VISIBLE;
return nullptr;
}
const char* SdCardThemeRegistry::defaultWriteRoot() {
const bool hiddenExists = Storage.exists(THEMES_DIR_HIDDEN);
const bool visibleExists = Storage.exists(THEMES_DIR_VISIBLE);
if (hiddenExists) return THEMES_DIR_HIDDEN;
if (visibleExists) return THEMES_DIR_VISIBLE;
return THEMES_DIR_HIDDEN;
}
@@ -0,0 +1,61 @@
#pragma once
#include <string>
#include <vector>
#include "components/themes/BaseTheme.h"
#include "components/themes/ThemeLayout.h"
// Theme's declared design resolution (portrait). Used to scale the theme's pixel
// metrics to the actual device panel (see scaleThemeMetrics in UITheme.cpp).
struct SdThemeDeviceConstraints {
int screenWidth = 0;
int screenHeight = 0;
};
struct SdCardThemeInfo {
std::string id;
std::string name;
int version = 0;
std::string path;
std::string inherits;
ThemeMetrics metrics = {};
ThemeHomeRecentsSpec homeRecents;
ThemeButtonMenuSpec buttonMenu;
ThemeListSpec list;
ThemeButtonHintsSpec buttonHints;
ThemeTabBarSpec tabBar;
ThemeHeaderSpec header;
ThemeHomeScreenSpec homeScreen;
ThemeScreenSpec fileBrowserScreen;
ThemeScreenSpec recentBooksScreen;
ThemeScreenSpec settingsScreen;
ThemeScreenSpec readerScreen;
ThemeReaderChromeSpec readerChrome;
ThemeIconMap icons;
SdThemeDeviceConstraints constraints;
};
class SdCardThemeRegistry {
public:
static constexpr int MAX_SD_THEMES = 64;
static constexpr const char* THEMES_DIR_HIDDEN = "/.themes";
static constexpr const char* THEMES_DIR_VISIBLE = "/themes";
bool discover();
void clear();
const std::vector<SdCardThemeInfo>& getThemes() const { return themes_; }
const SdCardThemeInfo* findTheme(const std::string& id) const;
static const char* findThemeRoot(const char* themeId);
static const char* defaultWriteRoot();
private:
std::vector<SdCardThemeInfo> themes_;
static const char* activeDeviceId();
static bool parseThemeJson(const char* themeDirPath, SdCardThemeInfo& out);
static bool isSafeId(const char* value);
static bool isSafeThemeId(const char* value);
static void scanRoot(const char* rootPath, std::vector<SdCardThemeInfo>& out);
};
+110
View File
@@ -0,0 +1,110 @@
#include "ThemeLayout.h"
#include <FreeInkUILayout.h>
#include <algorithm>
#include <cstdint>
#include <cstring>
int themeLayoutTokenSize(const ThemeMetrics& metrics, const std::string& token) {
if (token == "topPadding") return metrics.topPadding;
if (token == "header") return metrics.headerHeight;
if (token == "tabBar" || token == "tabs") return metrics.tabBarHeight;
if (token == "footer" || token == "buttons" || token == "buttonHints") return metrics.buttonHintsHeight;
if (token == "row") return metrics.listRowHeight;
if (token == "subtitleRow") return metrics.listWithSubtitleRowHeight;
if (token == "menuRow") return metrics.menuRowHeight;
if (token == "recents") return metrics.homeCoverTileHeight;
if (token == "cover") return metrics.homeCoverHeight;
if (token == "verticalSpacing" || token == "gap") return metrics.verticalSpacing;
if (token == "progress") return metrics.progressBarHeight;
return 0;
}
namespace {
freeink::ui::Axis toUiAxis(ThemeLayoutAxis axis) {
return axis == ThemeLayoutAxis::Row ? freeink::ui::Axis::Row : freeink::ui::Axis::Column;
}
freeink::ui::LayoutLength toUiLength(const ThemeLayoutNode& node, const ThemeMetrics& metrics) {
if (node.sizeType == ThemeLayoutSizeType::Fixed) {
return freeink::ui::LayoutLength::fixed(std::max(0, node.size));
}
if (node.sizeType == ThemeLayoutSizeType::Token) {
return freeink::ui::LayoutLength::fixed(std::max(0, themeLayoutTokenSize(metrics, node.sizeToken)));
}
return freeink::ui::LayoutLength::flexible(static_cast<uint8_t>(std::max(1, node.flex)));
}
struct LayoutTreeStorage {
static constexpr size_t kMaxNodes = 64;
freeink::ui::LayoutNode nodes[kMaxNodes];
size_t used = 0;
bool overflow = false;
freeink::ui::LayoutNode* allocate(size_t count) {
if (count == 0) return nullptr;
if (used + count > kMaxNodes) {
overflow = true;
return nullptr;
}
auto* out = &nodes[used];
used += count;
return out;
}
};
freeink::ui::LayoutNode toUiNode(const ThemeLayoutNode& node, const ThemeMetrics& metrics, LayoutTreeStorage& storage) {
freeink::ui::LayoutNode out;
out.id = node.id.empty() ? nullptr : node.id.c_str();
out.axis = toUiAxis(node.axis);
out.gap = static_cast<int16_t>(std::max(0, node.gap));
out.length = toUiLength(node, metrics);
const uint8_t childCount = static_cast<uint8_t>(std::min<size_t>(node.children.size(), UINT8_MAX));
if (childCount == 0) return out;
auto* children = storage.allocate(childCount);
if (children == nullptr) return out;
for (uint8_t i = 0; i < childCount; ++i) {
children[i] = toUiNode(node.children[i], metrics, storage);
}
out.children = children;
out.childCount = childCount;
return out;
}
} // namespace
void layoutThemeSlots(const ThemeLayoutNode& node, Rect rect, const ThemeMetrics& metrics, ThemeLayoutSlots& slots) {
slots.clear();
LayoutTreeStorage storage;
const freeink::ui::LayoutNode uiNode = toUiNode(node, metrics, storage);
freeink::ui::layoutTree(uiNode, freeink::ui::LayoutRect{rect.x, rect.y, rect.width, rect.height},
[&](const char* id, freeink::ui::LayoutRect slot) {
if (id != nullptr && id[0] != '\0') {
slots.push(id, Rect{static_cast<int>(slot.x), static_cast<int>(slot.y),
static_cast<int>(slot.width), static_cast<int>(slot.height)});
}
});
}
Rect findThemeSlot(const ThemeLayoutSlots& slots, const std::string& id) {
for (size_t i = 0; i < slots.count; ++i) {
const auto& slot = slots.items[i];
if (slot.id != nullptr && std::strcmp(slot.id, id.c_str()) == 0) return slot.rect;
}
return Rect{};
}
Rect normalizeThemeHeaderSlot(Rect rect, const ThemeMetrics& metrics) {
if (rect.y == 0 && metrics.topPadding > 0 && rect.height > metrics.topPadding) {
rect.y += metrics.topPadding;
rect.height -= metrics.topPadding;
}
return rect;
}
+172
View File
@@ -0,0 +1,172 @@
#pragma once
#include <cstddef>
#include <string>
#include <vector>
#include "components/themes/BaseTheme.h"
enum class ThemeLayoutAxis { Column, Row };
enum class ThemeLayoutSizeType { Flex, Fixed, Token };
enum class ThemeScreenKind { Home, FileBrowser, RecentBooks, Settings, Reader };
enum class ThemeHomeWidgetType {
Header,
HeaderTitle,
Battery,
Clock,
Recents,
FeaturedBookCard,
RecentCoverGrid,
LauncherList,
LauncherGrid,
ButtonHints
};
enum class ThemeHomeAction { FileBrowser, RecentBooks, OpdsBrowser, FileTransfer, Settings, RecentBook };
enum class ThemeLauncherPresentation { Menu, IconTabs };
enum class ThemeWidgetSelectionStyle { Fill, Outline, CoverFrame, None };
enum class ThemeHomeNavigationMode { Linear, SplitAxis, CarouselAxis };
enum class ThemeScreenWidgetType { List, CoverGrid };
enum class ThemeButtonHintLabel { Default, Empty, Back, Home, Select, Confirm, Open, Toggle, Up, Down, Left, Right };
constexpr size_t kMaxThemeWidgets = 12;
constexpr size_t kMaxThemeLauncherItems = 12;
constexpr size_t kMaxThemeCoverGridItems = 64;
struct ThemeLayoutNode {
std::string id;
ThemeLayoutAxis axis = ThemeLayoutAxis::Column;
int gap = 0;
ThemeLayoutSizeType sizeType = ThemeLayoutSizeType::Flex;
int size = 0;
int flex = 1;
std::string sizeToken;
std::vector<ThemeLayoutNode> children;
};
struct ThemeHomeLauncherSpec {
std::string text;
UIIcon icon = UIIcon::None;
ThemeHomeAction action = ThemeHomeAction::FileBrowser;
};
struct ThemeEdgeInsets {
int top = 0;
int right = 0;
int bottom = 0;
int left = 0;
};
struct ThemeHomeLauncherWidgetSpec {
ThemeLauncherPresentation presentation = ThemeLauncherPresentation::Menu;
int columns = 1;
int rows = 0;
int gap = 0;
int iconSize = 32;
int selectedRadius = 6;
std::vector<ThemeHomeLauncherSpec> items;
};
struct ThemeFeaturedBookWidgetSpec {
int startIndex = 0;
int coverWidth = 0;
int coverHeight = 0;
int coverGap = 14;
int titleGap = 8;
int selectedRadius = 6;
int placeholderIconSize = 0;
};
struct ThemeCoverGridWidgetSpec {
bool configured = false;
int columns = 1;
int rows = 0;
int gap = 0;
int rowGap = -1;
int coverWidth = 0;
int coverHeight = 0;
int placeholderIconSize = 0;
int rowHeight = 0;
int labelHeight = 20;
int labelGap = 2;
int labelLines = 1;
int startIndex = 0;
int selectedRadius = 6;
ThemeWidgetSelectionStyle selectionStyle = ThemeWidgetSelectionStyle::Fill;
ThemeEdgeInsets cellInset;
ThemeEdgeInsets labelInset;
};
struct ThemeButtonHintsWidgetSpec {
ThemeButtonHintLabel back = ThemeButtonHintLabel::Default;
ThemeButtonHintLabel confirm = ThemeButtonHintLabel::Default;
ThemeButtonHintLabel previous = ThemeButtonHintLabel::Default;
ThemeButtonHintLabel next = ThemeButtonHintLabel::Default;
};
struct ThemeHomeWidgetSpec {
std::string slot;
ThemeHomeWidgetType type = ThemeHomeWidgetType::LauncherList;
int layer = 0;
int offsetX = 0;
int offsetY = 0;
ThemeEdgeInsets bleed;
ThemeEdgeInsets inset;
ThemeHomeLauncherWidgetSpec launcher;
ThemeFeaturedBookWidgetSpec featured;
ThemeCoverGridWidgetSpec coverGrid;
ThemeButtonHintsWidgetSpec buttonHints;
};
struct ThemeHomeScreenSpec {
bool enabled = false;
ThemeHomeNavigationMode navigation = ThemeHomeNavigationMode::Linear;
bool hasInitialAction = false;
ThemeHomeAction initialAction = ThemeHomeAction::FileBrowser;
ThemeLayoutNode layout;
std::vector<ThemeHomeWidgetSpec> widgets;
};
struct ThemeScreenSpec {
bool enabled = false;
ThemeLayoutNode layout;
struct Widget {
std::string slot;
ThemeScreenWidgetType type = ThemeScreenWidgetType::List;
ThemeCoverGridWidgetSpec coverGrid;
};
std::vector<Widget> widgets;
};
struct ThemeLayoutSlot {
const char* id = nullptr;
Rect rect;
};
struct ThemeLayoutSlots {
static constexpr size_t kMaxSlots = 32;
ThemeLayoutSlot items[kMaxSlots];
size_t count = 0;
bool overflow = false;
void clear() {
count = 0;
overflow = false;
}
bool empty() const { return count == 0; }
size_t size() const { return count; }
void push(const char* id, Rect rect) {
if (count >= kMaxSlots) {
overflow = true;
return;
}
items[count++] = ThemeLayoutSlot{id, rect};
}
};
int themeLayoutTokenSize(const ThemeMetrics& metrics, const std::string& token);
void layoutThemeSlots(const ThemeLayoutNode& node, Rect rect, const ThemeMetrics& metrics, ThemeLayoutSlots& slots);
Rect findThemeSlot(const ThemeLayoutSlots& slots, const std::string& id);
Rect normalizeThemeHeaderSlot(Rect rect, const ThemeMetrics& metrics);
@@ -19,8 +19,9 @@ constexpr int cornerRadius = 6;
} // namespace } // namespace
void Lyra3CoversTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector<RecentBook>& recentBooks, void Lyra3CoversTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector<RecentBook>& recentBooks,
const int selectorIndex, bool& coverRendered, bool& coverBufferStored, const int coverSelectorIndex, bool& coverRendered, bool& coverBufferStored,
bool& bufferRestored, std::function<bool()> storeCoverBuffer) const { bool& bufferRestored, std::function<bool()> storeCoverBuffer,
bool coverStripSelected) const {
const int tileWidth = (rect.width - 2 * Lyra3CoversMetrics::values.contentSidePadding) / 3; const int tileWidth = (rect.width - 2 * Lyra3CoversMetrics::values.contentSidePadding) / 3;
const int tileY = rect.y; const int tileY = rect.y;
const bool hasContinueReading = !recentBooks.empty(); const bool hasContinueReading = !recentBooks.empty();
@@ -82,7 +83,7 @@ void Lyra3CoversTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, con
for (int i = 0; i < std::min(static_cast<int>(recentBooks.size()), Lyra3CoversMetrics::values.homeRecentBooksCount); for (int i = 0; i < std::min(static_cast<int>(recentBooks.size()), Lyra3CoversMetrics::values.homeRecentBooksCount);
i++) { i++) {
bool bookSelected = (selectorIndex == i); bool bookSelected = coverStripSelected && coverSelectorIndex == i;
int tileX = Lyra3CoversMetrics::values.contentSidePadding + tileWidth * i; int tileX = Lyra3CoversMetrics::values.contentSidePadding + tileWidth * i;
@@ -18,6 +18,7 @@ constexpr ThemeMetrics values = [] {
class Lyra3CoversTheme : public LyraTheme { class Lyra3CoversTheme : public LyraTheme {
public: public:
void drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector<RecentBook>& recentBooks, void drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector<RecentBook>& recentBooks,
const int selectorIndex, bool& coverRendered, bool& coverBufferStored, bool& bufferRestored, const int coverSelectorIndex, bool& coverRendered, bool& coverBufferStored,
std::function<bool()> storeCoverBuffer) const override; bool& bufferRestored, std::function<bool()> storeCoverBuffer,
bool coverStripSelected = true) const override;
}; };
File diff suppressed because it is too large Load Diff
+39 -2
View File
@@ -28,6 +28,7 @@ constexpr ThemeMetrics values = {.batteryWidth = 16,
.homeCoverTileHeight = 242, .homeCoverTileHeight = 242,
.homeRecentBooksCount = 1, .homeRecentBooksCount = 1,
.homeContinueReadingInMenu = false, .homeContinueReadingInMenu = false,
.homeShowContinueReadingHeader = true,
.homeMenuTopOffset = 16, .homeMenuTopOffset = 16,
.buttonHintsHeight = 40, .buttonHintsHeight = 40,
.sideButtonHintsWidth = 30, .sideButtonHintsWidth = 30,
@@ -73,6 +74,21 @@ constexpr ThemeMetrics values = {.batteryWidth = 16,
class LyraTheme : public BaseTheme { class LyraTheme : public BaseTheme {
public: public:
explicit LyraTheme(const ThemeMetrics* metrics = &LyraMetrics::values,
const ThemeHomeRecentsSpec* homeRecents = nullptr, const ThemeButtonMenuSpec* buttonMenu = nullptr,
const ThemeListSpec* list = nullptr, const ThemeButtonHintsSpec* buttonHints = nullptr,
const ThemeTabBarSpec* tabBar = nullptr, const ThemeHeaderSpec* header = nullptr,
const char* assetRoot = nullptr, const ThemeIconMap* icons = nullptr)
: metrics_(metrics),
homeRecents_(homeRecents),
buttonMenu_(buttonMenu),
list_(list),
buttonHints_(buttonHints),
tabBar_(tabBar),
header_(header),
assetRoot_(assetRoot),
icons_(icons) {}
// Component drawing methods // Component drawing methods
void fillBatteryIcon(const GfxRenderer& renderer, Rect rect, uint16_t percentage) const override; void fillBatteryIcon(const GfxRenderer& renderer, Rect rect, uint16_t percentage) const override;
void drawHeader(const GfxRenderer& renderer, Rect rect, const char* title, const char* subtitle) const override; void drawHeader(const GfxRenderer& renderer, Rect rect, const char* title, const char* subtitle) const override;
@@ -92,9 +108,30 @@ class LyraTheme : public BaseTheme {
void drawButtonMenu(GfxRenderer& renderer, Rect rect, int buttonCount, int selectedIndex, void drawButtonMenu(GfxRenderer& renderer, Rect rect, int buttonCount, int selectedIndex,
const std::function<std::string(int index)>& buttonLabel, const std::function<std::string(int index)>& buttonLabel,
const std::function<UIIcon(int index)>& rowIcon) const override; const std::function<UIIcon(int index)>& rowIcon) const override;
bool homeCoverCacheDependsOnSelector() const override {
return homeRecents_ == nullptr || homeRecents_->type != ThemeHomeRecentsType::None;
}
void drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector<RecentBook>& recentBooks, void drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector<RecentBook>& recentBooks,
const int selectorIndex, bool& coverRendered, bool& coverBufferStored, bool& bufferRestored, const int coverSelectorIndex, bool& coverRendered, bool& coverBufferStored,
std::function<bool()> storeCoverBuffer) const override; bool& bufferRestored, std::function<bool()> storeCoverBuffer,
bool coverStripSelected = true) const override;
void drawEmptyRecents(const GfxRenderer& renderer, const Rect rect) const; void drawEmptyRecents(const GfxRenderer& renderer, const Rect rect) const;
bool showsFileIcons() const override { return true; } bool showsFileIcons() const override { return true; }
private:
const ThemeMetrics* metrics_;
const ThemeHomeRecentsSpec* homeRecents_;
const ThemeButtonMenuSpec* buttonMenu_;
const ThemeListSpec* list_;
const ThemeButtonHintsSpec* buttonHints_;
const ThemeTabBarSpec* tabBar_;
const ThemeHeaderSpec* header_;
const char* assetRoot_;
const ThemeIconMap* icons_;
const ThemeMetrics& metrics() const { return metrics_ ? *metrics_ : LyraMetrics::values; }
bool hasThemeIcon(UIIcon icon) const;
bool drawThemeIcon(const GfxRenderer& renderer, UIIcon icon, int x, int y, int size) const;
void drawCoverStripRecents(GfxRenderer& renderer, Rect rect, const std::vector<RecentBook>& recentBooks,
int selectorIndex, bool& coverRendered, bool& coverBufferStored, bool bufferRestored,
std::function<bool()> storeCoverBuffer, bool coverStripSelected) const;
}; };
@@ -113,8 +113,10 @@ void RoundedRaffTheme::drawTabBar(const GfxRenderer& renderer, Rect rect, const
} }
void RoundedRaffTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector<RecentBook>& recentBooks, void RoundedRaffTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector<RecentBook>& recentBooks,
const int selectorIndex, bool& coverRendered, bool& coverBufferStored, const int coverSelectorIndex, bool& coverRendered, bool& coverBufferStored,
bool& bufferRestored, std::function<bool()> storeCoverBuffer) const { bool& bufferRestored, std::function<bool()> storeCoverBuffer,
bool coverStripSelected) const {
(void)coverSelectorIndex;
const int tileWidth = rect.width - 2 * RoundedRaffMetrics::values.contentSidePadding; const int tileWidth = rect.width - 2 * RoundedRaffMetrics::values.contentSidePadding;
const int tileHeight = rect.height; const int tileHeight = rect.height;
const int tileY = rect.y; const int tileY = rect.y;
@@ -131,6 +133,7 @@ void RoundedRaffTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, con
if (hasContinueReading) { if (hasContinueReading) {
RecentBook book = recentBooks[0]; RecentBook book = recentBooks[0];
if (!coverRendered) { if (!coverRendered) {
renderer.fillRect(tileX, tileY, tileWidth, tileHeight, false);
std::string coverPath = book.coverBmpPath; std::string coverPath = book.coverBmpPath;
bool hasCover = true; bool hasCover = true;
if (coverPath.empty()) { if (coverPath.empty()) {
@@ -175,15 +178,17 @@ void RoundedRaffTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, con
coverRendered = coverBufferStored; // Only consider it rendered if we successfully stored the buffer coverRendered = coverBufferStored; // Only consider it rendered if we successfully stored the buffer
} }
renderer.fillRoundedRect(tileX, tileY, tileWidth, imgY - tileY, kRowRadius, true, true, false, false, if (coverStripSelected) {
Color::LightGray); renderer.fillRoundedRect(tileX, tileY, tileWidth, imgY - tileY, kRowRadius, true, true, false, false,
renderer.fillRectDither(tileX, imgY, (tileWidth - coverWidth) / 2, RoundedRaffMetrics::values.homeCoverHeight, Color::LightGray);
Color::LightGray); renderer.fillRectDither(tileX, imgY, (tileWidth - coverWidth) / 2, RoundedRaffMetrics::values.homeCoverHeight,
renderer.fillRectDither(tileX + (tileWidth + coverWidth) / 2, imgY, (tileWidth - coverWidth) / 2, Color::LightGray);
RoundedRaffMetrics::values.homeCoverHeight, Color::LightGray); renderer.fillRectDither(tileX + (tileWidth + coverWidth) / 2, imgY, (tileWidth - coverWidth) / 2,
renderer.fillRoundedRect(tileX, imgY + RoundedRaffMetrics::values.homeCoverHeight, tileWidth, RoundedRaffMetrics::values.homeCoverHeight, Color::LightGray);
tileHeight - (imgY - tileY + RoundedRaffMetrics::values.homeCoverHeight), kRowRadius, renderer.fillRoundedRect(tileX, imgY + RoundedRaffMetrics::values.homeCoverHeight, tileWidth,
false, false, true, true, Color::LightGray); tileHeight - (imgY - tileY + RoundedRaffMetrics::values.homeCoverHeight), kRowRadius,
false, false, true, true, Color::LightGray);
}
} else { } else {
renderer.fillRoundedRect(tileX, tileY, tileWidth, tileHeight, kRowRadius, Color::LightGray); renderer.fillRoundedRect(tileX, tileY, tileWidth, tileHeight, kRowRadius, Color::LightGray);
renderer.drawCenteredText(kTitleFontId, rect.y + rect.height / 2 - renderer.getLineHeight(kTitleFontId) / 2, renderer.drawCenteredText(kTitleFontId, rect.y + rect.height / 2 - renderer.getLineHeight(kTitleFontId) / 2,
@@ -78,8 +78,8 @@ class RoundedRaffTheme : public BaseTheme {
void drawTabBar(const GfxRenderer& renderer, Rect rect, const std::vector<TabInfo>& tabs, void drawTabBar(const GfxRenderer& renderer, Rect rect, const std::vector<TabInfo>& tabs,
bool selected) const override; bool selected) const override;
void drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector<RecentBook>& recentBooks, void drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector<RecentBook>& recentBooks,
int selectorIndex, bool& coverRendered, bool& coverBufferStored, bool& bufferRestored, int coverSelectorIndex, bool& coverRendered, bool& coverBufferStored, bool& bufferRestored,
std::function<bool()> storeCoverBuffer) const override; std::function<bool()> storeCoverBuffer, bool coverStripSelected = true) const override;
void drawButtonMenu(GfxRenderer& renderer, Rect rect, int buttonCount, int selectedIndex, void drawButtonMenu(GfxRenderer& renderer, Rect rect, int buttonCount, int selectedIndex,
const std::function<std::string(int index)>& buttonLabel, const std::function<std::string(int index)>& buttonLabel,
const std::function<UIIcon(int index)>& rowIcon) const override; const std::function<UIIcon(int index)>& rowIcon) const override;
+1
View File
@@ -350,6 +350,7 @@ void setup() {
I18N.setLanguage(static_cast<Language>(SETTINGS.language)); I18N.setLanguage(static_cast<Language>(SETTINGS.language));
KOREADER_STORE.loadFromFile(); KOREADER_STORE.loadFromFile();
OPDS_STORE.loadFromFile(); OPDS_STORE.loadFromFile();
UITheme::getInstance().refreshRegistry();
UITheme::getInstance().reload(); UITheme::getInstance().reload();
ButtonNavigator::setMappedInputManager(mappedInputManager); ButtonNavigator::setMappedInputManager(mappedInputManager);
+16 -2
View File
@@ -15,8 +15,10 @@
#include "OpdsServerStore.h" #include "OpdsServerStore.h"
#include "SdCardFontSystem.h" #include "SdCardFontSystem.h"
#include "SettingsList.h" #include "SettingsList.h"
#include "SilentRestart.h"
#include "WebDAVHandler.h" #include "WebDAVHandler.h"
#include "WifiCredentialStore.h" #include "WifiCredentialStore.h"
#include "components/UITheme.h"
#include "html/FilesPageHtml.generated.h" #include "html/FilesPageHtml.generated.h"
#include "html/FontsPageHtml.generated.h" #include "html/FontsPageHtml.generated.h"
#include "html/HomePageHtml.generated.h" #include "html/HomePageHtml.generated.h"
@@ -1106,7 +1108,8 @@ void CrossPointWebServer::handleGetSettings() const {
// Pass the SD font registry so the fontFamily setting's enumStringValues // Pass the SD font registry so the fontFamily setting's enumStringValues
// includes SD-resident families — otherwise the web API only exposes the // includes SD-resident families — otherwise the web API only exposes the
// three built-in fonts. // three built-in fonts.
const auto& settings = getSettingsList(&sdFontSystem.registry()); UITheme::getInstance().refreshRegistry();
const auto& settings = getSettingsList(&sdFontSystem.registry(), &UITheme::getInstance().registry());
server->setContentLength(CONTENT_LENGTH_UNKNOWN); server->setContentLength(CONTENT_LENGTH_UNKNOWN);
server->send(200, "application/json", ""); server->send(200, "application/json", "");
@@ -1208,8 +1211,10 @@ void CrossPointWebServer::handlePostSettings() {
return; return;
} }
const auto& settings = getSettingsList(&sdFontSystem.registry()); UITheme::getInstance().refreshRegistry();
const auto& settings = getSettingsList(&sdFontSystem.registry(), &UITheme::getInstance().registry());
int applied = 0; int applied = 0;
bool themeChanged = false;
for (const auto& s : settings) { for (const auto& s : settings) {
if (!s.key) continue; if (!s.key) continue;
@@ -1234,6 +1239,9 @@ void CrossPointWebServer::handlePostSettings() {
} else if (s.valueSetter) { } else if (s.valueSetter) {
s.valueSetter(static_cast<uint8_t>(val)); s.valueSetter(static_cast<uint8_t>(val));
} }
if (strcmp(s.key, "uiTheme") == 0) {
themeChanged = true;
}
applied++; applied++;
} }
break; break;
@@ -1268,6 +1276,12 @@ void CrossPointWebServer::handlePostSettings() {
SETTINGS.saveToFile(); SETTINGS.saveToFile();
LOG_DBG("WEB", "Applied %d setting(s)", applied); LOG_DBG("WEB", "Applied %d setting(s)", applied);
if (themeChanged) {
server->send(200, "text/plain", String("Applied ") + String(applied) + " setting(s); restarting");
delay(100);
silentRestart();
return;
}
server->send(200, "text/plain", String("Applied ") + String(applied) + " setting(s)"); server->send(200, "text/plain", String("Applied ") + String(applied) + " setting(s)");
} }
+38 -23
View File
@@ -80,11 +80,11 @@
} }
.breadcrumb-inline .sep { .breadcrumb-inline .sep {
margin: 0 6px; margin: 0 6px;
color: var(--border-color); color: var(--label-color);
} }
.breadcrumb-inline .current { .breadcrumb-inline .current {
color: var(--title-color); color: var(--title-color);
font-weight: 500; font-weight: 600;
} }
.nav-links { .nav-links {
margin: 20px 0; margin: 20px 0;
@@ -960,12 +960,6 @@
align-items: center; align-items: center;
margin-bottom: 12px; margin-bottom: 12px;
} }
.contents-title {
font-size: 1.1em;
font-weight: 600;
color: var(--title-color);
margin: 0;
}
.summary-inline { .summary-inline {
color: var(--label-color); color: var(--label-color);
font-size: 0.9em; font-size: 0.9em;
@@ -1296,9 +1290,6 @@
flex-wrap: wrap; flex-wrap: wrap;
gap: 4px; gap: 4px;
} }
.contents-title {
font-size: 1em;
}
.summary-inline { .summary-inline {
font-size: 0.8em; font-size: 0.8em;
} }
@@ -1501,7 +1492,6 @@
<div class="page-header"> <div class="page-header">
<div class="page-header-left"> <div class="page-header-left">
<h2>📁 File Manager</h2> <h2>📁 File Manager</h2>
<div class="breadcrumb-inline" id="directory-breadcrumbs"></div>
</div> </div>
<div class="action-buttons"> <div class="action-buttons">
@@ -1523,7 +1513,7 @@
<div class="card"> <div class="card">
<div class="contents-header"> <div class="contents-header">
<h2 class="contents-title">Contents</h2> <div class="breadcrumb-inline" id="directory-breadcrumbs"></div>
<span class="summary-inline" id="folder-summary"></span> <span class="summary-inline" id="folder-summary"></span>
</div> </div>
@@ -1904,19 +1894,44 @@
const breadcrumbs = document.getElementById('directory-breadcrumbs'); const breadcrumbs = document.getElementById('directory-breadcrumbs');
const fileTable = document.getElementById('file-table'); const fileTable = document.getElementById('file-table');
let breadcrumbContent = '<span class="sep">/</span>'; const segments = currentPath.split('/').filter(Boolean);
if (currentPath === '/') { breadcrumbs.replaceChildren();
breadcrumbContent += '<span class="current">🏠</span>';
const appendSep = function() {
const sep = document.createElement('span');
sep.className = 'sep';
sep.textContent = '';
breadcrumbs.appendChild(sep);
};
const appendLink = function(label, href) {
const link = document.createElement('a');
link.href = href;
link.textContent = label;
breadcrumbs.appendChild(link);
};
const appendCurrent = function(label) {
const current = document.createElement('span');
current.className = 'current';
current.textContent = label;
breadcrumbs.appendChild(current);
};
if (segments.length === 0) {
appendCurrent('🏠 Home');
} else { } else {
breadcrumbContent += '<a href="/files">🏠</a>'; appendLink('🏠 Home', '/files');
const pathSegments = currentPath.split('/'); segments.forEach(function(segment, index) {
pathSegments.slice(1, pathSegments.length - 1).forEach(function(segment, index) { appendSep();
breadcrumbContent += '<span class="sep">/</span><a href="/files?path=' + encodeURIComponent(pathSegments.slice(0, index + 2).join('/')) + '">' + escapeHtml(segment) + '</a>'; if (index === segments.length - 1) {
appendCurrent(segment);
} else {
const path = '/' + segments.slice(0, index + 1).join('/');
appendLink(segment, '/files?path=' + encodeURIComponent(path));
}
}); });
breadcrumbContent += '<span class="sep">/</span>';
breadcrumbContent += '<span class="current">' + escapeHtml(pathSegments[pathSegments.length - 1]) + '</span>';
} }
breadcrumbs.innerHTML = breadcrumbContent;
let files = []; let files = [];
try { try {
+1
View File
@@ -43,3 +43,4 @@ add_subdirectory(release_json_parser)
add_subdirectory(differential_rounding) add_subdirectory(differential_rounding)
add_subdirectory(hyphenation_eval) add_subdirectory(hyphenation_eval)
add_subdirectory(utf8_compose) add_subdirectory(utf8_compose)
add_subdirectory(theme_layout)
+16
View File
@@ -0,0 +1,16 @@
add_executable(ThemeLayoutTest
ThemeLayoutTest.cpp
${REPO_ROOT}/src/components/themes/ThemeLayout.cpp
)
target_include_directories(ThemeLayoutTest PRIVATE
${REPO_ROOT}/src
${REPO_ROOT}/freeink-sdk/libs/ui/FreeInkUI/include
)
target_link_libraries(ThemeLayoutTest PRIVATE
crosspoint_test_common
GTest::gtest_main
)
gtest_discover_tests(ThemeLayoutTest)
+85
View File
@@ -0,0 +1,85 @@
#include <gtest/gtest.h>
#include "components/themes/ThemeLayout.h"
namespace {
ThemeMetrics testMetrics() {
ThemeMetrics metrics{};
metrics.headerHeight = 48;
metrics.buttonHintsHeight = 40;
metrics.tabBarHeight = 36;
metrics.listRowHeight = 42;
metrics.listWithSubtitleRowHeight = 58;
metrics.menuRowHeight = 42;
metrics.homeCoverTileHeight = 0;
metrics.homeCoverHeight = 0;
metrics.verticalSpacing = 8;
metrics.progressBarHeight = 4;
return metrics;
}
ThemeLayoutNode slot(const char* id, ThemeLayoutSizeType type, int value) {
ThemeLayoutNode node;
node.id = id;
node.sizeType = type;
if (type == ThemeLayoutSizeType::Fixed) {
node.size = value;
} else {
node.flex = value;
}
return node;
}
} // namespace
TEST(ThemeLayoutTest, EmitsSuperMinimalHomeSlots) {
ThemeLayoutNode root;
root.id = "root";
root.axis = ThemeLayoutAxis::Column;
root.children.push_back(slot("header", ThemeLayoutSizeType::Fixed, 48));
root.children.push_back(slot("menu", ThemeLayoutSizeType::Flex, 1));
root.children.push_back(slot("buttons", ThemeLayoutSizeType::Fixed, 40));
ThemeLayoutSlots slots;
layoutThemeSlots(root, Rect{0, 0, 528, 792}, testMetrics(), slots);
ASSERT_EQ(slots.size(), 3u);
EXPECT_STREQ(slots.items[0].id, "header");
EXPECT_EQ(slots.items[0].rect.x, 0);
EXPECT_EQ(slots.items[0].rect.y, 0);
EXPECT_EQ(slots.items[0].rect.width, 528);
EXPECT_EQ(slots.items[0].rect.height, 48);
EXPECT_STREQ(slots.items[1].id, "menu");
EXPECT_EQ(slots.items[1].rect.x, 0);
EXPECT_EQ(slots.items[1].rect.y, 48);
EXPECT_EQ(slots.items[1].rect.width, 528);
EXPECT_EQ(slots.items[1].rect.height, 704);
EXPECT_STREQ(slots.items[2].id, "buttons");
EXPECT_EQ(slots.items[2].rect.x, 0);
EXPECT_EQ(slots.items[2].rect.y, 752);
EXPECT_EQ(slots.items[2].rect.width, 528);
EXPECT_EQ(slots.items[2].rect.height, 40);
}
TEST(ThemeLayoutTest, EmitsFileBrowserSlotsWithPath) {
ThemeLayoutNode root;
root.id = "root";
root.axis = ThemeLayoutAxis::Column;
root.gap = 8;
root.children.push_back(slot("header", ThemeLayoutSizeType::Fixed, 48));
root.children.push_back(slot("list", ThemeLayoutSizeType::Flex, 1));
root.children.push_back(slot("path", ThemeLayoutSizeType::Fixed, 14));
root.children.push_back(slot("buttons", ThemeLayoutSizeType::Fixed, 40));
ThemeLayoutSlots slots;
layoutThemeSlots(root, Rect{0, 0, 528, 792}, testMetrics(), slots);
ASSERT_EQ(slots.size(), 4u);
EXPECT_EQ(findThemeSlot(slots, "header").height, 48);
EXPECT_EQ(findThemeSlot(slots, "list").height, 666);
EXPECT_EQ(findThemeSlot(slots, "path").height, 14);
EXPECT_EQ(findThemeSlot(slots, "buttons").height, 40);
}