Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79f5657fb2 | ||
|
|
48aa3c8e02 |
@@ -67,6 +67,10 @@ USB port or browser before assuming the device is locked. Only reach for the unl
|
|||||||
> Flashing any other firmware on a USB-locked device may **permanently brick the device** or leave it **permanently
|
> Flashing any other firmware on a USB-locked device may **permanently brick the device** or leave it **permanently
|
||||||
> stuck on that firmware with no recovery path**. Once USB flashing is re-locked, your only way back is via OTA, and if
|
> stuck on that firmware with no recovery path**. Once USB flashing is re-locked, your only way back is via OTA, and if
|
||||||
> the firmware you flashed doesn't support OTA, **there is no way out**.
|
> the firmware you flashed doesn't support OTA, **there is no way out**.
|
||||||
|
>
|
||||||
|
> **The Papyrix fork has removed OTA update support from its code.** If you flash Papyrix onto a
|
||||||
|
> USB-locked unit, you will have **zero update or recovery path** and will be stuck on it forever. **Do not flash
|
||||||
|
> Papyrix (or any other unsupported firmware) on a locked device.**
|
||||||
|
|
||||||
## Install firmware
|
## Install firmware
|
||||||
|
|
||||||
@@ -127,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
|
||||||
@@ -134,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)
|
||||||
|
|
||||||
@@ -245,7 +267,7 @@ One of the best things about open source is that anyone can take the code in a d
|
|||||||
|
|
||||||
- [papyrix-reader](https://github.com/bigbag/papyrix-reader) — Adds FB2 and MD format support. Actively maintained with Arabic script support. Custom themes via SD card.
|
- [papyrix-reader](https://github.com/bigbag/papyrix-reader) — Adds FB2 and MD format support. Actively maintained with Arabic script support. Custom themes via SD card.
|
||||||
|
|
||||||
- ~~[crosspet](https://github.com/trilwu/crosspet) — A Vietnamese fork that adds a Tamagotchi-style virtual chicken that grows based on your reading milestones (pages read, streaks, care). Also: Flashcards, Weather, Pomodoro timer, and mini-games.~~ (Unmaintained)
|
- [crosspet](https://github.com/trilwu/crosspet) — A Vietnamese fork that adds a Tamagotchi-style virtual chicken that grows based on your reading milestones (pages read, streaks, care). Also: Flashcards, Weather, Pomodoro timer, and mini-games.
|
||||||
|
|
||||||
- [crosspoint-reader-cjk](https://github.com/aBER0724/crosspoint-reader-cjk) — Purpose-built for Chinese, Japanese, and Korean reading.
|
- [crosspoint-reader-cjk](https://github.com/aBER0724/crosspoint-reader-cjk) — Purpose-built for Chinese, Japanese, and Korean reading.
|
||||||
|
|
||||||
|
|||||||
+12
-21
@@ -5,7 +5,6 @@ Welcome to the **CrossPoint** firmware. This guide outlines the hardware control
|
|||||||
- [CrossPoint User Guide](#crosspoint-user-guide)
|
- [CrossPoint User Guide](#crosspoint-user-guide)
|
||||||
- [1. Hardware Overview](#1-hardware-overview)
|
- [1. Hardware Overview](#1-hardware-overview)
|
||||||
- [Button Layout](#button-layout)
|
- [Button Layout](#button-layout)
|
||||||
- [Taking a Screenshot](#taking-a-screenshot)
|
|
||||||
- [2. Power \& Startup](#2-power--startup)
|
- [2. Power \& Startup](#2-power--startup)
|
||||||
- [Power On / Off](#power-on--off)
|
- [Power On / Off](#power-on--off)
|
||||||
- [First Launch](#first-launch)
|
- [First Launch](#first-launch)
|
||||||
@@ -15,11 +14,7 @@ Welcome to the **CrossPoint** firmware. This guide outlines the hardware control
|
|||||||
- [3.3 Browse Files Screen](#33-browse-files-screen)
|
- [3.3 Browse Files Screen](#33-browse-files-screen)
|
||||||
- [3.4 Recent Books Screen](#34-recent-books-screen)
|
- [3.4 Recent Books Screen](#34-recent-books-screen)
|
||||||
- [3.5 File Transfer Screen](#35-file-transfer-screen)
|
- [3.5 File Transfer Screen](#35-file-transfer-screen)
|
||||||
- [3.5.1 Calibre Wireless Transfers](#351-calibre-wireless-transfers)
|
- [3.5.1 Calibre Wireless Transfers](#351-calibre-wireless-transfers)
|
||||||
- [Installing the Plugin in Calibre](#installing-the-plugin-in-calibre)
|
|
||||||
- [Configuring the CrossPoint Plugin in Calibre](#configuring-the-crosspoint-plugin-in-calibre)
|
|
||||||
- [Uploading Books](#uploading-books)
|
|
||||||
- [Removing a Book](#removing-a-book)
|
|
||||||
- [3.6 Settings](#36-settings)
|
- [3.6 Settings](#36-settings)
|
||||||
- [3.6.1 Display](#361-display)
|
- [3.6.1 Display](#361-display)
|
||||||
- [3.6.2 Reader](#362-reader)
|
- [3.6.2 Reader](#362-reader)
|
||||||
@@ -28,25 +23,21 @@ Welcome to the **CrossPoint** firmware. This guide outlines the hardware control
|
|||||||
- [3.6.5 OPDS Servers (Multiple Libraries)](#365-opds-servers-multiple-libraries)
|
- [3.6.5 OPDS Servers (Multiple Libraries)](#365-opds-servers-multiple-libraries)
|
||||||
- [3.6.6 Web Settings (Wi-Fi + OPDS)](#366-web-settings-wi-fi--opds)
|
- [3.6.6 Web Settings (Wi-Fi + OPDS)](#366-web-settings-wi-fi--opds)
|
||||||
- [3.6.7 KOReader Sync Quick Setup](#367-koreader-sync-quick-setup)
|
- [3.6.7 KOReader Sync Quick Setup](#367-koreader-sync-quick-setup)
|
||||||
- [Option A: Free Public Server (`sync.koreader.rocks`)](#option-a-free-public-server-synckoreaderrocks)
|
|
||||||
- [Option B: Self-Hosted Server (Docker Compose)](#option-b-self-hosted-server-docker-compose)
|
|
||||||
- [3.7 Sleep Screen](#37-sleep-screen)
|
- [3.7 Sleep Screen](#37-sleep-screen)
|
||||||
- [Cover settings](#cover-settings)
|
|
||||||
- [Custom images](#custom-images)
|
|
||||||
- [3.8 Custom Fonts (SD Card)](#38-custom-fonts-sd-card)
|
- [3.8 Custom Fonts (SD Card)](#38-custom-fonts-sd-card)
|
||||||
- [4. Reading Mode](#4-reading-mode)
|
- [4. Reading Mode](#4-reading-mode)
|
||||||
- [Page Turning](#page-turning)
|
- [Page Turning](#page-turning)
|
||||||
- [Chapter Navigation](#chapter-navigation)
|
- [Chapter Navigation](#chapter-navigation)
|
||||||
- [Auto Page Turn](#auto-page-turn)
|
- [Auto Page Turn](#auto-page-turn)
|
||||||
- [Tilt Page Turn (X3 only)](#tilt-page-turn-x3-only)
|
- [Tilt Page Turn (X3 only)](#tilt-page-turn-x3-only)
|
||||||
- [Footnote Navigation](#footnote-navigation)
|
- [Footnote Navigation](#footnote-navigation)
|
||||||
- [System Navigation](#system-navigation)
|
- [System Navigation](#system-navigation)
|
||||||
- [Supported Languages](#supported-languages)
|
- [Supported Languages](#supported-languages)
|
||||||
- [5. Reader Menu](#5-reader-menu)
|
- [5. Reader Menu](#5-reader-menu)
|
||||||
- [5.1 Chapter Selection](#51-chapter-selection)
|
- [5.1 Chapter Selection](#51-chapter-selection)
|
||||||
- [5.2 Bookmarks](#52-bookmarks)
|
- [5.2 Bookmarks](#52-bookmarks)
|
||||||
- [6. Current Limitations \& Roadmap](#6-current-limitations--roadmap)
|
- [6. Current Limitations & Roadmap](#6-current-limitations--roadmap)
|
||||||
- [7. Troubleshooting Issues \& Escaping Bootloop](#7-troubleshooting-issues--escaping-bootloop)
|
- [7. Troubleshooting Issues & Escaping Bootloop](#7-troubleshooting-issues--escaping-bootloop)
|
||||||
|
|
||||||
## 1. Hardware Overview
|
## 1. Hardware Overview
|
||||||
|
|
||||||
|
|||||||
@@ -90,13 +90,13 @@ if (parsedSize != fileSize) {
|
|||||||
|
|
||||||
## `section.bin`
|
## `section.bin`
|
||||||
|
|
||||||
### Version 28
|
### Version 25
|
||||||
|
|
||||||
Each file in `sections/*.bin` stores one laid-out spine section. The header is
|
Each file in `sections/*.bin` stores one laid-out spine section. The header is
|
||||||
also the cache-busting key: if any layout-affecting setting differs from the
|
also the cache-busting key: if any layout-affecting setting differs from the
|
||||||
current reader settings, the section is discarded and rebuilt.
|
current reader settings, the section is discarded and rebuilt.
|
||||||
|
|
||||||
Version 28 includes:
|
Version 25 includes:
|
||||||
|
|
||||||
- cache-busting fields for paragraph alignment, hyphenation, embedded CSS,
|
- cache-busting fields for paragraph alignment, hyphenation, embedded CSS,
|
||||||
image rendering mode, and Focus Reading
|
image rendering mode, and Focus Reading
|
||||||
@@ -105,8 +105,6 @@ Version 28 includes:
|
|||||||
- paragraph and list-item LUTs used by KOReader sync page refinement
|
- paragraph and list-item LUTs used by KOReader sync page refinement
|
||||||
- optional per-word Focus Reading split metadata
|
- optional per-word Focus Reading split metadata
|
||||||
- per-page footnote entries
|
- per-page footnote entries
|
||||||
- serialized word style bits for underline, strikethrough, superscript, and
|
|
||||||
subscript
|
|
||||||
|
|
||||||
ImHex pattern:
|
ImHex pattern:
|
||||||
|
|
||||||
@@ -115,7 +113,7 @@ import std.mem;
|
|||||||
import std.string;
|
import std.string;
|
||||||
import std.core;
|
import std.core;
|
||||||
|
|
||||||
#define EXPECTED_VERSION 28
|
#define EXPECTED_VERSION 25
|
||||||
#define MAX_STRING_LENGTH 65535
|
#define MAX_STRING_LENGTH 65535
|
||||||
#define FOOTNOTE_NUMBER_LEN 32
|
#define FOOTNOTE_NUMBER_LEN 32
|
||||||
#define FOOTNOTE_HREF_LEN 96
|
#define FOOTNOTE_HREF_LEN 96
|
||||||
|
|||||||
@@ -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.
|
||||||
+1
-1
Submodule freeink-sdk updated: f611d71d8e...329a4bebef
@@ -1,7 +1,7 @@
|
|||||||
#include "EpdFontFamily.h"
|
#include "EpdFontFamily.h"
|
||||||
|
|
||||||
const EpdFont* EpdFontFamily::getFont(const Style style) const {
|
const EpdFont* EpdFontFamily::getFont(const Style style) const {
|
||||||
// Extract font style bits; render-time overlay bits do not affect font selection.
|
// Extract font style bits (ignore UNDERLINE bit for font selection)
|
||||||
const bool hasBold = (style & BOLD) != 0;
|
const bool hasBold = (style & BOLD) != 0;
|
||||||
const bool hasItalic = (style & ITALIC) != 0;
|
const bool hasItalic = (style & ITALIC) != 0;
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ class EpdFontFamily {
|
|||||||
SUP = 16, // superscript: glyph scaled 50%, raised ~40% of ascender
|
SUP = 16, // superscript: glyph scaled 50%, raised ~40% of ascender
|
||||||
SUB = 32, // subscript: glyph scaled 50%, lowered ~25% of ascender
|
SUB = 32, // subscript: glyph scaled 50%, lowered ~25% of ascender
|
||||||
};
|
};
|
||||||
static constexpr uint8_t TEXT_DECORATION_MASK = static_cast<uint8_t>(UNDERLINE | STRIKETHROUGH);
|
|
||||||
|
|
||||||
explicit EpdFontFamily(const EpdFont* regular, const EpdFont* bold = nullptr, const EpdFont* italic = nullptr,
|
explicit EpdFontFamily(const EpdFont* regular, const EpdFont* bold = nullptr, const EpdFont* italic = nullptr,
|
||||||
const EpdFont* boldItalic = nullptr)
|
const EpdFont* boldItalic = nullptr)
|
||||||
@@ -28,9 +27,6 @@ class EpdFontFamily {
|
|||||||
const EpdGlyph* getGlyph(uint32_t cp, Style style = REGULAR) const;
|
const EpdGlyph* getGlyph(uint32_t cp, Style style = REGULAR) const;
|
||||||
int8_t getKerning(uint32_t leftCp, uint32_t rightCp, Style style = REGULAR) const;
|
int8_t getKerning(uint32_t leftCp, uint32_t rightCp, Style style = REGULAR) const;
|
||||||
uint32_t applyLigatures(uint32_t cp, const char*& text, Style style = REGULAR) const;
|
uint32_t applyLigatures(uint32_t cp, const char*& text, Style style = REGULAR) const;
|
||||||
static constexpr bool hasTextDecoration(const Style style) {
|
|
||||||
return (static_cast<uint8_t>(style) & TEXT_DECORATION_MASK) != 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
private:
|
private:
|
||||||
const EpdFont* regular;
|
const EpdFont* regular;
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ import sys
|
|||||||
import tempfile
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import socket
|
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from concurrent.futures import ProcessPoolExecutor, as_completed
|
from concurrent.futures import ProcessPoolExecutor, as_completed
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -50,44 +49,17 @@ INSTANCE_DIR = SCRIPT_DIR / "instanced_fonts"
|
|||||||
DEFAULT_FALLBACK_FONT = EPDFONTS_DIR / "builtinFonts/source/NotoSans/NotoSans-Regular.ttf"
|
DEFAULT_FALLBACK_FONT = EPDFONTS_DIR / "builtinFonts/source/NotoSans/NotoSans-Regular.ttf"
|
||||||
|
|
||||||
|
|
||||||
_orig_getaddrinfo = socket.getaddrinfo
|
def download_font(url: str, dest: Path) -> Path:
|
||||||
|
"""Download a font file if not already cached. Returns the local path."""
|
||||||
|
|
||||||
def _ipv4_only_getaddrinfo(*args, **kwargs):
|
|
||||||
"""getaddrinfo variant that drops AAAA records (IPv4 only)."""
|
|
||||||
return [ai for ai in _orig_getaddrinfo(*args, **kwargs) if ai[0] == socket.AF_INET]
|
|
||||||
|
|
||||||
|
|
||||||
def download_font(url: str, dest: Path, retries: int = 3) -> Path:
|
|
||||||
"""Download a font file if not already cached. Returns the local path.
|
|
||||||
|
|
||||||
Some sources (e.g. mirrors.ctan.org) are round-robin redirectors that land
|
|
||||||
on a different mirror each request; a mirror may advertise an IPv6 address a
|
|
||||||
host without an IPv6 route cannot reach ([Errno 101] Network is unreachable).
|
|
||||||
Retry on failure, forcing IPv4 resolution after the first attempt.
|
|
||||||
"""
|
|
||||||
if dest.exists():
|
if dest.exists():
|
||||||
return dest
|
return dest
|
||||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
print(f" Downloading {dest.name}...")
|
print(f" Downloading {dest.name}...")
|
||||||
last_err = None
|
try:
|
||||||
for attempt in range(1, retries + 1):
|
urllib.request.urlretrieve(url, dest)
|
||||||
force_ipv4 = attempt > 1
|
except Exception as e:
|
||||||
if force_ipv4:
|
dest.unlink(missing_ok=True)
|
||||||
socket.getaddrinfo = _ipv4_only_getaddrinfo
|
raise RuntimeError(f"Failed to download {url}: {e}") from e
|
||||||
try:
|
|
||||||
urllib.request.urlretrieve(url, dest)
|
|
||||||
break
|
|
||||||
except Exception as e: # noqa: BLE001 - reported via RuntimeError below
|
|
||||||
last_err = e
|
|
||||||
dest.unlink(missing_ok=True)
|
|
||||||
if attempt < retries:
|
|
||||||
print(f" Attempt {attempt} failed ({e}); retrying (IPv4-only)...")
|
|
||||||
finally:
|
|
||||||
if force_ipv4:
|
|
||||||
socket.getaddrinfo = _orig_getaddrinfo
|
|
||||||
else:
|
|
||||||
raise RuntimeError(f"Failed to download {url}: {last_err}") from last_err
|
|
||||||
size_kb = dest.stat().st_size / 1024
|
size_kb = dest.stat().st_size / 1024
|
||||||
print(f" Downloaded {dest.name} ({size_kb:.0f} KB)")
|
print(f" Downloaded {dest.name} ({size_kb:.0f} KB)")
|
||||||
return dest
|
return dest
|
||||||
|
|||||||
@@ -133,15 +133,6 @@ families:
|
|||||||
italic: {url: "https://raw.githubusercontent.com/impallari/Libre-Baskerville/master/fonts/ttf/LibreBaskerville-Italic.ttf"}
|
italic: {url: "https://raw.githubusercontent.com/impallari/Libre-Baskerville/master/fonts/ttf/LibreBaskerville-Italic.ttf"}
|
||||||
bolditalic: {url: "https://raw.githubusercontent.com/impallari/Libre-Baskerville/master/fonts/ttf/LibreBaskerville-BoldItalic.ttf"}
|
bolditalic: {url: "https://raw.githubusercontent.com/impallari/Libre-Baskerville/master/fonts/ttf/LibreBaskerville-BoldItalic.ttf"}
|
||||||
|
|
||||||
- name: Vollkorn
|
|
||||||
description: "A serif for bread and butter use by Friedrich Althausen (Latin, Greek, Cyrillic)"
|
|
||||||
intervals: latin-ext,greek,cyrillic
|
|
||||||
sizes: [12, 14, 16, 18]
|
|
||||||
styles:
|
|
||||||
regular: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/vollkorn/Vollkorn%5Bwght%5D.ttf", variable: {wght: 400}}
|
|
||||||
bold: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/vollkorn/Vollkorn%5Bwght%5D.ttf", variable: {wght: 700}}
|
|
||||||
italic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/vollkorn/Vollkorn-Italic%5Bwght%5D.ttf", variable: {wght: 400}}
|
|
||||||
bolditalic: {url: "https://raw.githubusercontent.com/google/fonts/main/ofl/vollkorn/Vollkorn-Italic%5Bwght%5D.ttf", variable: {wght: 700}}
|
|
||||||
|
|
||||||
# ── Sans-serif ─────────────────────────────────────────────────────────
|
# ── Sans-serif ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
+108
-512
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
#include <HalStorage.h>
|
#include <HalStorage.h>
|
||||||
#include <Logging.h>
|
#include <Logging.h>
|
||||||
#include <Memory.h>
|
|
||||||
#include <Serialization.h>
|
#include <Serialization.h>
|
||||||
|
|
||||||
#include "Epub/css/CssParser.h"
|
#include "Epub/css/CssParser.h"
|
||||||
@@ -11,59 +10,34 @@
|
|||||||
#include "parsers/ChapterHtmlSlimParser.h"
|
#include "parsers/ChapterHtmlSlimParser.h"
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
// v28: text decoration bits now include line-through in serialized wordStyles.
|
// v27: words NFC-composed at layout time; bump invalidates NFD section caches.
|
||||||
constexpr uint8_t SECTION_FILE_VERSION = 28;
|
constexpr uint8_t SECTION_FILE_VERSION = 27;
|
||||||
// Written into the version field while a build is in progress; patched to
|
|
||||||
// SECTION_FILE_VERSION only when the build is finalized. An abandoned /
|
|
||||||
// crash-interrupted .bin therefore carries version 0, which loadSectionFile rejects
|
|
||||||
// as unknown and clears -- so an incomplete file is never mistaken for a valid one.
|
|
||||||
constexpr uint8_t SECTION_FILE_INCOMPLETE_VERSION = 0;
|
|
||||||
// Written when a build is suspended partway (reader exited or device slept mid-build).
|
|
||||||
// The file carries valid pages 0..pageCount-1, all LUTs, and a trailer with the parse
|
|
||||||
// watermark (bytesConsumed, totalBytes) appended after the li LUT. loadSectionFile
|
|
||||||
// accepts it so a resume shows those pages instantly; the reader extends it by
|
|
||||||
// rebuilding in the background. Uses the same header layout as SECTION_FILE_VERSION,
|
|
||||||
// so finalized files are untouched by this feature; older firmware treats the sentinel
|
|
||||||
// as an unknown version and rebuilds, which is a safe downgrade.
|
|
||||||
constexpr uint8_t SECTION_FILE_PARTIAL_VERSION = 0xFE;
|
|
||||||
constexpr uint32_t HEADER_SIZE = sizeof(uint8_t) + sizeof(int) + sizeof(float) + sizeof(bool) + sizeof(uint8_t) +
|
constexpr uint32_t HEADER_SIZE = sizeof(uint8_t) + sizeof(int) + sizeof(float) + sizeof(bool) + sizeof(uint8_t) +
|
||||||
sizeof(uint16_t) + sizeof(uint16_t) + sizeof(uint16_t) + sizeof(bool) + sizeof(bool) +
|
sizeof(uint16_t) + sizeof(uint16_t) + sizeof(uint16_t) + sizeof(bool) + sizeof(bool) +
|
||||||
sizeof(uint8_t) + sizeof(bool) + sizeof(uint32_t) + sizeof(uint32_t) +
|
sizeof(uint8_t) + sizeof(bool) + sizeof(uint32_t) + sizeof(uint32_t) +
|
||||||
sizeof(uint32_t) + sizeof(uint32_t);
|
sizeof(uint32_t) + sizeof(uint32_t);
|
||||||
|
|
||||||
|
struct PageLutEntry {
|
||||||
|
uint32_t fileOffset;
|
||||||
|
uint16_t paragraphIndex;
|
||||||
|
uint16_t listItemIndex;
|
||||||
|
};
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
// Out-of-line so the unique_ptr<ChapterHtmlSlimParser> in BuildContext can be
|
|
||||||
// constructed/destroyed where the parser's full definition is visible.
|
|
||||||
Section::Section(const std::shared_ptr<Epub>& epub, const int spineIndex, GfxRenderer& renderer)
|
|
||||||
: epub(epub),
|
|
||||||
spineIndex(spineIndex),
|
|
||||||
renderer(renderer),
|
|
||||||
filePath(epub->getCachePath() + "/sections/" + std::to_string(spineIndex) + ".bin") {}
|
|
||||||
|
|
||||||
// Suspend any in-progress build so every section.reset() / navigation / sleep path
|
|
||||||
// persists the pages already laid out as a partial .bin instead of discarding them
|
|
||||||
// (no-op once a build has completed or never started).
|
|
||||||
Section::~Section() { suspendBuild(); }
|
|
||||||
|
|
||||||
uint32_t Section::onPageComplete(std::unique_ptr<Page> page) {
|
uint32_t Section::onPageComplete(std::unique_ptr<Page> page) {
|
||||||
if (!file) {
|
if (!file) {
|
||||||
LOG_ERR("SCT", "File not open for writing page %d", builtPageCount_);
|
LOG_ERR("SCT", "File not open for writing page %d", pageCount);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const uint32_t position = file.position();
|
const uint32_t position = file.position();
|
||||||
if (!page->serialize(file)) {
|
if (!page->serialize(file)) {
|
||||||
LOG_ERR("SCT", "Failed to serialize page %d", builtPageCount_);
|
LOG_ERR("SCT", "Failed to serialize page %d", pageCount);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
LOG_DBG("SCT", "Page %d processed", builtPageCount_);
|
LOG_DBG("SCT", "Page %d processed", pageCount);
|
||||||
|
|
||||||
builtPageCount_++;
|
pageCount++;
|
||||||
// pageCount is the pages available to read: a rebuild over a partial only raises it
|
|
||||||
// once it has laid out more pages than the partial already covers.
|
|
||||||
if (builtPageCount_ > pageCount) {
|
|
||||||
pageCount = builtPageCount_;
|
|
||||||
}
|
|
||||||
return position;
|
return position;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,9 +56,7 @@ void Section::writeSectionFileHeader(const int fontId, const float lineCompressi
|
|||||||
sizeof(embeddedStyle) + sizeof(imageRendering) + sizeof(focusReadingEnabled) +
|
sizeof(embeddedStyle) + sizeof(imageRendering) + sizeof(focusReadingEnabled) +
|
||||||
sizeof(uint32_t) + sizeof(uint32_t) + sizeof(uint32_t) + sizeof(uint32_t),
|
sizeof(uint32_t) + sizeof(uint32_t) + sizeof(uint32_t) + sizeof(uint32_t),
|
||||||
"Header size mismatch");
|
"Header size mismatch");
|
||||||
// Written as the incomplete sentinel; finalizeBuild() patches it to
|
serialization::writePod(file, SECTION_FILE_VERSION);
|
||||||
// SECTION_FILE_VERSION as the last step, committing the file.
|
|
||||||
serialization::writePod(file, SECTION_FILE_INCOMPLETE_VERSION);
|
|
||||||
serialization::writePod(file, fontId);
|
serialization::writePod(file, fontId);
|
||||||
serialization::writePod(file, lineCompression);
|
serialization::writePod(file, lineCompression);
|
||||||
serialization::writePod(file, extraParagraphSpacing);
|
serialization::writePod(file, extraParagraphSpacing);
|
||||||
@@ -111,18 +83,16 @@ bool Section::loadSectionFile(const int fontId, const float lineCompression, con
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Match parameters
|
// Match parameters
|
||||||
bool filePartial = false;
|
|
||||||
{
|
{
|
||||||
uint8_t version;
|
uint8_t version;
|
||||||
serialization::readPod(file, version);
|
serialization::readPod(file, version);
|
||||||
if (version != SECTION_FILE_VERSION && version != SECTION_FILE_PARTIAL_VERSION) {
|
if (version != SECTION_FILE_VERSION) {
|
||||||
// Explicit close() required: member variable persists beyond function scope
|
// Explicit close() required: member variable persists beyond function scope
|
||||||
file.close();
|
file.close();
|
||||||
LOG_ERR("SCT", "Deserialization failed: Unknown version %u", version);
|
LOG_ERR("SCT", "Deserialization failed: Unknown version %u", version);
|
||||||
clearCache();
|
clearCache();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
filePartial = (version == SECTION_FILE_PARTIAL_VERSION);
|
|
||||||
|
|
||||||
int fileFontId;
|
int fileFontId;
|
||||||
uint16_t fileViewportWidth, fileViewportHeight;
|
uint16_t fileViewportWidth, fileViewportHeight;
|
||||||
@@ -157,42 +127,14 @@ bool Section::loadSectionFile(const int fontId, const float lineCompression, con
|
|||||||
}
|
}
|
||||||
|
|
||||||
serialization::readPod(file, pageCount);
|
serialization::readPod(file, pageCount);
|
||||||
|
|
||||||
if (filePartial) {
|
|
||||||
// A partial's pageCount is the watermark of a suspended build. Read the watermark
|
|
||||||
// trailer (appended after the li LUT) so estimatedTotalPages can extrapolate.
|
|
||||||
uint32_t liLutOffset = 0;
|
|
||||||
file.seek(HEADER_SIZE - sizeof(uint32_t));
|
|
||||||
serialization::readPod(file, liLutOffset);
|
|
||||||
const uint32_t trailerOffset = liLutOffset + static_cast<uint32_t>(pageCount) * sizeof(uint16_t);
|
|
||||||
const bool trailerValid =
|
|
||||||
pageCount > 0 && liLutOffset >= HEADER_SIZE && trailerOffset + 2 * sizeof(uint32_t) <= file.size();
|
|
||||||
if (!trailerValid) {
|
|
||||||
file.close();
|
|
||||||
LOG_ERR("SCT", "Deserialization failed: malformed partial section");
|
|
||||||
clearCache();
|
|
||||||
pageCount = 0;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
file.seek(trailerOffset);
|
|
||||||
serialization::readPod(file, partialBytesConsumed_);
|
|
||||||
serialization::readPod(file, partialTotalBytes_);
|
|
||||||
partial_ = true;
|
|
||||||
partialPageCount_ = pageCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Explicit close() required: member variable persists beyond function scope
|
// Explicit close() required: member variable persists beyond function scope
|
||||||
file.close();
|
file.close();
|
||||||
LOG_DBG("SCT", "Deserialization succeeded: %d pages%s", pageCount, filePartial ? " (partial)" : "");
|
LOG_DBG("SCT", "Deserialization succeeded: %d pages", pageCount);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Your updated class method (assuming you are using the 'SD' object, which is a wrapper for a specific filesystem)
|
// Your updated class method (assuming you are using the 'SD' object, which is a wrapper for a specific filesystem)
|
||||||
bool Section::clearCache() const {
|
bool Section::clearCache() const {
|
||||||
const std::string tmpBin = binTmpPath();
|
|
||||||
if (Storage.exists(tmpBin.c_str())) {
|
|
||||||
Storage.remove(tmpBin.c_str());
|
|
||||||
}
|
|
||||||
if (!Storage.exists(filePath.c_str())) {
|
if (!Storage.exists(filePath.c_str())) {
|
||||||
LOG_DBG("SCT", "Cache does not exist, no action needed");
|
LOG_DBG("SCT", "Cache does not exist, no action needed");
|
||||||
return true;
|
return true;
|
||||||
@@ -212,43 +154,8 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
|
|||||||
const uint16_t viewportHeight, const bool hyphenationEnabled, const bool embeddedStyle,
|
const uint16_t viewportHeight, const bool hyphenationEnabled, const bool embeddedStyle,
|
||||||
const uint8_t imageRendering, const bool focusReadingEnabled,
|
const uint8_t imageRendering, const bool focusReadingEnabled,
|
||||||
const std::function<void()>& popupFn) {
|
const std::function<void()>& popupFn) {
|
||||||
// One-shot build: start, then lay out the whole section in a single pass.
|
|
||||||
if (!startBuild(fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth, viewportHeight,
|
|
||||||
hyphenationEnabled, embeddedStyle, imageRendering, focusReadingEnabled, popupFn)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!buildSomeMore(0)) { // 0 = build to completion
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return buildComplete_;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool Section::startBuild(const int fontId, const float lineCompression, const bool extraParagraphSpacing,
|
|
||||||
const uint8_t paragraphAlignment, const uint16_t viewportWidth, const uint16_t viewportHeight,
|
|
||||||
const bool hyphenationEnabled, const bool embeddedStyle, const uint8_t imageRendering,
|
|
||||||
const bool focusReadingEnabled, const std::function<void()>& popupFn) {
|
|
||||||
if (build_) {
|
|
||||||
LOG_ERR("SCT", "startBuild called while a build is already active");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
buildComplete_ = false;
|
|
||||||
builtPageCount_ = 0;
|
|
||||||
// Pages from a loaded partial stay readable (from filePath) while this build writes
|
|
||||||
// to the tmp .bin, so availability never drops below the partial's watermark.
|
|
||||||
pageCount = partial_ ? partialPageCount_ : 0;
|
|
||||||
|
|
||||||
// Remove a stale tmp .bin from a crash-interrupted build; this build recreates it.
|
|
||||||
{
|
|
||||||
const std::string staleTmp = binTmpPath();
|
|
||||||
if (Storage.exists(staleTmp.c_str())) {
|
|
||||||
Storage.remove(staleTmp.c_str());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const auto localPath = epub->getSpineItem(spineIndex).href;
|
const auto localPath = epub->getSpineItem(spineIndex).href;
|
||||||
const auto htmlDir = epub->getCachePath() + "/html";
|
const auto tmpHtmlPath = epub->getCachePath() + "/.tmp_" + std::to_string(spineIndex) + ".html";
|
||||||
const auto htmlPath = htmlDir + "/" + std::to_string(spineIndex) + ".html";
|
|
||||||
const auto tmpHtmlPath = htmlDir + "/.tmp_" + std::to_string(spineIndex) + ".html";
|
|
||||||
|
|
||||||
// Create cache directory if it doesn't exist
|
// Create cache directory if it doesn't exist
|
||||||
{
|
{
|
||||||
@@ -256,101 +163,62 @@ bool Section::startBuild(const int fontId, const float lineCompression, const bo
|
|||||||
Storage.mkdir(sectionsDir.c_str());
|
Storage.mkdir(sectionsDir.c_str());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reuse the previously unzipped HTML if we already have it. The unzipped HTML is keyed only on the
|
// Retry logic for SD card timing issues
|
||||||
// book (it lives in the per-book cache dir), not on render settings, so it survives the invalidation
|
bool success = false;
|
||||||
// that wipes the layout (.bin) caches when font/margin/orientation change -- rebuilds then skip zip
|
uint32_t fileSize = 0;
|
||||||
// inflation entirely. It's promoted by an atomic rename as soon as the inflate succeeds (below), so
|
for (int attempt = 0; attempt < 3 && !success; attempt++) {
|
||||||
// even a window-only giant spine -- whose .bin never finalizes -- still caches its HTML, letting a
|
if (attempt > 0) {
|
||||||
// reopen skip the multi-second inflate. If htmlPath exists it is known-complete.
|
LOG_DBG("SCT", "Retrying stream (attempt %d)...", attempt + 1);
|
||||||
const bool reusedHtml = Storage.exists(htmlPath.c_str());
|
delay(50); // Brief delay before retry
|
||||||
bool htmlCached = reusedHtml;
|
|
||||||
if (reusedHtml) {
|
|
||||||
LOG_DBG("SCT", "Reusing cached HTML %s", htmlPath.c_str());
|
|
||||||
} else {
|
|
||||||
Storage.mkdir(htmlDir.c_str());
|
|
||||||
|
|
||||||
// Retry logic for SD card timing issues
|
|
||||||
bool streamed = false;
|
|
||||||
uint32_t fileSize = 0;
|
|
||||||
for (int attempt = 0; attempt < 3 && !streamed; attempt++) {
|
|
||||||
if (attempt > 0) {
|
|
||||||
LOG_DBG("SCT", "Retrying stream (attempt %d)...", attempt + 1);
|
|
||||||
delay(50); // Brief delay before retry
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove any incomplete file from previous attempt before retrying
|
|
||||||
if (Storage.exists(tmpHtmlPath.c_str())) {
|
|
||||||
Storage.remove(tmpHtmlPath.c_str());
|
|
||||||
}
|
|
||||||
|
|
||||||
HalFile tmpHtml;
|
|
||||||
if (!Storage.openFileForWrite("SCT", tmpHtmlPath, tmpHtml)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// Larger chunks mean far fewer SD writes inflating the HTML; a 1KB chunk turned a 584KB
|
|
||||||
// single-spine novel into ~570 tiny writes (multi-second). 8KB keeps the transient buffers
|
|
||||||
// small while cutting the write count 8x.
|
|
||||||
streamed = epub->readItemContentsToStream(localPath, tmpHtml, 8192);
|
|
||||||
fileSize = tmpHtml.size();
|
|
||||||
// Explicitly close() file before calling Storage.remove()
|
|
||||||
tmpHtml.close();
|
|
||||||
|
|
||||||
// If streaming failed, remove the incomplete file immediately
|
|
||||||
if (!streamed && Storage.exists(tmpHtmlPath.c_str())) {
|
|
||||||
Storage.remove(tmpHtmlPath.c_str());
|
|
||||||
LOG_DBG("SCT", "Removed incomplete temp file after failed attempt");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!streamed) {
|
// Remove any incomplete file from previous attempt before retrying
|
||||||
LOG_ERR("SCT", "Failed to stream item contents to temp file after retries");
|
if (Storage.exists(tmpHtmlPath.c_str())) {
|
||||||
return false;
|
Storage.remove(tmpHtmlPath.c_str());
|
||||||
}
|
}
|
||||||
|
|
||||||
LOG_DBG("SCT", "Streamed temp HTML to %s (%d bytes)", tmpHtmlPath.c_str(), fileSize);
|
HalFile tmpHtml;
|
||||||
|
if (!Storage.openFileForWrite("SCT", tmpHtmlPath, tmpHtml)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
success = epub->readItemContentsToStream(localPath, tmpHtml, 1024);
|
||||||
|
fileSize = tmpHtml.size();
|
||||||
|
// Explicitly close() file before calling Storage.remove()
|
||||||
|
tmpHtml.close();
|
||||||
|
|
||||||
// Promote to the persistent HTML cache immediately -- the inflate is complete and the bytes are
|
// If streaming failed, remove the incomplete file immediately
|
||||||
// valid regardless of whether the layout build finishes, so reopening (even a window-only spine
|
if (!success && Storage.exists(tmpHtmlPath.c_str())) {
|
||||||
// that never finalizes its .bin) skips re-inflation. If the rename fails we just parse the temp.
|
Storage.remove(tmpHtmlPath.c_str());
|
||||||
if (Storage.rename(tmpHtmlPath.c_str(), htmlPath.c_str())) {
|
LOG_DBG("SCT", "Removed incomplete temp file after failed attempt");
|
||||||
htmlCached = true;
|
|
||||||
} else {
|
|
||||||
LOG_DBG("SCT", "Failed to promote HTML cache; parsing from temp");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Storage.openFileForWrite("SCT", binTmpPath(), file)) {
|
if (!success) {
|
||||||
if (!reusedHtml) Storage.remove(tmpHtmlPath.c_str());
|
LOG_ERR("SCT", "Failed to stream item contents to temp file after retries");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
LOG_DBG("SCT", "Streamed temp HTML to %s (%d bytes)", tmpHtmlPath.c_str(), fileSize);
|
||||||
|
|
||||||
|
if (!Storage.openFileForWrite("SCT", filePath, file)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// Header is written with the incomplete-version sentinel; finalizeBuild() commits it.
|
|
||||||
writeSectionFileHeader(fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth,
|
writeSectionFileHeader(fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth,
|
||||||
viewportHeight, hyphenationEnabled, embeddedStyle, imageRendering, focusReadingEnabled);
|
viewportHeight, hyphenationEnabled, embeddedStyle, imageRendering, focusReadingEnabled);
|
||||||
|
std::vector<PageLutEntry> lut = {};
|
||||||
auto ctx = makeUniqueNoThrow<BuildContext>();
|
|
||||||
if (!ctx) {
|
|
||||||
LOG_ERR("SCT", "OOM: BuildContext");
|
|
||||||
file.close();
|
|
||||||
Storage.remove(binTmpPath().c_str());
|
|
||||||
if (!reusedHtml) Storage.remove(tmpHtmlPath.c_str());
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// htmlCached == "htmlPath is the live cache" (reused, or just promoted). finalizeBuild/abandonBuild
|
|
||||||
// then leave the cached HTML alone; only an un-promoted temp (rename failed) is theirs to clean up.
|
|
||||||
ctx->reusedHtml = htmlCached;
|
|
||||||
ctx->htmlPath = htmlPath;
|
|
||||||
ctx->tmpHtmlPath = tmpHtmlPath;
|
|
||||||
ctx->parsePath = htmlCached ? htmlPath : tmpHtmlPath;
|
|
||||||
|
|
||||||
// Derive the content base directory and image cache path prefix for the parser
|
// Derive the content base directory and image cache path prefix for the parser
|
||||||
const size_t lastSlash = localPath.find_last_of('/');
|
size_t lastSlash = localPath.find_last_of('/');
|
||||||
ctx->contentBase = (lastSlash != std::string::npos) ? localPath.substr(0, lastSlash + 1) : "";
|
std::string contentBase = (lastSlash != std::string::npos) ? localPath.substr(0, lastSlash + 1) : "";
|
||||||
ctx->imageBasePath = epub->getCachePath() + "/img_" + std::to_string(spineIndex) + "_";
|
std::string imageBasePath = epub->getCachePath() + "/img_" + std::to_string(spineIndex) + "_";
|
||||||
|
|
||||||
|
CssParser* cssParser = nullptr;
|
||||||
if (embeddedStyle) {
|
if (embeddedStyle) {
|
||||||
ctx->cssParser = epub->getCssParser();
|
cssParser = epub->getCssParser();
|
||||||
if (ctx->cssParser && !ctx->cssParser->loadFromCache()) {
|
if (cssParser) {
|
||||||
LOG_ERR("SCT", "Failed to load CSS from cache");
|
if (!cssParser->loadFromCache()) {
|
||||||
|
LOG_ERR("SCT", "Failed to load CSS from cache");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,367 +235,104 @@ bool Section::startBuild(const int fontId, const float lineCompression, const bo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The parser stores the path/contentBase/imageBasePath by reference, so they must
|
ChapterHtmlSlimParser visitor(
|
||||||
// live in the BuildContext (which outlives the parser). The page-complete callback
|
epub, tmpHtmlPath, renderer, fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth,
|
||||||
// captures the BuildContext pointer to append to its in-RAM LUT; build_ owns the
|
viewportHeight, hyphenationEnabled, focusReadingEnabled,
|
||||||
// context for the parser's whole lifetime.
|
[this, &lut](std::unique_ptr<Page> page, const uint16_t paragraphIndex, const uint16_t listItemIndex) {
|
||||||
BuildContext* ctxPtr = ctx.get();
|
lut.push_back({this->onPageComplete(std::move(page)), paragraphIndex, listItemIndex});
|
||||||
ctx->parser = makeUniqueNoThrow<ChapterHtmlSlimParser>(
|
|
||||||
epub, ctxPtr->parsePath, renderer, fontId, lineCompression, extraParagraphSpacing, paragraphAlignment,
|
|
||||||
viewportWidth, viewportHeight, hyphenationEnabled, focusReadingEnabled,
|
|
||||||
[this, ctxPtr](std::unique_ptr<Page> page, const uint16_t paragraphIndex, const uint16_t listItemIndex) {
|
|
||||||
ctxPtr->lut.push_back({this->onPageComplete(std::move(page)), paragraphIndex, listItemIndex});
|
|
||||||
},
|
},
|
||||||
embeddedStyle, ctxPtr->contentBase, ctxPtr->imageBasePath, imageRendering, std::move(tocAnchors), popupFn,
|
embeddedStyle, contentBase, imageBasePath, imageRendering, std::move(tocAnchors), popupFn, cssParser);
|
||||||
ctxPtr->cssParser);
|
|
||||||
if (!ctx->parser) {
|
|
||||||
LOG_ERR("SCT", "OOM: ChapterHtmlSlimParser");
|
|
||||||
if (ctx->cssParser) ctx->cssParser->clear();
|
|
||||||
file.close();
|
|
||||||
Storage.remove(binTmpPath().c_str());
|
|
||||||
if (!reusedHtml) Storage.remove(tmpHtmlPath.c_str());
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
Hyphenator::setPreferredLanguage(epub->getLanguage());
|
Hyphenator::setPreferredLanguage(epub->getLanguage());
|
||||||
build_ = std::move(ctx);
|
success = visitor.parseAndBuildPages();
|
||||||
|
|
||||||
if (!build_->parser->beginParse()) {
|
Storage.remove(tmpHtmlPath.c_str());
|
||||||
LOG_ERR("SCT", "Failed to begin parse");
|
if (!success) {
|
||||||
abandonBuild();
|
LOG_ERR("SCT", "Failed to parse XML and build pages");
|
||||||
return false;
|
// Explicitly close() file before calling Storage.remove()
|
||||||
}
|
|
||||||
build_->totalBytes = build_->parser->parseTotalBytes();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool Section::buildSomeMore(const int maxPages) {
|
|
||||||
if (!build_ || !build_->parser) {
|
|
||||||
LOG_ERR("SCT", "buildSomeMore with no active build");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// Pace on pages laid out by THIS build, not pageCount: during a rebuild over a partial,
|
|
||||||
// pageCount stays pinned at the partial's watermark until the build passes it, which
|
|
||||||
// would otherwise turn one "small" chunk into a blocking rebuild of the whole watermark.
|
|
||||||
const int startCount = builtPageCount_;
|
|
||||||
for (;;) {
|
|
||||||
const auto status = build_->parser->parseStep();
|
|
||||||
if (status == ChapterHtmlSlimParser::ParseStatus::Error) {
|
|
||||||
LOG_ERR("SCT", "Parse error during incremental build");
|
|
||||||
abandonBuild();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (status == ChapterHtmlSlimParser::ParseStatus::Done) {
|
|
||||||
return finalizeBuild();
|
|
||||||
}
|
|
||||||
// ParseStatus::More: yield once we've laid out the requested number of pages.
|
|
||||||
if (maxPages > 0 && (builtPageCount_ - startCount) >= maxPages) {
|
|
||||||
build_->bytesConsumed = build_->parser->parseBytesConsumed();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bool Section::hasHtmlCache() const {
|
|
||||||
const std::string htmlPath = epub->getCachePath() + "/html/" + std::to_string(spineIndex) + ".html";
|
|
||||||
return Storage.exists(htmlPath.c_str());
|
|
||||||
}
|
|
||||||
|
|
||||||
std::optional<uint16_t> Section::findAnchorDuringBuild(const std::string& anchor) const {
|
|
||||||
if (!build_ || !build_->parser) return std::nullopt;
|
|
||||||
for (const auto& [key, page] : build_->parser->getAnchors()) {
|
|
||||||
if (key == anchor) return page;
|
|
||||||
}
|
|
||||||
return std::nullopt;
|
|
||||||
}
|
|
||||||
|
|
||||||
std::optional<uint16_t> Section::findAnchor(const std::string& anchor) const {
|
|
||||||
if (const auto page = findAnchorDuringBuild(anchor)) {
|
|
||||||
return page;
|
|
||||||
}
|
|
||||||
// Fall back to the on-disk anchor map: a finalized section, or a partial whose map
|
|
||||||
// covers everything up to its watermark (nullopt past it -- build further and retry).
|
|
||||||
return getPageForAnchor(anchor);
|
|
||||||
}
|
|
||||||
|
|
||||||
uint16_t Section::estimatedTotalPages() const {
|
|
||||||
// Extrapolation from a suspended session's watermark trailer. A static snapshot, so no EMA
|
|
||||||
// damping is needed. Also the best guess while a rebuild is running but hasn't laid out
|
|
||||||
// enough pages yet to extrapolate from its own progress.
|
|
||||||
const auto partialEstimate = [this]() -> uint16_t {
|
|
||||||
if (!partial_ || partialBytesConsumed_ == 0 || partialTotalBytes_ <= partialBytesConsumed_) {
|
|
||||||
return pageCount;
|
|
||||||
}
|
|
||||||
const uint64_t est = static_cast<uint64_t>(partialPageCount_) * partialTotalBytes_ / partialBytesConsumed_;
|
|
||||||
if (est <= pageCount) return pageCount;
|
|
||||||
return est > 60000 ? 60000 : static_cast<uint16_t>(est);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!build_) {
|
|
||||||
return partial_ ? partialEstimate() : pageCount; // partial -> extrapolate, finalized -> exact
|
|
||||||
}
|
|
||||||
const uint32_t consumed = build_->bytesConsumed;
|
|
||||||
const uint32_t total = build_->totalBytes;
|
|
||||||
if (builtPageCount_ == 0 || consumed == 0 || total <= consumed) return partialEstimate();
|
|
||||||
|
|
||||||
// Raw extrapolation: scale the pages built so far by the fraction of HTML still unparsed. This
|
|
||||||
// re-derives from a growing, non-uniform sample, so it jitters up and down as the build crosses
|
|
||||||
// dense vs sparse regions of the chapter.
|
|
||||||
const uint64_t raw = static_cast<uint64_t>(builtPageCount_) * total / consumed;
|
|
||||||
|
|
||||||
// Damp that jitter with an exponential moving average. Step it once per build advance (keyed on
|
|
||||||
// bytesConsumed) rather than per status-bar redraw, so the smoothing rate doesn't depend on how
|
|
||||||
// often we repaint. As the build nears the end, consumed -> total and raw -> the built count, so
|
|
||||||
// the average settles onto the true count (and finalizeBuild then returns the exact pageCount).
|
|
||||||
constexpr float ALPHA = 0.25f; // weight of each new sample; lower = steadier but slower to settle
|
|
||||||
if (build_->smoothedEstimate <= 0) {
|
|
||||||
build_->smoothedEstimate = static_cast<float>(raw); // seed on the first estimate
|
|
||||||
} else if (consumed != build_->smoothedAtConsumed) {
|
|
||||||
build_->smoothedEstimate += ALPHA * (static_cast<float>(raw) - build_->smoothedEstimate);
|
|
||||||
}
|
|
||||||
build_->smoothedAtConsumed = consumed;
|
|
||||||
|
|
||||||
const uint64_t est = static_cast<uint64_t>(build_->smoothedEstimate + 0.5f);
|
|
||||||
if (est <= pageCount) return pageCount; // never fewer than the pages already available
|
|
||||||
return est > 60000 ? 60000 : static_cast<uint16_t>(est);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write the LUTs and anchor map into the open tmp .bin, patch the header with the built
|
|
||||||
// page count and table offsets, stamp `version` as the commit point, then swap the tmp
|
|
||||||
// file over filePath. For SECTION_FILE_PARTIAL_VERSION a watermark trailer
|
|
||||||
// (bytesConsumed, totalBytes) is appended after the li LUT so a later open can estimate
|
|
||||||
// the total page count. The parser must still be alive (anchors are read from it).
|
|
||||||
// On failure the tmp is removed and any pre-existing file at filePath is left intact.
|
|
||||||
bool Section::commitBuildFile(const uint8_t version, const uint32_t bytesConsumed, const uint32_t totalBytes) {
|
|
||||||
const bool asPartial = (version == SECTION_FILE_PARTIAL_VERSION);
|
|
||||||
|
|
||||||
const auto failCommit = [this]() {
|
|
||||||
// Explicit close() required before remove (member variable, O_RDWR handle).
|
|
||||||
file.close();
|
file.close();
|
||||||
Storage.remove(binTmpPath().c_str());
|
Storage.remove(filePath.c_str());
|
||||||
|
if (cssParser) {
|
||||||
|
cssParser->clear();
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
};
|
}
|
||||||
|
|
||||||
const uint32_t lutOffset = file.position();
|
const uint32_t lutOffset = file.position();
|
||||||
for (const auto& entry : build_->lut) {
|
bool hasFailedLutRecords = false;
|
||||||
|
// Write LUT
|
||||||
|
for (const auto& entry : lut) {
|
||||||
if (entry.fileOffset == 0) {
|
if (entry.fileOffset == 0) {
|
||||||
LOG_ERR("SCT", "Failed to write LUT due to invalid page positions");
|
hasFailedLutRecords = true;
|
||||||
return failCommit();
|
break;
|
||||||
}
|
}
|
||||||
serialization::writePod(file, entry.fileOffset);
|
serialization::writePod(file, entry.fileOffset);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write anchor-to-page map for fragment navigation (e.g. footnote targets). For a
|
if (hasFailedLutRecords) {
|
||||||
// partial, skip anchors that landed on the incomplete trailing page the suspend drops.
|
LOG_ERR("SCT", "Failed to write LUT due to invalid page positions");
|
||||||
const uint32_t anchorMapOffset = file.position();
|
// Explicitly close() file before calling Storage.remove()
|
||||||
const auto& anchors = build_->parser->getAnchors();
|
file.close();
|
||||||
uint16_t anchorCount = 0;
|
Storage.remove(filePath.c_str());
|
||||||
for (const auto& [anchor, page] : anchors) {
|
return false;
|
||||||
if (!asPartial || page < builtPageCount_) anchorCount++;
|
|
||||||
}
|
}
|
||||||
serialization::writePod(file, anchorCount);
|
|
||||||
|
// Write anchor-to-page map for fragment navigation (e.g. footnote targets)
|
||||||
|
const uint32_t anchorMapOffset = file.position();
|
||||||
|
const auto& anchors = visitor.getAnchors();
|
||||||
|
serialization::writePod(file, static_cast<uint16_t>(anchors.size()));
|
||||||
for (const auto& [anchor, page] : anchors) {
|
for (const auto& [anchor, page] : anchors) {
|
||||||
if (asPartial && page >= builtPageCount_) continue;
|
|
||||||
serialization::writeString(file, anchor);
|
serialization::writeString(file, anchor);
|
||||||
serialization::writePod(file, page);
|
serialization::writePod(file, page);
|
||||||
}
|
}
|
||||||
|
|
||||||
const uint32_t paragraphLutOffset = file.position();
|
const uint32_t paragraphLutOffset = file.position();
|
||||||
serialization::writePod(file, static_cast<uint16_t>(build_->lut.size()));
|
serialization::writePod(file, static_cast<uint16_t>(lut.size()));
|
||||||
for (const auto& entry : build_->lut) {
|
for (const auto& entry : lut) {
|
||||||
serialization::writePod(file, entry.paragraphIndex);
|
serialization::writePod(file, entry.paragraphIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
const uint32_t liLutFileOffset = static_cast<uint32_t>(file.position());
|
const uint32_t liLutFileOffset = static_cast<uint32_t>(file.position());
|
||||||
for (const auto& entry : build_->lut) {
|
for (const auto& entry : lut) {
|
||||||
serialization::writePod(file, entry.listItemIndex);
|
serialization::writePod(file, entry.listItemIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (asPartial) {
|
// Patch header with final pageCount, lutOffset, anchorMapOffset, paragraphLutOffset, and liLutOffset
|
||||||
// Watermark trailer, located on load as liLutOffset + pageCount * sizeof(uint16_t).
|
file.seek(HEADER_SIZE - sizeof(uint32_t) * 4 - sizeof(pageCount));
|
||||||
serialization::writePod(file, bytesConsumed);
|
serialization::writePod(file, pageCount);
|
||||||
serialization::writePod(file, totalBytes);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Patch header with the built page count and section offsets...
|
|
||||||
file.seek(HEADER_SIZE - sizeof(uint32_t) * 4 - sizeof(builtPageCount_));
|
|
||||||
serialization::writePod(file, builtPageCount_);
|
|
||||||
serialization::writePod(file, lutOffset);
|
serialization::writePod(file, lutOffset);
|
||||||
serialization::writePod(file, anchorMapOffset);
|
serialization::writePod(file, anchorMapOffset);
|
||||||
serialization::writePod(file, paragraphLutOffset);
|
serialization::writePod(file, paragraphLutOffset);
|
||||||
serialization::writePod(file, liLutFileOffset);
|
serialization::writePod(file, liLutFileOffset);
|
||||||
// ...then commit by overwriting the sentinel version with the real one. Writing the
|
|
||||||
// version last makes it the commit point: a crash before here leaves version 0.
|
|
||||||
file.seek(0);
|
|
||||||
serialization::writePod(file, version);
|
|
||||||
// Explicit close() required: member variable persists beyond function scope
|
// Explicit close() required: member variable persists beyond function scope
|
||||||
file.close();
|
file.close();
|
||||||
|
if (cssParser) {
|
||||||
// Swap into place. A crash between remove and rename loses the old file but keeps a
|
cssParser->clear();
|
||||||
// fully-committed tmp; the next build just removes it and rebuilds.
|
|
||||||
if (Storage.exists(filePath.c_str())) {
|
|
||||||
Storage.remove(filePath.c_str());
|
|
||||||
}
|
|
||||||
if (!Storage.rename(binTmpPath().c_str(), filePath.c_str())) {
|
|
||||||
LOG_ERR("SCT", "Failed to move built section into place");
|
|
||||||
Storage.remove(binTmpPath().c_str());
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool Section::finalizeBuild() {
|
std::unique_ptr<Page> Section::loadPageFromSectionFile() {
|
||||||
// Flush the trailing page (emits the last page via the completePageFn into the LUT).
|
if (!Storage.openFileForRead("SCT", filePath, file)) {
|
||||||
build_->parser->finishParse();
|
|
||||||
|
|
||||||
if (!build_->reusedHtml) {
|
|
||||||
// Parse succeeded: promote the freshly unzipped HTML to the persistent cache so future
|
|
||||||
// rebuilds skip zip inflation. If promotion fails, drop the temp -- the build still succeeded.
|
|
||||||
if (!Storage.rename(build_->tmpHtmlPath.c_str(), build_->htmlPath.c_str())) {
|
|
||||||
LOG_DBG("SCT", "Failed to promote HTML cache, removing temp");
|
|
||||||
Storage.remove(build_->tmpHtmlPath.c_str());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const bool committed = commitBuildFile(SECTION_FILE_VERSION, 0, 0);
|
|
||||||
if (build_->cssParser) build_->cssParser->clear();
|
|
||||||
build_.reset();
|
|
||||||
if (!committed) {
|
|
||||||
// commitBuildFile removed filePath before the failed swap, so nothing valid remains.
|
|
||||||
partial_ = false;
|
|
||||||
partialPageCount_ = 0;
|
|
||||||
pageCount = 0;
|
|
||||||
builtPageCount_ = 0;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
buildComplete_ = true;
|
|
||||||
partial_ = false;
|
|
||||||
partialPageCount_ = 0;
|
|
||||||
pageCount = builtPageCount_;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
void Section::suspendBuild() {
|
|
||||||
if (!build_) return;
|
|
||||||
|
|
||||||
// Only worth persisting if this build produced pages a pre-existing partial doesn't
|
|
||||||
// already cover; otherwise keep the older (bigger) partial and just drop the tmp.
|
|
||||||
const bool worthKeeping = builtPageCount_ > 0 && (!partial_ || builtPageCount_ > partialPageCount_);
|
|
||||||
|
|
||||||
bool committed = false;
|
|
||||||
if (worthKeeping) {
|
|
||||||
// Capture the parse watermark and commit before tearing the parser down (the anchor
|
|
||||||
// map is read from it). The incomplete trailing page is intentionally not flushed:
|
|
||||||
// only fully laid-out pages are persisted, and the rebuild re-derives the rest.
|
|
||||||
const uint32_t consumed = static_cast<uint32_t>(build_->parser->parseBytesConsumed());
|
|
||||||
committed = commitBuildFile(SECTION_FILE_PARTIAL_VERSION, consumed, build_->totalBytes);
|
|
||||||
if (committed) {
|
|
||||||
partial_ = true;
|
|
||||||
partialPageCount_ = builtPageCount_;
|
|
||||||
partialBytesConsumed_ = consumed;
|
|
||||||
partialTotalBytes_ = build_->totalBytes;
|
|
||||||
LOG_INF("SCT", "Suspended build: %u pages persisted", builtPageCount_);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (build_->parser) build_->parser->abortParse();
|
|
||||||
if (build_->cssParser) build_->cssParser->clear();
|
|
||||||
if (!committed && file) {
|
|
||||||
// Explicit close() required before remove (member variable, O_RDWR handle).
|
|
||||||
file.close();
|
|
||||||
Storage.remove(binTmpPath().c_str());
|
|
||||||
}
|
|
||||||
if (!build_->reusedHtml && Storage.exists(build_->tmpHtmlPath.c_str())) {
|
|
||||||
Storage.remove(build_->tmpHtmlPath.c_str());
|
|
||||||
}
|
|
||||||
build_.reset();
|
|
||||||
buildComplete_ = false;
|
|
||||||
pageCount = partial_ ? partialPageCount_ : 0;
|
|
||||||
builtPageCount_ = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
void Section::abandonBuild() {
|
|
||||||
if (!build_) return;
|
|
||||||
if (build_->parser) build_->parser->abortParse();
|
|
||||||
if (build_->cssParser) build_->cssParser->clear();
|
|
||||||
if (file) {
|
|
||||||
// Explicit close() required before remove (member variable, O_RDWR handle).
|
|
||||||
file.close();
|
|
||||||
Storage.remove(binTmpPath().c_str());
|
|
||||||
}
|
|
||||||
// A parse error would recur against the same HTML, so drop any partial too -- resuming
|
|
||||||
// from it would just re-enter the failing build every open.
|
|
||||||
if (Storage.exists(filePath.c_str())) {
|
|
||||||
Storage.remove(filePath.c_str());
|
|
||||||
}
|
|
||||||
if (!build_->reusedHtml && Storage.exists(build_->tmpHtmlPath.c_str())) {
|
|
||||||
Storage.remove(build_->tmpHtmlPath.c_str());
|
|
||||||
}
|
|
||||||
build_.reset();
|
|
||||||
buildComplete_ = false;
|
|
||||||
partial_ = false;
|
|
||||||
partialPageCount_ = 0;
|
|
||||||
pageCount = 0;
|
|
||||||
builtPageCount_ = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
std::unique_ptr<Page> Section::loadPageDuringBuild(const int page) {
|
|
||||||
if (!build_ || page < 0 || page >= static_cast<int>(build_->lut.size()) || !file) {
|
|
||||||
return nullptr;
|
|
||||||
}
|
|
||||||
const uint32_t pos = build_->lut[page].fileOffset;
|
|
||||||
if (pos == 0) {
|
|
||||||
return nullptr;
|
|
||||||
}
|
|
||||||
// The .bin is open O_RDWR for the build. Read the already-written page, then restore
|
|
||||||
// the write cursor so the next onPageComplete keeps appending where it left off.
|
|
||||||
const uint32_t writePos = file.position();
|
|
||||||
file.seek(pos);
|
|
||||||
auto p = Page::deserialize(file);
|
|
||||||
file.seek(writePos);
|
|
||||||
return p;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read a page from the committed file at filePath (finalized section or partial from a
|
|
||||||
// previous session). Uses a local handle so it is safe while a build holds the member
|
|
||||||
// `file` open on the tmp .bin.
|
|
||||||
std::unique_ptr<Page> Section::loadPageAt(const int page) const {
|
|
||||||
HalFile f;
|
|
||||||
if (!Storage.openFileForRead("SCT", filePath, f)) {
|
|
||||||
return nullptr;
|
return nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
f.seek(HEADER_SIZE - sizeof(uint32_t) * 4);
|
file.seek(HEADER_SIZE - sizeof(uint32_t) * 4);
|
||||||
uint32_t lutOffset;
|
uint32_t lutOffset;
|
||||||
serialization::readPod(f, lutOffset);
|
serialization::readPod(file, lutOffset);
|
||||||
f.seek(lutOffset + sizeof(uint32_t) * page);
|
file.seek(lutOffset + sizeof(uint32_t) * currentPage);
|
||||||
uint32_t pagePos;
|
uint32_t pagePos;
|
||||||
serialization::readPod(f, pagePos);
|
serialization::readPod(file, pagePos);
|
||||||
f.seek(pagePos);
|
file.seek(pagePos);
|
||||||
|
|
||||||
return Page::deserialize(f);
|
auto page = Page::deserialize(file);
|
||||||
// No f.close() needed -- DESTRUCTOR_CLOSES_FILE=1 handles it at scope exit
|
// Explicit close() required: member variable persists beyond function scope
|
||||||
}
|
file.close();
|
||||||
|
return page;
|
||||||
std::unique_ptr<Page> Section::loadPage(const int page) {
|
|
||||||
if (page < 0) {
|
|
||||||
return nullptr;
|
|
||||||
}
|
|
||||||
if (build_ && page < static_cast<int>(build_->lut.size())) {
|
|
||||||
return loadPageDuringBuild(page);
|
|
||||||
}
|
|
||||||
// Not (yet) in the active build: serve from the file on disk -- a finalized section,
|
|
||||||
// or a partial from a previous session whose pages the rebuild hasn't reached again.
|
|
||||||
const int onDisk = partial_ ? partialPageCount_ : (build_ ? 0 : pageCount);
|
|
||||||
if (page >= onDisk) {
|
|
||||||
return nullptr;
|
|
||||||
}
|
|
||||||
return loadPageAt(page);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string Section::getTextFromSectionFile() {
|
std::string Section::getTextFromSectionFile() {
|
||||||
std::string fullText;
|
std::string fullText;
|
||||||
auto p = loadPage(currentPage);
|
auto p = this->loadPageFromSectionFile();
|
||||||
if (p) {
|
if (p) {
|
||||||
for (const auto& el : p->elements) {
|
for (const auto& el : p->elements) {
|
||||||
if (el->getTag() == TAG_PageLine) {
|
if (el->getTag() == TAG_PageLine) {
|
||||||
@@ -756,15 +361,6 @@ std::optional<uint16_t> Section::getCachedPageCount() const {
|
|||||||
return std::nullopt;
|
return std::nullopt;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only a finalized section's count is the chapter total; a partial's count is just the
|
|
||||||
// suspended build's watermark, which would skew progress mapping. Callers fall back to
|
|
||||||
// their own estimates.
|
|
||||||
uint8_t version;
|
|
||||||
serialization::readPod(f, version);
|
|
||||||
if (version != SECTION_FILE_VERSION) {
|
|
||||||
return std::nullopt;
|
|
||||||
}
|
|
||||||
|
|
||||||
f.seek(HEADER_SIZE - sizeof(uint32_t) * 4 - sizeof(uint16_t));
|
f.seek(HEADER_SIZE - sizeof(uint32_t) * 4 - sizeof(uint16_t));
|
||||||
uint16_t count;
|
uint16_t count;
|
||||||
serialization::readPod(f, count);
|
serialization::readPod(f, count);
|
||||||
|
|||||||
+7
-105
@@ -3,14 +3,11 @@
|
|||||||
#include <memory>
|
#include <memory>
|
||||||
#include <optional>
|
#include <optional>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
|
||||||
|
|
||||||
#include "Epub.h"
|
#include "Epub.h"
|
||||||
|
|
||||||
class Page;
|
class Page;
|
||||||
class GfxRenderer;
|
class GfxRenderer;
|
||||||
class ChapterHtmlSlimParser;
|
|
||||||
class CssParser;
|
|
||||||
|
|
||||||
class Section {
|
class Section {
|
||||||
std::shared_ptr<Epub> epub;
|
std::shared_ptr<Epub> epub;
|
||||||
@@ -24,67 +21,16 @@ class Section {
|
|||||||
bool embeddedStyle, uint8_t imageRendering, bool focusReadingEnabled);
|
bool embeddedStyle, uint8_t imageRendering, bool focusReadingEnabled);
|
||||||
uint32_t onPageComplete(std::unique_ptr<Page> page);
|
uint32_t onPageComplete(std::unique_ptr<Page> page);
|
||||||
|
|
||||||
// Page-offset table entry, kept in RAM while an incremental build is running so
|
|
||||||
// already-built pages can be located in the partially-written .bin.
|
|
||||||
struct PageLutEntry {
|
|
||||||
uint32_t fileOffset;
|
|
||||||
uint16_t paragraphIndex;
|
|
||||||
uint16_t listItemIndex;
|
|
||||||
};
|
|
||||||
// Held only while an incremental build is in progress (see startBuild). Carries the
|
|
||||||
// live parser plus the strings it references (the parser stores them by reference)
|
|
||||||
// and the in-RAM page-offset table.
|
|
||||||
struct BuildContext {
|
|
||||||
std::unique_ptr<ChapterHtmlSlimParser> parser;
|
|
||||||
std::vector<PageLutEntry> lut;
|
|
||||||
std::string parsePath;
|
|
||||||
std::string contentBase;
|
|
||||||
std::string imageBasePath;
|
|
||||||
std::string htmlPath;
|
|
||||||
std::string tmpHtmlPath;
|
|
||||||
bool reusedHtml = false;
|
|
||||||
CssParser* cssParser = nullptr;
|
|
||||||
// HTML byte progress, for estimating the section's total page count while it's still building.
|
|
||||||
uint32_t bytesConsumed = 0;
|
|
||||||
uint32_t totalBytes = 0;
|
|
||||||
// Exponentially-smoothed page-count estimate (0 = not yet seeded) and the bytesConsumed at its
|
|
||||||
// last update. The raw byte-ratio estimate jitters as the build crosses dense/sparse regions;
|
|
||||||
// the EMA is stepped once per build advance (not per redraw) to damp that wobble.
|
|
||||||
float smoothedEstimate = 0;
|
|
||||||
uint32_t smoothedAtConsumed = 0;
|
|
||||||
};
|
|
||||||
std::unique_ptr<BuildContext> build_;
|
|
||||||
bool buildComplete_ = false;
|
|
||||||
// Pages laid out by the active build (== build_->lut.size()). Distinct from pageCount,
|
|
||||||
// which is the pages *available to read* and also counts a loaded partial file's pages.
|
|
||||||
uint16_t builtPageCount_ = 0;
|
|
||||||
// A partial section file (suspended build from a previous session) is loaded at filePath.
|
|
||||||
// Its pages 0..partialPageCount_-1 are readable while a rebuild extends past them.
|
|
||||||
bool partial_ = false;
|
|
||||||
uint16_t partialPageCount_ = 0;
|
|
||||||
// Parse watermark from the partial's trailer, for estimating the total page count.
|
|
||||||
uint32_t partialBytesConsumed_ = 0;
|
|
||||||
uint32_t partialTotalBytes_ = 0;
|
|
||||||
bool finalizeBuild();
|
|
||||||
// Write the LUTs/anchor map (and, for a partial, the watermark trailer), patch the
|
|
||||||
// header, stamp the version byte, and swap the tmp .bin over filePath.
|
|
||||||
bool commitBuildFile(uint8_t version, uint32_t bytesConsumed, uint32_t totalBytes);
|
|
||||||
// Builds write here and are swapped over filePath only on commit, so a prior
|
|
||||||
// partial/finalized file stays readable while a rebuild is in progress.
|
|
||||||
std::string binTmpPath() const { return filePath + ".part"; }
|
|
||||||
std::unique_ptr<Page> loadPageAt(int page) const;
|
|
||||||
// Read a page already laid out by the in-progress build (page < build LUT size), from
|
|
||||||
// the partially-written tmp .bin without disturbing the build's write cursor.
|
|
||||||
std::unique_ptr<Page> loadPageDuringBuild(int page);
|
|
||||||
|
|
||||||
public:
|
public:
|
||||||
uint16_t pageCount = 0;
|
uint16_t pageCount = 0;
|
||||||
int currentPage = 0;
|
int currentPage = 0;
|
||||||
|
|
||||||
// Constructor and destructor are out-of-line: BuildContext holds a unique_ptr to the
|
explicit Section(const std::shared_ptr<Epub>& epub, const int spineIndex, GfxRenderer& renderer)
|
||||||
// forward-declared ChapterHtmlSlimParser, whose full definition is only visible in the .cpp.
|
: epub(epub),
|
||||||
explicit Section(const std::shared_ptr<Epub>& epub, int spineIndex, GfxRenderer& renderer);
|
spineIndex(spineIndex),
|
||||||
~Section();
|
renderer(renderer),
|
||||||
|
filePath(epub->getCachePath() + "/sections/" + std::to_string(spineIndex) + ".bin") {}
|
||||||
|
~Section() = default;
|
||||||
bool loadSectionFile(int fontId, float lineCompression, bool extraParagraphSpacing, uint8_t paragraphAlignment,
|
bool loadSectionFile(int fontId, float lineCompression, bool extraParagraphSpacing, uint8_t paragraphAlignment,
|
||||||
uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled, bool embeddedStyle,
|
uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled, bool embeddedStyle,
|
||||||
uint8_t imageRendering, bool focusReadingEnabled);
|
uint8_t imageRendering, bool focusReadingEnabled);
|
||||||
@@ -93,56 +39,12 @@ class Section {
|
|||||||
uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled, bool embeddedStyle,
|
uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled, bool embeddedStyle,
|
||||||
uint8_t imageRendering, bool focusReadingEnabled,
|
uint8_t imageRendering, bool focusReadingEnabled,
|
||||||
const std::function<void()>& popupFn = nullptr);
|
const std::function<void()>& popupFn = nullptr);
|
||||||
|
std::unique_ptr<Page> loadPageFromSectionFile();
|
||||||
// Incremental build: lay out the section a few pages at a time so a large chapter
|
|
||||||
// can show its first page immediately and keep the UI responsive while the rest
|
|
||||||
// builds. createSectionFile() above is the one-shot wrapper over these.
|
|
||||||
// if (!startBuild(...)) fail;
|
|
||||||
// each tick: buildSomeMore(N); render up to pageCount; when isBuildComplete() stop.
|
|
||||||
bool startBuild(int fontId, float lineCompression, bool extraParagraphSpacing, uint8_t paragraphAlignment,
|
|
||||||
uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled, bool embeddedStyle,
|
|
||||||
uint8_t imageRendering, bool focusReadingEnabled, const std::function<void()>& popupFn = nullptr);
|
|
||||||
// Lay out up to maxPages more pages (maxPages <= 0 = build to completion). Returns
|
|
||||||
// false on error (the build is abandoned). Sets isBuildComplete() when finished.
|
|
||||||
bool buildSomeMore(int maxPages);
|
|
||||||
bool isBuilding() const { return static_cast<bool>(build_); }
|
|
||||||
bool isBuildComplete() const { return buildComplete_; }
|
|
||||||
// Best-known total page count: the exact pageCount once finalized, or a smoothed byte-based
|
|
||||||
// estimate (pages so far scaled by totalBytes/bytesConsumed, damped by an EMA) while a giant spine
|
|
||||||
// is still building, so "page X of Y" / progress don't read off the small build watermark.
|
|
||||||
uint16_t estimatedTotalPages() const;
|
|
||||||
void abandonBuild();
|
|
||||||
// Persist an in-progress build as a partial section file (version sentinel + LUTs +
|
|
||||||
// watermark trailer) instead of discarding it, so the next open of this spine can show
|
|
||||||
// its pages instantly and only rebuild in the background. Called by the destructor, so
|
|
||||||
// any teardown path (exit, sleep, navigation) keeps the work already done. Keeps a
|
|
||||||
// pre-existing partial when it covers more pages than this build reached.
|
|
||||||
void suspendBuild();
|
|
||||||
// True when a partial file was loaded: pageCount is a watermark, not the chapter total.
|
|
||||||
bool isPartial() const { return partial_; }
|
|
||||||
|
|
||||||
// Unified page read: from the active build if it has reached the page, otherwise from
|
|
||||||
// the on-disk file (finalized section, or a partial the rebuild hasn't caught up to).
|
|
||||||
std::unique_ptr<Page> loadPage(int page);
|
|
||||||
|
|
||||||
std::string getTextFromSectionFile();
|
std::string getTextFromSectionFile();
|
||||||
|
|
||||||
// Resolve an anchor from the in-progress build first, then the on-disk anchor map
|
|
||||||
// (covers finalized sections and partials from a previous session).
|
|
||||||
std::optional<uint16_t> findAnchor(const std::string& anchor) const;
|
|
||||||
|
|
||||||
// True if this spine's unzipped HTML is already cached, so a build won't pay the (multi-second on a
|
|
||||||
// giant spine) zip inflation. Lets the reader skip the indexing popup on a fast reopen/rebuild.
|
|
||||||
bool hasHtmlCache() const;
|
|
||||||
|
|
||||||
// Look up the page number for an anchor id from the section cache file.
|
// Look up the page number for an anchor id from the section cache file.
|
||||||
std::optional<uint16_t> getPageForAnchor(const std::string& anchor) const;
|
std::optional<uint16_t> getPageForAnchor(const std::string& anchor) const;
|
||||||
|
|
||||||
// Look up an anchor among the pages built so far by the in-progress build, so an anchor jump
|
|
||||||
// (TOC / chapter select, usually the chapter top = page 0) can resolve without laying out the
|
|
||||||
// whole chapter. Returns nullopt if the anchor hasn't been reached yet (build more) or no build.
|
|
||||||
std::optional<uint16_t> findAnchorDuringBuild(const std::string& anchor) const;
|
|
||||||
|
|
||||||
// Get the page count from the section cache file without fully loading it.
|
// Get the page count from the section cache file without fully loading it.
|
||||||
std::optional<uint16_t> getCachedPageCount() const;
|
std::optional<uint16_t> getCachedPageCount() const;
|
||||||
|
|
||||||
|
|||||||
@@ -21,39 +21,6 @@ void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int
|
|||||||
|
|
||||||
const bool scanning = renderer.isFontCacheScanning();
|
const bool scanning = renderer.isFontCacheScanning();
|
||||||
const int ascender = renderer.getFontAscenderSize(fontId);
|
const int ascender = renderer.getFontAscenderSize(fontId);
|
||||||
|
|
||||||
struct DecorationLineTracker {
|
|
||||||
EpdFontFamily::Style style;
|
|
||||||
int yOffset;
|
|
||||||
int startX = -1;
|
|
||||||
int endX = -1;
|
|
||||||
int yPos = 0;
|
|
||||||
|
|
||||||
bool active() const { return startX != -1; }
|
|
||||||
void reset() {
|
|
||||||
startX = -1;
|
|
||||||
endX = -1;
|
|
||||||
yPos = 0;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
DecorationLineTracker decorationLines[] = {
|
|
||||||
{EpdFontFamily::UNDERLINE, ascender + 2},
|
|
||||||
{EpdFontFamily::STRIKETHROUGH, ascender * 4 / 5},
|
|
||||||
};
|
|
||||||
|
|
||||||
const auto flushDecoration = [&](DecorationLineTracker& line) {
|
|
||||||
if (line.active()) {
|
|
||||||
renderer.drawLine(line.startX, line.yPos, line.endX, line.yPos, 2, true);
|
|
||||||
line.reset();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const auto flushDecorations = [&]() {
|
|
||||||
for (auto& line : decorationLines) {
|
|
||||||
flushDecoration(line);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
for (size_t i = 0; i < words.size(); i++) {
|
for (size_t i = 0; i < words.size(); i++) {
|
||||||
const int wordX = wordXpos[i] + x;
|
const int wordX = wordXpos[i] + x;
|
||||||
const EpdFontFamily::Style currentStyle = wordStyles[i];
|
const EpdFontFamily::Style currentStyle = wordStyles[i];
|
||||||
@@ -92,51 +59,18 @@ void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int
|
|||||||
renderer.drawText(fontId, wordX, wordY, words[i].c_str(), true, currentStyle, baseDir);
|
renderer.drawText(fontId, wordX, wordY, words[i].c_str(), true, currentStyle, baseDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (scanning) {
|
if (!scanning && (currentStyle & EpdFontFamily::UNDERLINE) != 0) {
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (EpdFontFamily::hasTextDecoration(currentStyle)) {
|
|
||||||
const std::string& w = words[i];
|
const std::string& w = words[i];
|
||||||
int lineStartX = wordX;
|
int underlineWidth = renderer.getTextWidth(fontId, w.c_str(), currentStyle, baseDir);
|
||||||
int lineWidth = renderer.getTextWidth(fontId, w.c_str(), currentStyle, baseDir);
|
const int underlineY = wordY + ascender + 2;
|
||||||
|
|
||||||
if ((currentStyle & (EpdFontFamily::SUP | EpdFontFamily::SUB)) != 0) {
|
if ((currentStyle & (EpdFontFamily::SUP | EpdFontFamily::SUB)) != 0) {
|
||||||
lineWidth = (lineWidth + 1) / 2;
|
underlineWidth = (underlineWidth + 1) / 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Do not decorate the synthetic em-space used for paragraph indentation.
|
renderer.drawLine(wordX, underlineY, wordX + underlineWidth, underlineY, true);
|
||||||
if (w.size() >= 3 && static_cast<uint8_t>(w[0]) == 0xE2 && static_cast<uint8_t>(w[1]) == 0x80 &&
|
|
||||||
static_cast<uint8_t>(w[2]) == 0x83) {
|
|
||||||
const char* visibleText = w.c_str() + 3;
|
|
||||||
lineStartX += renderer.getTextAdvanceX(fontId, "\xe2\x80\x83", currentStyle);
|
|
||||||
lineWidth = renderer.getTextWidth(fontId, visibleText, currentStyle, baseDir);
|
|
||||||
if ((currentStyle & (EpdFontFamily::SUP | EpdFontFamily::SUB)) != 0) {
|
|
||||||
lineWidth = (lineWidth + 1) / 2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (auto& line : decorationLines) {
|
|
||||||
if ((currentStyle & line.style) == 0) {
|
|
||||||
flushDecoration(line);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const int lineY = wordY + line.yOffset;
|
|
||||||
if (line.active() && line.yPos != lineY) {
|
|
||||||
flushDecoration(line);
|
|
||||||
}
|
|
||||||
if (!line.active()) {
|
|
||||||
line.startX = lineStartX;
|
|
||||||
line.yPos = lineY;
|
|
||||||
}
|
|
||||||
line.endX = lineStartX + lineWidth;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
flushDecorations();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
flushDecorations();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bool TextBlock::serialize(HalFile& file) const {
|
bool TextBlock::serialize(HalFile& file) const {
|
||||||
|
|||||||
@@ -67,6 +67,13 @@ constexpr bool iequalsAscii(std::string_view value, std::string_view lowercaseKe
|
|||||||
[](char a, char b) { return asciiToLower(a) == b; });
|
[](char a, char b) { return asciiToLower(a) == b; });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Case-insensitive ASCII substring search. Only needed by text-decoration,
|
||||||
|
// which accepts multi-value strings like "underline solid red".
|
||||||
|
constexpr bool icontainsAscii(std::string_view value, std::string_view lowercaseKeyword) {
|
||||||
|
return std::search(value.begin(), value.end(), lowercaseKeyword.begin(), lowercaseKeyword.end(),
|
||||||
|
[](char a, char b) { return asciiToLower(a) == b; }) != value.end();
|
||||||
|
}
|
||||||
|
|
||||||
// Walk s and invoke fn(token) for each non-empty run between delimiters.
|
// Walk s and invoke fn(token) for each non-empty run between delimiters.
|
||||||
// Tokens are boundary-trimmed and yielded as string_views into s; no
|
// Tokens are boundary-trimmed and yielded as string_views into s; no
|
||||||
// allocation. Runs of consecutive delimiters coalesce — no empty tokens are
|
// allocation. Runs of consecutive delimiters coalesce — no empty tokens are
|
||||||
@@ -245,20 +252,11 @@ CssFontWeight CssParser::interpretFontWeight(std::string_view val) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
CssTextDecoration CssParser::interpretDecoration(std::string_view val) {
|
CssTextDecoration CssParser::interpretDecoration(std::string_view val) {
|
||||||
// text-decoration can have multiple space-separated values. Compare whole tokens
|
// text-decoration can have multiple space-separated values
|
||||||
// so malformed values like "notunderline" do not accidentally enable a line.
|
if (icontainsAscii(val, "underline")) {
|
||||||
CssTextDecoration result = CssTextDecoration::None;
|
return CssTextDecoration::Underline;
|
||||||
bool explicitNone = false;
|
}
|
||||||
forEachDelimitedToken(val, isCssWhitespace, [&](const std::string_view token) {
|
return CssTextDecoration::None;
|
||||||
if (iequalsAscii(token, "none")) {
|
|
||||||
explicitNone = true;
|
|
||||||
} else if (iequalsAscii(token, "underline")) {
|
|
||||||
result = result | CssTextDecoration::Underline;
|
|
||||||
} else if (iequalsAscii(token, "line-through")) {
|
|
||||||
result = result | CssTextDecoration::LineThrough;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return explicitNone ? CssTextDecoration::None : result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
CssLength CssParser::interpretLength(std::string_view val) {
|
CssLength CssParser::interpretLength(std::string_view val) {
|
||||||
@@ -870,7 +868,7 @@ bool CssParser::loadFromCache() {
|
|||||||
rulesBySelector_.clear();
|
rulesBySelector_.clear();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
style.textDecoration = static_cast<CssTextDecoration>(enumVal & CSS_TEXT_DECORATION_MASK);
|
style.textDecoration = static_cast<CssTextDecoration>(enumVal);
|
||||||
|
|
||||||
if (file.read(&enumVal, 1) != 1) {
|
if (file.read(&enumVal, 1) != 1) {
|
||||||
rulesBySelector_.clear();
|
rulesBySelector_.clear();
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
class CssParser {
|
class CssParser {
|
||||||
public:
|
public:
|
||||||
// Bump when CSS cache format or rules change; section caches are invalidated when this changes
|
// Bump when CSS cache format or rules change; section caches are invalidated when this changes
|
||||||
static constexpr uint8_t CSS_CACHE_VERSION = 7;
|
static constexpr uint8_t CSS_CACHE_VERSION = 6;
|
||||||
|
|
||||||
explicit CssParser(std::string cachePath) : cachePath(std::move(cachePath)) {}
|
explicit CssParser(std::string cachePath) : cachePath(std::move(cachePath)) {}
|
||||||
~CssParser() = default;
|
~CssParser() = default;
|
||||||
|
|||||||
@@ -52,19 +52,8 @@ enum class CssFontStyle : uint8_t { Normal = 0, Italic = 1 };
|
|||||||
// Font weight options - CSS supports 100-900, we simplify to normal/bold
|
// Font weight options - CSS supports 100-900, we simplify to normal/bold
|
||||||
enum class CssFontWeight : uint8_t { Normal = 0, Bold = 1 };
|
enum class CssFontWeight : uint8_t { Normal = 0, Bold = 1 };
|
||||||
|
|
||||||
// Text decoration options. Values are bit flags so CSS can combine multiple line decorations.
|
// Text decoration options
|
||||||
enum class CssTextDecoration : uint8_t { None = 0, Underline = 1, LineThrough = 2 };
|
enum class CssTextDecoration : uint8_t { None = 0, Underline = 1 };
|
||||||
|
|
||||||
constexpr CssTextDecoration operator|(const CssTextDecoration a, const CssTextDecoration b) {
|
|
||||||
return static_cast<CssTextDecoration>(static_cast<uint8_t>(a) | static_cast<uint8_t>(b));
|
|
||||||
}
|
|
||||||
|
|
||||||
constexpr CssTextDecoration operator&(const CssTextDecoration a, const CssTextDecoration b) {
|
|
||||||
return static_cast<CssTextDecoration>(static_cast<uint8_t>(a) & static_cast<uint8_t>(b));
|
|
||||||
}
|
|
||||||
|
|
||||||
constexpr uint8_t CSS_TEXT_DECORATION_MASK =
|
|
||||||
static_cast<uint8_t>(CssTextDecoration::Underline) | static_cast<uint8_t>(CssTextDecoration::LineThrough);
|
|
||||||
|
|
||||||
// Display options - only None and Block are relevant for e-ink rendering
|
// Display options - only None and Block are relevant for e-ink rendering
|
||||||
enum class CssDisplay : uint8_t { Block = 0, None = 1 };
|
enum class CssDisplay : uint8_t { Block = 0, None = 1 };
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ constexpr const char* BLOCK_TAGS[] = {"p", "li", "div", "br", "blockquote"};
|
|||||||
constexpr const char* BOLD_TAGS[] = {"b", "strong"};
|
constexpr const char* BOLD_TAGS[] = {"b", "strong"};
|
||||||
constexpr const char* ITALIC_TAGS[] = {"i", "em"};
|
constexpr const char* ITALIC_TAGS[] = {"i", "em"};
|
||||||
constexpr const char* UNDERLINE_TAGS[] = {"u", "ins"};
|
constexpr const char* UNDERLINE_TAGS[] = {"u", "ins"};
|
||||||
constexpr const char* LINETHROUGH_TAGS[] = {"del", "s", "strike"};
|
|
||||||
constexpr const char* IMAGE_TAGS[] = {"img"};
|
constexpr const char* IMAGE_TAGS[] = {"img"};
|
||||||
constexpr const char* SKIP_TAGS[] = {"head"};
|
constexpr const char* SKIP_TAGS[] = {"head"};
|
||||||
|
|
||||||
@@ -90,50 +89,13 @@ void ChapterHtmlSlimParser::applyDirectionToEntry(StyleStackEntry& entry, const
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
EpdFontFamily::Style ChapterHtmlSlimParser::fontStyleForTextDecoration(const CssTextDecoration decoration) {
|
// Update effective bold/italic/underline based on block style and inline style stack
|
||||||
EpdFontFamily::Style style = EpdFontFamily::REGULAR;
|
|
||||||
if ((decoration & CssTextDecoration::Underline) != CssTextDecoration::None) {
|
|
||||||
style = static_cast<EpdFontFamily::Style>(style | EpdFontFamily::UNDERLINE);
|
|
||||||
}
|
|
||||||
if ((decoration & CssTextDecoration::LineThrough) != CssTextDecoration::None) {
|
|
||||||
style = static_cast<EpdFontFamily::Style>(style | EpdFontFamily::STRIKETHROUGH);
|
|
||||||
}
|
|
||||||
return style;
|
|
||||||
}
|
|
||||||
|
|
||||||
void ChapterHtmlSlimParser::applyTextDecorationToEntry(StyleStackEntry& entry, const CssStyle& css) {
|
|
||||||
if (css.hasTextDecoration()) {
|
|
||||||
entry.hasTextDecoration = true;
|
|
||||||
entry.textDecoration = css.textDecoration;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void ChapterHtmlSlimParser::pushDecorationStyleEntry(const CssTextDecoration defaultDecoration,
|
|
||||||
const CssStyle& cssStyle) {
|
|
||||||
StyleStackEntry entry;
|
|
||||||
entry.depth = depth;
|
|
||||||
entry.hasTextDecoration = true;
|
|
||||||
entry.textDecoration = cssStyle.hasTextDecoration() ? cssStyle.textDecoration : defaultDecoration;
|
|
||||||
if (cssStyle.hasFontWeight()) {
|
|
||||||
entry.hasBold = true;
|
|
||||||
entry.bold = cssStyle.fontWeight == CssFontWeight::Bold;
|
|
||||||
}
|
|
||||||
if (cssStyle.hasFontStyle()) {
|
|
||||||
entry.hasItalic = true;
|
|
||||||
entry.italic = cssStyle.fontStyle == CssFontStyle::Italic;
|
|
||||||
}
|
|
||||||
applyDirectionToEntry(entry, cssStyle);
|
|
||||||
inlineStyleStack.push_back(entry);
|
|
||||||
updateEffectiveInlineStyle();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update effective bold/italic/decorations based on block style and inline style stack
|
|
||||||
void ChapterHtmlSlimParser::updateEffectiveInlineStyle() {
|
void ChapterHtmlSlimParser::updateEffectiveInlineStyle() {
|
||||||
// Start with block-level styles
|
// Start with block-level styles
|
||||||
effectiveBold = currentCssStyle.hasFontWeight() && currentCssStyle.fontWeight == CssFontWeight::Bold;
|
effectiveBold = currentCssStyle.hasFontWeight() && currentCssStyle.fontWeight == CssFontWeight::Bold;
|
||||||
effectiveItalic = currentCssStyle.hasFontStyle() && currentCssStyle.fontStyle == CssFontStyle::Italic;
|
effectiveItalic = currentCssStyle.hasFontStyle() && currentCssStyle.fontStyle == CssFontStyle::Italic;
|
||||||
effectiveTextDecoration =
|
effectiveUnderline =
|
||||||
currentCssStyle.hasTextDecoration() ? currentCssStyle.textDecoration : CssTextDecoration::None;
|
currentCssStyle.hasTextDecoration() && currentCssStyle.textDecoration == CssTextDecoration::Underline;
|
||||||
effectiveDirectionDefined = currentCssStyle.hasDirection();
|
effectiveDirectionDefined = currentCssStyle.hasDirection();
|
||||||
effectiveDirection = currentCssStyle.direction;
|
effectiveDirection = currentCssStyle.direction;
|
||||||
effectiveSup = false;
|
effectiveSup = false;
|
||||||
@@ -147,10 +109,8 @@ void ChapterHtmlSlimParser::updateEffectiveInlineStyle() {
|
|||||||
if (entry.hasItalic) {
|
if (entry.hasItalic) {
|
||||||
effectiveItalic = entry.italic;
|
effectiveItalic = entry.italic;
|
||||||
}
|
}
|
||||||
// CSS line decorations propagate through descendants; child entries add
|
if (entry.hasUnderline) {
|
||||||
// their own lines but cannot cancel an ancestor's already active line.
|
effectiveUnderline = entry.underline;
|
||||||
if (entry.hasTextDecoration) {
|
|
||||||
effectiveTextDecoration = effectiveTextDecoration | entry.textDecoration;
|
|
||||||
}
|
}
|
||||||
if (entry.hasDirection) {
|
if (entry.hasDirection) {
|
||||||
effectiveDirectionDefined = true;
|
effectiveDirectionDefined = true;
|
||||||
@@ -204,6 +164,7 @@ void ChapterHtmlSlimParser::flushPartWordBuffer() {
|
|||||||
// Determine font style from depth-based tracking and CSS effective style
|
// Determine font style from depth-based tracking and CSS effective style
|
||||||
const bool isBold = boldUntilDepth < depth || effectiveBold;
|
const bool isBold = boldUntilDepth < depth || effectiveBold;
|
||||||
const bool isItalic = italicUntilDepth < depth || effectiveItalic;
|
const bool isItalic = italicUntilDepth < depth || effectiveItalic;
|
||||||
|
const bool isUnderline = underlineUntilDepth < depth || effectiveUnderline;
|
||||||
|
|
||||||
// Combine style flags using bitwise OR
|
// Combine style flags using bitwise OR
|
||||||
EpdFontFamily::Style fontStyle = EpdFontFamily::REGULAR;
|
EpdFontFamily::Style fontStyle = EpdFontFamily::REGULAR;
|
||||||
@@ -213,7 +174,9 @@ void ChapterHtmlSlimParser::flushPartWordBuffer() {
|
|||||||
if (isItalic) {
|
if (isItalic) {
|
||||||
fontStyle = static_cast<EpdFontFamily::Style>(fontStyle | EpdFontFamily::ITALIC);
|
fontStyle = static_cast<EpdFontFamily::Style>(fontStyle | EpdFontFamily::ITALIC);
|
||||||
}
|
}
|
||||||
fontStyle = static_cast<EpdFontFamily::Style>(fontStyle | fontStyleForTextDecoration(effectiveTextDecoration));
|
if (isUnderline) {
|
||||||
|
fontStyle = static_cast<EpdFontFamily::Style>(fontStyle | EpdFontFamily::UNDERLINE);
|
||||||
|
}
|
||||||
if (effectiveSup) {
|
if (effectiveSup) {
|
||||||
fontStyle = static_cast<EpdFontFamily::Style>(fontStyle | EpdFontFamily::SUP);
|
fontStyle = static_cast<EpdFontFamily::Style>(fontStyle | EpdFontFamily::SUP);
|
||||||
} else if (effectiveSub) {
|
} else if (effectiveSub) {
|
||||||
@@ -457,15 +420,14 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
|||||||
headerStyle.bold = false;
|
headerStyle.bold = false;
|
||||||
headerStyle.hasItalic = true;
|
headerStyle.hasItalic = true;
|
||||||
headerStyle.italic = true;
|
headerStyle.italic = true;
|
||||||
|
headerStyle.hasUnderline = true;
|
||||||
|
headerStyle.underline = false;
|
||||||
self->inlineStyleStack.push_back(headerStyle);
|
self->inlineStyleStack.push_back(headerStyle);
|
||||||
self->updateEffectiveInlineStyle();
|
self->updateEffectiveInlineStyle();
|
||||||
const CssTextDecoration savedTextDecoration = self->effectiveTextDecoration;
|
|
||||||
self->effectiveTextDecoration = CssTextDecoration::None;
|
|
||||||
self->characterData(userData, headerText.c_str(), static_cast<int>(headerText.length()));
|
self->characterData(userData, headerText.c_str(), static_cast<int>(headerText.length()));
|
||||||
if (self->partWordBufferIndex > 0) {
|
if (self->partWordBufferIndex > 0) {
|
||||||
self->flushPartWordBuffer();
|
self->flushPartWordBuffer();
|
||||||
}
|
}
|
||||||
self->effectiveTextDecoration = savedTextDecoration;
|
|
||||||
self->nextWordContinues = false;
|
self->nextWordContinues = false;
|
||||||
self->inlineStyleStack.pop_back();
|
self->inlineStyleStack.pop_back();
|
||||||
self->updateEffectiveInlineStyle();
|
self->updateEffectiveInlineStyle();
|
||||||
@@ -798,11 +760,12 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
|||||||
self->currentFootnote.number[0] = '\0';
|
self->currentFootnote.number[0] = '\0';
|
||||||
self->currentFootnoteLinkTextLen = 0;
|
self->currentFootnoteLinkTextLen = 0;
|
||||||
|
|
||||||
// Apply underline style to visually indicate the link.
|
// Apply underline style to visually indicate the link
|
||||||
|
self->underlineUntilDepth = std::min(self->underlineUntilDepth, self->depth);
|
||||||
StyleStackEntry entry;
|
StyleStackEntry entry;
|
||||||
entry.depth = self->depth;
|
entry.depth = self->depth;
|
||||||
entry.hasTextDecoration = true;
|
entry.hasUnderline = true;
|
||||||
entry.textDecoration = CssTextDecoration::Underline;
|
entry.underline = true;
|
||||||
applyDirectionToEntry(entry, cssStyle);
|
applyDirectionToEntry(entry, cssStyle);
|
||||||
self->inlineStyleStack.push_back(entry);
|
self->inlineStyleStack.push_back(entry);
|
||||||
self->updateEffectiveInlineStyle();
|
self->updateEffectiveInlineStyle();
|
||||||
@@ -874,14 +837,23 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
|||||||
self->flushPartWordBuffer();
|
self->flushPartWordBuffer();
|
||||||
self->nextWordContinues = true;
|
self->nextWordContinues = true;
|
||||||
}
|
}
|
||||||
self->pushDecorationStyleEntry(CssTextDecoration::Underline, cssStyle);
|
self->underlineUntilDepth = std::min(self->underlineUntilDepth, self->depth);
|
||||||
} else if (matches(name, LINETHROUGH_TAGS, std::size(LINETHROUGH_TAGS))) {
|
// Push inline style entry for underline tag
|
||||||
// Flush buffer before style change so preceding text gets current style
|
StyleStackEntry entry;
|
||||||
if (self->partWordBufferIndex > 0) {
|
entry.depth = self->depth; // Track depth for matching pop
|
||||||
self->flushPartWordBuffer();
|
entry.hasUnderline = true;
|
||||||
self->nextWordContinues = true;
|
entry.underline = true;
|
||||||
|
if (cssStyle.hasFontWeight()) {
|
||||||
|
entry.hasBold = true;
|
||||||
|
entry.bold = cssStyle.fontWeight == CssFontWeight::Bold;
|
||||||
}
|
}
|
||||||
self->pushDecorationStyleEntry(CssTextDecoration::LineThrough, cssStyle);
|
if (cssStyle.hasFontStyle()) {
|
||||||
|
entry.hasItalic = true;
|
||||||
|
entry.italic = cssStyle.fontStyle == CssFontStyle::Italic;
|
||||||
|
}
|
||||||
|
applyDirectionToEntry(entry, cssStyle);
|
||||||
|
self->inlineStyleStack.push_back(entry);
|
||||||
|
self->updateEffectiveInlineStyle();
|
||||||
} else if (matches(name, BOLD_TAGS, std::size(BOLD_TAGS))) {
|
} else if (matches(name, BOLD_TAGS, std::size(BOLD_TAGS))) {
|
||||||
// Flush buffer before style change so preceding text gets current style
|
// Flush buffer before style change so preceding text gets current style
|
||||||
if (self->partWordBufferIndex > 0) {
|
if (self->partWordBufferIndex > 0) {
|
||||||
@@ -898,7 +870,10 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
|||||||
entry.hasItalic = true;
|
entry.hasItalic = true;
|
||||||
entry.italic = cssStyle.fontStyle == CssFontStyle::Italic;
|
entry.italic = cssStyle.fontStyle == CssFontStyle::Italic;
|
||||||
}
|
}
|
||||||
applyTextDecorationToEntry(entry, cssStyle);
|
if (cssStyle.hasTextDecoration()) {
|
||||||
|
entry.hasUnderline = true;
|
||||||
|
entry.underline = cssStyle.textDecoration == CssTextDecoration::Underline;
|
||||||
|
}
|
||||||
applyDirectionToEntry(entry, cssStyle);
|
applyDirectionToEntry(entry, cssStyle);
|
||||||
self->inlineStyleStack.push_back(entry);
|
self->inlineStyleStack.push_back(entry);
|
||||||
self->updateEffectiveInlineStyle();
|
self->updateEffectiveInlineStyle();
|
||||||
@@ -918,7 +893,10 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
|||||||
entry.hasBold = true;
|
entry.hasBold = true;
|
||||||
entry.bold = cssStyle.fontWeight == CssFontWeight::Bold;
|
entry.bold = cssStyle.fontWeight == CssFontWeight::Bold;
|
||||||
}
|
}
|
||||||
applyTextDecorationToEntry(entry, cssStyle);
|
if (cssStyle.hasTextDecoration()) {
|
||||||
|
entry.hasUnderline = true;
|
||||||
|
entry.underline = cssStyle.textDecoration == CssTextDecoration::Underline;
|
||||||
|
}
|
||||||
applyDirectionToEntry(entry, cssStyle);
|
applyDirectionToEntry(entry, cssStyle);
|
||||||
self->inlineStyleStack.push_back(entry);
|
self->inlineStyleStack.push_back(entry);
|
||||||
self->updateEffectiveInlineStyle();
|
self->updateEffectiveInlineStyle();
|
||||||
@@ -957,7 +935,10 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
|||||||
entry.hasItalic = true;
|
entry.hasItalic = true;
|
||||||
entry.italic = cssStyle.fontStyle == CssFontStyle::Italic;
|
entry.italic = cssStyle.fontStyle == CssFontStyle::Italic;
|
||||||
}
|
}
|
||||||
applyTextDecorationToEntry(entry, cssStyle);
|
if (cssStyle.hasTextDecoration()) {
|
||||||
|
entry.hasUnderline = true;
|
||||||
|
entry.underline = cssStyle.textDecoration == CssTextDecoration::Underline;
|
||||||
|
}
|
||||||
applyDirectionToEntry(entry, cssStyle);
|
applyDirectionToEntry(entry, cssStyle);
|
||||||
if (cssStyle.hasVerticalAlign()) {
|
if (cssStyle.hasVerticalAlign()) {
|
||||||
if (cssStyle.verticalAlign == CssVerticalAlign::Super) {
|
if (cssStyle.verticalAlign == CssVerticalAlign::Super) {
|
||||||
@@ -1169,8 +1150,9 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n
|
|||||||
!self->inlineStyleStack.empty() && self->inlineStyleStack.back().depth == self->depth - 1;
|
!self->inlineStyleStack.empty() && self->inlineStyleStack.back().depth == self->depth - 1;
|
||||||
const bool willClearBold = self->boldUntilDepth == self->depth - 1;
|
const bool willClearBold = self->boldUntilDepth == self->depth - 1;
|
||||||
const bool willClearItalic = self->italicUntilDepth == self->depth - 1;
|
const bool willClearItalic = self->italicUntilDepth == self->depth - 1;
|
||||||
|
const bool willClearUnderline = self->underlineUntilDepth == self->depth - 1;
|
||||||
|
|
||||||
const bool styleWillChange = willPopStyleStack || willClearBold || willClearItalic;
|
const bool styleWillChange = willPopStyleStack || willClearBold || willClearItalic || willClearUnderline;
|
||||||
const bool headerOrBlockTag = isHeaderOrBlock(name);
|
const bool headerOrBlockTag = isHeaderOrBlock(name);
|
||||||
const bool tableStructuralTag = isTableStructuralTag(name);
|
const bool tableStructuralTag = isTableStructuralTag(name);
|
||||||
|
|
||||||
@@ -1189,8 +1171,7 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n
|
|||||||
!matches(name, IMAGE_TAGS, std::size(IMAGE_TAGS)) && self->depth != 1;
|
!matches(name, IMAGE_TAGS, std::size(IMAGE_TAGS)) && self->depth != 1;
|
||||||
const bool shouldFlush = styleWillChange || headerOrBlockTag || matches(name, BOLD_TAGS, std::size(BOLD_TAGS)) ||
|
const bool shouldFlush = styleWillChange || headerOrBlockTag || matches(name, BOLD_TAGS, std::size(BOLD_TAGS)) ||
|
||||||
matches(name, ITALIC_TAGS, std::size(ITALIC_TAGS)) ||
|
matches(name, ITALIC_TAGS, std::size(ITALIC_TAGS)) ||
|
||||||
matches(name, UNDERLINE_TAGS, std::size(UNDERLINE_TAGS)) ||
|
matches(name, UNDERLINE_TAGS, std::size(UNDERLINE_TAGS)) || tableStructuralTag ||
|
||||||
matches(name, LINETHROUGH_TAGS, std::size(LINETHROUGH_TAGS)) || tableStructuralTag ||
|
|
||||||
matches(name, IMAGE_TAGS, std::size(IMAGE_TAGS)) || self->depth == 1;
|
matches(name, IMAGE_TAGS, std::size(IMAGE_TAGS)) || self->depth == 1;
|
||||||
|
|
||||||
if (shouldFlush) {
|
if (shouldFlush) {
|
||||||
@@ -1249,6 +1230,11 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n
|
|||||||
self->italicUntilDepth = INT_MAX;
|
self->italicUntilDepth = INT_MAX;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Leaving underline tag
|
||||||
|
if (self->underlineUntilDepth == self->depth) {
|
||||||
|
self->underlineUntilDepth = INT_MAX;
|
||||||
|
}
|
||||||
|
|
||||||
// Pop from inline style stack if we pushed an entry at this depth
|
// Pop from inline style stack if we pushed an entry at this depth
|
||||||
// This handles all inline elements: b, i, u, span, etc.
|
// This handles all inline elements: b, i, u, span, etc.
|
||||||
if (!self->inlineStyleStack.empty() && self->inlineStyleStack.back().depth == self->depth) {
|
if (!self->inlineStyleStack.empty() && self->inlineStyleStack.back().depth == self->depth) {
|
||||||
@@ -1275,9 +1261,7 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ChapterHtmlSlimParser::~ChapterHtmlSlimParser() { abortParse(); }
|
bool ChapterHtmlSlimParser::parseAndBuildPages() {
|
||||||
|
|
||||||
bool ChapterHtmlSlimParser::beginParse() {
|
|
||||||
// Initialize block style stack with a root entry representing "no ancestor block elements".
|
// Initialize block style stack with a root entry representing "no ancestor block elements".
|
||||||
// The user's paragraph alignment is set as the default so child elements without explicit
|
// The user's paragraph alignment is set as the default so child elements without explicit
|
||||||
// text-align inherit it correctly through getCombinedBlockStyle.
|
// text-align inherit it correctly through getCombinedBlockStyle.
|
||||||
@@ -1295,78 +1279,67 @@ bool ChapterHtmlSlimParser::beginParse() {
|
|||||||
paragraphAlignmentBlockStyle.alignment = align;
|
paragraphAlignmentBlockStyle.alignment = align;
|
||||||
startNewTextBlock(paragraphAlignmentBlockStyle);
|
startNewTextBlock(paragraphAlignmentBlockStyle);
|
||||||
|
|
||||||
xmlParser_ = XML_ParserCreate(nullptr);
|
XML_Parser parser = XML_ParserCreate(nullptr);
|
||||||
if (!xmlParser_) {
|
int done;
|
||||||
|
|
||||||
|
if (!parser) {
|
||||||
LOG_ERR("EHP", "Couldn't allocate memory for parser");
|
LOG_ERR("EHP", "Couldn't allocate memory for parser");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle HTML entities (like ) that aren't in XML spec or DTD
|
// Handle HTML entities (like ) that aren't in XML spec or DTD
|
||||||
// Using DefaultHandlerExpand preserves normal entity expansion from DOCTYPE
|
// Using DefaultHandlerExpand preserves normal entity expansion from DOCTYPE
|
||||||
XML_SetDefaultHandlerExpand(xmlParser_, defaultHandlerExpand);
|
XML_SetDefaultHandlerExpand(parser, defaultHandlerExpand);
|
||||||
|
|
||||||
if (!Storage.openFileForRead("EHP", filepath, parseFile_)) {
|
HalFile file;
|
||||||
destroyXmlParser(xmlParser_);
|
if (!Storage.openFileForRead("EHP", filepath, file)) {
|
||||||
xmlParser_ = nullptr;
|
destroyXmlParser(parser);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get file size to decide whether to show indexing popup.
|
// Get file size to decide whether to show indexing popup.
|
||||||
if (popupFn && parseFile_.size() >= MIN_SIZE_FOR_POPUP) {
|
if (popupFn && file.size() >= MIN_SIZE_FOR_POPUP) {
|
||||||
popupFn();
|
popupFn();
|
||||||
}
|
}
|
||||||
|
|
||||||
XML_SetUserData(xmlParser_, this);
|
XML_SetUserData(parser, this);
|
||||||
XML_SetElementHandler(xmlParser_, startElement, endElement);
|
XML_SetElementHandler(parser, startElement, endElement);
|
||||||
XML_SetCharacterDataHandler(xmlParser_, characterData);
|
XML_SetCharacterDataHandler(parser, characterData);
|
||||||
|
|
||||||
parseStartTime_ = millis();
|
// Compute the time taken to parse and build pages
|
||||||
return true;
|
const uint32_t chapterStartTime = millis();
|
||||||
}
|
do {
|
||||||
|
void* const buf = XML_GetBuffer(parser, PARSE_BUFFER_SIZE);
|
||||||
|
if (!buf) {
|
||||||
|
LOG_ERR("EHP", "Couldn't allocate memory for buffer");
|
||||||
|
destroyXmlParser(parser);
|
||||||
|
file.close();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
ChapterHtmlSlimParser::ParseStatus ChapterHtmlSlimParser::parseStep() {
|
const size_t len = file.read(buf, PARSE_BUFFER_SIZE);
|
||||||
void* const buf = XML_GetBuffer(xmlParser_, PARSE_BUFFER_SIZE);
|
|
||||||
if (!buf) {
|
|
||||||
LOG_ERR("EHP", "Couldn't allocate memory for buffer");
|
|
||||||
return ParseStatus::Error;
|
|
||||||
}
|
|
||||||
|
|
||||||
const size_t len = parseFile_.read(buf, PARSE_BUFFER_SIZE);
|
if (len == 0 && file.available() > 0) {
|
||||||
|
LOG_ERR("EHP", "File read error");
|
||||||
|
destroyXmlParser(parser);
|
||||||
|
file.close();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (len == 0 && parseFile_.available() > 0) {
|
done = file.available() == 0;
|
||||||
LOG_ERR("EHP", "File read error");
|
|
||||||
return ParseStatus::Error;
|
|
||||||
}
|
|
||||||
|
|
||||||
const int done = parseFile_.available() == 0;
|
if (XML_ParseBuffer(parser, static_cast<int>(len), done) == XML_STATUS_ERROR) {
|
||||||
|
LOG_ERR("EHP", "Parse error at line %lu:\n%s", XML_GetCurrentLineNumber(parser),
|
||||||
|
XML_ErrorString(XML_GetErrorCode(parser)));
|
||||||
|
destroyXmlParser(parser);
|
||||||
|
file.close();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} while (!done);
|
||||||
|
LOG_DBG("EHP", "Time to parse and build pages: %lu ms", millis() - chapterStartTime);
|
||||||
|
|
||||||
if (XML_ParseBuffer(xmlParser_, static_cast<int>(len), done) == XML_STATUS_ERROR) {
|
destroyXmlParser(parser);
|
||||||
LOG_ERR("EHP", "Parse error at line %lu:\n%s", XML_GetCurrentLineNumber(xmlParser_),
|
file.close();
|
||||||
XML_ErrorString(XML_GetErrorCode(xmlParser_)));
|
|
||||||
return ParseStatus::Error;
|
|
||||||
}
|
|
||||||
|
|
||||||
return done ? ParseStatus::Done : ParseStatus::More;
|
|
||||||
}
|
|
||||||
|
|
||||||
void ChapterHtmlSlimParser::abortParse() {
|
|
||||||
if (xmlParser_) {
|
|
||||||
destroyXmlParser(xmlParser_);
|
|
||||||
xmlParser_ = nullptr;
|
|
||||||
}
|
|
||||||
// Only close the file if it was successfully opened in beginParse()
|
|
||||||
if (parseFile_.isOpen()) {
|
|
||||||
parseFile_.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bool ChapterHtmlSlimParser::finishParse() {
|
|
||||||
if (xmlParser_) {
|
|
||||||
LOG_DBG("EHP", "Time to parse and build pages: %lu ms", millis() - parseStartTime_);
|
|
||||||
destroyXmlParser(xmlParser_);
|
|
||||||
xmlParser_ = nullptr;
|
|
||||||
}
|
|
||||||
parseFile_.close();
|
|
||||||
|
|
||||||
// Process last page if there is still text
|
// Process last page if there is still text
|
||||||
if (currentTextBlock) {
|
if (currentTextBlock) {
|
||||||
@@ -1384,23 +1357,6 @@ bool ChapterHtmlSlimParser::finishParse() {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool ChapterHtmlSlimParser::parseAndBuildPages() {
|
|
||||||
if (!beginParse()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
for (;;) {
|
|
||||||
const ParseStatus status = parseStep();
|
|
||||||
if (status == ParseStatus::Error) {
|
|
||||||
abortParse();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (status == ParseStatus::Done) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return finishParse();
|
|
||||||
}
|
|
||||||
|
|
||||||
void ChapterHtmlSlimParser::addLineToPage(std::shared_ptr<TextBlock> line) {
|
void ChapterHtmlSlimParser::addLineToPage(std::shared_ptr<TextBlock> line) {
|
||||||
const int lineHeight = renderer.getLineHeight(fontId) * lineCompression;
|
const int lineHeight = renderer.getLineHeight(fontId) * lineCompression;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include <HalStorage.h>
|
|
||||||
#include <expat.h>
|
#include <expat.h>
|
||||||
|
|
||||||
#include <climits>
|
#include <climits>
|
||||||
@@ -32,6 +31,7 @@ class ChapterHtmlSlimParser {
|
|||||||
int skipUntilDepth = INT_MAX;
|
int skipUntilDepth = INT_MAX;
|
||||||
int boldUntilDepth = INT_MAX;
|
int boldUntilDepth = INT_MAX;
|
||||||
int italicUntilDepth = INT_MAX;
|
int italicUntilDepth = INT_MAX;
|
||||||
|
int underlineUntilDepth = INT_MAX;
|
||||||
// buffer for building up words from characters, will auto break if longer than this
|
// buffer for building up words from characters, will auto break if longer than this
|
||||||
// leave one char at end for null pointer
|
// leave one char at end for null pointer
|
||||||
char partWordBuffer[MAX_WORD_SIZE + 1] = {};
|
char partWordBuffer[MAX_WORD_SIZE + 1] = {};
|
||||||
@@ -60,8 +60,7 @@ class ChapterHtmlSlimParser {
|
|||||||
int depth = 0;
|
int depth = 0;
|
||||||
bool hasBold = false, bold = false;
|
bool hasBold = false, bold = false;
|
||||||
bool hasItalic = false, italic = false;
|
bool hasItalic = false, italic = false;
|
||||||
bool hasTextDecoration = false;
|
bool hasUnderline = false, underline = false;
|
||||||
CssTextDecoration textDecoration = CssTextDecoration::None;
|
|
||||||
bool hasDirection = false;
|
bool hasDirection = false;
|
||||||
CssTextDirection direction = CssTextDirection::Ltr;
|
CssTextDirection direction = CssTextDirection::Ltr;
|
||||||
bool hasSup = false, sup = false;
|
bool hasSup = false, sup = false;
|
||||||
@@ -72,7 +71,7 @@ class ChapterHtmlSlimParser {
|
|||||||
CssStyle currentCssStyle;
|
CssStyle currentCssStyle;
|
||||||
bool effectiveBold = false;
|
bool effectiveBold = false;
|
||||||
bool effectiveItalic = false;
|
bool effectiveItalic = false;
|
||||||
CssTextDecoration effectiveTextDecoration = CssTextDecoration::None;
|
bool effectiveUnderline = false;
|
||||||
bool effectiveDirectionDefined = false;
|
bool effectiveDirectionDefined = false;
|
||||||
CssTextDirection effectiveDirection = CssTextDirection::Ltr;
|
CssTextDirection effectiveDirection = CssTextDirection::Ltr;
|
||||||
bool effectiveSup = false;
|
bool effectiveSup = false;
|
||||||
@@ -97,25 +96,12 @@ class ChapterHtmlSlimParser {
|
|||||||
std::vector<std::pair<int, FootnoteEntry>> pendingFootnotes; // <wordIndex, entry>
|
std::vector<std::pair<int, FootnoteEntry>> pendingFootnotes; // <wordIndex, entry>
|
||||||
int wordsExtractedInBlock = 0;
|
int wordsExtractedInBlock = 0;
|
||||||
|
|
||||||
// Resumable parse state. The one-shot parseAndBuildPages() drives these
|
|
||||||
// internally; the incremental section builder drives them across render ticks
|
|
||||||
// so a large single chapter can yield between pages instead of blocking the UI
|
|
||||||
// until the whole thing is laid out. parseFile_ and the expat parser stay alive
|
|
||||||
// for the lifetime of the parse so it can be paused and resumed at buffer
|
|
||||||
// boundaries.
|
|
||||||
XML_Parser xmlParser_ = nullptr;
|
|
||||||
HalFile parseFile_;
|
|
||||||
uint32_t parseStartTime_ = 0;
|
|
||||||
|
|
||||||
void updateEffectiveInlineStyle();
|
void updateEffectiveInlineStyle();
|
||||||
void startNewTextBlock(const BlockStyle& blockStyle);
|
void startNewTextBlock(const BlockStyle& blockStyle);
|
||||||
void flushPendingAnchor();
|
void flushPendingAnchor();
|
||||||
void flushPartWordBuffer();
|
void flushPartWordBuffer();
|
||||||
void makePages();
|
void makePages();
|
||||||
static EpdFontFamily::Style fontStyleForTextDecoration(CssTextDecoration decoration);
|
|
||||||
static void applyDirectionToEntry(StyleStackEntry& entry, const CssStyle& css);
|
static void applyDirectionToEntry(StyleStackEntry& entry, const CssStyle& css);
|
||||||
static void applyTextDecorationToEntry(StyleStackEntry& entry, const CssStyle& css);
|
|
||||||
void pushDecorationStyleEntry(CssTextDecoration defaultDecoration, const CssStyle& cssStyle);
|
|
||||||
void emitHorizontalRule(const BlockStyle& blockStyle);
|
void emitHorizontalRule(const BlockStyle& blockStyle);
|
||||||
// XML callbacks
|
// XML callbacks
|
||||||
static void XMLCALL startElement(void* userData, const XML_Char* name, const XML_Char** atts);
|
static void XMLCALL startElement(void* userData, const XML_Char* name, const XML_Char** atts);
|
||||||
@@ -155,28 +141,8 @@ class ChapterHtmlSlimParser {
|
|||||||
imageBasePath(imageBasePath),
|
imageBasePath(imageBasePath),
|
||||||
tocAnchors(std::move(tocAnchors)) {}
|
tocAnchors(std::move(tocAnchors)) {}
|
||||||
|
|
||||||
~ChapterHtmlSlimParser();
|
~ChapterHtmlSlimParser() = default;
|
||||||
|
|
||||||
// One-shot parse: builds every page before returning (begin + step* + finish).
|
|
||||||
bool parseAndBuildPages();
|
bool parseAndBuildPages();
|
||||||
|
|
||||||
// Resumable parse, for the incremental section builder. Drive as:
|
|
||||||
// if (!beginParse()) fail;
|
|
||||||
// loop: switch (parseStep()) { More: keep going / yield; Done: finishParse(); Error: abortParse(); }
|
|
||||||
// Pages are emitted via completePageFn as they complete during parseStep(), so
|
|
||||||
// the caller can stop once enough pages are built and resume on a later tick.
|
|
||||||
enum class ParseStatus { More, Done, Error };
|
|
||||||
bool beginParse();
|
|
||||||
ParseStatus parseStep();
|
|
||||||
bool finishParse(); // flush the trailing page and tear down; returns true
|
|
||||||
void abortParse(); // tear down without flushing (error / abandon)
|
|
||||||
|
|
||||||
void addLineToPage(std::shared_ptr<TextBlock> line);
|
void addLineToPage(std::shared_ptr<TextBlock> line);
|
||||||
const std::vector<std::pair<std::string, uint16_t>>& getAnchors() const { return anchorData; }
|
const std::vector<std::pair<std::string, uint16_t>>& getAnchors() const { return anchorData; }
|
||||||
|
|
||||||
// Byte progress of the in-flight parse, used to estimate a still-building section's total page
|
|
||||||
// count (a giant single-spine book never fully lays out, so its real count is unknown). Valid
|
|
||||||
// between beginParse() and finishParse()/abortParse().
|
|
||||||
size_t parseBytesConsumed() { return parseFile_ ? parseFile_.position() : 0; }
|
|
||||||
size_t parseTotalBytes() { return parseFile_ ? parseFile_.size() : 0; }
|
|
||||||
};
|
};
|
||||||
|
|||||||
+40
-48
@@ -79,53 +79,6 @@ std::string normalisePath(const std::string& path) {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool naturalLess(const std::string& str1, const std::string& str2) {
|
|
||||||
// Naive natural sort: numeric-aware, case-insensitive
|
|
||||||
const char* s1 = str1.c_str();
|
|
||||||
const char* s2 = str2.c_str();
|
|
||||||
|
|
||||||
// ctype functions require unsigned char values: passing a negative char (UTF-8
|
|
||||||
// bytes above 0x7f with signed char) is undefined behavior
|
|
||||||
const auto isDigit = [](const char c) { return isdigit(static_cast<unsigned char>(c)) != 0; };
|
|
||||||
|
|
||||||
// Iterate while both strings have characters
|
|
||||||
while (*s1 && *s2) {
|
|
||||||
// Check if both are at the start of a number
|
|
||||||
if (isDigit(*s1) && isDigit(*s2)) {
|
|
||||||
// Skip leading zeros and track them
|
|
||||||
while (*s1 == '0') s1++;
|
|
||||||
while (*s2 == '0') s2++;
|
|
||||||
|
|
||||||
// Count digits to compare lengths first
|
|
||||||
int len1 = 0, len2 = 0;
|
|
||||||
while (isDigit(s1[len1])) len1++;
|
|
||||||
while (isDigit(s2[len2])) len2++;
|
|
||||||
|
|
||||||
// Different length so return smaller integer value
|
|
||||||
if (len1 != len2) return len1 < len2;
|
|
||||||
|
|
||||||
// Same length so compare digit by digit
|
|
||||||
for (int i = 0; i < len1; i++) {
|
|
||||||
if (s1[i] != s2[i]) return s1[i] < s2[i];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Numbers equal so advance pointers
|
|
||||||
s1 += len1;
|
|
||||||
s2 += len2;
|
|
||||||
} else {
|
|
||||||
// Regular case-insensitive character comparison
|
|
||||||
const int c1 = tolower(static_cast<unsigned char>(*s1));
|
|
||||||
const int c2 = tolower(static_cast<unsigned char>(*s2));
|
|
||||||
if (c1 != c2) return c1 < c2;
|
|
||||||
s1++;
|
|
||||||
s2++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// One string is prefix of other
|
|
||||||
return *s1 == '\0' && *s2 != '\0';
|
|
||||||
}
|
|
||||||
|
|
||||||
void sortFileList(std::vector<std::string>& strs) {
|
void sortFileList(std::vector<std::string>& strs) {
|
||||||
std::sort(begin(strs), end(strs), [](const std::string& str1, const std::string& str2) {
|
std::sort(begin(strs), end(strs), [](const std::string& str1, const std::string& str2) {
|
||||||
// Directories first
|
// Directories first
|
||||||
@@ -133,7 +86,46 @@ void sortFileList(std::vector<std::string>& strs) {
|
|||||||
bool isDir2 = str2.back() == '/';
|
bool isDir2 = str2.back() == '/';
|
||||||
if (isDir1 != isDir2) return isDir1;
|
if (isDir1 != isDir2) return isDir1;
|
||||||
|
|
||||||
return naturalLess(str1, str2);
|
// Start naive natural sort
|
||||||
|
const char* s1 = str1.c_str();
|
||||||
|
const char* s2 = str2.c_str();
|
||||||
|
|
||||||
|
// Iterate while both strings have characters
|
||||||
|
while (*s1 && *s2) {
|
||||||
|
// Check if both are at the start of a number
|
||||||
|
if (isdigit(*s1) && isdigit(*s2)) {
|
||||||
|
// Skip leading zeros and track them
|
||||||
|
while (*s1 == '0') s1++;
|
||||||
|
while (*s2 == '0') s2++;
|
||||||
|
|
||||||
|
// Count digits to compare lengths first
|
||||||
|
int len1 = 0, len2 = 0;
|
||||||
|
while (isdigit(s1[len1])) len1++;
|
||||||
|
while (isdigit(s2[len2])) len2++;
|
||||||
|
|
||||||
|
// Different length so return smaller integer value
|
||||||
|
if (len1 != len2) return len1 < len2;
|
||||||
|
|
||||||
|
// Same length so compare digit by digit
|
||||||
|
for (int i = 0; i < len1; i++) {
|
||||||
|
if (s1[i] != s2[i]) return s1[i] < s2[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Numbers equal so advance pointers
|
||||||
|
s1 += len1;
|
||||||
|
s2 += len2;
|
||||||
|
} else {
|
||||||
|
// Regular case-insensitive character comparison
|
||||||
|
char c1 = tolower(*s1);
|
||||||
|
char c2 = tolower(*s2);
|
||||||
|
if (c1 != c2) return c1 < c2;
|
||||||
|
s1++;
|
||||||
|
s2++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One string is prefix of other
|
||||||
|
return *s1 == '\0' && *s2 != '\0';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,10 +11,6 @@ std::string decodeUriEscapes(const std::string& path);
|
|||||||
|
|
||||||
std::string normalisePath(const std::string& path);
|
std::string normalisePath(const std::string& path);
|
||||||
|
|
||||||
// Numeric-aware, case-insensitive comparison ("2" < "10"). Returns true when str1 orders
|
|
||||||
// before str2. Same ordering sortFileList applies within the file/directory groups.
|
|
||||||
bool naturalLess(const std::string& str1, const std::string& str2);
|
|
||||||
|
|
||||||
void sortFileList(std::vector<std::string>& strs);
|
void sortFileList(std::vector<std::string>& strs);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
#include <Utf8.h>
|
#include <Utf8.h>
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
#include <cassert>
|
||||||
|
|
||||||
#include "FontCacheManager.h"
|
#include "FontCacheManager.h"
|
||||||
|
|
||||||
@@ -282,17 +283,8 @@ static void renderCharImpl(const GfxRenderer& renderer, GfxRenderer::RenderMode
|
|||||||
const uint8_t bmpVal = 3 - ((byte >> bit_index) & 0x3);
|
const uint8_t bmpVal = 3 - ((byte >> bit_index) & 0x3);
|
||||||
|
|
||||||
if (renderMode == GfxRenderer::BW && bmpVal < 3) {
|
if (renderMode == GfxRenderer::BW && bmpVal < 3) {
|
||||||
// Default: gray pixels paint solid black. Required when grayscale
|
// Black (also paints over the grays in BW mode)
|
||||||
// passes follow (their LUT lightens pixels it assumes were just
|
renderer.drawPixel(screenX, screenY, pixelState);
|
||||||
// driven solid black), and it is the plain non-AA look otherwise.
|
|
||||||
// Fast AA: partial (anti-aliased edge) coverage dithers with the
|
|
||||||
// same period-2 patterns as fillRectDither (dark grey = 50%
|
|
||||||
// checkerboard, light grey = 25%), approximating AA in a single
|
|
||||||
// BW pass. Skipped pixels leave the background untouched.
|
|
||||||
if (!renderer.isFastAntiAliasing() || bmpVal == 0 || (bmpVal == 1 && ((screenX + screenY) & 1) == 0) ||
|
|
||||||
(bmpVal == 2 && (screenX & 1) == 0 && (screenY & 1) == 0)) {
|
|
||||||
renderer.drawPixel(screenX, screenY, pixelState);
|
|
||||||
}
|
|
||||||
} else if (renderMode == GfxRenderer::GRAYSCALE_MSB && (bmpVal == 1 || bmpVal == 2)) {
|
} else if (renderMode == GfxRenderer::GRAYSCALE_MSB && (bmpVal == 1 || bmpVal == 2)) {
|
||||||
// Light gray (also mark the MSB if it's going to be a dark gray too)
|
// Light gray (also mark the MSB if it's going to be a dark gray too)
|
||||||
// Dedicated X3 gray LUTs now provide proper 4-level gray on both devices
|
// Dedicated X3 gray LUTs now provide proper 4-level gray on both devices
|
||||||
@@ -1063,22 +1055,18 @@ void GfxRenderer::drawImage(const uint8_t bitmap[], const int x, const int y, co
|
|||||||
display.drawImage(bitmap, rotatedX, rotatedY, width, height);
|
display.drawImage(bitmap, rotatedX, rotatedY, width, height);
|
||||||
}
|
}
|
||||||
|
|
||||||
void GfxRenderer::drawIcon(const uint8_t bitmap[], const int x, const int y, const int size) const {
|
void GfxRenderer::drawIcon(const uint8_t bitmap[], const int x, const int y, const int width, const int height) const {
|
||||||
// Plot the icon pixel-by-pixel through drawPixel (which applies the orientation
|
if (bitmap == nullptr || width <= 0 || height <= 0) return;
|
||||||
// transform) instead of the byte-aligned framebuffer blit. The blit snaps the
|
assert(width == height);
|
||||||
// icon's position to 8px (one byte) along the rotated axis, which prevents it
|
|
||||||
// from aligning with adjacent text; per-pixel plotting is pixel-precise.
|
const int bytesPerRow = (width + 7) / 8;
|
||||||
// Icons are square and 1bpp (MSB-first, bit==0 = ink). The (size-1-row, col)
|
for (int sourceY = 0; sourceY < height; ++sourceY) {
|
||||||
// mapping reproduces the Portrait orientation the blit produced; drawIcon is
|
for (int sourceX = 0; sourceX < width; ++sourceX) {
|
||||||
// only called by the UI themes, which all render in forced Portrait.
|
const uint8_t rowByte = bitmap[sourceY * bytesPerRow + sourceX / 8];
|
||||||
const int rowBytes = (size + 7) / 8;
|
const bool background = (rowByte >> (7 - (sourceX % 8))) & 0x01;
|
||||||
for (int row = 0; row < size; row++) {
|
if (background) continue;
|
||||||
for (int col = 0; col < size; col++) {
|
|
||||||
const uint8_t byte = bitmap[row * rowBytes + (col >> 3)];
|
drawPixel(x + height - 1 - sourceY, y + sourceX, true);
|
||||||
const bool ink = ((byte >> (7 - (col & 7))) & 1) == 0;
|
|
||||||
if (ink) {
|
|
||||||
drawPixel(x + (size - 1 - row), y + col, true);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,13 +44,6 @@ class GfxRenderer {
|
|||||||
RenderMode renderMode;
|
RenderMode renderMode;
|
||||||
Orientation orientation;
|
Orientation orientation;
|
||||||
bool fadingFix;
|
bool fadingFix;
|
||||||
// Fast text anti-aliasing: when set, BW-mode rendering of 2-bit glyphs
|
|
||||||
// dithers partial-coverage (gray) pixels instead of painting them solid
|
|
||||||
// black, approximating AA in a single BW pass with no grayscale refresh.
|
|
||||||
// Must stay false when grayscale LSB/MSB passes follow the BW render: the
|
|
||||||
// gray LUT lightens pixels it assumes were just driven solid black, and
|
|
||||||
// dither-skipped white pixels would take its white-drive frames unopposed.
|
|
||||||
bool fastAntiAliasing = false;
|
|
||||||
uint8_t* frameBuffer = nullptr;
|
uint8_t* frameBuffer = nullptr;
|
||||||
uint16_t panelWidth = HalDisplay::DISPLAY_WIDTH;
|
uint16_t panelWidth = HalDisplay::DISPLAY_WIDTH;
|
||||||
uint16_t panelHeight = HalDisplay::DISPLAY_HEIGHT;
|
uint16_t panelHeight = HalDisplay::DISPLAY_HEIGHT;
|
||||||
@@ -190,7 +183,7 @@ class GfxRenderer {
|
|||||||
void fillRoundedRect(int x, int y, int width, int height, int cornerRadius, bool roundTopLeft, bool roundTopRight,
|
void fillRoundedRect(int x, int y, int width, int height, int cornerRadius, bool roundTopLeft, bool roundTopRight,
|
||||||
bool roundBottomLeft, bool roundBottomRight, Color color) const;
|
bool roundBottomLeft, bool roundBottomRight, Color color) const;
|
||||||
void drawImage(const uint8_t bitmap[], int x, int y, int width, int height) const;
|
void drawImage(const uint8_t bitmap[], int x, int y, int width, int height) const;
|
||||||
void drawIcon(const uint8_t bitmap[], int x, int y, int size) const;
|
void drawIcon(const uint8_t bitmap[], int x, int y, int width, int height) const;
|
||||||
void drawBitmap(const Bitmap& bitmap, int x, int y, int maxWidth, int maxHeight, float cropX = 0,
|
void drawBitmap(const Bitmap& bitmap, int x, int y, int maxWidth, int maxHeight, float cropX = 0,
|
||||||
float cropY = 0) const;
|
float cropY = 0) const;
|
||||||
void drawBitmap1Bit(const Bitmap& bitmap, int x, int y, int maxWidth, int maxHeight) const;
|
void drawBitmap1Bit(const Bitmap& bitmap, int x, int y, int maxWidth, int maxHeight) const;
|
||||||
@@ -231,8 +224,6 @@ class GfxRenderer {
|
|||||||
// Grayscale functions
|
// Grayscale functions
|
||||||
void setRenderMode(const RenderMode mode) { this->renderMode = mode; }
|
void setRenderMode(const RenderMode mode) { this->renderMode = mode; }
|
||||||
RenderMode getRenderMode() const { return renderMode; }
|
RenderMode getRenderMode() const { return renderMode; }
|
||||||
void setFastAntiAliasing(const bool enabled) { this->fastAntiAliasing = enabled; }
|
|
||||||
bool isFastAntiAliasing() const { return fastAntiAliasing; }
|
|
||||||
// Grayscale preconditioning settle pass (no-op on X4). The rect overload
|
// Grayscale preconditioning settle pass (no-op on X4). The rect overload
|
||||||
// takes the gray region in LOGICAL screen coordinates and rotates it to the
|
// takes the gray region in LOGICAL screen coordinates and rotates it to the
|
||||||
// panel; the no-arg overload settles the full frame. Call after the BW base
|
// panel; the no-arg overload settles the full frame. Call after the BW base
|
||||||
|
|||||||
@@ -65,8 +65,6 @@ STR_SLEEP_COVER_MODE: "Рэжым вокладкі сну"
|
|||||||
STR_HIDE_BATTERY: "Схаваць % батарэі"
|
STR_HIDE_BATTERY: "Схаваць % батарэі"
|
||||||
STR_EXTRA_SPACING: "Дадат. інтэрвал абзаца"
|
STR_EXTRA_SPACING: "Дадат. інтэрвал абзаца"
|
||||||
STR_TEXT_AA: "Згладжванне тэксту"
|
STR_TEXT_AA: "Згладжванне тэксту"
|
||||||
STR_TEXT_AA_FULL: "Поўнае"
|
|
||||||
STR_TEXT_AA_FAST: "Хуткае"
|
|
||||||
STR_SHORT_PWR_BTN: "Кароткае націсканне PWR"
|
STR_SHORT_PWR_BTN: "Кароткае націсканне PWR"
|
||||||
STR_ORIENTATION: "Арыентацыя чытання"
|
STR_ORIENTATION: "Арыентацыя чытання"
|
||||||
STR_SIDE_BTN_LAYOUT: "Бакавыя кнопкі"
|
STR_SIDE_BTN_LAYOUT: "Бакавыя кнопкі"
|
||||||
@@ -238,6 +236,7 @@ STR_CHAPTER_PREFIX: "Раздзел:"
|
|||||||
STR_PAGES_SEPARATOR: "стар. |"
|
STR_PAGES_SEPARATOR: "стар. |"
|
||||||
STR_BOOK_PREFIX: "Кніга:"
|
STR_BOOK_PREFIX: "Кніга:"
|
||||||
STR_CALIBRE_URL_HINT: "Для Calibre дадайце /opds да URL"
|
STR_CALIBRE_URL_HINT: "Для Calibre дадайце /opds да URL"
|
||||||
|
STR_PERCENT_STEP_HINT: "Улева/Управа: 1% Уверх/Уніз: 10%"
|
||||||
STR_SYNCING_TIME: "Сінхранізацыя часу..."
|
STR_SYNCING_TIME: "Сінхранізацыя часу..."
|
||||||
STR_CALC_HASH: "Вылічэнне хэша дакумента..."
|
STR_CALC_HASH: "Вылічэнне хэша дакумента..."
|
||||||
STR_HASH_FAILED: "Не ўдалося вылічыць хэш дакумента"
|
STR_HASH_FAILED: "Не ўдалося вылічыць хэш дакумента"
|
||||||
@@ -297,8 +296,8 @@ STR_NO_FOOTNOTES: "На гэтай старонцы няма зносак"
|
|||||||
STR_LINK: "[спасылка]"
|
STR_LINK: "[спасылка]"
|
||||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u хв"
|
STR_SLEEP_TIMER_VALUE_FORMAT: "%u хв"
|
||||||
STR_SLEEP_NEVER: "Ніколі"
|
STR_SLEEP_NEVER: "Ніколі"
|
||||||
STR_STEP_HINT_FRONT: "Пярэднія кнопкі:"
|
STR_SLEEP_TIMER_STEP_HINT: "Улева/Управа: 1 хв Уверх/Уніз: 5 хв"
|
||||||
STR_STEP_HINT_SIDE: "Бакавыя кнопкі:"
|
|
||||||
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: "Кіраванне тэмамі"
|
||||||
|
|||||||
@@ -65,8 +65,6 @@ STR_SLEEP_COVER_MODE: "Mode de pantalla de repòs"
|
|||||||
STR_HIDE_BATTERY: "Oculta el % de bateria"
|
STR_HIDE_BATTERY: "Oculta el % de bateria"
|
||||||
STR_EXTRA_SPACING: "Espaiat de paràgraf extra"
|
STR_EXTRA_SPACING: "Espaiat de paràgraf extra"
|
||||||
STR_TEXT_AA: "Antialiàsing del text"
|
STR_TEXT_AA: "Antialiàsing del text"
|
||||||
STR_TEXT_AA_FULL: "Complet"
|
|
||||||
STR_TEXT_AA_FAST: "Ràpid"
|
|
||||||
STR_IMAGES: "Imatges"
|
STR_IMAGES: "Imatges"
|
||||||
STR_IMAGES_DISPLAY: "Mostrar"
|
STR_IMAGES_DISPLAY: "Mostrar"
|
||||||
STR_IMAGES_PLACEHOLDER: "Text de mostra"
|
STR_IMAGES_PLACEHOLDER: "Text de mostra"
|
||||||
@@ -274,6 +272,7 @@ STR_CHAPTER_PREFIX: "Capítol: "
|
|||||||
STR_PAGES_SEPARATOR: " pàgines | "
|
STR_PAGES_SEPARATOR: " pàgines | "
|
||||||
STR_BOOK_PREFIX: "Llibre: "
|
STR_BOOK_PREFIX: "Llibre: "
|
||||||
STR_CALIBRE_URL_HINT: "Per al Calibre, afegiu /opds a la URL"
|
STR_CALIBRE_URL_HINT: "Per al Calibre, afegiu /opds a la URL"
|
||||||
|
STR_PERCENT_STEP_HINT: "Esquerra/Dreta: 1% Amunt/Avall: 10%"
|
||||||
STR_SYNCING_TIME: "S'està sincronitzant el temps..."
|
STR_SYNCING_TIME: "S'està sincronitzant el temps..."
|
||||||
STR_CALC_HASH: "S'està calculant el hash del document..."
|
STR_CALC_HASH: "S'està calculant el hash del document..."
|
||||||
STR_HASH_FAILED: "No s'ha pogut calcular el hash del document"
|
STR_HASH_FAILED: "No s'ha pogut calcular el hash del document"
|
||||||
@@ -306,8 +305,7 @@ STR_NO_FOOTNOTES: "No hi ha notes al peu en aquesta pàgina"
|
|||||||
STR_LINK: "[enllaç]"
|
STR_LINK: "[enllaç]"
|
||||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
||||||
STR_SLEEP_NEVER: "Mai"
|
STR_SLEEP_NEVER: "Mai"
|
||||||
STR_STEP_HINT_FRONT: "Botons frontals:"
|
STR_SLEEP_TIMER_STEP_HINT: "Esquerra/Dreta: 1 min Amunt/Avall: 5 min"
|
||||||
STR_STEP_HINT_SIDE: "Botons laterals:"
|
|
||||||
STR_SCREENSHOT_BUTTON: "Fes una captura de pantalla"
|
STR_SCREENSHOT_BUTTON: "Fes una captura de pantalla"
|
||||||
STR_AUTO_TURN_ENABLED: "Passar automàtic activat: "
|
STR_AUTO_TURN_ENABLED: "Passar automàtic activat: "
|
||||||
STR_AUTO_TURN_PAGES_PER_MIN: "Passar automàtic (pàgines per minut)"
|
STR_AUTO_TURN_PAGES_PER_MIN: "Passar automàtic (pàgines per minut)"
|
||||||
@@ -385,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"
|
||||||
|
|||||||
@@ -58,15 +58,13 @@ STR_CALIBRE_INSTRUCTION_3: "3) V Calibre: „Odeslat do zařízení“"
|
|||||||
STR_CALIBRE_INSTRUCTION_4: "„Při odesílání ponechat tuto obrazovku otevřenou“"
|
STR_CALIBRE_INSTRUCTION_4: "„Při odesílání ponechat tuto obrazovku otevřenou“"
|
||||||
STR_CAT_DISPLAY: "Displej"
|
STR_CAT_DISPLAY: "Displej"
|
||||||
STR_CAT_READER: "Čtečka"
|
STR_CAT_READER: "Čtečka"
|
||||||
STR_CAT_CONTROLS: "Ovládání"
|
STR_CAT_CONTROLS: "Ovládací prvky"
|
||||||
STR_CAT_SYSTEM: "Systém"
|
STR_CAT_SYSTEM: "Systém"
|
||||||
STR_SLEEP_SCREEN: "Obrazovka spánku"
|
STR_SLEEP_SCREEN: "Obrazovka spánku"
|
||||||
STR_SLEEP_COVER_MODE: "Obrazovka spánku Režim krytu"
|
STR_SLEEP_COVER_MODE: "Obrazovka spánku Režim krytu"
|
||||||
STR_HIDE_BATTERY: "Skrýt baterii %"
|
STR_HIDE_BATTERY: "Skrýt baterii %"
|
||||||
STR_EXTRA_SPACING: "Extra mezery mezi odstavci"
|
STR_EXTRA_SPACING: "Extra mezery mezi odstavci"
|
||||||
STR_TEXT_AA: "Vyhlazování textu"
|
STR_TEXT_AA: "Vyhlazování textu"
|
||||||
STR_TEXT_AA_FULL: "Plné"
|
|
||||||
STR_TEXT_AA_FAST: "Rychlé"
|
|
||||||
STR_SHORT_PWR_BTN: "Krátké stisknutí tlačítka napájení"
|
STR_SHORT_PWR_BTN: "Krátké stisknutí tlačítka napájení"
|
||||||
STR_ORIENTATION: "Orientace čtení"
|
STR_ORIENTATION: "Orientace čtení"
|
||||||
STR_SIDE_BTN_LAYOUT: "Rozvržení bočních tlačítek (čtečka)"
|
STR_SIDE_BTN_LAYOUT: "Rozvržení bočních tlačítek (čtečka)"
|
||||||
@@ -245,6 +243,7 @@ STR_CHAPTER_PREFIX: "Kapitola:"
|
|||||||
STR_PAGES_SEPARATOR: "stránek |"
|
STR_PAGES_SEPARATOR: "stránek |"
|
||||||
STR_BOOK_PREFIX: "Kniha:"
|
STR_BOOK_PREFIX: "Kniha:"
|
||||||
STR_CALIBRE_URL_HINT: "Pro Calibre přidejte /opds do URL adresy"
|
STR_CALIBRE_URL_HINT: "Pro Calibre přidejte /opds do URL adresy"
|
||||||
|
STR_PERCENT_STEP_HINT: "Vlevo/Vpravo: 1 % Nahoru/Dolů: 10 %"
|
||||||
STR_SYNCING_TIME: "Čas synchronizace..."
|
STR_SYNCING_TIME: "Čas synchronizace..."
|
||||||
STR_CALC_HASH: "Výpočet hashe dokumentu..."
|
STR_CALC_HASH: "Výpočet hashe dokumentu..."
|
||||||
STR_HASH_FAILED: "Nepodařilo se vypočítat hash dokumentu"
|
STR_HASH_FAILED: "Nepodařilo se vypočítat hash dokumentu"
|
||||||
@@ -274,6 +273,6 @@ STR_OPDS_SERVER_URL: "URL serveru OPDS"
|
|||||||
STR_SCREENSHOT_BUTTON: "Udělat snímek obrazovky"
|
STR_SCREENSHOT_BUTTON: "Udělat snímek obrazovky"
|
||||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
||||||
STR_SLEEP_NEVER: "Nikdy"
|
STR_SLEEP_NEVER: "Nikdy"
|
||||||
STR_STEP_HINT_FRONT: "Přední tlačítka:"
|
STR_SLEEP_TIMER_STEP_HINT: "Vlevo/Vpravo: 1 min Nahoru/Dolů: 5 min"
|
||||||
STR_STEP_HINT_SIDE: "Boční tlačítka:"
|
|
||||||
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"
|
||||||
|
|||||||
@@ -65,8 +65,6 @@ STR_SLEEP_COVER_MODE: "Hvile-skærm omslag-tilstand"
|
|||||||
STR_HIDE_BATTERY: "Skjul batteri %"
|
STR_HIDE_BATTERY: "Skjul batteri %"
|
||||||
STR_EXTRA_SPACING: "Ekstra afsnitsafstand"
|
STR_EXTRA_SPACING: "Ekstra afsnitsafstand"
|
||||||
STR_TEXT_AA: "Tekst Anti-Aliasing"
|
STR_TEXT_AA: "Tekst Anti-Aliasing"
|
||||||
STR_TEXT_AA_FULL: "Fuld"
|
|
||||||
STR_TEXT_AA_FAST: "Hurtig"
|
|
||||||
STR_IMAGES: "Billeder"
|
STR_IMAGES: "Billeder"
|
||||||
STR_IMAGES_DISPLAY: "Vis"
|
STR_IMAGES_DISPLAY: "Vis"
|
||||||
STR_IMAGES_PLACEHOLDER: "Pladsholder"
|
STR_IMAGES_PLACEHOLDER: "Pladsholder"
|
||||||
@@ -268,6 +266,7 @@ STR_CHAPTER_PREFIX: "Kapitel: "
|
|||||||
STR_PAGES_SEPARATOR: " sider | "
|
STR_PAGES_SEPARATOR: " sider | "
|
||||||
STR_BOOK_PREFIX: "Bog: "
|
STR_BOOK_PREFIX: "Bog: "
|
||||||
STR_CALIBRE_URL_HINT: "Tilføj /opds til din URL for Calibre"
|
STR_CALIBRE_URL_HINT: "Tilføj /opds til din URL for Calibre"
|
||||||
|
STR_PERCENT_STEP_HINT: "Venstre/Højre: 1% Op/Ned: 10%"
|
||||||
STR_SYNCING_TIME: "Synkroniserer tid..."
|
STR_SYNCING_TIME: "Synkroniserer tid..."
|
||||||
STR_CALC_HASH: "Beregner dokument-hash..."
|
STR_CALC_HASH: "Beregner dokument-hash..."
|
||||||
STR_HASH_FAILED: "Kunne ikke beregne dokument-hash"
|
STR_HASH_FAILED: "Kunne ikke beregne dokument-hash"
|
||||||
@@ -299,9 +298,9 @@ STR_NO_FOOTNOTES: "Ingen fodnoter på denne side"
|
|||||||
STR_LINK: "[link]"
|
STR_LINK: "[link]"
|
||||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
||||||
STR_SLEEP_NEVER: "Aldrig"
|
STR_SLEEP_NEVER: "Aldrig"
|
||||||
STR_STEP_HINT_FRONT: "Frontknapper:"
|
STR_SLEEP_TIMER_STEP_HINT: "Venstre/Højre: 1 min Op/Ned: 5 min"
|
||||||
STR_STEP_HINT_SIDE: "Sideknapper:"
|
|
||||||
STR_SCREENSHOT_BUTTON: "Tag skærmbillede"
|
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"
|
||||||
|
|||||||
@@ -65,8 +65,6 @@ STR_SLEEP_COVER_MODE: "Slaapscherm omslag-modus"
|
|||||||
STR_HIDE_BATTERY: "Batterij % verbergen"
|
STR_HIDE_BATTERY: "Batterij % verbergen"
|
||||||
STR_EXTRA_SPACING: "Extra regelafstand alinea"
|
STR_EXTRA_SPACING: "Extra regelafstand alinea"
|
||||||
STR_TEXT_AA: "Tekst Anti-Aliasing"
|
STR_TEXT_AA: "Tekst Anti-Aliasing"
|
||||||
STR_TEXT_AA_FULL: "Volledig"
|
|
||||||
STR_TEXT_AA_FAST: "Snel"
|
|
||||||
STR_IMAGES: "Afbeeldingen"
|
STR_IMAGES: "Afbeeldingen"
|
||||||
STR_IMAGES_DISPLAY: "Weergave"
|
STR_IMAGES_DISPLAY: "Weergave"
|
||||||
STR_IMAGES_PLACEHOLDER: "Placeholder"
|
STR_IMAGES_PLACEHOLDER: "Placeholder"
|
||||||
@@ -268,6 +266,7 @@ STR_CHAPTER_PREFIX: "Hoofdstuk: "
|
|||||||
STR_PAGES_SEPARATOR: " pagina's | "
|
STR_PAGES_SEPARATOR: " pagina's | "
|
||||||
STR_BOOK_PREFIX: "Boek: "
|
STR_BOOK_PREFIX: "Boek: "
|
||||||
STR_CALIBRE_URL_HINT: "Voeg voor Calibre /opds toe aan de URL"
|
STR_CALIBRE_URL_HINT: "Voeg voor Calibre /opds toe aan de URL"
|
||||||
|
STR_PERCENT_STEP_HINT: "Links/Rechts: 1% Omhoog/Omlaag: 10%"
|
||||||
STR_SYNCING_TIME: "Tijd synchroniseren..."
|
STR_SYNCING_TIME: "Tijd synchroniseren..."
|
||||||
STR_CALC_HASH: "Document-hash berekenen..."
|
STR_CALC_HASH: "Document-hash berekenen..."
|
||||||
STR_HASH_FAILED: "Document-hash berekenen mislukt"
|
STR_HASH_FAILED: "Document-hash berekenen mislukt"
|
||||||
@@ -299,9 +298,9 @@ STR_NO_FOOTNOTES: "Geen voetnoten op deze pagina"
|
|||||||
STR_LINK: "[link]"
|
STR_LINK: "[link]"
|
||||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
||||||
STR_SLEEP_NEVER: "Nooit"
|
STR_SLEEP_NEVER: "Nooit"
|
||||||
STR_STEP_HINT_FRONT: "Voorknoppen:"
|
STR_SLEEP_TIMER_STEP_HINT: "Links/Rechts: 1 min Omhoog/Omlaag: 5 min"
|
||||||
STR_STEP_HINT_SIDE: "Zijknoppen:"
|
|
||||||
STR_SCREENSHOT_BUTTON: "Screenshot maken"
|
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"
|
||||||
|
|||||||
@@ -66,14 +66,10 @@ STR_SLEEP_COVER_MODE: "Sleep Screen Cover Mode"
|
|||||||
STR_HIDE_BATTERY: "Hide Battery %"
|
STR_HIDE_BATTERY: "Hide Battery %"
|
||||||
STR_EXTRA_SPACING: "Extra Paragraph Spacing"
|
STR_EXTRA_SPACING: "Extra Paragraph Spacing"
|
||||||
STR_TEXT_AA: "Text Anti-Aliasing"
|
STR_TEXT_AA: "Text Anti-Aliasing"
|
||||||
STR_TEXT_AA_FULL: "Full"
|
|
||||||
STR_TEXT_AA_FAST: "Fast"
|
|
||||||
STR_IMAGES: "Images"
|
STR_IMAGES: "Images"
|
||||||
STR_IMAGES_DISPLAY: "Display"
|
STR_IMAGES_DISPLAY: "Display"
|
||||||
STR_IMAGES_PLACEHOLDER: "Placeholder"
|
STR_IMAGES_PLACEHOLDER: "Placeholder"
|
||||||
STR_IMAGES_SUPPRESS: "Suppress"
|
STR_IMAGES_SUPPRESS: "Suppress"
|
||||||
STR_EOB_HOME: "Home"
|
|
||||||
STR_EOB_CONTINUE_WITH: "Continue with"
|
|
||||||
STR_SHORT_PWR_BTN: "Short Power Button Click"
|
STR_SHORT_PWR_BTN: "Short Power Button Click"
|
||||||
STR_ORIENTATION: "Reading Orientation"
|
STR_ORIENTATION: "Reading Orientation"
|
||||||
STR_SIDE_BTN_LAYOUT: "Side Button Layout (reader)"
|
STR_SIDE_BTN_LAYOUT: "Side Button Layout (reader)"
|
||||||
@@ -301,6 +297,7 @@ STR_CHAPTER_PREFIX: "Chapter: "
|
|||||||
STR_PAGES_SEPARATOR: " pages | "
|
STR_PAGES_SEPARATOR: " pages | "
|
||||||
STR_BOOK_PREFIX: "Book: "
|
STR_BOOK_PREFIX: "Book: "
|
||||||
STR_CALIBRE_URL_HINT: "For Calibre, add /opds to your URL"
|
STR_CALIBRE_URL_HINT: "For Calibre, add /opds to your URL"
|
||||||
|
STR_PERCENT_STEP_HINT: "Left/Right: 1% Up/Down: 10%"
|
||||||
STR_SYNCING_TIME: "Syncing time..."
|
STR_SYNCING_TIME: "Syncing time..."
|
||||||
STR_CALC_HASH: "Calculating document hash..."
|
STR_CALC_HASH: "Calculating document hash..."
|
||||||
STR_HASH_FAILED: "Failed to calculate document hash"
|
STR_HASH_FAILED: "Failed to calculate document hash"
|
||||||
@@ -335,8 +332,7 @@ STR_LINK: "[link]"
|
|||||||
STR_SCREENSHOT_BUTTON: "Take screenshot"
|
STR_SCREENSHOT_BUTTON: "Take screenshot"
|
||||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
||||||
STR_SLEEP_NEVER: "Never"
|
STR_SLEEP_NEVER: "Never"
|
||||||
STR_STEP_HINT_FRONT: "Front buttons:"
|
STR_SLEEP_TIMER_STEP_HINT: "Left/Right: 1 min Up/Down: 5 min"
|
||||||
STR_STEP_HINT_SIDE: "Side buttons:"
|
|
||||||
STR_ADD_SERVER: "Add Server"
|
STR_ADD_SERVER: "Add Server"
|
||||||
STR_SERVER_NAME: "Server Name"
|
STR_SERVER_NAME: "Server Name"
|
||||||
STR_NO_SERVERS: "No OPDS servers configured"
|
STR_NO_SERVERS: "No OPDS servers configured"
|
||||||
@@ -354,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:"
|
||||||
|
|||||||
@@ -65,8 +65,6 @@ STR_SLEEP_COVER_MODE: "Lepotilanäytön kansitila"
|
|||||||
STR_HIDE_BATTERY: "Piilota akun %"
|
STR_HIDE_BATTERY: "Piilota akun %"
|
||||||
STR_EXTRA_SPACING: "Kappaleiden lisäväli"
|
STR_EXTRA_SPACING: "Kappaleiden lisäväli"
|
||||||
STR_TEXT_AA: "Tekstin reunanpehmennys"
|
STR_TEXT_AA: "Tekstin reunanpehmennys"
|
||||||
STR_TEXT_AA_FULL: "Täysi"
|
|
||||||
STR_TEXT_AA_FAST: "Nopea"
|
|
||||||
STR_SHORT_PWR_BTN: "Lyhyt virtapainikkeen painallus"
|
STR_SHORT_PWR_BTN: "Lyhyt virtapainikkeen painallus"
|
||||||
STR_ORIENTATION: "Lukusuunta"
|
STR_ORIENTATION: "Lukusuunta"
|
||||||
STR_SIDE_BTN_LAYOUT: "Sivupainikkeiden asettelu (lukija)"
|
STR_SIDE_BTN_LAYOUT: "Sivupainikkeiden asettelu (lukija)"
|
||||||
@@ -243,6 +241,7 @@ STR_CHAPTER_PREFIX: "Luku: "
|
|||||||
STR_PAGES_SEPARATOR: " sivua | "
|
STR_PAGES_SEPARATOR: " sivua | "
|
||||||
STR_BOOK_PREFIX: "Kirja: "
|
STR_BOOK_PREFIX: "Kirja: "
|
||||||
STR_CALIBRE_URL_HINT: "Calibrelle lisää /opds osoitteeseen"
|
STR_CALIBRE_URL_HINT: "Calibrelle lisää /opds osoitteeseen"
|
||||||
|
STR_PERCENT_STEP_HINT: "Vasen/Oikea: 1% Ylös/Alas: 10%"
|
||||||
STR_SYNCING_TIME: "Synkronoidaan aikaa..."
|
STR_SYNCING_TIME: "Synkronoidaan aikaa..."
|
||||||
STR_CALC_HASH: "Lasketaan dokumentin tarkistussummaa..."
|
STR_CALC_HASH: "Lasketaan dokumentin tarkistussummaa..."
|
||||||
STR_HASH_FAILED: "Dokumentin tiivisteen laskenta epäonnistui"
|
STR_HASH_FAILED: "Dokumentin tiivisteen laskenta epäonnistui"
|
||||||
@@ -272,6 +271,6 @@ STR_OPDS_SERVER_URL: "OPDS-palvelimen osoite"
|
|||||||
STR_SCREENSHOT_BUTTON: "Ota kuvakaappaus"
|
STR_SCREENSHOT_BUTTON: "Ota kuvakaappaus"
|
||||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
||||||
STR_SLEEP_NEVER: "Ei koskaan"
|
STR_SLEEP_NEVER: "Ei koskaan"
|
||||||
STR_STEP_HINT_FRONT: "Etupainikkeet:"
|
STR_SLEEP_TIMER_STEP_HINT: "Vasen/Oikea: 1 min Ylös/Alas: 5 min"
|
||||||
STR_STEP_HINT_SIDE: "Sivupainikkeet:"
|
|
||||||
STR_TILT_PAGE_TURN: "Sivunkääntö kallistamalla"
|
STR_TILT_PAGE_TURN: "Sivunkääntö kallistamalla"
|
||||||
|
STR_MANAGE_THEMES: "Hallinnoi teemoja"
|
||||||
|
|||||||
@@ -65,8 +65,6 @@ STR_SLEEP_COVER_MODE: "Mode écran de veille"
|
|||||||
STR_HIDE_BATTERY: "Masquer % batterie"
|
STR_HIDE_BATTERY: "Masquer % batterie"
|
||||||
STR_EXTRA_SPACING: "Espacement paragraphes"
|
STR_EXTRA_SPACING: "Espacement paragraphes"
|
||||||
STR_TEXT_AA: "Lissage du texte"
|
STR_TEXT_AA: "Lissage du texte"
|
||||||
STR_TEXT_AA_FULL: "Complet"
|
|
||||||
STR_TEXT_AA_FAST: "Rapide"
|
|
||||||
STR_IMAGES: "Images"
|
STR_IMAGES: "Images"
|
||||||
STR_IMAGES_DISPLAY: "Affichage"
|
STR_IMAGES_DISPLAY: "Affichage"
|
||||||
STR_IMAGES_PLACEHOLDER: "Espace réservé"
|
STR_IMAGES_PLACEHOLDER: "Espace réservé"
|
||||||
@@ -269,6 +267,7 @@ STR_CHAPTER_PREFIX: "Chapitre : "
|
|||||||
STR_PAGES_SEPARATOR: " pages | "
|
STR_PAGES_SEPARATOR: " pages | "
|
||||||
STR_BOOK_PREFIX: "Livre : "
|
STR_BOOK_PREFIX: "Livre : "
|
||||||
STR_CALIBRE_URL_HINT: "Pour Calibre, ajoutez /opds à l’URL"
|
STR_CALIBRE_URL_HINT: "Pour Calibre, ajoutez /opds à l’URL"
|
||||||
|
STR_PERCENT_STEP_HINT: "Gauche/Droite : 1% Haut/Bas : 10%"
|
||||||
STR_SYNCING_TIME: "Synchro de l’heure…"
|
STR_SYNCING_TIME: "Synchro de l’heure…"
|
||||||
STR_CALC_HASH: "Calcul hash doc…"
|
STR_CALC_HASH: "Calcul hash doc…"
|
||||||
STR_HASH_FAILED: "Échec calcul hash"
|
STR_HASH_FAILED: "Échec calcul hash"
|
||||||
@@ -300,9 +299,9 @@ STR_NO_FOOTNOTES: "Aucune note sur cette page"
|
|||||||
STR_LINK: "[lien]"
|
STR_LINK: "[lien]"
|
||||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
||||||
STR_SLEEP_NEVER: "Jamais"
|
STR_SLEEP_NEVER: "Jamais"
|
||||||
STR_STEP_HINT_FRONT: "Boutons avant :"
|
STR_SLEEP_TIMER_STEP_HINT: "Gauche/Droite : 1 min Haut/Bas : 5 min"
|
||||||
STR_STEP_HINT_SIDE: "Boutons latéraux :"
|
|
||||||
STR_SCREENSHOT_BUTTON: "Capture d'écran"
|
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"
|
||||||
|
|||||||
@@ -65,8 +65,6 @@ STR_SLEEP_COVER_MODE: "Standby-Bildmodus"
|
|||||||
STR_HIDE_BATTERY: "Batterie % ausblenden"
|
STR_HIDE_BATTERY: "Batterie % ausblenden"
|
||||||
STR_EXTRA_SPACING: "Absatzabstand"
|
STR_EXTRA_SPACING: "Absatzabstand"
|
||||||
STR_TEXT_AA: "Schriftglättung"
|
STR_TEXT_AA: "Schriftglättung"
|
||||||
STR_TEXT_AA_FULL: "Voll"
|
|
||||||
STR_TEXT_AA_FAST: "Schnell"
|
|
||||||
STR_SHORT_PWR_BTN: "An-Taste kurz drücken"
|
STR_SHORT_PWR_BTN: "An-Taste kurz drücken"
|
||||||
STR_ORIENTATION: "Leseausrichtung"
|
STR_ORIENTATION: "Leseausrichtung"
|
||||||
STR_SIDE_BTN_LAYOUT: "Seitliche Tasten (Lesen)"
|
STR_SIDE_BTN_LAYOUT: "Seitliche Tasten (Lesen)"
|
||||||
@@ -290,6 +288,7 @@ STR_CHAPTER_PREFIX: "Kapitel:"
|
|||||||
STR_PAGES_SEPARATOR: " Seiten | "
|
STR_PAGES_SEPARATOR: " Seiten | "
|
||||||
STR_BOOK_PREFIX: "Buch: "
|
STR_BOOK_PREFIX: "Buch: "
|
||||||
STR_CALIBRE_URL_HINT: "Calibre: URL um /opds ergänzen"
|
STR_CALIBRE_URL_HINT: "Calibre: URL um /opds ergänzen"
|
||||||
|
STR_PERCENT_STEP_HINT: "links/rechts: 1% hoch/runter: 10%"
|
||||||
STR_SYNCING_TIME: "Zeit synchronisieren…"
|
STR_SYNCING_TIME: "Zeit synchronisieren…"
|
||||||
STR_CALC_HASH: "Dokument-Hash berechnen…"
|
STR_CALC_HASH: "Dokument-Hash berechnen…"
|
||||||
STR_HASH_FAILED: "Dokument-Hash fehlgeschlagen"
|
STR_HASH_FAILED: "Dokument-Hash fehlgeschlagen"
|
||||||
@@ -323,8 +322,7 @@ STR_NO_FOOTNOTES: "Keine Fußnoten auf dieser Seite"
|
|||||||
STR_LINK: "[Link]"
|
STR_LINK: "[Link]"
|
||||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u Min."
|
STR_SLEEP_TIMER_VALUE_FORMAT: "%u Min."
|
||||||
STR_SLEEP_NEVER: "Nie"
|
STR_SLEEP_NEVER: "Nie"
|
||||||
STR_STEP_HINT_FRONT: "Vordere Tasten:"
|
STR_SLEEP_TIMER_STEP_HINT: "Links/Rechts: 1 Min. Hoch/Runter: 5 Min."
|
||||||
STR_STEP_HINT_SIDE: "Seitentasten:"
|
|
||||||
STR_ADD_SERVER: "Server hinzufügen"
|
STR_ADD_SERVER: "Server hinzufügen"
|
||||||
STR_SERVER_NAME: "Servername"
|
STR_SERVER_NAME: "Servername"
|
||||||
STR_NO_SERVERS: "Keine OPDS-Server konfiguriert"
|
STR_NO_SERVERS: "Keine OPDS-Server konfiguriert"
|
||||||
@@ -382,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"
|
||||||
|
|||||||
@@ -66,8 +66,6 @@ STR_SLEEP_COVER_MODE: "תצוגת כריכה במצב שינה"
|
|||||||
STR_HIDE_BATTERY: "הסתר אחוזי סוללה"
|
STR_HIDE_BATTERY: "הסתר אחוזי סוללה"
|
||||||
STR_EXTRA_SPACING: "מרווח פסקאות מוגדל"
|
STR_EXTRA_SPACING: "מרווח פסקאות מוגדל"
|
||||||
STR_TEXT_AA: "החלקת קצוות טקסט"
|
STR_TEXT_AA: "החלקת קצוות טקסט"
|
||||||
STR_TEXT_AA_FULL: "מלאה"
|
|
||||||
STR_TEXT_AA_FAST: "מהירה"
|
|
||||||
STR_IMAGES: "תמונות"
|
STR_IMAGES: "תמונות"
|
||||||
STR_IMAGES_DISPLAY: "הצג תמונות"
|
STR_IMAGES_DISPLAY: "הצג תמונות"
|
||||||
STR_IMAGES_PLACEHOLDER: "סמן"
|
STR_IMAGES_PLACEHOLDER: "סמן"
|
||||||
@@ -270,6 +268,7 @@ STR_CHAPTER_PREFIX: "פרק: "
|
|||||||
STR_PAGES_SEPARATOR: " דפים | "
|
STR_PAGES_SEPARATOR: " דפים | "
|
||||||
STR_BOOK_PREFIX: "ספר: "
|
STR_BOOK_PREFIX: "ספר: "
|
||||||
STR_CALIBRE_URL_HINT: "עבור Calibre, הוסף /opds לכתובת ה-URL"
|
STR_CALIBRE_URL_HINT: "עבור Calibre, הוסף /opds לכתובת ה-URL"
|
||||||
|
STR_PERCENT_STEP_HINT: "שמאל/ימין: 1% למעלה/למטה: 10%"
|
||||||
STR_SYNCING_TIME: "מסנכרן שעון..."
|
STR_SYNCING_TIME: "מסנכרן שעון..."
|
||||||
STR_CALC_HASH: "מחשב האש..."
|
STR_CALC_HASH: "מחשב האש..."
|
||||||
STR_HASH_FAILED: "חישוב האש נכשל"
|
STR_HASH_FAILED: "חישוב האש נכשל"
|
||||||
@@ -385,11 +384,11 @@ 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: "סימנייה"
|
||||||
STR_PWR_BTN_FOOTNOTE_BACK: "חזרה מהירה מהערות שוליים"
|
STR_PWR_BTN_FOOTNOTE_BACK: "חזרה מהירה מהערות שוליים"
|
||||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u דקות"
|
STR_SLEEP_TIMER_VALUE_FORMAT: "%u דקות"
|
||||||
STR_SLEEP_NEVER: "אף פעם"
|
STR_SLEEP_NEVER: "אף פעם"
|
||||||
STR_STEP_HINT_FRONT: "לחצנים קדמיים:"
|
STR_SLEEP_TIMER_STEP_HINT: "שמאל/ימין: 1 דק' למעלה/למטה: 5 דק'"
|
||||||
STR_STEP_HINT_SIDE: "לחצני צד:"
|
|
||||||
|
|||||||
@@ -65,8 +65,6 @@ STR_SLEEP_COVER_MODE: "Alvásképernyő borítómód"
|
|||||||
STR_HIDE_BATTERY: "Akkumulátor % elrejtése"
|
STR_HIDE_BATTERY: "Akkumulátor % elrejtése"
|
||||||
STR_EXTRA_SPACING: "Extra bekezdésköz"
|
STR_EXTRA_SPACING: "Extra bekezdésköz"
|
||||||
STR_TEXT_AA: "Szöveg élsimítás"
|
STR_TEXT_AA: "Szöveg élsimítás"
|
||||||
STR_TEXT_AA_FULL: "Teljes"
|
|
||||||
STR_TEXT_AA_FAST: "Gyors"
|
|
||||||
STR_IMAGES: "Képek"
|
STR_IMAGES: "Képek"
|
||||||
STR_IMAGES_DISPLAY: "Megjelenítés"
|
STR_IMAGES_DISPLAY: "Megjelenítés"
|
||||||
STR_IMAGES_PLACEHOLDER: "Helyőrző"
|
STR_IMAGES_PLACEHOLDER: "Helyőrző"
|
||||||
@@ -265,6 +263,7 @@ STR_CHAPTER_PREFIX: "Fejezet: "
|
|||||||
STR_PAGES_SEPARATOR: " oldal | "
|
STR_PAGES_SEPARATOR: " oldal | "
|
||||||
STR_BOOK_PREFIX: "Könyv: "
|
STR_BOOK_PREFIX: "Könyv: "
|
||||||
STR_CALIBRE_URL_HINT: "Calibre esetén adj /opds-t az URL-hez"
|
STR_CALIBRE_URL_HINT: "Calibre esetén adj /opds-t az URL-hez"
|
||||||
|
STR_PERCENT_STEP_HINT: "Bal/Jobb: 1% Fel/Le: 10%"
|
||||||
STR_SYNCING_TIME: "Idő szinkronizálása..."
|
STR_SYNCING_TIME: "Idő szinkronizálása..."
|
||||||
STR_CALC_HASH: "Dokumentum hash kiszámítása..."
|
STR_CALC_HASH: "Dokumentum hash kiszámítása..."
|
||||||
STR_HASH_FAILED: "Dokumentum hash kiszámítása sikertelen"
|
STR_HASH_FAILED: "Dokumentum hash kiszámítása sikertelen"
|
||||||
@@ -296,9 +295,9 @@ STR_NO_FOOTNOTES: "Nincsenek lábjegyzetek ezen az oldalon"
|
|||||||
STR_LINK: "[link]"
|
STR_LINK: "[link]"
|
||||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u perc"
|
STR_SLEEP_TIMER_VALUE_FORMAT: "%u perc"
|
||||||
STR_SLEEP_NEVER: "Soha"
|
STR_SLEEP_NEVER: "Soha"
|
||||||
STR_STEP_HINT_FRONT: "Elülső gombok:"
|
STR_SLEEP_TIMER_STEP_HINT: "Bal/Jobb: 1 perc Fel/Le: 5 perc"
|
||||||
STR_STEP_HINT_SIDE: "Oldalsó gombok:"
|
|
||||||
STR_SCREENSHOT_BUTTON: "Képernyőkép készítése"
|
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"
|
||||||
|
|||||||
@@ -65,8 +65,6 @@ STR_SLEEP_COVER_MODE: "Modalità copertina"
|
|||||||
STR_HIDE_BATTERY: "Nascondi % batteria"
|
STR_HIDE_BATTERY: "Nascondi % batteria"
|
||||||
STR_EXTRA_SPACING: "Spaziatura extra paragrafi"
|
STR_EXTRA_SPACING: "Spaziatura extra paragrafi"
|
||||||
STR_TEXT_AA: "Anti-aliasing testo"
|
STR_TEXT_AA: "Anti-aliasing testo"
|
||||||
STR_TEXT_AA_FULL: "Completo"
|
|
||||||
STR_TEXT_AA_FAST: "Veloce"
|
|
||||||
STR_IMAGES: "Immagini"
|
STR_IMAGES: "Immagini"
|
||||||
STR_IMAGES_DISPLAY: "Visualizza"
|
STR_IMAGES_DISPLAY: "Visualizza"
|
||||||
STR_IMAGES_PLACEHOLDER: "Segnaposto"
|
STR_IMAGES_PLACEHOLDER: "Segnaposto"
|
||||||
@@ -276,6 +274,7 @@ STR_CHAPTER_PREFIX: "Capitolo: "
|
|||||||
STR_PAGES_SEPARATOR: " pagine | "
|
STR_PAGES_SEPARATOR: " pagine | "
|
||||||
STR_BOOK_PREFIX: "Libro: "
|
STR_BOOK_PREFIX: "Libro: "
|
||||||
STR_CALIBRE_URL_HINT: "Per Calibre, aggiungere /opds all'URL"
|
STR_CALIBRE_URL_HINT: "Per Calibre, aggiungere /opds all'URL"
|
||||||
|
STR_PERCENT_STEP_HINT: "Sinistra/Destra: 1% Su/Giù: 10%"
|
||||||
STR_SYNCING_TIME: "Sincronizzazione orario..."
|
STR_SYNCING_TIME: "Sincronizzazione orario..."
|
||||||
STR_CALC_HASH: "Calcolo hash documento..."
|
STR_CALC_HASH: "Calcolo hash documento..."
|
||||||
STR_HASH_FAILED: "Impossibile calcolare l'hash del documento"
|
STR_HASH_FAILED: "Impossibile calcolare l'hash del documento"
|
||||||
@@ -308,8 +307,7 @@ STR_NO_FOOTNOTES: "Nessuna nota in questa pagina"
|
|||||||
STR_LINK: "[link]"
|
STR_LINK: "[link]"
|
||||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
||||||
STR_SLEEP_NEVER: "Mai"
|
STR_SLEEP_NEVER: "Mai"
|
||||||
STR_STEP_HINT_FRONT: "Pulsanti frontali:"
|
STR_SLEEP_TIMER_STEP_HINT: "Sinistra/Destra: 1 min Su/Giù: 5 min"
|
||||||
STR_STEP_HINT_SIDE: "Pulsanti laterali:"
|
|
||||||
STR_SCREENSHOT_BUTTON: "Screenshot"
|
STR_SCREENSHOT_BUTTON: "Screenshot"
|
||||||
STR_ADD_SERVER: "Aggiungi server"
|
STR_ADD_SERVER: "Aggiungi server"
|
||||||
STR_SERVER_NAME: "Nome server"
|
STR_SERVER_NAME: "Nome server"
|
||||||
@@ -378,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: "
|
||||||
|
|||||||
@@ -64,8 +64,6 @@ STR_SLEEP_COVER_MODE: "Ұйқы экраны мұқаба режимі"
|
|||||||
STR_HIDE_BATTERY: "Батарея % жасыру"
|
STR_HIDE_BATTERY: "Батарея % жасыру"
|
||||||
STR_EXTRA_SPACING: "Қосымша абзац аралығы"
|
STR_EXTRA_SPACING: "Қосымша абзац аралығы"
|
||||||
STR_TEXT_AA: "Мәтін сырғытпасы"
|
STR_TEXT_AA: "Мәтін сырғытпасы"
|
||||||
STR_TEXT_AA_FULL: "Толық"
|
|
||||||
STR_TEXT_AA_FAST: "Жылдам"
|
|
||||||
STR_SHORT_PWR_BTN: "Қуат түймесін қысқа басу"
|
STR_SHORT_PWR_BTN: "Қуат түймесін қысқа басу"
|
||||||
STR_ORIENTATION: "Оқу бағдары"
|
STR_ORIENTATION: "Оқу бағдары"
|
||||||
STR_SIDE_BTN_LAYOUT: "Бүйірлік түймелер орналасуы (оқырман)"
|
STR_SIDE_BTN_LAYOUT: "Бүйірлік түймелер орналасуы (оқырман)"
|
||||||
@@ -239,6 +237,7 @@ STR_CHAPTER_PREFIX: "Тарау: "
|
|||||||
STR_PAGES_SEPARATOR: " бет | "
|
STR_PAGES_SEPARATOR: " бет | "
|
||||||
STR_BOOK_PREFIX: "Кітап: "
|
STR_BOOK_PREFIX: "Кітап: "
|
||||||
STR_CALIBRE_URL_HINT: "Calibre үшін URL-ге /opds қосыңыз"
|
STR_CALIBRE_URL_HINT: "Calibre үшін URL-ге /opds қосыңыз"
|
||||||
|
STR_PERCENT_STEP_HINT: "Сол/Оң: 1% Жоғары/Төмен: 10%"
|
||||||
STR_SYNCING_TIME: "Уақыт синхрондалуда..."
|
STR_SYNCING_TIME: "Уақыт синхрондалуда..."
|
||||||
STR_CALC_HASH: "Құжат хэші есептелуде..."
|
STR_CALC_HASH: "Құжат хэші есептелуде..."
|
||||||
STR_HASH_FAILED: "Құжат хэшін есептеу сәтсіз"
|
STR_HASH_FAILED: "Құжат хэшін есептеу сәтсіз"
|
||||||
@@ -295,9 +294,9 @@ STR_NO_FOOTNOTES: "Бұл бетте түсіндірме жазбалар жо
|
|||||||
STR_LINK: "[сілтеме]"
|
STR_LINK: "[сілтеме]"
|
||||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u мин"
|
STR_SLEEP_TIMER_VALUE_FORMAT: "%u мин"
|
||||||
STR_SLEEP_NEVER: "Ешқашан"
|
STR_SLEEP_NEVER: "Ешқашан"
|
||||||
STR_STEP_HINT_FRONT: "Алдыңғы түймелер:"
|
STR_SLEEP_TIMER_STEP_HINT: "Сол/Оң: 1 мин Жоғары/Төмен: 5 мин"
|
||||||
STR_STEP_HINT_SIDE: "Бүйір түймелері:"
|
|
||||||
STR_SCREENSHOT_BUTTON: "Скриншот түсіру"
|
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: "Тақырыптарды басқару"
|
||||||
|
|||||||
@@ -65,8 +65,6 @@ STR_SLEEP_COVER_MODE: "Viršelio režimas"
|
|||||||
STR_HIDE_BATTERY: "Slėpti baterijos %"
|
STR_HIDE_BATTERY: "Slėpti baterijos %"
|
||||||
STR_EXTRA_SPACING: "Tarpai tarp pastraipų"
|
STR_EXTRA_SPACING: "Tarpai tarp pastraipų"
|
||||||
STR_TEXT_AA: "Teksto glotninimas"
|
STR_TEXT_AA: "Teksto glotninimas"
|
||||||
STR_TEXT_AA_FULL: "Pilnas"
|
|
||||||
STR_TEXT_AA_FAST: "Greitas"
|
|
||||||
STR_IMAGES: "Paveikslėliai"
|
STR_IMAGES: "Paveikslėliai"
|
||||||
STR_IMAGES_DISPLAY: "Rodyti"
|
STR_IMAGES_DISPLAY: "Rodyti"
|
||||||
STR_IMAGES_PLACEHOLDER: "Vietaženklis"
|
STR_IMAGES_PLACEHOLDER: "Vietaženklis"
|
||||||
@@ -265,6 +263,7 @@ STR_CHAPTER_PREFIX: "Sk: "
|
|||||||
STR_PAGES_SEPARATOR: " psl. | "
|
STR_PAGES_SEPARATOR: " psl. | "
|
||||||
STR_BOOK_PREFIX: "Kn: "
|
STR_BOOK_PREFIX: "Kn: "
|
||||||
STR_CALIBRE_URL_HINT: "Pridėkite /opds prie URL"
|
STR_CALIBRE_URL_HINT: "Pridėkite /opds prie URL"
|
||||||
|
STR_PERCENT_STEP_HINT: "K/D: 1% | V/A: 10%"
|
||||||
STR_SYNCING_TIME: "Sinchr. laikas..."
|
STR_SYNCING_TIME: "Sinchr. laikas..."
|
||||||
STR_CALC_HASH: "Skaičiuojama maiša..."
|
STR_CALC_HASH: "Skaičiuojama maiša..."
|
||||||
STR_HASH_FAILED: "Maišos klaida"
|
STR_HASH_FAILED: "Maišos klaida"
|
||||||
@@ -296,9 +295,9 @@ STR_NO_FOOTNOTES: "Šiame psl. išnašų nėra"
|
|||||||
STR_LINK: "[nuoroda]"
|
STR_LINK: "[nuoroda]"
|
||||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min."
|
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min."
|
||||||
STR_SLEEP_NEVER: "Niekada"
|
STR_SLEEP_NEVER: "Niekada"
|
||||||
STR_STEP_HINT_FRONT: "Priekiniai mygtukai:"
|
STR_SLEEP_TIMER_STEP_HINT: "Kairė/Dešinė: 1 min. Viršus/Apačia: 5 min."
|
||||||
STR_STEP_HINT_SIDE: "Šoniniai mygtukai:"
|
|
||||||
STR_SCREENSHOT_BUTTON: "Ekrano nuotrauka"
|
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"
|
||||||
|
|||||||
@@ -65,8 +65,6 @@ STR_SLEEP_COVER_MODE: "Okładki wygaszacza"
|
|||||||
STR_HIDE_BATTERY: "Ukryj % baterii"
|
STR_HIDE_BATTERY: "Ukryj % baterii"
|
||||||
STR_EXTRA_SPACING: "Dodatkowe odstępy paragrafów"
|
STR_EXTRA_SPACING: "Dodatkowe odstępy paragrafów"
|
||||||
STR_TEXT_AA: "Wygładzanie tekstu"
|
STR_TEXT_AA: "Wygładzanie tekstu"
|
||||||
STR_TEXT_AA_FULL: "Pełne"
|
|
||||||
STR_TEXT_AA_FAST: "Szybkie"
|
|
||||||
STR_IMAGES: "Obrazki"
|
STR_IMAGES: "Obrazki"
|
||||||
STR_IMAGES_DISPLAY: "Pokazuj"
|
STR_IMAGES_DISPLAY: "Pokazuj"
|
||||||
STR_IMAGES_PLACEHOLDER: "Ramki"
|
STR_IMAGES_PLACEHOLDER: "Ramki"
|
||||||
@@ -277,6 +275,7 @@ STR_CHAPTER_PREFIX: "Rozdział: "
|
|||||||
STR_PAGES_SEPARATOR: " stron | "
|
STR_PAGES_SEPARATOR: " stron | "
|
||||||
STR_BOOK_PREFIX: "Książka: "
|
STR_BOOK_PREFIX: "Książka: "
|
||||||
STR_CALIBRE_URL_HINT: "Dla Calibre, dodaj /opds do adresu URL"
|
STR_CALIBRE_URL_HINT: "Dla Calibre, dodaj /opds do adresu URL"
|
||||||
|
STR_PERCENT_STEP_HINT: "Lewo/Prawo: 1% Góra/Dół: 10%"
|
||||||
STR_SYNCING_TIME: "Synchronizacja czasu..."
|
STR_SYNCING_TIME: "Synchronizacja czasu..."
|
||||||
STR_CALC_HASH: "Obliczanie sumy kontrolnej..."
|
STR_CALC_HASH: "Obliczanie sumy kontrolnej..."
|
||||||
STR_HASH_FAILED: "Błąd obliczania sumy kontrolnej"
|
STR_HASH_FAILED: "Błąd obliczania sumy kontrolnej"
|
||||||
@@ -309,8 +308,7 @@ STR_NO_FOOTNOTES: "Brak przypisów na tej stronie"
|
|||||||
STR_LINK: "[link]"
|
STR_LINK: "[link]"
|
||||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
||||||
STR_SLEEP_NEVER: "Nigdy"
|
STR_SLEEP_NEVER: "Nigdy"
|
||||||
STR_STEP_HINT_FRONT: "Przyciski przednie:"
|
STR_SLEEP_TIMER_STEP_HINT: "Lewo/Prawo: 1 min Góra/Dół: 5 min"
|
||||||
STR_STEP_HINT_SIDE: "Przyciski boczne:"
|
|
||||||
STR_SCREENSHOT_BUTTON: "Zrób zrzut ekranu"
|
STR_SCREENSHOT_BUTTON: "Zrób zrzut ekranu"
|
||||||
STR_ADD_SERVER: "Dodaj serwer"
|
STR_ADD_SERVER: "Dodaj serwer"
|
||||||
STR_SERVER_NAME: "Nazwa serwera"
|
STR_SERVER_NAME: "Nazwa serwera"
|
||||||
@@ -362,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"
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ STR_END_OF_BOOK: "Fim do livro"
|
|||||||
STR_EMPTY_CHAPTER: "Capítulo vazio"
|
STR_EMPTY_CHAPTER: "Capítulo vazio"
|
||||||
STR_INDEXING: "Indexando"
|
STR_INDEXING: "Indexando"
|
||||||
STR_MEMORY_ERROR: "Erro de memória"
|
STR_MEMORY_ERROR: "Erro de memória"
|
||||||
STR_PAGE_LOAD_ERROR: "Erro ao carregar pág."
|
STR_PAGE_LOAD_ERROR: "Erro página"
|
||||||
STR_EMPTY_FILE: "Arquivo vazio"
|
STR_EMPTY_FILE: "Arquivo vazio"
|
||||||
STR_OUT_OF_BOUNDS: "Fora dos limites"
|
STR_OUT_OF_BOUNDS: "Fora dos limites"
|
||||||
STR_LOADING: "Carregando..."
|
STR_LOADING: "Carregando..."
|
||||||
@@ -32,8 +32,8 @@ STR_CONNECTING: "Conectando..."
|
|||||||
STR_CONNECTED: "Conectado!"
|
STR_CONNECTED: "Conectado!"
|
||||||
STR_CONNECTION_FAILED: "Falha na conexão"
|
STR_CONNECTION_FAILED: "Falha na conexão"
|
||||||
STR_FORGET_NETWORK: "Esquecer rede?"
|
STR_FORGET_NETWORK: "Esquecer rede?"
|
||||||
STR_SAVE_PASSWORD: "Salvar senha na próxima vez?"
|
STR_SAVE_PASSWORD: "Salvar senha a próxima vez?"
|
||||||
STR_PRESS_OK_SCAN: "Pressione OK para buscar novamente"
|
STR_PRESS_OK_SCAN: "Pressione OK procurar novamente"
|
||||||
STR_JOIN_NETWORK: "Entrar em uma rede"
|
STR_JOIN_NETWORK: "Entrar em uma rede"
|
||||||
STR_CREATE_HOTSPOT: "Criar hotspot"
|
STR_CREATE_HOTSPOT: "Criar hotspot"
|
||||||
STR_JOIN_DESC: "Conecte-se a uma rede Wi-Fi existente"
|
STR_JOIN_DESC: "Conecte-se a uma rede Wi-Fi existente"
|
||||||
@@ -41,7 +41,7 @@ STR_HOTSPOT_DESC: "Crie uma rede Wi-Fi para outras pessoas entrarem"
|
|||||||
STR_STARTING_HOTSPOT: "Iniciando hotspot..."
|
STR_STARTING_HOTSPOT: "Iniciando hotspot..."
|
||||||
STR_HOTSPOT_MODE: "Modo hotspot"
|
STR_HOTSPOT_MODE: "Modo hotspot"
|
||||||
STR_CONNECT_WIFI_HINT: "Conecte seu dispositivo a esta rede Wi-Fi"
|
STR_CONNECT_WIFI_HINT: "Conecte seu dispositivo a esta rede Wi-Fi"
|
||||||
STR_OPEN_URL_HINT: "Abra esta URL no seu navegador"
|
STR_OPEN_URL_HINT: "Abra este URL seu navegador"
|
||||||
STR_OR_HTTP_PREFIX: "ou http://"
|
STR_OR_HTTP_PREFIX: "ou http://"
|
||||||
STR_SCAN_QR_HINT: "ou escaneie o QR code com seu celular:"
|
STR_SCAN_QR_HINT: "ou escaneie o QR code com seu celular:"
|
||||||
STR_CALIBRE_WIRELESS: "Calibre sem fio"
|
STR_CALIBRE_WIRELESS: "Calibre sem fio"
|
||||||
@@ -50,38 +50,30 @@ STR_MAC_ADDRESS: "Endereço MAC:"
|
|||||||
STR_CHECKING_WIFI: "Verificando Wi-Fi..."
|
STR_CHECKING_WIFI: "Verificando Wi-Fi..."
|
||||||
STR_ENTER_WIFI_PASSWORD: "Digite a senha Wi-Fi"
|
STR_ENTER_WIFI_PASSWORD: "Digite a senha Wi-Fi"
|
||||||
STR_TO_PREFIX: "para"
|
STR_TO_PREFIX: "para"
|
||||||
STR_CALIBRE_RECEIVING: "Recebendo: "
|
STR_CALIBRE_RECEIVING: "Recebendo:"
|
||||||
STR_CALIBRE_RECEIVED: "Recebido: "
|
STR_CALIBRE_RECEIVED: "Recebido:"
|
||||||
STR_CALIBRE_INSTRUCTION_1: "1) Instale o plugin CrossPoint Reader"
|
STR_CALIBRE_INSTRUCTION_1: "1) Instale o plugin CrossPoint Reader"
|
||||||
STR_CALIBRE_INSTRUCTION_2: "2) Esteja na mesma rede Wi-Fi"
|
STR_CALIBRE_INSTRUCTION_2: "2) Esteja na mesma rede Wi-Fi"
|
||||||
STR_CALIBRE_INSTRUCTION_3: "3) No Calibre: \"Enviar ao dispositivo\""
|
STR_CALIBRE_INSTRUCTION_3: "3) No Calibre: \"Enviar o dispositivo\""
|
||||||
STR_CALIBRE_INSTRUCTION_4: "\"Mantenha esta tela aberta durante o envio\""
|
STR_CALIBRE_INSTRUCTION_4: "\"Mantenha esta tela aberta durante o envio\""
|
||||||
STR_CAT_DISPLAY: "Tela"
|
STR_CAT_DISPLAY: "Tela"
|
||||||
STR_CAT_READER: "Leitor"
|
STR_CAT_READER: "Leitor"
|
||||||
STR_CAT_CONTROLS: "Controles"
|
STR_CAT_CONTROLS: "Controles"
|
||||||
STR_CAT_SYSTEM: "Sistema"
|
STR_CAT_SYSTEM: "Sistema"
|
||||||
STR_SLEEP_SCREEN: "Tela de repouso"
|
STR_SLEEP_SCREEN: "Tela de repouso"
|
||||||
STR_QUICK_RESUME_TIMEOUT: "Retomada rápida após tempo limite"
|
|
||||||
STR_SLEEP_COVER_MODE: "Modo capa tela repouso"
|
STR_SLEEP_COVER_MODE: "Modo capa tela repouso"
|
||||||
STR_HIDE_BATTERY: "Ocultar % da bateria"
|
STR_HIDE_BATTERY: "Ocultar % da bateria"
|
||||||
STR_EXTRA_SPACING: "Espaço de parágrafos extra"
|
STR_EXTRA_SPACING: "Espaço de parágrafos extra"
|
||||||
STR_TEXT_AA: "Suavização de texto"
|
STR_TEXT_AA: "Suavização de texto"
|
||||||
STR_TEXT_AA_FULL: "Completa"
|
|
||||||
STR_TEXT_AA_FAST: "Rápida"
|
|
||||||
STR_IMAGES: "Imagens"
|
|
||||||
STR_IMAGES_DISPLAY: "Exibição"
|
|
||||||
STR_IMAGES_PLACEHOLDER: "Espaço reservado"
|
|
||||||
STR_IMAGES_SUPPRESS: "Ocultar"
|
|
||||||
STR_SHORT_PWR_BTN: "Clique curto botão ligar"
|
STR_SHORT_PWR_BTN: "Clique curto botão ligar"
|
||||||
STR_ORIENTATION: "Orientação de leitura"
|
STR_ORIENTATION: "Orientação de leitura"
|
||||||
STR_SIDE_BTN_LAYOUT: "Disposição botões laterais"
|
STR_SIDE_BTN_LAYOUT: "Disposição botões laterais"
|
||||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Orientar botões frontais"
|
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Orientar botões frontais"
|
||||||
STR_LONG_PRESS_BEHAVIOR: "Comportamento de Pressionar e segurar"
|
STR_LONG_PRESS_BEHAVIOR: "Comportamento do botão de premir e segurar"
|
||||||
STR_LONG_PRESS_BEHAVIOR_OFF: "DESL."
|
STR_LONG_PRESS_BEHAVIOR_OFF: "Desligado"
|
||||||
STR_LONG_PRESS_BEHAVIOR_SKIP: "Pular capítulo"
|
STR_LONG_PRESS_BEHAVIOR_SKIP: "Saltar capítulo"
|
||||||
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Alterar orientação"
|
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Alterar orientação"
|
||||||
STR_LONG_PRESS_MENU: "Pressionar e segurar Menu"
|
STR_FONT_PREVIEW_TEXT: "Vejo galã sexy pôr quinze kiwis à força em baú achatado"
|
||||||
STR_FONT_PREVIEW_TEXT: "Um pequeno jabuti xereta viu dez cegonhas felizes. "
|
|
||||||
STR_FONT_FAMILY: "Fonte do leitor"
|
STR_FONT_FAMILY: "Fonte do leitor"
|
||||||
STR_FONT_SIZE: "Tam. fonte UI"
|
STR_FONT_SIZE: "Tam. fonte UI"
|
||||||
STR_LINE_SPACING: "Espaçamento entre linhas"
|
STR_LINE_SPACING: "Espaçamento entre linhas"
|
||||||
@@ -89,7 +81,6 @@ STR_SCREEN_MARGIN: "Margens da tela"
|
|||||||
STR_PARA_ALIGNMENT: "Alinhamento parágrafo"
|
STR_PARA_ALIGNMENT: "Alinhamento parágrafo"
|
||||||
STR_HYPHENATION: "Hifenização"
|
STR_HYPHENATION: "Hifenização"
|
||||||
STR_TIME_TO_SLEEP: "Tempo para repousar"
|
STR_TIME_TO_SLEEP: "Tempo para repousar"
|
||||||
STR_SHOW_HIDDEN_FILES: "Mostrar arquivos ocultos"
|
|
||||||
STR_REMOVE_READ_FROM_RECENTS: "Limpar livros lidos da lista de recentes"
|
STR_REMOVE_READ_FROM_RECENTS: "Limpar livros lidos da lista de recentes"
|
||||||
STR_MOVE_FINISHED_TO_READ: "Mover livros lidos para a pasta Read"
|
STR_MOVE_FINISHED_TO_READ: "Mover livros lidos para a pasta Read"
|
||||||
STR_REFRESH_FREQ: "Frequência atualização"
|
STR_REFRESH_FREQ: "Frequência atualização"
|
||||||
@@ -137,7 +128,6 @@ STR_ALWAYS: "Sempre"
|
|||||||
STR_IGNORE: "Ignorar"
|
STR_IGNORE: "Ignorar"
|
||||||
STR_SLEEP: "Repouso"
|
STR_SLEEP: "Repouso"
|
||||||
STR_PAGE_TURN: "Virar página"
|
STR_PAGE_TURN: "Virar página"
|
||||||
STR_FORCE_REFRESH: "Atualizar tela"
|
|
||||||
STR_PORTRAIT: "Retrato"
|
STR_PORTRAIT: "Retrato"
|
||||||
STR_LANDSCAPE_CW: "Paisagem H"
|
STR_LANDSCAPE_CW: "Paisagem H"
|
||||||
STR_INVERTED: "Invertido"
|
STR_INVERTED: "Invertido"
|
||||||
@@ -145,9 +135,6 @@ 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"
|
||||||
STR_KOSYNC: "KOSync"
|
|
||||||
STR_BOOKMARK_OPTION: "Marcador"
|
|
||||||
STR_DISABLED: "Desabilitado"
|
|
||||||
STR_NOTO_SERIF: "Noto Serif"
|
STR_NOTO_SERIF: "Noto Serif"
|
||||||
STR_NOTO_SANS: "Noto Sans"
|
STR_NOTO_SANS: "Noto Sans"
|
||||||
STR_SMALL: "Pequeno"
|
STR_SMALL: "Pequeno"
|
||||||
@@ -175,21 +162,17 @@ STR_UPDATING: "Atualizando..."
|
|||||||
STR_NO_UPDATE: "Nenhuma atualização disponível"
|
STR_NO_UPDATE: "Nenhuma atualização disponível"
|
||||||
STR_UPDATE_FAILED: "Falha na atualização"
|
STR_UPDATE_FAILED: "Falha na atualização"
|
||||||
STR_UPDATE_COMPLETE: "Atualização concluída"
|
STR_UPDATE_COMPLETE: "Atualização concluída"
|
||||||
STR_POWER_ON_HINT: "Pressione e segure o botão de energia para ligar novamente"
|
STR_POWER_ON_HINT: "Pressione e segure o botão energia ligar novamente"
|
||||||
STR_RESTARTING_HINT: "Reiniciando... Se não reiniciar, pressione e segure o botão de energia por alguns segundos."
|
STR_NO_ENTRIES: "Nenhum entries encontrado"
|
||||||
STR_NO_ENTRIES: "Nenhuma entrada encontrada"
|
|
||||||
STR_DOWNLOADING: "Baixando..."
|
STR_DOWNLOADING: "Baixando..."
|
||||||
STR_DOWNLOAD_FAILED: "Falha no download"
|
STR_DOWNLOAD_FAILED: "Falha no download"
|
||||||
STR_ERROR_MSG: "Erro:"
|
STR_ERROR_MSG: "Erro:"
|
||||||
STR_UNNAMED: "Sem nome"
|
STR_UNNAMED: "Sem nome"
|
||||||
STR_HOLD_OPEN_TO_DELETE: "Segure 'Abrir' para excluir"
|
|
||||||
STR_NO_SERVER_URL: "Nenhum URL servidor configurado"
|
STR_NO_SERVER_URL: "Nenhum URL servidor configurado"
|
||||||
STR_FETCH_FEED_FAILED: "Falha ao buscar o feed"
|
STR_FETCH_FEED_FAILED: "Falha ao buscar o feed"
|
||||||
STR_PARSE_FEED_FAILED: "Falha ao interpretar o feed"
|
STR_PARSE_FEED_FAILED: "Falha ao interpretar o feed"
|
||||||
STR_NEXT_PAGE: "Próxima Página »"
|
STR_NETWORK_PREFIX: "Rede:"
|
||||||
STR_PREV_PAGE: "« Página Anterior"
|
STR_IP_ADDRESS_PREFIX: "Endereço IP:"
|
||||||
STR_NETWORK_PREFIX: "Rede: "
|
|
||||||
STR_IP_ADDRESS_PREFIX: "Endereço IP: "
|
|
||||||
STR_ERROR_GENERAL_FAILURE: "Erro: falha geral"
|
STR_ERROR_GENERAL_FAILURE: "Erro: falha geral"
|
||||||
STR_ERROR_NETWORK_NOT_FOUND: "Erro: rede não encontrada"
|
STR_ERROR_NETWORK_NOT_FOUND: "Erro: rede não encontrada"
|
||||||
STR_ERROR_CONNECTION_TIMEOUT: "Erro: tempo limite conexão"
|
STR_ERROR_CONNECTION_TIMEOUT: "Erro: tempo limite conexão"
|
||||||
@@ -198,9 +181,10 @@ STR_BACK: "« Voltar"
|
|||||||
STR_EXIT: "« Sair"
|
STR_EXIT: "« Sair"
|
||||||
STR_HOME: "« Início"
|
STR_HOME: "« Início"
|
||||||
STR_SELECT: "Escolher"
|
STR_SELECT: "Escolher"
|
||||||
STR_SELECTED: "Selecionado"
|
|
||||||
STR_TOGGLE: "Alternar"
|
STR_TOGGLE: "Alternar"
|
||||||
STR_TOGGLE_BOOKMARK: "Alternar marcador"
|
STR_TOGGLE_BOOKMARK: "Alternar marcador"
|
||||||
|
STR_BOOKMARK_REMOVED: "Marcador removido."
|
||||||
|
STR_HOLD_OPEN_TO_DELETE: "Mantenha Abrir pressionado para excluir"
|
||||||
STR_CONFIRM: "Confirmar"
|
STR_CONFIRM: "Confirmar"
|
||||||
STR_CANCEL: "Cancelar"
|
STR_CANCEL: "Cancelar"
|
||||||
STR_CONNECT: "Conectar"
|
STR_CONNECT: "Conectar"
|
||||||
@@ -209,8 +193,6 @@ STR_DOWNLOAD: "Baixar"
|
|||||||
STR_RETRY: "Tentar novamente"
|
STR_RETRY: "Tentar novamente"
|
||||||
STR_YES: "Sim"
|
STR_YES: "Sim"
|
||||||
STR_NO: "Não"
|
STR_NO: "Não"
|
||||||
STR_SHOW: "Mostrar"
|
|
||||||
STR_HIDE: "Ocultar"
|
|
||||||
STR_STATE_ON: "LIG."
|
STR_STATE_ON: "LIG."
|
||||||
STR_STATE_OFF: "DESL."
|
STR_STATE_OFF: "DESL."
|
||||||
STR_NOT_SET: "Não definido"
|
STR_NOT_SET: "Não definido"
|
||||||
@@ -221,51 +203,15 @@ STR_DIR_DOWN: "Baixo"
|
|||||||
STR_OK_BUTTON: "OK"
|
STR_OK_BUTTON: "OK"
|
||||||
STR_SLEEP_COVER_FILTER: "Filtro capa tela repouso"
|
STR_SLEEP_COVER_FILTER: "Filtro capa tela repouso"
|
||||||
STR_FILTER_CONTRAST: "Contraste"
|
STR_FILTER_CONTRAST: "Contraste"
|
||||||
STR_CUSTOMISE_STATUS_BAR: "Customizar Barra de Status"
|
STR_UI_THEME: "Tema da interface"
|
||||||
STR_CHAPTER_PAGE_COUNT: "Páginas do Capítulo"
|
|
||||||
STR_BOOK_PROGRESS_PERCENTAGE: "Porcentagem de Progresso"
|
|
||||||
STR_PROGRESS_BAR: "Barra de Progresso"
|
|
||||||
STR_PROGRESS_BAR_THICKNESS: "Espessura da Barra de Progresso"
|
|
||||||
STR_PROGRESS_BAR_THIN: "Fino"
|
|
||||||
STR_PROGRESS_BAR_MEDIUM: "Médio"
|
|
||||||
STR_PROGRESS_BAR_THICK: "Grosso"
|
|
||||||
STR_BOOK: "Livro"
|
|
||||||
STR_CHAPTER: "Capítulo"
|
|
||||||
STR_EXAMPLE_CHAPTER: "Capítulo 21"
|
|
||||||
STR_EXAMPLE_BOOK: "Título do Livro"
|
|
||||||
STR_PREVIEW: "Prévia"
|
|
||||||
STR_TITLE: "Título"
|
|
||||||
STR_BATTERY: "Bateria"
|
|
||||||
STR_XTC_STATUS_BAR: "Barra de Status XTC"
|
|
||||||
STR_BOTTOM: "Baixo"
|
|
||||||
STR_TOP: "Topo"
|
|
||||||
STR_CLOCK: "Relógio"
|
|
||||||
STR_CLOCK_UTC_OFFSET: "Offset UTC do Relógio"
|
|
||||||
STR_CLOCK_FORMAT: "Formato do Relógio"
|
|
||||||
STR_CLOCK_FORMAT_24H: "24 horas"
|
|
||||||
STR_CLOCK_FORMAT_12H: "12 horas"
|
|
||||||
STR_CURRENT_TIME: "Hora atual:"
|
|
||||||
STR_NEXT_FIELD: "Próximo"
|
|
||||||
STR_CLOCK_SYNC: "Sincronizar Relógio"
|
|
||||||
STR_CLOCK_SYNC_NOW: "Sincronizar relógio agora"
|
|
||||||
STR_CLOCK_SYNCING: "Sincronizando via NTP..."
|
|
||||||
STR_CLOCK_SYNC_OK: "Relógio sincronizado"
|
|
||||||
STR_CLOCK_SYNC_FAIL: "Falha na sincronização"
|
|
||||||
STR_CLOCK_SYNC_NO_WIFI: "Wi-Fi não conectado"
|
|
||||||
STR_CLOCK_SYNC_NO_WIFI_HINT: "Conecte ao Wi-Fi primeiro, depois tente novamente."
|
|
||||||
STR_CLOCK_SYNCED: "Relógio sincronizado"
|
|
||||||
STR_UI_THEME: "Tema"
|
|
||||||
STR_THEME_CLASSIC: "Clássico"
|
STR_THEME_CLASSIC: "Clássico"
|
||||||
STR_THEME_LYRA: "Lyra"
|
STR_THEME_LYRA: "Lyra"
|
||||||
STR_THEME_ROUNDEDRAFF: "RoundedRaff"
|
STR_THEME_ROUNDEDRAFF: "RoundedRaff"
|
||||||
STR_THEME_LYRA_EXTENDED: "Lyra Estendido"
|
STR_THEME_LYRA_EXTENDED: "Lyra Extended"
|
||||||
STR_SUNLIGHT_FADING_FIX: "Ajuste desbotamento ao sol"
|
STR_SUNLIGHT_FADING_FIX: "Ajuste desbotamento ao sol"
|
||||||
|
STR_QUICK_RESUME_TIMEOUT: "Retomada rápida após tempo limite"
|
||||||
STR_REMAP_FRONT_BUTTONS: "Remapear botões frontais"
|
STR_REMAP_FRONT_BUTTONS: "Remapear botões frontais"
|
||||||
STR_BOOKMARKS: "Marcadores"
|
|
||||||
STR_BOOKMARK_ADDED: "Marcador adicionado."
|
|
||||||
STR_BOOKMARK_REMOVED: "Marcador removido."
|
|
||||||
STR_OPDS_BROWSER: "Navegador OPDS"
|
STR_OPDS_BROWSER: "Navegador OPDS"
|
||||||
STR_SEARCH: "Busca"
|
|
||||||
STR_COVER_CUSTOM: "Capa + personalizado"
|
STR_COVER_CUSTOM: "Capa + personalizado"
|
||||||
STR_QUICK_RESUME: "Retomada rápida"
|
STR_QUICK_RESUME: "Retomada rápida"
|
||||||
STR_MENU_RECENT_BOOKS: "Livros recentes"
|
STR_MENU_RECENT_BOOKS: "Livros recentes"
|
||||||
@@ -279,11 +225,11 @@ STR_CALIBRE_SETUP: "Configuração"
|
|||||||
STR_CALIBRE_STATUS: "Status"
|
STR_CALIBRE_STATUS: "Status"
|
||||||
STR_CLEAR_BUTTON: "Limpar"
|
STR_CLEAR_BUTTON: "Limpar"
|
||||||
STR_DEFAULT_VALUE: "Padrão"
|
STR_DEFAULT_VALUE: "Padrão"
|
||||||
STR_REMAP_PROMPT: "Pressione um botão frontal para cada função"
|
STR_REMAP_PROMPT: "Pressione um botão frontal cada função"
|
||||||
STR_UNASSIGNED: "Não atribuído"
|
STR_UNASSIGNED: "Não atribuído"
|
||||||
STR_ALREADY_ASSIGNED: "Já atribuído"
|
STR_ALREADY_ASSIGNED: "Já atribuído"
|
||||||
STR_REMAP_RESET_HINT: "Botão lateral de cima: redefinir layout padrão"
|
STR_REMAP_RESET_HINT: "Botão lateral cima: redefinir o disposição padrão"
|
||||||
STR_REMAP_CANCEL_HINT: "Botão lateral de baixo: cancelar remapeamento"
|
STR_REMAP_CANCEL_HINT: "Botão lateral baixo: cancelar remapeamento"
|
||||||
STR_HW_BACK_LABEL: "Voltar (1º botão)"
|
STR_HW_BACK_LABEL: "Voltar (1º botão)"
|
||||||
STR_HW_CONFIRM_LABEL: "Confirmar (2º botão)"
|
STR_HW_CONFIRM_LABEL: "Confirmar (2º botão)"
|
||||||
STR_HW_LEFT_LABEL: "Esquerda (3º botão)"
|
STR_HW_LEFT_LABEL: "Esquerda (3º botão)"
|
||||||
@@ -293,15 +239,14 @@ STR_GO_HOME_BUTTON: "Ir para o início"
|
|||||||
STR_SYNC_PROGRESS: "Sincronizar progresso"
|
STR_SYNC_PROGRESS: "Sincronizar progresso"
|
||||||
STR_DELETE_CACHE: "Excluir cache do livro"
|
STR_DELETE_CACHE: "Excluir cache do livro"
|
||||||
STR_DELETE: "Excluir"
|
STR_DELETE: "Excluir"
|
||||||
STR_CONFIRM_DELETE_BOOKMARK: "Excluir este marcador?"
|
|
||||||
STR_DISPLAY_QR: "Mostrar página como QR"
|
|
||||||
STR_CHAPTER_PREFIX: "Capítulo:"
|
STR_CHAPTER_PREFIX: "Capítulo:"
|
||||||
STR_PAGES_SEPARATOR: "páginas |"
|
STR_PAGES_SEPARATOR: "páginas |"
|
||||||
STR_BOOK_PREFIX: "Livro:"
|
STR_BOOK_PREFIX: "Livro:"
|
||||||
STR_CALIBRE_URL_HINT: "Para o Calibre, adicione /opds ao seu URL"
|
STR_CALIBRE_URL_HINT: "Para o Calibre, adicione /opds ao seu URL"
|
||||||
|
STR_PERCENT_STEP_HINT: "Esq/Dir: 1% Cima/Baixo: 10%"
|
||||||
STR_SYNCING_TIME: "Sincronizando horário..."
|
STR_SYNCING_TIME: "Sincronizando horário..."
|
||||||
STR_CALC_HASH: "Calculando hash do documento..."
|
STR_CALC_HASH: "Calculando hash documento..."
|
||||||
STR_HASH_FAILED: "Falha ao calcular o hash do documento"
|
STR_HASH_FAILED: "Falha ao calcular o hash documento"
|
||||||
STR_FETCH_PROGRESS: "Buscando progresso remoto..."
|
STR_FETCH_PROGRESS: "Buscando progresso remoto..."
|
||||||
STR_UPLOAD_PROGRESS: "Enviando progresso..."
|
STR_UPLOAD_PROGRESS: "Enviando progresso..."
|
||||||
STR_NO_CREDENTIALS_MSG: "Nenhuma credencial configurada"
|
STR_NO_CREDENTIALS_MSG: "Nenhuma credencial configurada"
|
||||||
@@ -319,69 +264,15 @@ STR_UPLOAD_PROMPT: "Enviar posição atual?"
|
|||||||
STR_UPLOAD_SUCCESS: "Progresso enviado!"
|
STR_UPLOAD_SUCCESS: "Progresso enviado!"
|
||||||
STR_SYNC_FAILED_MSG: "Falha na sincronização"
|
STR_SYNC_FAILED_MSG: "Falha na sincronização"
|
||||||
STR_SAVE_PROGRESS_FAILED: "Não foi possível salvar o progresso"
|
STR_SAVE_PROGRESS_FAILED: "Não foi possível salvar o progresso"
|
||||||
STR_SECTION_PREFIX: "Seção "
|
STR_SECTION_PREFIX: "Seção"
|
||||||
STR_UPLOAD: "Enviar"
|
STR_UPLOAD: "Enviar"
|
||||||
STR_BOOK_S_STYLE: "Estilo do livro"
|
STR_BOOK_S_STYLE: "Estilo do livro"
|
||||||
STR_EMBEDDED_STYLE: "Estilo embutido"
|
STR_EMBEDDED_STYLE: "Estilo embutido"
|
||||||
STR_FOCUS_READING: "Leitura focada"
|
STR_FOCUS_READING: "Leitura focada"
|
||||||
STR_OPDS_SERVER_URL: "URL do servidor OPDS"
|
STR_OPDS_SERVER_URL: "URL do servidor OPDS"
|
||||||
STR_PWR_BTN_FOOTNOTE_BACK: "Retorno rápido de notas de rodapé"
|
|
||||||
STR_SET_SLEEP_COVER: "Definir capa"
|
|
||||||
STR_FOOTNOTES: "Notas de rodapé"
|
|
||||||
STR_NO_FOOTNOTES: "Nenhuma nota de rodapé nesta página"
|
|
||||||
STR_LINK: "[link]"
|
|
||||||
STR_SCREENSHOT_BUTTON: "Capturar tela"
|
STR_SCREENSHOT_BUTTON: "Capturar tela"
|
||||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
||||||
STR_SLEEP_NEVER: "Nunca"
|
STR_SLEEP_NEVER: "Nunca"
|
||||||
STR_STEP_HINT_FRONT: "Botões frontais:"
|
STR_SLEEP_TIMER_STEP_HINT: "Esq/Dir: 1 min Cima/Baixo: 5 min"
|
||||||
STR_STEP_HINT_SIDE: "Botões laterais:"
|
|
||||||
STR_ADD_SERVER: "Adicionar Servidor"
|
|
||||||
STR_SERVER_NAME: "Nome do Servidor"
|
|
||||||
STR_NO_SERVERS: "Nenhum servidor OPDS configurado"
|
|
||||||
STR_DELETE_SERVER: "Excluir Servidor"
|
|
||||||
STR_OPDS_SERVERS: "Servidores OPDS"
|
|
||||||
STR_AUTO_TURN_ENABLED: "Virada automática ativada: "
|
|
||||||
STR_AUTO_TURN_PAGES_PER_MIN: "Virada automática (páginas por minuto)"
|
|
||||||
STR_MANAGE_FONTS: "Gerenciar fontes"
|
|
||||||
STR_FONT_BROWSER: "Navegador de fontes"
|
|
||||||
STR_LOADING_FONT_LIST: "Carregando lista de fontes..."
|
|
||||||
STR_NO_FONTS_AVAILABLE: "Nenhuma fonte disponível"
|
|
||||||
STR_FONT_INSTALLED: "Fonte instalada!"
|
|
||||||
STR_FONT_INSTALL_FAILED: "Falha na instalação da fonte"
|
|
||||||
STR_INSTALLED: "Instalada"
|
|
||||||
STR_DOWNLOAD_ALL: "Baixar tudo"
|
|
||||||
STR_UPDATE_ALL: "Atualizar tudo"
|
|
||||||
STR_UPDATE_AVAILABLE: "Atualizar"
|
|
||||||
STR_CRASH_TITLE: "Falha do sistema"
|
|
||||||
STR_CRASH_DESCRIPTION: "Um relatório detalhado foi salvo em crash_report.txt. Por favor, inclua este arquivo no seu relatório de erro."
|
|
||||||
STR_CRASH_REASON: "Motivo da falha:"
|
|
||||||
STR_CRASH_NO_REASON: "(Nenhum motivo registrado)"
|
|
||||||
STR_TILT_PAGE_TURN: "Virar página por inclinação"
|
STR_TILT_PAGE_TURN: "Virar página por inclinação"
|
||||||
STR_KB_HINT_MOVE_CURSOR: "Pressione ESQ ou DIR para mover o cursor"
|
STR_MANAGE_THEMES: "Gerenciar temas"
|
||||||
STR_KB_HINT_RETURN_CURSOR: "Pressione ESQ para retornar à posição do cursor"
|
|
||||||
STR_KB_HINT_HIDE_PASSWORD: "Segure DIR e pressione [***] para ocultar a senha"
|
|
||||||
STR_KB_HINT_SHOW_PASSWORD: "Segure DIR e pressione [abc] para exibir a senha"
|
|
||||||
STR_KB_HINT_TOGGLE_HIDE_PASSWORD: "Pressione [***] para ocultar a senha"
|
|
||||||
STR_KB_HINT_TOGGLE_SHOW_PASSWORD: "Pressione [abc] para exibir a senha"
|
|
||||||
STR_KB_HINT_EDIT_ENTRY: "Segure CIMA para editar a entrada"
|
|
||||||
STR_KB_TIPS: "Dicas:"
|
|
||||||
STR_KB_HINT_RETURN_KEYBOARD: "Pressione BAIXO para retornar ao teclado"
|
|
||||||
STR_KB_HINT_EXIT_URL_MODE: "Pressione ABC para sair do modo URL"
|
|
||||||
STR_KB_HINT_CLEAR_TEXT: "Segure DEL para limpar todo o texto"
|
|
||||||
STR_KB_HINT_SECONDARY_CHAR: "Segure SELECT para caractere secundário"
|
|
||||||
STR_KB_HINT_UPPER_SECONDARY: "Segure SELECT para MAIÚSCULO ou caractere secundário"
|
|
||||||
STR_KB_HINT_LOWER_SECONDARY: "Segure SELECT para minúsculo ou caractere secundário"
|
|
||||||
STR_KB_HINT_URL_SNIPPETS: "Pressione URL para atalhos"
|
|
||||||
STR_SD_FIRMWARE_UPDATE: "Atualização de firmware via cartão SD"
|
|
||||||
STR_SELECT_FIRMWARE_FILE: "Selecione o arquivo de firmware (.bin)"
|
|
||||||
STR_NO_BIN_FILES: "Nenhum arquivo .bin encontrado"
|
|
||||||
STR_VALIDATING_FIRMWARE: "Validando firmware..."
|
|
||||||
STR_INVALID_FIRMWARE: "Arquivo de firmware inválido"
|
|
||||||
STR_FIRMWARE_TOO_LARGE: "Firmware grande demais para a partição"
|
|
||||||
STR_FIRMWARE_TOO_SMALL: "Arquivo de firmware pequeno demais"
|
|
||||||
STR_FIRMWARE_UPDATE_PROMPT: "Atualizar firmware?"
|
|
||||||
STR_FIRMWARE_FILE_OPEN_FAILED: "Não foi possível abrir o arquivo"
|
|
||||||
STR_FIRMWARE_WRITE_FAILED: "Falha na gravação do firmware"
|
|
||||||
STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "Não desligue o dispositivo!"
|
|
||||||
STR_RECOVERY_MODE: "Modo de Recuperação"
|
|
||||||
STR_RECOVERY_MODE_HINT: "Coloque o arquivo firmware.bin na raiz do cartão SD e selecione-o"
|
|
||||||
|
|||||||
@@ -65,8 +65,6 @@ STR_SLEEP_COVER_MODE: "Mod ecran de repaus cu copertă"
|
|||||||
STR_HIDE_BATTERY: "Ascunde procentul bateriei"
|
STR_HIDE_BATTERY: "Ascunde procentul bateriei"
|
||||||
STR_EXTRA_SPACING: "Spaţiere suplimentară între paragrafe"
|
STR_EXTRA_SPACING: "Spaţiere suplimentară între paragrafe"
|
||||||
STR_TEXT_AA: "Anti-Aliasing text"
|
STR_TEXT_AA: "Anti-Aliasing text"
|
||||||
STR_TEXT_AA_FULL: "Complet"
|
|
||||||
STR_TEXT_AA_FAST: "Rapid"
|
|
||||||
STR_IMAGES: "Imagini"
|
STR_IMAGES: "Imagini"
|
||||||
STR_IMAGES_DISPLAY: "Afişare"
|
STR_IMAGES_DISPLAY: "Afişare"
|
||||||
STR_IMAGES_PLACEHOLDER: "Substituent"
|
STR_IMAGES_PLACEHOLDER: "Substituent"
|
||||||
@@ -268,6 +266,7 @@ STR_CHAPTER_PREFIX: "Capitol: "
|
|||||||
STR_PAGES_SEPARATOR: " pagini | "
|
STR_PAGES_SEPARATOR: " pagini | "
|
||||||
STR_BOOK_PREFIX: "Carte: "
|
STR_BOOK_PREFIX: "Carte: "
|
||||||
STR_CALIBRE_URL_HINT: "Pentru Calibre, adăugaţi /opds la URL"
|
STR_CALIBRE_URL_HINT: "Pentru Calibre, adăugaţi /opds la URL"
|
||||||
|
STR_PERCENT_STEP_HINT: "Stânga/Dreapta: 1% Sus/Jos: 10%"
|
||||||
STR_SYNCING_TIME: "Timp de sincronizare..."
|
STR_SYNCING_TIME: "Timp de sincronizare..."
|
||||||
STR_CALC_HASH: "Calcularea hash-ului documentului..."
|
STR_CALC_HASH: "Calcularea hash-ului documentului..."
|
||||||
STR_HASH_FAILED: "Eşec la calcularea hash-ului documentului"
|
STR_HASH_FAILED: "Eşec la calcularea hash-ului documentului"
|
||||||
@@ -299,9 +298,9 @@ STR_NO_FOOTNOTES: "Nicio notă de subsol"
|
|||||||
STR_LINK: "[link]"
|
STR_LINK: "[link]"
|
||||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
||||||
STR_SLEEP_NEVER: "Niciodată"
|
STR_SLEEP_NEVER: "Niciodată"
|
||||||
STR_STEP_HINT_FRONT: "Butoane frontale:"
|
STR_SLEEP_TIMER_STEP_HINT: "Stânga/Dreapta: 1 min Sus/Jos: 5 min"
|
||||||
STR_STEP_HINT_SIDE: "Butoane laterale:"
|
|
||||||
STR_SCREENSHOT_BUTTON: "Captură ecran"
|
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"
|
||||||
|
|||||||
@@ -66,8 +66,6 @@ STR_SLEEP_COVER_MODE: "Режим обложки сна"
|
|||||||
STR_HIDE_BATTERY: "Скрыть % батареи"
|
STR_HIDE_BATTERY: "Скрыть % батареи"
|
||||||
STR_EXTRA_SPACING: "Доп. интервал абзаца"
|
STR_EXTRA_SPACING: "Доп. интервал абзаца"
|
||||||
STR_TEXT_AA: "Сглаживание текста"
|
STR_TEXT_AA: "Сглаживание текста"
|
||||||
STR_TEXT_AA_FULL: "Полное"
|
|
||||||
STR_TEXT_AA_FAST: "Быстрое"
|
|
||||||
STR_IMAGES: "Изображения"
|
STR_IMAGES: "Изображения"
|
||||||
STR_IMAGES_DISPLAY: "Показать"
|
STR_IMAGES_DISPLAY: "Показать"
|
||||||
STR_IMAGES_PLACEHOLDER: "Заглушки"
|
STR_IMAGES_PLACEHOLDER: "Заглушки"
|
||||||
@@ -300,6 +298,7 @@ STR_CHAPTER_PREFIX: "Глава: "
|
|||||||
STR_PAGES_SEPARATOR: " стр. | "
|
STR_PAGES_SEPARATOR: " стр. | "
|
||||||
STR_BOOK_PREFIX: "Книга: "
|
STR_BOOK_PREFIX: "Книга: "
|
||||||
STR_CALIBRE_URL_HINT: "Для Calibre добавьте /opds к URL"
|
STR_CALIBRE_URL_HINT: "Для Calibre добавьте /opds к URL"
|
||||||
|
STR_PERCENT_STEP_HINT: "Влево/Вправо: 1% Вверх/Вниз: 10%"
|
||||||
STR_SYNCING_TIME: "Синхронизация времени..."
|
STR_SYNCING_TIME: "Синхронизация времени..."
|
||||||
STR_CALC_HASH: "Расчёт хэша документа..."
|
STR_CALC_HASH: "Расчёт хэша документа..."
|
||||||
STR_HASH_FAILED: "Не удалось вычислить хэш документа"
|
STR_HASH_FAILED: "Не удалось вычислить хэш документа"
|
||||||
@@ -333,8 +332,7 @@ STR_LINK: "[ссылка]"
|
|||||||
STR_SCREENSHOT_BUTTON: "Сделать снимок экрана"
|
STR_SCREENSHOT_BUTTON: "Сделать снимок экрана"
|
||||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u мин"
|
STR_SLEEP_TIMER_VALUE_FORMAT: "%u мин"
|
||||||
STR_SLEEP_NEVER: "Никогда"
|
STR_SLEEP_NEVER: "Никогда"
|
||||||
STR_STEP_HINT_FRONT: "Передние кнопки:"
|
STR_SLEEP_TIMER_STEP_HINT: "Влево/Вправо: 1 мин Вверх/Вниз: 5 мин"
|
||||||
STR_STEP_HINT_SIDE: "Боковые кнопки:"
|
|
||||||
STR_ADD_SERVER: "Добавить сервер"
|
STR_ADD_SERVER: "Добавить сервер"
|
||||||
STR_SERVER_NAME: "Имя сервера"
|
STR_SERVER_NAME: "Имя сервера"
|
||||||
STR_NO_SERVERS: "Нет настроенных серверов OPDS"
|
STR_NO_SERVERS: "Нет настроенных серверов OPDS"
|
||||||
@@ -385,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: "Управление темами"
|
||||||
|
|||||||
@@ -66,8 +66,6 @@ STR_SLEEP_COVER_MODE: "Obrazovka spánku – režim krytu"
|
|||||||
STR_HIDE_BATTERY: "Skryť % batérie"
|
STR_HIDE_BATTERY: "Skryť % batérie"
|
||||||
STR_EXTRA_SPACING: "Dodatočné medzery medzi odsekmi"
|
STR_EXTRA_SPACING: "Dodatočné medzery medzi odsekmi"
|
||||||
STR_TEXT_AA: "Vyhladzovanie textu"
|
STR_TEXT_AA: "Vyhladzovanie textu"
|
||||||
STR_TEXT_AA_FULL: "Plné"
|
|
||||||
STR_TEXT_AA_FAST: "Rýchle"
|
|
||||||
STR_IMAGES: "Obrázky"
|
STR_IMAGES: "Obrázky"
|
||||||
STR_IMAGES_DISPLAY: "Zobraz"
|
STR_IMAGES_DISPLAY: "Zobraz"
|
||||||
STR_IMAGES_PLACEHOLDER: "Rezervované miesto"
|
STR_IMAGES_PLACEHOLDER: "Rezervované miesto"
|
||||||
@@ -296,6 +294,7 @@ STR_CHAPTER_PREFIX: "Kapitola:"
|
|||||||
STR_PAGES_SEPARATOR: "strán |"
|
STR_PAGES_SEPARATOR: "strán |"
|
||||||
STR_BOOK_PREFIX: "Kniha:"
|
STR_BOOK_PREFIX: "Kniha:"
|
||||||
STR_CALIBRE_URL_HINT: "Pre Calibre pridajte /opds na koniec URL adresy"
|
STR_CALIBRE_URL_HINT: "Pre Calibre pridajte /opds na koniec URL adresy"
|
||||||
|
STR_PERCENT_STEP_HINT: "Vľavo/Vpravo: 1 % Hore/Dole: 10 %"
|
||||||
STR_SYNCING_TIME: "Čas synchronizácie..."
|
STR_SYNCING_TIME: "Čas synchronizácie..."
|
||||||
STR_CALC_HASH: "Výpočet hashu dokumentu..."
|
STR_CALC_HASH: "Výpočet hashu dokumentu..."
|
||||||
STR_HASH_FAILED: "Nepodarilo sa vypočítať hash dokumentu"
|
STR_HASH_FAILED: "Nepodarilo sa vypočítať hash dokumentu"
|
||||||
@@ -329,8 +328,7 @@ STR_LINK: "[link]"
|
|||||||
STR_SCREENSHOT_BUTTON: "Urobiť snímku obrazovky"
|
STR_SCREENSHOT_BUTTON: "Urobiť snímku obrazovky"
|
||||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
||||||
STR_SLEEP_NEVER: "Nikdy"
|
STR_SLEEP_NEVER: "Nikdy"
|
||||||
STR_STEP_HINT_FRONT: "Predné tlačidlá:"
|
STR_SLEEP_TIMER_STEP_HINT: "Vľavo/Vpravo: 1 min Hore/Dole: 5 min"
|
||||||
STR_STEP_HINT_SIDE: "Bočné tlačidlá:"
|
|
||||||
STR_ADD_SERVER: "Pridať server"
|
STR_ADD_SERVER: "Pridať server"
|
||||||
STR_SERVER_NAME: "Názov servera"
|
STR_SERVER_NAME: "Názov servera"
|
||||||
STR_NO_SERVERS: "Nie sú nakonfigurované žiadne OPDS servery"
|
STR_NO_SERVERS: "Nie sú nakonfigurované žiadne OPDS servery"
|
||||||
|
|||||||
@@ -65,8 +65,6 @@ STR_SLEEP_COVER_MODE: "Način naslovnice v spanju"
|
|||||||
STR_HIDE_BATTERY: "Skrij % baterije"
|
STR_HIDE_BATTERY: "Skrij % baterije"
|
||||||
STR_EXTRA_SPACING: "Dodaten razmik med odstavki"
|
STR_EXTRA_SPACING: "Dodaten razmik med odstavki"
|
||||||
STR_TEXT_AA: "Glajenje besedila (AA)"
|
STR_TEXT_AA: "Glajenje besedila (AA)"
|
||||||
STR_TEXT_AA_FULL: "Polno"
|
|
||||||
STR_TEXT_AA_FAST: "Hitro"
|
|
||||||
STR_IMAGES: "Slike"
|
STR_IMAGES: "Slike"
|
||||||
STR_IMAGES_DISPLAY: "Prikaži"
|
STR_IMAGES_DISPLAY: "Prikaži"
|
||||||
STR_IMAGES_PLACEHOLDER: "Oznaka mesta"
|
STR_IMAGES_PLACEHOLDER: "Oznaka mesta"
|
||||||
@@ -265,6 +263,7 @@ STR_CHAPTER_PREFIX: "Poglavje: "
|
|||||||
STR_PAGES_SEPARATOR: " strani | "
|
STR_PAGES_SEPARATOR: " strani | "
|
||||||
STR_BOOK_PREFIX: "Knjiga: "
|
STR_BOOK_PREFIX: "Knjiga: "
|
||||||
STR_CALIBRE_URL_HINT: "Za Calibre dodaj /opds svojemu URL-ju"
|
STR_CALIBRE_URL_HINT: "Za Calibre dodaj /opds svojemu URL-ju"
|
||||||
|
STR_PERCENT_STEP_HINT: "Levo/desno: 1% Gor/dol: 10%"
|
||||||
STR_SYNCING_TIME: "Sinhronizacija časa..."
|
STR_SYNCING_TIME: "Sinhronizacija časa..."
|
||||||
STR_CALC_HASH: "Izračunavanje podpisa dokumenta..."
|
STR_CALC_HASH: "Izračunavanje podpisa dokumenta..."
|
||||||
STR_HASH_FAILED: "Izračun podpisa dokumenta ni uspel"
|
STR_HASH_FAILED: "Izračun podpisa dokumenta ni uspel"
|
||||||
@@ -296,9 +295,9 @@ STR_NO_FOOTNOTES: "Na tej strani ni opomb"
|
|||||||
STR_LINK: "[povezava]"
|
STR_LINK: "[povezava]"
|
||||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
||||||
STR_SLEEP_NEVER: "Nikoli"
|
STR_SLEEP_NEVER: "Nikoli"
|
||||||
STR_STEP_HINT_FRONT: "Sprednji gumbi:"
|
STR_SLEEP_TIMER_STEP_HINT: "Levo/desno: 1 min Gor/dol: 5 min"
|
||||||
STR_STEP_HINT_SIDE: "Stranski gumbi:"
|
|
||||||
STR_SCREENSHOT_BUTTON: "Naredi posnetek zaslona"
|
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"
|
||||||
|
|||||||
@@ -65,8 +65,6 @@ STR_SLEEP_COVER_MODE: "Modo de pantalla de suspensión"
|
|||||||
STR_HIDE_BATTERY: "Ocultar % de batería"
|
STR_HIDE_BATTERY: "Ocultar % de batería"
|
||||||
STR_EXTRA_SPACING: "Espaciado entre párrafos"
|
STR_EXTRA_SPACING: "Espaciado entre párrafos"
|
||||||
STR_TEXT_AA: "Suavizado de texto"
|
STR_TEXT_AA: "Suavizado de texto"
|
||||||
STR_TEXT_AA_FULL: "Completo"
|
|
||||||
STR_TEXT_AA_FAST: "Rápido"
|
|
||||||
STR_IMAGES: "Imágenes"
|
STR_IMAGES: "Imágenes"
|
||||||
STR_IMAGES_DISPLAY: "Mostrar"
|
STR_IMAGES_DISPLAY: "Mostrar"
|
||||||
STR_IMAGES_PLACEHOLDER: "Reemplazar"
|
STR_IMAGES_PLACEHOLDER: "Reemplazar"
|
||||||
@@ -299,6 +297,7 @@ STR_CHAPTER_PREFIX: "Cap.: "
|
|||||||
STR_PAGES_SEPARATOR: " pág. | "
|
STR_PAGES_SEPARATOR: " pág. | "
|
||||||
STR_BOOK_PREFIX: "Libro: "
|
STR_BOOK_PREFIX: "Libro: "
|
||||||
STR_CALIBRE_URL_HINT: "Para Calibre, agregue /opds a su URL"
|
STR_CALIBRE_URL_HINT: "Para Calibre, agregue /opds a su URL"
|
||||||
|
STR_PERCENT_STEP_HINT: "Izq./Dcha.: 1% | Subir/Bajar: 10%"
|
||||||
STR_SYNCING_TIME: "Tiempo de sincronización..."
|
STR_SYNCING_TIME: "Tiempo de sincronización..."
|
||||||
STR_CALC_HASH: "Calculando hash del documento..."
|
STR_CALC_HASH: "Calculando hash del documento..."
|
||||||
STR_HASH_FAILED: "No se pudo calcular el hash del documento"
|
STR_HASH_FAILED: "No se pudo calcular el hash del documento"
|
||||||
@@ -332,8 +331,7 @@ STR_NO_FOOTNOTES: "No hay notas al pie de esta página"
|
|||||||
STR_LINK: "[enlace]"
|
STR_LINK: "[enlace]"
|
||||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min."
|
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min."
|
||||||
STR_SLEEP_NEVER: "Nunca"
|
STR_SLEEP_NEVER: "Nunca"
|
||||||
STR_STEP_HINT_FRONT: "Botones frontales:"
|
STR_SLEEP_TIMER_STEP_HINT: "Izq./Dcha.: 1 min. Subir/Bajar: 5 min."
|
||||||
STR_STEP_HINT_SIDE: "Botones laterales:"
|
|
||||||
STR_SCREENSHOT_BUTTON: "Tomar captura de pantalla"
|
STR_SCREENSHOT_BUTTON: "Tomar captura de pantalla"
|
||||||
STR_ADD_SERVER: "Agregar servidor"
|
STR_ADD_SERVER: "Agregar servidor"
|
||||||
STR_SERVER_NAME: "Nombre de servidor"
|
STR_SERVER_NAME: "Nombre de servidor"
|
||||||
@@ -385,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"
|
||||||
|
|||||||
@@ -66,8 +66,6 @@ STR_SLEEP_COVER_MODE: "Viloskärmens omslagsläge"
|
|||||||
STR_HIDE_BATTERY: "Dölj batteriprocent"
|
STR_HIDE_BATTERY: "Dölj batteriprocent"
|
||||||
STR_EXTRA_SPACING: "Extra paragrafmellanrum"
|
STR_EXTRA_SPACING: "Extra paragrafmellanrum"
|
||||||
STR_TEXT_AA: "Textkantutjämning"
|
STR_TEXT_AA: "Textkantutjämning"
|
||||||
STR_TEXT_AA_FULL: "Full"
|
|
||||||
STR_TEXT_AA_FAST: "Snabb"
|
|
||||||
STR_IMAGES: "Bilder"
|
STR_IMAGES: "Bilder"
|
||||||
STR_IMAGES_DISPLAY: "Visa"
|
STR_IMAGES_DISPLAY: "Visa"
|
||||||
STR_IMAGES_PLACEHOLDER: "Platshållare"
|
STR_IMAGES_PLACEHOLDER: "Platshållare"
|
||||||
@@ -297,6 +295,7 @@ STR_CHAPTER_PREFIX: "Kapitel:"
|
|||||||
STR_PAGES_SEPARATOR: " sidor | "
|
STR_PAGES_SEPARATOR: " sidor | "
|
||||||
STR_BOOK_PREFIX: "Bok:"
|
STR_BOOK_PREFIX: "Bok:"
|
||||||
STR_CALIBRE_URL_HINT: "För Calibre: lägg till /opds i din adress"
|
STR_CALIBRE_URL_HINT: "För Calibre: lägg till /opds i din adress"
|
||||||
|
STR_PERCENT_STEP_HINT: "Vänster/Höger: 1% Upp/Ner 10%"
|
||||||
STR_SYNCING_TIME: "Synkroniserar tid…"
|
STR_SYNCING_TIME: "Synkroniserar tid…"
|
||||||
STR_CALC_HASH: "Beräknar dokumenthash"
|
STR_CALC_HASH: "Beräknar dokumenthash"
|
||||||
STR_HASH_FAILED: "Misslyckades att beräkna dokumenthash"
|
STR_HASH_FAILED: "Misslyckades att beräkna dokumenthash"
|
||||||
@@ -329,8 +328,7 @@ STR_NO_FOOTNOTES: "Inga fotnoter på den här sidan"
|
|||||||
STR_LINK: "[länk]"
|
STR_LINK: "[länk]"
|
||||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
||||||
STR_SLEEP_NEVER: "Aldrig"
|
STR_SLEEP_NEVER: "Aldrig"
|
||||||
STR_STEP_HINT_FRONT: "Framknappar:"
|
STR_SLEEP_TIMER_STEP_HINT: "Vänster/Höger: 1 min Upp/Ner: 5 min"
|
||||||
STR_STEP_HINT_SIDE: "Sidoknappar:"
|
|
||||||
STR_SCREENSHOT_BUTTON: "Ta en skärmdump"
|
STR_SCREENSHOT_BUTTON: "Ta en skärmdump"
|
||||||
STR_ADD_SERVER: "Lägg till server"
|
STR_ADD_SERVER: "Lägg till server"
|
||||||
STR_SERVER_NAME: "Servernamn"
|
STR_SERVER_NAME: "Servernamn"
|
||||||
@@ -382,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"
|
||||||
|
|||||||
@@ -64,8 +64,6 @@ STR_SLEEP_COVER_MODE: "Uyku Ekranı Kapak Modu"
|
|||||||
STR_HIDE_BATTERY: "Pil Yüzdesini Gizle"
|
STR_HIDE_BATTERY: "Pil Yüzdesini Gizle"
|
||||||
STR_EXTRA_SPACING: "Ekstra Paragraf Boşluğu"
|
STR_EXTRA_SPACING: "Ekstra Paragraf Boşluğu"
|
||||||
STR_TEXT_AA: "Metin Yumuşatma (AA)"
|
STR_TEXT_AA: "Metin Yumuşatma (AA)"
|
||||||
STR_TEXT_AA_FULL: "Tam"
|
|
||||||
STR_TEXT_AA_FAST: "Hızlı"
|
|
||||||
STR_SHORT_PWR_BTN: "Kısa Güç Tuşu Tıklaması"
|
STR_SHORT_PWR_BTN: "Kısa Güç Tuşu Tıklaması"
|
||||||
STR_ORIENTATION: "Okuma Yönü"
|
STR_ORIENTATION: "Okuma Yönü"
|
||||||
STR_SIDE_BTN_LAYOUT: "Yan Tuş Dizilimi (okuyucu)"
|
STR_SIDE_BTN_LAYOUT: "Yan Tuş Dizilimi (okuyucu)"
|
||||||
@@ -243,6 +241,7 @@ STR_CHAPTER_PREFIX: "Bölüm: "
|
|||||||
STR_PAGES_SEPARATOR: " sayfa | "
|
STR_PAGES_SEPARATOR: " sayfa | "
|
||||||
STR_BOOK_PREFIX: "Kitap: "
|
STR_BOOK_PREFIX: "Kitap: "
|
||||||
STR_CALIBRE_URL_HINT: "Calibre için URL'nize /opds ekleyin"
|
STR_CALIBRE_URL_HINT: "Calibre için URL'nize /opds ekleyin"
|
||||||
|
STR_PERCENT_STEP_HINT: "Sol/Sağ: %1 Yukarı/Aşağı: %10"
|
||||||
STR_SYNCING_TIME: "Zaman senkronize ediliyor..."
|
STR_SYNCING_TIME: "Zaman senkronize ediliyor..."
|
||||||
STR_CALC_HASH: "Belge özeti hesaplanıyor..."
|
STR_CALC_HASH: "Belge özeti hesaplanıyor..."
|
||||||
STR_HASH_FAILED: "Belge özeti hesaplanamadı"
|
STR_HASH_FAILED: "Belge özeti hesaplanamadı"
|
||||||
@@ -290,8 +289,7 @@ STR_IMAGES_SUPPRESS: "Bastır"
|
|||||||
STR_LINK: "[bağlantı]"
|
STR_LINK: "[bağlantı]"
|
||||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u dak"
|
STR_SLEEP_TIMER_VALUE_FORMAT: "%u dak"
|
||||||
STR_SLEEP_NEVER: "Asla"
|
STR_SLEEP_NEVER: "Asla"
|
||||||
STR_STEP_HINT_FRONT: "Ön tuşlar:"
|
STR_SLEEP_TIMER_STEP_HINT: "Sol/Sağ: 1 dak Yukarı/Aşağı: 5 dak"
|
||||||
STR_STEP_HINT_SIDE: "Yan tuşlar:"
|
|
||||||
STR_NO_FILES_FOUND: "Dosya bulunamadı"
|
STR_NO_FILES_FOUND: "Dosya bulunamadı"
|
||||||
STR_NO_FOOTNOTES: "Bu sayfada dipnot yok"
|
STR_NO_FOOTNOTES: "Bu sayfada dipnot yok"
|
||||||
STR_PREVIEW: "Önizleme"
|
STR_PREVIEW: "Önizleme"
|
||||||
@@ -305,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"
|
||||||
|
|||||||
@@ -66,8 +66,6 @@ STR_SLEEP_COVER_MODE: "Режим заповнення"
|
|||||||
STR_HIDE_BATTERY: "Приховати % батареї"
|
STR_HIDE_BATTERY: "Приховати % батареї"
|
||||||
STR_EXTRA_SPACING: "Додатковий інтервал між абзацами"
|
STR_EXTRA_SPACING: "Додатковий інтервал між абзацами"
|
||||||
STR_TEXT_AA: "Згладжування тексту"
|
STR_TEXT_AA: "Згладжування тексту"
|
||||||
STR_TEXT_AA_FULL: "Повне"
|
|
||||||
STR_TEXT_AA_FAST: "Швидке"
|
|
||||||
STR_IMAGES: "Зображення"
|
STR_IMAGES: "Зображення"
|
||||||
STR_IMAGES_DISPLAY: "Показати"
|
STR_IMAGES_DISPLAY: "Показати"
|
||||||
STR_IMAGES_PLACEHOLDER: "Заглушка"
|
STR_IMAGES_PLACEHOLDER: "Заглушка"
|
||||||
@@ -296,6 +294,7 @@ STR_CHAPTER_PREFIX: "Розділ: "
|
|||||||
STR_PAGES_SEPARATOR: " сторінок | "
|
STR_PAGES_SEPARATOR: " сторінок | "
|
||||||
STR_BOOK_PREFIX: "Книга: "
|
STR_BOOK_PREFIX: "Книга: "
|
||||||
STR_CALIBRE_URL_HINT: "Для Calibre додайте /opds до вашої URL"
|
STR_CALIBRE_URL_HINT: "Для Calibre додайте /opds до вашої URL"
|
||||||
|
STR_PERCENT_STEP_HINT: "Вліво/Вправо: 1% Вгору/Вниз: 10%"
|
||||||
STR_SYNCING_TIME: "Синхронізація часу..."
|
STR_SYNCING_TIME: "Синхронізація часу..."
|
||||||
STR_CALC_HASH: "Обчислення хешу документа..."
|
STR_CALC_HASH: "Обчислення хешу документа..."
|
||||||
STR_HASH_FAILED: "Не вдалося обчислити хеш документа"
|
STR_HASH_FAILED: "Не вдалося обчислити хеш документа"
|
||||||
@@ -330,8 +329,7 @@ STR_LINK: "[посилання]"
|
|||||||
STR_SCREENSHOT_BUTTON: "Знімок екрана"
|
STR_SCREENSHOT_BUTTON: "Знімок екрана"
|
||||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u хв"
|
STR_SLEEP_TIMER_VALUE_FORMAT: "%u хв"
|
||||||
STR_SLEEP_NEVER: "Ніколи"
|
STR_SLEEP_NEVER: "Ніколи"
|
||||||
STR_STEP_HINT_FRONT: "Передні кнопки:"
|
STR_SLEEP_TIMER_STEP_HINT: "Вліво/Вправо: 1 хв Вгору/Вниз: 5 хв"
|
||||||
STR_STEP_HINT_SIDE: "Бічні кнопки:"
|
|
||||||
STR_ADD_SERVER: "Додати сервер"
|
STR_ADD_SERVER: "Додати сервер"
|
||||||
STR_SERVER_NAME: "Назва сервера"
|
STR_SERVER_NAME: "Назва сервера"
|
||||||
STR_NO_SERVERS: "Не налаштовано жодного сервера OPDS"
|
STR_NO_SERVERS: "Не налаштовано жодного сервера OPDS"
|
||||||
@@ -382,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: "Керування темами"
|
||||||
|
|||||||
@@ -66,8 +66,6 @@ STR_SLEEP_COVER_MODE: "Mode de pantalla de repòs"
|
|||||||
STR_HIDE_BATTERY: "Oculta el % de bateria"
|
STR_HIDE_BATTERY: "Oculta el % de bateria"
|
||||||
STR_EXTRA_SPACING: "Espaiat de paràgraf extra"
|
STR_EXTRA_SPACING: "Espaiat de paràgraf extra"
|
||||||
STR_TEXT_AA: "Antialiàsing del text"
|
STR_TEXT_AA: "Antialiàsing del text"
|
||||||
STR_TEXT_AA_FULL: "Complet"
|
|
||||||
STR_TEXT_AA_FAST: "Ràpid"
|
|
||||||
STR_IMAGES: "Imatges"
|
STR_IMAGES: "Imatges"
|
||||||
STR_IMAGES_DISPLAY: "Mostrar"
|
STR_IMAGES_DISPLAY: "Mostrar"
|
||||||
STR_IMAGES_PLACEHOLDER: "Text de mostra"
|
STR_IMAGES_PLACEHOLDER: "Text de mostra"
|
||||||
@@ -91,8 +89,7 @@ STR_HYPHENATION: "Partició de mots"
|
|||||||
STR_TIME_TO_SLEEP: "Temps per entrar en repòs"
|
STR_TIME_TO_SLEEP: "Temps per entrar en repòs"
|
||||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
||||||
STR_SLEEP_NEVER: "Mai"
|
STR_SLEEP_NEVER: "Mai"
|
||||||
STR_STEP_HINT_FRONT: "Botons frontals:"
|
STR_SLEEP_TIMER_STEP_HINT: "Esquerra/Dreta: 1 min Amunt/Avall: 5 min"
|
||||||
STR_STEP_HINT_SIDE: "Botons laterals:"
|
|
||||||
STR_SHOW_HIDDEN_FILES: "Mostra arxius ocults"
|
STR_SHOW_HIDDEN_FILES: "Mostra arxius ocults"
|
||||||
STR_REMOVE_READ_FROM_RECENTS: "Esborra els llibres llegits de la llista de recents"
|
STR_REMOVE_READ_FROM_RECENTS: "Esborra els llibres llegits de la llista de recents"
|
||||||
STR_MOVE_FINISHED_TO_READ: "Mou els llibres acabats a la carpeta Read"
|
STR_MOVE_FINISHED_TO_READ: "Mou els llibres acabats a la carpeta Read"
|
||||||
@@ -279,6 +276,7 @@ STR_CHAPTER_PREFIX: "Capítol: "
|
|||||||
STR_PAGES_SEPARATOR: " pàgines | "
|
STR_PAGES_SEPARATOR: " pàgines | "
|
||||||
STR_BOOK_PREFIX: "Llibre: "
|
STR_BOOK_PREFIX: "Llibre: "
|
||||||
STR_CALIBRE_URL_HINT: "Per al Calibre, afegiu /opds a la URL"
|
STR_CALIBRE_URL_HINT: "Per al Calibre, afegiu /opds a la URL"
|
||||||
|
STR_PERCENT_STEP_HINT: "Esquerra/Dreta: 1% Amunt/Avall: 10%"
|
||||||
STR_SYNCING_TIME: "S'està sincronitzant el temps..."
|
STR_SYNCING_TIME: "S'està sincronitzant el temps..."
|
||||||
STR_CALC_HASH: "S'està calculant el hash del document..."
|
STR_CALC_HASH: "S'està calculant el hash del document..."
|
||||||
STR_HASH_FAILED: "No s'ha pogut calcular el hash del document"
|
STR_HASH_FAILED: "No s'ha pogut calcular el hash del document"
|
||||||
@@ -385,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"
|
||||||
|
|||||||
@@ -66,8 +66,6 @@ STR_SLEEP_COVER_MODE: "Kiểu ảnh bìa màn hình ngủ"
|
|||||||
STR_HIDE_BATTERY: "Ẩn % pin"
|
STR_HIDE_BATTERY: "Ẩn % pin"
|
||||||
STR_EXTRA_SPACING: "Giãn cách đoạn thêm"
|
STR_EXTRA_SPACING: "Giãn cách đoạn thêm"
|
||||||
STR_TEXT_AA: "Khử răng cưa chữ"
|
STR_TEXT_AA: "Khử răng cưa chữ"
|
||||||
STR_TEXT_AA_FULL: "Đầy đủ"
|
|
||||||
STR_TEXT_AA_FAST: "Nhanh"
|
|
||||||
STR_IMAGES: "Hình ảnh"
|
STR_IMAGES: "Hình ảnh"
|
||||||
STR_IMAGES_DISPLAY: "Hiển thị"
|
STR_IMAGES_DISPLAY: "Hiển thị"
|
||||||
STR_IMAGES_PLACEHOLDER: "Khung thay thế"
|
STR_IMAGES_PLACEHOLDER: "Khung thay thế"
|
||||||
@@ -296,6 +294,7 @@ STR_CHAPTER_PREFIX: "Chương: "
|
|||||||
STR_PAGES_SEPARATOR: " trang | "
|
STR_PAGES_SEPARATOR: " trang | "
|
||||||
STR_BOOK_PREFIX: "Sách: "
|
STR_BOOK_PREFIX: "Sách: "
|
||||||
STR_CALIBRE_URL_HINT: "Với Calibre, thêm /opds vào URL"
|
STR_CALIBRE_URL_HINT: "Với Calibre, thêm /opds vào URL"
|
||||||
|
STR_PERCENT_STEP_HINT: "Trái/Phải: 1% Lên/Xuống: 10%"
|
||||||
STR_SYNCING_TIME: "Đang đồng bộ giờ..."
|
STR_SYNCING_TIME: "Đang đồng bộ giờ..."
|
||||||
STR_CALC_HASH: "Đang tính mã băm tài liệu..."
|
STR_CALC_HASH: "Đang tính mã băm tài liệu..."
|
||||||
STR_HASH_FAILED: "Không tính được mã băm tài liệu"
|
STR_HASH_FAILED: "Không tính được mã băm tài liệu"
|
||||||
@@ -329,8 +328,7 @@ STR_LINK: "[liên kết]"
|
|||||||
STR_SCREENSHOT_BUTTON: "Chụp màn hình"
|
STR_SCREENSHOT_BUTTON: "Chụp màn hình"
|
||||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u phút"
|
STR_SLEEP_TIMER_VALUE_FORMAT: "%u phút"
|
||||||
STR_SLEEP_NEVER: "Không bao giờ"
|
STR_SLEEP_NEVER: "Không bao giờ"
|
||||||
STR_STEP_HINT_FRONT: "Nút trước:"
|
STR_SLEEP_TIMER_STEP_HINT: "Trái/Phải: 1 phút Lên/Xuống: 5 phút"
|
||||||
STR_STEP_HINT_SIDE: "Nút bên:"
|
|
||||||
STR_ADD_SERVER: "Thêm máy chủ"
|
STR_ADD_SERVER: "Thêm máy chủ"
|
||||||
STR_SERVER_NAME: "Tên máy chủ"
|
STR_SERVER_NAME: "Tên máy chủ"
|
||||||
STR_NO_SERVERS: "Chưa cấu hình máy chủ OPDS"
|
STR_NO_SERVERS: "Chưa cấu hình máy chủ OPDS"
|
||||||
|
|||||||
@@ -278,18 +278,13 @@ class XPathParagraphResolver final : public Print {
|
|||||||
path.push_back({name, siblingIndex});
|
path.push_back({name, siblingIndex});
|
||||||
parentStates.emplace_back();
|
parentStates.emplace_back();
|
||||||
|
|
||||||
// Count both <p> and <li> as paragraph-like positions, matching how the section
|
|
||||||
// layout tracks them (xpathParagraphIndex and xpathListItemIndex). This ensures
|
|
||||||
// KOReader progress in list items maps to the correct XPath.
|
|
||||||
if (name == "p") {
|
if (name == "p") {
|
||||||
paragraphCount++;
|
paragraphCount++;
|
||||||
} else if (name == "li") {
|
if (paragraphCount == targetParagraph) {
|
||||||
paragraphCount++;
|
xpath = buildParagraphXPath(spineIndex, path, 0, 0);
|
||||||
}
|
stopped = true;
|
||||||
if (paragraphCount == targetParagraph) {
|
XML_StopParser(parser, XML_FALSE);
|
||||||
xpath = buildParagraphXPath(spineIndex, path, 0, 0);
|
}
|
||||||
stopped = true;
|
|
||||||
XML_StopParser(parser, XML_FALSE);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
depth++;
|
depth++;
|
||||||
|
|||||||
@@ -709,13 +709,12 @@ SavedProgressPosition ProgressMapper::toSavedProgress(const std::shared_ptr<Epub
|
|||||||
float intra =
|
float intra =
|
||||||
(pos.totalPages > 1) ? static_cast<float>(pos.pageNumber) / static_cast<float>(pos.totalPages - 1) : 0.0f;
|
(pos.totalPages > 1) ? static_cast<float>(pos.pageNumber) / static_cast<float>(pos.totalPages - 1) : 0.0f;
|
||||||
result.percentage = epub->calculateProgress(pos.spineIndex, intra);
|
result.percentage = epub->calculateProgress(pos.spineIndex, intra);
|
||||||
if (pos.hasParagraphIndex && pos.paragraphIndex > 0) {
|
// Progress-based XPath correctly handles both <p> and <li> positions.
|
||||||
|
result.xpath = ChapterXPathResolver::findXPathForProgress(epub, pos.spineIndex, intra);
|
||||||
|
// Fall back to paragraph-index lookup when progress-based resolution fails.
|
||||||
|
if (result.xpath.empty() && pos.hasParagraphIndex && pos.paragraphIndex > 0) {
|
||||||
result.xpath = ChapterXPathResolver::findXPathForParagraph(epub, pos.spineIndex, pos.paragraphIndex);
|
result.xpath = ChapterXPathResolver::findXPathForParagraph(epub, pos.spineIndex, pos.paragraphIndex);
|
||||||
}
|
}
|
||||||
// Fall back to progress-based XPath, then synthetic progress mapping.
|
|
||||||
if (result.xpath.empty()) {
|
|
||||||
result.xpath = ChapterXPathResolver::findXPathForProgress(epub, pos.spineIndex, intra);
|
|
||||||
}
|
|
||||||
if (result.xpath.empty()) {
|
if (result.xpath.empty()) {
|
||||||
result.xpath = generateXPath(epub, pos.spineIndex, intra);
|
result.xpath = generateXPath(epub, pos.spineIndex, intra);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,11 +10,7 @@ OpdsParser::OpdsParser() {
|
|||||||
if (!parser) {
|
if (!parser) {
|
||||||
errorOccured = true;
|
errorOccured = true;
|
||||||
LOG_DBG("OPDS", "Couldn't allocate memory for parser");
|
LOG_DBG("OPDS", "Couldn't allocate memory for parser");
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
XML_SetUserData(parser, this);
|
|
||||||
XML_SetElementHandler(parser, startElement, endElement);
|
|
||||||
XML_SetCharacterDataHandler(parser, characterData);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
OpdsParser::~OpdsParser() { destroyXmlParser(parser); }
|
OpdsParser::~OpdsParser() { destroyXmlParser(parser); }
|
||||||
@@ -24,6 +20,10 @@ size_t OpdsParser::write(uint8_t c) { return write(&c, 1); }
|
|||||||
size_t OpdsParser::write(const uint8_t* xmlData, const size_t length) {
|
size_t OpdsParser::write(const uint8_t* xmlData, const size_t length) {
|
||||||
if (errorOccured) return length;
|
if (errorOccured) return length;
|
||||||
|
|
||||||
|
XML_SetUserData(parser, this);
|
||||||
|
XML_SetElementHandler(parser, startElement, endElement);
|
||||||
|
XML_SetCharacterDataHandler(parser, characterData);
|
||||||
|
|
||||||
const char* currentPos = reinterpret_cast<const char*>(xmlData);
|
const char* currentPos = reinterpret_cast<const char*>(xmlData);
|
||||||
size_t remaining = length;
|
size_t remaining = length;
|
||||||
constexpr size_t chunkSize = 1024;
|
constexpr size_t chunkSize = 1024;
|
||||||
@@ -54,7 +54,6 @@ size_t OpdsParser::write(const uint8_t* xmlData, const size_t length) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void OpdsParser::flush() {
|
void OpdsParser::flush() {
|
||||||
if (errorOccured || !parser) return;
|
|
||||||
if (XML_Parse(parser, nullptr, 0, XML_TRUE) != XML_STATUS_OK) {
|
if (XML_Parse(parser, nullptr, 0, XML_TRUE) != XML_STATUS_OK) {
|
||||||
errorOccured = true;
|
errorOccured = true;
|
||||||
destroyXmlParser(parser);
|
destroyXmlParser(parser);
|
||||||
|
|||||||
+1
-1
@@ -42,7 +42,7 @@ std::string Txt::getTitle() const {
|
|||||||
|
|
||||||
// Remove .txt extension
|
// Remove .txt extension
|
||||||
if (FsHelpers::hasTxtExtension(filename)) {
|
if (FsHelpers::hasTxtExtension(filename)) {
|
||||||
filename.resize(filename.length() - 4);
|
filename = filename.substr(0, filename.length() - 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
return filename;
|
return filename;
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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,7 +6,6 @@
|
|||||||
#include <Serialization.h>
|
#include <Serialization.h>
|
||||||
|
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
#include <mutex>
|
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
#include "I18nKeys.h"
|
#include "I18nKeys.h"
|
||||||
@@ -97,7 +96,6 @@ uint8_t CrossPointSettings::sleepTimeoutEnumToMinutes(const uint8_t legacyValue)
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool CrossPointSettings::saveToFile() const {
|
bool CrossPointSettings::saveToFile() const {
|
||||||
std::lock_guard<std::mutex> lock(_mutex);
|
|
||||||
Storage.mkdir("/.crosspoint");
|
Storage.mkdir("/.crosspoint");
|
||||||
return JsonSettingsIO::saveSettings(*this, SETTINGS_FILE_JSON);
|
return JsonSettingsIO::saveSettings(*this, SETTINGS_FILE_JSON);
|
||||||
}
|
}
|
||||||
@@ -108,11 +106,7 @@ bool CrossPointSettings::loadFromFile() {
|
|||||||
String json = Storage.readFile(SETTINGS_FILE_JSON);
|
String json = Storage.readFile(SETTINGS_FILE_JSON);
|
||||||
if (!json.isEmpty()) {
|
if (!json.isEmpty()) {
|
||||||
bool resave = false;
|
bool resave = false;
|
||||||
bool result;
|
bool result = JsonSettingsIO::loadSettings(*this, json.c_str(), &resave);
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock(_mutex);
|
|
||||||
result = JsonSettingsIO::loadSettings(*this, json.c_str(), &resave);
|
|
||||||
}
|
|
||||||
if (result && resave) {
|
if (result && resave) {
|
||||||
if (saveToFile()) {
|
if (saveToFile()) {
|
||||||
LOG_DBG("CPS", "Resaved settings to update format");
|
LOG_DBG("CPS", "Resaved settings to update format");
|
||||||
@@ -172,7 +166,6 @@ bool CrossPointSettings::loadFromBinaryFile() {
|
|||||||
if (!Storage.openFileForRead("CPS", SETTINGS_FILE_BIN, inputFile)) {
|
if (!Storage.openFileForRead("CPS", SETTINGS_FILE_BIN, inputFile)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
std::lock_guard<std::mutex> lock(_mutex);
|
|
||||||
|
|
||||||
uint8_t version;
|
uint8_t version;
|
||||||
serialization::readPod(inputFile, version);
|
serialization::readPod(inputFile, version);
|
||||||
|
|||||||
@@ -3,12 +3,9 @@
|
|||||||
|
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <iosfwd>
|
#include <iosfwd>
|
||||||
#include <mutex>
|
|
||||||
|
|
||||||
class CrossPointSettings {
|
class CrossPointSettings {
|
||||||
private:
|
private:
|
||||||
mutable std::mutex _mutex;
|
|
||||||
|
|
||||||
// Private constructor for singleton
|
// Private constructor for singleton
|
||||||
CrossPointSettings() = default;
|
CrossPointSettings() = default;
|
||||||
|
|
||||||
@@ -20,10 +17,6 @@ class CrossPointSettings {
|
|||||||
CrossPointSettings(const CrossPointSettings&) = delete;
|
CrossPointSettings(const CrossPointSettings&) = delete;
|
||||||
CrossPointSettings& operator=(const CrossPointSettings&) = delete;
|
CrossPointSettings& operator=(const CrossPointSettings&) = delete;
|
||||||
|
|
||||||
// Access the settings mutex for protecting multi-field reads/writes from other cores.
|
|
||||||
// Callers must not re-enter SETTINGS methods that lock _mutex while holding it.
|
|
||||||
std::mutex& getMutex() const { return _mutex; }
|
|
||||||
|
|
||||||
enum SLEEP_SCREEN_MODE {
|
enum SLEEP_SCREEN_MODE {
|
||||||
DARK = 0,
|
DARK = 0,
|
||||||
LIGHT = 1,
|
LIGHT = 1,
|
||||||
@@ -168,18 +161,11 @@ 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 };
|
||||||
|
|
||||||
// Text anti-aliasing mode for 2-bit fonts. FULL runs the grayscale LUT
|
|
||||||
// passes after the BW page render (extra refresh per page turn). FAST
|
|
||||||
// approximates AA inside the single BW pass by dithering partial-coverage
|
|
||||||
// glyph pixels (GfxRenderer fast anti-aliasing flag). Value 1 must stay
|
|
||||||
// FULL: it was the "on" value of the old on/off toggle in saved settings.
|
|
||||||
enum TEXT_ANTIALIASING { TEXT_AA_OFF = 0, TEXT_AA_FULL = 1, TEXT_AA_FAST = 2, TEXT_ANTIALIASING_COUNT };
|
|
||||||
|
|
||||||
enum TILT_PAGE_TURN { TILT_OFF = 0, TILT_NORMAL = 1, TILT_NVERTED = 2, TILT_PAGE_TURN_COUNT };
|
enum TILT_PAGE_TURN { TILT_OFF = 0, TILT_NORMAL = 1, TILT_NVERTED = 2, TILT_PAGE_TURN_COUNT };
|
||||||
|
|
||||||
enum QUICK_RESUME_SLEEP_SCREEN {
|
enum QUICK_RESUME_SLEEP_SCREEN {
|
||||||
@@ -216,7 +202,7 @@ class CrossPointSettings {
|
|||||||
uint8_t clockHasBeenSynced = 0;
|
uint8_t clockHasBeenSynced = 0;
|
||||||
// Text rendering settings
|
// Text rendering settings
|
||||||
uint8_t extraParagraphSpacing = 1;
|
uint8_t extraParagraphSpacing = 1;
|
||||||
uint8_t textAntiAliasing = TEXT_AA_FULL;
|
uint8_t textAntiAliasing = 1;
|
||||||
// Short power button click behaviour
|
// Short power button click behaviour
|
||||||
uint8_t shortPwrBtn = IGNORE;
|
uint8_t shortPwrBtn = IGNORE;
|
||||||
// EPUB reading orientation settings
|
// EPUB reading orientation settings
|
||||||
@@ -268,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)
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
#include <Serialization.h>
|
#include <Serialization.h>
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <mutex>
|
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
constexpr uint8_t STATE_FILE_VERSION = 4;
|
constexpr uint8_t STATE_FILE_VERSION = 4;
|
||||||
@@ -33,7 +32,6 @@ void CrossPointState::pushRecentSleep(uint16_t idx) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool CrossPointState::saveToFile() const {
|
bool CrossPointState::saveToFile() const {
|
||||||
std::lock_guard<std::mutex> lock(_mutex);
|
|
||||||
Storage.mkdir("/.crosspoint");
|
Storage.mkdir("/.crosspoint");
|
||||||
return JsonSettingsIO::saveState(*this, STATE_FILE_JSON);
|
return JsonSettingsIO::saveState(*this, STATE_FILE_JSON);
|
||||||
}
|
}
|
||||||
@@ -43,7 +41,6 @@ bool CrossPointState::loadFromFile() {
|
|||||||
if (Storage.exists(STATE_FILE_JSON)) {
|
if (Storage.exists(STATE_FILE_JSON)) {
|
||||||
String json = Storage.readFile(STATE_FILE_JSON);
|
String json = Storage.readFile(STATE_FILE_JSON);
|
||||||
if (!json.isEmpty()) {
|
if (!json.isEmpty()) {
|
||||||
std::lock_guard<std::mutex> lock(_mutex);
|
|
||||||
return JsonSettingsIO::loadState(*this, json.c_str());
|
return JsonSettingsIO::loadState(*this, json.c_str());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,7 +67,6 @@ bool CrossPointState::loadFromBinaryFile() {
|
|||||||
if (!Storage.openFileForRead("CPS", STATE_FILE_BIN, inputFile)) {
|
if (!Storage.openFileForRead("CPS", STATE_FILE_BIN, inputFile)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
std::lock_guard<std::mutex> lock(_mutex);
|
|
||||||
|
|
||||||
uint8_t version;
|
uint8_t version;
|
||||||
serialization::readPod(inputFile, version);
|
serialization::readPod(inputFile, version);
|
||||||
|
|||||||
@@ -1,18 +1,12 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <mutex>
|
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
class CrossPointState {
|
class CrossPointState {
|
||||||
mutable std::mutex _mutex;
|
|
||||||
|
|
||||||
// Static instance
|
// Static instance
|
||||||
static CrossPointState instance;
|
static CrossPointState instance;
|
||||||
|
|
||||||
public:
|
public:
|
||||||
// Access the state mutex for protecting multi-field reads/writes from other cores.
|
|
||||||
std::mutex& getMutex() const { return _mutex; }
|
|
||||||
|
|
||||||
static constexpr uint8_t SLEEP_RECENT_COUNT = 16;
|
static constexpr uint8_t SLEEP_RECENT_COUNT = 16;
|
||||||
|
|
||||||
std::string openEpubPath;
|
std::string openEpubPath;
|
||||||
|
|||||||
+17
-2
@@ -5,7 +5,6 @@
|
|||||||
#include <Logging.h>
|
#include <Logging.h>
|
||||||
#include <ObfuscationUtils.h>
|
#include <ObfuscationUtils.h>
|
||||||
|
|
||||||
#include <algorithm>
|
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
@@ -145,10 +144,20 @@ 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.
|
||||||
|
// Stored as ISO code string ("EN", "DE", ...) for stability across enum reorders.
|
||||||
|
doc["language"] = (s.language < getLanguageCount()) ? LANGUAGE_CODES[s.language] : "EN";
|
||||||
|
|
||||||
// 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.
|
||||||
@@ -243,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);
|
||||||
@@ -342,7 +358,6 @@ bool JsonSettingsIO::loadRecentBooks(RecentBooksStore& store, const char* json)
|
|||||||
|
|
||||||
store.recentBooks.clear();
|
store.recentBooks.clear();
|
||||||
JsonArray arr = doc["books"].as<JsonArray>();
|
JsonArray arr = doc["books"].as<JsonArray>();
|
||||||
store.recentBooks.reserve(std::min(arr.size(), (size_t)10));
|
|
||||||
for (JsonObject obj : arr) {
|
for (JsonObject obj : arr) {
|
||||||
if (store.getCount() >= 10) break;
|
if (store.getCount() >= 10) break;
|
||||||
RecentBook book;
|
RecentBook book;
|
||||||
|
|||||||
@@ -34,12 +34,10 @@ void SdCardFontSystem::begin(GfxRenderer& renderer) {
|
|||||||
} else {
|
} else {
|
||||||
LOG_ERR("SDFS", "Failed to load SD font family: %s (clearing)", SETTINGS.sdFontFamilyName);
|
LOG_ERR("SDFS", "Failed to load SD font family: %s (clearing)", SETTINGS.sdFontFamilyName);
|
||||||
SETTINGS.sdFontFamilyName[0] = '\0';
|
SETTINGS.sdFontFamilyName[0] = '\0';
|
||||||
SETTINGS.saveToFile();
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
LOG_DBG("SDFS", "SD font family not found on card: %s (clearing)", SETTINGS.sdFontFamilyName);
|
LOG_DBG("SDFS", "SD font family not found on card: %s (clearing)", SETTINGS.sdFontFamilyName);
|
||||||
SETTINGS.sdFontFamilyName[0] = '\0';
|
SETTINGS.sdFontFamilyName[0] = '\0';
|
||||||
SETTINGS.saveToFile();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,7 +76,6 @@ void SdCardFontSystem::ensureLoaded(GfxRenderer& renderer) {
|
|||||||
LOG_DBG("SDFS", "SD font family disappeared: %s (clearing)", wantedFamily);
|
LOG_DBG("SDFS", "SD font family disappeared: %s (clearing)", wantedFamily);
|
||||||
manager_.unloadAll(renderer);
|
manager_.unloadAll(renderer);
|
||||||
SETTINGS.sdFontFamilyName[0] = '\0';
|
SETTINGS.sdFontFamilyName[0] = '\0';
|
||||||
SETTINGS.saveToFile();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const auto* selected = family->findClosestReaderSize(sizeEnum);
|
const auto* selected = family->findClosestReaderSize(sizeEnum);
|
||||||
@@ -99,12 +96,10 @@ void SdCardFontSystem::ensureLoaded(GfxRenderer& renderer) {
|
|||||||
} else {
|
} else {
|
||||||
LOG_ERR("SDFS", "Failed to load SD font family: %s (clearing)", wantedFamily);
|
LOG_ERR("SDFS", "Failed to load SD font family: %s (clearing)", wantedFamily);
|
||||||
SETTINGS.sdFontFamilyName[0] = '\0';
|
SETTINGS.sdFontFamilyName[0] = '\0';
|
||||||
SETTINGS.saveToFile();
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
LOG_DBG("SDFS", "SD font family not found: %s (clearing)", wantedFamily);
|
LOG_DBG("SDFS", "SD font family not found: %s (clearing)", wantedFamily);
|
||||||
SETTINGS.sdFontFamilyName[0] = '\0';
|
SETTINGS.sdFontFamilyName[0] = '\0';
|
||||||
SETTINGS.saveToFile();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+72
-10
@@ -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,13 +157,14 @@ 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 ---
|
||||||
SettingInfo::Enum(StrId::STR_SLEEP_SCREEN, &CrossPointSettings::sleepScreen,
|
SettingInfo::Enum(StrId::STR_SLEEP_SCREEN, &CrossPointSettings::sleepScreen,
|
||||||
{StrId::STR_DARK, StrId::STR_LIGHT, StrId::STR_CUSTOM, StrId::STR_COVER,
|
{StrId::STR_DARK, StrId::STR_LIGHT, StrId::STR_CUSTOM, StrId::STR_COVER, StrId::STR_NONE_OPT,
|
||||||
StrId::STR_COVER_CUSTOM, StrId::STR_NONE_OPT, StrId::STR_QUICK_RESUME},
|
StrId::STR_COVER_CUSTOM, StrId::STR_QUICK_RESUME},
|
||||||
"sleepScreen", StrId::STR_CAT_DISPLAY),
|
"sleepScreen", StrId::STR_CAT_DISPLAY),
|
||||||
SettingInfo::Enum(StrId::STR_SLEEP_COVER_MODE, &CrossPointSettings::sleepScreenCoverMode,
|
SettingInfo::Enum(StrId::STR_SLEEP_COVER_MODE, &CrossPointSettings::sleepScreenCoverMode,
|
||||||
{StrId::STR_FIT, StrId::STR_CROP}, "sleepScreenCoverMode", StrId::STR_CAT_DISPLAY),
|
{StrId::STR_FIT, StrId::STR_CROP}, "sleepScreenCoverMode", StrId::STR_CAT_DISPLAY),
|
||||||
@@ -157,11 +216,8 @@ inline std::vector<SettingInfo> getSettingsList(const SdCardFontRegistry* regist
|
|||||||
"orientation", StrId::STR_CAT_READER),
|
"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),
|
||||||
// Option order must match TEXT_ANTIALIASING values (popup index is
|
SettingInfo::Toggle(StrId::STR_TEXT_AA, &CrossPointSettings::textAntiAliasing, "textAntiAliasing",
|
||||||
// stored directly): OFF=0, FULL=1 (legacy toggle "on"), FAST=2.
|
StrId::STR_CAT_READER),
|
||||||
SettingInfo::Enum(StrId::STR_TEXT_AA, &CrossPointSettings::textAntiAliasing,
|
|
||||||
{StrId::STR_STATE_OFF, StrId::STR_TEXT_AA_FULL, StrId::STR_TEXT_AA_FAST}, "textAntiAliasing",
|
|
||||||
StrId::STR_CAT_READER),
|
|
||||||
SettingInfo::Enum(StrId::STR_IMAGES, &CrossPointSettings::imageRendering,
|
SettingInfo::Enum(StrId::STR_IMAGES, &CrossPointSettings::imageRendering,
|
||||||
{StrId::STR_IMAGES_DISPLAY, StrId::STR_IMAGES_PLACEHOLDER, StrId::STR_IMAGES_SUPPRESS},
|
{StrId::STR_IMAGES_DISPLAY, StrId::STR_IMAGES_PLACEHOLDER, StrId::STR_IMAGES_SUPPRESS},
|
||||||
"imageRendering", StrId::STR_CAT_READER),
|
"imageRendering", StrId::STR_CAT_READER),
|
||||||
@@ -277,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;
|
||||||
|
|||||||
@@ -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; }
|
||||||
@@ -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_;
|
||||||
|
};
|
||||||
@@ -6,8 +6,6 @@
|
|||||||
#include <ObfuscationUtils.h>
|
#include <ObfuscationUtils.h>
|
||||||
#include <Serialization.h>
|
#include <Serialization.h>
|
||||||
|
|
||||||
#include <algorithm>
|
|
||||||
|
|
||||||
// Initialize the static instance
|
// Initialize the static instance
|
||||||
WifiCredentialStore WifiCredentialStore::instance;
|
WifiCredentialStore WifiCredentialStore::instance;
|
||||||
|
|
||||||
@@ -91,7 +89,6 @@ bool WifiCredentialStore::loadFromBinaryFile() {
|
|||||||
serialization::readPod(file, count);
|
serialization::readPod(file, count);
|
||||||
|
|
||||||
credentials.clear();
|
credentials.clear();
|
||||||
credentials.reserve(std::min<size_t>(count, MAX_NETWORKS));
|
|
||||||
for (uint8_t i = 0; i < count && i < MAX_NETWORKS; i++) {
|
for (uint8_t i = 0; i < count && i < MAX_NETWORKS; i++) {
|
||||||
WifiCredential cred;
|
WifiCredential cred;
|
||||||
serialization::readString(file, cred.ssid);
|
serialization::readString(file, cred.ssid);
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -19,15 +20,12 @@
|
|||||||
#include "settings/SettingsActivity.h"
|
#include "settings/SettingsActivity.h"
|
||||||
#include "util/FullScreenMessageActivity.h"
|
#include "util/FullScreenMessageActivity.h"
|
||||||
|
|
||||||
static portMUX_TYPE activityManagerSpinlock = portMUX_INITIALIZER_UNLOCKED;
|
|
||||||
|
|
||||||
void ActivityManager::begin() {
|
void ActivityManager::begin() {
|
||||||
xTaskCreatePinnedToCore(&renderTaskTrampoline, "ActivityManagerRender",
|
xTaskCreate(&renderTaskTrampoline, "ActivityManagerRender",
|
||||||
8192, // Stack size
|
8192, // Stack size
|
||||||
this, // Parameters
|
this, // Parameters
|
||||||
1, // Priority
|
1, // Priority
|
||||||
&renderTaskHandle, // Task handle
|
&renderTaskHandle // Task handle
|
||||||
0 // Pin to core 0 (PRO_CPU)
|
|
||||||
);
|
);
|
||||||
assert(renderTaskHandle != nullptr && "Failed to create render task");
|
assert(renderTaskHandle != nullptr && "Failed to create render task");
|
||||||
}
|
}
|
||||||
@@ -49,10 +47,10 @@ void ActivityManager::renderTaskLoop() {
|
|||||||
}
|
}
|
||||||
// Notify any task blocked in requestUpdateAndWait() that the render is done.
|
// Notify any task blocked in requestUpdateAndWait() that the render is done.
|
||||||
TaskHandle_t waiter = nullptr;
|
TaskHandle_t waiter = nullptr;
|
||||||
taskENTER_CRITICAL(&activityManagerSpinlock);
|
taskENTER_CRITICAL(nullptr);
|
||||||
waiter = waitingTaskHandle;
|
waiter = waitingTaskHandle;
|
||||||
waitingTaskHandle = nullptr;
|
waitingTaskHandle = nullptr;
|
||||||
taskEXIT_CRITICAL(&activityManagerSpinlock);
|
taskEXIT_CRITICAL(nullptr);
|
||||||
if (waiter) {
|
if (waiter) {
|
||||||
xTaskNotify(waiter, 1, eIncrement);
|
xTaskNotify(waiter, 1, eIncrement);
|
||||||
}
|
}
|
||||||
@@ -140,7 +138,8 @@ void ActivityManager::loop() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (requestedUpdate.exchange(false)) {
|
if (requestedUpdate) {
|
||||||
|
requestedUpdate = false;
|
||||||
// Using direct notification to signal the render task to update
|
// Using direct notification to signal the render task to update
|
||||||
// Increment counter so multiple rapid calls won't be lost
|
// Increment counter so multiple rapid calls won't be lost
|
||||||
if (renderTaskHandle) {
|
if (renderTaskHandle) {
|
||||||
@@ -172,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));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,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) {
|
||||||
@@ -196,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)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,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)); }
|
||||||
@@ -282,7 +285,7 @@ void ActivityManager::requestUpdateAndWait() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Atomic section to perform checks
|
// Atomic section to perform checks
|
||||||
taskENTER_CRITICAL(&activityManagerSpinlock);
|
taskENTER_CRITICAL(nullptr);
|
||||||
auto currTaskHandler = xTaskGetCurrentTaskHandle();
|
auto currTaskHandler = xTaskGetCurrentTaskHandle();
|
||||||
auto mutexHolder = xSemaphoreGetMutexHolder(renderingMutex);
|
auto mutexHolder = xSemaphoreGetMutexHolder(renderingMutex);
|
||||||
bool isRenderTask = (currTaskHandler == renderTaskHandle);
|
bool isRenderTask = (currTaskHandler == renderTaskHandle);
|
||||||
@@ -291,7 +294,7 @@ void ActivityManager::requestUpdateAndWait() {
|
|||||||
if (!alreadyWaiting && !isRenderTask && !holdingRenderLock) {
|
if (!alreadyWaiting && !isRenderTask && !holdingRenderLock) {
|
||||||
waitingTaskHandle = currTaskHandler;
|
waitingTaskHandle = currTaskHandler;
|
||||||
}
|
}
|
||||||
taskEXIT_CRITICAL(&activityManagerSpinlock);
|
taskEXIT_CRITICAL(nullptr);
|
||||||
|
|
||||||
// Render task cannot call requestUpdateAndWait() or it will cause a deadlock
|
// Render task cannot call requestUpdateAndWait() or it will cause a deadlock
|
||||||
assert(!isRenderTask && "Render task cannot call requestUpdateAndWait()");
|
assert(!isRenderTask && "Render task cannot call requestUpdateAndWait()");
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
#include <freertos/semphr.h>
|
#include <freertos/semphr.h>
|
||||||
#include <freertos/task.h>
|
#include <freertos/task.h>
|
||||||
|
|
||||||
#include <atomic>
|
|
||||||
#include <cassert>
|
#include <cassert>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
#include <string>
|
#include <string>
|
||||||
@@ -64,7 +63,7 @@ class ActivityManager {
|
|||||||
|
|
||||||
// Whether to trigger a render after the current loop()
|
// Whether to trigger a render after the current loop()
|
||||||
// This variable must only be set by the main loop, to avoid race conditions
|
// This variable must only be set by the main loop, to avoid race conditions
|
||||||
std::atomic<bool> requestedUpdate{false};
|
bool requestedUpdate = false;
|
||||||
|
|
||||||
public:
|
public:
|
||||||
explicit ActivityManager(GfxRenderer& renderer, MappedInputManager& mappedInput)
|
explicit ActivityManager(GfxRenderer& renderer, MappedInputManager& mappedInput)
|
||||||
|
|||||||
@@ -43,10 +43,6 @@ struct PageResult {
|
|||||||
struct ProgressChangeResult {
|
struct ProgressChangeResult {
|
||||||
int spineIndex = 0;
|
int spineIndex = 0;
|
||||||
int page = 0;
|
int page = 0;
|
||||||
int totalPages = 0;
|
|
||||||
std::string xpath;
|
|
||||||
float percentage = 0.0f;
|
|
||||||
bool hasSavedProgress = false;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
enum class NetworkMode;
|
enum class NetworkMode;
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ void SleepActivity::renderDefaultSleepScreen() const {
|
|||||||
renderer.invertScreen();
|
renderer.invertScreen();
|
||||||
}
|
}
|
||||||
|
|
||||||
renderer.displayBuffer(HalDisplay::FULL_REFRESH);
|
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
||||||
}
|
}
|
||||||
|
|
||||||
void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap) const {
|
void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap) const {
|
||||||
@@ -219,11 +219,13 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap) const {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (hasGreyscale) {
|
if (hasGreyscale) {
|
||||||
// OEM grayscale pipeline base: use a full sleep-screen paint so the panel
|
// OEM grayscale pipeline base: on X3 this displays the frame with the
|
||||||
// enters deep sleep from a clean B/W baseline before the gray nudge refresh.
|
// dedicated "AA-pre-BW(mid)" differential waveform, leaving every pixel
|
||||||
renderer.displayGrayscaleBase(HalDisplay::FULL_REFRESH);
|
// in the calibrated state the gray nudge refresh expects; on X4 it is a
|
||||||
|
// plain HALF refresh (previous behavior).
|
||||||
|
renderer.displayGrayscaleBase(HalDisplay::HALF_REFRESH);
|
||||||
} else {
|
} else {
|
||||||
renderer.displayBuffer(HalDisplay::FULL_REFRESH);
|
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasGreyscale) {
|
if (hasGreyscale) {
|
||||||
@@ -331,5 +333,5 @@ void SleepActivity::renderLastScreenSleepScreen() const {
|
|||||||
|
|
||||||
void SleepActivity::renderBlankSleepScreen() const {
|
void SleepActivity::renderBlankSleepScreen() const {
|
||||||
renderer.clearScreen();
|
renderer.clearScreen();
|
||||||
renderer.displayBuffer(HalDisplay::FULL_REFRESH);
|
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -336,7 +337,7 @@ std::string getFileName(std::string filename) {
|
|||||||
return filename.substr(0, pos);
|
return filename.substr(0, pos);
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string getFileExtension(const std::string& filename) {
|
std::string getFileExtension(std::string filename) {
|
||||||
if (filename.back() == '/') {
|
if (filename.back() == '/') {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
#include "EndOfBookOptions.h"
|
|
||||||
|
|
||||||
#include <FsHelpers.h>
|
|
||||||
#include <GfxRenderer.h>
|
|
||||||
#include <I18n.h>
|
|
||||||
|
|
||||||
#include "CrossPointSettings.h"
|
|
||||||
#include "ReaderUtils.h"
|
|
||||||
#include "components/UITheme.h"
|
|
||||||
#include "fontIds.h"
|
|
||||||
#include "util/ButtonNavigator.h"
|
|
||||||
#include "util/NextBookFinder.h"
|
|
||||||
|
|
||||||
namespace {
|
|
||||||
// Display name without the file extension, mirroring the file browser rows
|
|
||||||
std::string displayName(const std::string& filename) {
|
|
||||||
const auto pos = filename.rfind('.');
|
|
||||||
return filename.substr(0, pos);
|
|
||||||
}
|
|
||||||
} // namespace
|
|
||||||
|
|
||||||
void EndOfBookOptions::loadOnce(const std::string& currentBookPath) {
|
|
||||||
if (isLoaded.load(std::memory_order_acquire)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
folder = FsHelpers::extractFolderPath(currentBookPath);
|
|
||||||
names = NextBookFinder::findNextBooks(currentBookPath, MAX_SUGGESTIONS);
|
|
||||||
selector = 0;
|
|
||||||
// Release-publish so the main task, which gates all access on isLoaded, never
|
|
||||||
// observes a partially built list
|
|
||||||
isLoaded.store(true, std::memory_order_release);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool EndOfBookOptions::menuActive() const { return isLoaded.load(std::memory_order_acquire) && !names.empty(); }
|
|
||||||
|
|
||||||
std::string EndOfBookOptions::fullPath(const size_t index) const {
|
|
||||||
if (index >= names.size()) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
return folder == "/" ? "/" + names[index] : folder + "/" + names[index];
|
|
||||||
}
|
|
||||||
|
|
||||||
EndOfBookOptions::Action EndOfBookOptions::handleMenuInput(const MappedInputManager& input, std::string* openPath) {
|
|
||||||
if (input.wasReleased(MappedInputManager::Button::Confirm)) {
|
|
||||||
if (selector < static_cast<int>(names.size())) {
|
|
||||||
if (openPath) {
|
|
||||||
*openPath = fullPath(selector);
|
|
||||||
}
|
|
||||||
return Action::OpenBook;
|
|
||||||
}
|
|
||||||
return Action::GoHome; // "Home" entry selected
|
|
||||||
}
|
|
||||||
|
|
||||||
// Short-press Back returns to the last page; a long press falls through to the
|
|
||||||
// reader's own handler (file browser). Home is reached through the list's Home entry.
|
|
||||||
if (input.wasReleased(MappedInputManager::Button::Back) && input.getHeldTime() < ReaderUtils::GO_HOME_MS) {
|
|
||||||
return Action::LastPage;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Selection movement on the standard list navigation buttons (side Up/Down plus front
|
|
||||||
// Left/Right, orientation swap included). It follows the reader's page-turn semantics
|
|
||||||
// (press-triggered by default, release-triggered when a long-press behavior is
|
|
||||||
// configured, same rule as ReaderUtils::detectPageTurn). This matters on entry: with
|
|
||||||
// press-triggered turns, the press that turned the final page already fired in the
|
|
||||||
// reader, and its release must not double-fire into this menu.
|
|
||||||
const bool usePress = SETTINGS.longPressButtonBehavior == CrossPointSettings::OFF;
|
|
||||||
const auto triggered = [&](const MappedInputManager::Button button) {
|
|
||||||
return usePress ? input.wasPressed(button) : input.wasReleased(button);
|
|
||||||
};
|
|
||||||
const int itemCount = static_cast<int>(names.size()) + 1; // + "Home" entry
|
|
||||||
if (triggered(MappedInputManager::Button::NavPrevious)) {
|
|
||||||
selector = ButtonNavigator::previousIndex(selector, itemCount); // wraps to the bottom
|
|
||||||
return Action::Redraw;
|
|
||||||
}
|
|
||||||
if (triggered(MappedInputManager::Button::NavNext)) {
|
|
||||||
selector = ButtonNavigator::nextIndex(selector, itemCount); // wraps to the top
|
|
||||||
return Action::Redraw;
|
|
||||||
}
|
|
||||||
return Action::None;
|
|
||||||
}
|
|
||||||
|
|
||||||
void EndOfBookOptions::render(GfxRenderer& renderer, const MappedInputManager& input) const {
|
|
||||||
const auto& metrics = UITheme::getInstance().getMetrics();
|
|
||||||
|
|
||||||
if (!menuActive()) {
|
|
||||||
// No suggestions: the historical plain end screen. 3/8 of the screen height matches
|
|
||||||
// the previous fixed position on the 480x800 panel and scales to other resolutions.
|
|
||||||
renderer.drawCenteredText(UI_12_FONT_ID, renderer.getScreenHeight() * 3 / 8, tr(STR_END_OF_BOOK), true,
|
|
||||||
EpdFontFamily::BOLD);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Suggestion menu: title, list (+ Home entry) and button hints. The hints are drawn at
|
|
||||||
// the physical front buttons, which is a logical side/top edge in the rotated
|
|
||||||
// orientations — lay out inside the safe area so nothing hides behind them. Vertical
|
|
||||||
// positions derive from the safe-area height and font line heights so other panel
|
|
||||||
// resolutions scale (review request on #2532).
|
|
||||||
const Rect safe = UITheme::getInstance().getScreenSafeArea(renderer, true, false);
|
|
||||||
const int titleY = safe.y + safe.height / 8;
|
|
||||||
const int subtitleY = titleY + renderer.getLineHeight(UI_12_FONT_ID) + metrics.verticalSpacing;
|
|
||||||
const int listTop = subtitleY + renderer.getLineHeight(UI_10_FONT_ID) + metrics.verticalSpacing * 2;
|
|
||||||
|
|
||||||
UITheme::drawCenteredText(renderer, safe, UI_12_FONT_ID, titleY, tr(STR_END_OF_BOOK), true, EpdFontFamily::BOLD);
|
|
||||||
UITheme::drawCenteredText(renderer, safe, UI_10_FONT_ID, subtitleY, tr(STR_EOB_CONTINUE_WITH));
|
|
||||||
|
|
||||||
const int listHeight = safe.y + safe.height - listTop - metrics.verticalSpacing;
|
|
||||||
GUI.drawList(renderer, Rect{safe.x, listTop, safe.width, listHeight}, static_cast<int>(names.size()) + 1, selector,
|
|
||||||
[this](const int index) {
|
|
||||||
return index < static_cast<int>(names.size()) ? displayName(names[index])
|
|
||||||
: std::string(tr(STR_EOB_HOME));
|
|
||||||
});
|
|
||||||
|
|
||||||
const auto labels = input.mapLabels(tr(STR_BACK), tr(STR_OPEN), tr(STR_DIR_UP), tr(STR_DIR_DOWN));
|
|
||||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
#include <atomic>
|
|
||||||
#include <string>
|
|
||||||
#include <vector>
|
|
||||||
|
|
||||||
class GfxRenderer;
|
|
||||||
class MappedInputManager;
|
|
||||||
|
|
||||||
// Shared End-of-Book next-book menu for the EPUB and XTC readers. Collects up to
|
|
||||||
// MAX_SUGGESTIONS sibling books once per reader session, handles the menu input, and
|
|
||||||
// draws the end screen. With no suggestions the end screen keeps its historical
|
|
||||||
// plain-title look and behavior.
|
|
||||||
class EndOfBookOptions {
|
|
||||||
public:
|
|
||||||
enum class Action { None, Redraw, OpenBook, GoHome, LastPage };
|
|
||||||
|
|
||||||
static constexpr size_t MAX_SUGGESTIONS = 3;
|
|
||||||
|
|
||||||
// Scans the book's folder for suggestions; no-op when already loaded. Call ONLY from
|
|
||||||
// the reader's render() (the render task, serialized by RenderLock) — the loaded flag
|
|
||||||
// is the release/acquire publication point that lets the main task read the finished
|
|
||||||
// list safely.
|
|
||||||
void loadOnce(const std::string& currentBookPath);
|
|
||||||
|
|
||||||
// True when the suggestion menu is showing and should own the reader's input.
|
|
||||||
bool menuActive() const;
|
|
||||||
|
|
||||||
// Menu input handling, following the standard list idiom: side Up/Down and front
|
|
||||||
// Left/Right move the selection (wrapping), Confirm opens it (or Home), and a short
|
|
||||||
// Back press returns to the last page of the book. Fills openPath when the result is
|
|
||||||
// OpenBook. Returns Action::None when nothing relevant was pressed; callers continue
|
|
||||||
// their normal input path (keeping long-press Back to the file browser working).
|
|
||||||
Action handleMenuInput(const MappedInputManager& input, std::string* openPath);
|
|
||||||
|
|
||||||
// Draws the full end screen (plain title, or the suggestion menu) onto a cleared buffer.
|
|
||||||
void render(GfxRenderer& renderer, const MappedInputManager& input) const;
|
|
||||||
|
|
||||||
private:
|
|
||||||
std::string folder;
|
|
||||||
// Written by the render task in loadOnce(), immutable afterwards; the main task only
|
|
||||||
// reads it after isLoaded is observed true (acquire), so no further locking is needed.
|
|
||||||
std::vector<std::string> names;
|
|
||||||
int selector = 0;
|
|
||||||
std::atomic<bool> isLoaded{false};
|
|
||||||
|
|
||||||
std::string fullPath(size_t index) const;
|
|
||||||
};
|
|
||||||
@@ -231,30 +231,6 @@ void EpubReaderActivity::onExit() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void EpubReaderActivity::openReaderMenu() {
|
|
||||||
const int currentPage = section ? section->currentPage + 1 : 0;
|
|
||||||
const int totalPages = section ? section->estimatedTotalPages() : 0;
|
|
||||||
float bookProgress = 0.0f;
|
|
||||||
if (epub->getBookSize() > 0 && section && section->estimatedTotalPages() > 0) {
|
|
||||||
const float chapterProgress =
|
|
||||||
static_cast<float>(section->currentPage) / static_cast<float>(section->estimatedTotalPages());
|
|
||||||
bookProgress = epub->calculateProgress(currentSpineIndex, chapterProgress) * 100.0f;
|
|
||||||
}
|
|
||||||
const int bookProgressPercent = clampPercent(static_cast<int>(bookProgress + 0.5f));
|
|
||||||
startActivityForResult(std::make_unique<EpubReaderMenuActivity>(
|
|
||||||
renderer, mappedInput, epub->getTitle(), currentPage, totalPages, bookProgressPercent,
|
|
||||||
SETTINGS.orientation, !currentPageFootnotes.empty(), !cachedBookmarks.empty()),
|
|
||||||
[this](const ActivityResult& result) {
|
|
||||||
// Always apply orientation change even if the menu was cancelled
|
|
||||||
const auto& menu = std::get<MenuResult>(result.data);
|
|
||||||
applyOrientation(menu.orientation);
|
|
||||||
toggleAutoPageTurn(menu.pageTurnOption);
|
|
||||||
if (!result.isCancelled) {
|
|
||||||
onReaderMenuConfirm(static_cast<EpubReaderMenuActivity::MenuAction>(menu.action));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void EpubReaderActivity::loop() {
|
void EpubReaderActivity::loop() {
|
||||||
if (!epub) {
|
if (!epub) {
|
||||||
// Should never happen
|
// Should never happen
|
||||||
@@ -262,33 +238,6 @@ void EpubReaderActivity::loop() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drive any in-progress incremental section build forward, off the page-turn critical path,
|
|
||||||
// but only within a small window ahead of the reader: an unbounded build monopolized the
|
|
||||||
// RenderLock and locked out page turns. The build follows the reader instead, and instant
|
|
||||||
// reopen comes from suspendBuild() persisting the laid-out pages as a partial on exit.
|
|
||||||
// Skip while the render mutex is busy so we never delay a pending render; re-check
|
|
||||||
// isBuilding() under the lock since render() may have just finished it.
|
|
||||||
if (section && section->isBuilding() && !RenderLock::peek() &&
|
|
||||||
static_cast<int>(section->pageCount) < section->currentPage + BUILD_WINDOW_AHEAD) {
|
|
||||||
RenderLock lock;
|
|
||||||
// Re-check under the lock: render() (which also holds the RenderLock) may have finalized the
|
|
||||||
// build between the outer isBuilding() check and acquiring the lock here, in which case
|
|
||||||
// buildSomeMore() would fail and wrongly reset the section. cppcheck can't see the cross-task
|
|
||||||
// mutation, so it flags this as always true.
|
|
||||||
// cppcheck-suppress knownConditionTrueFalse
|
|
||||||
if (section->isBuilding()) {
|
|
||||||
if (!section->buildSomeMore(BACKGROUND_BUILD_PAGES_PER_TICK)) {
|
|
||||||
LOG_ERR("ERS", "Background section build failed");
|
|
||||||
section.reset();
|
|
||||||
requestUpdate();
|
|
||||||
} else if (section->isBuildComplete() && applyDeferredReposition()) {
|
|
||||||
// The chapter re-paginated since the saved progress (settings changed): we now know the
|
|
||||||
// real page count, so re-render at the remapped page. No-op for an unchanged resume.
|
|
||||||
requestUpdate();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// End-of-Book screen reached (currentSpineIndex == spine count) means the book is
|
// End-of-Book screen reached (currentSpineIndex == spine count) means the book is
|
||||||
// finished. Two independent finished-book features key off this same condition.
|
// finished. Two independent finished-book features key off this same condition.
|
||||||
const bool atEndOfBook = currentSpineIndex > 0 && currentSpineIndex >= epub->getSpineItemsCount();
|
const bool atEndOfBook = currentSpineIndex > 0 && currentSpineIndex >= epub->getSpineItemsCount();
|
||||||
@@ -349,35 +298,6 @@ void EpubReaderActivity::loop() {
|
|||||||
requestUpdate();
|
requestUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
// While the end screen suggestion menu is showing it owns Confirm/Back/navigation
|
|
||||||
// input. Anything it doesn't handle (e.g. long-press Back to the file browser) falls
|
|
||||||
// through to the regular handlers below; page turns are absorbed by the end-of-book
|
|
||||||
// block. A Confirm release after a long-press function (bookmark/sync) fired is left
|
|
||||||
// to the regular Confirm handler below, which consumes it via ignoreNextConfirmRelease.
|
|
||||||
if (atEndOfBook && endOfBookOptions.menuActive() &&
|
|
||||||
!(ignoreNextConfirmRelease && mappedInput.wasReleased(MappedInputManager::Button::Confirm))) {
|
|
||||||
std::string openPath;
|
|
||||||
switch (endOfBookOptions.handleMenuInput(mappedInput, &openPath)) {
|
|
||||||
case EndOfBookOptions::Action::OpenBook:
|
|
||||||
activityManager.goToReader(openPath);
|
|
||||||
return;
|
|
||||||
case EndOfBookOptions::Action::GoHome:
|
|
||||||
onGoHome();
|
|
||||||
return;
|
|
||||||
case EndOfBookOptions::Action::LastPage:
|
|
||||||
currentSpineIndex = std::max(epub->getSpineItemsCount() - 1, 0);
|
|
||||||
nextPageNumber = 0;
|
|
||||||
pendingPageJump = std::numeric_limits<uint16_t>::max();
|
|
||||||
requestUpdate();
|
|
||||||
return;
|
|
||||||
case EndOfBookOptions::Action::Redraw:
|
|
||||||
requestUpdate();
|
|
||||||
return;
|
|
||||||
case EndOfBookOptions::Action::None:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enter reader menu activity on short-press Confirm. A long-press that fired a bound
|
// Enter reader menu activity on short-press Confirm. A long-press that fired a bound
|
||||||
// function (bookmark or KOReader sync) sets ignoreNextConfirmRelease so the release
|
// function (bookmark or KOReader sync) sets ignoreNextConfirmRelease so the release
|
||||||
// following the hold does not also open the menu.
|
// following the hold does not also open the menu.
|
||||||
@@ -385,7 +305,26 @@ void EpubReaderActivity::loop() {
|
|||||||
if (ignoreNextConfirmRelease) {
|
if (ignoreNextConfirmRelease) {
|
||||||
ignoreNextConfirmRelease = false;
|
ignoreNextConfirmRelease = false;
|
||||||
} else {
|
} else {
|
||||||
openReaderMenu();
|
const int currentPage = section ? section->currentPage + 1 : 0;
|
||||||
|
const int totalPages = section ? section->pageCount : 0;
|
||||||
|
float bookProgress = 0.0f;
|
||||||
|
if (epub->getBookSize() > 0 && section && section->pageCount > 0) {
|
||||||
|
const float chapterProgress = static_cast<float>(section->currentPage) / static_cast<float>(section->pageCount);
|
||||||
|
bookProgress = epub->calculateProgress(currentSpineIndex, chapterProgress) * 100.0f;
|
||||||
|
}
|
||||||
|
const int bookProgressPercent = clampPercent(static_cast<int>(bookProgress + 0.5f));
|
||||||
|
startActivityForResult(std::make_unique<EpubReaderMenuActivity>(
|
||||||
|
renderer, mappedInput, epub->getTitle(), currentPage, totalPages, bookProgressPercent,
|
||||||
|
SETTINGS.orientation, !currentPageFootnotes.empty(), !cachedBookmarks.empty()),
|
||||||
|
[this](const ActivityResult& result) {
|
||||||
|
// Always apply orientation change even if the menu was cancelled
|
||||||
|
const auto& menu = std::get<MenuResult>(result.data);
|
||||||
|
applyOrientation(menu.orientation);
|
||||||
|
toggleAutoPageTurn(menu.pageTurnOption);
|
||||||
|
if (!result.isCancelled) {
|
||||||
|
onReaderMenuConfirm(static_cast<EpubReaderMenuActivity::MenuAction>(menu.action));
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -466,14 +405,8 @@ void EpubReaderActivity::loop() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// At end of the book with no suggestion menu, forward button goes home and back
|
// At end of the book, forward button goes home and back button returns to last page
|
||||||
// button returns to last page
|
|
||||||
if (currentSpineIndex > 0 && currentSpineIndex >= epub->getSpineItemsCount()) {
|
if (currentSpineIndex > 0 && currentSpineIndex >= epub->getSpineItemsCount()) {
|
||||||
if (endOfBookOptions.menuActive()) {
|
|
||||||
// Selection movement was handled above; absorb leftover page-turn triggers so
|
|
||||||
// e.g. "previous" at the top of the list doesn't jump back into the book
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (nextTriggered) {
|
if (nextTriggered) {
|
||||||
onGoHome();
|
onGoHome();
|
||||||
} else {
|
} else {
|
||||||
@@ -604,32 +537,11 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
|
|||||||
loadCachedBookmarks();
|
loadCachedBookmarks();
|
||||||
if (!result.isCancelled) {
|
if (!result.isCancelled) {
|
||||||
const auto& sync = std::get<ProgressChangeResult>(result.data);
|
const auto& sync = std::get<ProgressChangeResult>(result.data);
|
||||||
int targetSpineIndex = sync.spineIndex;
|
if (currentSpineIndex != sync.spineIndex || (section && section->currentPage != sync.page)) {
|
||||||
int targetPage = sync.page;
|
|
||||||
const int activeTotalPages = section ? section->estimatedTotalPages() : 0;
|
|
||||||
const bool cachedPageMatchesActiveSection = section && sync.totalPages > 0 &&
|
|
||||||
currentSpineIndex == sync.spineIndex && sync.page >= 0 &&
|
|
||||||
sync.page < sync.totalPages && activeTotalPages == sync.totalPages;
|
|
||||||
|
|
||||||
if (!cachedPageMatchesActiveSection && sync.hasSavedProgress) {
|
|
||||||
const int totalPages = section ? section->estimatedTotalPages() : cachedChapterTotalPageCount;
|
|
||||||
CrossPointPosition fallback =
|
|
||||||
ProgressMapper::toCrossPoint(epub, {sync.xpath, sync.percentage}, renderer, currentSpineIndex, totalPages);
|
|
||||||
targetSpineIndex = fallback.spineIndex;
|
|
||||||
targetPage = fallback.pageNumber;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentSpineIndex != targetSpineIndex) {
|
|
||||||
RenderLock lock(*this);
|
RenderLock lock(*this);
|
||||||
currentSpineIndex = targetSpineIndex;
|
currentSpineIndex = sync.spineIndex;
|
||||||
nextPageNumber = targetPage;
|
nextPageNumber = sync.page;
|
||||||
section.reset();
|
section.reset();
|
||||||
} else if (section && section->currentPage != targetPage) {
|
|
||||||
RenderLock lock(*this);
|
|
||||||
const int clampedTargetPage = std::max(0, targetPage);
|
|
||||||
section->currentPage = clampedTargetPage;
|
|
||||||
} else if (!section) {
|
|
||||||
nextPageNumber = targetPage;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -749,7 +661,7 @@ bool EpubReaderActivity::launchKOReaderSync() {
|
|||||||
if (!KOREADER_STORE.hasCredentials()) return false; // no-op: nothing to launch
|
if (!KOREADER_STORE.hasCredentials()) return false; // no-op: nothing to launch
|
||||||
|
|
||||||
const int currentPage = section ? section->currentPage : nextPageNumber;
|
const int currentPage = section ? section->currentPage : nextPageNumber;
|
||||||
const int totalPages = section ? section->estimatedTotalPages() : cachedChapterTotalPageCount;
|
const int totalPages = section ? section->pageCount : cachedChapterTotalPageCount;
|
||||||
std::optional<uint16_t> paragraphIndex;
|
std::optional<uint16_t> paragraphIndex;
|
||||||
if (section && currentPage >= 0 && currentPage < section->pageCount) {
|
if (section && currentPage >= 0 && currentPage < section->pageCount) {
|
||||||
const uint16_t paragraphPage =
|
const uint16_t paragraphPage =
|
||||||
@@ -847,12 +759,7 @@ void EpubReaderActivity::toggleAutoPageTurn(const uint8_t selectedPageTurnOption
|
|||||||
|
|
||||||
void EpubReaderActivity::pageTurn(bool isForwardTurn) {
|
void EpubReaderActivity::pageTurn(bool isForwardTurn) {
|
||||||
if (isForwardTurn) {
|
if (isForwardTurn) {
|
||||||
// Advance within the section while there are (or may still be) more pages: either a built
|
if (section->currentPage < section->pageCount - 1) {
|
||||||
// page ahead, or the section is still building (windowed), in which case more pages exist
|
|
||||||
// beyond the current watermark and render()'s ensure-built pump will lay them out. Only when
|
|
||||||
// the section is fully built AND we're on its last page do we move to the next spine -- using
|
|
||||||
// the live pageCount alone would mistake the build watermark for the end of a giant spine.
|
|
||||||
if (section->currentPage < section->pageCount - 1 || section->isBuilding()) {
|
|
||||||
section->currentPage++;
|
section->currentPage++;
|
||||||
} else {
|
} else {
|
||||||
// We don't want to delete the section mid-render, so grab the semaphore
|
// We don't want to delete the section mid-render, so grab the semaphore
|
||||||
@@ -904,11 +811,8 @@ void EpubReaderActivity::render(RenderLock&& lock) {
|
|||||||
|
|
||||||
// Show end of book screen
|
// Show end of book screen
|
||||||
if (currentSpineIndex == epub->getSpineItemsCount()) {
|
if (currentSpineIndex == epub->getSpineItemsCount()) {
|
||||||
// Sole load site: runs on the render task (serialized by RenderLock); the main
|
|
||||||
// task only reads the suggestions once the loaded flag is published
|
|
||||||
endOfBookOptions.loadOnce(epub->getPath());
|
|
||||||
renderer.clearScreen();
|
renderer.clearScreen();
|
||||||
endOfBookOptions.render(renderer, mappedInput);
|
renderer.drawCenteredText(UI_12_FONT_ID, 300, tr(STR_END_OF_BOOK), true, EpdFontFamily::BOLD);
|
||||||
renderer.displayBuffer();
|
renderer.displayBuffer();
|
||||||
automaticPageTurnActive = false;
|
automaticPageTurnActive = false;
|
||||||
showPendingSyncSaveError();
|
showPendingSyncSaveError();
|
||||||
@@ -943,130 +847,48 @@ void EpubReaderActivity::render(RenderLock&& lock) {
|
|||||||
LOG_DBG("ERS", "Loading file: %s, index: %d", filepath.c_str(), currentSpineIndex);
|
LOG_DBG("ERS", "Loading file: %s, index: %d", filepath.c_str(), currentSpineIndex);
|
||||||
section = std::unique_ptr<Section>(new Section(epub, currentSpineIndex, renderer));
|
section = std::unique_ptr<Section>(new Section(epub, currentSpineIndex, renderer));
|
||||||
|
|
||||||
// A finalized cache serves every page as-is. A partial cache (suspended build from a
|
if (!section->loadSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
|
||||||
// previous session) serves its pages instantly too, but a build must still run to lay
|
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
|
||||||
// out the rest -- it re-parses from the top in the background (HTML already cached,
|
viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle,
|
||||||
// pages are deterministic) and finalizes, so the partial machinery retires itself.
|
SETTINGS.imageRendering, SETTINGS.focusReadingEnabled)) {
|
||||||
const bool cacheLoaded = section->loadSectionFile(
|
LOG_DBG("ERS", "Cache not found, building...");
|
||||||
SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(), SETTINGS.extraParagraphSpacing,
|
|
||||||
SETTINGS.paragraphAlignment, viewportWidth, viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle,
|
|
||||||
SETTINGS.imageRendering, SETTINGS.focusReadingEnabled);
|
|
||||||
if (cacheLoaded) {
|
|
||||||
// Matching render params means identical pagination, so the saved page number is valid
|
|
||||||
// as-is: consume any pending settings-change reposition. Without this, a chapter total
|
|
||||||
// saved while the section was still building (i.e. a watermark, not the real count)
|
|
||||||
// would remap the resume page against the finalized count and teleport the reader.
|
|
||||||
cachedChapterTotalPageCount = 0;
|
|
||||||
}
|
|
||||||
const bool cacheComplete = cacheLoaded && !section->isPartial();
|
|
||||||
if (!cacheComplete) {
|
|
||||||
if (section->isPartial()) {
|
|
||||||
LOG_DBG("ERS", "Partial cache found (%d pages), resuming build...", section->pageCount);
|
|
||||||
} else {
|
|
||||||
LOG_DBG("ERS", "Cache not found, building...");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Jumps that need the final pagination or the anchor map -- explicit page jumps,
|
GUI.drawPopup(renderer, tr(STR_INDEXING));
|
||||||
// fragment anchors, percent jumps, and cross-setting progress repositioning -- can't
|
|
||||||
// resolve their landing page until the whole chapter is laid out, so they take the full
|
const auto popupFn = [this]() { GUI.drawPopup(renderer, tr(STR_INDEXING)); };
|
||||||
// (blocking) build with the indexing popup. Everything else -- plain forward reads, resume,
|
|
||||||
// and explicit page jumps -- only needs a specific page, so it builds incrementally to that
|
if (!section->createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
|
||||||
// page and finishes the rest in loop(). The settings-change reposition (cachedChapterTotal*)
|
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
|
||||||
// is NOT a full-build trigger: it's deferred to applyDeferredReposition() once the real page
|
viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle,
|
||||||
// count is known, so it never blocks the first page.
|
SETTINGS.imageRendering, SETTINGS.focusReadingEnabled, popupFn)) {
|
||||||
// Only a percent jump truly needs the whole chapter up front (percent -> page needs the final
|
LOG_ERR("ERS", "Failed to persist page data to SD");
|
||||||
// page count). Anchor jumps (TOC / chapter select / footnotes) resolve incrementally below --
|
section.reset();
|
||||||
// the anchor is recorded as its page is laid out, so a chapter-top anchor lands on page 0
|
showPendingSyncSaveError();
|
||||||
// without indexing the whole chapter.
|
return;
|
||||||
const bool needsFullBuild = pendingPercentJump;
|
|
||||||
if (needsFullBuild) {
|
|
||||||
GUI.drawPopup(renderer, tr(STR_INDEXING));
|
|
||||||
// The popup's own refresh is a plain FAST, so force the page that replaces it onto the HALF
|
|
||||||
// ghost-cleanup path -- otherwise the "INDEXING" text ghosts under the rendered page.
|
|
||||||
pagesUntilFullRefresh = 1;
|
|
||||||
const auto popupFn = [this]() { GUI.drawPopup(renderer, tr(STR_INDEXING)); };
|
|
||||||
if (!section->createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
|
|
||||||
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
|
|
||||||
viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle,
|
|
||||||
SETTINGS.imageRendering, SETTINGS.focusReadingEnabled, popupFn)) {
|
|
||||||
LOG_ERR("ERS", "Failed to persist page data to SD");
|
|
||||||
section.reset();
|
|
||||||
showPendingSyncSaveError();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Lay out just enough to show the landing page; loop() builds the rest behind it. Show the
|
|
||||||
// indexing popup up front only when the build will actually be slow: a large spine (its
|
|
||||||
// whole HTML must be inflated before page 1 can lay out -- the giant single-spine case), or
|
|
||||||
// a deep resume/jump that must lay out many pages to reach the landing page. Tiny sections
|
|
||||||
// build in a blink and stay popup-free.
|
|
||||||
const int target = pendingPageJump.has_value() ? *pendingPageJump : (nextPageNumber < 0 ? 0 : nextPageNumber);
|
|
||||||
const size_t spineBytes = epub->getCumulativeSpineItemSize(currentSpineIndex) -
|
|
||||||
(currentSpineIndex > 0 ? epub->getCumulativeSpineItemSize(currentSpineIndex - 1) : 0);
|
|
||||||
// Popup only when the build will actually be slow: a big spine whose HTML still needs
|
|
||||||
// inflating (the multi-second cost), or a deep page target. A reopen with cached HTML builds
|
|
||||||
// fast, so no popup -- that's what made an already-indexed book look like it was reindexing.
|
|
||||||
// A partial cache that already covers the target page shows it instantly: never popup.
|
|
||||||
const bool willInflate = !section->hasHtmlCache();
|
|
||||||
const bool anchorJump = !pendingAnchor.empty();
|
|
||||||
bool showPopup;
|
|
||||||
if (anchorJump) {
|
|
||||||
// An anchor jump's cost is bounded by the anchor's page, not `target`. An anchor already
|
|
||||||
// in the on-disk map (partial or finalized cache) lands instantly: no popup. Otherwise it
|
|
||||||
// lies beyond the indexed watermark and the build may lay out the whole spine to find it,
|
|
||||||
// so gate on spine size alone -- laying out a big spine takes seconds even with cached
|
|
||||||
// HTML. Ordinary chapter-top TOC jumps resolve on page 0 and stay popup-free.
|
|
||||||
showPopup = !section->findAnchor(pendingAnchor).has_value() && spineBytes > BUILD_POPUP_BYTE_THRESHOLD;
|
|
||||||
} else {
|
|
||||||
const bool targetAvailable = target < static_cast<int>(section->pageCount);
|
|
||||||
showPopup = !targetAvailable &&
|
|
||||||
((spineBytes > BUILD_POPUP_BYTE_THRESHOLD && willInflate) || target > BUILD_POPUP_PAGE_THRESHOLD);
|
|
||||||
}
|
|
||||||
if (showPopup) {
|
|
||||||
GUI.drawPopup(renderer, tr(STR_INDEXING));
|
|
||||||
// HALF-clear the popup when the page replaces it, else "INDEXING" ghosts under the page.
|
|
||||||
pagesUntilFullRefresh = 1;
|
|
||||||
}
|
|
||||||
if (!section->startBuild(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
|
|
||||||
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
|
|
||||||
viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle,
|
|
||||||
SETTINGS.imageRendering, SETTINGS.focusReadingEnabled)) {
|
|
||||||
LOG_ERR("ERS", "Failed to start section build");
|
|
||||||
section.reset();
|
|
||||||
showPendingSyncSaveError();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
while (!section->isBuildComplete() &&
|
|
||||||
(anchorJump ? !section->findAnchor(pendingAnchor) : static_cast<int>(section->pageCount) <= target)) {
|
|
||||||
// Anchor jump: build until the anchor's page is laid out (usually page 0), checking a
|
|
||||||
// partial's on-disk anchor map too so an already-indexed anchor resolves immediately.
|
|
||||||
// Otherwise: build until the target page exists. loop() builds the rest behind it.
|
|
||||||
if (!section->buildSomeMore(BUILD_PAGES_PER_CHUNK)) {
|
|
||||||
LOG_ERR("ERS", "Failed during incremental section build");
|
|
||||||
section.reset();
|
|
||||||
showPendingSyncSaveError();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
LOG_DBG("ERS", "Cache found, skipping build...");
|
LOG_DBG("ERS", "Cache found, skipping build...");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pendingPageJump.has_value()) {
|
if (pendingPageJump.has_value()) {
|
||||||
section->currentPage = *pendingPageJump;
|
if (*pendingPageJump >= section->pageCount && section->pageCount > 0) {
|
||||||
|
section->currentPage = section->pageCount - 1;
|
||||||
|
} else {
|
||||||
|
section->currentPage = *pendingPageJump;
|
||||||
|
}
|
||||||
pendingPageJump.reset();
|
pendingPageJump.reset();
|
||||||
} else {
|
} else {
|
||||||
section->currentPage = nextPageNumber;
|
section->currentPage = nextPageNumber;
|
||||||
if (section->currentPage < 0) {
|
if (section->currentPage < 0) {
|
||||||
section->currentPage = 0;
|
section->currentPage = 0;
|
||||||
|
} else if (section->currentPage >= section->pageCount && section->pageCount > 0) {
|
||||||
|
LOG_DBG("ERS", "Clamping cached page %d to %d", section->currentPage, section->pageCount - 1);
|
||||||
|
section->currentPage = section->pageCount - 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!pendingAnchor.empty()) {
|
if (!pendingAnchor.empty()) {
|
||||||
// Resolve from the pages laid out so far and/or the on-disk map (finalized or partial).
|
if (const auto page = section->getPageForAnchor(pendingAnchor)) {
|
||||||
const auto page = section->findAnchor(pendingAnchor);
|
|
||||||
if (page) {
|
|
||||||
section->currentPage = *page;
|
section->currentPage = *page;
|
||||||
LOG_DBG("ERS", "Resolved anchor '%s' to page %d", pendingAnchor.c_str(), *page);
|
LOG_DBG("ERS", "Resolved anchor '%s' to page %d", pendingAnchor.c_str(), *page);
|
||||||
} else {
|
} else {
|
||||||
@@ -1075,6 +897,17 @@ void EpubReaderActivity::render(RenderLock&& lock) {
|
|||||||
pendingAnchor.clear();
|
pendingAnchor.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handles changes in reader settings and reset to approximate position based on cached progress
|
||||||
|
if (cachedChapterTotalPageCount > 0) {
|
||||||
|
// only goes to relative position if spine index matches cached value
|
||||||
|
if (currentSpineIndex == cachedSpineIndex && section->pageCount != cachedChapterTotalPageCount) {
|
||||||
|
float progress = static_cast<float>(section->currentPage) / static_cast<float>(cachedChapterTotalPageCount);
|
||||||
|
int newPage = static_cast<int>(progress * section->pageCount);
|
||||||
|
section->currentPage = newPage;
|
||||||
|
}
|
||||||
|
cachedChapterTotalPageCount = 0; // resets to 0 to prevent reading cached progress again
|
||||||
|
}
|
||||||
|
|
||||||
if (pendingPercentJump && section->pageCount > 0) {
|
if (pendingPercentJump && section->pageCount > 0) {
|
||||||
// Apply the pending percent jump now that we know the new section's page count.
|
// Apply the pending percent jump now that we know the new section's page count.
|
||||||
int newPage = static_cast<int>(pendingSpineProgress * static_cast<float>(section->pageCount));
|
int newPage = static_cast<int>(pendingSpineProgress * static_cast<float>(section->pageCount));
|
||||||
@@ -1086,57 +919,6 @@ void EpubReaderActivity::render(RenderLock&& lock) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extend the build to the requested page if needed (for partials and in-progress builds).
|
|
||||||
// This runs every render, so it covers both the first page and any forward turn that gets
|
|
||||||
// ahead of the background builder; pages already built do no work here.
|
|
||||||
while (section->isPartial() && section->currentPage >= static_cast<int>(section->pageCount)) {
|
|
||||||
// Start a build to extend a partial toward the requested page.
|
|
||||||
if (!section->isBuilding() &&
|
|
||||||
!section->startBuild(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
|
|
||||||
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth, viewportHeight,
|
|
||||||
SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle, SETTINGS.imageRendering,
|
|
||||||
SETTINGS.focusReadingEnabled)) {
|
|
||||||
LOG_ERR("ERS", "Failed to start partial extension build");
|
|
||||||
section.reset();
|
|
||||||
showPendingSyncSaveError();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Extend until either the target page exists or the build completes.
|
|
||||||
while (!section->isBuildComplete() && section->currentPage >= static_cast<int>(section->pageCount)) {
|
|
||||||
if (!section->buildSomeMore(BUILD_PAGES_PER_CHUNK)) {
|
|
||||||
LOG_ERR("ERS", "Failed during incremental section build");
|
|
||||||
section.reset();
|
|
||||||
showPendingSyncSaveError();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// For an in-progress incremental build, make sure the page we're about to show has been laid out.
|
|
||||||
if (section->isBuilding()) {
|
|
||||||
while (!section->isBuildComplete() && section->currentPage >= static_cast<int>(section->pageCount)) {
|
|
||||||
if (!section->buildSomeMore(BUILD_PAGES_PER_CHUNK)) {
|
|
||||||
LOG_ERR("ERS", "Failed during incremental section build");
|
|
||||||
section.reset();
|
|
||||||
showPendingSyncSaveError();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The requested page is now as built as it will get. If it still lands past the end,
|
|
||||||
// clamp to the last real page: the UINT16_MAX "last page" sentinel from backward chapter
|
|
||||||
// navigation, an explicit jump beyond a finished chapter, or a stale saved position.
|
|
||||||
// Guarded on !isBuilding() because a still-building section's pageCount is only the current
|
|
||||||
// watermark (not the final count) and has already been driven far enough by the loops above.
|
|
||||||
if (!section->isBuilding() && section->pageCount > 0 &&
|
|
||||||
section->currentPage >= static_cast<int>(section->pageCount)) {
|
|
||||||
section->currentPage = section->pageCount - 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply a deferred settings-change reposition now that the real page count is known (a no-op for
|
|
||||||
// a plain resume / unchanged pagination). If still building, this defers to loop() on completion.
|
|
||||||
applyDeferredReposition();
|
|
||||||
|
|
||||||
renderer.clearScreen();
|
renderer.clearScreen();
|
||||||
|
|
||||||
if (section->pageCount == 0) {
|
if (section->pageCount == 0) {
|
||||||
@@ -1162,14 +944,9 @@ void EpubReaderActivity::render(RenderLock&& lock) {
|
|||||||
updateBookmarkFlag();
|
updateBookmarkFlag();
|
||||||
|
|
||||||
{
|
{
|
||||||
// Unified page read: the in-progress build's in-RAM table if it has reached the page,
|
auto p = section->loadPageFromSectionFile();
|
||||||
// otherwise the on-disk file (finalized section, or a partial from a previous session).
|
|
||||||
auto p = section->loadPage(section->currentPage);
|
|
||||||
if (!p) {
|
if (!p) {
|
||||||
LOG_ERR("ERS", "Failed to load page from SD - clearing section cache");
|
LOG_ERR("ERS", "Failed to load page from SD - clearing section cache");
|
||||||
// Abandon (not suspend) any active build BEFORE clearing: clearCache deletes the files,
|
|
||||||
// and the destructor's suspend would otherwise commit tables into a deleted handle.
|
|
||||||
section->abandonBuild();
|
|
||||||
section->clearCache();
|
section->clearCache();
|
||||||
section.reset();
|
section.reset();
|
||||||
requestUpdate(); // Try again after clearing cache
|
requestUpdate(); // Try again after clearing cache
|
||||||
@@ -1186,7 +963,8 @@ void EpubReaderActivity::render(RenderLock&& lock) {
|
|||||||
renderContents(std::move(p), orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
|
renderContents(std::move(p), orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
|
||||||
LOG_DBG("ERS", "Rendered page in %dms", millis() - start);
|
LOG_DBG("ERS", "Rendered page in %dms", millis() - start);
|
||||||
}
|
}
|
||||||
saveProgress(currentSpineIndex, section->currentPage, section->estimatedTotalPages());
|
silentIndexNextChapterIfNeeded(viewportWidth, viewportHeight);
|
||||||
|
saveProgress(currentSpineIndex, section->currentPage, section->pageCount);
|
||||||
|
|
||||||
showPendingSyncSaveError();
|
showPendingSyncSaveError();
|
||||||
|
|
||||||
@@ -1200,28 +978,36 @@ void EpubReaderActivity::render(RenderLock&& lock) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
bool EpubReaderActivity::applyDeferredReposition() {
|
void EpubReaderActivity::silentIndexNextChapterIfNeeded(const uint16_t viewportWidth, const uint16_t viewportHeight) {
|
||||||
if (cachedChapterTotalPageCount == 0 || !section || section->isBuilding()) {
|
if (!epub || !section || section->pageCount < 2) {
|
||||||
return false;
|
return;
|
||||||
}
|
}
|
||||||
bool changed = false;
|
|
||||||
// Only remap when the chapter actually re-paginated (e.g. after a settings change). A plain
|
// Build the next chapter cache while the penultimate page is on screen.
|
||||||
// resume has identical pagination, so section->pageCount == cachedChapterTotalPageCount and
|
if (section->currentPage != section->pageCount - 2) {
|
||||||
// nothing moves.
|
return;
|
||||||
if (currentSpineIndex == cachedSpineIndex && section->pageCount != cachedChapterTotalPageCount) {
|
}
|
||||||
const float progress = static_cast<float>(section->currentPage) / static_cast<float>(cachedChapterTotalPageCount);
|
|
||||||
int newPage = static_cast<int>(progress * static_cast<float>(section->pageCount));
|
const int nextSpineIndex = currentSpineIndex + 1;
|
||||||
if (newPage < 0) newPage = 0;
|
if (nextSpineIndex < 0 || nextSpineIndex >= epub->getSpineItemsCount()) {
|
||||||
if (section->pageCount > 0 && newPage >= static_cast<int>(section->pageCount)) {
|
return;
|
||||||
newPage = section->pageCount - 1;
|
}
|
||||||
}
|
|
||||||
if (newPage != section->currentPage) {
|
Section nextSection(epub, nextSpineIndex, renderer);
|
||||||
section->currentPage = newPage;
|
if (nextSection.loadSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
|
||||||
changed = true;
|
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
|
||||||
}
|
viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle,
|
||||||
|
SETTINGS.imageRendering, SETTINGS.focusReadingEnabled)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
LOG_DBG("ERS", "Silently indexing next chapter: %d", nextSpineIndex);
|
||||||
|
if (!nextSection.createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
|
||||||
|
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
|
||||||
|
viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle,
|
||||||
|
SETTINGS.imageRendering, SETTINGS.focusReadingEnabled)) {
|
||||||
|
LOG_ERR("ERS", "Failed silent indexing for chapter: %d", nextSpineIndex);
|
||||||
}
|
}
|
||||||
cachedChapterTotalPageCount = 0; // consumed; don't read cached progress again
|
|
||||||
return changed;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bool EpubReaderActivity::saveProgress(int spineIndex, int currentPage, int pageCount) {
|
bool EpubReaderActivity::saveProgress(int spineIndex, int currentPage, int pageCount) {
|
||||||
@@ -1241,8 +1027,7 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
|
|||||||
const auto tPrewarm = millis();
|
const auto tPrewarm = millis();
|
||||||
|
|
||||||
const bool pageHasImages = page->hasImages();
|
const bool pageHasImages = page->hasImages();
|
||||||
const bool needsTextGrayscale = SETTINGS.textAntiAliasing == CrossPointSettings::TEXT_AA_FULL;
|
const bool needsTextGrayscale = SETTINGS.textAntiAliasing;
|
||||||
const bool fastTextAA = SETTINGS.textAntiAliasing == CrossPointSettings::TEXT_AA_FAST;
|
|
||||||
const bool needsAnyGrayscale = needsTextGrayscale || pageHasImages;
|
const bool needsAnyGrayscale = needsTextGrayscale || pageHasImages;
|
||||||
auto renderGrayscalePass = [&]() {
|
auto renderGrayscalePass = [&]() {
|
||||||
if (needsTextGrayscale) {
|
if (needsTextGrayscale) {
|
||||||
@@ -1252,11 +1037,7 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fast AA dithers 2-bit glyph edges in the BW pass itself, no grayscale
|
|
||||||
// refresh. Scoped to the page render so nothing else inherits the flag.
|
|
||||||
renderer.setFastAntiAliasing(fastTextAA);
|
|
||||||
page->render(renderer, fontId, orientedMarginLeft, orientedMarginTop);
|
page->render(renderer, fontId, orientedMarginLeft, orientedMarginTop);
|
||||||
renderer.setFastAntiAliasing(false);
|
|
||||||
renderStatusBar();
|
renderStatusBar();
|
||||||
const auto tBwRender = millis();
|
const auto tBwRender = millis();
|
||||||
|
|
||||||
@@ -1273,9 +1054,7 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
|
|||||||
|
|
||||||
// Re-render page content to restore images into the blanked area
|
// Re-render page content to restore images into the blanked area
|
||||||
// Status bar is not re-rendered here to avoid reading stale dynamic values (e.g. battery %)
|
// Status bar is not re-rendered here to avoid reading stale dynamic values (e.g. battery %)
|
||||||
renderer.setFastAntiAliasing(fastTextAA);
|
|
||||||
page->render(renderer, fontId, orientedMarginLeft, orientedMarginTop);
|
page->render(renderer, fontId, orientedMarginLeft, orientedMarginTop);
|
||||||
renderer.setFastAntiAliasing(false);
|
|
||||||
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||||
} else {
|
} else {
|
||||||
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
||||||
@@ -1308,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);
|
||||||
@@ -1401,10 +1185,9 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
|
|||||||
}
|
}
|
||||||
|
|
||||||
void EpubReaderActivity::renderStatusBar() const {
|
void EpubReaderActivity::renderStatusBar() const {
|
||||||
// Calculate progress in book. Use the estimated total while a giant spine is still building so
|
// Calculate progress in book
|
||||||
// "page X of Y" and the progress bar don't read off the small build watermark.
|
|
||||||
const int currentPage = section->currentPage + 1;
|
const int currentPage = section->currentPage + 1;
|
||||||
const float pageCount = section->estimatedTotalPages();
|
const float pageCount = section->pageCount;
|
||||||
const float sectionChapterProg = (pageCount > 0) ? (static_cast<float>(currentPage) / pageCount) : 0;
|
const float sectionChapterProg = (pageCount > 0) ? (static_cast<float>(currentPage) / pageCount) : 0;
|
||||||
const float bookProgress = epub->calculateProgress(currentSpineIndex, sectionChapterProg) * 100;
|
const float bookProgress = epub->calculateProgress(currentSpineIndex, sectionChapterProg) * 100;
|
||||||
|
|
||||||
@@ -1435,8 +1218,7 @@ void EpubReaderActivity::renderStatusBar() const {
|
|||||||
title = epub->getTitle();
|
title = epub->getTitle();
|
||||||
}
|
}
|
||||||
|
|
||||||
GUI.drawStatusBar(renderer, bookProgress, currentPage, pageCount, title, 0, textYOffset, true, currentPageBookmarked,
|
GUI.drawStatusBar(renderer, bookProgress, currentPage, pageCount, title, 0, textYOffset, true, currentPageBookmarked);
|
||||||
section->isBuilding());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void EpubReaderActivity::navigateToHref(const std::string& hrefStr, const bool savePosition) {
|
void EpubReaderActivity::navigateToHref(const std::string& hrefStr, const bool savePosition) {
|
||||||
@@ -1527,7 +1309,7 @@ void EpubReaderActivity::addBookmark() {
|
|||||||
int pageCount;
|
int pageCount;
|
||||||
{
|
{
|
||||||
RenderLock lock(*this);
|
RenderLock lock(*this);
|
||||||
pageCount = section->estimatedTotalPages();
|
pageCount = section->pageCount;
|
||||||
currentPage = section->currentPage;
|
currentPage = section->currentPage;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1576,10 +1358,10 @@ void EpubReaderActivity::updateBookmarkFlag() {
|
|||||||
currentPageBookmarked = false;
|
currentPageBookmarked = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const int pageCount = section->estimatedTotalPages();
|
const ProgressRange pageRange =
|
||||||
const ProgressRange pageRange = getPageProgressRange(epub, currentSpineIndex, section->currentPage, 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, currentSpineIndex, section->currentPage, pageCount, pageRange);
|
return bookmarkMatchesProgress(b, currentSpineIndex, section->currentPage, section->pageCount, pageRange);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1592,9 +1374,9 @@ ScreenshotInfo EpubReaderActivity::getScreenshotInfo() const {
|
|||||||
}
|
}
|
||||||
if (section) {
|
if (section) {
|
||||||
info.currentPage = section->currentPage + 1;
|
info.currentPage = section->currentPage + 1;
|
||||||
info.totalPages = section->estimatedTotalPages();
|
info.totalPages = section->pageCount;
|
||||||
if (epub && epub->getBookSize() > 0 && info.totalPages > 0) {
|
if (epub && epub->getBookSize() > 0 && section->pageCount > 0) {
|
||||||
const float chapterProgress = static_cast<float>(section->currentPage) / static_cast<float>(info.totalPages);
|
const float chapterProgress = static_cast<float>(section->currentPage) / static_cast<float>(section->pageCount);
|
||||||
int pct = static_cast<int>(epub->calculateProgress(currentSpineIndex, chapterProgress) * 100.0f + 0.5f);
|
int pct = static_cast<int>(epub->calculateProgress(currentSpineIndex, chapterProgress) * 100.0f + 0.5f);
|
||||||
if (pct < 0) pct = 0;
|
if (pct < 0) pct = 0;
|
||||||
if (pct > 100) pct = 100;
|
if (pct > 100) pct = 100;
|
||||||
@@ -1606,7 +1388,7 @@ ScreenshotInfo EpubReaderActivity::getScreenshotInfo() const {
|
|||||||
|
|
||||||
CrossPointPosition EpubReaderActivity::getCurrentPosition() const {
|
CrossPointPosition EpubReaderActivity::getCurrentPosition() const {
|
||||||
const int currentPage = section ? section->currentPage : nextPageNumber;
|
const int currentPage = section ? section->currentPage : nextPageNumber;
|
||||||
const int totalPages = section ? section->estimatedTotalPages() : cachedChapterTotalPageCount;
|
const int totalPages = section ? section->pageCount : cachedChapterTotalPageCount;
|
||||||
std::optional<uint16_t> paragraphIndex;
|
std::optional<uint16_t> paragraphIndex;
|
||||||
if (section && currentPage >= 0 && currentPage < section->pageCount) {
|
if (section && currentPage >= 0 && currentPage < section->pageCount) {
|
||||||
const uint16_t paragraphPage =
|
const uint16_t paragraphPage =
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
#include <optional>
|
#include <optional>
|
||||||
|
|
||||||
#include "BookmarkEntry.h"
|
#include "BookmarkEntry.h"
|
||||||
#include "EndOfBookOptions.h"
|
|
||||||
#include "EpubReaderMenuActivity.h"
|
#include "EpubReaderMenuActivity.h"
|
||||||
#include "ProgressMapper.h"
|
#include "ProgressMapper.h"
|
||||||
#include "activities/Activity.h"
|
#include "activities/Activity.h"
|
||||||
@@ -46,8 +45,6 @@ class EpubReaderActivity final : public Activity {
|
|||||||
// Set when the reader is left at end-of-book and SETTINGS.moveFinishedToReadFolder is on.
|
// Set when the reader is left at end-of-book and SETTINGS.moveFinishedToReadFolder is on.
|
||||||
// Consumed in onExit() to relocate the finished book into /Read/.
|
// Consumed in onExit() to relocate the finished book into /Read/.
|
||||||
bool pendingReadFolderMove = false;
|
bool pendingReadFolderMove = false;
|
||||||
// Next-book suggestion menu for the End-of-Book screen
|
|
||||||
EndOfBookOptions endOfBookOptions;
|
|
||||||
|
|
||||||
// Footnote support
|
// Footnote support
|
||||||
std::vector<FootnoteEntry> currentPageFootnotes;
|
std::vector<FootnoteEntry> currentPageFootnotes;
|
||||||
@@ -62,37 +59,11 @@ class EpubReaderActivity final : public Activity {
|
|||||||
void renderContents(std::unique_ptr<Page> page, int orientedMarginTop, int orientedMarginRight,
|
void renderContents(std::unique_ptr<Page> page, int orientedMarginTop, int orientedMarginRight,
|
||||||
int orientedMarginBottom, int orientedMarginLeft);
|
int orientedMarginBottom, int orientedMarginLeft);
|
||||||
void renderStatusBar() const;
|
void renderStatusBar() const;
|
||||||
// Pages laid out per incremental-build pump: on the render path (catching up to the page
|
void silentIndexNextChapterIfNeeded(uint16_t viewportWidth, uint16_t viewportHeight);
|
||||||
// being shown) and per loop() tick (background build of a large chapter). Kept small so a
|
|
||||||
// background build chunk never noticeably delays input or a pending render.
|
|
||||||
static constexpr int BUILD_PAGES_PER_CHUNK = 8;
|
|
||||||
static constexpr int BACKGROUND_BUILD_PAGES_PER_TICK = 2;
|
|
||||||
// How many pages to keep laid out ahead of the reader for a still-building section. A page
|
|
||||||
// turn is ~1s on e-ink and a page builds in ~30ms, so the reader can't out-click the builder
|
|
||||||
// -- a tiny buffer is enough. The background build stops once the watermark is this far
|
|
||||||
// ahead and resumes as the reader advances; building unbounded instead locked up input by
|
|
||||||
// monopolizing the RenderLock. A giant single-spine book therefore never finalizes its .bin
|
|
||||||
// in one sitting -- instant reopen comes from Section::suspendBuild() persisting the pages
|
|
||||||
// already laid out as a partial file on exit/sleep.
|
|
||||||
static constexpr int BUILD_WINDOW_AHEAD = 5;
|
|
||||||
// Show the indexing popup when an initial build must lay out more than this many pages up front
|
|
||||||
// (a deep resume/jump into a not-yet-built section), so it isn't a silent wait. Kept independent
|
|
||||||
// of the small look-ahead window so ordinary landings stay popup-free.
|
|
||||||
static constexpr int BUILD_POPUP_PAGE_THRESHOLD = 20;
|
|
||||||
// Also show the popup when first building a spine larger than this (uncompressed bytes): its
|
|
||||||
// whole HTML must be inflated before page 1 can lay out (the giant single-spine case), which is
|
|
||||||
// a multi-second wait. Normal chapters are well under this and stay popup-free.
|
|
||||||
static constexpr size_t BUILD_POPUP_BYTE_THRESHOLD = 96 * 1024;
|
|
||||||
// Remap the cached relative reading position once the section's real page count is known
|
|
||||||
// (used after a settings change re-paginates a chapter). Returns true if currentPage moved.
|
|
||||||
// No-op while the section is still building or when the pagination is unchanged (plain resume).
|
|
||||||
bool applyDeferredReposition();
|
|
||||||
bool saveProgress(int spineIndex, int currentPage, int pageCount);
|
bool saveProgress(int spineIndex, int currentPage, int pageCount);
|
||||||
// Jump to a percentage of the book (0-100), mapping it to spine and page.
|
// Jump to a percentage of the book (0-100), mapping it to spine and page.
|
||||||
void jumpToPercent(int percent);
|
void jumpToPercent(int percent);
|
||||||
void onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action);
|
void onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action);
|
||||||
// Opens the reader menu for the current position (short-press Confirm)
|
|
||||||
void openReaderMenu();
|
|
||||||
// Returns true if sync acted (launched, or surfaced a save error); false if it was a no-op
|
// Returns true if sync acted (launched, or surfaced a save error); false if it was a no-op
|
||||||
// because no KOReader credentials are stored.
|
// because no KOReader credentials are stored.
|
||||||
bool launchKOReaderSync();
|
bool launchKOReaderSync();
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
|
||||||
#include "MappedInputManager.h"
|
#include "MappedInputManager.h"
|
||||||
|
#include "ProgressMapper.h"
|
||||||
#include "components/UITheme.h"
|
#include "components/UITheme.h"
|
||||||
#include "fontIds.h"
|
#include "fontIds.h"
|
||||||
|
|
||||||
@@ -107,17 +108,8 @@ void EpubReaderBookmarksActivity::loop() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
auto bookmark = bookmarks.at(selectorIndex);
|
auto bookmark = bookmarks.at(selectorIndex);
|
||||||
ProgressChangeResult result{};
|
CrossPointPosition pos = ProgressMapper::toCrossPoint(epub, {bookmark.xpath, bookmark.percentage}, renderer);
|
||||||
result.xpath = bookmark.xpath;
|
setResult(ProgressChangeResult{pos.spineIndex, pos.pageNumber});
|
||||||
result.percentage = bookmark.percentage;
|
|
||||||
result.hasSavedProgress = true;
|
|
||||||
if (bookmark.computedChapterPageCount > 0 && bookmark.computedChapterProgress < bookmark.computedChapterPageCount &&
|
|
||||||
bookmark.computedSpineIndex < epub->getSpineItemsCount()) {
|
|
||||||
result.spineIndex = bookmark.computedSpineIndex;
|
|
||||||
result.page = bookmark.computedChapterProgress;
|
|
||||||
result.totalPages = bookmark.computedChapterPageCount;
|
|
||||||
}
|
|
||||||
setResult(std::move(result));
|
|
||||||
finish();
|
finish();
|
||||||
return;
|
return;
|
||||||
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||||
|
|||||||
@@ -50,8 +50,6 @@ void EpubReaderMenuActivity::onEnter() {
|
|||||||
void EpubReaderMenuActivity::onExit() { Activity::onExit(); }
|
void EpubReaderMenuActivity::onExit() { Activity::onExit(); }
|
||||||
|
|
||||||
void EpubReaderMenuActivity::loop() {
|
void EpubReaderMenuActivity::loop() {
|
||||||
if (optionPopup.handleInput(mappedInput, [this] { requestUpdate(); })) return;
|
|
||||||
|
|
||||||
// Handle navigation
|
// Handle navigation
|
||||||
buttonNavigator.onNext([this] {
|
buttonNavigator.onNext([this] {
|
||||||
selectedIndex = ButtonNavigator::nextIndex(selectedIndex, static_cast<int>(menuItems.size()));
|
selectedIndex = ButtonNavigator::nextIndex(selectedIndex, static_cast<int>(menuItems.size()));
|
||||||
@@ -66,21 +64,14 @@ void EpubReaderMenuActivity::loop() {
|
|||||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||||
const auto selectedAction = menuItems[selectedIndex].action;
|
const auto selectedAction = menuItems[selectedIndex].action;
|
||||||
if (selectedAction == MenuAction::ROTATE_SCREEN) {
|
if (selectedAction == MenuAction::ROTATE_SCREEN) {
|
||||||
optionPopup.show(StrId::STR_ORIENTATION, orientationLabels.data(), static_cast<int>(orientationLabels.size()),
|
// Cycle orientation preview locally; actual rotation happens on menu exit.
|
||||||
pendingOrientation, [this](int idx) {
|
pendingOrientation = (pendingOrientation + 1) % orientationLabels.size();
|
||||||
pendingOrientation = idx;
|
|
||||||
requestUpdate();
|
|
||||||
});
|
|
||||||
requestUpdate();
|
requestUpdate();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedAction == MenuAction::AUTO_PAGE_TURN) {
|
if (selectedAction == MenuAction::AUTO_PAGE_TURN) {
|
||||||
optionPopup.show(I18N.get(StrId::STR_AUTO_TURN_PAGES_PER_MIN), pageTurnLabels.data(),
|
selectedPageTurnOption = (selectedPageTurnOption + 1) % pageTurnLabels.size();
|
||||||
static_cast<int>(pageTurnLabels.size()), selectedPageTurnOption, [this](int idx) {
|
|
||||||
selectedPageTurnOption = idx;
|
|
||||||
requestUpdate();
|
|
||||||
});
|
|
||||||
requestUpdate();
|
requestUpdate();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -99,8 +90,6 @@ void EpubReaderMenuActivity::loop() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void EpubReaderMenuActivity::render(RenderLock&&) {
|
void EpubReaderMenuActivity::render(RenderLock&&) {
|
||||||
if (optionPopup.processRender(renderer, mappedInput)) return;
|
|
||||||
|
|
||||||
renderer.clearScreen();
|
renderer.clearScreen();
|
||||||
|
|
||||||
auto metrics = UITheme::getInstance().getMetrics();
|
auto metrics = UITheme::getInstance().getMetrics();
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
#include "activities/Activity.h"
|
#include "activities/Activity.h"
|
||||||
#include "components/OptionPopup.h"
|
|
||||||
#include "util/ButtonNavigator.h"
|
#include "util/ButtonNavigator.h"
|
||||||
|
|
||||||
class EpubReaderMenuActivity final : public Activity {
|
class EpubReaderMenuActivity final : public Activity {
|
||||||
@@ -50,7 +49,6 @@ class EpubReaderMenuActivity final : public Activity {
|
|||||||
int selectedIndex = 0;
|
int selectedIndex = 0;
|
||||||
|
|
||||||
ButtonNavigator buttonNavigator;
|
ButtonNavigator buttonNavigator;
|
||||||
OptionPopup optionPopup;
|
|
||||||
std::string title = "Reader Menu";
|
std::string title = "Reader Menu";
|
||||||
uint8_t pendingOrientation = 0;
|
uint8_t pendingOrientation = 0;
|
||||||
uint8_t selectedPageTurnOption = 0;
|
uint8_t selectedPageTurnOption = 0;
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
#include "EpubReaderPercentSelectionActivity.h"
|
#include "EpubReaderPercentSelectionActivity.h"
|
||||||
|
|
||||||
#include <GfxRenderer.h>
|
#include <GfxRenderer.h>
|
||||||
#include <HalGPIO.h>
|
|
||||||
#include <I18n.h>
|
#include <I18n.h>
|
||||||
|
|
||||||
#include <cstdio>
|
|
||||||
|
|
||||||
#include "MappedInputManager.h"
|
#include "MappedInputManager.h"
|
||||||
#include "components/UITheme.h"
|
#include "components/UITheme.h"
|
||||||
#include "fontIds.h"
|
#include "fontIds.h"
|
||||||
@@ -54,14 +51,8 @@ void EpubReaderPercentSelectionActivity::loop() {
|
|||||||
buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Left}, [this] { adjustPercent(-kSmallStep); });
|
buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Left}, [this] { adjustPercent(-kSmallStep); });
|
||||||
buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Right}, [this] { adjustPercent(kSmallStep); });
|
buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Right}, [this] { adjustPercent(kSmallStep); });
|
||||||
|
|
||||||
// On X3 the side buttons sit on the left/right edges of the screen rather than as a vertical up/down
|
buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Up}, [this] { adjustPercent(kLargeStep); });
|
||||||
// rocker (X4), so BTN_UP is physically the left button and BTN_DOWN the right one. Flip the large-step
|
buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Down}, [this] { adjustPercent(-kLargeStep); });
|
||||||
// direction there so the left button decreases and the right button increases, matching the layout.
|
|
||||||
const int upDelta = gpio.deviceIsX3() ? -kLargeStep : kLargeStep;
|
|
||||||
const int downDelta = gpio.deviceIsX3() ? kLargeStep : -kLargeStep;
|
|
||||||
buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Up}, [this, upDelta] { adjustPercent(upDelta); });
|
|
||||||
buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Down},
|
|
||||||
[this, downDelta] { adjustPercent(downDelta); });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void EpubReaderPercentSelectionActivity::render(RenderLock&&) {
|
void EpubReaderPercentSelectionActivity::render(RenderLock&&) {
|
||||||
@@ -98,13 +89,8 @@ void EpubReaderPercentSelectionActivity::render(RenderLock&&) {
|
|||||||
const int knobX = barX + 2 + fillWidth - 2;
|
const int knobX = barX + 2 + fillWidth - 2;
|
||||||
renderer.fillRect(knobX, barY - 4, 4, barHeight + 8, true);
|
renderer.fillRect(knobX, barY - 4, 4, barHeight + 8, true);
|
||||||
|
|
||||||
// Two-line step hint built from separate label + value strings (front buttons = fine step, side
|
// Hint text for step sizes.
|
||||||
// buttons = coarse step), so the layout doesn't depend on a separator hidden in translated text.
|
UITheme::drawCenteredText(renderer, screen, SMALL_FONT_ID, barY + 30, tr(STR_PERCENT_STEP_HINT), true);
|
||||||
char line[64];
|
|
||||||
snprintf(line, sizeof(line), "%s %d%%", I18N.get(StrId::STR_STEP_HINT_FRONT), kSmallStep);
|
|
||||||
UITheme::drawCenteredText(renderer, screen, SMALL_FONT_ID, barY + 30, line, true);
|
|
||||||
snprintf(line, sizeof(line), "%s %d%%", I18N.get(StrId::STR_STEP_HINT_SIDE), kLargeStep);
|
|
||||||
UITheme::drawCenteredText(renderer, screen, SMALL_FONT_ID, barY + 52, line, true);
|
|
||||||
|
|
||||||
// Button hints follow the current front button layout.
|
// Button hints follow the current front button layout.
|
||||||
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SELECT), "-", "+");
|
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SELECT), "-", "+");
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
#include <FsHelpers.h>
|
#include <FsHelpers.h>
|
||||||
#include <HalStorage.h>
|
#include <HalStorage.h>
|
||||||
#include <I18n.h>
|
|
||||||
#include <Memory.h>
|
#include <Memory.h>
|
||||||
|
|
||||||
#include "CrossPointSettings.h"
|
#include "CrossPointSettings.h"
|
||||||
@@ -15,7 +14,6 @@
|
|||||||
#include "XtcReaderActivity.h"
|
#include "XtcReaderActivity.h"
|
||||||
#include "activities/util/BmpViewerActivity.h"
|
#include "activities/util/BmpViewerActivity.h"
|
||||||
#include "activities/util/FullScreenMessageActivity.h"
|
#include "activities/util/FullScreenMessageActivity.h"
|
||||||
#include "components/UITheme.h"
|
|
||||||
|
|
||||||
bool ReaderActivity::isXtcFile(const std::string& path) { return FsHelpers::hasXtcExtension(path); }
|
bool ReaderActivity::isXtcFile(const std::string& path) { return FsHelpers::hasXtcExtension(path); }
|
||||||
|
|
||||||
@@ -37,12 +35,6 @@ std::unique_ptr<Epub> ReaderActivity::loadEpub(const std::string& path) {
|
|||||||
LOG_ERR("READER", "Failed to allocate EPUB object");
|
LOG_ERR("READER", "Failed to allocate EPUB object");
|
||||||
return nullptr;
|
return nullptr;
|
||||||
}
|
}
|
||||||
// First open: building the spine/TOC index (book.bin) takes a couple of seconds. Show the
|
|
||||||
// indexing popup so it isn't a silent wait on the home screen. The cachePath/hash is known at
|
|
||||||
// construction, so this check is valid before load(); a cached open loads in a blink -> no popup.
|
|
||||||
if (!Storage.exists((epub->getCachePath() + "/book.bin").c_str())) {
|
|
||||||
GUI.drawPopup(renderer, tr(STR_INDEXING));
|
|
||||||
}
|
|
||||||
if (epub->load(true, SETTINGS.embeddedStyle == 0)) {
|
if (epub->load(true, SETTINGS.embeddedStyle == 0)) {
|
||||||
return epub;
|
return epub;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,7 @@ class Txt;
|
|||||||
class ReaderActivity final : public Activity {
|
class ReaderActivity final : public Activity {
|
||||||
std::string initialBookPath;
|
std::string initialBookPath;
|
||||||
std::string currentBookPath; // Track current book path for navigation
|
std::string currentBookPath; // Track current book path for navigation
|
||||||
// Non-static (unlike the other loaders): draws the first-open indexing popup, which needs the renderer.
|
static std::unique_ptr<Epub> loadEpub(const std::string& path);
|
||||||
std::unique_ptr<Epub> loadEpub(const std::string& path);
|
|
||||||
static std::unique_ptr<Xtc> loadXtc(const std::string& path);
|
static std::unique_ptr<Xtc> loadXtc(const std::string& path);
|
||||||
static std::unique_ptr<Txt> loadTxt(const std::string& path);
|
static std::unique_ptr<Txt> loadTxt(const std::string& path);
|
||||||
static bool isXtcFile(const std::string& path);
|
static bool isXtcFile(const std::string& path);
|
||||||
|
|||||||
@@ -400,16 +400,13 @@ void TxtReaderActivity::renderPage() {
|
|||||||
renderLines(); // scan pass — text accumulated, no drawing
|
renderLines(); // scan pass — text accumulated, no drawing
|
||||||
scope.endScanAndPrewarm();
|
scope.endScanAndPrewarm();
|
||||||
|
|
||||||
// BW rendering. Fast AA dithers 2-bit glyph edges in this single pass;
|
// BW rendering
|
||||||
// scoped to the page text so nothing else inherits the flag.
|
|
||||||
renderer.setFastAntiAliasing(SETTINGS.textAntiAliasing == CrossPointSettings::TEXT_AA_FAST);
|
|
||||||
renderLines();
|
renderLines();
|
||||||
renderer.setFastAntiAliasing(false);
|
|
||||||
renderStatusBar();
|
renderStatusBar();
|
||||||
|
|
||||||
ReaderUtils::displayWithRefreshCycle(renderer, pagesUntilFullRefresh);
|
ReaderUtils::displayWithRefreshCycle(renderer, pagesUntilFullRefresh);
|
||||||
|
|
||||||
if (SETTINGS.textAntiAliasing == CrossPointSettings::TEXT_AA_FULL) {
|
if (SETTINGS.textAntiAliasing) {
|
||||||
ReaderUtils::renderAntiAliased(renderer, [&renderLines]() { renderLines(); });
|
ReaderUtils::renderAntiAliased(renderer, [&renderLines]() { renderLines(); });
|
||||||
}
|
}
|
||||||
// scope destructor clears font cache via FontCacheManager
|
// scope destructor clears font cache via FontCacheManager
|
||||||
|
|||||||
@@ -53,52 +53,18 @@ void XtcReaderActivity::onExit() {
|
|||||||
xtc.reset();
|
xtc.reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
void XtcReaderActivity::openChapterSelection() {
|
|
||||||
if (xtc && xtc->hasChapters() && !xtc->getChapters().empty()) {
|
|
||||||
startActivityForResult(std::make_unique<XtcReaderChapterSelectionActivity>(renderer, mappedInput, xtc, currentPage),
|
|
||||||
[this](const ActivityResult& result) {
|
|
||||||
if (!result.isCancelled) {
|
|
||||||
currentPage = std::get<PageResult>(result.data).page;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void XtcReaderActivity::loop() {
|
void XtcReaderActivity::loop() {
|
||||||
if (!xtc) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const bool atEndOfBook = currentPage >= xtc->getPageCount();
|
|
||||||
|
|
||||||
// While the end screen suggestion menu is showing it owns Confirm/Back/navigation
|
|
||||||
// input. Anything it doesn't handle (e.g. long-press Back to the file browser) falls
|
|
||||||
// through to the regular handlers below; page turns are absorbed by the end-of-book
|
|
||||||
// block.
|
|
||||||
if (atEndOfBook && endOfBookOptions.menuActive()) {
|
|
||||||
std::string openPath;
|
|
||||||
switch (endOfBookOptions.handleMenuInput(mappedInput, &openPath)) {
|
|
||||||
case EndOfBookOptions::Action::OpenBook:
|
|
||||||
activityManager.goToReader(openPath);
|
|
||||||
return;
|
|
||||||
case EndOfBookOptions::Action::GoHome:
|
|
||||||
onGoHome();
|
|
||||||
return;
|
|
||||||
case EndOfBookOptions::Action::LastPage:
|
|
||||||
currentPage = xtc->getPageCount() > 0 ? xtc->getPageCount() - 1 : 0;
|
|
||||||
requestUpdate();
|
|
||||||
return;
|
|
||||||
case EndOfBookOptions::Action::Redraw:
|
|
||||||
requestUpdate();
|
|
||||||
return;
|
|
||||||
case EndOfBookOptions::Action::None:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enter chapter selection activity
|
// Enter chapter selection activity
|
||||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||||
openChapterSelection();
|
if (xtc && xtc->hasChapters() && !xtc->getChapters().empty()) {
|
||||||
|
startActivityForResult(
|
||||||
|
std::make_unique<XtcReaderChapterSelectionActivity>(renderer, mappedInput, xtc, currentPage),
|
||||||
|
[this](const ActivityResult& result) {
|
||||||
|
if (!result.isCancelled) {
|
||||||
|
currentPage = std::get<PageResult>(result.data).page;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Long press BACK (1s+) goes to file selection
|
// Long press BACK (1s+) goes to file selection
|
||||||
@@ -119,14 +85,8 @@ void XtcReaderActivity::loop() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// At end of the book with no suggestion menu, forward button goes home and back
|
// At end of the book, forward button goes home and back button returns to last page
|
||||||
// button returns to last page
|
|
||||||
if (currentPage >= xtc->getPageCount()) {
|
if (currentPage >= xtc->getPageCount()) {
|
||||||
if (endOfBookOptions.menuActive()) {
|
|
||||||
// Selection movement was handled above; absorb leftover page-turn triggers so
|
|
||||||
// e.g. "previous" at the top of the list doesn't jump back into the book
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (nextTriggered) {
|
if (nextTriggered) {
|
||||||
onGoHome();
|
onGoHome();
|
||||||
} else {
|
} else {
|
||||||
@@ -163,11 +123,9 @@ void XtcReaderActivity::render(RenderLock&&) {
|
|||||||
|
|
||||||
// Bounds check
|
// Bounds check
|
||||||
if (currentPage >= xtc->getPageCount()) {
|
if (currentPage >= xtc->getPageCount()) {
|
||||||
// Show end of book screen. Sole load site: runs on the render task (serialized by
|
// Show end of book screen
|
||||||
// RenderLock); the main task only reads the suggestions once the flag is published.
|
|
||||||
endOfBookOptions.loadOnce(xtc->getPath());
|
|
||||||
renderer.clearScreen();
|
renderer.clearScreen();
|
||||||
endOfBookOptions.render(renderer, mappedInput);
|
renderer.drawCenteredText(UI_12_FONT_ID, 300, tr(STR_END_OF_BOOK), true, EpdFontFamily::BOLD);
|
||||||
renderer.displayBuffer();
|
renderer.displayBuffer();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@
|
|||||||
#include <string>
|
#include <string>
|
||||||
#include <utility>
|
#include <utility>
|
||||||
|
|
||||||
#include "EndOfBookOptions.h"
|
|
||||||
#include "activities/Activity.h"
|
#include "activities/Activity.h"
|
||||||
|
|
||||||
class XtcReaderActivity final : public Activity {
|
class XtcReaderActivity final : public Activity {
|
||||||
@@ -20,8 +19,6 @@ class XtcReaderActivity final : public Activity {
|
|||||||
|
|
||||||
uint32_t currentPage = 0;
|
uint32_t currentPage = 0;
|
||||||
int pagesUntilFullRefresh = 0;
|
int pagesUntilFullRefresh = 0;
|
||||||
// Next-book suggestion menu for the End-of-Book screen
|
|
||||||
EndOfBookOptions endOfBookOptions;
|
|
||||||
|
|
||||||
enum class StatusBarOverlayPosition { Bottom, Top };
|
enum class StatusBarOverlayPosition { Bottom, Top };
|
||||||
struct StatusBarInfo {
|
struct StatusBarInfo {
|
||||||
@@ -31,8 +28,6 @@ class XtcReaderActivity final : public Activity {
|
|||||||
};
|
};
|
||||||
|
|
||||||
void renderPage();
|
void renderPage();
|
||||||
// Opens chapter selection when the book has chapters (short-press Confirm); no-op otherwise
|
|
||||||
void openChapterSelection();
|
|
||||||
void renderStatusBarOverlay(StatusBarOverlayPosition position) const;
|
void renderStatusBarOverlay(StatusBarOverlayPosition position) const;
|
||||||
StatusBarInfo getStatusBarInfo() const;
|
StatusBarInfo getStatusBarInfo() const;
|
||||||
void saveProgress() const;
|
void saveProgress() const;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
@@ -113,8 +137,6 @@ void SettingsActivity::onExit() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void SettingsActivity::loop() {
|
void SettingsActivity::loop() {
|
||||||
if (optionPopup.handleInput(mappedInput, [this] { requestUpdate(); })) return;
|
|
||||||
|
|
||||||
bool hasChangedCategory = false;
|
bool hasChangedCategory = false;
|
||||||
|
|
||||||
// Handle actions with early return
|
// Handle actions with early return
|
||||||
@@ -193,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();
|
||||||
@@ -205,18 +228,6 @@ void SettingsActivity::toggleCurrentSetting() {
|
|||||||
SETTINGS.*(setting.valuePtr) = !currentValue;
|
SETTINGS.*(setting.valuePtr) = !currentValue;
|
||||||
} else if (setting.type == SettingType::ENUM && setting.valuePtr != nullptr) {
|
} else if (setting.type == SettingType::ENUM && setting.valuePtr != nullptr) {
|
||||||
const uint8_t currentValue = SETTINGS.*(setting.valuePtr);
|
const uint8_t currentValue = SETTINGS.*(setting.valuePtr);
|
||||||
if (setting.enumValues.size() > 2) {
|
|
||||||
const auto valuePtr = setting.valuePtr;
|
|
||||||
optionPopup.show(setting.nameId, setting.enumValues.data(), static_cast<int>(setting.enumValues.size()),
|
|
||||||
currentValue, [this, valuePtr, sleepScreenChanged, quickResumeTimeoutChanged](int idx) {
|
|
||||||
SETTINGS.*valuePtr = idx;
|
|
||||||
syncQuickResumeTimeoutForSleepScreen(sleepScreenChanged, quickResumeTimeoutChanged);
|
|
||||||
SETTINGS.saveToFile();
|
|
||||||
rebuildSettingsLists();
|
|
||||||
});
|
|
||||||
requestUpdate();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
SETTINGS.*(setting.valuePtr) = (currentValue + 1) % static_cast<uint8_t>(setting.enumValues.size());
|
SETTINGS.*(setting.valuePtr) = (currentValue + 1) % static_cast<uint8_t>(setting.enumValues.size());
|
||||||
} else if (setting.type == SettingType::ENUM && setting.valueGetter && setting.valueSetter) {
|
} else if (setting.type == SettingType::ENUM && setting.valueGetter && setting.valueSetter) {
|
||||||
if (setting.nameId == StrId::STR_FONT_FAMILY) {
|
if (setting.nameId == StrId::STR_FONT_FAMILY) {
|
||||||
@@ -232,23 +243,6 @@ void SettingsActivity::toggleCurrentSetting() {
|
|||||||
? static_cast<uint8_t>(setting.enumValues.size())
|
? static_cast<uint8_t>(setting.enumValues.size())
|
||||||
: static_cast<uint8_t>(setting.enumStringValues.size());
|
: static_cast<uint8_t>(setting.enumStringValues.size());
|
||||||
const uint8_t cur = setting.valueGetter();
|
const uint8_t cur = setting.valueGetter();
|
||||||
if (totalValues > 2) {
|
|
||||||
const auto valueSetter = setting.valueSetter;
|
|
||||||
auto onSelect = [this, valueSetter, sleepScreenChanged, quickResumeTimeoutChanged](int idx) {
|
|
||||||
valueSetter(idx);
|
|
||||||
syncQuickResumeTimeoutForSleepScreen(sleepScreenChanged, quickResumeTimeoutChanged);
|
|
||||||
SETTINGS.saveToFile();
|
|
||||||
rebuildSettingsLists();
|
|
||||||
};
|
|
||||||
if (!setting.enumStringValues.empty()) {
|
|
||||||
optionPopup.show(setting.nameId, setting.enumStringValues, cur, std::move(onSelect));
|
|
||||||
} else {
|
|
||||||
optionPopup.show(setting.nameId, setting.enumValues.data(), static_cast<int>(setting.enumValues.size()), cur,
|
|
||||||
std::move(onSelect));
|
|
||||||
}
|
|
||||||
requestUpdate();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setting.valueSetter((cur + 1) % totalValues);
|
setting.valueSetter((cur + 1) % totalValues);
|
||||||
} else if (setting.type == SettingType::VALUE && setting.valuePtr != nullptr) {
|
} else if (setting.type == SettingType::VALUE && setting.valuePtr != nullptr) {
|
||||||
const int8_t currentValue = SETTINGS.*(setting.valuePtr);
|
const int8_t currentValue = SETTINGS.*(setting.valuePtr);
|
||||||
@@ -286,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;
|
||||||
@@ -306,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) {
|
||||||
@@ -336,9 +349,10 @@ void SettingsActivity::syncQuickResumeTimeoutForSleepScreen(bool sleepScreenChan
|
|||||||
void SettingsActivity::openSleepTimeoutPicker() {
|
void SettingsActivity::openSleepTimeoutPicker() {
|
||||||
startActivityForResult(
|
startActivityForResult(
|
||||||
std::make_unique<IntervalSelectionActivity>(
|
std::make_unique<IntervalSelectionActivity>(
|
||||||
renderer, mappedInput, "SleepTimeoutInterval", StrId::STR_TIME_TO_SLEEP, SETTINGS.sleepTimeoutMinutes,
|
renderer, mappedInput, "SleepTimeoutInterval", StrId::STR_TIME_TO_SLEEP, StrId::STR_SLEEP_TIMER_STEP_HINT,
|
||||||
CrossPointSettings::MIN_SLEEP_TIMEOUT_MINUTES, CrossPointSettings::MAX_SLEEP_TIMEOUT_MINUTES, 1, 5,
|
SETTINGS.sleepTimeoutMinutes, CrossPointSettings::MIN_SLEEP_TIMEOUT_MINUTES,
|
||||||
StrId::STR_SLEEP_TIMER_VALUE_FORMAT, false, true, StrId::STR_SLEEP_NEVER),
|
CrossPointSettings::MAX_SLEEP_TIMEOUT_MINUTES, 1, 5, StrId::STR_SLEEP_TIMER_VALUE_FORMAT, false, true,
|
||||||
|
StrId::STR_SLEEP_NEVER),
|
||||||
[this](const ActivityResult& result) {
|
[this](const ActivityResult& result) {
|
||||||
if (!result.isCancelled) {
|
if (!result.isCancelled) {
|
||||||
SETTINGS.sleepTimeoutMinutes = static_cast<uint8_t>(std::get<IntervalResult>(result.data).value);
|
SETTINGS.sleepTimeoutMinutes = static_cast<uint8_t>(std::get<IntervalResult>(result.data).value);
|
||||||
@@ -349,8 +363,6 @@ void SettingsActivity::openSleepTimeoutPicker() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void SettingsActivity::render(RenderLock&&) {
|
void SettingsActivity::render(RenderLock&&) {
|
||||||
if (optionPopup.processRender(renderer, mappedInput)) return;
|
|
||||||
|
|
||||||
renderer.clearScreen();
|
renderer.clearScreen();
|
||||||
|
|
||||||
const auto pageWidth = renderer.getScreenWidth();
|
const auto pageWidth = renderer.getScreenWidth();
|
||||||
@@ -358,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 =
|
||||||
@@ -418,9 +462,10 @@ void SettingsActivity::render(RenderLock&&) {
|
|||||||
: (selectedSettingIndex > 0 && (*currentSettings)[selectedSettingIndex - 1].nameId == StrId::STR_TIME_TO_SLEEP
|
: (selectedSettingIndex > 0 && (*currentSettings)[selectedSettingIndex - 1].nameId == StrId::STR_TIME_TO_SLEEP
|
||||||
? 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();
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
|
|
||||||
#include "CrossPointSettings.h"
|
#include "CrossPointSettings.h"
|
||||||
#include "activities/Activity.h"
|
#include "activities/Activity.h"
|
||||||
#include "components/OptionPopup.h"
|
|
||||||
#include "util/ButtonNavigator.h"
|
#include "util/ButtonNavigator.h"
|
||||||
|
|
||||||
enum class SettingType { TOGGLE, ENUM, ACTION, VALUE, STRING };
|
enum class SettingType { TOGGLE, ENUM, ACTION, VALUE, STRING };
|
||||||
@@ -24,6 +23,7 @@ enum class SettingAction {
|
|||||||
SdFirmwareUpdate,
|
SdFirmwareUpdate,
|
||||||
Language,
|
Language,
|
||||||
DownloadFonts,
|
DownloadFonts,
|
||||||
|
DownloadThemes,
|
||||||
};
|
};
|
||||||
|
|
||||||
struct SettingInfo {
|
struct SettingInfo {
|
||||||
@@ -160,8 +160,6 @@ class SettingsActivity final : public Activity {
|
|||||||
bool preserveQuickResumeTimeoutOn = false;
|
bool preserveQuickResumeTimeoutOn = false;
|
||||||
bool quickResumeTimeoutAutoEnabled = false;
|
bool quickResumeTimeoutAutoEnabled = false;
|
||||||
|
|
||||||
OptionPopup optionPopup;
|
|
||||||
|
|
||||||
static constexpr int categoryCount = 4;
|
static constexpr int categoryCount = 4;
|
||||||
static const StrId categoryNames[categoryCount];
|
static const StrId categoryNames[categoryCount];
|
||||||
|
|
||||||
@@ -169,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:
|
||||||
|
|||||||
@@ -125,8 +125,6 @@ void StatusBarSettingsActivity::onEnter() {
|
|||||||
void StatusBarSettingsActivity::onExit() { Activity::onExit(); }
|
void StatusBarSettingsActivity::onExit() { Activity::onExit(); }
|
||||||
|
|
||||||
void StatusBarSettingsActivity::loop() {
|
void StatusBarSettingsActivity::loop() {
|
||||||
if (optionPopup.handleInput(mappedInput, [this] { requestUpdate(); })) return;
|
|
||||||
|
|
||||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
|
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
|
||||||
finish();
|
finish();
|
||||||
return;
|
return;
|
||||||
@@ -169,35 +167,21 @@ void StatusBarSettingsActivity::handleSelection() {
|
|||||||
SETTINGS.statusBarBookProgressPercentage = (SETTINGS.statusBarBookProgressPercentage + 1) % 2;
|
SETTINGS.statusBarBookProgressPercentage = (SETTINGS.statusBarBookProgressPercentage + 1) % 2;
|
||||||
break;
|
break;
|
||||||
case ITEM_PROGRESS_BAR:
|
case ITEM_PROGRESS_BAR:
|
||||||
optionPopup.show(StrId::STR_PROGRESS_BAR, progressBarNames, PROGRESS_BAR_ITEMS, SETTINGS.statusBarProgressBar,
|
SETTINGS.statusBarProgressBar = (SETTINGS.statusBarProgressBar + 1) % PROGRESS_BAR_ITEMS;
|
||||||
[this](int idx) {
|
break;
|
||||||
SETTINGS.statusBarProgressBar = idx;
|
|
||||||
SETTINGS.saveToFile();
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
case ITEM_PROGRESS_BAR_THICKNESS:
|
case ITEM_PROGRESS_BAR_THICKNESS:
|
||||||
optionPopup.show(StrId::STR_PROGRESS_BAR_THICKNESS, progressBarThicknessNames, PROGRESS_BAR_THICKNESS_ITEMS,
|
SETTINGS.statusBarProgressBarThickness =
|
||||||
SETTINGS.statusBarProgressBarThickness, [this](int idx) {
|
(SETTINGS.statusBarProgressBarThickness + 1) % PROGRESS_BAR_THICKNESS_ITEMS;
|
||||||
SETTINGS.statusBarProgressBarThickness = idx;
|
break;
|
||||||
SETTINGS.saveToFile();
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
case ITEM_TITLE:
|
case ITEM_TITLE:
|
||||||
optionPopup.show(StrId::STR_TITLE, titleNames, TITLE_ITEMS, SETTINGS.statusBarTitle, [this](int idx) {
|
SETTINGS.statusBarTitle = (SETTINGS.statusBarTitle + 1) % TITLE_ITEMS;
|
||||||
SETTINGS.statusBarTitle = idx;
|
break;
|
||||||
SETTINGS.saveToFile();
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
case ITEM_BATTERY:
|
case ITEM_BATTERY:
|
||||||
SETTINGS.statusBarBattery = (SETTINGS.statusBarBattery + 1) % 2;
|
SETTINGS.statusBarBattery = (SETTINGS.statusBarBattery + 1) % 2;
|
||||||
break;
|
break;
|
||||||
case ITEM_XTC_STATUS_BAR:
|
case ITEM_XTC_STATUS_BAR:
|
||||||
optionPopup.show(StrId::STR_XTC_STATUS_BAR, xtcStatusBarNames, XTC_STATUS_BAR_ITEMS, SETTINGS.xtcStatusBarMode,
|
SETTINGS.xtcStatusBarMode = (SETTINGS.xtcStatusBarMode + 1) % XTC_STATUS_BAR_ITEMS;
|
||||||
[this](int idx) {
|
break;
|
||||||
SETTINGS.xtcStatusBarMode = idx;
|
|
||||||
SETTINGS.saveToFile();
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
case ITEM_CLOCK:
|
case ITEM_CLOCK:
|
||||||
SETTINGS.statusBarClock = (SETTINGS.statusBarClock + 1) % STATUS_BAR_CLOCK_ITEMS;
|
SETTINGS.statusBarClock = (SETTINGS.statusBarClock + 1) % STATUS_BAR_CLOCK_ITEMS;
|
||||||
break;
|
break;
|
||||||
@@ -218,8 +202,6 @@ void StatusBarSettingsActivity::handleSelection() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void StatusBarSettingsActivity::render(RenderLock&&) {
|
void StatusBarSettingsActivity::render(RenderLock&&) {
|
||||||
if (optionPopup.processRender(renderer, mappedInput)) return;
|
|
||||||
|
|
||||||
renderer.clearScreen();
|
renderer.clearScreen();
|
||||||
|
|
||||||
auto metrics = UITheme::getInstance().getMetrics();
|
auto metrics = UITheme::getInstance().getMetrics();
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
#include <freertos/task.h>
|
#include <freertos/task.h>
|
||||||
|
|
||||||
#include "activities/Activity.h"
|
#include "activities/Activity.h"
|
||||||
#include "components/OptionPopup.h"
|
|
||||||
#include "util/ButtonNavigator.h"
|
#include "util/ButtonNavigator.h"
|
||||||
|
|
||||||
// Reader status bar configuration activity
|
// Reader status bar configuration activity
|
||||||
@@ -20,7 +19,6 @@ class StatusBarSettingsActivity final : public Activity {
|
|||||||
|
|
||||||
private:
|
private:
|
||||||
ButtonNavigator buttonNavigator;
|
ButtonNavigator buttonNavigator;
|
||||||
OptionPopup optionPopup;
|
|
||||||
|
|
||||||
int selectedIndex = 0;
|
int selectedIndex = 0;
|
||||||
// Decided in onEnter() based on halClock.isAvailable() so clock entries are hidden on X4.
|
// Decided in onEnter() based on halClock.isAvailable() so clock entries are hidden on X4.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
};
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
#include "IntervalSelectionActivity.h"
|
#include "IntervalSelectionActivity.h"
|
||||||
|
|
||||||
#include <GfxRenderer.h>
|
#include <GfxRenderer.h>
|
||||||
#include <HalGPIO.h>
|
|
||||||
#include <I18n.h>
|
#include <I18n.h>
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
@@ -26,18 +25,6 @@ void IntervalSelectionActivity::adjustValue(const int delta) {
|
|||||||
requestUpdate();
|
requestUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
void IntervalSelectionActivity::drawStepHintLine(const int y, const StrId labelId, const int step) {
|
|
||||||
char stepText[24];
|
|
||||||
if (valueFormatId != StrId::STR_NONE_OPT) {
|
|
||||||
snprintf(stepText, sizeof(stepText), I18N.get(valueFormatId), static_cast<unsigned int>(step));
|
|
||||||
} else {
|
|
||||||
snprintf(stepText, sizeof(stepText), "%d", step);
|
|
||||||
}
|
|
||||||
char line[64];
|
|
||||||
snprintf(line, sizeof(line), "%s %s", I18N.get(labelId), stepText);
|
|
||||||
renderer.drawCenteredText(SMALL_FONT_ID, y, line, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
void IntervalSelectionActivity::loop() {
|
void IntervalSelectionActivity::loop() {
|
||||||
if (ignoreConfirmRelease) {
|
if (ignoreConfirmRelease) {
|
||||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||||
@@ -65,15 +52,8 @@ void IntervalSelectionActivity::loop() {
|
|||||||
|
|
||||||
buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Left}, [this] { adjustValue(-smallStep); });
|
buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Left}, [this] { adjustValue(-smallStep); });
|
||||||
buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Right}, [this] { adjustValue(smallStep); });
|
buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Right}, [this] { adjustValue(smallStep); });
|
||||||
|
buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Up}, [this] { adjustValue(largeStep); });
|
||||||
// On X3 the side buttons sit on the left/right edges of the screen rather than as a vertical up/down
|
buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Down}, [this] { adjustValue(-largeStep); });
|
||||||
// rocker (X4), so BTN_UP is physically the left button and BTN_DOWN the right one. Flip the large-step
|
|
||||||
// direction there so the left button decreases and the right button increases, matching the layout.
|
|
||||||
const int upDelta = gpio.deviceIsX3() ? -largeStep : largeStep;
|
|
||||||
const int downDelta = gpio.deviceIsX3() ? largeStep : -largeStep;
|
|
||||||
buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Up}, [this, upDelta] { adjustValue(upDelta); });
|
|
||||||
buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Down},
|
|
||||||
[this, downDelta] { adjustValue(downDelta); });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void IntervalSelectionActivity::render(RenderLock&&) {
|
void IntervalSelectionActivity::render(RenderLock&&) {
|
||||||
@@ -108,11 +88,7 @@ void IntervalSelectionActivity::render(RenderLock&&) {
|
|||||||
const int knobX = std::max(barX + 2, barX + 2 + fillWidth - 2);
|
const int knobX = std::max(barX + 2, barX + 2 + fillWidth - 2);
|
||||||
renderer.fillRect(knobX, barY - 4, 4, barHeight + 8, true);
|
renderer.fillRect(knobX, barY - 4, 4, barHeight + 8, true);
|
||||||
|
|
||||||
// Two-line step hint: front buttons do the small step, side buttons the large step. Built from
|
renderer.drawCenteredText(SMALL_FONT_ID, barY + 30, I18N.get(stepHintId), true);
|
||||||
// separate label + value strings (rather than splitting one localized sentence) so the layout
|
|
||||||
// doesn't depend on translators preserving a hidden separator.
|
|
||||||
drawStepHintLine(barY + 30, StrId::STR_STEP_HINT_FRONT, smallStep);
|
|
||||||
drawStepHintLine(barY + 52, StrId::STR_STEP_HINT_SIDE, largeStep);
|
|
||||||
|
|
||||||
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SELECT), "-", "+");
|
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SELECT), "-", "+");
|
||||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||||
|
|||||||
@@ -11,12 +11,13 @@ class GfxRenderer;
|
|||||||
class IntervalSelectionActivity final : public Activity {
|
class IntervalSelectionActivity final : public Activity {
|
||||||
public:
|
public:
|
||||||
explicit IntervalSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const char* activityName,
|
explicit IntervalSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const char* activityName,
|
||||||
StrId titleId, int initialValue, int minValue, int maxValue, int smallStep,
|
StrId titleId, StrId stepHintId, int initialValue, int minValue, int maxValue,
|
||||||
int largeStep, StrId valueFormatId = StrId::STR_NONE_OPT,
|
int smallStep, int largeStep, StrId valueFormatId = StrId::STR_NONE_OPT,
|
||||||
bool readerActivity = false, bool ignoreInitialConfirmRelease = false,
|
bool readerActivity = false, bool ignoreInitialConfirmRelease = false,
|
||||||
StrId maxBoundaryLabelId = StrId::STR_NONE_OPT)
|
StrId maxBoundaryLabelId = StrId::STR_NONE_OPT)
|
||||||
: Activity(activityName, renderer, mappedInput),
|
: Activity(activityName, renderer, mappedInput),
|
||||||
titleId(titleId),
|
titleId(titleId),
|
||||||
|
stepHintId(stepHintId),
|
||||||
valueFormatId(valueFormatId),
|
valueFormatId(valueFormatId),
|
||||||
maxBoundaryLabelId(maxBoundaryLabelId),
|
maxBoundaryLabelId(maxBoundaryLabelId),
|
||||||
value(initialValue),
|
value(initialValue),
|
||||||
@@ -34,6 +35,7 @@ class IntervalSelectionActivity final : public Activity {
|
|||||||
|
|
||||||
private:
|
private:
|
||||||
StrId titleId;
|
StrId titleId;
|
||||||
|
StrId stepHintId;
|
||||||
StrId valueFormatId;
|
StrId valueFormatId;
|
||||||
StrId maxBoundaryLabelId;
|
StrId maxBoundaryLabelId;
|
||||||
int value;
|
int value;
|
||||||
@@ -47,5 +49,4 @@ class IntervalSelectionActivity final : public Activity {
|
|||||||
|
|
||||||
void adjustValue(int delta);
|
void adjustValue(int delta);
|
||||||
int clampedValue(int candidate) const;
|
int clampedValue(int candidate) const;
|
||||||
void drawStepHintLine(int y, StrId labelId, int step);
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,95 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
#include <I18n.h>
|
|
||||||
|
|
||||||
#include <functional>
|
|
||||||
#include <string>
|
|
||||||
#include <vector>
|
|
||||||
|
|
||||||
#include "GfxRenderer.h"
|
|
||||||
#include "MappedInputManager.h"
|
|
||||||
#include "components/UITheme.h"
|
|
||||||
|
|
||||||
class OptionPopup {
|
|
||||||
public:
|
|
||||||
void show(StrId titleId, const StrId* optionIds, int optionCount, int currentIndex,
|
|
||||||
std::function<void(int)> onSelect) {
|
|
||||||
title = I18N.get(titleId);
|
|
||||||
ownedStrings.resize(optionCount);
|
|
||||||
for (int i = 0; i < optionCount; i++) {
|
|
||||||
ownedStrings[i] = I18N.get(optionIds[i]);
|
|
||||||
}
|
|
||||||
selectedIndex = currentIndex;
|
|
||||||
onSelectCallback = std::move(onSelect);
|
|
||||||
active = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
void show(const char* titleStr, const char* const* options, int optionCount, int currentIndex,
|
|
||||||
std::function<void(int)> onSelect) {
|
|
||||||
title = titleStr;
|
|
||||||
ownedStrings.resize(optionCount);
|
|
||||||
for (int i = 0; i < optionCount; i++) {
|
|
||||||
ownedStrings[i] = options[i];
|
|
||||||
}
|
|
||||||
selectedIndex = currentIndex;
|
|
||||||
onSelectCallback = std::move(onSelect);
|
|
||||||
active = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
void show(StrId titleId, const std::vector<std::string>& options, int currentIndex,
|
|
||||||
std::function<void(int)> onSelect) {
|
|
||||||
title = I18N.get(titleId);
|
|
||||||
ownedStrings = options;
|
|
||||||
selectedIndex = currentIndex;
|
|
||||||
onSelectCallback = std::move(onSelect);
|
|
||||||
active = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool handleInput(MappedInputManager& input, const std::function<void()>& requestUpdate) {
|
|
||||||
if (!active) return false;
|
|
||||||
|
|
||||||
const int count = static_cast<int>(ownedStrings.size());
|
|
||||||
if (input.wasPressed(MappedInputManager::Button::Up) || input.wasPressed(MappedInputManager::Button::Left)) {
|
|
||||||
selectedIndex = (selectedIndex - 1 + count) % count;
|
|
||||||
requestUpdate();
|
|
||||||
return true;
|
|
||||||
} else if (input.wasPressed(MappedInputManager::Button::Down) ||
|
|
||||||
input.wasPressed(MappedInputManager::Button::Right)) {
|
|
||||||
selectedIndex = (selectedIndex + 1) % count;
|
|
||||||
requestUpdate();
|
|
||||||
return true;
|
|
||||||
} else if (input.wasPressed(MappedInputManager::Button::Confirm)) {
|
|
||||||
active = false;
|
|
||||||
if (onSelectCallback) onSelectCallback(selectedIndex);
|
|
||||||
requestUpdate();
|
|
||||||
return true;
|
|
||||||
} else if (input.wasPressed(MappedInputManager::Button::Back)) {
|
|
||||||
active = false;
|
|
||||||
requestUpdate();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool processRender(GfxRenderer& renderer, const MappedInputManager& input) const {
|
|
||||||
if (!active) return false;
|
|
||||||
const auto popupLabels = input.mapLabels(tr(STR_BACK), tr(STR_SELECT), tr(STR_DIR_UP), tr(STR_DIR_DOWN));
|
|
||||||
GUI.drawButtonHints(renderer, popupLabels.btn1, popupLabels.btn2, popupLabels.btn3, popupLabels.btn4);
|
|
||||||
render(renderer);
|
|
||||||
renderer.displayBuffer();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
void render(const GfxRenderer& renderer) const {
|
|
||||||
if (!active) return;
|
|
||||||
GUI.drawOptionPopup(renderer, title.c_str(), ownedStrings, selectedIndex);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool isActive() const { return active; }
|
|
||||||
|
|
||||||
private:
|
|
||||||
bool active = false;
|
|
||||||
std::string title;
|
|
||||||
std::vector<std::string> ownedStrings;
|
|
||||||
int selectedIndex = 0;
|
|
||||||
std::function<void(int)> onSelectCallback;
|
|
||||||
};
|
|
||||||
+288
-12
@@ -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 ? ¤tSdFileBrowserScreen : nullptr;
|
||||||
|
case ThemeScreenKind::RecentBooks:
|
||||||
|
return currentSdRecentBooksScreen.enabled ? ¤tSdRecentBooksScreen : nullptr;
|
||||||
|
case ThemeScreenKind::Settings:
|
||||||
|
return currentSdSettingsScreen.enabled ? ¤tSdSettingsScreen : nullptr;
|
||||||
|
case ThemeScreenKind::Reader:
|
||||||
|
return currentSdReaderScreen.enabled ? ¤tSdReaderScreen : 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 = ¤tSdMetrics;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ThemeHomeRecentsSpec* homeRecents =
|
||||||
|
currentSdHomeRecents.type != ThemeHomeRecentsType::Default ? ¤tSdHomeRecents : nullptr;
|
||||||
|
const ThemeButtonMenuSpec* buttonMenu = currentSdButtonMenu.enabled ? ¤tSdButtonMenu : nullptr;
|
||||||
|
const ThemeListSpec* list = currentSdList.enabled ? ¤tSdList : nullptr;
|
||||||
|
const ThemeButtonHintsSpec* buttonHints = currentSdButtonHints.enabled ? ¤tSdButtonHints : nullptr;
|
||||||
|
const ThemeTabBarSpec* tabBar = currentSdTabBar.enabled ? ¤tSdTabBar : nullptr;
|
||||||
|
const ThemeHeaderSpec* header = currentSdHeader.enabled ? ¤tSdHeader : nullptr;
|
||||||
|
currentTheme = std::make_unique<LyraTheme>(¤tSdMetrics, homeRecents, buttonMenu, list, buttonHints, tabBar,
|
||||||
|
header, currentSdThemePath.c_str(), ¤tSdIcons);
|
||||||
|
currentMetrics = ¤tSdMetrics;
|
||||||
|
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,
|
||||||
|
|||||||
@@ -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 ? ¤tSdHomeScreen : nullptr;
|
||||||
|
}
|
||||||
|
const ThemeScreenSpec* getScreenSpec(ThemeScreenKind screen) const;
|
||||||
|
const ThemeReaderChromeSpec* getReaderChromeSpec() const {
|
||||||
|
return currentSdReaderChrome.battery.enabled ? ¤tSdReaderChrome : 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()
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user