Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79f5657fb2 | ||
|
|
48aa3c8e02 |
@@ -131,6 +131,23 @@ Convert your own TTF/OTF files into `.cpfont` files that load from the SD card.
|
||||
|
||||
Conversion runs the firmware repo's `lib/EpdFont/scripts/fontconvert_sdcard.py` script unmodified, so output matches a local host build.
|
||||
|
||||
## 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
|
||||
@@ -138,6 +155,7 @@ Conversion runs the firmware repo's `lib/EpdFont/scripts/fontconvert_sdcard.py`
|
||||
- [User Guide](./USER_GUIDE.md)
|
||||
- [Web server usage](./docs/webserver.md)
|
||||
- [Web server endpoints](./docs/webserver-endpoints.md)
|
||||
- [SD-card theme creation](./docs/theme-creation.md)
|
||||
- [Project scope](./SCOPE.md)
|
||||
- [Contributing docs](./docs/contributing/README.md)
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -37,5 +37,3 @@
|
||||
#include <builtinFonts/ubuntu_10_regular.h>
|
||||
#include <builtinFonts/ubuntu_12_bold.h>
|
||||
#include <builtinFonts/ubuntu_12_regular.h>
|
||||
#include <builtinFonts/ubuntu_14_bold.h>
|
||||
#include <builtinFonts/ubuntu_14_regular.h>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -94,13 +94,6 @@ ruby -rdigest -e 'puts [
|
||||
].map{|f| Digest::SHA256.hexdigest(File.read(f)).to_i(16) }.sum % (2 ** 32) - (2 ** 31)'
|
||||
))"
|
||||
|
||||
echo "#define UI_14_FONT_ID ($(
|
||||
ruby -rdigest -e 'puts [
|
||||
"./ubuntu_14_regular.h",
|
||||
"./ubuntu_14_bold.h",
|
||||
].map{|f| Digest::SHA256.hexdigest(File.read(f)).to_i(16) }.sum % (2 ** 32) - (2 ** 31)'
|
||||
))"
|
||||
|
||||
echo "#define SMALL_FONT_ID ($(
|
||||
ruby -rdigest -e 'puts [
|
||||
"./notosans_8_regular.h",
|
||||
|
||||
@@ -28,42 +28,23 @@ for size in ${NOTOSANS_FONT_SIZES[@]}; do
|
||||
done
|
||||
done
|
||||
|
||||
# Small UI chrome (button devices, uiScale 1.0). Rendered 1-bit: crisp at these
|
||||
# sizes and half the flash of 2-bit.
|
||||
UI_FONT_SIZES=(10 12)
|
||||
# Larger UI chrome substituted in on touch/high-density boards via the uiScale
|
||||
# remap (see src/main.cpp setupFonts). Rendered 2-bit so it stays smooth when
|
||||
# enlarged. Touch boards use uiScale 1.2, so UI_12 -> 14.4 -> 14 is the size the
|
||||
# remap actually lands on; add larger sizes here if a board adopts a higher scale.
|
||||
UI_FONT_SIZES_LARGE=(14)
|
||||
UI_FONT_STYLES=("Regular" "Bold")
|
||||
|
||||
# Ubuntu lacks the Latin Extended Additional block (U+1EA0-U+1EF9) used for
|
||||
# Vietnamese tone marks. Append a Vietnamese-only Ubuntu cut so those glyphs are
|
||||
# filled from it while every glyph Ubuntu already has stays unchanged (fontstack
|
||||
# is ordered by descending priority). NotoSansHebrew fills U+05D0-U+05EA so the
|
||||
# Hebrew UI translation renders in menus and settings.
|
||||
generate_ui_font() {
|
||||
local size="$1" style="$2" extra_flags="$3"
|
||||
local font_name="ubuntu_${size}_$(echo $style | tr '[:upper:]' '[:lower:]')"
|
||||
local font_path="../builtinFonts/source/Ubuntu/Ubuntu-${style}.ttf"
|
||||
local hebrew_path="../builtinFonts/source/NotoSansHebrew/NotoSansHebrew-${style}.ttf"
|
||||
local viet_path="../builtinFonts/source/Ubuntu/Ubuntu-Vietnamese-${style}.ttf"
|
||||
local output_path="../builtinFonts/${font_name}.h"
|
||||
python fontconvert.py $font_name $size $font_path $hebrew_path $viet_path \
|
||||
--additional-intervals 0x05D0,0x05EA $extra_flags > $output_path
|
||||
echo "Generated $output_path"
|
||||
}
|
||||
|
||||
for size in ${UI_FONT_SIZES[@]}; do
|
||||
for style in ${UI_FONT_STYLES[@]}; do
|
||||
generate_ui_font $size $style ""
|
||||
done
|
||||
done
|
||||
|
||||
for size in ${UI_FONT_SIZES_LARGE[@]}; do
|
||||
for style in ${UI_FONT_STYLES[@]}; do
|
||||
generate_ui_font $size $style "--2bit --compress"
|
||||
font_name="ubuntu_${size}_$(echo $style | tr '[:upper:]' '[:lower:]')"
|
||||
font_path="../builtinFonts/source/Ubuntu/Ubuntu-${style}.ttf"
|
||||
hebrew_path="../builtinFonts/source/NotoSansHebrew/NotoSansHebrew-${style}.ttf"
|
||||
# Ubuntu lacks the Latin Extended Additional block (U+1EA0-U+1EF9) used for
|
||||
# Vietnamese tone marks. Append a Vietnamese-only Ubuntu cut so those glyphs
|
||||
# are filled from it while every glyph Ubuntu already has stays unchanged
|
||||
# (fontstack is ordered by descending priority).
|
||||
viet_path="../builtinFonts/source/Ubuntu/Ubuntu-Vietnamese-${style}.ttf"
|
||||
output_path="../builtinFonts/${font_name}.h"
|
||||
python fontconvert.py $font_name $size $font_path $hebrew_path $viet_path \
|
||||
--additional-intervals 0x05D0,0x05EA > $output_path
|
||||
echo "Generated $output_path"
|
||||
done
|
||||
done
|
||||
|
||||
|
||||
@@ -14,47 +14,8 @@ constexpr uint8_t BOOK_CACHE_VERSION = 8; // v8: TOC/book titles stored NFC-com
|
||||
constexpr char bookBinFile[] = "/book.bin";
|
||||
constexpr char tmpSpineBinFile[] = "/spine.bin.tmp";
|
||||
constexpr char tmpTocBinFile[] = "/toc.bin.tmp";
|
||||
constexpr uint32_t MAX_CACHE_STRING_LEN = 4096;
|
||||
|
||||
bool readStringBounded(HalFile& file, std::string& out, const uint32_t maxLen = MAX_CACHE_STRING_LEN) {
|
||||
uint32_t len = 0;
|
||||
if (file.read(&len, sizeof(len)) != static_cast<int>(sizeof(len))) {
|
||||
return false;
|
||||
}
|
||||
if (len > maxLen || len > static_cast<uint32_t>(file.available())) {
|
||||
LOG_ERR("BMC", "Invalid cache string length: %lu (max=%lu available=%d)", static_cast<unsigned long>(len),
|
||||
static_cast<unsigned long>(maxLen), file.available());
|
||||
return false;
|
||||
}
|
||||
out.clear();
|
||||
if (len == 0) {
|
||||
return true;
|
||||
}
|
||||
out.resize(len);
|
||||
return file.read(out.data(), len) == static_cast<int>(len);
|
||||
}
|
||||
|
||||
class CacheIoLock {
|
||||
public:
|
||||
explicit CacheIoLock(SemaphoreHandle_t mutex) : mutex(mutex) {
|
||||
if (mutex) xSemaphoreTakeRecursive(mutex, portMAX_DELAY);
|
||||
}
|
||||
~CacheIoLock() {
|
||||
if (mutex) xSemaphoreGiveRecursive(mutex);
|
||||
}
|
||||
|
||||
private:
|
||||
SemaphoreHandle_t mutex;
|
||||
};
|
||||
} // namespace
|
||||
|
||||
BookMetadataCache::~BookMetadataCache() {
|
||||
if (ioMutex) {
|
||||
vSemaphoreDelete(ioMutex);
|
||||
ioMutex = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============= WRITING / BUILDING FUNCTIONS ================ */
|
||||
|
||||
bool BookMetadataCache::beginWrite() {
|
||||
@@ -414,7 +375,6 @@ void BookMetadataCache::createTocEntry(const std::string& title, const std::stri
|
||||
/* ============= READING / LOADING FUNCTIONS ================ */
|
||||
|
||||
bool BookMetadataCache::load() {
|
||||
CacheIoLock ioLock(ioMutex);
|
||||
if (!Storage.openFileForRead("BMC", cachePath + bookBinFile, bookFile)) {
|
||||
return false;
|
||||
}
|
||||
@@ -432,13 +392,11 @@ bool BookMetadataCache::load() {
|
||||
serialization::readPod(bookFile, spineCount);
|
||||
serialization::readPod(bookFile, tocCount);
|
||||
|
||||
if (!readStringBounded(bookFile, coreMetadata.title) || !readStringBounded(bookFile, coreMetadata.author) ||
|
||||
!readStringBounded(bookFile, coreMetadata.language) || !readStringBounded(bookFile, coreMetadata.coverItemHref) ||
|
||||
!readStringBounded(bookFile, coreMetadata.textReferenceHref)) {
|
||||
LOG_ERR("BMC", "Invalid cache metadata strings");
|
||||
bookFile.close();
|
||||
return false;
|
||||
}
|
||||
serialization::readString(bookFile, coreMetadata.title);
|
||||
serialization::readString(bookFile, coreMetadata.author);
|
||||
serialization::readString(bookFile, coreMetadata.language);
|
||||
serialization::readString(bookFile, coreMetadata.coverItemHref);
|
||||
serialization::readString(bookFile, coreMetadata.textReferenceHref);
|
||||
|
||||
loaded = true;
|
||||
LOG_DBG("BMC", "Loaded cache data: %d spine, %d TOC entries", spineCount, tocCount);
|
||||
@@ -446,7 +404,6 @@ bool BookMetadataCache::load() {
|
||||
}
|
||||
|
||||
BookMetadataCache::SpineEntry BookMetadataCache::getSpineEntry(const int index) {
|
||||
CacheIoLock ioLock(ioMutex);
|
||||
if (!loaded) {
|
||||
LOG_ERR("BMC", "getSpineEntry called but cache not loaded");
|
||||
return {};
|
||||
@@ -461,16 +418,11 @@ BookMetadataCache::SpineEntry BookMetadataCache::getSpineEntry(const int index)
|
||||
bookFile.seek(lutOffset + sizeof(uint32_t) * index);
|
||||
uint32_t spineEntryPos;
|
||||
serialization::readPod(bookFile, spineEntryPos);
|
||||
if (spineEntryPos >= bookFile.size()) {
|
||||
LOG_ERR("BMC", "Spine entry offset out of range: %lu", static_cast<unsigned long>(spineEntryPos));
|
||||
return {};
|
||||
}
|
||||
bookFile.seek(spineEntryPos);
|
||||
return readSpineEntry(bookFile);
|
||||
}
|
||||
|
||||
BookMetadataCache::TocEntry BookMetadataCache::getTocEntry(const int index) {
|
||||
CacheIoLock ioLock(ioMutex);
|
||||
if (!loaded) {
|
||||
LOG_ERR("BMC", "getTocEntry called but cache not loaded");
|
||||
return {};
|
||||
@@ -485,34 +437,24 @@ BookMetadataCache::TocEntry BookMetadataCache::getTocEntry(const int index) {
|
||||
bookFile.seek(lutOffset + sizeof(uint32_t) * spineCount + sizeof(uint32_t) * index);
|
||||
uint32_t tocEntryPos;
|
||||
serialization::readPod(bookFile, tocEntryPos);
|
||||
if (tocEntryPos >= bookFile.size()) {
|
||||
LOG_ERR("BMC", "TOC entry offset out of range: %lu", static_cast<unsigned long>(tocEntryPos));
|
||||
return {};
|
||||
}
|
||||
bookFile.seek(tocEntryPos);
|
||||
return readTocEntry(bookFile);
|
||||
}
|
||||
|
||||
BookMetadataCache::SpineEntry BookMetadataCache::readSpineEntry(HalFile& file) const {
|
||||
SpineEntry entry;
|
||||
if (!readStringBounded(file, entry.href) ||
|
||||
file.read(&entry.cumulativeSize, sizeof(entry.cumulativeSize)) !=
|
||||
static_cast<int>(sizeof(entry.cumulativeSize)) ||
|
||||
file.read(&entry.tocIndex, sizeof(entry.tocIndex)) != static_cast<int>(sizeof(entry.tocIndex))) {
|
||||
LOG_ERR("BMC", "Invalid spine cache entry");
|
||||
return {};
|
||||
}
|
||||
serialization::readString(file, entry.href);
|
||||
serialization::readPod(file, entry.cumulativeSize);
|
||||
serialization::readPod(file, entry.tocIndex);
|
||||
return entry;
|
||||
}
|
||||
|
||||
BookMetadataCache::TocEntry BookMetadataCache::readTocEntry(HalFile& file) const {
|
||||
TocEntry entry;
|
||||
if (!readStringBounded(file, entry.title) || !readStringBounded(file, entry.href) ||
|
||||
!readStringBounded(file, entry.anchor) ||
|
||||
file.read(&entry.level, sizeof(entry.level)) != static_cast<int>(sizeof(entry.level)) ||
|
||||
file.read(&entry.spineIndex, sizeof(entry.spineIndex)) != static_cast<int>(sizeof(entry.spineIndex))) {
|
||||
LOG_ERR("BMC", "Invalid TOC cache entry");
|
||||
return {};
|
||||
}
|
||||
serialization::readString(file, entry.title);
|
||||
serialization::readString(file, entry.href);
|
||||
serialization::readString(file, entry.anchor);
|
||||
serialization::readPod(file, entry.level);
|
||||
serialization::readPod(file, entry.spineIndex);
|
||||
return entry;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#include <HalStorage.h>
|
||||
#include <freertos/semphr.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <deque>
|
||||
@@ -55,7 +54,6 @@ class BookMetadataCache {
|
||||
// Temp file handles during build
|
||||
HalFile spineFile;
|
||||
HalFile tocFile;
|
||||
SemaphoreHandle_t ioMutex;
|
||||
|
||||
// Index for fast href→spineIndex lookup (used only for large EPUBs)
|
||||
struct SpineHrefIndexEntry {
|
||||
@@ -87,14 +85,8 @@ class BookMetadataCache {
|
||||
BookMetadata coreMetadata;
|
||||
|
||||
explicit BookMetadataCache(std::string cachePath)
|
||||
: cachePath(std::move(cachePath)),
|
||||
lutOffset(0),
|
||||
spineCount(0),
|
||||
tocCount(0),
|
||||
loaded(false),
|
||||
buildMode(false),
|
||||
ioMutex(xSemaphoreCreateRecursiveMutex()) {}
|
||||
~BookMetadataCache();
|
||||
: cachePath(std::move(cachePath)), lutOffset(0), spineCount(0), tocCount(0), loaded(false), buildMode(false) {}
|
||||
~BookMetadataCache() = default;
|
||||
|
||||
// Building phase (stream to disk immediately)
|
||||
bool beginWrite();
|
||||
|
||||
@@ -150,6 +150,7 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name
|
||||
|
||||
if (self->state == IN_PACKAGE && (strcmp(name, "guide") == 0 || strcmp(name, "opf:guide") == 0)) {
|
||||
self->state = IN_GUIDE;
|
||||
// TODO Remove print
|
||||
LOG_DBG("COF", "Entering guide state.");
|
||||
if (!Storage.openFileForRead("COF", self->cachePath + itemCacheFile, self->tempItemStore)) {
|
||||
LOG_ERR("COF", "Couldn't open temp items file for reading. This is probably going to be a fatal error.");
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
#include <BidiUtils.h>
|
||||
#include <FontDecompressor.h>
|
||||
#include <HalGPIO.h>
|
||||
#include <Icon.h>
|
||||
#include <Logging.h>
|
||||
#include <SdCardFont.h>
|
||||
#include <Utf8.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cassert>
|
||||
|
||||
#include "FontCacheManager.h"
|
||||
|
||||
@@ -101,23 +101,6 @@ void GfxRenderer::insertFont(const int fontId, EpdFontFamily font) {
|
||||
}
|
||||
}
|
||||
|
||||
void GfxRenderer::setUiFontRemap(const int* from, const int* to, int count) {
|
||||
if (count < 0) count = 0;
|
||||
if (count > MAX_UI_FONT_REMAP) count = MAX_UI_FONT_REMAP;
|
||||
for (int i = 0; i < count; ++i) {
|
||||
uiFontFrom_[i] = from[i];
|
||||
uiFontTo_[i] = to[i];
|
||||
}
|
||||
uiFontRemapCount_ = count;
|
||||
}
|
||||
|
||||
int GfxRenderer::remapUiFont(const int fontId) const {
|
||||
for (int i = 0; i < uiFontRemapCount_; ++i) {
|
||||
if (uiFontFrom_[i] == fontId) return uiFontTo_[i];
|
||||
}
|
||||
return fontId;
|
||||
}
|
||||
|
||||
// Translate logical (x,y) coordinates to physical panel coordinates based on current orientation
|
||||
// This should always be inlined for better performance
|
||||
static inline void rotateCoordinates(const GfxRenderer::Orientation orientation, const int x, const int y, int* phyX,
|
||||
@@ -383,7 +366,7 @@ int GfxRenderer::getTextWidth(const int fontId, const char* text, const EpdFontF
|
||||
return 0;
|
||||
}
|
||||
|
||||
const auto fontIt = fontMap.find(remapUiFont(fontId));
|
||||
const auto fontIt = fontMap.find(fontId);
|
||||
if (fontIt == fontMap.end()) {
|
||||
LOG_ERR("GFX", "Font %d not found", fontId);
|
||||
return 0;
|
||||
@@ -425,7 +408,7 @@ void GfxRenderer::drawText(const int fontId, const int x, const int y, const cha
|
||||
return;
|
||||
}
|
||||
|
||||
const auto fontIt = fontMap.find(remapUiFont(fontId));
|
||||
const auto fontIt = fontMap.find(fontId);
|
||||
if (fontIt == fontMap.end()) {
|
||||
LOG_ERR("GFX", "Font %d not found", fontId);
|
||||
return;
|
||||
@@ -1073,20 +1056,17 @@ void GfxRenderer::drawImage(const uint8_t bitmap[], const int x, const int y, co
|
||||
}
|
||||
|
||||
void GfxRenderer::drawIcon(const uint8_t bitmap[], const int x, const int y, const int width, const int height) const {
|
||||
display.drawImageTransparent(bitmap, y, getScreenWidth() - width - x, height, width);
|
||||
}
|
||||
if (bitmap == nullptr || width <= 0 || height <= 0) return;
|
||||
assert(width == height);
|
||||
|
||||
void GfxRenderer::drawIcon(const freeink::Icon& icon, const int x, const int y, const bool black) const {
|
||||
// Bits are un-rotated, so each drawn pixel goes through drawPixel (which applies
|
||||
// the orientation transform) — correct in every orientation, unlike the legacy
|
||||
// uint8_t* overload that pre-rotates for one orientation.
|
||||
const int rowBytes = (icon.w + 7) / 8;
|
||||
for (int row = 0; row < icon.h; ++row) {
|
||||
const uint8_t* r = icon.bits + static_cast<int>(row) * rowBytes;
|
||||
for (int col = 0; col < icon.w; ++col) {
|
||||
if (((r[col >> 3] >> (7 - (col & 7))) & 1) == 0) { // 0 = drawn
|
||||
drawPixel(x + col, y + row, black);
|
||||
}
|
||||
const int bytesPerRow = (width + 7) / 8;
|
||||
for (int sourceY = 0; sourceY < height; ++sourceY) {
|
||||
for (int sourceX = 0; sourceX < width; ++sourceX) {
|
||||
const uint8_t rowByte = bitmap[sourceY * bytesPerRow + sourceX / 8];
|
||||
const bool background = (rowByte >> (7 - (sourceX % 8))) & 0x01;
|
||||
if (background) continue;
|
||||
|
||||
drawPixel(x + height - 1 - sourceY, y + sourceX, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1499,38 +1479,6 @@ int GfxRenderer::getScreenHeight() const {
|
||||
return panelWidth;
|
||||
}
|
||||
|
||||
void GfxRenderer::tapToLogical(float nx, float ny, int& outX, int& outY) const {
|
||||
// Native panel pixel of the tap (wasTouchTap normalizes over the native panel).
|
||||
int phyX = static_cast<int>(nx * panelWidth);
|
||||
int phyY = static_cast<int>(ny * panelHeight);
|
||||
if (phyX < 0) phyX = 0;
|
||||
if (phyX > panelWidth - 1) phyX = panelWidth - 1;
|
||||
if (phyY < 0) phyY = 0;
|
||||
if (phyY > panelHeight - 1) phyY = panelHeight - 1;
|
||||
|
||||
// Inverse of rotateCoordinates() (see the forward transform above): map a
|
||||
// physical/native point back into the current orientation's logical frame.
|
||||
switch (orientation) {
|
||||
case Portrait: // forward: phyX=logY, phyY=panelHeight-1-logX
|
||||
outX = panelHeight - 1 - phyY;
|
||||
outY = phyX;
|
||||
break;
|
||||
case PortraitInverted: // forward: phyX=panelWidth-1-logY, phyY=logX
|
||||
outX = phyY;
|
||||
outY = panelWidth - 1 - phyX;
|
||||
break;
|
||||
case LandscapeClockwise: // forward: phyX=panelWidth-1-logX, phyY=panelHeight-1-logY
|
||||
outX = panelWidth - 1 - phyX;
|
||||
outY = panelHeight - 1 - phyY;
|
||||
break;
|
||||
case LandscapeCounterClockwise: // forward: identity
|
||||
default:
|
||||
outX = phyX;
|
||||
outY = phyY;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Translate a logical rect through rotateCoordinates and take the bounding
|
||||
// box of its four corners on the physical panel. Output coords are inclusive
|
||||
// and clamped. Returns false if the rect ends up fully off-panel.
|
||||
@@ -1622,7 +1570,7 @@ int GfxRenderer::getSpaceWidth(const int fontId, const EpdFontFamily::Style styl
|
||||
return fp4::toPixel(sdIt->second->getAdvance(' ', resolvedStyle));
|
||||
}
|
||||
|
||||
const auto fontIt = fontMap.find(remapUiFont(fontId));
|
||||
const auto fontIt = fontMap.find(fontId);
|
||||
if (fontIt == fontMap.end()) {
|
||||
LOG_ERR("GFX", "Font %d not found", fontId);
|
||||
return 0;
|
||||
@@ -1643,7 +1591,7 @@ int GfxRenderer::getSpaceAdvance(const int fontId, const uint32_t leftCp, const
|
||||
return fp4::toPixel(sdIt->second->getAdvance(' ', resolvedStyle));
|
||||
}
|
||||
|
||||
const auto fontIt = fontMap.find(remapUiFont(fontId));
|
||||
const auto fontIt = fontMap.find(fontId);
|
||||
if (fontIt == fontMap.end()) return 0;
|
||||
const auto& font = fontIt->second;
|
||||
const EpdGlyph* spaceGlyph = font.getGlyph(' ', style);
|
||||
@@ -1657,7 +1605,7 @@ int GfxRenderer::getSpaceAdvance(const int fontId, const uint32_t leftCp, const
|
||||
|
||||
int GfxRenderer::getKerning(const int fontId, const uint32_t leftCp, const uint32_t rightCp,
|
||||
const EpdFontFamily::Style style) const {
|
||||
const auto fontIt = fontMap.find(remapUiFont(fontId));
|
||||
const auto fontIt = fontMap.find(fontId);
|
||||
if (fontIt == fontMap.end()) return 0;
|
||||
const int kernFP = fontIt->second.getKerning(leftCp, rightCp, style); // 4.4 fixed-point
|
||||
return fp4::toPixel(kernFP); // snap 4.4 fixed-point to nearest pixel
|
||||
@@ -1689,7 +1637,7 @@ int GfxRenderer::getTextAdvanceX(const int fontId, const char* text, EpdFontFami
|
||||
return fp4::toPixel(widthFP);
|
||||
}
|
||||
|
||||
const auto fontIt = fontMap.find(remapUiFont(fontId));
|
||||
const auto fontIt = fontMap.find(fontId);
|
||||
if (fontIt == fontMap.end()) {
|
||||
LOG_ERR("GFX", "Font %d not found", fontId);
|
||||
return 0;
|
||||
@@ -1725,7 +1673,7 @@ int GfxRenderer::getTextAdvanceX(const int fontId, const char* text, EpdFontFami
|
||||
}
|
||||
|
||||
int GfxRenderer::getFontAscenderSize(const int fontId) const {
|
||||
const auto fontIt = fontMap.find(remapUiFont(fontId));
|
||||
const auto fontIt = fontMap.find(fontId);
|
||||
if (fontIt == fontMap.end()) {
|
||||
LOG_ERR("GFX", "Font %d not found", fontId);
|
||||
return 0;
|
||||
@@ -1735,7 +1683,7 @@ int GfxRenderer::getFontAscenderSize(const int fontId) const {
|
||||
}
|
||||
|
||||
int GfxRenderer::getLineHeight(const int fontId) const {
|
||||
const auto fontIt = fontMap.find(remapUiFont(fontId));
|
||||
const auto fontIt = fontMap.find(fontId);
|
||||
if (fontIt == fontMap.end()) {
|
||||
LOG_ERR("GFX", "Font %d not found", fontId);
|
||||
return 0;
|
||||
@@ -1745,7 +1693,7 @@ int GfxRenderer::getLineHeight(const int fontId) const {
|
||||
}
|
||||
|
||||
int GfxRenderer::getTextHeight(const int fontId) const {
|
||||
const auto fontIt = fontMap.find(remapUiFont(fontId));
|
||||
const auto fontIt = fontMap.find(fontId);
|
||||
if (fontIt == fontMap.end()) {
|
||||
LOG_ERR("GFX", "Font %d not found", fontId);
|
||||
return 0;
|
||||
@@ -1753,27 +1701,6 @@ int GfxRenderer::getTextHeight(const int fontId) const {
|
||||
return fontIt->second.getData(EpdFontFamily::REGULAR)->ascender;
|
||||
}
|
||||
|
||||
// Height of a reference glyph above the baseline (glyph->top): 'H' gives the real
|
||||
// cap height, the actual visible font extent for vertical centering.
|
||||
int GfxRenderer::glyphTop(const int fontId, const uint32_t cp) const {
|
||||
const auto fontIt = fontMap.find(remapUiFont(fontId));
|
||||
if (fontIt == fontMap.end()) return 0;
|
||||
const EpdGlyph* g = fontIt->second.getGlyph(cp, EpdFontFamily::REGULAR);
|
||||
return g ? g->top : 0;
|
||||
}
|
||||
|
||||
int GfxRenderer::getFontCapHeight(const int fontId) const { return glyphTop(fontId, 'H'); }
|
||||
|
||||
int GfxRenderer::getTextVisualCenterOffset(const int fontId) const {
|
||||
// Cap-height middle: baseline is at top + ascender, caps reach capHeight above it,
|
||||
// so the optical center is ascender - capHeight/2 below the top. Cap (not x) height
|
||||
// because UI labels are capital-led. Falls back to ascender*0.65 without an 'H'.
|
||||
const int ascender = getFontAscenderSize(fontId);
|
||||
const int capHeight = getFontCapHeight(fontId);
|
||||
if (capHeight <= 0) return (ascender * 65) / 100;
|
||||
return ascender - capHeight / 2;
|
||||
}
|
||||
|
||||
void GfxRenderer::drawTextRotated90CW(const int fontId, const int x, const int y, const char* text, const bool black,
|
||||
const EpdFontFamily::Style style) const {
|
||||
// Cannot draw a NULL / empty string
|
||||
@@ -1781,7 +1708,7 @@ void GfxRenderer::drawTextRotated90CW(const int fontId, const int x, const int y
|
||||
return;
|
||||
}
|
||||
|
||||
const auto fontIt = fontMap.find(remapUiFont(fontId));
|
||||
const auto fontIt = fontMap.find(fontId);
|
||||
if (fontIt == fontMap.end()) {
|
||||
LOG_ERR("GFX", "Font %d not found", fontId);
|
||||
return;
|
||||
|
||||
@@ -13,9 +13,6 @@ enum class BidiBaseDir : signed char { AUTO = -1, LTR = 0, RTL = 1 };
|
||||
|
||||
class FontCacheManager;
|
||||
class SdCardFont;
|
||||
namespace freeink {
|
||||
struct Icon;
|
||||
}
|
||||
|
||||
#include <cstring>
|
||||
#include <map>
|
||||
@@ -54,13 +51,6 @@ class GfxRenderer {
|
||||
uint32_t frameBufferSize = HalDisplay::BUFFER_SIZE;
|
||||
std::vector<uint8_t*> bwBufferChunks;
|
||||
std::map<int, EpdFontFamily> fontMap;
|
||||
// UI chrome font remap table (see setUiFontRemap). Empty = identity.
|
||||
static constexpr int MAX_UI_FONT_REMAP = 8;
|
||||
int uiFontFrom_[MAX_UI_FONT_REMAP] = {0};
|
||||
int uiFontTo_[MAX_UI_FONT_REMAP] = {0};
|
||||
int uiFontRemapCount_ = 0;
|
||||
int remapUiFont(int fontId) const;
|
||||
int glyphTop(int fontId, uint32_t cp) const;
|
||||
// Mutable because ensureSdCardFontReady() is const (called from layout code
|
||||
// that holds a const GfxRenderer&) but triggers SD card reads and heap
|
||||
// allocation inside the SdCardFont objects. Same pragmatic compromise as
|
||||
@@ -111,14 +101,6 @@ class GfxRenderer {
|
||||
// Setup
|
||||
void begin(); // must be called right after display.begin()
|
||||
void insertFont(int fontId, EpdFontFamily font);
|
||||
// Orientation-correct icon blit for the freeink::Icon format (un-rotated bits +
|
||||
// optical-center metadata). Prefer this over the legacy raw-bitmap drawIcon.
|
||||
void drawIcon(const freeink::Icon& icon, int x, int y, bool black = true) const;
|
||||
// UI chrome font scaling: firmware supplies a small remap table (from the board
|
||||
// uiScale) that substitutes a larger font for each scaled UI font id at lookup
|
||||
// time, so layout and drawing stay consistent with no call-site changes. Reader
|
||||
// body fonts aren't in the table, so book text is unaffected. count <= 8.
|
||||
void setUiFontRemap(const int* from, const int* to, int count);
|
||||
// Clears both the flash-font map and any SD-font registration for fontId.
|
||||
// Coupled to avoid dangling SdCardFont* in sdCardFonts_ when callers free
|
||||
// the underlying SdCardFont and forget the SD-side unregister.
|
||||
@@ -159,11 +141,6 @@ class GfxRenderer {
|
||||
void clearScreen(uint8_t color = 0xFF) const;
|
||||
void getOrientedViewableTRBL(int* outTop, int* outRight, int* outBottom, int* outLeft) const;
|
||||
|
||||
// Map a touch tap (normalized 0..1 in panel-native orientation, from
|
||||
// InputManager::wasTouchTap) to logical screen coordinates matching the Rects the
|
||||
// UI draws in. Inverse of rotateCoordinates() for the current orientation.
|
||||
void tapToLogical(float nx, float ny, int& outX, int& outY) const;
|
||||
|
||||
// Tiled grayscale strip target. While active, drawPixel() and clearScreen()
|
||||
// operate on `scratch` (panelWidthBytes * stripRows bytes, holding physical
|
||||
// rows [stripY0, stripY0 + stripRows)) instead of the framebuffer; pixels
|
||||
@@ -231,12 +208,6 @@ class GfxRenderer {
|
||||
int getTextAdvanceX(int fontId, const char* text, EpdFontFamily::Style style) const;
|
||||
int getFontAscenderSize(int fontId) const;
|
||||
int getLineHeight(int fontId) const;
|
||||
// Cap height (from the 'H' glyph), the real visible font extent for vertical
|
||||
// alignment that follows the font instead of a guessed ascender fraction.
|
||||
int getFontCapHeight(int fontId) const;
|
||||
// Offset from text top (drawText's y) to the text's optical center; align an
|
||||
// element to a line via centerY = textTop + getTextVisualCenterOffset(fontId).
|
||||
int getTextVisualCenterOffset(int fontId) const;
|
||||
std::string truncatedText(int fontId, const char* text, int maxWidth,
|
||||
EpdFontFamily::Style style = EpdFontFamily::REGULAR) const;
|
||||
/// Word-wrap \p text into at most \p maxLines lines, each no wider than
|
||||
|
||||
@@ -68,7 +68,6 @@ STR_TEXT_AA: "Згладжванне тэксту"
|
||||
STR_SHORT_PWR_BTN: "Кароткае націсканне PWR"
|
||||
STR_ORIENTATION: "Арыентацыя чытання"
|
||||
STR_SIDE_BTN_LAYOUT: "Бакавыя кнопкі"
|
||||
STR_TOUCH_READER_CONTROLS: "Сэнсарнае кіраванне чытаннем"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Арыентаваць пярэднія кнопкі"
|
||||
STR_LONG_PRESS_SKIP: "Доўгае націсканне - змена раздзела"
|
||||
STR_FONT_PREVIEW_TEXT: "У Іўі худы жвавы чорт у зялёнай камізэльцы пабег пад'есці фаршу з юшкай"
|
||||
@@ -301,3 +300,4 @@ STR_SLEEP_TIMER_STEP_HINT: "Улева/Управа: 1 хв Уверх/Уніз
|
||||
STR_AUTO_TURN_ENABLED: "Аўтаперагортванне: "
|
||||
STR_AUTO_TURN_PAGES_PER_MIN: "Аўтаперагортванне (старонак за хвіліну)"
|
||||
STR_TILT_PAGE_TURN: "Перагортванне нахілам"
|
||||
STR_MANAGE_THEMES: "Кіраванне тэмамі"
|
||||
|
||||
@@ -72,7 +72,6 @@ STR_IMAGES_SUPPRESS: "Suprimir"
|
||||
STR_SHORT_PWR_BTN: "Clic curt del botó d'engegada"
|
||||
STR_ORIENTATION: "Orientació de lectura"
|
||||
STR_SIDE_BTN_LAYOUT: "Disposició botons laterals"
|
||||
STR_TOUCH_READER_CONTROLS: "Controls tàctils del lector"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Orientar botons frontals"
|
||||
STR_LONG_PRESS_BEHAVIOR: "Comportament de prémer llargament el botó"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "Desactivat"
|
||||
@@ -384,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_RECOVERY_MODE: "Mode de recuperació"
|
||||
STR_RECOVERY_MODE_HINT: "Poseu firmware.bin a l'arrel de la targeta SD i seleccioneu-lo"
|
||||
STR_MANAGE_THEMES: "Gestiona els temes"
|
||||
|
||||
@@ -68,7 +68,6 @@ STR_TEXT_AA: "Vyhlazování textu"
|
||||
STR_SHORT_PWR_BTN: "Krátké stisknutí tlačítka napájení"
|
||||
STR_ORIENTATION: "Orientace čtení"
|
||||
STR_SIDE_BTN_LAYOUT: "Rozvržení bočních tlačítek (čtečka)"
|
||||
STR_TOUCH_READER_CONTROLS: "Dotykové ovládání čtečky"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Orientovat přední tlačítka"
|
||||
STR_LONG_PRESS_BEHAVIOR: "Chování při dlouhém stisknutí tlačítka"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "VYP"
|
||||
@@ -276,3 +275,4 @@ STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
||||
STR_SLEEP_NEVER: "Nikdy"
|
||||
STR_SLEEP_TIMER_STEP_HINT: "Vlevo/Vpravo: 1 min Nahoru/Dolů: 5 min"
|
||||
STR_TILT_PAGE_TURN: "Otáčení stránek nakloněním"
|
||||
STR_MANAGE_THEMES: "Spravovat motivy"
|
||||
|
||||
@@ -72,7 +72,6 @@ STR_IMAGES_SUPPRESS: "Skjul"
|
||||
STR_SHORT_PWR_BTN: "Kort tryk på tænd/sluk-knap"
|
||||
STR_ORIENTATION: "Læseretning"
|
||||
STR_SIDE_BTN_LAYOUT: "Knaplayout på siden (læser)"
|
||||
STR_TOUCH_READER_CONTROLS: "Touch-betjening (læser)"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Orientér forreste knapper"
|
||||
STR_LONG_PRESS_BEHAVIOR: "Comportamiento al mantener pulsado el botón"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "Desactivado"
|
||||
@@ -304,3 +303,4 @@ STR_SCREENSHOT_BUTTON: "Tag skærmbillede"
|
||||
STR_AUTO_TURN_ENABLED: "Automatisk sidevendning aktiveret: "
|
||||
STR_AUTO_TURN_PAGES_PER_MIN: "Automatisk vending (sider per minut)"
|
||||
STR_TILT_PAGE_TURN: "Vip for at vende side"
|
||||
STR_MANAGE_THEMES: "Administrer temaer"
|
||||
|
||||
@@ -72,7 +72,6 @@ STR_IMAGES_SUPPRESS: "Verbergen"
|
||||
STR_SHORT_PWR_BTN: "Korte klik aan/uit-knop"
|
||||
STR_ORIENTATION: "Leesstand"
|
||||
STR_SIDE_BTN_LAYOUT: "Indeling zijknoppen (lezer)"
|
||||
STR_TOUCH_READER_CONTROLS: "Aanraakbediening lezer"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Richt voorste knoppen"
|
||||
STR_LONG_PRESS_BEHAVIOR: "Long-press button behavior"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "OFF"
|
||||
@@ -304,3 +303,4 @@ STR_SCREENSHOT_BUTTON: "Screenshot maken"
|
||||
STR_AUTO_TURN_ENABLED: "Automatisch omslaan ingeschakeld: "
|
||||
STR_AUTO_TURN_PAGES_PER_MIN: "Autom. omslaan (pagina's per minuut)"
|
||||
STR_TILT_PAGE_TURN: "Kantel om te bladeren"
|
||||
STR_MANAGE_THEMES: "Thema's beheren"
|
||||
|
||||
@@ -73,7 +73,6 @@ STR_IMAGES_SUPPRESS: "Suppress"
|
||||
STR_SHORT_PWR_BTN: "Short Power Button Click"
|
||||
STR_ORIENTATION: "Reading Orientation"
|
||||
STR_SIDE_BTN_LAYOUT: "Side Button Layout (reader)"
|
||||
STR_TOUCH_READER_CONTROLS: "Touch Reader Controls"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Orient front buttons"
|
||||
STR_LONG_PRESS_BEHAVIOR: "Long-press button behavior"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "OFF"
|
||||
@@ -351,6 +350,7 @@ STR_INSTALLED: "Installed"
|
||||
STR_DOWNLOAD_ALL: "Download All"
|
||||
STR_UPDATE_ALL: "Update All"
|
||||
STR_UPDATE_AVAILABLE: "Update"
|
||||
STR_MANAGE_THEMES: "Manage Themes"
|
||||
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_REASON: "Crash reason:"
|
||||
|
||||
@@ -68,7 +68,6 @@ STR_TEXT_AA: "Tekstin reunanpehmennys"
|
||||
STR_SHORT_PWR_BTN: "Lyhyt virtapainikkeen painallus"
|
||||
STR_ORIENTATION: "Lukusuunta"
|
||||
STR_SIDE_BTN_LAYOUT: "Sivupainikkeiden asettelu (lukija)"
|
||||
STR_TOUCH_READER_CONTROLS: "Kosketusohjaus (lukija)"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Suuntaa etupainikkeet"
|
||||
STR_LONG_PRESS_BEHAVIOR: "Long-press button behavior"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "OFF"
|
||||
@@ -274,3 +273,4 @@ STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
||||
STR_SLEEP_NEVER: "Ei koskaan"
|
||||
STR_SLEEP_TIMER_STEP_HINT: "Vasen/Oikea: 1 min Ylös/Alas: 5 min"
|
||||
STR_TILT_PAGE_TURN: "Sivunkääntö kallistamalla"
|
||||
STR_MANAGE_THEMES: "Hallinnoi teemoja"
|
||||
|
||||
@@ -72,7 +72,6 @@ STR_IMAGES_SUPPRESS: "Masquer"
|
||||
STR_SHORT_PWR_BTN: "Appui court alim."
|
||||
STR_ORIENTATION: "Orientation de lecture"
|
||||
STR_SIDE_BTN_LAYOUT: "Boutons latéraux"
|
||||
STR_TOUCH_READER_CONTROLS: "Commandes tactiles (lecteur)"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Orienter boutons avant"
|
||||
STR_LONG_PRESS_BEHAVIOR: "Comportement lors d'un appui long"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "Désactivé"
|
||||
@@ -305,3 +304,4 @@ STR_SCREENSHOT_BUTTON: "Capture d'écran"
|
||||
STR_AUTO_TURN_ENABLED: "Tourne-page auto : "
|
||||
STR_AUTO_TURN_PAGES_PER_MIN: "Tourne-page auto (pages par minute)"
|
||||
STR_TILT_PAGE_TURN: "Tourner par inclinaison"
|
||||
STR_MANAGE_THEMES: "Gérer les thèmes"
|
||||
|
||||
@@ -68,7 +68,6 @@ STR_TEXT_AA: "Schriftglättung"
|
||||
STR_SHORT_PWR_BTN: "An-Taste kurz drücken"
|
||||
STR_ORIENTATION: "Leseausrichtung"
|
||||
STR_SIDE_BTN_LAYOUT: "Seitliche Tasten (Lesen)"
|
||||
STR_TOUCH_READER_CONTROLS: "Touch-Steuerung (Lesen)"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Vordere Tasten ausrichten"
|
||||
STR_LONG_PRESS_BEHAVIOR: "Verhalten bei langem Tastendruck"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "Aus"
|
||||
@@ -381,3 +380,4 @@ STR_FIRMWARE_WRITE_FAILED: "Schreiben der Firmware-Datei ist fehlgeschlagen"
|
||||
STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "Nicht ausschalten!"
|
||||
STR_RECOVERY_MODE: "Wiederherstellungsmodus"
|
||||
STR_RECOVERY_MODE_HINT: "Lege firmware.bin im SD-Kartenwurzelverzeichnis ab und wähle es aus"
|
||||
STR_MANAGE_THEMES: "Designs verwalten"
|
||||
|
||||
@@ -73,7 +73,6 @@ STR_IMAGES_SUPPRESS: "הסתר תמונות"
|
||||
STR_SHORT_PWR_BTN: "לחיצה קצרה על כפתור ההפעלה"
|
||||
STR_ORIENTATION: "כיוון קריאה (מסך)"
|
||||
STR_SIDE_BTN_LAYOUT: "פריסת כפתורי צד (בקריאה)"
|
||||
STR_TOUCH_READER_CONTROLS: "פקדי מגע בקריאה"
|
||||
STR_LONG_PRESS_BEHAVIOR: "פעולת לחיצה ארוכה"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "כבוי"
|
||||
STR_LONG_PRESS_BEHAVIOR_SKIP: "דלג פרק"
|
||||
@@ -385,6 +384,7 @@ STR_BOOKMARK_REMOVED: "הסימנייה הוסרה"
|
||||
STR_QUICK_RESUME: "חזרה מהירה"
|
||||
STR_REMOVE_FROM_RECENTS: "להסיר מרשימת הספרים האחרונים?"
|
||||
STR_CONFIRM_DELETE_BOOKMARK: "למחוק סימנייה זו?"
|
||||
STR_MANAGE_THEMES: "ניהול ערכות נושא"
|
||||
STR_LONG_PRESS_MENU: "לחיצה ארוכה על אישור"
|
||||
STR_KOSYNC: "KOSync"
|
||||
STR_BOOKMARK_OPTION: "סימנייה"
|
||||
|
||||
@@ -72,7 +72,6 @@ STR_IMAGES_SUPPRESS: "Elnyomás"
|
||||
STR_SHORT_PWR_BTN: "Rövid bekapcsológomb nyomás"
|
||||
STR_ORIENTATION: "Olvasási irány"
|
||||
STR_SIDE_BTN_LAYOUT: "Oldalsó gomb elrendezés (olvasó)"
|
||||
STR_TOUCH_READER_CONTROLS: "Érintőképernyős vezérlés (olvasó)"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Elülső gombok tájolása"
|
||||
STR_LONG_PRESS_SKIP: "Hosszú nyomás - fejezet ugrás"
|
||||
STR_FONT_PREVIEW_TEXT: "Egy hűtlen vejét fülöncsípő, dühös mexikói úr Wesselényinél mázol Quitóban"
|
||||
@@ -301,3 +300,4 @@ STR_SCREENSHOT_BUTTON: "Képernyőkép készítése"
|
||||
STR_AUTO_TURN_ENABLED: "Automatikus lapozás bekapcsolva: "
|
||||
STR_AUTO_TURN_PAGES_PER_MIN: "Automatikus lapozás (oldal/perc)"
|
||||
STR_TILT_PAGE_TURN: "Döntéses lapozás"
|
||||
STR_MANAGE_THEMES: "Témák kezelése"
|
||||
|
||||
@@ -72,7 +72,6 @@ STR_IMAGES_SUPPRESS: "Nascondi"
|
||||
STR_SHORT_PWR_BTN: "Press. breve pul. accensione"
|
||||
STR_ORIENTATION: "Orientamento lettura"
|
||||
STR_SIDE_BTN_LAYOUT: "Pul. laterali (lettore)"
|
||||
STR_TOUCH_READER_CONTROLS: "Controlli touch (lettore)"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Orienta pul. frontali"
|
||||
STR_LONG_PRESS_BEHAVIOR: "Press. lunga pul. laterali"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "OFF"
|
||||
@@ -377,6 +376,8 @@ STR_CLOCK_SYNCING: "Sincronizzazione con il server NTP..."
|
||||
STR_CLOCK_SYNC_FAIL: "Sincronizzazione non riuscita"
|
||||
STR_CLOCK_SYNC_NOW: "Sincronizza l'orologio adesso"
|
||||
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_NEXT_FIELD: "Succ."
|
||||
STR_CURRENT_TIME: "Ora attuale: "
|
||||
|
||||
@@ -67,7 +67,6 @@ STR_TEXT_AA: "Мәтін сырғытпасы"
|
||||
STR_SHORT_PWR_BTN: "Қуат түймесін қысқа басу"
|
||||
STR_ORIENTATION: "Оқу бағдары"
|
||||
STR_SIDE_BTN_LAYOUT: "Бүйірлік түймелер орналасуы (оқырман)"
|
||||
STR_TOUCH_READER_CONTROLS: "Сенсорлық басқару (оқырман)"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Алдыңғы түймелерді бағдарлау"
|
||||
STR_LONG_PRESS_SKIP: "Ұзақ басу арқылы тарау өткізу"
|
||||
STR_FONT_PREVIEW_TEXT: "Канагаттандырылмагандыктарыныздан"
|
||||
@@ -300,3 +299,4 @@ STR_SCREENSHOT_BUTTON: "Скриншот түсіру"
|
||||
STR_AUTO_TURN_ENABLED: "Автоматты бет аудару қосулы: "
|
||||
STR_AUTO_TURN_PAGES_PER_MIN: "Автоматты бет аудару (минутына бет саны)"
|
||||
STR_TILT_PAGE_TURN: "Еңкейту арқылы бет аудару"
|
||||
STR_MANAGE_THEMES: "Тақырыптарды басқару"
|
||||
|
||||
@@ -72,7 +72,6 @@ STR_IMAGES_SUPPRESS: "Slėpti"
|
||||
STR_SHORT_PWR_BTN: "Trumpas įjungimo pasp."
|
||||
STR_ORIENTATION: "Orientacija"
|
||||
STR_SIDE_BTN_LAYOUT: "Šoniniai mygtukai"
|
||||
STR_TOUCH_READER_CONTROLS: "Liečiamasis valdymas (skaityklė)"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Orientuoti priekinius mygtukus"
|
||||
STR_LONG_PRESS_SKIP: "Praleisti skyrių (ilgai)"
|
||||
STR_FONT_PREVIEW_TEXT: "Įlinkdama fechtuotojo špaga sublykčiojusi pragręžė apvalų arbūzą"
|
||||
@@ -301,3 +300,4 @@ STR_SCREENSHOT_BUTTON: "Ekrano nuotrauka"
|
||||
STR_AUTO_TURN_ENABLED: "Auto-vertimas: "
|
||||
STR_AUTO_TURN_PAGES_PER_MIN: "Auto-vertimas (psl/min)"
|
||||
STR_TILT_PAGE_TURN: "Puslapio vertimas pakreipiant"
|
||||
STR_MANAGE_THEMES: "Tvarkyti temas"
|
||||
|
||||
@@ -72,7 +72,6 @@ STR_IMAGES_SUPPRESS: "Pomijaj"
|
||||
STR_SHORT_PWR_BTN: "Krótkie naciśnięcie zasilania"
|
||||
STR_ORIENTATION: "Układ czytania"
|
||||
STR_SIDE_BTN_LAYOUT: "Układ przycisków bocznych"
|
||||
STR_TOUCH_READER_CONTROLS: "Sterowanie dotykowe (czytnik)"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Orientuj przednie przyciski"
|
||||
STR_LONG_PRESS_BEHAVIOR: "Funkcja długiego przyciśnięcia"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "Wył."
|
||||
@@ -361,3 +360,4 @@ STR_FIRMWARE_WRITE_FAILED: "Zapis oprogramowania nieudany"
|
||||
STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "Nie wyłączać!"
|
||||
STR_RECOVERY_MODE: "Tryb przywracania"
|
||||
STR_RECOVERY_MODE_HINT: "Umieść firmware.bin w głównym katalogu karty SD i wybierz go"
|
||||
STR_MANAGE_THEMES: "Zarządzaj motywami"
|
||||
|
||||
@@ -68,7 +68,6 @@ STR_TEXT_AA: "Suavização de texto"
|
||||
STR_SHORT_PWR_BTN: "Clique curto botão ligar"
|
||||
STR_ORIENTATION: "Orientação de leitura"
|
||||
STR_SIDE_BTN_LAYOUT: "Disposição botões laterais"
|
||||
STR_TOUCH_READER_CONTROLS: "Controlos táteis do leitor"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Orientar botões frontais"
|
||||
STR_LONG_PRESS_BEHAVIOR: "Comportamento do botão de premir e segurar"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "Desligado"
|
||||
@@ -276,3 +275,4 @@ STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
||||
STR_SLEEP_NEVER: "Nunca"
|
||||
STR_SLEEP_TIMER_STEP_HINT: "Esq/Dir: 1 min Cima/Baixo: 5 min"
|
||||
STR_TILT_PAGE_TURN: "Virar página por inclinação"
|
||||
STR_MANAGE_THEMES: "Gerenciar temas"
|
||||
|
||||
@@ -72,7 +72,6 @@ STR_IMAGES_SUPPRESS: "Suprimare"
|
||||
STR_SHORT_PWR_BTN: "Apăsare scurtă întrerupător"
|
||||
STR_ORIENTATION: "Orientare lectură"
|
||||
STR_SIDE_BTN_LAYOUT: "Aspect butoane laterale (lectură)"
|
||||
STR_TOUCH_READER_CONTROLS: "Control tactil (lectură)"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Orientare butoane frontale"
|
||||
STR_LONG_PRESS_BEHAVIOR: "Comportament buton apăsat lung"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "Dezactivat"
|
||||
@@ -304,3 +303,4 @@ STR_SCREENSHOT_BUTTON: "Captură ecran"
|
||||
STR_AUTO_TURN_ENABLED: "Răsfoire automată: "
|
||||
STR_AUTO_TURN_PAGES_PER_MIN: "Pagini pe minut"
|
||||
STR_TILT_PAGE_TURN: "Întoarcere pagină prin înclinare"
|
||||
STR_MANAGE_THEMES: "Gestionează temele"
|
||||
|
||||
@@ -73,7 +73,6 @@ STR_IMAGES_SUPPRESS: "Скрыть"
|
||||
STR_SHORT_PWR_BTN: "Короткое нажатие PWR"
|
||||
STR_ORIENTATION: "Ориентация чтения"
|
||||
STR_SIDE_BTN_LAYOUT: "Боковые кнопки"
|
||||
STR_TOUCH_READER_CONTROLS: "Сенсорное управление чтением"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Ориентировать передние кнопки"
|
||||
STR_LONG_PRESS_BEHAVIOR: "Долгое нажатие"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "Ничего"
|
||||
@@ -384,3 +383,4 @@ STR_FIRMWARE_WRITE_FAILED: "Ошибка записи прошивки"
|
||||
STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "Не выключайте питание!"
|
||||
STR_RECOVERY_MODE: "Режим восстановления"
|
||||
STR_RECOVERY_MODE_HINT: "Поместите firmware.bin в корень SD-карты и выберите его"
|
||||
STR_MANAGE_THEMES: "Управление темами"
|
||||
|
||||
+373
-374
@@ -1,382 +1,381 @@
|
||||
_language_name: "Slovenčina"
|
||||
_language_code: "SK"
|
||||
_order: "24"
|
||||
|
||||
STR_CROSSPOINT: "CrossPoint"
|
||||
STR_BOOTING: "SPÚŠŤANIE"
|
||||
STR_SLEEPING: "SPÁNOK"
|
||||
STR_ENTERING_SLEEP: "Prechod do režimu spánku"
|
||||
STR_BROWSE_FILES: "Prehliadať súbory"
|
||||
STR_FILE_TRANSFER: "Prenos súborov"
|
||||
STR_SETTINGS_TITLE: "Nastavenia"
|
||||
STR_CONTINUE_READING: "Pokračovať v čítaní"
|
||||
STR_NO_OPEN_BOOK: "Žiadna otvorená kniha"
|
||||
STR_START_READING: "Začnite čítať nižšie"
|
||||
STR_NO_FILES_FOUND: "Neboli nájdené žiadne súbory"
|
||||
STR_SELECT_CHAPTER: "Vybrať kapitolu"
|
||||
STR_NO_CHAPTERS: "Žiadne kapitoly"
|
||||
STR_END_OF_BOOK: "Koniec knihy"
|
||||
STR_EMPTY_CHAPTER: "Prázdna kapitola"
|
||||
STR_INDEXING: "Indexovanie"
|
||||
STR_MEMORY_ERROR: "Chyba pamäte"
|
||||
STR_PAGE_LOAD_ERROR: "Chyba načítania stránky"
|
||||
STR_EMPTY_FILE: "Prázdny súbor"
|
||||
STR_OUT_OF_BOUNDS: "Mimo rozsahu"
|
||||
STR_LOADING: "Načítava sa..."
|
||||
STR_LOADING_POPUP: "Načítavanie"
|
||||
STR_WIFI_NETWORKS: "Wi-Fi siete"
|
||||
STR_NO_NETWORKS: "Nenašli sa žiadne siete"
|
||||
STR_NETWORKS_FOUND: "Nájdených %zu sietí"
|
||||
STR_SCANNING: "Skenovanie..."
|
||||
STR_CONNECTING: "Pripájanie..."
|
||||
STR_CONNECTED: "Pripojené!"
|
||||
STR_CONNECTION_FAILED: "Pripojenie zlyhalo"
|
||||
STR_FORGET_NETWORK: "Zabudnúť sieť?"
|
||||
STR_SAVE_PASSWORD: "Uložiť heslo na nabudúce?"
|
||||
STR_PRESS_OK_SCAN: "Stlačte OK pre opätovné skenovanie"
|
||||
STR_JOIN_NETWORK: "Pripojiť sa k sieti"
|
||||
STR_CREATE_HOTSPOT: "Vytvoriť hotspot"
|
||||
STR_JOIN_DESC: "Pripojiť sa k existujúcej Wi-Fi sieti"
|
||||
STR_HOTSPOT_DESC: "Vytvoriť Wi-Fi sieť, ku ktorej sa môžu pripojiť ostatní"
|
||||
STR_STARTING_HOTSPOT: "Spúšťanie hotspotu..."
|
||||
STR_HOTSPOT_MODE: "Režim hotspotu"
|
||||
STR_CONNECT_WIFI_HINT: "Pripojte svoje zariadenie k tejto Wi-Fi sieti"
|
||||
STR_OPEN_URL_HINT: "Otvorte túto URL adresu vo svojom prehliadači"
|
||||
STR_OR_HTTP_PREFIX: "alebo http://"
|
||||
STR_SCAN_QR_HINT: "alebo naskenujte QR kód telefónom:"
|
||||
STR_CALIBRE_WIRELESS: "Calibre Wireless"
|
||||
STR_NETWORK_LEGEND: "* = Šifrované | + = Uložené"
|
||||
STR_MAC_ADDRESS: "MAC adresa:"
|
||||
STR_CHECKING_WIFI: "Kontrola Wi-Fi..."
|
||||
STR_ENTER_WIFI_PASSWORD: "Zadajte heslo Wi-Fi"
|
||||
STR_TO_PREFIX: "pre"
|
||||
STR_CALIBRE_RECEIVING: "Prijímanie:"
|
||||
STR_CALIBRE_RECEIVED: "Prijaté:"
|
||||
STR_CALIBRE_INSTRUCTION_1: "1) Nainštalujte plugin CrossPoint Reader"
|
||||
STR_CALIBRE_INSTRUCTION_2: "2) Buďte v rovnakej Wi-Fi sieti"
|
||||
STR_CALIBRE_INSTRUCTION_3: "3) V Calibre: „Odoslať do zariadenia“"
|
||||
STR_CALIBRE_INSTRUCTION_4: "„Pri odosielaní nechajte túto obrazovku otvorenú“"
|
||||
STR_CAT_DISPLAY: "Displej"
|
||||
STR_CAT_READER: "Čítačka"
|
||||
STR_CAT_CONTROLS: "Ovládanie"
|
||||
STR_CAT_SYSTEM: "Systém"
|
||||
STR_SLEEP_SCREEN: "Obrazovka spánku"
|
||||
STR_QUICK_RESUME_TIMEOUT: "Rýchle obnovenie pri nečinnosti"
|
||||
STR_SLEEP_COVER_MODE: "Obrazovka spánku – režim krytu"
|
||||
STR_HIDE_BATTERY: "Skryť % batérie"
|
||||
STR_EXTRA_SPACING: "Dodatočné medzery medzi odsekmi"
|
||||
STR_TEXT_AA: "Vyhladzovanie textu"
|
||||
STR_IMAGES: "Obrázky"
|
||||
STR_IMAGES_DISPLAY: "Zobraz"
|
||||
STR_IMAGES_PLACEHOLDER: "Rezervované miesto"
|
||||
STR_IMAGES_SUPPRESS: "Potlačiť"
|
||||
STR_SHORT_PWR_BTN: "Krátke stlačenie tlačidla napájania"
|
||||
STR_ORIENTATION: "Orientácia čítania"
|
||||
STR_SIDE_BTN_LAYOUT: "Rozloženie bočných tlačidiel (čítačka)"
|
||||
STR_TOUCH_READER_CONTROLS: "Dotykové ovládanie čítačky"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Prispôsobiť predné tlačidlá orientácii"
|
||||
STR_LONG_PRESS_BEHAVIOR: "Správanie pri dlhom stlačení tlačidla"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "VYP"
|
||||
STR_LONG_PRESS_BEHAVIOR_SKIP: "Preskočiť kapitolu"
|
||||
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Zmeniť orientáciu"
|
||||
STR_FONT_PREVIEW_TEXT: "Vypätá dcéra grófa Maxwella s IQ nižším ako kôň núti čeľaď hrýzť hŕbu jabĺk"
|
||||
STR_FONT_FAMILY: "Rodina písiem čítačky"
|
||||
STR_FONT_SIZE: "Veľkosť písma rozhrania"
|
||||
STR_LINE_SPACING: "Riadkovanie čítačky"
|
||||
STR_SCREEN_MARGIN: "Okraj obrazovky čítačky"
|
||||
STR_PARA_ALIGNMENT: "Zarovnanie odsekov čítačky"
|
||||
STR_HYPHENATION: "Delenie slov"
|
||||
STR_TIME_TO_SLEEP: "Čas do uspania"
|
||||
STR_SHOW_HIDDEN_FILES: "Zobraz Skryté Súbory"
|
||||
STR_REMOVE_READ_FROM_RECENTS: "Odstrániť prečítané knihy zo zoznamu nedávnych"
|
||||
STR_MOVE_FINISHED_TO_READ: "Presunúť prečítané knihy do priečinka Read"
|
||||
STR_REFRESH_FREQ: "Frekvencia obnovovania"
|
||||
STR_KOREADER_SYNC: "KOReader Sync"
|
||||
STR_CHECK_UPDATES: "Skontrolovať aktualizácie"
|
||||
STR_LANGUAGE: "Jazyk"
|
||||
STR_CLEAR_READING_CACHE: "Vymazať vyrovnávaciu pamäť čítania"
|
||||
STR_USERNAME: "Používateľské meno"
|
||||
STR_PASSWORD: "Heslo"
|
||||
STR_SYNC_SERVER_URL: "URL synchronizačného servera"
|
||||
STR_DOCUMENT_MATCHING: "Párovanie dokumentov"
|
||||
STR_AUTHENTICATE: "Overiť"
|
||||
STR_KOREADER_USERNAME: "Používateľské meno KOReader"
|
||||
STR_KOREADER_PASSWORD: "Heslo KOReader"
|
||||
STR_FILENAME: "Názov súboru"
|
||||
STR_BINARY: "Binárny"
|
||||
STR_SET_CREDENTIALS_FIRST: "Najprv nastavte prihlasovacie údaje"
|
||||
STR_WIFI_CONN_FAILED: "Pripojenie k Wi-Fi zlyhalo"
|
||||
STR_AUTHENTICATING: "Overovanie..."
|
||||
STR_AUTH_SUCCESS: "Overenie úspešné!"
|
||||
STR_KOREADER_AUTH: "Overenie KOReader"
|
||||
STR_SYNC_READY: "Synchronizácia KOReader je pripravená na použitie"
|
||||
STR_AUTH_FAILED: "Overenie zlyhalo"
|
||||
STR_DONE: "Hotovo"
|
||||
STR_CLEAR_CACHE_WARNING_1: "Týmto vymažete všetky údaje kníh vo vyrovnávacej pamäti."
|
||||
STR_CLEAR_CACHE_WARNING_2: "Všetok priebeh čítania bude stratený!"
|
||||
STR_CLEAR_CACHE_WARNING_3: "Knihy bude potrebné znova indexovať"
|
||||
STR_CLEAR_CACHE_WARNING_4: "pri ich opätovnom otvorení."
|
||||
STR_CLEARING_CACHE: "Mazanie vyrovnávacej pamäte..."
|
||||
STR_CACHE_CLEARED: "Vyrovnávacia pamäť vymazaná"
|
||||
STR_ITEMS_REMOVED: "položiek odstránených"
|
||||
STR_FAILED_LOWER: "zlyhalo"
|
||||
STR_CLEAR_CACHE_FAILED: "Vymazanie vyrovnávacej pamäte zlyhalo"
|
||||
STR_CHECK_SERIAL_OUTPUT: "Podrobnosti nájdete v sériovom výstupe"
|
||||
STR_DARK: "Tmavý"
|
||||
STR_LIGHT: "Svetlý"
|
||||
STR_CUSTOM: "Vlastný"
|
||||
STR_COVER: "Obálka"
|
||||
STR_NONE_OPT: "Žiadny"
|
||||
STR_FIT: "Prispôsobiť"
|
||||
STR_CROP: "Orezať"
|
||||
STR_NEVER: "Nikdy"
|
||||
STR_IN_READER: "V čítačke"
|
||||
STR_ALWAYS: "Vždy"
|
||||
STR_IGNORE: "Ignorovať"
|
||||
STR_SLEEP: "Spánok"
|
||||
STR_PAGE_TURN: "Otáčanie stránok"
|
||||
STR_FORCE_REFRESH: "Obnoviť obrazovku"
|
||||
STR_PORTRAIT: "Na výšku"
|
||||
STR_LANDSCAPE_CW: "Na šírku v smere hodinových ručičiek"
|
||||
_language_name: "Slovenčina"
|
||||
_language_code: "SK"
|
||||
_order: "24"
|
||||
|
||||
STR_CROSSPOINT: "CrossPoint"
|
||||
STR_BOOTING: "SPÚŠŤANIE"
|
||||
STR_SLEEPING: "SPÁNOK"
|
||||
STR_ENTERING_SLEEP: "Prechod do režimu spánku"
|
||||
STR_BROWSE_FILES: "Prehliadať súbory"
|
||||
STR_FILE_TRANSFER: "Prenos súborov"
|
||||
STR_SETTINGS_TITLE: "Nastavenia"
|
||||
STR_CONTINUE_READING: "Pokračovať v čítaní"
|
||||
STR_NO_OPEN_BOOK: "Žiadna otvorená kniha"
|
||||
STR_START_READING: "Začnite čítať nižšie"
|
||||
STR_NO_FILES_FOUND: "Neboli nájdené žiadne súbory"
|
||||
STR_SELECT_CHAPTER: "Vybrať kapitolu"
|
||||
STR_NO_CHAPTERS: "Žiadne kapitoly"
|
||||
STR_END_OF_BOOK: "Koniec knihy"
|
||||
STR_EMPTY_CHAPTER: "Prázdna kapitola"
|
||||
STR_INDEXING: "Indexovanie"
|
||||
STR_MEMORY_ERROR: "Chyba pamäte"
|
||||
STR_PAGE_LOAD_ERROR: "Chyba načítania stránky"
|
||||
STR_EMPTY_FILE: "Prázdny súbor"
|
||||
STR_OUT_OF_BOUNDS: "Mimo rozsahu"
|
||||
STR_LOADING: "Načítava sa..."
|
||||
STR_LOADING_POPUP: "Načítavanie"
|
||||
STR_WIFI_NETWORKS: "Wi-Fi siete"
|
||||
STR_NO_NETWORKS: "Nenašli sa žiadne siete"
|
||||
STR_NETWORKS_FOUND: "Nájdených %zu sietí"
|
||||
STR_SCANNING: "Skenovanie..."
|
||||
STR_CONNECTING: "Pripájanie..."
|
||||
STR_CONNECTED: "Pripojené!"
|
||||
STR_CONNECTION_FAILED: "Pripojenie zlyhalo"
|
||||
STR_FORGET_NETWORK: "Zabudnúť sieť?"
|
||||
STR_SAVE_PASSWORD: "Uložiť heslo na nabudúce?"
|
||||
STR_PRESS_OK_SCAN: "Stlačte OK pre opätovné skenovanie"
|
||||
STR_JOIN_NETWORK: "Pripojiť sa k sieti"
|
||||
STR_CREATE_HOTSPOT: "Vytvoriť hotspot"
|
||||
STR_JOIN_DESC: "Pripojiť sa k existujúcej Wi-Fi sieti"
|
||||
STR_HOTSPOT_DESC: "Vytvoriť Wi-Fi sieť, ku ktorej sa môžu pripojiť ostatní"
|
||||
STR_STARTING_HOTSPOT: "Spúšťanie hotspotu..."
|
||||
STR_HOTSPOT_MODE: "Režim hotspotu"
|
||||
STR_CONNECT_WIFI_HINT: "Pripojte svoje zariadenie k tejto Wi-Fi sieti"
|
||||
STR_OPEN_URL_HINT: "Otvorte túto URL adresu vo svojom prehliadači"
|
||||
STR_OR_HTTP_PREFIX: "alebo http://"
|
||||
STR_SCAN_QR_HINT: "alebo naskenujte QR kód telefónom:"
|
||||
STR_CALIBRE_WIRELESS: "Calibre Wireless"
|
||||
STR_NETWORK_LEGEND: "* = Šifrované | + = Uložené"
|
||||
STR_MAC_ADDRESS: "MAC adresa:"
|
||||
STR_CHECKING_WIFI: "Kontrola Wi-Fi..."
|
||||
STR_ENTER_WIFI_PASSWORD: "Zadajte heslo Wi-Fi"
|
||||
STR_TO_PREFIX: "pre"
|
||||
STR_CALIBRE_RECEIVING: "Prijímanie:"
|
||||
STR_CALIBRE_RECEIVED: "Prijaté:"
|
||||
STR_CALIBRE_INSTRUCTION_1: "1) Nainštalujte plugin CrossPoint Reader"
|
||||
STR_CALIBRE_INSTRUCTION_2: "2) Buďte v rovnakej Wi-Fi sieti"
|
||||
STR_CALIBRE_INSTRUCTION_3: "3) V Calibre: „Odoslať do zariadenia“"
|
||||
STR_CALIBRE_INSTRUCTION_4: "„Pri odosielaní nechajte túto obrazovku otvorenú“"
|
||||
STR_CAT_DISPLAY: "Displej"
|
||||
STR_CAT_READER: "Čítačka"
|
||||
STR_CAT_CONTROLS: "Ovládanie"
|
||||
STR_CAT_SYSTEM: "Systém"
|
||||
STR_SLEEP_SCREEN: "Obrazovka spánku"
|
||||
STR_QUICK_RESUME_TIMEOUT: "Rýchle obnovenie pri nečinnosti"
|
||||
STR_SLEEP_COVER_MODE: "Obrazovka spánku – režim krytu"
|
||||
STR_HIDE_BATTERY: "Skryť % batérie"
|
||||
STR_EXTRA_SPACING: "Dodatočné medzery medzi odsekmi"
|
||||
STR_TEXT_AA: "Vyhladzovanie textu"
|
||||
STR_IMAGES: "Obrázky"
|
||||
STR_IMAGES_DISPLAY: "Zobraz"
|
||||
STR_IMAGES_PLACEHOLDER: "Rezervované miesto"
|
||||
STR_IMAGES_SUPPRESS: "Potlačiť"
|
||||
STR_SHORT_PWR_BTN: "Krátke stlačenie tlačidla napájania"
|
||||
STR_ORIENTATION: "Orientácia čítania"
|
||||
STR_SIDE_BTN_LAYOUT: "Rozloženie bočných tlačidiel (čítačka)"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Prispôsobiť predné tlačidlá orientácii"
|
||||
STR_LONG_PRESS_BEHAVIOR: "Správanie pri dlhom stlačení tlačidla"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "VYP"
|
||||
STR_LONG_PRESS_BEHAVIOR_SKIP: "Preskočiť kapitolu"
|
||||
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Zmeniť orientáciu"
|
||||
STR_FONT_PREVIEW_TEXT: "Vypätá dcéra grófa Maxwella s IQ nižším ako kôň núti čeľaď hrýzť hŕbu jabĺk"
|
||||
STR_FONT_FAMILY: "Rodina písiem čítačky"
|
||||
STR_FONT_SIZE: "Veľkosť písma rozhrania"
|
||||
STR_LINE_SPACING: "Riadkovanie čítačky"
|
||||
STR_SCREEN_MARGIN: "Okraj obrazovky čítačky"
|
||||
STR_PARA_ALIGNMENT: "Zarovnanie odsekov čítačky"
|
||||
STR_HYPHENATION: "Delenie slov"
|
||||
STR_TIME_TO_SLEEP: "Čas do uspania"
|
||||
STR_SHOW_HIDDEN_FILES: "Zobraz Skryté Súbory"
|
||||
STR_REMOVE_READ_FROM_RECENTS: "Odstrániť prečítané knihy zo zoznamu nedávnych"
|
||||
STR_MOVE_FINISHED_TO_READ: "Presunúť prečítané knihy do priečinka Read"
|
||||
STR_REFRESH_FREQ: "Frekvencia obnovovania"
|
||||
STR_KOREADER_SYNC: "KOReader Sync"
|
||||
STR_CHECK_UPDATES: "Skontrolovať aktualizácie"
|
||||
STR_LANGUAGE: "Jazyk"
|
||||
STR_CLEAR_READING_CACHE: "Vymazať vyrovnávaciu pamäť čítania"
|
||||
STR_USERNAME: "Používateľské meno"
|
||||
STR_PASSWORD: "Heslo"
|
||||
STR_SYNC_SERVER_URL: "URL synchronizačného servera"
|
||||
STR_DOCUMENT_MATCHING: "Párovanie dokumentov"
|
||||
STR_AUTHENTICATE: "Overiť"
|
||||
STR_KOREADER_USERNAME: "Používateľské meno KOReader"
|
||||
STR_KOREADER_PASSWORD: "Heslo KOReader"
|
||||
STR_FILENAME: "Názov súboru"
|
||||
STR_BINARY: "Binárny"
|
||||
STR_SET_CREDENTIALS_FIRST: "Najprv nastavte prihlasovacie údaje"
|
||||
STR_WIFI_CONN_FAILED: "Pripojenie k Wi-Fi zlyhalo"
|
||||
STR_AUTHENTICATING: "Overovanie..."
|
||||
STR_AUTH_SUCCESS: "Overenie úspešné!"
|
||||
STR_KOREADER_AUTH: "Overenie KOReader"
|
||||
STR_SYNC_READY: "Synchronizácia KOReader je pripravená na použitie"
|
||||
STR_AUTH_FAILED: "Overenie zlyhalo"
|
||||
STR_DONE: "Hotovo"
|
||||
STR_CLEAR_CACHE_WARNING_1: "Týmto vymažete všetky údaje kníh vo vyrovnávacej pamäti."
|
||||
STR_CLEAR_CACHE_WARNING_2: "Všetok priebeh čítania bude stratený!"
|
||||
STR_CLEAR_CACHE_WARNING_3: "Knihy bude potrebné znova indexovať"
|
||||
STR_CLEAR_CACHE_WARNING_4: "pri ich opätovnom otvorení."
|
||||
STR_CLEARING_CACHE: "Mazanie vyrovnávacej pamäte..."
|
||||
STR_CACHE_CLEARED: "Vyrovnávacia pamäť vymazaná"
|
||||
STR_ITEMS_REMOVED: "položiek odstránených"
|
||||
STR_FAILED_LOWER: "zlyhalo"
|
||||
STR_CLEAR_CACHE_FAILED: "Vymazanie vyrovnávacej pamäte zlyhalo"
|
||||
STR_CHECK_SERIAL_OUTPUT: "Podrobnosti nájdete v sériovom výstupe"
|
||||
STR_DARK: "Tmavý"
|
||||
STR_LIGHT: "Svetlý"
|
||||
STR_CUSTOM: "Vlastný"
|
||||
STR_COVER: "Obálka"
|
||||
STR_NONE_OPT: "Žiadny"
|
||||
STR_FIT: "Prispôsobiť"
|
||||
STR_CROP: "Orezať"
|
||||
STR_NEVER: "Nikdy"
|
||||
STR_IN_READER: "V čítačke"
|
||||
STR_ALWAYS: "Vždy"
|
||||
STR_IGNORE: "Ignorovať"
|
||||
STR_SLEEP: "Spánok"
|
||||
STR_PAGE_TURN: "Otáčanie stránok"
|
||||
STR_FORCE_REFRESH: "Obnoviť obrazovku"
|
||||
STR_PORTRAIT: "Na výšku"
|
||||
STR_LANDSCAPE_CW: "Na šírku v smere hodinových ručičiek"
|
||||
STR_INVERTED: "Invertovaný"
|
||||
STR_ORIENTATION_INVERTED: "Na výšku 180°"
|
||||
STR_LANDSCAPE_CCW: "Na šírku proti smeru hodinových ručičiek"
|
||||
STR_PREV_NEXT: "Predchádzajúca/Nasledujúca"
|
||||
STR_NEXT_PREV: "Nasledujúca/Predchádzajúca"
|
||||
STR_DISABLED: "Vypnuté"
|
||||
STR_NOTO_SERIF: "Noto Serif"
|
||||
STR_NOTO_SANS: "Noto Sans"
|
||||
STR_SMALL: "Malý"
|
||||
STR_MEDIUM: "Stredný"
|
||||
STR_LARGE: "Veľký"
|
||||
STR_X_LARGE: "Obrovský"
|
||||
STR_TIGHT: "Tesný"
|
||||
STR_NORMAL: "Normálny"
|
||||
STR_WIDE: "Široký"
|
||||
STR_JUSTIFY: "Zarovnať do bloku"
|
||||
STR_ALIGN_LEFT: "Vľavo"
|
||||
STR_CENTER: "Na stred"
|
||||
STR_ALIGN_RIGHT: "Vpravo"
|
||||
STR_PAGES_1: "1 strana"
|
||||
STR_PAGES_5: "5 strán"
|
||||
STR_PAGES_10: "10 strán"
|
||||
STR_PAGES_15: "15 strán"
|
||||
STR_PAGES_30: "30 strán"
|
||||
STR_UPDATE: "Aktualizácia"
|
||||
STR_CHECKING_UPDATE: "Kontrola aktualizácií…"
|
||||
STR_NEW_UPDATE: "K dispozícii je nová aktualizácia!"
|
||||
STR_CURRENT_VERSION: "Aktuálna verzia:"
|
||||
STR_NEW_VERSION: "Nová verzia:"
|
||||
STR_UPDATING: "Aktualizácia..."
|
||||
STR_NO_UPDATE: "Nie je k dispozícii žiadna aktualizácia"
|
||||
STR_UPDATE_FAILED: "Aktualizácia zlyhala"
|
||||
STR_UPDATE_COMPLETE: "Aktualizácia dokončená"
|
||||
STR_POWER_ON_HINT: "Stlačte a podržte tlačidlo napájania pre zapnutie"
|
||||
STR_RESTARTING_HINT: "Reštartujem... Ak sa zariadenie nereštartuje, podrž tlačidlo na zapnutie niekoľko s."
|
||||
STR_NO_ENTRIES: "Neboli nájdené žiadne položky"
|
||||
STR_DOWNLOADING: "Sťahovanie..."
|
||||
STR_DOWNLOAD_FAILED: "Sťahovanie zlyhalo"
|
||||
STR_ERROR_MSG: "Chyba:"
|
||||
STR_UNNAMED: "Nepomenované"
|
||||
STR_LANDSCAPE_CCW: "Na šírku proti smeru hodinových ručičiek"
|
||||
STR_PREV_NEXT: "Predchádzajúca/Nasledujúca"
|
||||
STR_NEXT_PREV: "Nasledujúca/Predchádzajúca"
|
||||
STR_DISABLED: "Vypnuté"
|
||||
STR_NOTO_SERIF: "Noto Serif"
|
||||
STR_NOTO_SANS: "Noto Sans"
|
||||
STR_SMALL: "Malý"
|
||||
STR_MEDIUM: "Stredný"
|
||||
STR_LARGE: "Veľký"
|
||||
STR_X_LARGE: "Obrovský"
|
||||
STR_TIGHT: "Tesný"
|
||||
STR_NORMAL: "Normálny"
|
||||
STR_WIDE: "Široký"
|
||||
STR_JUSTIFY: "Zarovnať do bloku"
|
||||
STR_ALIGN_LEFT: "Vľavo"
|
||||
STR_CENTER: "Na stred"
|
||||
STR_ALIGN_RIGHT: "Vpravo"
|
||||
STR_PAGES_1: "1 strana"
|
||||
STR_PAGES_5: "5 strán"
|
||||
STR_PAGES_10: "10 strán"
|
||||
STR_PAGES_15: "15 strán"
|
||||
STR_PAGES_30: "30 strán"
|
||||
STR_UPDATE: "Aktualizácia"
|
||||
STR_CHECKING_UPDATE: "Kontrola aktualizácií…"
|
||||
STR_NEW_UPDATE: "K dispozícii je nová aktualizácia!"
|
||||
STR_CURRENT_VERSION: "Aktuálna verzia:"
|
||||
STR_NEW_VERSION: "Nová verzia:"
|
||||
STR_UPDATING: "Aktualizácia..."
|
||||
STR_NO_UPDATE: "Nie je k dispozícii žiadna aktualizácia"
|
||||
STR_UPDATE_FAILED: "Aktualizácia zlyhala"
|
||||
STR_UPDATE_COMPLETE: "Aktualizácia dokončená"
|
||||
STR_POWER_ON_HINT: "Stlačte a podržte tlačidlo napájania pre zapnutie"
|
||||
STR_RESTARTING_HINT: "Reštartujem... Ak sa zariadenie nereštartuje, podrž tlačidlo na zapnutie niekoľko s."
|
||||
STR_NO_ENTRIES: "Neboli nájdené žiadne položky"
|
||||
STR_DOWNLOADING: "Sťahovanie..."
|
||||
STR_DOWNLOAD_FAILED: "Sťahovanie zlyhalo"
|
||||
STR_ERROR_MSG: "Chyba:"
|
||||
STR_UNNAMED: "Nepomenované"
|
||||
STR_HOLD_OPEN_TO_DELETE: "Podržte Otvoriť pre vymazanie"
|
||||
STR_NO_SERVER_URL: "Nie je nakonfigurovaná URL adresa servera"
|
||||
STR_FETCH_FEED_FAILED: "Načítanie kanála zlyhalo"
|
||||
STR_PARSE_FEED_FAILED: "Spracovanie kanála zlyhalo"
|
||||
STR_NEXT_PAGE: "Ďaľsia strana »"
|
||||
STR_PREV_PAGE: "« Predchádzajúca str."
|
||||
STR_NETWORK_PREFIX: "Sieť:"
|
||||
STR_IP_ADDRESS_PREFIX: "IP adresa:"
|
||||
STR_ERROR_GENERAL_FAILURE: "Chyba: Všeobecná chyba"
|
||||
STR_ERROR_NETWORK_NOT_FOUND: "Chyba: Sieť nebola nájdená"
|
||||
STR_ERROR_CONNECTION_TIMEOUT: "Chyba: Časový limit pripojenia"
|
||||
STR_SD_CARD: "SD karta"
|
||||
STR_BACK: "« Späť"
|
||||
STR_EXIT: "« Koniec"
|
||||
STR_HOME: "« Domov"
|
||||
STR_SELECT: "Vybrať"
|
||||
STR_SELECTED: "Vybrané"
|
||||
STR_NO_SERVER_URL: "Nie je nakonfigurovaná URL adresa servera"
|
||||
STR_FETCH_FEED_FAILED: "Načítanie kanála zlyhalo"
|
||||
STR_PARSE_FEED_FAILED: "Spracovanie kanála zlyhalo"
|
||||
STR_NEXT_PAGE: "Ďaľsia strana »"
|
||||
STR_PREV_PAGE: "« Predchádzajúca str."
|
||||
STR_NETWORK_PREFIX: "Sieť:"
|
||||
STR_IP_ADDRESS_PREFIX: "IP adresa:"
|
||||
STR_ERROR_GENERAL_FAILURE: "Chyba: Všeobecná chyba"
|
||||
STR_ERROR_NETWORK_NOT_FOUND: "Chyba: Sieť nebola nájdená"
|
||||
STR_ERROR_CONNECTION_TIMEOUT: "Chyba: Časový limit pripojenia"
|
||||
STR_SD_CARD: "SD karta"
|
||||
STR_BACK: "« Späť"
|
||||
STR_EXIT: "« Koniec"
|
||||
STR_HOME: "« Domov"
|
||||
STR_SELECT: "Vybrať"
|
||||
STR_SELECTED: "Vybrané"
|
||||
STR_TOGGLE: "Prepnúť"
|
||||
STR_TOGGLE_BOOKMARK: "Prepnúť záložku"
|
||||
STR_CONFIRM: "Potvrdiť"
|
||||
STR_CANCEL: "Zrušiť"
|
||||
STR_CONNECT: "Pripojiť"
|
||||
STR_OPEN: "Otvoriť"
|
||||
STR_DOWNLOAD: "Stiahnuť"
|
||||
STR_RETRY: "Skúsiť znova"
|
||||
STR_YES: "Áno"
|
||||
STR_NO: "Nie"
|
||||
STR_SHOW: "Zobraz"
|
||||
STR_HIDE: "Skryť"
|
||||
STR_STATE_ON: "ZAP"
|
||||
STR_STATE_OFF: "VYP"
|
||||
STR_NOT_SET: "Nenastavené"
|
||||
STR_DIR_LEFT: "Vľavo"
|
||||
STR_DIR_RIGHT: "Vpravo"
|
||||
STR_DIR_UP: "Hore"
|
||||
STR_DIR_DOWN: "Dole"
|
||||
STR_OK_BUTTON: "OK"
|
||||
STR_SLEEP_COVER_FILTER: "Filter obrazovky spánku"
|
||||
STR_FILTER_CONTRAST: "Kontrast"
|
||||
STR_CUSTOMISE_STATUS_BAR: "Uprav status bar"
|
||||
STR_CHAPTER_PAGE_COUNT: "Počítadlo strán kapitoly"
|
||||
STR_BOOK_PROGRESS_PERCENTAGE: "Prečítané percent"
|
||||
STR_PROGRESS_BAR: "Ukazovateľ čítania"
|
||||
STR_PROGRESS_BAR_THICKNESS: "Hrúbka indikátora priebehu"
|
||||
STR_PROGRESS_BAR_THIN: "Tenký"
|
||||
STR_PROGRESS_BAR_MEDIUM: "Stredný"
|
||||
STR_PROGRESS_BAR_THICK: "Hrubý"
|
||||
STR_BOOK: "Kniha"
|
||||
STR_CHAPTER: "Kapitola"
|
||||
STR_EXAMPLE_CHAPTER: "Kapitola 21"
|
||||
STR_EXAMPLE_BOOK: "Názov knihy"
|
||||
STR_PREVIEW: "Ukážka"
|
||||
STR_TITLE: "Názov"
|
||||
STR_BATTERY: "Batéria"
|
||||
STR_XTC_STATUS_BAR: "Stavový panel XTC"
|
||||
STR_BOTTOM: "Dole"
|
||||
STR_TOP: "Hore"
|
||||
STR_CLOCK: "Hodiny"
|
||||
STR_CLOCK_UTC_OFFSET: "UTC posun hodín"
|
||||
STR_CLOCK_FORMAT: "Formát času"
|
||||
STR_CLOCK_FORMAT_24H: "24-hodinový"
|
||||
STR_CLOCK_FORMAT_12H: "12-hodinový"
|
||||
STR_CURRENT_TIME: "Aktuálny čas:"
|
||||
STR_NEXT_FIELD: "Ďalej"
|
||||
STR_CLOCK_SYNC: "Synchronizácia hodín"
|
||||
STR_CLOCK_SYNC_NOW: "Synchronizovať teraz"
|
||||
STR_CLOCK_SYNCING: "Synchronizácia cez NTP..."
|
||||
STR_CLOCK_SYNC_OK: "Hodiny synchronizované"
|
||||
STR_CLOCK_SYNC_FAIL: "Synchronizácia zlyhala"
|
||||
STR_CLOCK_SYNC_NO_WIFI: "Wi-Fi nie je pripojená"
|
||||
STR_CLOCK_SYNC_NO_WIFI_HINT: "Najprv sa pripojte k Wi-Fi a potom skúste znova."
|
||||
STR_CLOCK_SYNCED: "Hodiny synchronizované"
|
||||
STR_UI_THEME: "Téma rozhrania"
|
||||
STR_THEME_CLASSIC: "Klasická"
|
||||
STR_THEME_LYRA: "Lyra"
|
||||
STR_THEME_ROUNDEDRAFF: "RoundedRaff"
|
||||
STR_THEME_LYRA_EXTENDED: "Lyra Extended"
|
||||
STR_SUNLIGHT_FADING_FIX: "Oprava blednutia na slnku"
|
||||
STR_REMAP_FRONT_BUTTONS: "Premapovať predné tlačidlá"
|
||||
STR_CONFIRM: "Potvrdiť"
|
||||
STR_CANCEL: "Zrušiť"
|
||||
STR_CONNECT: "Pripojiť"
|
||||
STR_OPEN: "Otvoriť"
|
||||
STR_DOWNLOAD: "Stiahnuť"
|
||||
STR_RETRY: "Skúsiť znova"
|
||||
STR_YES: "Áno"
|
||||
STR_NO: "Nie"
|
||||
STR_SHOW: "Zobraz"
|
||||
STR_HIDE: "Skryť"
|
||||
STR_STATE_ON: "ZAP"
|
||||
STR_STATE_OFF: "VYP"
|
||||
STR_NOT_SET: "Nenastavené"
|
||||
STR_DIR_LEFT: "Vľavo"
|
||||
STR_DIR_RIGHT: "Vpravo"
|
||||
STR_DIR_UP: "Hore"
|
||||
STR_DIR_DOWN: "Dole"
|
||||
STR_OK_BUTTON: "OK"
|
||||
STR_SLEEP_COVER_FILTER: "Filter obrazovky spánku"
|
||||
STR_FILTER_CONTRAST: "Kontrast"
|
||||
STR_CUSTOMISE_STATUS_BAR: "Uprav status bar"
|
||||
STR_CHAPTER_PAGE_COUNT: "Počítadlo strán kapitoly"
|
||||
STR_BOOK_PROGRESS_PERCENTAGE: "Prečítané percent"
|
||||
STR_PROGRESS_BAR: "Ukazovateľ čítania"
|
||||
STR_PROGRESS_BAR_THICKNESS: "Hrúbka indikátora priebehu"
|
||||
STR_PROGRESS_BAR_THIN: "Tenký"
|
||||
STR_PROGRESS_BAR_MEDIUM: "Stredný"
|
||||
STR_PROGRESS_BAR_THICK: "Hrubý"
|
||||
STR_BOOK: "Kniha"
|
||||
STR_CHAPTER: "Kapitola"
|
||||
STR_EXAMPLE_CHAPTER: "Kapitola 21"
|
||||
STR_EXAMPLE_BOOK: "Názov knihy"
|
||||
STR_PREVIEW: "Ukážka"
|
||||
STR_TITLE: "Názov"
|
||||
STR_BATTERY: "Batéria"
|
||||
STR_XTC_STATUS_BAR: "Stavový panel XTC"
|
||||
STR_BOTTOM: "Dole"
|
||||
STR_TOP: "Hore"
|
||||
STR_CLOCK: "Hodiny"
|
||||
STR_CLOCK_UTC_OFFSET: "UTC posun hodín"
|
||||
STR_CLOCK_FORMAT: "Formát času"
|
||||
STR_CLOCK_FORMAT_24H: "24-hodinový"
|
||||
STR_CLOCK_FORMAT_12H: "12-hodinový"
|
||||
STR_CURRENT_TIME: "Aktuálny čas:"
|
||||
STR_NEXT_FIELD: "Ďalej"
|
||||
STR_CLOCK_SYNC: "Synchronizácia hodín"
|
||||
STR_CLOCK_SYNC_NOW: "Synchronizovať teraz"
|
||||
STR_CLOCK_SYNCING: "Synchronizácia cez NTP..."
|
||||
STR_CLOCK_SYNC_OK: "Hodiny synchronizované"
|
||||
STR_CLOCK_SYNC_FAIL: "Synchronizácia zlyhala"
|
||||
STR_CLOCK_SYNC_NO_WIFI: "Wi-Fi nie je pripojená"
|
||||
STR_CLOCK_SYNC_NO_WIFI_HINT: "Najprv sa pripojte k Wi-Fi a potom skúste znova."
|
||||
STR_CLOCK_SYNCED: "Hodiny synchronizované"
|
||||
STR_UI_THEME: "Téma rozhrania"
|
||||
STR_THEME_CLASSIC: "Klasická"
|
||||
STR_THEME_LYRA: "Lyra"
|
||||
STR_THEME_ROUNDEDRAFF: "RoundedRaff"
|
||||
STR_THEME_LYRA_EXTENDED: "Lyra Extended"
|
||||
STR_SUNLIGHT_FADING_FIX: "Oprava blednutia na slnku"
|
||||
STR_REMAP_FRONT_BUTTONS: "Premapovať predné tlačidlá"
|
||||
STR_BOOKMARKS: "Záložky"
|
||||
STR_BOOKMARK_ADDED: "Záložka pridaná."
|
||||
STR_BOOKMARK_REMOVED: "Záložka odstránená."
|
||||
STR_OPDS_BROWSER: "Prehliadač OPDS"
|
||||
STR_SEARCH: "Hľadať"
|
||||
STR_COVER_CUSTOM: "Obálka + Vlastné"
|
||||
STR_QUICK_RESUME: "Rýchle obnovenie"
|
||||
STR_MENU_RECENT_BOOKS: "Nedávne knihy"
|
||||
STR_REMOVE_FROM_RECENTS: "Odstrániť z nedávnych kníh?"
|
||||
STR_NO_RECENT_BOOKS: "Žiadne nedávne knihy"
|
||||
STR_CALIBRE_DESC: "Používať bezdrôtové prenosy zariadení Calibre"
|
||||
STR_FORGET_AND_REMOVE: "Zabudnúť sieť a odstrániť uložené heslo?"
|
||||
STR_FORGET_BUTTON: "Zabudnúť"
|
||||
STR_CALIBRE_STARTING: "Spúšťanie Calibre..."
|
||||
STR_CALIBRE_SETUP: "Nastavenie"
|
||||
STR_CALIBRE_STATUS: "Stav"
|
||||
STR_CLEAR_BUTTON: "Vymazať"
|
||||
STR_DEFAULT_VALUE: "Predvolené"
|
||||
STR_REMAP_PROMPT: "Stlačte predné tlačidlo pre každú funkciu"
|
||||
STR_UNASSIGNED: "Nepriradené"
|
||||
STR_ALREADY_ASSIGNED: "Už priradené"
|
||||
STR_REMAP_RESET_HINT: "Bočné tlačidlo Hore: Obnoviť predvolené rozloženie"
|
||||
STR_REMAP_CANCEL_HINT: "Bočné tlačidlo Dole: Zrušiť premapovanie"
|
||||
STR_HW_BACK_LABEL: "Späť (1. tlačidlo)"
|
||||
STR_HW_CONFIRM_LABEL: "Potvrdiť (2. tlačidlo)"
|
||||
STR_HW_LEFT_LABEL: "Vľavo (3. tlačidlo)"
|
||||
STR_HW_RIGHT_LABEL: "Vpravo (4. tlačidlo)"
|
||||
STR_GO_TO_PERCENT: "Prejsť na %"
|
||||
STR_GO_HOME_BUTTON: "Prejsť na Domov"
|
||||
STR_SYNC_PROGRESS: "Priebeh synchronizácie"
|
||||
STR_DELETE_CACHE: "Vymazať vyrovnávaciu pamäť knihy"
|
||||
STR_DELETE: "Vymazať"
|
||||
STR_CONFIRM_DELETE_BOOKMARK: "Vymazať záložku?"
|
||||
STR_DISPLAY_QR: "Zobraz stránku ako QR"
|
||||
STR_CHAPTER_PREFIX: "Kapitola:"
|
||||
STR_PAGES_SEPARATOR: "strán |"
|
||||
STR_BOOK_PREFIX: "Kniha:"
|
||||
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_CALC_HASH: "Výpočet hashu dokumentu..."
|
||||
STR_HASH_FAILED: "Nepodarilo sa vypočítať hash dokumentu"
|
||||
STR_FETCH_PROGRESS: "Načítavanie vzdialeného priebehu..."
|
||||
STR_UPLOAD_PROGRESS: "Nahrávanie priebehu..."
|
||||
STR_NO_CREDENTIALS_MSG: "Prihlasovacie údaje nie sú nastavené"
|
||||
STR_KOREADER_SETUP_HINT: "Nastavte účet KOReader v Nastaveniach"
|
||||
STR_PROGRESS_FOUND: "Priebeh nájdený!"
|
||||
STR_REMOTE_LABEL: "Vzdialený:"
|
||||
STR_LOCAL_LABEL: "Lokálny:"
|
||||
STR_PAGE_OVERALL_FORMAT: "Strana %d, celkovo %.2f%%"
|
||||
STR_PAGE_TOTAL_OVERALL_FORMAT: "Strana %d/%d, celkovo %.2f%%"
|
||||
STR_DEVICE_FROM_FORMAT: " Zo zariadenia: %s"
|
||||
STR_APPLY_REMOTE: "Použiť vzdialený priebeh"
|
||||
STR_UPLOAD_LOCAL: "Nahrať lokálny priebeh"
|
||||
STR_NO_REMOTE_MSG: "Nenašiel sa žiadny vzdialený priebeh"
|
||||
STR_UPLOAD_PROMPT: "Nahrať aktuálnu pozíciu?"
|
||||
STR_UPLOAD_SUCCESS: "Priebeh nahraný!"
|
||||
STR_SYNC_FAILED_MSG: "Synchronizácia zlyhala"
|
||||
STR_SAVE_PROGRESS_FAILED: "Nepodarilo sa uložiť priebeh"
|
||||
STR_SECTION_PREFIX: "Sekcia"
|
||||
STR_UPLOAD: "Nahrať"
|
||||
STR_BOOK_S_STYLE: "Štýl knihy"
|
||||
STR_EMBEDDED_STYLE: "Vložený štýl"
|
||||
STR_FOCUS_READING: "Sústredené čítanie"
|
||||
STR_OPDS_SERVER_URL: "URL adresa OPDS servera"
|
||||
STR_SET_SLEEP_COVER: "Nastav obal"
|
||||
STR_FOOTNOTES: "Poznámky pod čiarou"
|
||||
STR_NO_FOOTNOTES: "Žiadne poznámky pod čiarou"
|
||||
STR_LINK: "[link]"
|
||||
STR_SCREENSHOT_BUTTON: "Urobiť snímku obrazovky"
|
||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
||||
STR_SLEEP_NEVER: "Nikdy"
|
||||
STR_SLEEP_TIMER_STEP_HINT: "Vľavo/Vpravo: 1 min Hore/Dole: 5 min"
|
||||
STR_ADD_SERVER: "Pridať server"
|
||||
STR_SERVER_NAME: "Názov servera"
|
||||
STR_NO_SERVERS: "Nie sú nakonfigurované žiadne OPDS servery"
|
||||
STR_DELETE_SERVER: "Odstrániť server"
|
||||
STR_OPDS_SERVERS: "OPDS servery"
|
||||
STR_AUTO_TURN_ENABLED: "Automatické otáčanie strán zapnuté: "
|
||||
STR_AUTO_TURN_PAGES_PER_MIN: "Automatické otáčanie (strán za minútu)"
|
||||
STR_MANAGE_FONTS: "Správa písiem"
|
||||
STR_FONT_BROWSER: "Prehliadač písiem"
|
||||
STR_LOADING_FONT_LIST: "Načítava sa zoznam písiem..."
|
||||
STR_NO_FONTS_AVAILABLE: "Nie sú dostupné žiadne písma"
|
||||
STR_FONT_INSTALLED: "Písmo nainštalované!"
|
||||
STR_FONT_INSTALL_FAILED: "Inštalácia písma zlyhala"
|
||||
STR_INSTALLED: "Nainštalované"
|
||||
STR_DOWNLOAD_ALL: "Stiahnuť všetko"
|
||||
STR_UPDATE_ALL: "Aktualizovať všetko"
|
||||
STR_UPDATE_AVAILABLE: "Aktualizovať"
|
||||
STR_CRASH_TITLE: "Zlyhanie systému"
|
||||
STR_CRASH_DESCRIPTION: "Podrobná správa bola uložená do súboru crash_report.txt. Priložte tento súbor k hláseniu chyby."
|
||||
STR_CRASH_REASON: "Dôvod zlyhania:"
|
||||
STR_CRASH_NO_REASON: "(Nebola zaznamenaná žiadna príčina)"
|
||||
STR_TILT_PAGE_TURN: "Otáčanie strán naklonením"
|
||||
STR_KB_HINT_MOVE_CURSOR: "Stlačte VĽAVO alebo VPRAVO pre presun kurzora"
|
||||
STR_KB_HINT_RETURN_CURSOR: "Stlačte VĽAVO pre návrat na pozíciu kurzora"
|
||||
STR_KB_HINT_HIDE_PASSWORD: "Podržte VPRAVO a potom stlačte [***] na skrytie hesla"
|
||||
STR_KB_HINT_SHOW_PASSWORD: "Podržte VPRAVO a potom stlačte [abc] na zobrazenie hesla"
|
||||
STR_KB_HINT_TOGGLE_HIDE_PASSWORD: "Stlačte [***] na skrytie hesla"
|
||||
STR_KB_HINT_TOGGLE_SHOW_PASSWORD: "Stlačte [abc] na zobrazenie hesla"
|
||||
STR_KB_HINT_EDIT_ENTRY: "Podržte HORE pre úpravu položky"
|
||||
STR_KB_TIPS: "Tipy:"
|
||||
STR_KB_HINT_RETURN_KEYBOARD: "Stlačte DOLE pre návrat ku klávesnici"
|
||||
STR_KB_HINT_EXIT_URL_MODE: "Stlačte ABC pre ukončenie režimu URL"
|
||||
STR_KB_HINT_CLEAR_TEXT: "Podržte DEL pre vymazanie celého textu"
|
||||
STR_KB_HINT_SECONDARY_CHAR: "Podržte SELECT pre alternatívny znak"
|
||||
STR_KB_HINT_UPPER_SECONDARY: "Podržte SELECT pre VEĽKÉ písmeno alebo alternatívny znak"
|
||||
STR_KB_HINT_LOWER_SECONDARY: "Podržte SELECT pre malé písmeno alebo alternatívny znak"
|
||||
STR_KB_HINT_URL_SNIPPETS: "Stlačte URL pre úryvky"
|
||||
STR_SD_FIRMWARE_UPDATE: "Aktualizácia firmvéru z SD karty"
|
||||
STR_SELECT_FIRMWARE_FILE: "Vyberte súbor firmvéru (.bin)"
|
||||
STR_NO_BIN_FILES: "Nenašli sa žiadne súbory .bin"
|
||||
STR_VALIDATING_FIRMWARE: "Overuje sa firmvér..."
|
||||
STR_INVALID_FIRMWARE: "Neplatný súbor firmvéru"
|
||||
STR_FIRMWARE_TOO_LARGE: "Firmvér je príliš veľký pre partíciu"
|
||||
STR_FIRMWARE_TOO_SMALL: "Súbor firmvéru je príliš malý"
|
||||
STR_FIRMWARE_UPDATE_PROMPT: "Aktualizovať firmvér?"
|
||||
STR_FIRMWARE_FILE_OPEN_FAILED: "Nie je možné otvoriť súbor"
|
||||
STR_FIRMWARE_WRITE_FAILED: "Zápis firmvéru zlyhal"
|
||||
STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "Nevypínajte zariadenie!"
|
||||
STR_RECOVERY_MODE: "Režim obnovenia"
|
||||
STR_RECOVERY_MODE_HINT: "Umiestnite firmware.bin do koreňového adresára SD karty a vyberte ho"
|
||||
STR_OPDS_BROWSER: "Prehliadač OPDS"
|
||||
STR_SEARCH: "Hľadať"
|
||||
STR_COVER_CUSTOM: "Obálka + Vlastné"
|
||||
STR_QUICK_RESUME: "Rýchle obnovenie"
|
||||
STR_MENU_RECENT_BOOKS: "Nedávne knihy"
|
||||
STR_REMOVE_FROM_RECENTS: "Odstrániť z nedávnych kníh?"
|
||||
STR_NO_RECENT_BOOKS: "Žiadne nedávne knihy"
|
||||
STR_CALIBRE_DESC: "Používať bezdrôtové prenosy zariadení Calibre"
|
||||
STR_FORGET_AND_REMOVE: "Zabudnúť sieť a odstrániť uložené heslo?"
|
||||
STR_FORGET_BUTTON: "Zabudnúť"
|
||||
STR_CALIBRE_STARTING: "Spúšťanie Calibre..."
|
||||
STR_CALIBRE_SETUP: "Nastavenie"
|
||||
STR_CALIBRE_STATUS: "Stav"
|
||||
STR_CLEAR_BUTTON: "Vymazať"
|
||||
STR_DEFAULT_VALUE: "Predvolené"
|
||||
STR_REMAP_PROMPT: "Stlačte predné tlačidlo pre každú funkciu"
|
||||
STR_UNASSIGNED: "Nepriradené"
|
||||
STR_ALREADY_ASSIGNED: "Už priradené"
|
||||
STR_REMAP_RESET_HINT: "Bočné tlačidlo Hore: Obnoviť predvolené rozloženie"
|
||||
STR_REMAP_CANCEL_HINT: "Bočné tlačidlo Dole: Zrušiť premapovanie"
|
||||
STR_HW_BACK_LABEL: "Späť (1. tlačidlo)"
|
||||
STR_HW_CONFIRM_LABEL: "Potvrdiť (2. tlačidlo)"
|
||||
STR_HW_LEFT_LABEL: "Vľavo (3. tlačidlo)"
|
||||
STR_HW_RIGHT_LABEL: "Vpravo (4. tlačidlo)"
|
||||
STR_GO_TO_PERCENT: "Prejsť na %"
|
||||
STR_GO_HOME_BUTTON: "Prejsť na Domov"
|
||||
STR_SYNC_PROGRESS: "Priebeh synchronizácie"
|
||||
STR_DELETE_CACHE: "Vymazať vyrovnávaciu pamäť knihy"
|
||||
STR_DELETE: "Vymazať"
|
||||
STR_CONFIRM_DELETE_BOOKMARK: "Vymazať záložku?"
|
||||
STR_DISPLAY_QR: "Zobraz stránku ako QR"
|
||||
STR_CHAPTER_PREFIX: "Kapitola:"
|
||||
STR_PAGES_SEPARATOR: "strán |"
|
||||
STR_BOOK_PREFIX: "Kniha:"
|
||||
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_CALC_HASH: "Výpočet hashu dokumentu..."
|
||||
STR_HASH_FAILED: "Nepodarilo sa vypočítať hash dokumentu"
|
||||
STR_FETCH_PROGRESS: "Načítavanie vzdialeného priebehu..."
|
||||
STR_UPLOAD_PROGRESS: "Nahrávanie priebehu..."
|
||||
STR_NO_CREDENTIALS_MSG: "Prihlasovacie údaje nie sú nastavené"
|
||||
STR_KOREADER_SETUP_HINT: "Nastavte účet KOReader v Nastaveniach"
|
||||
STR_PROGRESS_FOUND: "Priebeh nájdený!"
|
||||
STR_REMOTE_LABEL: "Vzdialený:"
|
||||
STR_LOCAL_LABEL: "Lokálny:"
|
||||
STR_PAGE_OVERALL_FORMAT: "Strana %d, celkovo %.2f%%"
|
||||
STR_PAGE_TOTAL_OVERALL_FORMAT: "Strana %d/%d, celkovo %.2f%%"
|
||||
STR_DEVICE_FROM_FORMAT: " Zo zariadenia: %s"
|
||||
STR_APPLY_REMOTE: "Použiť vzdialený priebeh"
|
||||
STR_UPLOAD_LOCAL: "Nahrať lokálny priebeh"
|
||||
STR_NO_REMOTE_MSG: "Nenašiel sa žiadny vzdialený priebeh"
|
||||
STR_UPLOAD_PROMPT: "Nahrať aktuálnu pozíciu?"
|
||||
STR_UPLOAD_SUCCESS: "Priebeh nahraný!"
|
||||
STR_SYNC_FAILED_MSG: "Synchronizácia zlyhala"
|
||||
STR_SAVE_PROGRESS_FAILED: "Nepodarilo sa uložiť priebeh"
|
||||
STR_SECTION_PREFIX: "Sekcia"
|
||||
STR_UPLOAD: "Nahrať"
|
||||
STR_BOOK_S_STYLE: "Štýl knihy"
|
||||
STR_EMBEDDED_STYLE: "Vložený štýl"
|
||||
STR_FOCUS_READING: "Sústredené čítanie"
|
||||
STR_OPDS_SERVER_URL: "URL adresa OPDS servera"
|
||||
STR_SET_SLEEP_COVER: "Nastav obal"
|
||||
STR_FOOTNOTES: "Poznámky pod čiarou"
|
||||
STR_NO_FOOTNOTES: "Žiadne poznámky pod čiarou"
|
||||
STR_LINK: "[link]"
|
||||
STR_SCREENSHOT_BUTTON: "Urobiť snímku obrazovky"
|
||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
||||
STR_SLEEP_NEVER: "Nikdy"
|
||||
STR_SLEEP_TIMER_STEP_HINT: "Vľavo/Vpravo: 1 min Hore/Dole: 5 min"
|
||||
STR_ADD_SERVER: "Pridať server"
|
||||
STR_SERVER_NAME: "Názov servera"
|
||||
STR_NO_SERVERS: "Nie sú nakonfigurované žiadne OPDS servery"
|
||||
STR_DELETE_SERVER: "Odstrániť server"
|
||||
STR_OPDS_SERVERS: "OPDS servery"
|
||||
STR_AUTO_TURN_ENABLED: "Automatické otáčanie strán zapnuté: "
|
||||
STR_AUTO_TURN_PAGES_PER_MIN: "Automatické otáčanie (strán za minútu)"
|
||||
STR_MANAGE_FONTS: "Správa písiem"
|
||||
STR_FONT_BROWSER: "Prehliadač písiem"
|
||||
STR_LOADING_FONT_LIST: "Načítava sa zoznam písiem..."
|
||||
STR_NO_FONTS_AVAILABLE: "Nie sú dostupné žiadne písma"
|
||||
STR_FONT_INSTALLED: "Písmo nainštalované!"
|
||||
STR_FONT_INSTALL_FAILED: "Inštalácia písma zlyhala"
|
||||
STR_INSTALLED: "Nainštalované"
|
||||
STR_DOWNLOAD_ALL: "Stiahnuť všetko"
|
||||
STR_UPDATE_ALL: "Aktualizovať všetko"
|
||||
STR_UPDATE_AVAILABLE: "Aktualizovať"
|
||||
STR_CRASH_TITLE: "Zlyhanie systému"
|
||||
STR_CRASH_DESCRIPTION: "Podrobná správa bola uložená do súboru crash_report.txt. Priložte tento súbor k hláseniu chyby."
|
||||
STR_CRASH_REASON: "Dôvod zlyhania:"
|
||||
STR_CRASH_NO_REASON: "(Nebola zaznamenaná žiadna príčina)"
|
||||
STR_TILT_PAGE_TURN: "Otáčanie strán naklonením"
|
||||
STR_KB_HINT_MOVE_CURSOR: "Stlačte VĽAVO alebo VPRAVO pre presun kurzora"
|
||||
STR_KB_HINT_RETURN_CURSOR: "Stlačte VĽAVO pre návrat na pozíciu kurzora"
|
||||
STR_KB_HINT_HIDE_PASSWORD: "Podržte VPRAVO a potom stlačte [***] na skrytie hesla"
|
||||
STR_KB_HINT_SHOW_PASSWORD: "Podržte VPRAVO a potom stlačte [abc] na zobrazenie hesla"
|
||||
STR_KB_HINT_TOGGLE_HIDE_PASSWORD: "Stlačte [***] na skrytie hesla"
|
||||
STR_KB_HINT_TOGGLE_SHOW_PASSWORD: "Stlačte [abc] na zobrazenie hesla"
|
||||
STR_KB_HINT_EDIT_ENTRY: "Podržte HORE pre úpravu položky"
|
||||
STR_KB_TIPS: "Tipy:"
|
||||
STR_KB_HINT_RETURN_KEYBOARD: "Stlačte DOLE pre návrat ku klávesnici"
|
||||
STR_KB_HINT_EXIT_URL_MODE: "Stlačte ABC pre ukončenie režimu URL"
|
||||
STR_KB_HINT_CLEAR_TEXT: "Podržte DEL pre vymazanie celého textu"
|
||||
STR_KB_HINT_SECONDARY_CHAR: "Podržte SELECT pre alternatívny znak"
|
||||
STR_KB_HINT_UPPER_SECONDARY: "Podržte SELECT pre VEĽKÉ písmeno alebo alternatívny znak"
|
||||
STR_KB_HINT_LOWER_SECONDARY: "Podržte SELECT pre malé písmeno alebo alternatívny znak"
|
||||
STR_KB_HINT_URL_SNIPPETS: "Stlačte URL pre úryvky"
|
||||
STR_SD_FIRMWARE_UPDATE: "Aktualizácia firmvéru z SD karty"
|
||||
STR_SELECT_FIRMWARE_FILE: "Vyberte súbor firmvéru (.bin)"
|
||||
STR_NO_BIN_FILES: "Nenašli sa žiadne súbory .bin"
|
||||
STR_VALIDATING_FIRMWARE: "Overuje sa firmvér..."
|
||||
STR_INVALID_FIRMWARE: "Neplatný súbor firmvéru"
|
||||
STR_FIRMWARE_TOO_LARGE: "Firmvér je príliš veľký pre partíciu"
|
||||
STR_FIRMWARE_TOO_SMALL: "Súbor firmvéru je príliš malý"
|
||||
STR_FIRMWARE_UPDATE_PROMPT: "Aktualizovať firmvér?"
|
||||
STR_FIRMWARE_FILE_OPEN_FAILED: "Nie je možné otvoriť súbor"
|
||||
STR_FIRMWARE_WRITE_FAILED: "Zápis firmvéru zlyhal"
|
||||
STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "Nevypínajte zariadenie!"
|
||||
STR_RECOVERY_MODE: "Režim obnovenia"
|
||||
STR_RECOVERY_MODE_HINT: "Umiestnite firmware.bin do koreňového adresára SD karty a vyberte ho"
|
||||
|
||||
@@ -72,7 +72,6 @@ STR_IMAGES_SUPPRESS: "Zatdi"
|
||||
STR_SHORT_PWR_BTN: "Kratek pritisk na gumb za vklop"
|
||||
STR_ORIENTATION: "Orientacija branja"
|
||||
STR_SIDE_BTN_LAYOUT: "Razpored stranskih gumbov"
|
||||
STR_TOUCH_READER_CONTROLS: "Dotikalni nadzor (bralnik)"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Usmeri sprednje gumbe"
|
||||
STR_LONG_PRESS_SKIP: "Dolgi pritisk za preskok poglavja"
|
||||
STR_FONT_PREVIEW_TEXT: "V kožuščku hudobnega fanta stopiclja mizar"
|
||||
@@ -301,3 +300,4 @@ STR_SCREENSHOT_BUTTON: "Naredi posnetek zaslona"
|
||||
STR_AUTO_TURN_ENABLED: "Samodejno obračanje: "
|
||||
STR_AUTO_TURN_PAGES_PER_MIN: "Samodejno obračanje (strani na minuto)"
|
||||
STR_TILT_PAGE_TURN: "Obračanje s priklonom"
|
||||
STR_MANAGE_THEMES: "Upravljanje tem"
|
||||
|
||||
@@ -72,7 +72,6 @@ STR_IMAGES_SUPPRESS: "Ocultar"
|
||||
STR_SHORT_PWR_BTN: "Toque corto del encendido"
|
||||
STR_ORIENTATION: "Orientación"
|
||||
STR_SIDE_BTN_LAYOUT: "Función botones laterales (lector)"
|
||||
STR_TOUCH_READER_CONTROLS: "Controles táctiles (lector)"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Orientar botones frontales"
|
||||
STR_LONG_PRESS_BEHAVIOR: "Al mantener pulsado un botón"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "No hacer nada"
|
||||
@@ -384,3 +383,4 @@ STR_FIRMWARE_WRITE_FAILED: "Falló la escritura del firmware"
|
||||
STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "¡No apague el dispositivo!"
|
||||
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_MANAGE_THEMES: "Gestionar temas"
|
||||
|
||||
@@ -73,7 +73,6 @@ STR_IMAGES_SUPPRESS: "Dölj"
|
||||
STR_SHORT_PWR_BTN: "Kort strömknappsklick"
|
||||
STR_ORIENTATION: "Läsrikting"
|
||||
STR_SIDE_BTN_LAYOUT: "Sidoknappslayout (Läsare)"
|
||||
STR_TOUCH_READER_CONTROLS: "Pekkontroller (läsare)"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Rikta främre knappar"
|
||||
STR_LONG_PRESS_BEHAVIOR: "Beteende vid lång knapptryckning"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "AV"
|
||||
@@ -381,3 +380,4 @@ STR_FIRMWARE_WRITE_FAILED: "Skrivning till firmware misslyckades"
|
||||
STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "Stäng inte av!"
|
||||
STR_RECOVERY_MODE: "Återställningsläge"
|
||||
STR_RECOVERY_MODE_HINT: "Placera firmware.bin i SD-kortroten och välj den"
|
||||
STR_MANAGE_THEMES: "Hantera teman"
|
||||
|
||||
@@ -67,7 +67,6 @@ STR_TEXT_AA: "Metin Yumuşatma (AA)"
|
||||
STR_SHORT_PWR_BTN: "Kısa Güç Tuşu Tıklaması"
|
||||
STR_ORIENTATION: "Okuma Yönü"
|
||||
STR_SIDE_BTN_LAYOUT: "Yan Tuş Dizilimi (okuyucu)"
|
||||
STR_TOUCH_READER_CONTROLS: "Dokunmatik okuyucu kontrolleri"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Ön düğmeleri yönlendir"
|
||||
STR_LONG_PRESS_BEHAVIOR: "Long-press button behavior"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "OFF"
|
||||
@@ -304,3 +303,4 @@ STR_SELECTED: "Seçili"
|
||||
STR_SHOW: "Göster"
|
||||
STR_TITLE: "Başlık"
|
||||
STR_TILT_PAGE_TURN: "Eğerek sayfa çevirme"
|
||||
STR_MANAGE_THEMES: "Temaları Yönet"
|
||||
|
||||
@@ -73,7 +73,6 @@ STR_IMAGES_SUPPRESS: "Приховати"
|
||||
STR_SHORT_PWR_BTN: "Короткий натиск кн. живл."
|
||||
STR_ORIENTATION: "Орієнтація читання"
|
||||
STR_SIDE_BTN_LAYOUT: "Схема бічних кнопок"
|
||||
STR_TOUCH_READER_CONTROLS: "Сенсорне керування читанням"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Орієнтувати передні кнопки"
|
||||
STR_LONG_PRESS_BEHAVIOR: "Поведінка при довгому настику"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "Немає"
|
||||
@@ -381,3 +380,4 @@ STR_FIRMWARE_WRITE_FAILED: "Помилка запису прошивки"
|
||||
STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "Не вимикайте пристрій!"
|
||||
STR_RECOVERY_MODE: "Режим відновлення"
|
||||
STR_RECOVERY_MODE_HINT: "Помістіть firmware.bin у корінь SD-карти та виберіть його"
|
||||
STR_MANAGE_THEMES: "Керування темами"
|
||||
|
||||
@@ -73,7 +73,6 @@ STR_IMAGES_SUPPRESS: "Eliminar"
|
||||
STR_SHORT_PWR_BTN: "Clic curt del botó d'engegada"
|
||||
STR_ORIENTATION: "Orientació de lectura"
|
||||
STR_SIDE_BTN_LAYOUT: "Disposició botons laterals"
|
||||
STR_TOUCH_READER_CONTROLS: "Controls tàctils del lector"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Orientar botons frontals"
|
||||
STR_LONG_PRESS_BEHAVIOR: "Comportament de prémer llargament el botó"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "Desactivat"
|
||||
@@ -384,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_RECOVERY_MODE: "Mode de recuperació"
|
||||
STR_RECOVERY_MODE_HINT: "Poseu firmware.bin a l'arrel de la targeta SD i seleccioneu-lo"
|
||||
STR_MANAGE_THEMES: "Gestiona els temes"
|
||||
|
||||
@@ -73,7 +73,6 @@ STR_IMAGES_SUPPRESS: "Ẩn đi"
|
||||
STR_SHORT_PWR_BTN: "Nhấn nhanh nút nguồn"
|
||||
STR_ORIENTATION: "Hướng đọc"
|
||||
STR_SIDE_BTN_LAYOUT: "Bố trí nút bên (trình đọc)"
|
||||
STR_TOUCH_READER_CONTROLS: "Điều khiển cảm ứng (trình đọc)"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Xoay nút trước theo hướng"
|
||||
STR_LONG_PRESS_BEHAVIOR: "Hành vi nhấn giữ nút"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "TẮT"
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
#include "Logging.h"
|
||||
|
||||
#include <BoardConfig.h>
|
||||
#include <esp_rom_sys.h>
|
||||
|
||||
#include <string>
|
||||
|
||||
#define MAX_ENTRY_LEN 256
|
||||
@@ -62,19 +59,9 @@ void logPrintf(const char* level, const char* origin, const char* format, ...) {
|
||||
}
|
||||
}
|
||||
va_end(args);
|
||||
#if FREEINK_LOG_TRANSPORT == FREEINK_LOG_TRANSPORT_USB_CDC_WRITE
|
||||
// Native USB CDC can report false while PlatformIO monitor is attached on
|
||||
// boards like LilyGo T5 S3, so write directly to the CDC object.
|
||||
logSerial.write(reinterpret_cast<const uint8_t*>(buf), strnlen(buf, sizeof(buf)));
|
||||
#elif FREEINK_LOG_TRANSPORT == FREEINK_LOG_TRANSPORT_ROM_PRINTF
|
||||
// IDF/ROM console path for boards whose monitor is attached there during
|
||||
// bring-up, e.g. Sticky.
|
||||
esp_rom_printf("%s", buf);
|
||||
#else
|
||||
if (logSerial) {
|
||||
logSerial.print(buf);
|
||||
}
|
||||
#endif
|
||||
addToLogRingBuffer(buf);
|
||||
}
|
||||
|
||||
|
||||
@@ -27,10 +27,7 @@ won't trigger deprecation warnings.
|
||||
#define LOG_LEVEL 0
|
||||
#endif
|
||||
|
||||
// The concrete Serial type differs by MCU: HWCDC (native USB CDC on C3/S3) vs
|
||||
// HardwareSerial (UART0 on the classic ESP32 / M5Paper). Bind to whatever the
|
||||
// real Serial is here — this is before the `#define Serial` shim below.
|
||||
static decltype(Serial)& logSerial = Serial;
|
||||
static HWCDC& logSerial = Serial;
|
||||
|
||||
void logPrintf(const char* level, const char* origin, const char* format, ...);
|
||||
|
||||
|
||||
+1
-62
@@ -18,11 +18,8 @@ static uint8_t bcdToDec(uint8_t bcd) { return ((bcd >> 4) * 10) + (bcd & 0x0F);
|
||||
static uint8_t decToBcd(uint8_t dec) { return ((dec / 10) << 4) | (dec % 10); }
|
||||
|
||||
void HalClock::begin() {
|
||||
_usesSdkRtc = false;
|
||||
if (!gpio.deviceIsX3()) {
|
||||
_available = _sdkRtc.begin();
|
||||
_usesSdkRtc = _available;
|
||||
LOG_INF("CLK", _available ? "SDK RTC found" : "RTC not found");
|
||||
_available = false;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -60,24 +57,6 @@ bool HalClock::getTime(uint8_t& hour, uint8_t& minute) const {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (_usesSdkRtc) {
|
||||
Rtc::DateTime dt;
|
||||
if (!_sdkRtc.now(dt)) {
|
||||
if (!_hasCachedTime) return false;
|
||||
_lastPollMs = now;
|
||||
hour = _cachedHour;
|
||||
minute = _cachedMinute;
|
||||
return true;
|
||||
}
|
||||
_cachedHour = dt.hour;
|
||||
_cachedMinute = dt.minute;
|
||||
_lastPollMs = now;
|
||||
_hasCachedTime = true;
|
||||
hour = _cachedHour;
|
||||
minute = _cachedMinute;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Read 3 bytes starting at register 0x00: seconds, minutes, hours
|
||||
Wire.beginTransmission(I2C_ADDR_DS3231);
|
||||
Wire.write(DS3231_SEC_REG);
|
||||
@@ -152,25 +131,6 @@ bool HalClock::writeTimeToRTC(uint8_t hour, uint8_t minute, uint8_t second) {
|
||||
assert(hour < 24);
|
||||
assert(minute < 60);
|
||||
assert(second < 60);
|
||||
if (_usesSdkRtc) {
|
||||
Rtc::DateTime dt;
|
||||
dt.hour = hour;
|
||||
dt.minute = minute;
|
||||
dt.second = second;
|
||||
dt.year = 2000;
|
||||
dt.month = 1;
|
||||
dt.day = 1;
|
||||
if (!_sdkRtc.set(dt)) {
|
||||
LOG_ERR("CLK", "Failed to write time to SDK RTC");
|
||||
return false;
|
||||
}
|
||||
_lastPollMs = 0;
|
||||
_cachedHour = hour;
|
||||
_cachedMinute = minute;
|
||||
_hasCachedTime = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
Wire.beginTransmission(I2C_ADDR_DS3231);
|
||||
Wire.write(DS3231_SEC_REG); // Start at register 0x00
|
||||
Wire.write(decToBcd(second)); // 0x00: Seconds
|
||||
@@ -208,27 +168,6 @@ bool HalClock::syncFromNTP() {
|
||||
struct tm timeinfo;
|
||||
gmtime_r(&now, &timeinfo);
|
||||
|
||||
if (_usesSdkRtc) {
|
||||
Rtc::DateTime dt;
|
||||
dt.year = static_cast<uint16_t>(timeinfo.tm_year + 1900);
|
||||
dt.month = static_cast<uint8_t>(timeinfo.tm_mon + 1);
|
||||
dt.day = static_cast<uint8_t>(timeinfo.tm_mday);
|
||||
dt.hour = static_cast<uint8_t>(timeinfo.tm_hour);
|
||||
dt.minute = static_cast<uint8_t>(timeinfo.tm_min);
|
||||
dt.second = static_cast<uint8_t>(timeinfo.tm_sec);
|
||||
dt.weekday = static_cast<uint8_t>(timeinfo.tm_wday);
|
||||
if (_sdkRtc.set(dt)) {
|
||||
_lastPollMs = 0;
|
||||
_cachedHour = dt.hour;
|
||||
_cachedMinute = dt.minute;
|
||||
_hasCachedTime = true;
|
||||
LOG_INF("CLK", "RTC set to %04u-%02u-%02u %02u:%02u:%02u UTC", dt.year, dt.month, dt.day, dt.hour, dt.minute,
|
||||
dt.second);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (writeTimeToRTC(timeinfo.tm_hour, timeinfo.tm_min, timeinfo.tm_sec)) {
|
||||
LOG_INF("CLK", "RTC set to %02d:%02d:%02d UTC", timeinfo.tm_hour, timeinfo.tm_min, timeinfo.tm_sec);
|
||||
return true;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <Rtc.h>
|
||||
#include <Wire.h>
|
||||
|
||||
#include "HalGPIO.h"
|
||||
@@ -11,8 +10,6 @@ extern HalClock halClock; // Singleton
|
||||
|
||||
class HalClock {
|
||||
bool _available = false;
|
||||
bool _usesSdkRtc = false;
|
||||
mutable Rtc _sdkRtc;
|
||||
mutable uint8_t _cachedHour = 0;
|
||||
mutable uint8_t _cachedMinute = 0;
|
||||
mutable bool _hasCachedTime = false;
|
||||
|
||||
+17
-79
@@ -1,6 +1,5 @@
|
||||
#include <HalGPIO.h>
|
||||
#include <Logging.h>
|
||||
#include <PowerManager.h>
|
||||
#include <Preferences.h>
|
||||
#include <SPI.h>
|
||||
#include <Wire.h>
|
||||
@@ -193,44 +192,14 @@ HalGPIO::DeviceType detectDeviceTypeWithFingerprint() {
|
||||
|
||||
void HalGPIO::begin() {
|
||||
inputMgr.begin();
|
||||
#if FREEINK_MCU_C3
|
||||
// Claim the shared SPI bus with the X4/X3 display+SD pins. These EPD_* pin
|
||||
// macros are hardcoded C3/Xteink values, so this pre-claim is only valid on the
|
||||
// C3 family. On other boards (M5Paper's IT8951, Sticky's SSD1677, ...) the SDK
|
||||
// driver and SDCardManager bring up SPI from BoardConfig::ACTIVE pins; pre-
|
||||
// claiming here would stick (SPIClass::begin early-returns once the bus is
|
||||
// started) and leave the display/SD on the wrong pins.
|
||||
SPI.begin(EPD_SCLK, SPI_MISO, EPD_MOSI, EPD_CS);
|
||||
#endif
|
||||
|
||||
#if FREEINK_MCU_C3
|
||||
_deviceType = detectDeviceTypeWithFingerprint();
|
||||
|
||||
// Sync the runtime board profile to the detected device NOW, before any I2C
|
||||
// consumer begins. powerManager/clock/tilt begin() run immediately after this
|
||||
// in setup() and rely on BoardConfig::ACTIVE: HalPowerManager only calls
|
||||
// Wire.begin() when the active profile has an I2C gauge (gaugeAddr != 0), and
|
||||
// the X3 clock/tilt raw-Wire paths assume that begin happened. The dual X3+X4
|
||||
// binary boots as X4 (gaugeAddr 0), so without this the X4 profile stays active
|
||||
// through those begins, Wire is never (re)initialised after the detection probe
|
||||
// end()s it, and every X3 read fails with lock==NULL ("could not acquire lock"
|
||||
// / "NULL TX buffer pointer"). FreeInkDisplay also calls selectDevice() later,
|
||||
// but that is after the I2C consumers — too late. selectDevice() is idempotent.
|
||||
BoardConfig::selectDevice(deviceIsX3() ? BoardConfig::Board::XteinkX3 : BoardConfig::Board::XteinkX4);
|
||||
|
||||
if (deviceIsX4()) {
|
||||
pinMode(BAT_GPIO0, INPUT);
|
||||
pinMode(UART0_RXD, INPUT);
|
||||
}
|
||||
#else
|
||||
// Non-C3 boards (S3/ESP32) are single-device builds; their pins, panel, and
|
||||
// battery backend all come from BoardConfig::ACTIVE. The X3/X4 fingerprint probe
|
||||
// targets Xteink C3 hardware and drives Wire on X3_I2C_SDA/SCL (GPIO20/0) — which
|
||||
// collides with this board's own I2C (e.g. Sticky's BQ27220 gauge shares GPIO0)
|
||||
// and reconfigures strapping pins. Skip it; _deviceType stays X4 ("not X3"),
|
||||
// the correct non-X3 branch for every deviceIsX3() consumer.
|
||||
_deviceType = DeviceType::X4;
|
||||
#endif
|
||||
}
|
||||
|
||||
void HalGPIO::update() {
|
||||
@@ -256,55 +225,30 @@ unsigned long HalGPIO::getHeldTime() const { return inputMgr.getHeldTime(); }
|
||||
|
||||
unsigned long HalGPIO::getPowerButtonHeldTime() const { return inputMgr.getPowerButtonHeldTime(); }
|
||||
|
||||
bool HalGPIO::wasTouchTap(float& nx, float& ny) const { return inputMgr.wasTouchTap(nx, ny); }
|
||||
|
||||
bool HalGPIO::wasTouchDown(float& nx, float& ny) const { return inputMgr.wasTouchPressedAt(nx, ny); }
|
||||
|
||||
bool HalGPIO::isTouchTapCandidate(float& nx, float& ny, unsigned long& heldMs) const {
|
||||
return inputMgr.isTouchTapCandidate(nx, ny, heldMs);
|
||||
}
|
||||
|
||||
unsigned long HalGPIO::lastTouchHeldMs() const { return inputMgr.lastTouchHeldMs(); }
|
||||
|
||||
bool HalGPIO::wasSwipe(float& nxStart, float& nyStart, float& nxEnd, float& nyEnd) const {
|
||||
return inputMgr.wasSwipe(nxStart, nyStart, nxEnd, nyEnd);
|
||||
}
|
||||
|
||||
bool HalGPIO::hasTouch() const { return inputMgr.hasTouch(); }
|
||||
|
||||
bool HalGPIO::wasTouchActivity() const { return inputMgr.wasTouchActivity(); }
|
||||
|
||||
bool HalGPIO::isXteinkDevice() const {
|
||||
const auto board = BoardConfig::ACTIVE.board;
|
||||
return board == BoardConfig::Board::XteinkX3 || board == BoardConfig::Board::XteinkX4;
|
||||
}
|
||||
|
||||
void HalGPIO::startDeepSleep() {
|
||||
// Ensure that the power button has been released to avoid immediately turning back on if you're holding it
|
||||
while (inputMgr.isPressed(BTN_POWER)) {
|
||||
delay(50);
|
||||
inputMgr.update();
|
||||
}
|
||||
freeink::PowerManager::armPowerButtonWakeup();
|
||||
freeink::PowerManager::deepSleep();
|
||||
// Arm the wakeup trigger *after* the button is released
|
||||
esp_deep_sleep_enable_gpio_wakeup(1ULL << InputManager::POWER_BUTTON_PIN, ESP_GPIO_WAKEUP_GPIO_LOW);
|
||||
// Enter Deep Sleep
|
||||
esp_deep_sleep_start();
|
||||
}
|
||||
|
||||
void HalGPIO::verifyPowerButtonWakeup(uint16_t requiredDurationMs, bool shortPressAllowed) {
|
||||
if (BoardConfig::ACTIVE.input.power < 0) {
|
||||
// No readable power-button input pin: can't verify a hold, so don't sleep.
|
||||
return;
|
||||
}
|
||||
#if defined(FREEINK_DEVICE_M5PAPER) && FREEINK_DEVICE_M5PAPER
|
||||
// M5Paper: power-on is a hardware latch and the "power button" is the rotary
|
||||
// push (G38), shared with Confirm. A USB/flash cold boot is indistinguishable
|
||||
// from an intentional power-on hold here, so skip the X4-style anti-accidental-
|
||||
// wake check and always boot. G38 still serves as the deep-sleep wake source.
|
||||
return;
|
||||
#endif
|
||||
if (shortPressAllowed) {
|
||||
// Fast path - no duration check needed
|
||||
return;
|
||||
}
|
||||
// TODO: Intermittent edge case remains: a single tap followed by another single tap
|
||||
// can still power on the device. Tighten wake debounce/state handling here.
|
||||
|
||||
// Calibrate: subtract boot time already elapsed, assuming button held since boot
|
||||
const uint16_t calibration = millis();
|
||||
const uint16_t calibratedDuration = (calibration < requiredDurationMs) ? (requiredDurationMs - calibration) : 1;
|
||||
|
||||
const auto start = millis();
|
||||
inputMgr.update();
|
||||
// inputMgr.isPressed() may take up to ~500ms to return correct state
|
||||
@@ -313,12 +257,11 @@ void HalGPIO::verifyPowerButtonWakeup(uint16_t requiredDurationMs, bool shortPre
|
||||
inputMgr.update();
|
||||
}
|
||||
if (inputMgr.isPressed(BTN_POWER)) {
|
||||
const auto holdStart = millis();
|
||||
do {
|
||||
delay(10);
|
||||
inputMgr.update();
|
||||
} while (inputMgr.isPressed(BTN_POWER) && millis() - holdStart < requiredDurationMs);
|
||||
if (millis() - holdStart < requiredDurationMs) {
|
||||
} while (inputMgr.isPressed(BTN_POWER) && inputMgr.getPowerButtonHeldTime() < calibratedDuration);
|
||||
if (inputMgr.getPowerButtonHeldTime() < calibratedDuration) {
|
||||
startDeepSleep();
|
||||
}
|
||||
} else {
|
||||
@@ -339,10 +282,8 @@ bool HalGPIO::isUsbConnected() const {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (BoardConfig::ACTIVE.usbDetect < 0) {
|
||||
return false;
|
||||
}
|
||||
return digitalRead(BoardConfig::ACTIVE.usbDetect) == HIGH;
|
||||
// U0RXD/GPIO20 reads HIGH when USB is connected
|
||||
return digitalRead(UART0_RXD) == HIGH;
|
||||
}
|
||||
|
||||
HalGPIO::WakeupReason HalGPIO::getWakeupReason() const {
|
||||
@@ -351,11 +292,8 @@ HalGPIO::WakeupReason HalGPIO::getWakeupReason() const {
|
||||
|
||||
const bool usbConnected = isUsbConnected();
|
||||
|
||||
if (resetReason == ESP_RST_DEEPSLEEP &&
|
||||
(wakeupCause == ESP_SLEEP_WAKEUP_GPIO || wakeupCause == ESP_SLEEP_WAKEUP_EXT1)) {
|
||||
return WakeupReason::PowerButton;
|
||||
}
|
||||
if (wakeupCause == ESP_SLEEP_WAKEUP_UNDEFINED && resetReason == ESP_RST_POWERON && !usbConnected) {
|
||||
if ((wakeupCause == ESP_SLEEP_WAKEUP_UNDEFINED && resetReason == ESP_RST_POWERON && !usbConnected) ||
|
||||
(wakeupCause == ESP_SLEEP_WAKEUP_GPIO && resetReason == ESP_RST_DEEPSLEEP && usbConnected)) {
|
||||
return WakeupReason::PowerButton;
|
||||
}
|
||||
if (wakeupCause == ESP_SLEEP_WAKEUP_UNDEFINED && resetReason == ESP_RST_UNKNOWN && usbConnected) {
|
||||
|
||||
@@ -59,11 +59,6 @@ class HalGPIO {
|
||||
inline bool deviceIsX3() const { return _deviceType == DeviceType::X3; }
|
||||
inline bool deviceIsX4() const { return _deviceType == DeviceType::X4; }
|
||||
|
||||
// True on the Xteink X3/X4 boards. Unlike deviceIsX3/X4 (which both stay "X4"
|
||||
// on non-C3 boards), this keys off BoardConfig::ACTIVE.board, so it reliably
|
||||
// gates Xteink-only features (sunlight fix) and non-Xteink-only ones (touch).
|
||||
bool isXteinkDevice() const;
|
||||
|
||||
// Start button GPIO and setup SPI for screen and SD card
|
||||
void begin();
|
||||
|
||||
@@ -77,34 +72,6 @@ class HalGPIO {
|
||||
unsigned long getHeldTime() const;
|
||||
unsigned long getPowerButtonHeldTime() const;
|
||||
|
||||
// Touch: one-shot tap with the release position normalized 0..1 in the panel's
|
||||
// native orientation. Returns false on non-touch devices. (Reusable gesture
|
||||
// primitive; see MappedInputManager for the top-left = Back mapping.)
|
||||
bool wasTouchTap(float& nx, float& ny) const;
|
||||
|
||||
// Press-edge of a touch: true on touch-down with the down position normalized
|
||||
// 0..1 (panel native). For showing the pressed/selected element before release.
|
||||
bool wasTouchDown(float& nx, float& ny) const;
|
||||
|
||||
// True while a touch remains within tap slop; writes touch-down position and held time.
|
||||
bool isTouchTapCandidate(float& nx, float& ny, unsigned long& heldMs) const;
|
||||
|
||||
// Duration (ms) of the last touch contact, latched on release. Valid on the
|
||||
// release frame (alongside wasTouchTap). For tap-vs-long-press decisions.
|
||||
unsigned long lastTouchHeldMs() const;
|
||||
|
||||
// Swipe (flick) gesture on the release frame: writes the start/end positions
|
||||
// normalized 0..1 in the panel's native frame (map via GfxRenderer::tapToLogical).
|
||||
// A swipe also raises wasTouchTap(), so check this first. False on non-touch.
|
||||
bool wasSwipe(float& nxStart, float& nyStart, float& nxEnd, float& nyEnd) const;
|
||||
|
||||
// True if a touch controller is present/active (runtime gate; false on the C3).
|
||||
bool hasTouch() const;
|
||||
|
||||
// True if a touch press or release happened this frame (the touch analogue of
|
||||
// wasAnyPressed/Released), for resetting idle/sleep timers and CPU frequency.
|
||||
bool wasTouchActivity() const;
|
||||
|
||||
// Setup wake up GPIO and enter deep sleep
|
||||
void startDeepSleep();
|
||||
|
||||
|
||||
+25
-45
@@ -1,7 +1,6 @@
|
||||
#include "HalPowerManager.h"
|
||||
|
||||
#include <Logging.h>
|
||||
#include <PowerManager.h>
|
||||
#include <WiFi.h>
|
||||
#include <esp_sleep.h>
|
||||
|
||||
@@ -11,32 +10,15 @@
|
||||
|
||||
HalPowerManager powerManager; // Singleton instance
|
||||
|
||||
namespace {
|
||||
// The fuel gauge's I2C controller (Wire or Wire1) per the active board profile.
|
||||
// On single-bus SoCs (ESP32-C3, SOC_I2C_NUM == 1) Wire1 doesn't exist, so always
|
||||
// use Wire. On Sticky the gauge is on Wire1 so it doesn't fight the GT911 touch,
|
||||
// which owns Wire.
|
||||
TwoWire& gaugeWire() {
|
||||
#if SOC_I2C_NUM > 1
|
||||
if (BoardConfig::ACTIVE.batteryGauge.i2cBus == 1) return Wire1;
|
||||
#endif
|
||||
return Wire;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void HalPowerManager::begin() {
|
||||
const auto& gauge = BoardConfig::ACTIVE.batteryGauge;
|
||||
if (gauge.gaugeAddr != 0) {
|
||||
// Board has an I2C fuel gauge (X3, LilyGo, Sticky, ...). Pins/freq come from
|
||||
// the active board profile, not hardcoded X3 values. I2C init must come AFTER
|
||||
// gpio.begin() so early hardware detection/probes are finished.
|
||||
gaugeWire().begin(gauge.i2cSda, gauge.i2cScl, gauge.i2cHz);
|
||||
gaugeWire().setTimeOut(4);
|
||||
if (gpio.deviceIsX3()) {
|
||||
// X3 uses an I2C fuel gauge for battery monitoring.
|
||||
// I2C init must come AFTER gpio.begin() so early hardware detection/probes are finished.
|
||||
Wire.begin(X3_I2C_SDA, X3_I2C_SCL, X3_I2C_FREQ);
|
||||
Wire.setTimeOut(4);
|
||||
_batteryUseI2C = true;
|
||||
} else if (BoardConfig::ACTIVE.batteryAdc >= 0) {
|
||||
// ADC-sensed board (X4: GPIO0, M5Paper: GPIO35). Skip when the profile leaves
|
||||
// batteryAdc unassigned (PIN_UNASSIGNED) — pinMode(255) faults the GPIO mux.
|
||||
pinMode(BoardConfig::ACTIVE.batteryAdc, INPUT);
|
||||
} else {
|
||||
pinMode(BAT_GPIO0, INPUT);
|
||||
}
|
||||
normalFreq = getCpuFrequencyMhz();
|
||||
modeMutex = xSemaphoreCreateMutex();
|
||||
@@ -94,19 +76,22 @@ void HalPowerManager::startDeepSleep(HalGPIO& gpio) const {
|
||||
#endif
|
||||
|
||||
// Pre-sleep routines from the original firmware
|
||||
#if !SOC_PM_SUPPORT_EXT1_WAKEUP // RISC-V (C3 / X4)
|
||||
// GPIO13 is connected to battery latch MOSFET, we need to make sure it's low during sleep
|
||||
// Note that this means the MCU will be completely powered off during sleep, including RTC
|
||||
// (X4-specific: on classic ESP32/M5Paper GPIO13 is SD MISO, so this is skipped.)
|
||||
constexpr gpio_num_t GPIO_SPIWP = GPIO_NUM_13;
|
||||
gpio_set_direction(GPIO_SPIWP, GPIO_MODE_OUTPUT);
|
||||
gpio_set_level(GPIO_SPIWP, 0);
|
||||
esp_sleep_config_gpio_isolate();
|
||||
gpio_deep_sleep_hold_en();
|
||||
gpio_hold_en(GPIO_SPIWP);
|
||||
#endif
|
||||
freeink::PowerManager::armPowerButtonWakeup();
|
||||
freeink::PowerManager::deepSleep();
|
||||
pinMode(InputManager::POWER_BUTTON_PIN, INPUT_PULLUP);
|
||||
// Arm the wakeup trigger *after* the button is released
|
||||
// Note: this is only useful for waking up on USB power. On battery, the MCU will be completely powered off, so the
|
||||
// power button is hard-wired to briefly provide power to the MCU, waking it up regardless of the wakeup source
|
||||
// configuration
|
||||
esp_deep_sleep_enable_gpio_wakeup(1ULL << InputManager::POWER_BUTTON_PIN, ESP_GPIO_WAKEUP_GPIO_LOW);
|
||||
// Enter Deep Sleep
|
||||
esp_deep_sleep_start();
|
||||
}
|
||||
|
||||
uint16_t HalPowerManager::getBatteryPercentage() const {
|
||||
@@ -116,32 +101,27 @@ uint16_t HalPowerManager::getBatteryPercentage() const {
|
||||
return _batteryCachedPercent;
|
||||
}
|
||||
|
||||
// Read SOC directly from the I2C fuel gauge (16-bit LE register). Gauge
|
||||
// address comes from the active board profile (all current gauges are
|
||||
// BQ27220-class, SOC at 0x2C). On I2C error, keep last known value to avoid
|
||||
// UI jitter/slowdowns.
|
||||
const uint8_t gaugeAddr = BoardConfig::ACTIVE.batteryGauge.gaugeAddr;
|
||||
TwoWire& w = gaugeWire();
|
||||
w.beginTransmission(gaugeAddr);
|
||||
w.write(BQ27220_SOC_REG);
|
||||
if (w.endTransmission(false) != 0) {
|
||||
// Read SOC directly from I2C fuel gauge (16-bit LE register).
|
||||
// On I2C error, keep last known value to avoid UI jitter/slowdowns.
|
||||
Wire.beginTransmission(I2C_ADDR_BQ27220);
|
||||
Wire.write(BQ27220_SOC_REG);
|
||||
if (Wire.endTransmission(false) != 0) {
|
||||
_batteryLastPollMs = now;
|
||||
return _batteryCachedPercent;
|
||||
}
|
||||
w.requestFrom(gaugeAddr, (uint8_t)2);
|
||||
if (w.available() < 2) {
|
||||
Wire.requestFrom(I2C_ADDR_BQ27220, (uint8_t)2);
|
||||
if (Wire.available() < 2) {
|
||||
_batteryLastPollMs = now;
|
||||
return _batteryCachedPercent;
|
||||
}
|
||||
const uint8_t lo = w.read();
|
||||
const uint8_t hi = w.read();
|
||||
const uint8_t lo = Wire.read();
|
||||
const uint8_t hi = Wire.read();
|
||||
const uint16_t soc = (hi << 8) | lo;
|
||||
_batteryCachedPercent = soc > 100 ? 100 : soc;
|
||||
_batteryLastPollMs = now;
|
||||
return _batteryCachedPercent;
|
||||
}
|
||||
// ADC pin from the active profile (X4 GPIO0 / M5Paper GPIO35); default 2:1 divider.
|
||||
static const BatteryMonitor battery = BatteryMonitor(BoardConfig::ACTIVE.batteryAdc);
|
||||
static const BatteryMonitor battery = BatteryMonitor(BAT_GPIO0);
|
||||
|
||||
// smooth the battery %.
|
||||
if (_batteryCachedPercent == 0) {
|
||||
|
||||
@@ -28,16 +28,7 @@ class HalPowerManager {
|
||||
SemaphoreHandle_t modeMutex = nullptr; // Protect access to currentLockMode
|
||||
|
||||
public:
|
||||
// On PSRAM boards (classic ESP32 / M5Paper) the APB clock follows the CPU below
|
||||
// 80 MHz, and APB clocks both the SD SPI bus and the 80 MHz PSRAM (which holds
|
||||
// the framebuffer). Dropping to 10 MHz breaks SD writes and PSRAM, so floor the
|
||||
// low-power CPU at 80 MHz (APB stays pinned at 80 for CPU 80/160/240, so power
|
||||
// still drops from the 240 MHz default while SD/PSRAM stay stable).
|
||||
#if defined(BOARD_HAS_PSRAM)
|
||||
static constexpr int LOW_POWER_FREQ = 80; // MHz (APB-safe floor for PSRAM/SD)
|
||||
#else
|
||||
static constexpr int LOW_POWER_FREQ = 10; // MHz
|
||||
#endif
|
||||
static constexpr int LOW_POWER_FREQ = 10; // MHz
|
||||
static constexpr unsigned long IDLE_POWER_SAVING_MS = 3000; // ms
|
||||
static constexpr unsigned long BATTERY_POLL_MS = 1500; // ms
|
||||
|
||||
|
||||
@@ -38,13 +38,6 @@ void IRAM_ATTR __wrap_panic_print_backtrace(const void* frame, int core) {
|
||||
__real_panic_print_backtrace(frame, core);
|
||||
return;
|
||||
}
|
||||
#if !__riscv
|
||||
// RvExcFrame and the flat SP scan below are RISC-V-only (C3). On Xtensa
|
||||
// (classic ESP32 / S3) the exception frame is XtExcFrame with windowed-register
|
||||
// unwinding, so fall back to the default backtrace.
|
||||
__real_panic_print_backtrace(frame, core);
|
||||
return;
|
||||
#else
|
||||
for (size_t i = 0; i < MAX_PANIC_STACK_DEPTH; i++) {
|
||||
panicStack[i].sp = 0;
|
||||
}
|
||||
@@ -72,7 +65,6 @@ void IRAM_ATTR __wrap_panic_print_backtrace(const void* frame, int core) {
|
||||
}
|
||||
|
||||
__real_panic_print_backtrace(frame, core);
|
||||
#endif // __riscv
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,15 +26,6 @@ bool HalTiltSensor::readReg(uint8_t reg, uint8_t* val) const {
|
||||
}
|
||||
|
||||
bool HalTiltSensor::readGyro(float& gx, float& gy, float& gz) const {
|
||||
if (_backend == Backend::SdkImu) {
|
||||
Imu::Sample sample;
|
||||
if (!_sdkImu.read(sample)) return false;
|
||||
gx = sample.gx;
|
||||
gy = sample.gy;
|
||||
gz = sample.gz;
|
||||
return true;
|
||||
}
|
||||
|
||||
Wire.beginTransmission(_i2cAddr);
|
||||
Wire.write(REG_GX_L); // Start reading at Gyro X Low
|
||||
if (Wire.endTransmission(false) != 0) {
|
||||
@@ -61,22 +52,11 @@ bool HalTiltSensor::readGyro(float& gx, float& gy, float& gz) const {
|
||||
}
|
||||
|
||||
void HalTiltSensor::begin() {
|
||||
_backend = Backend::None;
|
||||
if (!gpio.deviceIsX3()) {
|
||||
_available = _sdkImu.begin();
|
||||
if (!_available) {
|
||||
LOG_ERR("GYR", "SDK IMU not found");
|
||||
return;
|
||||
}
|
||||
_backend = Backend::SdkImu;
|
||||
_initMs = millis();
|
||||
_lastPollMs = millis();
|
||||
LOG_INF("GYR", "SDK IMU initialized");
|
||||
_available = false;
|
||||
return;
|
||||
}
|
||||
|
||||
_backend = Backend::Qmi8658;
|
||||
|
||||
// Try primary address, then alternate
|
||||
uint8_t whoami = 0;
|
||||
_i2cAddr = I2C_ADDR_QMI8658;
|
||||
@@ -109,14 +89,6 @@ bool HalTiltSensor::wake() {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_backend == Backend::SdkImu) {
|
||||
_lastPollMs = millis();
|
||||
_lastTiltMs = millis();
|
||||
_wakeMs = millis();
|
||||
_isAwake = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Wait for init to complete before waking
|
||||
if ((millis() - _initMs) < SLEEP_STABILIZE_MS) {
|
||||
return false;
|
||||
@@ -139,13 +111,6 @@ bool HalTiltSensor::deepSleep() {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_backend == Backend::SdkImu) {
|
||||
clearPendingEvents();
|
||||
_inTilt = false;
|
||||
_isAwake = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
if ((millis() - _wakeMs) < SLEEP_STABILIZE_MS) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <Imu.h>
|
||||
#include <Wire.h>
|
||||
|
||||
#include "HalGPIO.h"
|
||||
@@ -19,12 +18,8 @@ class HalTiltSensor;
|
||||
extern HalTiltSensor halTiltSensor; // Singleton
|
||||
|
||||
class HalTiltSensor {
|
||||
enum class Backend : uint8_t { None, Qmi8658, SdkImu };
|
||||
|
||||
bool _available = false;
|
||||
Backend _backend = Backend::None;
|
||||
uint8_t _i2cAddr = 0;
|
||||
mutable Imu _sdkImu;
|
||||
|
||||
// Tilt gesture state machine
|
||||
bool _tiltForwardEvent = false; // Consumed by wasTiltedForward()
|
||||
|
||||
+10
-78
@@ -35,6 +35,8 @@ build_flags =
|
||||
# Increase PNG scanline buffer to support up to 2048px wide images
|
||||
# Default is (320*4+1)*2=2562, we need more for larger images
|
||||
-DPNG_MAX_BUFFERED_PIXELS=16416
|
||||
-DFREEINK_DEVICE_X4=1
|
||||
-DFREEINK_DEVICE_X3=1
|
||||
-Wno-bidi-chars
|
||||
-Wl,--wrap=panic_print_backtrace,--wrap=panic_abort,--wrap=bootloader_common_check_efuse_blk_validity
|
||||
-fno-exceptions
|
||||
@@ -55,10 +57,7 @@ extra_scripts =
|
||||
pre:scripts/patch_jpegdec.py
|
||||
post:scripts/register_unit_tests_target.py
|
||||
|
||||
; Libraries — FreeInk SDK (git submodule at ./freeink-sdk). The left-hand names
|
||||
; are the include paths the firmware already uses (<EInkDisplay.h>, etc.); FreeInk's
|
||||
; compat shim preserves them. BoardConfig + PowerManager are FreeInk-only and
|
||||
; required (device profiles + the SoC-correct deep-sleep wakeup the HAL relies on).
|
||||
; Libraries
|
||||
lib_deps =
|
||||
BatteryMonitor=symlink://freeink-sdk/libs/hardware/BatteryMonitor
|
||||
InputManager=symlink://freeink-sdk/libs/hardware/InputManager
|
||||
@@ -66,8 +65,6 @@ lib_deps =
|
||||
SDCardManager=symlink://freeink-sdk/libs/hardware/SDCardManager
|
||||
BoardConfig=symlink://freeink-sdk/libs/hardware/BoardConfig
|
||||
PowerManager=symlink://freeink-sdk/libs/hardware/PowerManager
|
||||
Rtc=symlink://freeink-sdk/libs/hardware/Rtc
|
||||
Imu=symlink://freeink-sdk/libs/hardware/Imu
|
||||
FreeInkUI=symlink://freeink-sdk/libs/ui/FreeInkUI
|
||||
Icons=symlink://freeink-sdk/libs/assets/Icons
|
||||
bblanchon/ArduinoJson @ 7.4.2
|
||||
@@ -76,100 +73,35 @@ lib_deps =
|
||||
https://github.com/bitbank2/JPEGDEC.git#86282979224c8a32fd51e091ed5a35b0c699a52b
|
||||
links2004/WebSockets @ 2.7.3
|
||||
|
||||
; ESP32-C3 device set: X3 + X4 compiled into one binary, runtime-selected after
|
||||
; the I2C fingerprint. FreeInk requires an explicit device selection, so the C3
|
||||
; envs extend this instead of [base] directly. (M5Paper is a different MCU family
|
||||
; and selects its own device below.)
|
||||
[c3]
|
||||
[env:default]
|
||||
extends = base
|
||||
build_flags =
|
||||
${base.build_flags}
|
||||
-DFREEINK_DEVICE_X3=1
|
||||
-DFREEINK_DEVICE_X4=1
|
||||
|
||||
[env:default]
|
||||
extends = c3
|
||||
build_flags =
|
||||
${c3.build_flags}
|
||||
; CROSSPOINT_VERSION is set by scripts/git_branch.py (includes branch + short SHA)
|
||||
-DENABLE_SERIAL_LOG
|
||||
-DLOG_LEVEL=2 ; Set log level to debug for development builds
|
||||
|
||||
|
||||
[env:gh_release]
|
||||
extends = c3
|
||||
extends = base
|
||||
build_flags =
|
||||
${c3.build_flags}
|
||||
${base.build_flags}
|
||||
-DCROSSPOINT_VERSION=\"${crosspoint.version}\"
|
||||
-DENABLE_SERIAL_LOG
|
||||
-DLOG_LEVEL=1 ; Set log level to info for release builds
|
||||
|
||||
[env:gh_release_rc]
|
||||
extends = c3
|
||||
extends = base
|
||||
build_flags =
|
||||
${c3.build_flags}
|
||||
${base.build_flags}
|
||||
-DCROSSPOINT_VERSION=\"${crosspoint.version}-rc+${sysenv.CROSSPOINT_RC_HASH}\"
|
||||
-DENABLE_SERIAL_LOG
|
||||
-DLOG_LEVEL=1 ; Set log level to info for release candidate builds
|
||||
|
||||
[env:slim]
|
||||
extends = c3
|
||||
extends = base
|
||||
build_flags =
|
||||
${c3.build_flags}
|
||||
${base.build_flags}
|
||||
-DCROSSPOINT_VERSION=\"${crosspoint.version}-slim\"
|
||||
; serial output is disabled in slim builds to save space
|
||||
-UENABLE_SERIAL_LOG
|
||||
|
||||
; --- LilyGo T5 S3 (ESP32-S3 N16R8, 4.7" ED047TC1 / raw-parallel EPD) ---------
|
||||
; Uses FreeInk's LilyGo board-support library for the PCA9535/TPS65185 display
|
||||
; power hooks and expander button.
|
||||
; pio run -e lilygo_t5s3 -t upload --upload-port /dev/tty.usbmodemXXXX
|
||||
[env:lilygo_t5s3]
|
||||
extends = base
|
||||
board = esp32-s3-devkitc1-n16r8
|
||||
board_build.mcu = esp32s3
|
||||
board_build.flash_mode = qio
|
||||
board_build.arduino.memory_type = qio_opi
|
||||
build_flags =
|
||||
${base.build_flags}
|
||||
-DBOARD_HAS_PSRAM
|
||||
-DFREEINK_DEVICE_LILYGO=1
|
||||
-DCROSSPOINT_VERSION=\"${crosspoint.version}-lilygo_t5s3\"
|
||||
-DCROSSPOINT_SHOW_BUTTON_HINTS=0
|
||||
-DENABLE_SERIAL_LOG
|
||||
-DLOG_LEVEL=2
|
||||
-DTOUCH_PROBE_DEBUG=1
|
||||
lib_deps =
|
||||
${base.lib_deps}
|
||||
BoardT5S3=symlink://freeink-sdk/libs/hardware/BoardT5S3
|
||||
m5stack/M5GFX @ 0.2.20
|
||||
|
||||
; --- M5Paper v1.1 (classic ESP32-D0WDQ6, 4.7" 540x960 / IT8951E) -------------
|
||||
; Different MCU family than the C3 base: override board/mcu/flash, disable the
|
||||
; C3-only native-USB CDC (logs over UART0), and select the M5Paper device.
|
||||
; pio run -e m5paper_v11 -t upload --upload-port /dev/tty.usbserial-XXXX
|
||||
[env:m5paper_v11]
|
||||
extends = base
|
||||
board = esp32dev
|
||||
board_build.mcu = esp32
|
||||
board_build.flash_mode = qio
|
||||
; CP2104 bridge: 460800 is ~4x the 115200 fallback and reliable; drop if a flash
|
||||
; stalls. (921600 usually works too.)
|
||||
upload_speed = 460800
|
||||
build_flags =
|
||||
${base.build_flags}
|
||||
-UARDUINO_USB_MODE
|
||||
-UARDUINO_USB_CDC_ON_BOOT
|
||||
-DBOARD_HAS_PSRAM
|
||||
-DFREEINK_DEVICE_M5PAPER=1
|
||||
; git_branch.py only injects CROSSPOINT_VERSION for the default env; set it here.
|
||||
-DCROSSPOINT_VERSION=\"${crosspoint.version}-m5paper_v11\"
|
||||
-DENABLE_SERIAL_LOG
|
||||
-DLOG_LEVEL=2
|
||||
; Touch + rotary: no physical button row, so hide on-screen button hints.
|
||||
-DCROSSPOINT_SHOW_BUTTON_HINTS=0
|
||||
; Archive-order workarounds for the classic-ESP32 link (see freeink-sdk
|
||||
; platformio.crosspoint.sample.ini): force Arduino startup + the I2C HAL members.
|
||||
-Wl,.pio/build/m5paper_v11/FrameworkArduino/main.cpp.o
|
||||
-Wl,-u,i2cInit
|
||||
-Wl,-u,i2cSlaveInit
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regenerate src/components/icons/generated_icons.h from icons.manifest, using the
|
||||
# FreeInk SDK icon generator and its vendored Lucide submodule. generated_icons.h is
|
||||
# a committed build product of these inputs — re-run this after editing the manifest.
|
||||
#
|
||||
# Requires: rsvg-convert (librsvg) + Pillow, and the Lucide submodule fetched
|
||||
# (git submodule update --init in the SDK).
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
SDK="$ROOT/freeink-sdk"
|
||||
SVGDIR="$SDK/libs/assets/Icons/lucide/icons"
|
||||
|
||||
if [ ! -d "$SVGDIR" ]; then
|
||||
echo "Lucide SVGs not found at $SVGDIR" >&2
|
||||
echo "Fetch the submodule: git -C \"$SDK\" submodule update --init libs/assets/Icons/lucide" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
python3 "$SDK/libs/assets/Icons/tools/gen_icons.py" \
|
||||
--manifest "$ROOT/src/components/icons/icons.manifest" \
|
||||
--svgdir "$SVGDIR" \
|
||||
--sizes 16,24,32,40,48 \
|
||||
--out "$ROOT/src/components/icons/generated_icons.h"
|
||||
|
||||
echo "Regenerated src/components/icons/generated_icons.h"
|
||||
@@ -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()
|
||||
@@ -161,7 +161,7 @@ class CrossPointSettings {
|
||||
};
|
||||
|
||||
// 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
|
||||
enum IMAGE_RENDERING { IMAGES_DISPLAY = 0, IMAGES_PLACEHOLDER = 1, IMAGES_SUPPRESS = 2, IMAGE_RENDERING_COUNT };
|
||||
@@ -246,9 +246,6 @@ class CrossPointSettings {
|
||||
uint8_t uiTheme = LYRA;
|
||||
// Sunlight fading compensation
|
||||
uint8_t fadingFix = 0;
|
||||
// Touch reader controls: tap zones for page back/forward + press-and-hold,
|
||||
// mirroring the physical buttons. Touch devices only (hidden elsewhere).
|
||||
uint8_t touchReaderControls = 1;
|
||||
// Power button return from footnotes (1 = enabled, 0 = disabled)
|
||||
uint8_t pwrBtnFootnoteBack = 1;
|
||||
// Use book's embedded CSS styles for EPUB rendering (1 = enabled, 0 = disabled)
|
||||
@@ -257,6 +254,8 @@ class CrossPointSettings {
|
||||
uint8_t focusReadingEnabled = 0;
|
||||
// SD card font family name (empty = use built-in fontFamily)
|
||||
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)
|
||||
uint8_t showHiddenFiles = 0;
|
||||
// Remove a book from the Recent Books list when its End-of-Book screen is reached (0 = off, 1 = on)
|
||||
|
||||
@@ -144,10 +144,16 @@ bool JsonSettingsIO::saveSettings(const CrossPointSettings& s, const char* path)
|
||||
doc["frontButtonRight"] = s.frontButtonRight;
|
||||
// Font family — uses dynamic getter/setter in SettingsList so the generic loop skips it.
|
||||
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
|
||||
if (s.sdFontFamilyName[0] != '\0') {
|
||||
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.
|
||||
@@ -246,10 +252,17 @@ bool JsonSettingsIO::loadSettings(CrossPointSettings& s, const char* json, bool*
|
||||
// Font family — uses dynamic getter/setter in SettingsList so the generic loop skips it.
|
||||
const uint8_t storedFontFamily = doc["fontFamily"] | (uint8_t)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
|
||||
const char* sfn = doc["sdFontFamilyName"] | "";
|
||||
strncpy(s.sdFontFamilyName, sfn, sizeof(s.sdFontFamilyName) - 1);
|
||||
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') {
|
||||
s.fontFamily = CrossPointSettings::NOTOSERIF;
|
||||
strncpy(s.sdFontFamilyName, "OpenDyslexic", sizeof(s.sdFontFamilyName) - 1);
|
||||
|
||||
+3
-187
@@ -1,12 +1,8 @@
|
||||
#include "MappedInputManager.h"
|
||||
|
||||
#include <FreeInkUI.h>
|
||||
#include <GfxRenderer.h>
|
||||
|
||||
#include <cstdlib>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "components/TouchRegistry.h"
|
||||
|
||||
bool MappedInputManager::isNavDirectionSwapped() const {
|
||||
// Key the swap on the orientation the screen is *actually* rendered at, not the persisted reader
|
||||
@@ -78,182 +74,9 @@ bool MappedInputManager::mapButton(const Button button, bool (HalGPIO::*fn)(uint
|
||||
return false;
|
||||
}
|
||||
|
||||
// Top-left corner fallback, as a fraction of the logical screen. Generous to hit.
|
||||
static constexpr float BACK_GESTURE_FRAC_X = 0.22f;
|
||||
static constexpr float BACK_GESTURE_FRAC_Y = 0.12f;
|
||||
static constexpr float BOTTOM_EDGE_BACK_GESTURE_FRAC_Y = 0.14f;
|
||||
static constexpr unsigned long TOUCH_DOWN_SELECT_DELAY_MS = 90;
|
||||
static constexpr unsigned long TOUCH_HELD_OVERRIDE_WINDOW_MS = 250;
|
||||
bool MappedInputManager::wasPressed(const Button button) const { return mapButton(button, &HalGPIO::wasPressed); }
|
||||
|
||||
void MappedInputManager::rememberTouchHeldTime() const {
|
||||
touchHeldOverrideValid = true;
|
||||
touchHeldOverrideMs = gpio.lastTouchHeldMs();
|
||||
touchHeldOverrideAt = millis();
|
||||
}
|
||||
|
||||
bool MappedInputManager::wasBottomEdgeSwipeUp() const {
|
||||
float nxs = 0.0f, nys = 0.0f, nxe = 0.0f, nye = 0.0f;
|
||||
if (!gpio.wasSwipe(nxs, nys, nxe, nye)) return false;
|
||||
|
||||
int sx = 0, sy = 0, ex = 0, ey = 0;
|
||||
renderer.tapToLogical(nxs, nys, sx, sy);
|
||||
renderer.tapToLogical(nxe, nye, ex, ey);
|
||||
|
||||
const int screenHeight = renderer.getScreenHeight();
|
||||
const int bottomEdgeTop = screenHeight - static_cast<int>(screenHeight * BOTTOM_EDGE_BACK_GESTURE_FRAC_Y);
|
||||
const bool isBackSwipe = sy >= bottomEdgeTop && ey < sy && std::abs(ey - sy) > std::abs(ex - sx);
|
||||
if (isBackSwipe) rememberTouchHeldTime();
|
||||
return isBackSwipe;
|
||||
}
|
||||
|
||||
bool MappedInputManager::wasBackGesture() const {
|
||||
if (wasBottomEdgeSwipeUp()) return true;
|
||||
|
||||
float nx = 0.0f, ny = 0.0f;
|
||||
if (!gpio.wasTouchTap(nx, ny)) return false;
|
||||
int lx = 0, ly = 0;
|
||||
renderer.tapToLogical(nx, ny, lx, ly);
|
||||
// A tap on the theme's header Back target acts as Back.
|
||||
int id = 0;
|
||||
if (TouchRegistry::getInstance().hitTest(lx, ly, TouchRegistry::Back, id)) {
|
||||
rememberTouchHeldTime();
|
||||
return true;
|
||||
}
|
||||
// Else the top-left corner, for screens with no Back target (e.g. the reader).
|
||||
const bool isTopLeftBack =
|
||||
lx <= renderer.getScreenWidth() * BACK_GESTURE_FRAC_X && ly <= renderer.getScreenHeight() * BACK_GESTURE_FRAC_Y;
|
||||
if (isTopLeftBack) rememberTouchHeldTime();
|
||||
return isTopLeftBack;
|
||||
}
|
||||
|
||||
bool MappedInputManager::wasItemTapped(int& id) const {
|
||||
float nx = 0.0f, ny = 0.0f;
|
||||
if (!gpio.wasTouchTap(nx, ny)) return false;
|
||||
int lx = 0, ly = 0;
|
||||
renderer.tapToLogical(nx, ny, lx, ly);
|
||||
const bool hit = TouchRegistry::getInstance().hitTest(lx, ly, TouchRegistry::Item, id);
|
||||
if (hit) rememberTouchHeldTime();
|
||||
return hit;
|
||||
}
|
||||
|
||||
bool MappedInputManager::wasItemTouchedDown(int& id) const {
|
||||
float nx = 0.0f, ny = 0.0f;
|
||||
unsigned long heldMs = 0;
|
||||
if (!gpio.isTouchTapCandidate(nx, ny, heldMs)) {
|
||||
touchSelectTracking = false;
|
||||
touchSelectEmitted = false;
|
||||
touchSelectId = -1;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!touchSelectTracking) {
|
||||
float downNx = 0.0f, downNy = 0.0f;
|
||||
if (!gpio.wasTouchDown(downNx, downNy)) return false;
|
||||
|
||||
int lx = 0, ly = 0;
|
||||
renderer.tapToLogical(downNx, downNy, lx, ly);
|
||||
int candidateId = -1;
|
||||
if (!TouchRegistry::getInstance().hitTest(lx, ly, TouchRegistry::Item, candidateId)) {
|
||||
touchSelectTracking = false;
|
||||
touchSelectEmitted = false;
|
||||
touchSelectId = -1;
|
||||
return false;
|
||||
}
|
||||
touchSelectTracking = true;
|
||||
touchSelectEmitted = false;
|
||||
touchSelectId = candidateId;
|
||||
}
|
||||
|
||||
if (touchSelectEmitted || heldMs < TOUCH_DOWN_SELECT_DELAY_MS) return false;
|
||||
touchSelectEmitted = true;
|
||||
id = touchSelectId;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool MappedInputManager::wasItemLongPressed(int& id) const {
|
||||
static constexpr unsigned long TOUCH_LONG_PRESS_MS = 500;
|
||||
float nx = 0.0f, ny = 0.0f;
|
||||
if (!gpio.wasTouchTap(nx, ny)) return false; // release frame
|
||||
if (gpio.lastTouchHeldMs() < TOUCH_LONG_PRESS_MS) return false;
|
||||
int lx = 0, ly = 0;
|
||||
renderer.tapToLogical(nx, ny, lx, ly);
|
||||
const bool hit = TouchRegistry::getInstance().hitTest(lx, ly, TouchRegistry::Item, id);
|
||||
if (hit) rememberTouchHeldTime();
|
||||
return hit;
|
||||
}
|
||||
|
||||
bool MappedInputManager::wasTabTapped(int& id) const {
|
||||
float nx = 0.0f, ny = 0.0f;
|
||||
if (!gpio.wasTouchTap(nx, ny)) return false;
|
||||
int lx = 0, ly = 0;
|
||||
renderer.tapToLogical(nx, ny, lx, ly);
|
||||
const bool hit = TouchRegistry::getInstance().hitTest(lx, ly, TouchRegistry::Tab, id);
|
||||
if (hit) rememberTouchHeldTime();
|
||||
return hit;
|
||||
}
|
||||
|
||||
bool MappedInputManager::wasCoverTapped(int& id) const {
|
||||
float nx = 0.0f, ny = 0.0f;
|
||||
if (!gpio.wasTouchTap(nx, ny)) return false;
|
||||
int lx = 0, ly = 0;
|
||||
renderer.tapToLogical(nx, ny, lx, ly);
|
||||
const bool hit = TouchRegistry::getInstance().hitTest(lx, ly, TouchRegistry::Cover, id);
|
||||
if (hit) rememberTouchHeldTime();
|
||||
return hit;
|
||||
}
|
||||
|
||||
bool MappedInputManager::wasScreenTapped(int& x, int& y) const {
|
||||
float nx = 0.0f, ny = 0.0f;
|
||||
if (!gpio.wasTouchTap(nx, ny)) return false;
|
||||
renderer.tapToLogical(nx, ny, x, y);
|
||||
rememberTouchHeldTime();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool MappedInputManager::wasListScroll(int& index, int count, int pageItems) const {
|
||||
if (count <= 0) return false;
|
||||
if (pageItems < 1) pageItems = 1;
|
||||
if (wasBottomEdgeSwipeUp()) return false;
|
||||
const SwipeDir swipe = wasSwipe();
|
||||
if (swipe == SwipeDir::Up) {
|
||||
return freeink::ui::listPageIndex(index, +1, count, pageItems);
|
||||
}
|
||||
if (swipe == SwipeDir::Down) {
|
||||
return freeink::ui::listPageIndex(index, -1, count, pageItems);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
MappedInputManager::SwipeDir MappedInputManager::wasSwipe() const {
|
||||
if (wasBottomEdgeSwipeUp()) return SwipeDir::None;
|
||||
|
||||
float nxs = 0.0f, nys = 0.0f, nxe = 0.0f, nye = 0.0f;
|
||||
if (!gpio.wasSwipe(nxs, nys, nxe, nye)) return SwipeDir::None;
|
||||
// Map both endpoints into the logical frame so the direction follows what the
|
||||
// user sees regardless of panel mount/orientation.
|
||||
int sx = 0, sy = 0, ex = 0, ey = 0;
|
||||
renderer.tapToLogical(nxs, nys, sx, sy);
|
||||
renderer.tapToLogical(nxe, nye, ex, ey);
|
||||
const int dx = ex - sx;
|
||||
const int dy = ey - sy;
|
||||
if (std::abs(dx) >= std::abs(dy)) {
|
||||
return dx < 0 ? SwipeDir::Left : SwipeDir::Right;
|
||||
}
|
||||
return dy < 0 ? SwipeDir::Up : SwipeDir::Down;
|
||||
}
|
||||
|
||||
bool MappedInputManager::wasPressed(const Button button) const {
|
||||
// A top-left tap fires on the release frame; expose it on Back's press edge too
|
||||
// so menus that act on wasPressed(Back) also respond. Deliberately NOT folded
|
||||
// into isPressed, so a quick tap never satisfies the readers' long-press-home.
|
||||
if (button == Button::Back && wasBackGesture()) return true;
|
||||
return mapButton(button, &HalGPIO::wasPressed);
|
||||
}
|
||||
|
||||
bool MappedInputManager::wasReleased(const Button button) const {
|
||||
if (button == Button::Back && wasBackGesture()) return true;
|
||||
return mapButton(button, &HalGPIO::wasReleased);
|
||||
}
|
||||
bool MappedInputManager::wasReleased(const Button button) const { return mapButton(button, &HalGPIO::wasReleased); }
|
||||
|
||||
bool MappedInputManager::isPressed(const Button button) const { return mapButton(button, &HalGPIO::isPressed); }
|
||||
|
||||
@@ -261,14 +84,7 @@ bool MappedInputManager::wasAnyPressed() const { return gpio.wasAnyPressed(); }
|
||||
|
||||
bool MappedInputManager::wasAnyReleased() const { return gpio.wasAnyReleased(); }
|
||||
|
||||
unsigned long MappedInputManager::getHeldTime() const {
|
||||
if (!gpio.wasAnyPressed() && !gpio.wasAnyReleased() && touchHeldOverrideValid &&
|
||||
millis() - touchHeldOverrideAt <= TOUCH_HELD_OVERRIDE_WINDOW_MS) {
|
||||
return touchHeldOverrideMs;
|
||||
}
|
||||
touchHeldOverrideValid = false;
|
||||
return gpio.getHeldTime();
|
||||
}
|
||||
unsigned long MappedInputManager::getHeldTime() const { return gpio.getHeldTime(); }
|
||||
|
||||
MappedInputManager::Labels MappedInputManager::mapLabels(const char* back, const char* confirm, const char* previous,
|
||||
const char* next) const {
|
||||
|
||||
@@ -7,7 +7,6 @@ class GfxRenderer;
|
||||
class MappedInputManager {
|
||||
public:
|
||||
enum class Button { Back, Confirm, Left, Right, Up, Down, Power, PageBack, PageForward, NavNext, NavPrevious };
|
||||
enum class SwipeDir { None, Left, Right, Up, Down };
|
||||
|
||||
struct Labels {
|
||||
const char* btn1;
|
||||
@@ -22,33 +21,6 @@ class MappedInputManager {
|
||||
bool wasPressed(Button button) const;
|
||||
bool wasReleased(Button button) const;
|
||||
bool isPressed(Button button) const;
|
||||
// Touch "back" gesture: a tap on the theme's header Back target, or in the
|
||||
// top-left corner, or a swipe up from the visible bottom edge. Folded into
|
||||
// Back's edges, so every screen gets it for free.
|
||||
bool wasBackGesture() const;
|
||||
// True (and writes the id) if a tap this frame hit a TouchRegistry item.
|
||||
// Activities treat the id as "select + activate". False on non-touch devices.
|
||||
bool wasItemTapped(int& id) const;
|
||||
// Stable touch-down candidate: fires once when a touch remains over an item
|
||||
// briefly without crossing tap slop, so swipes do not show row selection.
|
||||
bool wasItemTouchedDown(int& id) const;
|
||||
// Subset of wasItemTapped's releases held past the long-press threshold (check
|
||||
// this first). Distinguishes tap vs press-and-hold.
|
||||
bool wasItemLongPressed(int& id) const;
|
||||
// wasItemTapped for tab-bar tabs (id = tab index) and cover/card targets
|
||||
// (id = item index). Distinct kinds so a screen with both doesn't confuse them.
|
||||
bool wasTabTapped(int& id) const;
|
||||
bool wasCoverTapped(int& id) const;
|
||||
// True on a touch release anywhere on screen, with logical/oriented coords.
|
||||
bool wasScreenTapped(int& x, int& y) const;
|
||||
// Swipe direction in the current logical (oriented) frame, or None. A swipe also
|
||||
// raises the tap helpers above, so check this first and consume it.
|
||||
SwipeDir wasSwipe() const;
|
||||
// Page-scroll a list selection by a vertical swipe (touch nav without buttons):
|
||||
// swipe up advances down the list, swipe down moves up, by pageItems, clamped to
|
||||
// [0, count-1]. Updates index and returns true when a vertical swipe occurred, so
|
||||
// callers can requestUpdate() and return before the tap handlers run.
|
||||
bool wasListScroll(int& index, int count, int pageItems) const;
|
||||
bool wasAnyPressed() const;
|
||||
bool wasAnyReleased() const;
|
||||
unsigned long getHeldTime() const;
|
||||
@@ -72,13 +44,4 @@ class MappedInputManager {
|
||||
const GfxRenderer& renderer;
|
||||
|
||||
bool mapButton(Button button, bool (HalGPIO::*fn)(uint8_t) const) const;
|
||||
void rememberTouchHeldTime() const;
|
||||
bool wasBottomEdgeSwipeUp() const;
|
||||
|
||||
mutable bool touchSelectTracking = false;
|
||||
mutable bool touchSelectEmitted = false;
|
||||
mutable int touchSelectId = -1;
|
||||
mutable bool touchHeldOverrideValid = false;
|
||||
mutable unsigned long touchHeldOverrideMs = 0;
|
||||
mutable unsigned long touchHeldOverrideAt = 0;
|
||||
};
|
||||
|
||||
+68
-25
@@ -1,8 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#include <BoardConfig.h>
|
||||
#include <HalClock.h>
|
||||
#include <HalGPIO.h>
|
||||
#include <HalTiltSensor.h>
|
||||
#include <I18n.h>
|
||||
#include <SdCardFontRegistry.h>
|
||||
@@ -15,6 +13,7 @@
|
||||
#include "CrossPointSettings.h"
|
||||
#include "KOReaderCredentialStore.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
|
||||
// are appended after the built-in fonts. Otherwise only built-in fonts are listed.
|
||||
@@ -92,6 +91,63 @@ inline SettingInfo buildFontFamilySetting(const SdCardFontRegistry* registry) {
|
||||
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.
|
||||
// Each entry has a key (for JSON API) and category (for grouping).
|
||||
// ACTION-type entries and entries without a key are device-only.
|
||||
@@ -101,7 +157,8 @@ inline SettingInfo buildFontFamilySetting(const SdCardFontRegistry* registry) {
|
||||
// SdCardFontRegistry is supplied AND has SD card fonts installed, the
|
||||
// font-family entry is replaced in a per-call copy with a registry-aware
|
||||
// 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 = [] {
|
||||
std::vector<SettingInfo> v = {
|
||||
// --- Display ---
|
||||
@@ -260,26 +317,6 @@ inline std::vector<SettingInfo> getSettingsList(const SdCardFontRegistry* regist
|
||||
SettingInfo::Toggle(StrId::STR_CLOCK_SYNCED, &CrossPointSettings::clockHasBeenSynced, "clockHasBeenSynced",
|
||||
StrId::STR_CUSTOMISE_STATUS_BAR),
|
||||
};
|
||||
// Sunlight fading fix is Xteink-only (transflective panel); other devices get
|
||||
// the touch reader controls toggle instead. Same gate as the reader runtime.
|
||||
if (!gpio.isXteinkDevice()) {
|
||||
v.erase(std::remove_if(v.begin(), v.end(),
|
||||
[](const SettingInfo& s) { return s.nameId == StrId::STR_SUNLIGHT_FADING_FIX; }),
|
||||
v.end());
|
||||
for (auto it = v.begin(); it != v.end(); ++it) {
|
||||
if (it->nameId == StrId::STR_SIDE_BTN_LAYOUT) {
|
||||
v.insert(it, SettingInfo::Toggle(StrId::STR_TOUCH_READER_CONTROLS, &CrossPointSettings::touchReaderControls,
|
||||
"touchReaderControls", StrId::STR_CAT_CONTROLS));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (BoardConfig::hasTouch()) {
|
||||
v.erase(std::remove_if(v.begin(), v.end(),
|
||||
[](const SettingInfo& s) { return s.nameId == StrId::STR_FRONT_BTN_FOLLOW_ORIENTATION; }),
|
||||
v.end());
|
||||
}
|
||||
|
||||
// Only show tilt page turn setting when the QMI8658 IMU is present (X3)
|
||||
if (halTiltSensor.isAvailable()) {
|
||||
// Insert after the short power button setting (end of Controls section)
|
||||
@@ -296,10 +333,16 @@ inline std::vector<SettingInfo> getSettingsList(const SdCardFontRegistry* regist
|
||||
}();
|
||||
|
||||
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; });
|
||||
if (it != v.end()) {
|
||||
*it = buildFontFamilySetting(registry);
|
||||
*it = buildFontFamilySetting(fontRegistry);
|
||||
}
|
||||
}
|
||||
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_;
|
||||
};
|
||||
@@ -56,7 +56,8 @@ class Activity {
|
||||
// Finish this activity and return to the previous one on the stack (if any)
|
||||
void finish();
|
||||
|
||||
// Convenience method to facilitate API transition to ActivityManager.
|
||||
// Convenience method to facilitate API transition to ActivityManager
|
||||
// TODO: remove this in near future
|
||||
void onGoHome(HomeMenuItem item = HomeMenuItem::NONE);
|
||||
void onSelectBook(const std::string& path);
|
||||
};
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
#include "boot_sleep/BootActivity.h"
|
||||
#include "boot_sleep/SleepActivity.h"
|
||||
#include "browser/OpdsBookBrowserActivity.h"
|
||||
#include "components/TouchRegistry.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "home/CrashActivity.h"
|
||||
#include "home/FileBrowserActivity.h"
|
||||
#include "home/HomeActivity.h"
|
||||
@@ -20,13 +20,6 @@
|
||||
#include "settings/SettingsActivity.h"
|
||||
#include "util/FullScreenMessageActivity.h"
|
||||
|
||||
// taskENTER_CRITICAL needs a real spinlock on dual-core targets (classic ESP32,
|
||||
// e.g. M5Paper). On the single-core ESP32-C3 a nullptr mux was tolerated, but
|
||||
// the dual-core port acquires an inter-core spinlock and asserts on a null
|
||||
// pointer (spinlock_acquire, spinlock.h:84). One shared spinlock guards the
|
||||
// short waitingTaskHandle critical sections below.
|
||||
static portMUX_TYPE activityMux = portMUX_INITIALIZER_UNLOCKED;
|
||||
|
||||
void ActivityManager::begin() {
|
||||
xTaskCreate(&renderTaskTrampoline, "ActivityManagerRender",
|
||||
8192, // Stack size
|
||||
@@ -50,18 +43,14 @@ void ActivityManager::renderTaskLoop() {
|
||||
RenderLock lock;
|
||||
if (currentActivity) {
|
||||
HalPowerManager::Lock powerLock; // Ensure we don't go into low-power mode while rendering
|
||||
// Touch targets are rebuilt every frame: clear before the activity draws, then
|
||||
// publish so the next loop() hit-tests against exactly what's on screen.
|
||||
TouchRegistry::getInstance().beginFrame();
|
||||
currentActivity->render(std::move(lock));
|
||||
TouchRegistry::getInstance().publish();
|
||||
}
|
||||
// Notify any task blocked in requestUpdateAndWait() that the render is done.
|
||||
TaskHandle_t waiter = nullptr;
|
||||
taskENTER_CRITICAL(&activityMux);
|
||||
taskENTER_CRITICAL(nullptr);
|
||||
waiter = waitingTaskHandle;
|
||||
waitingTaskHandle = nullptr;
|
||||
taskEXIT_CRITICAL(&activityMux);
|
||||
taskEXIT_CRITICAL(nullptr);
|
||||
if (waiter) {
|
||||
xTaskNotify(waiter, 1, eIncrement);
|
||||
}
|
||||
@@ -182,6 +171,7 @@ void ActivityManager::replaceActivity(std::unique_ptr<Activity>&& newActivity) {
|
||||
}
|
||||
|
||||
void ActivityManager::goToFileTransfer() {
|
||||
UITheme::getInstance().releaseSdThemeAssetMemory();
|
||||
replaceActivity(std::make_unique<CrossPointWebServerActivity>(renderer, mappedInput));
|
||||
}
|
||||
|
||||
@@ -196,6 +186,7 @@ void ActivityManager::goToRecentBooks() {
|
||||
}
|
||||
|
||||
void ActivityManager::goToBrowser() {
|
||||
UITheme::getInstance().releaseSdThemeAssetMemory();
|
||||
const auto& servers = OPDS_STORE.getServers();
|
||||
// Skip the server picker when there's only one server configured
|
||||
if (servers.size() == 1) {
|
||||
@@ -206,6 +197,7 @@ void ActivityManager::goToBrowser() {
|
||||
}
|
||||
|
||||
void ActivityManager::goToReader(std::string path) {
|
||||
UITheme::getInstance().releaseSdThemeAssetMemory();
|
||||
replaceActivity(std::make_unique<ReaderActivity>(renderer, mappedInput, std::move(path)));
|
||||
}
|
||||
|
||||
@@ -235,6 +227,7 @@ void ActivityManager::goHome(HomeMenuItem initialMenuItem) {
|
||||
initialMenuItem = HomeMenuItem::SETTINGS_MENU;
|
||||
}
|
||||
}
|
||||
UITheme::getInstance().reload();
|
||||
replaceActivity(std::make_unique<HomeActivity>(renderer, mappedInput, initialMenuItem));
|
||||
}
|
||||
void ActivityManager::goToCrashReport() { replaceActivity(std::make_unique<CrashActivity>(renderer, mappedInput)); }
|
||||
@@ -292,7 +285,7 @@ void ActivityManager::requestUpdateAndWait() {
|
||||
}
|
||||
|
||||
// Atomic section to perform checks
|
||||
taskENTER_CRITICAL(&activityMux);
|
||||
taskENTER_CRITICAL(nullptr);
|
||||
auto currTaskHandler = xTaskGetCurrentTaskHandle();
|
||||
auto mutexHolder = xSemaphoreGetMutexHolder(renderingMutex);
|
||||
bool isRenderTask = (currTaskHandler == renderTaskHandle);
|
||||
@@ -301,7 +294,7 @@ void ActivityManager::requestUpdateAndWait() {
|
||||
if (!alreadyWaiting && !isRenderTask && !holdingRenderLock) {
|
||||
waitingTaskHandle = currTaskHandler;
|
||||
}
|
||||
taskEXIT_CRITICAL(&activityMux);
|
||||
taskEXIT_CRITICAL(nullptr);
|
||||
|
||||
// Render task cannot call requestUpdateAndWait() or it will cause a deadlock
|
||||
assert(!isRenderTask && "Render task cannot call requestUpdateAndWait()");
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
#include "OpdsBookBrowserActivity.h"
|
||||
|
||||
#include <FreeInkUI.h>
|
||||
#include <GfxRenderer.h>
|
||||
#include <I18n.h>
|
||||
#include <Logging.h>
|
||||
@@ -11,7 +10,6 @@
|
||||
#include "SilentRestart.h"
|
||||
#include "activities/network/WifiSelectionActivity.h"
|
||||
#include "activities/util/KeyboardEntryActivity.h"
|
||||
#include "components/TouchRegistry.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
#include "network/HttpDownloader.h"
|
||||
@@ -33,6 +31,7 @@ void OpdsBookBrowserActivity::onEnter() {
|
||||
currentPath = "";
|
||||
selectorIndex = 0;
|
||||
consumeConfirm = false;
|
||||
consumeBack = false;
|
||||
errorMessage.clear();
|
||||
statusMessage = tr(STR_CHECKING_WIFI);
|
||||
requestUpdate();
|
||||
@@ -61,6 +60,10 @@ void OpdsBookBrowserActivity::loop() {
|
||||
consumeConfirm = false;
|
||||
return;
|
||||
}
|
||||
if (consumeBack && mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
consumeBack = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (state == BrowserState::ERROR) {
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
@@ -88,28 +91,11 @@ void OpdsBookBrowserActivity::loop() {
|
||||
if (state == BrowserState::DOWNLOADING) return;
|
||||
|
||||
if (state == BrowserState::BROWSING) {
|
||||
if (!entries.empty()) {
|
||||
if (mappedInput.wasListScroll(selectorIndex, static_cast<int>(entries.size()), PAGE_ITEMS)) {
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
int downId = -1;
|
||||
if (mappedInput.wasItemTouchedDown(downId) &&
|
||||
freeink::ui::listSelectIndex(selectorIndex, downId, static_cast<int>(entries.size()))) {
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
int tappedId = -1;
|
||||
if (mappedInput.wasItemTapped(tappedId) && tappedId >= 0 && tappedId < static_cast<int>(entries.size())) {
|
||||
selectorIndex = tappedId;
|
||||
activateSelectedEntry();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
activateSelectedEntry();
|
||||
if (!entries.empty()) {
|
||||
const auto& entry = entries[selectorIndex];
|
||||
entry.type == OpdsEntryType::BOOK ? downloadBook(entry) : navigateToEntry(entry);
|
||||
}
|
||||
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
navigateBack();
|
||||
} else if (mappedInput.wasReleased(MappedInputManager::Button::Left)) {
|
||||
@@ -192,20 +178,13 @@ void OpdsBookBrowserActivity::render(RenderLock&&) {
|
||||
std::string displayText = (entry.type == OpdsEntryType::NAVIGATION) ? "> " + entry.title : entry.title;
|
||||
if (entry.type == OpdsEntryType::BOOK && !entry.author.empty()) displayText += " - " + entry.author;
|
||||
auto item = renderer.truncatedText(UI_10_FONT_ID, displayText.c_str(), pageWidth - 40);
|
||||
const int itemY = 60 + (i % PAGE_ITEMS) * 30;
|
||||
renderer.drawText(UI_10_FONT_ID, 20, itemY, item.c_str(), i != static_cast<size_t>(selectorIndex));
|
||||
TouchRegistry::getInstance().add(Rect{0, itemY - 2, pageWidth, 30}, static_cast<int>(i), TouchRegistry::Item);
|
||||
renderer.drawText(UI_10_FONT_ID, 20, 60 + (i % PAGE_ITEMS) * 30, item.c_str(),
|
||||
i != static_cast<size_t>(selectorIndex));
|
||||
}
|
||||
}
|
||||
renderer.displayBuffer();
|
||||
}
|
||||
|
||||
void OpdsBookBrowserActivity::activateSelectedEntry() {
|
||||
if (entries.empty() || selectorIndex < 0 || selectorIndex >= static_cast<int>(entries.size())) return;
|
||||
const auto& entry = entries[selectorIndex];
|
||||
entry.type == OpdsEntryType::BOOK ? downloadBook(entry) : navigateToEntry(entry);
|
||||
}
|
||||
|
||||
void OpdsBookBrowserActivity::fetchFeed(const std::string& path) {
|
||||
if (server.url.empty()) {
|
||||
state = BrowserState::ERROR;
|
||||
|
||||
@@ -33,6 +33,7 @@ class OpdsBookBrowserActivity final : public Activity {
|
||||
std::string currentPath;
|
||||
std::string searchTemplate;
|
||||
bool consumeConfirm = false;
|
||||
bool consumeBack = false; // Added missing member
|
||||
int selectorIndex = 0;
|
||||
std::string errorMessage;
|
||||
std::string statusMessage;
|
||||
@@ -50,6 +51,5 @@ class OpdsBookBrowserActivity final : public Activity {
|
||||
void downloadBook(const OpdsEntry& book);
|
||||
void launchSearch();
|
||||
void performSearch(const std::string& query);
|
||||
void activateSelectedEntry();
|
||||
bool preventAutoSleep() override { return true; }
|
||||
};
|
||||
|
||||
@@ -20,9 +20,7 @@ void CrashActivity::onEnter() {
|
||||
}
|
||||
|
||||
void CrashActivity::loop() {
|
||||
int tapX = 0;
|
||||
int tapY = 0;
|
||||
if (mappedInput.isPressed(MappedInputManager::Button::Back) || mappedInput.wasScreenTapped(tapX, tapY)) {
|
||||
if (mappedInput.isPressed(MappedInputManager::Button::Back)) {
|
||||
finish();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
#include <Memory.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <vector>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "MappedInputManager.h"
|
||||
@@ -206,29 +207,7 @@ void FileBrowserActivity::loop() {
|
||||
const int pathReserved = renderer.getLineHeight(SMALL_FONT_ID) + UITheme::getInstance().getMetrics().verticalSpacing;
|
||||
const int pageItems = UITheme::getNumberOfItemsPerPage(renderer, true, false, true, false, pathReserved);
|
||||
|
||||
// Vertical swipe page-scrolls the list (touch nav without the side buttons).
|
||||
int scrollIdx = static_cast<int>(selectorIndex);
|
||||
if (mappedInput.wasListScroll(scrollIdx, static_cast<int>(files.size()), pageItems)) {
|
||||
selectorIndex = scrollIdx;
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
int downId = -1;
|
||||
if (mappedInput.wasItemTouchedDown(downId) && downId >= 0 && downId < static_cast<int>(files.size())) {
|
||||
selectorIndex = downId;
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
// A tap opens the tapped entry (held-time is 0 on a tap, so it takes the short-press
|
||||
// open path below, never the long-press delete).
|
||||
int tappedId = -1;
|
||||
const bool tapped = mappedInput.wasItemTapped(tappedId);
|
||||
if (tapped && tappedId >= 0 && tappedId < static_cast<int>(files.size())) {
|
||||
selectorIndex = tappedId;
|
||||
}
|
||||
|
||||
if (tapped || mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
if (lockNextConfirmRelease) {
|
||||
lockNextConfirmRelease = false;
|
||||
return;
|
||||
@@ -377,30 +356,60 @@ void FileBrowserActivity::render(RenderLock&&) {
|
||||
(mode == Mode::PickFirmware)
|
||||
? std::string(tr(STR_SELECT_FIRMWARE_FILE))
|
||||
: ((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 pathReserved = pathLineHeight + metrics.verticalSpacing;
|
||||
const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
|
||||
const int contentHeight =
|
||||
pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing - pathReserved;
|
||||
if (files.empty()) {
|
||||
if (screenSpec != nullptr) {
|
||||
layoutThemeSlots(screenSpec->layout, Rect{0, 0, pageWidth, pageHeight}, metrics, slots);
|
||||
headerRect = normalizeThemeHeaderSlot(findThemeSlot(slots, "header"), metrics);
|
||||
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);
|
||||
renderer.drawText(UI_10_FONT_ID, metrics.contentSidePadding, contentTop + 20, emptyMsg);
|
||||
renderer.drawText(UI_10_FONT_ID, metrics.contentSidePadding, listRect.y + 20, emptyMsg);
|
||||
} else {
|
||||
GUI.drawList(
|
||||
renderer, Rect{0, contentTop, pageWidth, contentHeight}, files.size(), selectorIndex,
|
||||
[this](int index) { return getFileName(files[index]); }, nullptr,
|
||||
[this](int index) { return UITheme::getFileIcon(files[index]); },
|
||||
renderer, listRect, files.size(), selectorIndex, [this](int index) { return getFileName(files[index]); },
|
||||
nullptr, [this](int index) { return UITheme::getFileIcon(files[index]); },
|
||||
[this](int index) { return getFileExtension(files[index]); }, false);
|
||||
}
|
||||
|
||||
// Full path display
|
||||
{
|
||||
const int pathY = pageHeight - metrics.buttonHintsHeight - metrics.verticalSpacing - pathLineHeight;
|
||||
const int separatorY = pathY - metrics.verticalSpacing / 2;
|
||||
if (pathRect.width > 0 && pathRect.height > 0) {
|
||||
const int pathY = pathRect.y;
|
||||
const int separatorY = pathRect.y - metrics.verticalSpacing / 2;
|
||||
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
|
||||
const char* pathStr = basepath.c_str();
|
||||
const char* pathDisplay = pathStr;
|
||||
@@ -419,7 +428,7 @@ void FileBrowserActivity::render(RenderLock&&) {
|
||||
snprintf(leftTruncBuf, sizeof(leftTruncBuf), "%s%s", ellipsis, p);
|
||||
pathDisplay = leftTruncBuf;
|
||||
}
|
||||
renderer.drawText(SMALL_FONT_ID, metrics.contentSidePadding, pathY, pathDisplay);
|
||||
renderer.drawText(SMALL_FONT_ID, pathRect.x, pathY, pathDisplay);
|
||||
}
|
||||
|
||||
// Help text
|
||||
@@ -430,7 +439,9 @@ void FileBrowserActivity::render(RenderLock&&) {
|
||||
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),
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -1,34 +1,40 @@
|
||||
#include "HomeActivity.h"
|
||||
|
||||
#include <Bitmap.h>
|
||||
#include <Epub.h>
|
||||
#include <FsHelpers.h>
|
||||
#include <GfxRenderer.h>
|
||||
#include <HalStorage.h>
|
||||
#include <I18n.h>
|
||||
#include <Utf8.h>
|
||||
#include <Memory.h>
|
||||
#include <Xtc.h>
|
||||
|
||||
#include <cstring>
|
||||
#include <algorithm>
|
||||
#include <vector>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "CrossPointState.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "OpdsServerStore.h"
|
||||
#include "RecentBooksStore.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
int HomeActivity::getMenuItemCount() const {
|
||||
int count = 4; // File Browser, Recents, File transfer, Settings
|
||||
if (!recentBooks.empty()) {
|
||||
count += recentBooks.size();
|
||||
}
|
||||
if (hasOpdsServers) {
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
void HomeActivity::buildHomeActions(std::vector<ThemeHomeActionEntry>& actions) const {
|
||||
buildThemeHomeActions(UITheme::getInstance().getHomeScreenSpec(), recentBooks, hasOpdsServers, actions);
|
||||
}
|
||||
|
||||
const std::vector<ThemeHomeActionEntry>& HomeActivity::refreshHomeActions() {
|
||||
buildHomeActions(homeActions);
|
||||
return homeActions;
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -51,7 +57,7 @@ void HomeActivity::loadRecentBooks(int maxBooks) {
|
||||
}
|
||||
}
|
||||
|
||||
void HomeActivity::loadRecentCovers(int coverHeight) {
|
||||
void HomeActivity::loadRecentCovers(const std::vector<int>& coverHeights) {
|
||||
recentsLoading = true;
|
||||
bool showingLoading = false;
|
||||
Rect popupRect;
|
||||
@@ -59,8 +65,16 @@ void HomeActivity::loadRecentCovers(int coverHeight) {
|
||||
int progress = 0;
|
||||
for (RecentBook& book : recentBooks) {
|
||||
if (!book.coverBmpPath.empty()) {
|
||||
std::string coverPath = UITheme::getCoverThumbPath(book.coverBmpPath, coverHeight);
|
||||
if (!Storage.exists(coverPath.c_str())) {
|
||||
bool hasMissingThumb = false;
|
||||
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 (FsHelpers::hasEpubExtension(book.path)) {
|
||||
Epub epub(book.path, "/.crosspoint");
|
||||
@@ -73,7 +87,13 @@ void HomeActivity::loadRecentCovers(int coverHeight) {
|
||||
popupRect = GUI.drawPopup(renderer, tr(STR_LOADING_POPUP));
|
||||
}
|
||||
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) {
|
||||
RECENT_BOOKS.updateBook(book.path, book.title, book.author, "");
|
||||
book.coverBmpPath = "";
|
||||
@@ -90,7 +110,13 @@ void HomeActivity::loadRecentCovers(int coverHeight) {
|
||||
popupRect = GUI.drawPopup(renderer, tr(STR_LOADING_POPUP));
|
||||
}
|
||||
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) {
|
||||
RECENT_BOOKS.updateBook(book.path, book.title, book.author, "");
|
||||
book.coverBmpPath = "";
|
||||
@@ -115,9 +141,46 @@ void HomeActivity::onEnter() {
|
||||
|
||||
const auto& metrics = UITheme::getInstance().getMetrics();
|
||||
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());
|
||||
selectorIndex = initialMenuItem == HomeMenuItem::NONE ? 0 : base + menuItemToIndex(initialMenuItem, hasOpdsServers);
|
||||
const auto& actions = refreshHomeActions();
|
||||
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
|
||||
requestUpdate();
|
||||
@@ -137,95 +200,124 @@ bool HomeActivity::storeCoverBuffer() {
|
||||
freeCoverBuffer();
|
||||
const size_t needed = renderer.getRegionByteSize(coverRectX, coverRectY, coverRectW, coverRectH);
|
||||
if (needed == 0) return false;
|
||||
coverBuffer = static_cast<uint8_t*>(malloc(needed));
|
||||
coverBuffer = makeUniqueNoThrow<uint8_t[]>(needed);
|
||||
if (!coverBuffer) {
|
||||
LOG_ERR("HOME", "OOM: cover buffer (%u bytes)", (unsigned)needed);
|
||||
return false;
|
||||
}
|
||||
coverBufferSize = needed;
|
||||
if (!renderer.copyRegionToBuffer(coverRectX, coverRectY, coverRectW, coverRectH, coverBuffer, coverBufferSize)) {
|
||||
free(coverBuffer);
|
||||
coverBuffer = nullptr;
|
||||
if (!renderer.copyRegionToBuffer(coverRectX, coverRectY, coverRectW, coverRectH, coverBuffer.get(),
|
||||
coverBufferSize)) {
|
||||
coverBuffer.reset();
|
||||
coverBufferSize = 0;
|
||||
return false;
|
||||
}
|
||||
coverBufferSelectorIndex = coverSelectorIndex;
|
||||
const auto& actions = refreshHomeActions();
|
||||
coverBufferStripSelected = selectorIndex >= 0 && selectorIndex < static_cast<int>(actions.size()) &&
|
||||
actions[selectorIndex].action == ThemeHomeAction::RecentBook;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool HomeActivity::restoreCoverBuffer() {
|
||||
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() {
|
||||
if (coverBuffer) {
|
||||
free(coverBuffer);
|
||||
coverBuffer = nullptr;
|
||||
}
|
||||
coverBuffer.reset();
|
||||
coverBufferSize = 0;
|
||||
coverBufferStored = false;
|
||||
coverBufferSelectorIndex = -1;
|
||||
coverBufferStripSelected = false;
|
||||
}
|
||||
|
||||
void HomeActivity::loop() {
|
||||
const int menuCount = getMenuItemCount();
|
||||
const ThemeHomeScreenSpec* homeSpec = UITheme::getInstance().getHomeScreenSpec();
|
||||
|
||||
buttonNavigator.onNext([this, menuCount] {
|
||||
selectorIndex = ButtonNavigator::nextIndex(selectorIndex, menuCount);
|
||||
requestUpdate();
|
||||
});
|
||||
auto updateCoverSelection = [this]() {
|
||||
const auto& actions = refreshHomeActions();
|
||||
if (selectorIndex < static_cast<int>(actions.size()) &&
|
||||
actions[selectorIndex].action == ThemeHomeAction::RecentBook) {
|
||||
coverSelectorIndex = actions[selectorIndex].value;
|
||||
}
|
||||
};
|
||||
|
||||
buttonNavigator.onPrevious([this, menuCount] {
|
||||
selectorIndex = ButtonNavigator::previousIndex(selectorIndex, menuCount);
|
||||
requestUpdate();
|
||||
});
|
||||
auto moveWithin = [this, &updateCoverSelection](bool wantRecentBook, int delta) {
|
||||
const auto& actions = refreshHomeActions();
|
||||
if (actions.empty()) return;
|
||||
|
||||
// Tap a menu button to select + activate it. The button menu registers
|
||||
// menu-local ids (drawn with selectorIndex offset by recentBooks.size()), so map
|
||||
// back into the global selector space.
|
||||
int downId = -1;
|
||||
if (mappedInput.wasItemTouchedDown(downId)) {
|
||||
selectorIndex = static_cast<int>(recentBooks.size()) + downId;
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
int tappedId = -1;
|
||||
const bool tapped = mappedInput.wasItemTapped(tappedId);
|
||||
if (tapped) {
|
||||
selectorIndex = static_cast<int>(recentBooks.size()) + tappedId;
|
||||
}
|
||||
|
||||
// A tap on the continue-reading cover selects that book (home selector index).
|
||||
int coverId = -1;
|
||||
const bool coverTapped = mappedInput.wasCoverTapped(coverId);
|
||||
if (coverTapped && coverId >= 0 && coverId < static_cast<int>(recentBooks.size())) {
|
||||
selectorIndex = coverId;
|
||||
}
|
||||
|
||||
if (tapped || coverTapped || mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
if (selectorIndex < recentBooks.size()) {
|
||||
onSelectBook(recentBooks[selectorIndex].path);
|
||||
} else {
|
||||
const int menuIndex = selectorIndex - static_cast<int>(recentBooks.size());
|
||||
switch (indexToMenuItem(menuIndex, hasOpdsServers)) {
|
||||
case HomeMenuItem::FILE_BROWSER:
|
||||
onFileBrowserOpen();
|
||||
break;
|
||||
case HomeMenuItem::RECENTS:
|
||||
onRecentsOpen();
|
||||
break;
|
||||
case HomeMenuItem::OPDS_BROWSER:
|
||||
onOpdsBrowserOpen();
|
||||
break;
|
||||
case HomeMenuItem::FILE_TRANSFER:
|
||||
onFileTransferOpen();
|
||||
break;
|
||||
case HomeMenuItem::SETTINGS_MENU:
|
||||
onSettingsOpen();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
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();
|
||||
};
|
||||
|
||||
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)) {
|
||||
const auto& actions = refreshHomeActions();
|
||||
if (selectorIndex < 0 || selectorIndex >= static_cast<int>(actions.size())) return;
|
||||
const auto& entry = actions[selectorIndex];
|
||||
switch (entry.action) {
|
||||
case ThemeHomeAction::RecentBook:
|
||||
if (entry.value >= 0 && entry.value < static_cast<int>(recentBooks.size()))
|
||||
onSelectBook(recentBooks[entry.value].path);
|
||||
break;
|
||||
case ThemeHomeAction::RecentBooks:
|
||||
onRecentsOpen();
|
||||
break;
|
||||
case ThemeHomeAction::OpdsBrowser:
|
||||
onOpdsBrowserOpen();
|
||||
break;
|
||||
case ThemeHomeAction::FileTransfer:
|
||||
onFileTransferOpen();
|
||||
break;
|
||||
case ThemeHomeAction::Settings:
|
||||
onSettingsOpen();
|
||||
break;
|
||||
case ThemeHomeAction::FileBrowser:
|
||||
default:
|
||||
onFileBrowserOpen();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,29 +325,91 @@ void HomeActivity::render(RenderLock&&) {
|
||||
const auto& metrics = UITheme::getInstance().getMetrics();
|
||||
const auto pageWidth = renderer.getScreenWidth();
|
||||
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();
|
||||
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
|
||||
// which sub-region of the framebuffer to snapshot. ~16 KB in Portrait
|
||||
// instead of the 48 KB full framebuffer the previous bind captured.
|
||||
// which sub-region of the framebuffer to snapshot. Include a small bleed
|
||||
// because cover-strip themes can draw selection outlines just outside the
|
||||
// nominal cover tile.
|
||||
coverRectX = 0;
|
||||
coverRectY = metrics.homeTopPadding;
|
||||
coverRectY = hasCoverArea ? std::max(0, metrics.homeTopPadding - coverCacheBleed) : 0;
|
||||
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},
|
||||
recentBooks, selectorIndex, coverRendered, coverBufferStored, bufferRestored,
|
||||
std::bind(&HomeActivity::storeCoverBuffer, this));
|
||||
GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.homeTopPadding},
|
||||
metrics.homeContinueReadingInMenu && metrics.homeShowContinueReadingHeader && !recentBooks.empty()
|
||||
? 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
|
||||
std::vector<const char*> menuItems = {tr(STR_BROWSE_FILES), tr(STR_MENU_RECENT_BOOKS), tr(STR_FILE_TRANSFER),
|
||||
tr(STR_SETTINGS_TITLE)};
|
||||
std::vector<UIIcon> menuIcons = {Folder, Book, Transfer, Settings};
|
||||
std::vector<UIIcon> menuIcons = {Folder, Recent, Transfer, Settings};
|
||||
|
||||
if (hasOpdsServers) {
|
||||
menuItems.insert(menuItems.begin() + 2, tr(STR_OPDS_BROWSER));
|
||||
@@ -268,13 +422,12 @@ void HomeActivity::render(RenderLock&&) {
|
||||
menuIcons.insert(menuIcons.begin(), Book);
|
||||
}
|
||||
|
||||
// Menu fills the space between the cover and the bottom reserve (button hints
|
||||
// when shown, else a margin). Sizing it to the real remaining height lets
|
||||
// drawButtonMenu fit the items, so a scaled-up menu never runs off the bottom.
|
||||
const int menuY = metrics.homeTopPadding + metrics.homeCoverTileHeight + metrics.homeMenuTopOffset;
|
||||
const int bottomReserve = (BaseTheme::showButtonHints() ? metrics.buttonHintsHeight : 0) + metrics.verticalSpacing;
|
||||
GUI.drawButtonMenu(
|
||||
renderer, Rect{0, menuY, pageWidth, pageHeight - menuY - bottomReserve}, static_cast<int>(menuItems.size()),
|
||||
renderer,
|
||||
Rect{0, metrics.homeTopPadding + metrics.homeCoverTileHeight + metrics.homeMenuTopOffset, pageWidth,
|
||||
pageHeight - (metrics.headerHeight + metrics.homeTopPadding + metrics.verticalSpacing +
|
||||
metrics.homeMenuTopOffset + metrics.buttonHintsHeight)},
|
||||
static_cast<int>(menuItems.size()),
|
||||
metrics.homeContinueReadingInMenu ? selectorIndex : selectorIndex - recentBooks.size(),
|
||||
[&menuItems](int index) { return std::string(menuItems[index]); },
|
||||
[&menuIcons](int index) { return menuIcons[index]; });
|
||||
@@ -287,9 +440,9 @@ void HomeActivity::render(RenderLock&&) {
|
||||
if (!firstRenderDone) {
|
||||
firstRenderDone = true;
|
||||
requestUpdate();
|
||||
} else if (!recentsLoaded && !recentsLoading) {
|
||||
} else if (!recentsLoaded && !recentsLoading && !UITheme::getInstance().getHomeCoverThumbHeights().empty()) {
|
||||
recentsLoading = true;
|
||||
loadRecentCovers(metrics.homeCoverHeight);
|
||||
loadRecentCovers(UITheme::getInstance().getHomeCoverThumbHeights());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,25 +1,28 @@
|
||||
#pragma once
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
|
||||
#include "./FileBrowserActivity.h"
|
||||
#include "./ThemeHomeRenderer.h"
|
||||
#include "activities/Activity.h"
|
||||
#include "util/ButtonNavigator.h"
|
||||
|
||||
struct RecentBook;
|
||||
struct Rect;
|
||||
|
||||
class HomeActivity final : public Activity {
|
||||
ButtonNavigator buttonNavigator;
|
||||
int selectorIndex = 0;
|
||||
int coverSelectorIndex = 0;
|
||||
bool recentsLoading = false;
|
||||
bool recentsLoaded = false;
|
||||
bool firstRenderDone = false;
|
||||
bool hasOpdsServers = false;
|
||||
bool coverRendered = false; // Track if cover has been rendered once
|
||||
bool coverBufferStored = false; // Track if cover buffer is stored
|
||||
uint8_t* coverBuffer = nullptr; // HomeActivity's own buffer for cover image
|
||||
size_t coverBufferSize = 0; // Bytes allocated to coverBuffer
|
||||
bool coverRendered = false;
|
||||
bool coverBufferStored = false;
|
||||
std::unique_ptr<uint8_t[]> coverBuffer;
|
||||
size_t coverBufferSize = 0;
|
||||
int coverBufferSelectorIndex = -1;
|
||||
bool coverBufferStripSelected = false;
|
||||
// Logical rect last passed to drawRecentBookCover. The cover snapshot only
|
||||
// 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.
|
||||
@@ -28,33 +31,10 @@ class HomeActivity final : public Activity {
|
||||
int coverRectW = 0;
|
||||
int coverRectH = 0;
|
||||
std::vector<RecentBook> recentBooks;
|
||||
std::vector<ThemeHomeActionEntry> homeActions;
|
||||
std::vector<int> navigationIndices;
|
||||
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 onFileBrowserOpen();
|
||||
void onRecentsOpen();
|
||||
@@ -62,12 +42,16 @@ class HomeActivity final : public Activity {
|
||||
void onFileTransferOpen();
|
||||
void onOpdsBrowserOpen();
|
||||
|
||||
int getMenuItemCount() const;
|
||||
bool storeCoverBuffer(); // Store frame buffer for cover image
|
||||
bool restoreCoverBuffer(); // Restore frame buffer from stored cover
|
||||
void freeCoverBuffer(); // Free the stored cover buffer
|
||||
void buildHomeActions(std::vector<ThemeHomeActionEntry>& actions) const;
|
||||
const std::vector<ThemeHomeActionEntry>& refreshHomeActions();
|
||||
int getMenuItemCount();
|
||||
static bool storeCoverBufferCallback(void* userData);
|
||||
static bool restoreCoverBufferCallback(void* userData);
|
||||
bool storeCoverBuffer();
|
||||
bool restoreCoverBuffer();
|
||||
void freeCoverBuffer();
|
||||
void loadRecentBooks(int maxBooks);
|
||||
void loadRecentCovers(int coverHeight);
|
||||
void loadRecentCovers(const std::vector<int>& coverHeights);
|
||||
|
||||
public:
|
||||
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 <FreeInkUIGfxRenderer.h>
|
||||
#include <GfxRenderer.h>
|
||||
#include <HalStorage.h>
|
||||
#include <I18n.h>
|
||||
#include <Logging.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
|
||||
#include "MappedInputManager.h"
|
||||
#include "RecentBookCoverPainter.h"
|
||||
#include "RecentBooksStore.h"
|
||||
#include "activities/util/ConfirmationActivity.h"
|
||||
#include "components/UITheme.h"
|
||||
@@ -16,10 +20,118 @@
|
||||
namespace {
|
||||
// Hold threshold for the long-press "remove from list" action (firmware convention).
|
||||
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
|
||||
|
||||
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() {
|
||||
Activity::onEnter();
|
||||
|
||||
@@ -42,15 +154,7 @@ void RecentBooksActivity::onExit() {
|
||||
}
|
||||
|
||||
void RecentBooksActivity::loop() {
|
||||
const int pageItems = UITheme::getInstance().getNumberOfItemsPerPage(renderer, true, false, true, true);
|
||||
|
||||
// Vertical swipe page-scrolls the list (touch nav without the side buttons).
|
||||
int scrollIdx = static_cast<int>(selectorIndex);
|
||||
if (mappedInput.wasListScroll(scrollIdx, static_cast<int>(recentBooks.size()), pageItems)) {
|
||||
selectorIndex = scrollIdx;
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
const int pageItems = getPageItems();
|
||||
|
||||
// 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).
|
||||
@@ -71,16 +175,7 @@ void RecentBooksActivity::loop() {
|
||||
return;
|
||||
}
|
||||
|
||||
int downId = -1;
|
||||
if (mappedInput.wasItemTouchedDown(downId) && downId >= 0 && downId < static_cast<int>(recentBooks.size())) {
|
||||
selectorIndex = downId;
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
int tappedId = -1;
|
||||
const bool tapped = mappedInput.wasItemTapped(tappedId);
|
||||
if (tapped && tappedId >= 0 && tappedId < static_cast<int>(recentBooks.size())) selectorIndex = tappedId;
|
||||
if (tapped || mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
if (!recentBooks.empty() && selectorIndex < static_cast<int>(recentBooks.size())) {
|
||||
LOG_DBG("RBA", "Selected recent book: %s", recentBooks[selectorIndex].path.c_str());
|
||||
onSelectBook(recentBooks[selectorIndex].path);
|
||||
@@ -141,28 +236,68 @@ void RecentBooksActivity::promptRemoveBook(const std::string& path, const std::s
|
||||
void RecentBooksActivity::render(RenderLock&&) {
|
||||
renderer.clearScreen();
|
||||
|
||||
const auto pageWidth = renderer.getScreenWidth();
|
||||
const auto pageHeight = renderer.getScreenHeight();
|
||||
const auto& metrics = UITheme::getInstance().getMetrics();
|
||||
auto& theme = UITheme::getInstance();
|
||||
const auto& metrics = theme.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));
|
||||
|
||||
const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
|
||||
const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing;
|
||||
if (rects.header.width > 0 && rects.header.height > 0) {
|
||||
GUI.drawHeader(renderer, rects.header, tr(STR_MENU_RECENT_BOOKS));
|
||||
}
|
||||
|
||||
// Recent tab
|
||||
if (recentBooks.empty()) {
|
||||
renderer.drawText(UI_10_FONT_ID, metrics.contentSidePadding, contentTop + 20, tr(STR_NO_RECENT_BOOKS));
|
||||
if (rects.list.width <= 0 || rects.list.height <= 0) {
|
||||
// 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 {
|
||||
GUI.drawList(
|
||||
renderer, Rect{0, contentTop, pageWidth, contentHeight}, recentBooks.size(), selectorIndex,
|
||||
[this](int index) { return recentBooks[index].title; }, [this](int index) { return recentBooks[index].author; },
|
||||
renderer, rects.list, recentBooks.size(), selectorIndex, [this](int index) { return recentBooks[index].title; },
|
||||
[this](int index) { return recentBooks[index].author; },
|
||||
[this](int index) { return UITheme::getFileIcon(recentBooks[index].path); });
|
||||
}
|
||||
|
||||
// Help text
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ class RecentBooksActivity final : public Activity {
|
||||
|
||||
// Data loading
|
||||
void loadRecentBooks();
|
||||
int getPageItems();
|
||||
|
||||
// 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);
|
||||
|
||||
@@ -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);
|
||||
@@ -4,13 +4,13 @@
|
||||
#include <GfxRenderer.h>
|
||||
#include <I18n.h>
|
||||
#include <WiFi.h>
|
||||
#include <esp_task_wdt.h>
|
||||
|
||||
#include "MappedInputManager.h"
|
||||
#include "SilentRestart.h"
|
||||
#include "WifiSelectionActivity.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
#include "network/TaskWatchdog.h"
|
||||
|
||||
namespace {
|
||||
constexpr const char* HOSTNAME = "crosspoint";
|
||||
@@ -110,12 +110,12 @@ void CalibreConnectActivity::loop() {
|
||||
LOG_DBG("CAL", "WARNING: %lu ms gap since last handleClient", timeSinceLastHandleClient);
|
||||
}
|
||||
|
||||
feedTaskWatchdog();
|
||||
esp_task_wdt_reset();
|
||||
constexpr int MAX_ITERATIONS = 80;
|
||||
for (int i = 0; i < MAX_ITERATIONS && webServer->isRunning(); i++) {
|
||||
webServer->handleClient();
|
||||
if ((i & 0x07) == 0x07) {
|
||||
feedTaskWatchdog();
|
||||
esp_task_wdt_reset();
|
||||
}
|
||||
if ((i & 0x0F) == 0x0F) {
|
||||
yield();
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#include <GfxRenderer.h>
|
||||
#include <I18n.h>
|
||||
#include <WiFi.h>
|
||||
#include <esp_task_wdt.h>
|
||||
|
||||
#include <cstddef>
|
||||
|
||||
@@ -15,7 +16,6 @@
|
||||
#include "activities/network/CalibreConnectActivity.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
#include "network/TaskWatchdog.h"
|
||||
#include "util/QrUtils.h"
|
||||
|
||||
namespace {
|
||||
@@ -328,7 +328,7 @@ void CrossPointWebServerActivity::loop() {
|
||||
}
|
||||
|
||||
// Reset watchdog BEFORE processing - HTTP header parsing can be slow
|
||||
feedTaskWatchdog();
|
||||
esp_task_wdt_reset();
|
||||
|
||||
// Process HTTP requests in tight loop for maximum throughput
|
||||
// More iterations = more data processed per main loop cycle
|
||||
@@ -337,7 +337,7 @@ void CrossPointWebServerActivity::loop() {
|
||||
webServer->handleClient();
|
||||
// Reset watchdog every 32 iterations
|
||||
if ((i & 0x1F) == 0x1F) {
|
||||
feedTaskWatchdog();
|
||||
esp_task_wdt_reset();
|
||||
}
|
||||
// Yield and check for exit button every 64 iterations
|
||||
if ((i & 0x3F) == 0x3F) {
|
||||
|
||||
@@ -30,17 +30,8 @@ void NetworkModeSelectionActivity::loop() {
|
||||
return;
|
||||
}
|
||||
|
||||
int downId = -1;
|
||||
if (mappedInput.wasItemTouchedDown(downId) && downId >= 0 && downId < static_cast<int>(MENU_ITEM_COUNT)) {
|
||||
selectedIndex = downId;
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
// Handle confirm button (or a tap) - select current option
|
||||
int tappedId = -1;
|
||||
const bool tapped = mappedInput.wasItemTapped(tappedId);
|
||||
if (tapped && tappedId >= 0 && tappedId < static_cast<int>(MENU_ITEM_COUNT)) selectedIndex = tappedId;
|
||||
if (tapped || mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
|
||||
// Handle confirm button - select current option
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
|
||||
NetworkMode mode = NetworkMode::JOIN_NETWORK;
|
||||
if (selectedIndex == 1) {
|
||||
mode = NetworkMode::CONNECT_CALIBRE;
|
||||
@@ -51,6 +42,7 @@ void NetworkModeSelectionActivity::loop() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle navigation
|
||||
buttonNavigator.onNext([this] {
|
||||
selectedIndex = ButtonNavigator::nextIndex(selectedIndex, MENU_ITEM_COUNT);
|
||||
requestUpdate();
|
||||
@@ -85,6 +77,7 @@ void NetworkModeSelectionActivity::render(RenderLock&&) {
|
||||
[](int index) { return std::string(I18N.get(menuItems[index])); },
|
||||
[](int index) { return std::string(I18N.get(menuDescs[index])); }, [](int index) { return menuIcons[index]; });
|
||||
|
||||
// Draw help text at bottom
|
||||
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SELECT), tr(STR_DIR_UP), tr(STR_DIR_DOWN));
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
|
||||
|
||||
@@ -409,27 +409,6 @@ void WifiSelectionActivity::loop() {
|
||||
|
||||
// Handle network list state
|
||||
if (state == WifiSelectionState::NETWORK_LIST) {
|
||||
// Vertical swipe page-scrolls the network list (touch nav without the side buttons).
|
||||
int scrollIdx = static_cast<int>(selectedNetworkIndex);
|
||||
if (mappedInput.wasListScroll(scrollIdx, static_cast<int>(networks.size()),
|
||||
UITheme::getInstance().getNumberOfItemsPerPage(renderer, true, false, true, false))) {
|
||||
selectedNetworkIndex = scrollIdx;
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
// Touch: stable press highlights; tap selects it (like Confirm). Drag/scroll contacts do not preselect a row.
|
||||
int downId = -1;
|
||||
if (mappedInput.wasItemTouchedDown(downId) && downId >= 0 && downId < static_cast<int>(networks.size())) {
|
||||
selectedNetworkIndex = downId;
|
||||
requestUpdate();
|
||||
}
|
||||
int tappedId = -1;
|
||||
if (mappedInput.wasItemTapped(tappedId) && tappedId >= 0 && tappedId < static_cast<int>(networks.size())) {
|
||||
selectedNetworkIndex = tappedId;
|
||||
selectNetwork(selectedNetworkIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for Back button to exit (cancel)
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
|
||||
onComplete(false);
|
||||
@@ -463,6 +442,7 @@ void WifiSelectionActivity::loop() {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle navigation
|
||||
buttonNavigator.onNext([this] {
|
||||
selectedNetworkIndex = ButtonNavigator::nextIndex(selectedNetworkIndex, networks.size());
|
||||
requestUpdate();
|
||||
|
||||
@@ -298,14 +298,10 @@ void EpubReaderActivity::loop() {
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
// Touch page nav (idempotent within a frame, so read here and again below). The
|
||||
// top-left Back corner is consumed by wasReleased(Back) above, never reaching here.
|
||||
const auto touch = ReaderUtils::detectTouchPageTurn(renderer);
|
||||
|
||||
// Enter reader menu activity on short-press Confirm release, or a center touch-and-hold. A
|
||||
// long-press that fired a bound function (bookmark or KOReader sync) sets ignoreNextConfirmRelease
|
||||
// so the release following the hold does not also open the menu.
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm) || ReaderUtils::isTouchMenuGesture(touch)) {
|
||||
// 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
|
||||
// following the hold does not also open the menu.
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
if (ignoreNextConfirmRelease) {
|
||||
ignoreNextConfirmRelease = false;
|
||||
} else {
|
||||
@@ -404,9 +400,7 @@ void EpubReaderActivity::loop() {
|
||||
return;
|
||||
}
|
||||
|
||||
auto [prevTriggered, nextTriggered, fromTilt] = ReaderUtils::detectPageTurn(mappedInput);
|
||||
prevTriggered = prevTriggered || touch.prev;
|
||||
nextTriggered = nextTriggered || touch.next;
|
||||
const auto [prevTriggered, nextTriggered, fromTilt] = ReaderUtils::detectPageTurn(mappedInput);
|
||||
if (!prevTriggered && !nextTriggered) {
|
||||
return;
|
||||
}
|
||||
@@ -424,8 +418,7 @@ void EpubReaderActivity::loop() {
|
||||
return;
|
||||
}
|
||||
|
||||
const unsigned long heldMs = (touch.prev || touch.next) ? touch.heldMs : mappedInput.getHeldTime();
|
||||
const bool longPress = !fromTilt && heldMs > ReaderUtils::SKIP_HOLD_MS;
|
||||
const bool longPress = !fromTilt && mappedInput.getHeldTime() > ReaderUtils::SKIP_HOLD_MS;
|
||||
|
||||
// Don't skip chapter after screenshot
|
||||
if (gpio.wasReleased(HalGPIO::BTN_POWER) && gpio.wasReleased(HalGPIO::BTN_DOWN)) {
|
||||
@@ -795,6 +788,7 @@ void EpubReaderActivity::pageTurn(bool isForwardTurn) {
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
// TODO: Failure handling
|
||||
void EpubReaderActivity::render(RenderLock&& lock) {
|
||||
if (!epub) {
|
||||
return;
|
||||
@@ -1093,6 +1087,11 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
|
||||
if (!scratch) {
|
||||
LOG_ERR("ERS", "OOM: grayscale strip scratch (%d bytes); skipping AA this page", gwBytes * STRIP_ROWS);
|
||||
} 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
|
||||
// via PTL.
|
||||
renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB);
|
||||
|
||||
@@ -103,24 +103,7 @@ void EpubReaderBookmarksActivity::loop() {
|
||||
}
|
||||
}
|
||||
|
||||
if (confirmingDelete < DELETE_MODE_DISPLAY) {
|
||||
// Vertical swipe page-scrolls the list (touch nav without the side buttons).
|
||||
const int pageItems = GUI.getListPageItems(getListHeight(renderer), true);
|
||||
if (mappedInput.wasListScroll(selectorIndex, static_cast<int>(bookmarks.size()), pageItems)) {
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
int downId = -1;
|
||||
if (mappedInput.wasItemTouchedDown(downId) && downId >= 0 && downId < static_cast<int>(bookmarks.size())) {
|
||||
selectorIndex = downId;
|
||||
requestUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
int tappedId = -1;
|
||||
const bool tapped = (confirmingDelete < DELETE_MODE_DISPLAY) && mappedInput.wasItemTapped(tappedId);
|
||||
if (tapped && tappedId >= 0 && tappedId < static_cast<int>(bookmarks.size())) selectorIndex = tappedId;
|
||||
if (tapped || mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { // Open
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { // Open
|
||||
if (bookmarks.empty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3,39 +3,11 @@
|
||||
#include <GfxRenderer.h>
|
||||
#include <I18n.h>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include "MappedInputManager.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
int EpubReaderChapterSelectionActivity::getTotalItems() const { return epub ? epub->getTocItemsCount() : 0; }
|
||||
|
||||
void EpubReaderChapterSelectionActivity::cancelAndFinish() {
|
||||
ActivityResult result;
|
||||
result.isCancelled = true;
|
||||
setResult(std::move(result));
|
||||
finish();
|
||||
}
|
||||
|
||||
void EpubReaderChapterSelectionActivity::activateSelection(int index) {
|
||||
const int totalItems = getTotalItems();
|
||||
if (!epub || totalItems <= 0) {
|
||||
cancelAndFinish();
|
||||
return;
|
||||
}
|
||||
|
||||
index = std::clamp(index, 0, totalItems - 1);
|
||||
const auto tocItem = epub->getTocItem(index);
|
||||
if (tocItem.spineIndex < 0 || tocItem.spineIndex >= epub->getSpineItemsCount()) {
|
||||
cancelAndFinish();
|
||||
return;
|
||||
}
|
||||
|
||||
selectorIndex = index;
|
||||
setResult(ChapterResult{tocItem.spineIndex, tocItem.anchor});
|
||||
finish();
|
||||
}
|
||||
int EpubReaderChapterSelectionActivity::getTotalItems() const { return epub->getTocItemsCount(); }
|
||||
|
||||
void EpubReaderChapterSelectionActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
@@ -58,38 +30,23 @@ void EpubReaderChapterSelectionActivity::onExit() { Activity::onExit(); }
|
||||
void EpubReaderChapterSelectionActivity::loop() {
|
||||
const int pageItems = UITheme::getInstance().getNumberOfItemsPerPage(renderer, true, false, true, false);
|
||||
const int totalItems = getTotalItems();
|
||||
if (!epub || totalItems <= 0) {
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
cancelAndFinish();
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
const auto tocItem = epub->getTocItem(selectorIndex);
|
||||
if (tocItem.spineIndex == -1) {
|
||||
ActivityResult result;
|
||||
result.isCancelled = true;
|
||||
setResult(std::move(result));
|
||||
finish();
|
||||
} else {
|
||||
setResult(ChapterResult{tocItem.spineIndex, tocItem.anchor});
|
||||
finish();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Vertical swipe page-scrolls the list (touch nav without the side buttons).
|
||||
if (mappedInput.wasListScroll(selectorIndex, totalItems, pageItems)) {
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
int downId = -1;
|
||||
if (mappedInput.wasItemTouchedDown(downId) && downId >= 0 && downId < totalItems) {
|
||||
selectorIndex = downId;
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
int tappedId = -1;
|
||||
const bool tapped = mappedInput.wasItemTapped(tappedId);
|
||||
const bool validTap = tapped && tappedId >= 0 && tappedId < totalItems;
|
||||
const bool confirm = mappedInput.wasReleased(MappedInputManager::Button::Confirm);
|
||||
if (validTap) {
|
||||
selectorIndex = tappedId;
|
||||
}
|
||||
if (validTap || confirm) {
|
||||
activateSelection(selectorIndex);
|
||||
return;
|
||||
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
cancelAndFinish();
|
||||
return;
|
||||
ActivityResult result;
|
||||
result.isCancelled = true;
|
||||
setResult(std::move(result));
|
||||
finish();
|
||||
}
|
||||
|
||||
buttonNavigator.onNextRelease([this, totalItems] {
|
||||
@@ -126,12 +83,10 @@ void EpubReaderChapterSelectionActivity::render(RenderLock&&) {
|
||||
const int contentHeight = screen.height - contentTop - metrics.verticalSpacing;
|
||||
|
||||
const int totalItems = getTotalItems();
|
||||
const int displayIndex = totalItems > 0 ? std::clamp(selectorIndex, 0, totalItems - 1) : 0;
|
||||
GUI.drawList(renderer, Rect{screen.x, contentTop, screen.width, contentHeight}, totalItems, displayIndex,
|
||||
GUI.drawList(renderer, Rect{screen.x, contentTop, screen.width, contentHeight}, totalItems, selectorIndex,
|
||||
[this](int index) {
|
||||
auto item = epub->getTocItem(index);
|
||||
const int level = item.level > 0 ? item.level - 1 : 0;
|
||||
std::string indent(level * 2, ' ');
|
||||
std::string indent((item.level - 1) * 2, ' ');
|
||||
return indent + item.title;
|
||||
});
|
||||
|
||||
|
||||
@@ -19,8 +19,6 @@ class EpubReaderChapterSelectionActivity final : public Activity {
|
||||
|
||||
// Total TOC items count
|
||||
int getTotalItems() const;
|
||||
void cancelAndFinish();
|
||||
void activateSelection(int index);
|
||||
|
||||
public:
|
||||
explicit EpubReaderChapterSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
#include "EpubReaderFootnotesActivity.h"
|
||||
|
||||
#include <FreeInkUI.h>
|
||||
#include <GfxRenderer.h>
|
||||
#include <I18n.h>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include "MappedInputManager.h"
|
||||
#include "components/TouchRegistry.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
@@ -20,30 +18,6 @@ void EpubReaderFootnotesActivity::onEnter() {
|
||||
void EpubReaderFootnotesActivity::onExit() { Activity::onExit(); }
|
||||
|
||||
void EpubReaderFootnotesActivity::loop() {
|
||||
constexpr int lineHeight = 36;
|
||||
const auto orientation = renderer.getOrientation();
|
||||
const bool isPortraitInverted = orientation == GfxRenderer::Orientation::PortraitInverted;
|
||||
const int contentY = isPortraitInverted ? 50 : 0;
|
||||
const int visibleCount = std::max(1, (renderer.getScreenHeight() - contentY) / lineHeight);
|
||||
if (mappedInput.wasListScroll(selectedIndex, static_cast<int>(footnotes.size()), visibleCount)) {
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
int downId = -1;
|
||||
if (mappedInput.wasItemTouchedDown(downId) &&
|
||||
freeink::ui::listSelectIndex(selectedIndex, downId, static_cast<int>(footnotes.size()))) {
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
int tappedId = -1;
|
||||
if (mappedInput.wasItemTapped(tappedId) && tappedId >= 0 && tappedId < static_cast<int>(footnotes.size())) {
|
||||
selectedIndex = tappedId;
|
||||
setResult(FootnoteResult{footnotes[selectedIndex].href});
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
ActivityResult result;
|
||||
result.isCancelled = true;
|
||||
@@ -128,7 +102,6 @@ void EpubReaderFootnotesActivity::render(RenderLock&&) {
|
||||
label = tr(STR_LINK);
|
||||
}
|
||||
renderer.drawText(UI_10_FONT_ID, marginLeft, y + 4, label.c_str(), !isSelected);
|
||||
TouchRegistry::getInstance().add(Rect{contentX, y, contentWidth, lineHeight}, i, TouchRegistry::Item);
|
||||
}
|
||||
|
||||
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SELECT), "", "");
|
||||
|
||||
@@ -50,6 +50,7 @@ void EpubReaderMenuActivity::onEnter() {
|
||||
void EpubReaderMenuActivity::onExit() { Activity::onExit(); }
|
||||
|
||||
void EpubReaderMenuActivity::loop() {
|
||||
// Handle navigation
|
||||
buttonNavigator.onNext([this] {
|
||||
selectedIndex = ButtonNavigator::nextIndex(selectedIndex, static_cast<int>(menuItems.size()));
|
||||
requestUpdate();
|
||||
@@ -60,27 +61,7 @@ void EpubReaderMenuActivity::loop() {
|
||||
requestUpdate();
|
||||
});
|
||||
|
||||
// Vertical swipe page-scrolls the menu (touch nav without the side buttons).
|
||||
if (mappedInput.wasListScroll(selectedIndex, static_cast<int>(menuItems.size()),
|
||||
UITheme::getInstance().getNumberOfItemsPerPage(renderer, true, false, true, false))) {
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
int downId = -1;
|
||||
if (mappedInput.wasItemTouchedDown(downId) && downId >= 0 && downId < static_cast<int>(menuItems.size())) {
|
||||
selectedIndex = downId;
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
// A tap selects the item and activates it in one gesture (falls into Confirm below).
|
||||
int tappedId = -1;
|
||||
const bool tapped = mappedInput.wasItemTapped(tappedId);
|
||||
if (tapped && tappedId >= 0 && tappedId < static_cast<int>(menuItems.size())) {
|
||||
selectedIndex = tappedId;
|
||||
}
|
||||
|
||||
if (tapped || mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
const auto selectedAction = menuItems[selectedIndex].action;
|
||||
if (selectedAction == MenuAction::ROTATE_SCREEN) {
|
||||
// Cycle orientation preview locally; actual rotation happens on menu exit.
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
#include <GfxRenderer.h>
|
||||
#include <I18n.h>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include "MappedInputManager.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
@@ -34,22 +32,6 @@ void EpubReaderPercentSelectionActivity::adjustPercent(const int delta) {
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
bool EpubReaderPercentSelectionActivity::setPercentFromTouch(const int x, const int y) {
|
||||
auto& theme = UITheme::getInstance();
|
||||
const auto metrics = theme.getMetrics();
|
||||
const Rect screen = theme.getScreenSafeArea(renderer, true, false);
|
||||
const int contentTop = screen.y + metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing * 4;
|
||||
constexpr int barWidth = 360;
|
||||
constexpr int barHeight = 16;
|
||||
const int barX = screen.x + (screen.width - barWidth) / 2;
|
||||
const int barY = contentTop + metrics.verticalSpacing * 2;
|
||||
if (y < barY - 28 || y > barY + barHeight + 36 || x < barX - 20 || x > barX + barWidth + 20) return false;
|
||||
|
||||
const int clampedX = std::max(barX, std::min(x, barX + barWidth));
|
||||
percent = (clampedX - barX) * 100 / barWidth;
|
||||
return true;
|
||||
}
|
||||
|
||||
void EpubReaderPercentSelectionActivity::loop() {
|
||||
// Back cancels, confirm selects, arrows adjust the percent.
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
@@ -66,31 +48,6 @@ void EpubReaderPercentSelectionActivity::loop() {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (mappedInput.wasSwipe()) {
|
||||
case MappedInputManager::SwipeDir::Left:
|
||||
adjustPercent(-kSmallStep);
|
||||
return;
|
||||
case MappedInputManager::SwipeDir::Right:
|
||||
adjustPercent(kSmallStep);
|
||||
return;
|
||||
case MappedInputManager::SwipeDir::Up:
|
||||
adjustPercent(kLargeStep);
|
||||
return;
|
||||
case MappedInputManager::SwipeDir::Down:
|
||||
adjustPercent(-kLargeStep);
|
||||
return;
|
||||
case MappedInputManager::SwipeDir::None:
|
||||
break;
|
||||
}
|
||||
|
||||
int tapX = 0;
|
||||
int tapY = 0;
|
||||
if (mappedInput.wasScreenTapped(tapX, tapY) && setPercentFromTouch(tapX, tapY)) {
|
||||
setResult(PercentResult{percent});
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Left}, [this] { adjustPercent(-kSmallStep); });
|
||||
buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Right}, [this] { adjustPercent(kSmallStep); });
|
||||
|
||||
|
||||
@@ -24,5 +24,4 @@ class EpubReaderPercentSelectionActivity final : public Activity {
|
||||
|
||||
// Change the current percent by a delta and clamp within bounds.
|
||||
void adjustPercent(int delta);
|
||||
bool setPercentFromTouch(int x, int y);
|
||||
};
|
||||
|
||||
@@ -21,13 +21,6 @@ void QrDisplayActivity::loop() {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
int tapX = 0;
|
||||
int tapY = 0;
|
||||
if (mappedInput.wasScreenTapped(tapX, tapY)) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void QrDisplayActivity::render(RenderLock&&) {
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
#include <CrossPointSettings.h>
|
||||
#include <GfxRenderer.h>
|
||||
#include <HalGPIO.h>
|
||||
#include <HalTiltSensor.h>
|
||||
#include <Logging.h>
|
||||
|
||||
@@ -14,9 +13,6 @@ constexpr unsigned long GO_HOME_MS = 1000;
|
||||
constexpr unsigned long SKIP_HOLD_MS = 700;
|
||||
constexpr unsigned long BOOKMARK_HOLD_MS = 400;
|
||||
constexpr unsigned long BOOKMARK_MESSAGE_DURATION_MS = 2500;
|
||||
// Press-and-hold in the center touch zone (see detectTouchPageTurn) opens the
|
||||
// reader menu, the touch analogue of releasing the Confirm button.
|
||||
constexpr unsigned long TOUCH_MENU_HOLD_MS = 400;
|
||||
|
||||
inline void applyOrientation(GfxRenderer& renderer, const uint8_t orientation) {
|
||||
switch (orientation) {
|
||||
@@ -63,46 +59,6 @@ inline PageTurnResult detectPageTurn(const MappedInputManager& input) {
|
||||
return {prev, next, tiltPrev || tiltNext};
|
||||
}
|
||||
|
||||
// Touch reader controls: left third = page back, right third = forward, center =
|
||||
// open menu on press-and-hold (see isTouchMenuGesture). heldMs is the contact
|
||||
// duration, for the same long-press behavior as the buttons (chapter skip).
|
||||
// All-false on Xteink (no touch) or when the setting is off.
|
||||
struct TouchPageTurn {
|
||||
bool prev;
|
||||
bool next;
|
||||
bool center;
|
||||
unsigned long heldMs;
|
||||
};
|
||||
|
||||
inline TouchPageTurn detectTouchPageTurn(GfxRenderer& renderer) {
|
||||
TouchPageTurn result{false, false, false, 0};
|
||||
if (gpio.isXteinkDevice() || !SETTINGS.touchReaderControls) {
|
||||
return result;
|
||||
}
|
||||
float nx = 0.0f, ny = 0.0f;
|
||||
if (!gpio.wasTouchTap(nx, ny)) {
|
||||
return result;
|
||||
}
|
||||
int lx = 0, ly = 0;
|
||||
renderer.tapToLogical(nx, ny, lx, ly);
|
||||
const int third = renderer.getScreenWidth() / 3;
|
||||
if (lx < third) {
|
||||
result.prev = true;
|
||||
} else if (lx >= 2 * third) {
|
||||
result.next = true;
|
||||
} else {
|
||||
result.center = true;
|
||||
}
|
||||
result.heldMs = gpio.lastTouchHeldMs();
|
||||
return result;
|
||||
}
|
||||
|
||||
// True when the center zone was pressed and held long enough to open the reader
|
||||
// menu (touch analogue of a Confirm release).
|
||||
inline bool isTouchMenuGesture(const TouchPageTurn& touch) {
|
||||
return touch.center && touch.heldMs >= TOUCH_MENU_HOLD_MS;
|
||||
}
|
||||
|
||||
inline void displayWithRefreshCycle(const GfxRenderer& renderer, int& pagesUntilFullRefresh) {
|
||||
if (pagesUntilFullRefresh <= 1) {
|
||||
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
||||
|
||||
@@ -73,12 +73,7 @@ void TxtReaderActivity::loop() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Touch page nav (left third = back, right third = forward).
|
||||
const auto touch = ReaderUtils::detectTouchPageTurn(renderer);
|
||||
|
||||
auto [prevTriggered, nextTriggered, fromTilt] = ReaderUtils::detectPageTurn(mappedInput);
|
||||
prevTriggered = prevTriggered || touch.prev;
|
||||
nextTriggered = nextTriggered || touch.next;
|
||||
const auto [prevTriggered, nextTriggered, fromTilt] = ReaderUtils::detectPageTurn(mappedInput);
|
||||
if (!prevTriggered && !nextTriggered) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -54,11 +54,8 @@ void XtcReaderActivity::onExit() {
|
||||
}
|
||||
|
||||
void XtcReaderActivity::loop() {
|
||||
// Touch page nav; center hold opens chapter selection (the Confirm analogue).
|
||||
const auto touch = ReaderUtils::detectTouchPageTurn(renderer);
|
||||
|
||||
// Enter chapter selection activity (Confirm release, or a center touch-and-hold).
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm) || ReaderUtils::isTouchMenuGesture(touch)) {
|
||||
// Enter chapter selection activity
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
if (xtc && xtc->hasChapters() && !xtc->getChapters().empty()) {
|
||||
startActivityForResult(
|
||||
std::make_unique<XtcReaderChapterSelectionActivity>(renderer, mappedInput, xtc, currentPage),
|
||||
@@ -83,9 +80,7 @@ void XtcReaderActivity::loop() {
|
||||
return;
|
||||
}
|
||||
|
||||
auto [prevTriggered, nextTriggered, fromTilt] = ReaderUtils::detectPageTurn(mappedInput);
|
||||
prevTriggered = prevTriggered || touch.prev;
|
||||
nextTriggered = nextTriggered || touch.next;
|
||||
const auto [prevTriggered, nextTriggered, fromTilt] = ReaderUtils::detectPageTurn(mappedInput);
|
||||
if (!prevTriggered && !nextTriggered) {
|
||||
return;
|
||||
}
|
||||
@@ -101,9 +96,8 @@ void XtcReaderActivity::loop() {
|
||||
return;
|
||||
}
|
||||
|
||||
const unsigned long heldMs = (touch.prev || touch.next) ? touch.heldMs : mappedInput.getHeldTime();
|
||||
const bool skipPages =
|
||||
!fromTilt && SETTINGS.longPressButtonBehavior == SETTINGS.CHAPTER_SKIP && heldMs > ReaderUtils::SKIP_HOLD_MS;
|
||||
const bool skipPages = !fromTilt && SETTINGS.longPressButtonBehavior == SETTINGS.CHAPTER_SKIP &&
|
||||
mappedInput.getHeldTime() > ReaderUtils::SKIP_HOLD_MS;
|
||||
const int skipAmount = skipPages ? 10 : 1;
|
||||
|
||||
if (prevTriggered) {
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
#include "XtcReaderChapterSelectionActivity.h"
|
||||
|
||||
#include <FreeInkUI.h>
|
||||
#include <GfxRenderer.h>
|
||||
#include <I18n.h>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include "MappedInputManager.h"
|
||||
#include "components/TouchRegistry.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
@@ -58,26 +56,6 @@ void XtcReaderChapterSelectionActivity::loop() {
|
||||
const int pageItems = getPageItems();
|
||||
const int totalItems = static_cast<int>(xtc->getChapters().size());
|
||||
|
||||
// Vertical swipe page-scrolls the list (touch nav without the side buttons).
|
||||
if (mappedInput.wasListScroll(selectorIndex, totalItems, pageItems)) {
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
int downId = -1;
|
||||
if (mappedInput.wasItemTouchedDown(downId) && freeink::ui::listSelectIndex(selectorIndex, downId, totalItems)) {
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
int tappedId = -1;
|
||||
if (mappedInput.wasItemTapped(tappedId) && tappedId >= 0 && tappedId < totalItems) {
|
||||
selectorIndex = tappedId;
|
||||
const auto& chapters = xtc->getChapters();
|
||||
setResult(PageResult{chapters[selectorIndex].startPage});
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
const auto& chapters = xtc->getChapters();
|
||||
if (!chapters.empty() && selectorIndex >= 0 && selectorIndex < static_cast<int>(chapters.size())) {
|
||||
@@ -149,9 +127,7 @@ void XtcReaderChapterSelectionActivity::render(RenderLock&&) {
|
||||
for (int i = pageStartIndex; i < static_cast<int>(chapters.size()) && i < pageStartIndex + pageItems; i++) {
|
||||
const auto& chapter = chapters[i];
|
||||
const char* title = chapter.name.empty() ? tr(STR_UNNAMED) : chapter.name.c_str();
|
||||
const int itemY = 60 + contentY + (i % pageItems) * 30;
|
||||
renderer.drawText(UI_10_FONT_ID, contentX + 20, itemY, title, i != selectorIndex);
|
||||
TouchRegistry::getInstance().add(Rect{contentX, itemY - 2, contentWidth, 30}, i, TouchRegistry::Item);
|
||||
renderer.drawText(UI_10_FONT_ID, contentX + 20, 60 + contentY + (i % pageItems) * 30, title, i != selectorIndex);
|
||||
}
|
||||
|
||||
// Skip button hints in landscape CW mode (they overlap content)
|
||||
|
||||
@@ -122,27 +122,6 @@ void ClearCacheActivity::clearCache() {
|
||||
|
||||
void ClearCacheActivity::loop() {
|
||||
if (state == WARNING) {
|
||||
int tapX = 0;
|
||||
int tapY = 0;
|
||||
if (mappedInput.wasScreenTapped(tapX, tapY)) {
|
||||
const int actionTop = renderer.getScreenHeight() - UITheme::getInstance().getMetrics().buttonHintsHeight - 12;
|
||||
if (tapY >= actionTop) {
|
||||
if (tapX < renderer.getScreenWidth() / 2) {
|
||||
LOG_DBG("CLEAR_CACHE", "User cancelled via touch");
|
||||
goBack();
|
||||
} else {
|
||||
LOG_DBG("CLEAR_CACHE", "User confirmed via touch, starting cache clear");
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
state = CLEARING;
|
||||
}
|
||||
requestUpdateAndWait();
|
||||
clearCache();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
|
||||
LOG_DBG("CLEAR_CACHE", "User confirmed, starting cache clear");
|
||||
{
|
||||
@@ -162,9 +141,7 @@ void ClearCacheActivity::loop() {
|
||||
}
|
||||
|
||||
if (state == SUCCESS || state == FAILED) {
|
||||
int tapX = 0;
|
||||
int tapY = 0;
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back) || mappedInput.wasScreenTapped(tapX, tapY)) {
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
|
||||
goBack();
|
||||
}
|
||||
return;
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "components/TouchRegistry.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
@@ -109,57 +108,14 @@ void ClockOffsetActivity::adjustActiveField(int delta) {
|
||||
}
|
||||
}
|
||||
|
||||
void ClockOffsetActivity::selectRelativeField(const int delta) {
|
||||
activeField = static_cast<Field>((activeField + delta + FIELD_COUNT) % FIELD_COUNT);
|
||||
}
|
||||
|
||||
void ClockOffsetActivity::loop() {
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
switch (mappedInput.wasSwipe()) {
|
||||
case MappedInputManager::SwipeDir::Up:
|
||||
adjustActiveField(+1);
|
||||
requestUpdate();
|
||||
return;
|
||||
case MappedInputManager::SwipeDir::Down:
|
||||
adjustActiveField(-1);
|
||||
requestUpdate();
|
||||
return;
|
||||
case MappedInputManager::SwipeDir::Left:
|
||||
selectRelativeField(+1);
|
||||
requestUpdate();
|
||||
return;
|
||||
case MappedInputManager::SwipeDir::Right:
|
||||
selectRelativeField(-1);
|
||||
requestUpdate();
|
||||
return;
|
||||
case MappedInputManager::SwipeDir::None:
|
||||
break;
|
||||
}
|
||||
|
||||
int downId = -1;
|
||||
if (mappedInput.wasItemTouchedDown(downId) && downId >= 0 && downId < FIELD_COUNT) {
|
||||
activeField = static_cast<Field>(downId);
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
int tappedId = -1;
|
||||
if (mappedInput.wasItemTapped(tappedId) && tappedId >= 0 && tappedId < FIELD_COUNT) {
|
||||
const Field tappedField = static_cast<Field>(tappedId);
|
||||
if (activeField == tappedField) {
|
||||
adjustActiveField(+1);
|
||||
} else {
|
||||
activeField = tappedField;
|
||||
}
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
|
||||
selectRelativeField(+1);
|
||||
activeField = static_cast<Field>((activeField + 1) % FIELD_COUNT);
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
@@ -227,8 +183,6 @@ void ClockOffsetActivity::render(RenderLock&&) {
|
||||
}
|
||||
const int textX = boxX + (boxWidth - widthOf(text)) / 2;
|
||||
renderer.drawText(UI_12_FONT_ID, textX, centreY, text, true, EpdFontFamily::BOLD);
|
||||
TouchRegistry::getInstance().add(Rect{boxX - 4, centreY - 8, boxWidth + 8, fieldHeight + 16},
|
||||
static_cast<int>(field), TouchRegistry::Item);
|
||||
};
|
||||
|
||||
drawField(signStr, x, signBoxW, FIELD_SIGN);
|
||||
|
||||
@@ -34,5 +34,4 @@ class ClockOffsetActivity final : public Activity {
|
||||
void saveToSettings() const;
|
||||
void adjustActiveField(int delta);
|
||||
void clampForSign();
|
||||
void selectRelativeField(int delta);
|
||||
};
|
||||
|
||||
@@ -95,13 +95,6 @@ void ClockSyncActivity::loop() {
|
||||
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
int tapX = 0;
|
||||
int tapY = 0;
|
||||
if (mappedInput.wasScreenTapped(tapX, tapY)) {
|
||||
finish();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
#include "FontDownloadActivity.h"
|
||||
|
||||
#include <ArduinoJson.h>
|
||||
#include <FreeInkUI.h>
|
||||
#include <GfxRenderer.h>
|
||||
#include <HalStorage.h>
|
||||
#include <I18n.h>
|
||||
@@ -421,38 +420,6 @@ bool FontDownloadActivity::isSelectedFamilyDeletable() const {
|
||||
return family.installed && !family.hasUpdate;
|
||||
}
|
||||
|
||||
void FontDownloadActivity::activateSelectedItem() {
|
||||
if (families_.empty()) return;
|
||||
|
||||
if (isDownloadAllRow(selectedIndex_)) {
|
||||
currentFileIndex_ = 0;
|
||||
currentFileTotal_ = 0;
|
||||
for (const auto& f : families_) {
|
||||
if (!f.installed) currentFileTotal_ += f.files.size();
|
||||
}
|
||||
|
||||
downloadAll();
|
||||
} else if (isUpdateAllRow(selectedIndex_)) {
|
||||
currentFileIndex_ = 0;
|
||||
currentFileTotal_ = 0;
|
||||
for (const auto& f : families_) {
|
||||
if (f.hasUpdate) currentFileTotal_ += f.files.size();
|
||||
}
|
||||
updateAll();
|
||||
} else {
|
||||
auto& family = families_[familyIndexFromList(selectedIndex_)];
|
||||
if (!family.installed || family.hasUpdate) {
|
||||
currentFileIndex_ = 0;
|
||||
currentFileTotal_ = family.files.size();
|
||||
downloadFamily(family);
|
||||
} else {
|
||||
promptDeleteSelectedFamily();
|
||||
return;
|
||||
}
|
||||
}
|
||||
requestUpdateAndWait();
|
||||
}
|
||||
|
||||
// --- Input handling ---
|
||||
|
||||
void FontDownloadActivity::loop() {
|
||||
@@ -464,23 +431,6 @@ void FontDownloadActivity::loop() {
|
||||
|
||||
const int listSize = listItemCount();
|
||||
const int pageItems = UITheme::getNumberOfItemsPerPage(renderer, true, false, true, false);
|
||||
// Vertical swipe page-scrolls the list (touch nav without the side buttons).
|
||||
if (mappedInput.wasListScroll(selectedIndex_, listSize, pageItems)) {
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
int downId = -1;
|
||||
if (mappedInput.wasItemTouchedDown(downId) && freeink::ui::listSelectIndex(selectedIndex_, downId, listSize)) {
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
int tappedId = -1;
|
||||
if (mappedInput.wasItemTapped(tappedId) && tappedId >= 0 && tappedId < listSize) {
|
||||
selectedIndex_ = tappedId;
|
||||
activateSelectedItem();
|
||||
return;
|
||||
}
|
||||
|
||||
buttonNavigator_.onNextRelease([this, listSize] {
|
||||
selectedIndex_ = ButtonNavigator::nextIndex(selectedIndex_, listSize);
|
||||
@@ -503,8 +453,36 @@ void FontDownloadActivity::loop() {
|
||||
});
|
||||
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
|
||||
activateSelectedItem();
|
||||
return;
|
||||
if (!families_.empty()) {
|
||||
if (isDownloadAllRow(selectedIndex_)) {
|
||||
currentFileIndex_ = 0;
|
||||
currentFileTotal_ = 0;
|
||||
for (const auto& f : families_) {
|
||||
if (!f.installed) currentFileTotal_ += f.files.size();
|
||||
}
|
||||
|
||||
downloadAll();
|
||||
} else if (isUpdateAllRow(selectedIndex_)) {
|
||||
currentFileIndex_ = 0;
|
||||
currentFileTotal_ = 0;
|
||||
for (const auto& f : families_) {
|
||||
if (f.hasUpdate) currentFileTotal_ += f.files.size();
|
||||
}
|
||||
updateAll();
|
||||
} else {
|
||||
auto& family = families_[familyIndexFromList(selectedIndex_)];
|
||||
if (!family.installed || family.hasUpdate) {
|
||||
currentFileIndex_ = 0;
|
||||
currentFileTotal_ = family.files.size();
|
||||
downloadFamily(family);
|
||||
} else {
|
||||
promptDeleteSelectedFamily();
|
||||
return;
|
||||
}
|
||||
}
|
||||
requestUpdateAndWait();
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else if (state_ == COMPLETE) {
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back) ||
|
||||
|
||||
@@ -99,7 +99,6 @@ class FontDownloadActivity : public Activity {
|
||||
bool isDownloadAllRow(int index) const;
|
||||
bool isUpdateAllRow(int index) const;
|
||||
bool isSelectedFamilyDeletable() const;
|
||||
void activateSelectedItem();
|
||||
void promptDeleteSelectedFamily();
|
||||
void onDeleteConfirmationResult(const ActivityResult& result);
|
||||
int familyIndexFromList(int listIndex) const { return listIndex - specialRowCount(); }
|
||||
|
||||
@@ -80,58 +80,33 @@ void FontSelectionActivity::loop() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
|
||||
if (selectedIndex_ == previewFontIndex_) {
|
||||
handleSelection();
|
||||
} else {
|
||||
previewFontIndex_ = selectedIndex_;
|
||||
const auto& font = fonts_[selectedIndex_];
|
||||
if (font.isBuiltin) {
|
||||
SETTINGS.fontFamily = font.settingIndex;
|
||||
SETTINGS.sdFontFamilyName[0] = '\0';
|
||||
} else if (registry_) {
|
||||
const int sdIdx = font.settingIndex - CrossPointSettings::BUILTIN_FONT_COUNT;
|
||||
const auto& families = registry_->getFamilies();
|
||||
if (sdIdx < static_cast<int>(families.size())) {
|
||||
strncpy(SETTINGS.sdFontFamilyName, families[sdIdx].name.c_str(), sizeof(SETTINGS.sdFontFamilyName) - 1);
|
||||
SETTINGS.sdFontFamilyName[sizeof(SETTINGS.sdFontFamilyName) - 1] = '\0';
|
||||
sdFontSystem.ensureLoaded(renderer);
|
||||
}
|
||||
}
|
||||
requestUpdate();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const int listSize = static_cast<int>(fonts_.size());
|
||||
const int pageItems =
|
||||
UITheme::getNumberOfItemsPerPage(renderer, true, false, true, false, previewHeight + metrics_.verticalSpacing);
|
||||
|
||||
// First select of a font loads it into the preview pane; confirming the already-previewed
|
||||
// font commits it. Shared by hardware Confirm and touch taps so both honour the preview step.
|
||||
const auto confirmSelection = [this]() {
|
||||
if (selectedIndex_ == previewFontIndex_) {
|
||||
handleSelection();
|
||||
return;
|
||||
}
|
||||
previewFontIndex_ = selectedIndex_;
|
||||
const auto& font = fonts_[selectedIndex_];
|
||||
if (font.isBuiltin) {
|
||||
SETTINGS.fontFamily = font.settingIndex;
|
||||
SETTINGS.sdFontFamilyName[0] = '\0';
|
||||
} else if (registry_) {
|
||||
const int sdIdx = font.settingIndex - CrossPointSettings::BUILTIN_FONT_COUNT;
|
||||
const auto& families = registry_->getFamilies();
|
||||
if (sdIdx < static_cast<int>(families.size())) {
|
||||
strncpy(SETTINGS.sdFontFamilyName, families[sdIdx].name.c_str(), sizeof(SETTINGS.sdFontFamilyName) - 1);
|
||||
SETTINGS.sdFontFamilyName[sizeof(SETTINGS.sdFontFamilyName) - 1] = '\0';
|
||||
sdFontSystem.ensureLoaded(renderer);
|
||||
}
|
||||
}
|
||||
requestUpdate();
|
||||
};
|
||||
|
||||
// Vertical swipe page-scrolls the list (touch nav without the side buttons).
|
||||
if (mappedInput.wasListScroll(selectedIndex_, listSize, pageItems)) {
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
int downId = -1;
|
||||
if (mappedInput.wasItemTouchedDown(downId) && downId >= 0) {
|
||||
selectedIndex_ = downId;
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
int tappedId = -1;
|
||||
if (mappedInput.wasItemTapped(tappedId) && tappedId >= 0) {
|
||||
selectedIndex_ = tappedId;
|
||||
confirmSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
|
||||
confirmSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
buttonNavigator_.onNextRelease([this, listSize] {
|
||||
selectedIndex_ = ButtonNavigator::nextIndex(selectedIndex_, listSize);
|
||||
requestUpdate();
|
||||
|
||||
@@ -101,10 +101,8 @@ void KOReaderAuthActivity::render(RenderLock&&) {
|
||||
|
||||
void KOReaderAuthActivity::loop() {
|
||||
if (state == SUCCESS || state == FAILED) {
|
||||
int tapX = 0;
|
||||
int tapY = 0;
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back) ||
|
||||
mappedInput.wasPressed(MappedInputManager::Button::Confirm) || mappedInput.wasScreenTapped(tapX, tapY)) {
|
||||
mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
|
||||
finish();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
#include "KOReaderSettingsActivity.h"
|
||||
|
||||
#include <FreeInkUI.h>
|
||||
#include <GfxRenderer.h>
|
||||
#include <I18n.h>
|
||||
|
||||
@@ -34,24 +33,12 @@ void KOReaderSettingsActivity::loop() {
|
||||
return;
|
||||
}
|
||||
|
||||
int downId = -1;
|
||||
if (mappedInput.wasItemTouchedDown(downId) && freeink::ui::listSelectIndex(selectedIndex, downId, MENU_ITEMS)) {
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
int tappedId = -1;
|
||||
if (mappedInput.wasItemTapped(tappedId) && tappedId >= 0 && tappedId < MENU_ITEMS) {
|
||||
selectedIndex = tappedId;
|
||||
handleSelection();
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
|
||||
handleSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle navigation
|
||||
buttonNavigator.onNext([this] {
|
||||
selectedIndex = (selectedIndex + 1) % MENU_ITEMS;
|
||||
requestUpdate();
|
||||
@@ -155,6 +142,7 @@ void KOReaderSettingsActivity::render(RenderLock&&) {
|
||||
},
|
||||
true);
|
||||
|
||||
// Draw help text at bottom
|
||||
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SELECT), tr(STR_DIR_UP), tr(STR_DIR_DOWN));
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user