Compare commits

...
Author SHA1 Message Date
Dylan Byars 973a902332 fix: duplicate User-Agent header on wolfSSL requests breaks strict servers (aiohttp 400) (#2661) 2026-07-21 01:48:26 -04:00
Justin Mitchell cb9b971c26 Refactor SCOPE.md for improved formatting
Updated SCOPE.md to remove unnecessary line breaks and improve readability.
2026-07-21 01:20:26 -04:00
Justin MitchellandJulia Nguyen f42fab1c66 feat: Add touch coordinate mapping and RTOS task yielding (#2481)
Co-authored-by: Julia Nguyen <julia@uxj.io>
2026-07-20 16:31:07 -04:00
Justin Mitchell c9188a7347 chore: Clarify project scope and development priorities (#2149)
Expand scope to support multiple e-ink devices beyond X4. Add guiding
principle to fill gaps in stock firmware rather than duplicate
functionality. Establish current focus on memory/flash optimization and
code cleanup. Temporarily freeze new themes and network connectors to
consolidate codebase before multi-device expansion.
2026-07-20 16:02:02 -04:00
Leopoldo Pla Sempere e03aa16330 fix: select strongest AP for matching WiFi SSID (#2655) 2026-07-20 18:17:16 +03:00
Phạm Bình An 3319aa1720 fix(epub): preserve word continuation when splitting CJK text on MAX_WORD_SIZE (#2652) 2026-07-20 15:02:08 +03:00
CookieCaptainD 3dfbc0383f fix(i18n): add missing strings in PT-PT translation (#2632) 2026-07-20 14:12:06 +03:00
Thiago Kenji Okada 64364bec5d feat: add Nix development shell (#2645) 2026-07-20 00:57:37 +03:00
Stefan Blixten Karlsson 556b8aee5b fix: swedish translation (#2649) 2026-07-19 22:12:10 +03:00
b1d1d757ba feat: Slim dictionary (#2583)
Co-authored-by: Ryan Hitchman <hitchmanr@gmail.com>
Co-authored-by: Kurtis Grant <kurtis.b.grant@gmail.com>
Co-authored-by: kemonine <kemonine@kemonine.info>
Co-authored-by: DustinHu <hu.dustin@gmail.com>
Co-authored-by: Justin Mitchell <justin@jmitch.com>
2026-07-19 17:57:16 +03:00
Uri Tauber 5ba1d5747f fix: EndOfBookOptions fails to compile
ActivityManager.h forward-declares Activity but holds
std::unique_ptr<Activity> members. Instantiating that unique_ptr's
destructor requires the complete type, so any TU including
ActivityManager.h without Activity.h fails to compile.

Include Activity.h directly. Adding it to ActivityManager.h instead does
not work: Activity.h depends on HomeMenuItem, which ActivityManager.h
defines.
2026-07-19 16:45:42 +03:00
Thomas Symalla 9fe4dc5e38 feat: Add option to switch behavior for "back to browser / home" in Reader activity (#2366)
## Summary

It would be nice to switch back to the file list from an Reader activity
via a short back button press. This change adds an Reader option to
switch the default behavior, so a short back button press in the Reader
activity can now go back to the file list, and a long press on back goes
back to the home view. This does a fair bit of refactoring, introducing
a new constant for the ms limit.

* **What changes are included?**

- Changes to the translation
- Additional global Reader option 
- Refactoring of the back button behavior in the Reader activity
2026-07-19 08:29:13 +03:00
Justin Mitchell b1d037569b feat: Add kosync user registration and switch to crosspoint-sync server (#2587)
Implements createUser() endpoint to allow account creation via the
KOSync protocol. Changes default sync server from sync.koreader.rocks to
sync.crosspointreader.com with migration logic to preserve existing
users' server settings. Extends sync protocol to include position data
(spine index, page numbers, xpath) that crosspoint-sync supports while
remaining compatible with standard kosync servers.
2026-07-18 14:50:51 -04:00
Julia 9737cb335c fix: correct the settings enums for "blank" and "cover + custom" sleep screens (#2635) 2026-07-17 12:35:51 -04:00
Phạm Bình An fdffc2e5d9 fix: reduce CSS parse-time OOM risk in chapter layout (#2606) 2026-07-16 07:21:08 +03:00
a2db43d235 feat: enable CORS headers in the HTTP API (#2594)
Closes #2558.

Enables the Arduino WebServer's built-in CORS support
(`enableCORS(true)`), which adds
`Access-Control-Allow-Origin/Methods/Headers: *` to every response, and
answers preflight `OPTIONS` requests with `204` in `handleNotFound()` —
routes are registered per-method, so OPTIONS always lands there. The
AP-mode captive-portal redirect is untouched (the OPTIONS check runs
before it, and browsers don't send preflights for captive-portal
probes).

This lets web-based clients and PWAs served from other origins call the
JSON API (`/api/status`, `/api/files`, `/api/settings`, ...) directly
from the browser.

Overhead is three static response headers; no behavior change for the
built-in web UI.

Note: not yet tested on hardware.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: metoli <metoli@metoli-Mac-mini.local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 18:35:10 -04:00
rxmmahandkira 63093e606c feat: Back on home menu opens the most recent book (#2619)
Co-authored-by: kira <rammah@tuta.io>
2026-07-15 23:50:22 +03:00
a4ac3b2788 feat: add smart KOReader progress sync (#2192)
## Summary

Make KOReader progress sync a one-click flow for common cases while
keeping the existing manual mode available.

### Changes

- Add a **Sync Behavior** setting:
  - **Smart sync** for new configurations
  - **Ask every time** for manual control
- Preserve **Ask every time** when migrating an existing credential file
that predates this setting.
- In Smart mode:
  - upload local progress when no remote record exists;
  - show a short confirmation when already synced;
  - upload when local progress is further ahead;
  - apply remote progress when remote progress is further ahead.
- Probe both KOReader document-matching hashes before making the Smart
decision, while keeping uploads on the user's configured matching
method.
- Auto-return after successful Smart terminal states, with Back/Confirm
still available.
- Persist the setting alongside the current credential, matching-method,
and send-metadata fields.
- Document both behaviors in the user guide.

## Additional context

This pairs well with #2189 (smart Wi-Fi auto-connect), but does not
depend on it. It does not add background Wi-Fi or passive network
detection; sync is still triggered by the user from the reader menu.

Smart sync uses the furthest progress because CrossPoint does not
currently persist local progress timestamps. Users who prefer explicit
conflict resolution can select **Ask every time**.

## Verification

- `git diff --check`
- `platformio check --fail-on-defect low --fail-on-defect medium
--fail-on-defect high` — no defects
- `platformio run -e default` — firmware build successful
- The original implementation was manually smoke-tested on an X4 device.

---

### AI Usage

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

AI tools were used to inspect the codebase, draft and review the
implementation, resolve the rebase against current `develop`, and
prepare the PR text. The resulting firmware was built locally.

---------

Co-authored-by: Alexander Hoffer <git@alexanderhoffer.com>
Co-authored-by: Alexander Hoffer <contact@alexanderhoffer.com>
2026-07-15 14:59:15 -04:00
Víctor Fernández 35a45f9c1e feat: add options to remember web upload settings & rename ebooks to {title} - {author} (#2534) 2026-07-15 14:43:42 -04:00
161 changed files with 6839 additions and 1623 deletions
+13
View File
@@ -0,0 +1,13 @@
blank_issues_enabled: false
contact_links:
- name: Scope or Roadmap Question (start here if unsure)
url: https://github.com/crosspoint-reader/crosspoint-reader/discussions
about: |
Not sure if your idea fits CrossPoint's scope? Start a Discussion before filing an issue.
See SCOPE.md and ROADMAP.md for what is in, out, and currently paused.
- name: Read the Scope document
url: https://github.com/crosspoint-reader/crosspoint-reader/blob/master/SCOPE.md
about: The authoritative list of what CrossPoint will and will not accept.
- name: Read the Roadmap
url: https://github.com/crosspoint-reader/crosspoint-reader/blob/master/ROADMAP.md
about: Current phase, what is being closed out, and what comes next.
@@ -0,0 +1,80 @@
name: Feature Request
description: Propose a new feature, enhancement, or change to CrossPoint
title: "Short, descriptive title of the request"
labels: ["enhancement", "needs-scope-review"]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to propose a change to CrossPoint!
**Before you continue, please read [SCOPE.md](../blob/master/SCOPE.md) and [ROADMAP.md](../blob/master/ROADMAP.md).**
CrossPoint is intentionally narrow. Most rejected proposals are rejected for scope reasons that are already
documented. The checklist below exists to save both of us time.
If you are not sure whether your idea fits, open a [Discussion](../../discussions) first instead of filing
this issue.
- type: checkboxes
id: scope-check
attributes:
label: Scope Self-Check (required)
description: Please confirm each of the following. If any are unchecked, your issue will likely be closed.
options:
- label: I have read SCOPE.md and ROADMAP.md.
required: true
- label: This is **not** a new theme or theming change (themes are temporarily closed pending the move to SD-loaded themes).
required: true
- label: This is **not** a new external network connector (sync engine, cloud storage, remote file access, OPDS extensions beyond what exists, or any new "talk to a server" feature).
required: true
- label: This is **not** an interactive app (game, calculator, notepad), writing/authoring tool, RSS/news/browser feature, media playback feature, or PDF rendering.
required: true
- label: The stock firmware does **not** already handle this well.
required: true
- label: No other popular CrossPoint fork already handles this well (or, if one does, I explain below why CrossPoint still needs it).
required: true
- type: textarea
id: problem
attributes:
label: Problem this solves
description: What user-facing problem or reading-experience gap does this address? Be concrete.
placeholder: e.g., "When reading in landscape, paragraph breaks are inconsistent because..."
validations:
required: true
- type: textarea
id: stock-gap
attributes:
label: Why the stock firmware (and other forks) do not already solve this
description: Explain which existing solutions you checked and why they fall short. This is the core scope filter.
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Proposed change
description: A short description of what you would build or change. Focus on user impact; implementation details can come later.
validations:
required: true
- type: textarea
id: tradeoffs
attributes:
label: Memory / flash / complexity cost
description: |
CrossPoint runs on 380KB of RAM. Roughly how much DRAM, flash, or code complexity does this add?
"Don't know" is a valid answer, but please attempt an estimate.
placeholder: e.g., "Adds ~2KB flash for the new font tables, no DRAM impact at runtime."
validations:
required: false
- type: textarea
id: additional-context
attributes:
label: Additional context
description: Anything else relevant (links, screenshots, related discussions).
validations:
required: false
+18
View File
@@ -3,10 +3,28 @@
* **What is the goal of this PR?** (e.g., Implements the new feature for file uploading.)
* **What changes are included?**
## Scope Check
CrossPoint is intentionally narrow. See [SCOPE.md](../blob/master/SCOPE.md) and [ROADMAP.md](../blob/master/ROADMAP.md).
Please confirm:
- [ ] I have read SCOPE.md and ROADMAP.md.
- [ ] This PR is **not** a new built-in theme (themes are temporarily closed pending the move to SD-loaded themes).
- [ ] This PR is **not** a new external network connector (sync engine, cloud storage, remote file access, etc.).
- [ ] This PR is **not** an interactive app, writing tool, RSS/news/browser, media playback, or PDF feature.
- [ ] The stock firmware does not already handle this well, **and** no other popular CrossPoint fork already does
(or, if one does, I explain why CrossPoint still needs it below).
- [ ] If this PR touches `freeink-sdk/`, `lib/hal/`, the bootloader, OTA, or recovery code, I have coordinated with
the relevant maintainer.
**If this PR was opened against the previous (broader) scope and was already in flight under Phase 0, link the
relevant Discussion or issue so reviewers can see the history.**
## Additional Context
* Add any other information that might be helpful for the reviewer (e.g., performance implications, potential risks,
specific areas to focus on).
* Memory / flash impact, if known.
---
+5 -1
View File
@@ -91,9 +91,13 @@ jobs:
restore-keys: pio-${{ runner.os }}-
- name: Build CrossPoint
# Both envs in ONE pio invocation: a second `pio run` can wipe the whole
# .pio/build tree (project-checksum mismatch after the S3 toolchain
# installs into the restored cache), deleting the default firmware.bin
# before the artifact upload.
run: |
set -euo pipefail
pio run | tee pio.log
pio run -e default -e sticky | tee pio.log
- name: Extract firmware stats
+20 -3
View File
@@ -12,7 +12,7 @@ CrossPoint is open-source e-reader firmware - community-built, fully hackable, f
## What can CrossPoint do?
- **Reader engine**: EPUB 2/3 rendering with embedded-style option, image handling, hyphenation, kerning, chapter navigation, footnotes, bookmarks, go-to-percent, auto page turn, orientation control, focus reading, KOReader progress sync and more.
- **Reader engine**: EPUB 2/3 rendering with embedded-style option, image handling, hyphenation, kerning, chapter navigation, footnotes, bookmarks, dictionary lookups ([StarDict](docs/dictionary.md)), go-to-percent, auto page turn, orientation control, focus reading, KOReader progress sync and more.
- **Various formats**: native handling for `.epub`, `.xtc/.xtch`, `.txt`, and `.bmp`.
@@ -42,8 +42,6 @@ CrossPoint is open-source e-reader firmware - community-built, fully hackable, f
### Coming soon:
- Dictionary lookup — inline word lookup without leaving the reader.
- More themes.
- Much more! stay tuned.
@@ -138,6 +136,7 @@ Conversion runs the firmware repo's `lib/EpdFont/scripts/fontconvert_sdcard.py`
- [Web server endpoints](./docs/webserver-endpoints.md)
- [Project scope](./SCOPE.md)
- [Contributing docs](./docs/contributing/README.md)
- [Touch and UI development](./docs/contributing/touch-and-ui.md) - FreeInkUI components for new screens, the touch bridge for existing ones, and build envs for the non-Xteink touch devices
---
@@ -160,6 +159,24 @@ cd crosspoint-reader
git submodule update --init --recursive
```
### Nix/NixOS
Nix/NixOS users can enter the development shell with either `nix develop` (flakes) or `nix-shell`:
```bash
nix develop -f nix
# or
nix-shell nix
```
To flash a connected ESP32-C3 device, enable PlatformIO's udev rules in your NixOS configuration:
```nix
services.udev.packages = with pkgs; [ platformio-core.udev ];
```
After rebuilding the system configuration, reconnect the device or reload udev rules.
### Build / flash / monitor
```bash
+88
View File
@@ -0,0 +1,88 @@
# CrossPoint Reader Roadmap
This roadmap describes how CrossPoint is moving through the tighter scope defined in [SCOPE.md](SCOPE.md). It is
intentionally phased: Phase 0 closed out the commitments already in flight before locking down to the stricter
"fill gaps the stock firmware leaves" delineator.
Phases are sequential. We do not start the next phase until the prior one is wrapped or explicitly carried over.
---
## Phase 0 - Close Out Legacy Scope Items — **COMPLETE**
**Goal:** Land the work that was already in motion under the prior, broader scope so contributors are not left
hanging, and so we enter the stricter phases with a clean slate.
**Landed in Phase 0:**
* **RTL support PRs.** The in-flight right-to-left work was reviewed, iterated, and merged.
* **Dictionary PR.** The offline dictionary lookup work was reviewed and merged.
* **Bookmarks** feature. First-class navigation markers in EPUBs.
* ~~**Transparent sleep screens.**~~ Shelved; not picked back up under the stricter phases.
Phase 0 is closed. The tighter scope in [SCOPE.md](SCOPE.md) is now fully enforced. "But it was on the old roadmap"
is not a valid argument for accepting a PR.
---
## Phase 1 - Consolidation, Footprint, and Multi-Device Support — **IN PROGRESS**
**Goal:** Reduce memory and flash usage, clean up the codebase, and land the SDK / HAL generalization work so
CrossPoint runs cleanly on ESP32-based e-reader hardware beyond Xteink (X3 / X4), including ESP32-S3 class devices.
**Focus areas:**
* DRAM and heap fragmentation reduction across the reader core.
* Flash footprint reduction (dead code, redundant strings, oversized tables).
* Refactors that tighten the HAL / SDK boundary.
* Pluggable per-device SDK layers (display, input, storage, battery) and per-device build configuration without
forking the reader core.
* Documentation for adding a new ESP32 e-reader target.
* E-ink driver refinement (ghosting, partial update behavior).
**Closed during this phase:** new themes built into firmware, new external network connectors (sync engines, cloud
storage, remote file access).
---
## Phase 2 - Languages, Fonts, and Themes
**Goal:** With the codebase smaller and portable, make reading great in every language: multi-language support,
better font support with custom fonts, UI translations, and themes loaded from the SD card instead of consuming
flash.
**Focus areas:**
* Multi-language reading support (underserved languages, complex script support where realistic on ESP32 hardware).
* Better font support and custom fonts.
* UI languages and localization.
* Moving themes off-firmware to SD-loaded assets (see SCOPE.md Section 6).
* **Moving hyphenation files off-firmware.** Hyphenation rules vary per language and the files are large (German
alone is ~200KB). Today these eat flash budget that should be available for the reader core. The plan is to build
a downloader analogous to the existing font downloader and store the dictionaries on SD / SPIFFS, loading on
demand. This unlocks better hyphenation for long-word languages (German, Finnish, Norwegian, etc.) without paying
the flash cost up front.
This phase depends on Phase 1 cleanup landing first; otherwise we generalize a moving target.
---
## Out of Roadmap
The following are explicitly *not* on the roadmap. They may live in other CrossPoint forks; they will not be picked
up here:
* Interactive apps (games, calculators, notepads).
* Writing / authoring tools.
* Active connectivity features (RSS, news, browsers).
* PDF rendering as a first-class format.
See [SCOPE.md](SCOPE.md) for the full rationale.
---
## How This Roadmap Changes
* Phase boundaries are decided by maintainers, not by individual PRs.
* If a phase needs to be extended or an item carried over, that is documented here with a short note.
* Proposals for new phases or reordering should go through a Discussion first.
+80 -42
View File
@@ -1,62 +1,100 @@
# Project Vision & Scope: CrossPoint Reader
The goal of CrossPoint Reader is to create an efficient, open-source reading experience for the Xteink X4. We believe a
dedicated e-reader should do one thing exceptionally well: **facilitate focused reading.**
The goal of CrossPoint Reader is to create an efficient, open-source reading experience for ESP32-based e-reader devices. Xteink hardware (X3, X4) is where the project started and remains a primary target, but CrossPoint is explicitly broadening to support the wider ecosystem of small ESP32 e-ink readers. We believe a dedicated e-reader should do one thing exceptionally well: **facilitate focused reading.**
## 1. Core Mission
To provide a lightweight, high-performance firmware that maximizes the potential of the X4, prioritizing legibility and
usability over "swiss-army-knife" functionality.
To provide a lightweight, high-performance firmware that maximizes the potential of ESP32-based e-reader hardware, prioritizing legibility, performance, and usability over "swiss-army-knife" functionality.
## 2. Scope
CrossPoint is **not** a kitchen-sink firmware, and it is **not** Xteink-only. We want clean, maintainable code that the community can build on, and that runs across the range of ESP32 e-reader devices (ESP32-C3, ESP32-S3, and adjacent variants). Every accepted change should make that goal easier, not harder. Device-specific code should live behind the HAL / SDK boundary so the reader core stays portable.
## 2. Guiding Principle: Fill Gaps the Stock Firmware Leaves
CrossPoint exists to do the things the stock firmware does poorly or not at all. New work is evaluated against that delineator:
* **Does the stock firmware already do this well?** We should hit that bar or surpass it
* **Is another popular CrossPoint fork already solving this well?** If yes, we generally defer to that fork if it's not part of the core reading experience. e.g. stats
* **Does this directly improve the reading experience or the firmware's long-term maintainability?** If no, it is out of scope.
## 3. Current Focus
We are intentionally narrowing scope to consolidate the codebase as we open it up to more ESP32 e-reader devices.
During this period, the priorities are:
* **Memory footprint:** Reducing DRAM usage and heap fragmentation. The ESP32-C3 is the tightest target and sets the ceiling, but the gains benefit every ESP32 variant we run on.
* **Flash footprint:** Trimming binary size to leave room for additional device targets and features.
* **Code cleanup:** Refactoring, removing dead code, tightening abstractions, and improving readability.
* **Reading experience:** EPUB parsing and rendering, typography, hyphenation, line spacing, font handling, and legibility improvements.
### Temporarily Closed Areas
PRs in the following areas will be closed until this notice is lifted. Adding these now makes the cleanup and multi-device work materially harder:
* **New themes.** The existing theming surface is frozen.
* **New external network connectors.** This includes sync engines, cloud storage clients, OPDS extensions beyond what exists, remote file access, and any new "talk to a server" feature. We now have our own CrossPoint KOSync server which gives us a way to sync to 3rd party systems like Hardcover at an API level instead of bloating the firmware. If you're interested in helping here, the sync server is also open source.
If you are unsure whether your idea falls into one of these categories, open a Discussion first.
## 4. Scope
### In-Scope
*These are features that directly improve the primary purpose of the device.*
*Features that directly improve the core reading experience or the firmware's maintainability.*
* **User Experience:** E.g. User-friendly interfaces, and interactions, both inside the reader and navigating the
firmware. This includes things like button mapping, book loading, and book navigation like bookmarks.
* **Document Rendering:** E.g. Support for rendering documents (primarily EPUB) and improvements to the rendering
engine.
* **Format Optimization:** E.g. Efficiently parsing EPUB (CSS/Images) and other documents within the device's
capabilities.
* **Typography & Legibility:** E.g. Custom font support, hyphenation engines, and adjustable line spacing.
* **E-Ink Driver Refinement:** E.g. Reducing full-screen flashes (ghosting management) and improving general rendering.
* **Library Management:** E.g. Simple, intuitive ways to organize and navigate a collection of books.
* **Local Transfer:** E.g. Simple, "pull" based book loading via a basic web-server or public and widely-used standards.
* **Language Support:** E.g. Support for multiple languages both in the reader and in the interfaces.
* **Reference Tools:** E.g. Local dictionary lookup. Providing quick, offline definitions to enhance comprehension
without breaking focus.
* **Clock Display (device dependent):**
| Device | Scope |
| -- | -- |
| X3 | The X3 uses a dedicated DS3231 RTC, which maintains accurate time across sleep cycles and can be treated as a reliable wall clock. |
| X4 | The X4 relies on the ESP32-C3's internal RTC, which drifts significantly during deep sleep. NTP sync could correct this, with an appropriate user experience around connecting to the internet on wake or on demand. This causes some tension with the **Active Connectivity** section below, so please open a discussion about this UX if it's a feature you would find useful. |
* **EPUB Rendering & Optimization:** Improvements to the rendering engine, CSS/image handling, and parsing performance.
* **Typography & Legibility:** Custom font support, hyphenation, line and paragraph spacing, margins.
* **E-Ink Driver Refinement:** Reducing full-screen flashes (ghosting management) and improving general rendering.
* **Reading UX:** Bookmarks, progress tracking, button mapping, page navigation, and other in-reader interactions.
* **Library Management:** Simple, intuitive ways to organize and navigate a local book collection.
* **Memory, Flash, and Code Quality:** Refactors and cleanups that reduce resource use or improve maintainability, even without a user-visible feature.
### Out-of-Scope
*These items are rejected because they compromise the device's stability or mission.*
*Rejected because they compromise the device's stability, maintainability, or core mission.*
* **Interactive Apps:** No Notepads, Calculators, or Games. This is a reader, not a PDA.
* **Active Connectivity:** No RSS readers, News aggregators, or Web browsers. Background Wi-Fi tasks drain the battery
and complicate the single-core CPU's execution.
* **Media Playback:** No Audio players or Audio-books.
* **Complex Annotation:** No typed out notes. These features are better suited for devices with better input
capabilities and more powerful chips.
* **Interactive Apps:** No notepads, calculators, or games. These belong in other forks and are not part of CrossPoint's focus.
* **Writing / Authoring Tools:** No typed notes, journals, or editors. Input hardware and RAM are wrong for this, and other forks already explore this space.
* **Active Connectivity:** No RSS readers, news aggregators, or web browsers. Background Wi-Fi drains the battery and complicates the single-core CPU.
* **PDF Rendering:** PDFs are fixed-layout documents, so rendering them requires displaying pages as images rather than reflowable text, resulting in constant panning and zooming that makes for a poor reading experience on e-ink. Out of scope on the current hardware class.
### In-scope — Technically Unsupported
## 5. Calls to Action
*These features align with CrossPoint's goals but are impractical on the current hardware or produce poor UX.*
These are the areas where contributor help is most valuable right now. If you want to take one of these on, open a Discussion or issue first so we can coordinate.
* **PDF Rendering:** PDFs are fixed-layout documents, so rendering them requires displaying pages as images rather than reflowable text — resulting in constant panning and zooming that makes for a poor reading experience on e-ink.
### Theme System: Move Themes Off-Firmware
## 3. Idea Evaluation
We want to abstract themes out of the firmware entirely so they no longer consume flash, and instead load from the SD card. This directly supports the current focus on flash footprint and code cleanup.
While I appreciate the desire to add new and exciting features to CrossPoint Reader, CrossPoint Reader is designed to be
a lightweight, reliable, and performant e-reader. Things which distract or compromise the device's core mission will not
be accepted. As a guiding question, consider if your idea improve the "core reading experience" for the average user,
and, critically, not distract from that reading experience.
* **Status:** [@itsthisjustin](https://github.com/itsthisjustin) plans to take this on eventually but is very open to someone else claiming it sooner.
* **Why it matters:** Every built-in theme costs flash that we would rather spend on rendering, fonts, or future device support. SD-loaded themes also let users customize without rebuilding firmware. It also leads to SD font loading for better language support in the UI.
* **How to claim:** Comment on the relevant Discussion (or open one) before starting.
> **Note to Contributors:** If you are unsure if your idea fits the scope, please open a **Discussion** before you start
> coding!
### Identifying Other Stock-Firmware Gaps
We want help cataloguing things the stock firmware (and other popular CrossPoint forks) handle poorly or not at all, so future work has a clear target list. Particularly interested in:
* **RTL (right-to-left) text support:** Arabic, Hebrew, Persian, and similar scripts.
* **Languages with poor stock and fork coverage:** Especially those that need shaping, complex layout, or non-Latin font work that nobody is handling well today.
* **Other gaps:** Rendering edge cases, accessibility issues, input quirks, anything stock does badly and existing forks have not fixed.
If you can read or use the device in one of these languages, your feedback (even without code) is genuinely useful. Open a Discussion with concrete examples (screenshots, sample EPUBs, expected vs actual behavior) and we will prioritize from there.
## 6. Funding and Contributor Sustainability
CrossPoint uses [Royalty.dev](https://royalty.dev) (yes, a product built by [@itsthisjustin](https://github.com/itsthisjustin)) to fund contributors. There has been some tension in the community around this, so the intent is being clarified here directly.
**Why we do this:**
* To maintain long-term interest from contributors and maintainers, in direct response to substantial community requests for a way to give back.
* To motivate contributors to invest in the *core* project rather than spinning up competing forks.
* To help pay for new ESP32 devices so we can port CrossPoint to additional hardware.
* To give the project a credible long-term path to sustainability.
**How it works:**
* Funds are distributed automatically to contributors based on impact to the codebase and tenure on the project.
* Over **$600** was raised in the first few hours after opening up funding, which is a signal the demand is real.
* The exact scoring methodology is published at <https://app.royalty.dev/transparency>.
**This is not fixed in stone.** The weighting, eligibility, and distribution rules can be tweaked as we learn what works for this project. If you have concerns or suggestions about how funds are allocated, open a Discussion. The goal is a system that fairly recognizes the people doing the work, not a perfect one on day one.
+45 -28
View File
@@ -28,8 +28,10 @@ Welcome to the **CrossPoint** firmware. This guide outlines the hardware control
- [3.6.5 OPDS Servers (Multiple Libraries)](#365-opds-servers-multiple-libraries)
- [3.6.6 Web Settings (Wi-Fi + OPDS)](#366-web-settings-wi-fi--opds)
- [3.6.7 KOReader Sync Quick Setup](#367-koreader-sync-quick-setup)
- [Option A: Free Public Server (`sync.koreader.rocks`)](#option-a-free-public-server-synckoreaderrocks)
- [Option B: Self-Hosted Server (Docker Compose)](#option-b-self-hosted-server-docker-compose)
- [Option A: CrossPoint Sync Server (`sync.crosspointreader.com`, default)](#option-a-crosspoint-sync-server-synccrosspointreadercom-default)
- [Option B: Legacy Public KOReader Server (`sync.koreader.rocks`)](#option-b-legacy-public-koreader-server-synckoreaderrocks)
- [Option C: Self-Hosted Server (Docker Compose)](#option-c-self-hosted-server-docker-compose)
- [Syncing While Reading](#syncing-while-reading)
- [3.7 Sleep Screen](#37-sleep-screen)
- [Cover settings](#cover-settings)
- [Custom images](#custom-images)
@@ -261,6 +263,8 @@ The Settings screen allows you to configure the device's behavior. There are a f
- "ON" - Vertical space will be added between paragraphs in Reading Mode
- "OFF" - Paragraphs will not have vertical space added, but will have first-line indentation
- **Dictionary**: Select the StarDict dictionary used for word lookups while reading, or "None" to disable lookups. *(Only shown when at least one dictionary folder exists under `/dictionaries/` on the SD card — see [docs/dictionary.md](docs/dictionary.md) for setup and usage.)*
- **Text Anti-Aliasing**: Whether to show smooth grey edges (anti-aliasing) on text in reading mode. Note this slows down page turns slightly.
- **Images**: Whether to display embedded images (JPG/PNG) found in EPUB files; options are "ON" (default) or "OFF".
@@ -280,6 +284,7 @@ The Settings screen allows you to configure the device's behavior. There are a f
- **Long-press Menu**: Selects the function bound to holding the menu button (Confirm) while reading an EPUB. **Cycles through the available functions** each time the setting is selected — additional functions may be added in future releases, so this is not a binary on/off toggle. A short press of Confirm always opens the reader menu as normal:
- "Bookmark" (default) - Hold Confirm (~0.4 second) to drop a bookmark at the current page.
- "KOSync" - Hold Confirm (~1 second) to launch KOReader sync directly.
- "Dictionary" - Hold Confirm (~0.4 second) to start dictionary word selection on the current page (see [docs/dictionary.md](docs/dictionary.md)).
- "Disabled" - Long-press is ignored; only short-press opens the reader menu.
- **Short Power Button Click**: Controls the effect of a short click of the power button:
@@ -297,7 +302,7 @@ The Settings screen allows you to configure the device's behavior. There are a f
- **Wi-Fi Networks**: Connect to Wi-Fi networks for file transfers and firmware updates.
- **KOReader Sync**: Options for setting up KOReader for syncing book progress.
- **KOReader Sync**: Options for setting up KOReader for syncing book progress. **Smart sync** is the default for new configurations and auto-resolves simple push/pull decisions. Existing credential files retain **Ask every time** when migrated; you can switch Sync Behavior at any time if you prefer manual confirmation.
- **OPDS Servers**: Manage one or more OPDS [(Open Publication Distribution System)](https://en.wikipedia.org/wiki/Open_Publication_Distribution_System) libraries for browsing and downloading books. See [OPDS Servers (Multiple Libraries)](#365-opds-servers-multiple-libraries) below.
@@ -360,9 +365,37 @@ Behavior notes:
CrossPoint can sync reading progress with KOReader-compatible sync servers.
It also interoperates with KOReader apps/devices when they use the same server and credentials.
##### Option A: Free Public Server (`sync.koreader.rocks`)
##### Option A: CrossPoint Sync Server (`sync.crosspointreader.com`, default)
1. Register a user once (only if needed):
When **Sync Server URL** is left empty, CrossPoint uses the free CrossPoint sync server at `https://sync.crosspointreader.com`. It speaks the standard KOReader sync protocol (so KOReader apps can use it too) and additionally stores an exact spine/page position for lossless CrossPoint-to-CrossPoint sync.
1. On each CrossPoint device:
- Go to **Settings -> System -> KOReader Sync**.
- Set **Username** and **Password** (enter the plain password; CrossPoint computes MD5 internally, and use the same values on all devices).
- Leave **Sync Server URL** empty (or set it to `https://sync.crosspointreader.com`).
- On the first device, run **Sign Up** once to create the account directly from the device. On every other device, just run **Authenticate**.
Accounts are per server. Existing `sync.koreader.rocks` credentials do not exist on the CrossPoint server; either sign up again with the same username/password or use Option B to keep using the legacy server.
##### Option B: Legacy Public KOReader Server (`sync.koreader.rocks`)
Use this if you already sync KOReader devices against the official public server.
1. On each CrossPoint device:
- Go to **Settings -> System -> KOReader Sync**.
- Set **Sync Server URL** to `https://sync.koreader.rocks` (required; an empty URL now points at the CrossPoint server instead).
- Set **Username** and **Password** to your existing KOReader Sync credentials.
- Run **Authenticate**.
2. If you do not have an account yet, run **Sign Up** on the device, or register once with curl:
```bash
USERNAME="user"
@@ -375,27 +408,9 @@ curl -i "https://sync.koreader.rocks/users/create" \
--data "{\"username\":\"$USERNAME\",\"password\":\"$PASSWORD_MD5\"}"
```
Already have KOReader Sync credentials? Skip registration; basic sync only requires using the same existing username/password on all devices.
When this returns `HTTP 402` with `{"code":2002,"message":"Username is already registered."}`, pick a different username or use that existing account.
2. On each CrossPoint device:
- Go to **Settings -> System -> KOReader Sync**.
- Set **Username** and **Password** (enter the plain password; CrossPoint computes MD5 internally, and use the same values on all devices).
- Set **Sync Server URL** to `https://sync.koreader.rocks`, or leave it empty (both use the same default KOReader sync server).
- Run **Authenticate**.
3. While reading, press **Confirm** to open the reader menu, then select **Sync Progress**.
- Choose **Apply Remote** to jump to remote progress.
- Choose **Upload Local** to push current progress.
##### Option B: Self-Hosted Server (Docker Compose)
##### Option C: Self-Hosted Server (Docker Compose)
1. Start a sync server:
@@ -468,11 +483,12 @@ If this returns `HTTP 402` with `{"code":2002,"message":"Username is already reg
If you use the HTTPS listener, use `https://<server-ip>:7200` (`curl -k` only for self-signed certificate testing).
5. While reading, press **Confirm** to open the reader menu, then select **Sync Progress**.
##### Syncing While Reading
- Choose **Apply Remote** to jump to remote progress.
Once any of the options above is set up, press **Confirm** while reading to open the reader menu, then select **Sync Progress**. Alternatively, set **Settings -> Controls -> Long-press Menu** to **KOSync** and hold Confirm to launch sync directly.
- Choose **Upload Local** to push current progress.
- With **Sync Behavior** set to **Ask every time**, choose **Apply Remote** to jump to remote progress or **Upload Local** to push current progress.
- With **Sync Behavior** set to **Smart sync**, CrossPoint auto-resolves simple cases: upload when no remote progress exists, confirm and leave both unchanged when local and remote progress are already synchronized, upload when local progress is further ahead, or apply remote when remote progress is further ahead.
### 3.7 Sleep Screen
@@ -570,7 +586,7 @@ If the device goes to sleep or you close the book while viewing a footnote, the
* **Return to Home:** Press the **Back** button to close the book and return to the **[Home](#31-home-screen)** screen.
* **Return to Browse Files:** Press and hold the **Back** button to close the book and return to the **[Browse Files](#33-browse-files-screen)** screen.
* **Reader Menu:** Press **Confirm** to open the **[Reader Menu](#5-reader-menu)**, which includes chapter navigation, reading options, and more.
* **Long-press Confirm (configurable):** Holding **Confirm** runs the function chosen by the **Long-press Menu** setting in **[Controls Settings](#363-controls)** — "Bookmark" (default) drops a bookmark, "KOSync" launches KOReader Sync, "Disabled" does nothing. A short press always opens the Reader Menu.
* **Long-press Confirm (configurable):** Holding **Confirm** runs the function chosen by the **Long-press Menu** setting in **[Controls Settings](#363-controls)** — "Bookmark" (default) drops a bookmark, "KOSync" launches KOReader Sync, "Dictionary" starts a word lookup, "Disabled" does nothing. A short press always opens the Reader Menu.
### Supported Languages
@@ -592,6 +608,7 @@ Available options include:
- **Select Chapter** Open the table of contents to jump to a specific chapter (see [Chapter Selection](#51-chapter-selection) below).
- **Footnotes** Navigate to the footnotes for the current section *(only shown in books that contain footnotes)*.
- **Look Up** Select a word on the current page and show its dictionary definition (see [docs/dictionary.md](docs/dictionary.md)). Requires a dictionary to be selected in **Settings → Reader → Dictionary**.
- **Reading Orientation** Cycle through screen orientations without leaving the reader.
- **Auto Turn (Pages Per Minute)** Cycle through automatic page turn speed options for hands-free reading.
- **Go to %** Jump to a specific position in the book by percentage.
+1
View File
@@ -7,5 +7,6 @@ It is written for software developers who may be new to embedded development.
- [Architecture Overview](./architecture.md)
- [Development Workflow](./development-workflow.md)
- [Testing and Debugging](./testing-debugging.md)
- [Touch and UI Development](./touch-and-ui.md)
If you are new, start with [Getting Started](./getting-started.md).
+221
View File
@@ -0,0 +1,221 @@
# Touch and UI Development
CrossPoint now runs on touch devices (Seeed Sticky, M5Paper, LilyGo T5) alongside the button-only Xteink X3/X4. Every screen must work with both input styles. There are two supported ways to get there:
1. **New screens: build them with FreeInkUI components.** Touch hit-testing, tap highlighting, long-press, and button focus navigation come with the component; you never hand-roll coordinate math.
2. **Existing screens and in-flight features: use the MappedInputManager touch bridge.** A small set of helpers adds tap/hold/swipe support to hand-rolled rendering without restructuring the activity.
If you are starting a new activity, use FreeInkUI. If you have a feature branch with a hand-rolled screen already working on buttons, use the bridge; do not rewrite mid-flight.
---
## Path 1: New screens with FreeInkUI
FreeInkUI (`freeink-sdk/libs/ui/FreeInkUI`, namespace `freeink::ui`) is an immediate-mode component library. The core idea:
- While a component renders, it registers its tappable areas ("hit rects") into the frame's interaction buffer via `Frame::hit(rect, action, value, inputMask, state)`.
- Each loop you build an `InputSnapshot` from the input manager and route it against that buffer. If a tap (or mapped button press) lands in a registered rect, the routed `ActionEvent` tells you which action fired and with what value.
- Components query `frame.stateFor(action, value)` while painting, so touch-down highlight and focus states render correctly without any per-activity code.
You get touch, hold highlighting, long-press, minimum touch-target sizing, and orientation-aware coordinates for free. The same action IDs fire from physical buttons, so one code path serves both input styles.
### Component inventory
All under `freeink-sdk/libs/ui/FreeInkUI/include/components/`:
| Category | Components |
|---|---|
| Controls | `button`, `checkbox`, `slider`, `progress-bar`, `header` |
| Lists | `list` (virtualized), `table`, `dropdown`, `radio-group`, `setting-row`, `toggle-row`, `stepper-row` |
| Keyboard | `keyboard` (QWERTY/AZERTY/QWERTZ/ES layouts), `key-grid` |
| Overlays | `popup`, `option-dialog`, `context-menu`, `message-panel`, `toast` |
| Bars | `status-bar`, `tab-bar`, `reader-chrome`, `battery-indicator`, `gesture-bar`, `tap-zones` |
| Media | `book-card`, `cover-grid`, `cover-carousel`, `metric-card` |
| Text | `text-field`, `text-area` |
### Integration skeleton
The in-tree reference is [`src/activities/util/KeyboardEntryActivity.cpp`](../../src/activities/util/KeyboardEntryActivity.cpp); it drives the FreeInkUI keyboard component inside a normal Activity. The shape:
```cpp
#include <FreeInkUIGfxRenderer.h>
namespace fui = freeink::ui;
// Render step: draw the component and (re)register its hit rects.
fui::GfxRendererTarget target(renderer); // adapts GfxRenderer to FreeInkUI's DrawTarget
target.setFont(fui::GfxRendererTarget::FONT_BODY, UI_12_FONT_ID);
const fui::DeviceContext device = target.deviceContext(); // orientation, safe area, touch sizing
fui::Frame<48> frame(target, device, fui::InputSnapshot{}, interactions); // 48 = max hit rects
fui::KeyboardProps props;
props.layout = &currentLayout();
props.keyAction = ACTION_KEY; // one action id; the key is the event value
props.inputMask = fui::InputTouch | fui::InputLongPress;
fui::keyboard(frame, kbRect, props); // draws AND registers hit rects
```
```cpp
// Input step (each loop): feed touch state, act on routed events.
int tx, ty, tapX, tapY, hx, hy;
const bool pressedDown = mappedInput.wasScreenTouchDown(tx, ty);
const bool tapped = mappedInput.wasScreenTapped(tapX, tapY);
const bool inContact = mappedInput.isScreenTouchHeld(hx, hy);
const auto result = touchRouter.update(interactions, pressedDown, tx, ty,
tapped, tapX, tapY, inContact, millis());
if (result.event) {
activateValue(result.event.value, result.event.longPress);
requestUpdate();
} else if (result.activeChanged) {
requestUpdate(); // repaint touch-down highlight
}
```
Notes:
- `Frame<N>` is templated on the max number of hit rects; the buffer is stack/static, no heap.
- `fui::TouchHoldRouter` (see `components/keyboard/keyboard.h`) synthesizes long-press while the finger is still down and swallows the eventual release. Use it whenever a component distinguishes tap from long-press.
- For simpler screens without long-press, `FreeInkApp` / `Screen<N>` (`FreeInkApp.h`) is the ergonomic layer: it owns the interaction buffer, offers a top-to-bottom builder (`takeTop`, footer helpers, etc.), and its `render(input)` call routes the snapshot and dispatches registered action callbacks in one step. `snapshotFrom(input, device)` in `FreeInkUIInputManager.h` builds the `InputSnapshot` with orientation-aware touch mapping.
- Everything stays allocation-free and works on the button-only devices unchanged: physical buttons route through the same interaction table via each hit rect's `inputMask`.
---
## Path 2: Adding touch to existing hand-rolled screens
For activities that draw their own rows, menus, and buttons, `MappedInputManager` ([src/MappedInputManager.h](../../src/MappedInputManager.h)) exposes bridge helpers. They all return **logical screen coordinates** (orientation already applied via `GfxRenderer::tapToLogical`); never touch raw normalized panel coordinates or the SDK `InputManager` directly.
| Helper | Use for |
|---|---|
| `wasScreenTapped(x, y)` | A completed tap (press + release), with logical coords |
| `wasScreenTouchDown(x, y)` | Touch-down (held > 90 ms, not yet released): draw selection highlight |
| `isScreenTouchHeld(x, y)` | Live contact position while the finger is down (drag tracking) |
| `wasTapInRect(x, y, w, h)` | One-off hit test on a rectangle (a single button, a banner) |
| `wasListItemTapped(index, count, selected, listTop, listHeight, hasSubtitle)` | Taps on a standard UITheme list; does the row/paging math for you |
| `wasListItemTouchedDown(...)` | Same geometry, touch-down phase (highlight before activate) |
| `rowTouch(row, top, rowStep, rowCount, xStart, xEnd, rowHeight)` | Any custom band of equal-height rows; returns `RowTouch::None/Down/Tap` |
| `colTouch(col, left, colStep, colCount, yStart, yEnd, colWidth)` | Horizontal button bands (dialogs, prompts) |
| `wasSwipe()` | Raw swipe direction if you need one beyond the global gestures |
| `hasTouch()` | True when the device has a touch panel (rarely needed; helpers simply never fire without one) |
### Pattern A: standard themed list
One call per loop; UITheme owns the row geometry ([EpubReaderBookmarksActivity.cpp:123](../../src/activities/reader/EpubReaderBookmarksActivity.cpp)):
```cpp
int tapped = -1;
if (mappedInput.wasListItemTapped(tapped, bookmarks.size(), selectorIndex, listY, listHeight, true)) {
selectorIndex = tapped;
openBookmark();
return;
}
```
### Pattern B: custom rows with hold highlight
For non-theme row layouts, use `rowTouch` and distinguish `Down` (highlight) from `Tap` (activate), as in [EpubReaderFootnotesActivity.cpp](../../src/activities/reader/EpubReaderFootnotesActivity.cpp):
```cpp
int row = -1;
const auto touch = mappedInput.rowTouch(row, listTop, lineHeight, visibleCount,
contentX, contentX + contentWidth);
if (touch != MappedInputManager::RowTouch::None) {
const int touched = scrollOffset + row;
if (touch == MappedInputManager::RowTouch::Down) {
if (selectedIndex != touched) { selectedIndex = touched; requestUpdate(); }
} else { // RowTouch::Tap
selectedIndex = touched;
activateSelection();
}
return;
}
```
The `Down` state exists because e-ink repaints are slow: highlight on touch-down gives immediate feedback, activation happens on release.
### Global gestures: do not reimplement these
Three gestures are handled once, for every screen. Activities must not add their own edge-swipe handling:
| Gesture | Trigger | Where it is handled |
|---|---|---|
| Back | Right-swipe starting in the left 25% of the screen | Folded into `Button::Back`, so the existing `wasPressed(Button::Back)` in your activity already fires |
| Home | Up-swipe starting in the bottom 14% | `ActivityManager::loop()`; pops to Home (activities can override via `handleHomeGesture()`) |
| Menu | Down-swipe starting in the top 14% | Activities that have a menu check `wasMenuGesture()` themselves (the reader does this) |
Because the back gesture arrives as `Button::Back`, most button-era activities gain back-swipe support with zero changes. That is the bar to aim for: bridge helpers should make touch an additive layer over the button flow, not a second input state machine.
### Bridge rules
- Handle touch in `loop()` next to the existing button handling, one helper call per interaction zone, and `return` after consuming an event (mirrors the button pattern).
- Never call the SDK `InputManager` or read GPIO directly; the HAL rule from the main guide applies to touch too.
- Coordinates from the helpers are logical and orientation-correct on all four rotations; test at least Portrait and one Landscape mode before PR.
- Nothing to clean up in `onExit()`; the helpers are stateless from the activity's point of view.
---
## Building and testing on non-Xteink devices
Each MCU family is its own binary: X3/X4 are ESP32-C3, Sticky and LilyGo T5 are ESP32-S3, M5Paper v1.1 is a classic ESP32. The Sticky env ships in `platformio.ini` (`pio run -e sticky`). Envs for other devices go in **`platformio.local.ini`**, a gitignored file that PlatformIO merges over `platformio.ini` (see `extra_configs`). Create it next to `platformio.ini`; personal envs, ports, and debug flags live there and never get committed.
Both envs below extend the repo's `[base]`, so they build against the `freeink-sdk` submodule with all the normal deps and scripts.
### M5Paper v1.1 (classic ESP32, IT8951 panel)
```ini
[env:m5paper_v11]
extends = base
board = esp32dev
board_build.mcu = esp32
board_build.flash_mode = qio
; CP2104 UART bridge: 921600 drops out on macOS after the stub baud switch
upload_speed = 460800
build_unflags =
${base.build_unflags}
; classic ESP32 has UART serial, not USB CDC; Logging.h keys off these
-DARDUINO_USB_MODE=1
-DARDUINO_USB_CDC_ON_BOOT=1
build_flags =
${base.build_flags}
-DFREEINK_DEVICE_M5PAPER=1
; the 63KB 540x960 framebuffer lives in PSRAM (FREEINK_FB_PSRAM auto-on)
-DBOARD_HAS_PSRAM
-DCROSSPOINT_VERSION=\"${crosspoint.version}-m5paper\"
-DENABLE_SERIAL_LOG
-DLOG_LEVEL=2
; touch-first device: hide front-button hint labels
-DCROSSPOINT_SHOW_BUTTON_HINTS=0
; archive-scan-order workaround: without these a full relink drops Wire's i2c symbols
-Wl,-u,i2cInit
-Wl,-u,i2cSlaveInit
```
### LilyGo T5 S3 (ESP32-S3, controller-less panel via LovyanGFX)
```ini
[env:lilygo_t5s3]
extends = base
board = esp32-s3-devkitc1-n16r8
board_build.mcu = esp32s3
build_flags =
${base.build_flags}
-DFREEINK_DEVICE_LILYGO=1
; board injects the parallel-bus pins + PMIC power hooks (BoardT5S3)
-DFREEINK_LGFX_EPD_CONFIG=lilygoT5S3LgfxConfig
-DCROSSPOINT_VERSION=\"${crosspoint.version}-lilygo\"
-DENABLE_SERIAL_LOG
-DLOG_LEVEL=2
-DCROSSPOINT_SHOW_BUTTON_HINTS=0
lib_deps =
${base.lib_deps}
; LgfxEpdConfig for the T5 S3 (pins, PCA9535/TPS65185 power sequence)
BoardT5S3=symlink://freeink-sdk/libs/hardware/BoardT5S3
; LovyanGFX Panel_EPD drives the controller-less ED047TC1 panel
m5stack/M5GFX @ 0.2.20
```
Then `pio run -e m5paper_v11 -t upload` (or `-e lilygo_t5s3`). Gotchas worth knowing:
- **Flash mode matters.** The M5Paper is `qio`; the X4-family standalone envs need `dio`. A wrong flash-mode header boots into a `partition 0 invalid magic number 0xffff` loop even though esptool verified the write.
- **One `FREEINK_DEVICE_*` flag per env** selects the board profile (pins, panel, touch controller) from the SDK's `BoardConfig`. See `freeink-sdk/platformio.sample.ini` for reference envs of every supported device.
- **Serial logs:** `[base]` does not enable logging; without `-DENABLE_SERIAL_LOG` a non-default env prints nothing.
- No touch hardware on your desk? The X4 build still exercises the same code paths through buttons; touch-specific behavior (tap zones, gestures) needs a real device.
+51
View File
@@ -0,0 +1,51 @@
# Dictionary
Look up words while reading an EPUB using an offline StarDict dictionary stored on the SD card.
## Supported Format
The reader supports **StarDict** dictionaries. When searching for dictionaries online, look for "StarDict format" or files with `.dict`, `.idx`, and `.ifo` extensions.
A dictionary folder must contain:
- `.idx` — word index (required, **must be uncompressed** — a `.idx.gz` will not work; decompress it on your computer with `gzip -d` first)
- `.dict` or `.dict.dz` — definition data (`.dict.dz` is supported as-is; entries are decompressed on the fly during lookup)
- `.ifo` — metadata (optional)
Not supported: `.syn` synonym files (ignored), dictionaries with 64-bit index offsets (`idxoffsetbits=64` in the `.ifo` — rare, and rejected with an error), and HTML-formatted definitions render as raw markup rather than styled text.
## Setting Up a Dictionary
1. Copy your dictionary folder(s) to `/dictionaries/` on the SD card — one dictionary per folder, e.g. `/dictionaries/webster/webster.idx` + `webster.dict.dz`. A hidden `/.dictionaries/` folder (dot-prefixed) works the same way, for keeping it out of the file browser.
2. Open **Settings → Reader → Dictionary** on the device.
3. Select a dictionary from the list, or **None** to disable lookups.
The Dictionary setting only appears when at least one usable dictionary folder exists. Folders containing more than one dictionary (multiple `.idx` stems) are skipped as ambiguous.
## Looking Up a Word
Two ways to start a lookup while reading:
- Open the reader menu (**Confirm**) and choose **Look Up**.
- Or set **Settings → Controls → Long-press Menu** to "Dictionary", then hold **Confirm** (~0.4s) on the reading page.
One word on the page becomes highlighted:
1. Use **Left/Right** to move between words in reading order, and the side **Up/Down** buttons to jump between lines.
2. Press **Confirm** to look up the highlighted word.
3. Press **Back** to return to the reader.
On the very first lookup with a dictionary (and again if the dictionary file changes), the reader shows *"Indexing dictionary…"* while it builds a small `.qidx` sidecar file next to the `.idx`. This takes a few seconds for large dictionaries and makes all subsequent lookups fast. The sidecar can be deleted safely at any time — it will simply be rebuilt.
### How Lookup Works
1. **Direct match** — the word is found as-is (case-insensitive) in the dictionary index. Surrounding punctuation is ignored.
2. **Stemming** — on a miss, common English word forms are retried automatically: possessives and plurals (`dogs``dog`, `stories``story`) and verb endings (`walked``walk`, `running``run`, `making``make`).
3. **Not found** — a short popup appears and you return to word selection.
## The Definition Screen
When a word is found, the definition screen shows the matched headword at the top and the definition text below, with a page counter for long definitions.
- **Left/Right** or side **Up/Down** — previous / next page
- **Back** — return to word selection
+25 -23
View File
@@ -287,7 +287,30 @@ void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle,
effectiveNoSpaceBefore = true;
}
const auto ensureTokenCapacity = [&](const size_t additionalTokens) {
if (additionalTokens == 0) return;
const size_t requiredSize = words.size() + additionalTokens;
if (words.capacity() >= requiredSize) return;
size_t newCapacity = words.capacity();
if (newCapacity < 16) {
newCapacity = 16;
}
while (newCapacity < requiredSize) {
newCapacity *= 2;
}
words.reserve(newCapacity);
wordStyles.reserve(newCapacity);
wordContinues.reserve(newCapacity);
wordNoSpaceBefore.reserve(newCapacity);
wordIsFocusSuffix.reserve(newCapacity);
};
if (auto breakOffsets = cjkCharacterBreakByteOffsets(word); !breakOffsets.empty()) {
// CJK-heavy paragraphs can push hundreds of tiny tokens quickly when CSS toggles
// inline styles. Reserve once up front to avoid repeated vector growth reallocations.
ensureTokenCapacity(breakOffsets.size() + 1);
bool firstToken = true;
size_t tokenStart = 0;
for (const size_t breakOffset : breakOffsets) {
@@ -326,29 +349,8 @@ void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle,
// --- FOCUS READING LOGIC BELOW ---
// Pre-reserve capacity to prevent mid-word heap reallocations.
size_t maxPossibleNewTokens = word.length();
size_t requiredSize = words.size() + maxPossibleNewTokens;
if (words.capacity() < requiredSize) {
// Emulate standard geometric growth (doubling) to ensure we don't reallocate on every word.
size_t newCapacity = words.capacity() * 2;
// Ensure the doubled capacity is actually enough for this specific word
if (newCapacity < requiredSize) {
newCapacity = requiredSize;
}
// Set a sensible minimum starting size so the first few words don't trigger tiny reallocations
if (newCapacity < 16) {
newCapacity = 16;
}
words.reserve(newCapacity);
wordStyles.reserve(newCapacity);
wordContinues.reserve(newCapacity);
wordNoSpaceBefore.reserve(newCapacity);
wordIsFocusSuffix.reserve(newCapacity);
}
// Worst case: a segment boundary on each byte (highly punctuated UTF-8 text).
ensureTokenCapacity(word.length());
// Lambda helper to process and push individual sub-segments of the string
// Use std::string_view to avoid heap allocations when slicing
+1 -1
View File
@@ -17,7 +17,7 @@ namespace {
// v30: Arabic shaping changed both drawing and measurement (getTextAdvanceX now
// measures the shaped visual text); cached word positions from v29 no longer
// match what drawText renders.
constexpr uint8_t SECTION_FILE_VERSION = 30;
constexpr uint8_t SECTION_FILE_VERSION = 31;
// Written into the version field while a build is in progress; patched to
// SECTION_FILE_VERSION only when the build is finalized. An abandoned /
// crash-interrupted .bin therefore carries version 0, which loadSectionFile rejects
@@ -22,6 +22,17 @@
constexpr size_t MIN_SIZE_FOR_POPUP = 10 * 1024; // 10KB
constexpr size_t PARSE_BUFFER_SIZE = 1024;
// This number comes from PR #73
// If we have > 750 words buffered up, perform the layout and consume out all but the last line
// There should be enough here to build out 1-2 full pages and doing this will free up a lot of
// memory.
// Spotted when reading Intermezzo, there are some really long text blocks in there.
constexpr size_t TEXT_BLOCK_SOFT_FLUSH_WORDS = 750;
// When CSS is enabled, flush earlier to save RAM. 320 is still more than enough to build a CJK
// page at font size 14
constexpr size_t TEXT_BLOCK_SOFT_FLUSH_WORDS_WITH_CSS = 320;
// Hard cap on the number of anchor IDs recorded per chapter. Legitimate navigation
// anchors (TOC entries, footnotes, cross-references) rarely exceed a few hundred per
// chapter. A runaway count usually means a converter injected machine-generated IDs on
@@ -1143,24 +1154,28 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char
}
self->partWordBufferIndex = safeLen;
self->flushPartWordBuffer();
self->nextWordContinues = true;
for (int j = 0; j < overflow; j++) {
self->partWordBuffer[j] = saved[j];
}
self->partWordBufferIndex = overflow;
} else {
self->flushPartWordBuffer();
self->nextWordContinues = true;
}
}
self->partWordBuffer[self->partWordBufferIndex++] = s[i];
}
// If we have > 750 words buffered up, perform the layout and consume out all but the last line
// There should be enough here to build out 1-2 full pages and doing this will free up a lot of
// memory.
// Spotted when reading Intermezzo, there are some really long text blocks in there.
if (self->currentTextBlock->size() > 750) {
LOG_DBG("EHP", "Text block too long, splitting into multiple pages");
// Keep token growth bounded: CSS-heavy spans can fragment text into many tiny
// words, so flush earlier when embedded CSS is active. We still keep the
// "exclude last line" behavior to preserve paragraph flow across chunks.
const size_t blockWordCount = self->currentTextBlock->size();
const size_t softFlushThreshold =
self->embeddedStyle ? TEXT_BLOCK_SOFT_FLUSH_WORDS_WITH_CSS : TEXT_BLOCK_SOFT_FLUSH_WORDS;
if (blockWordCount > softFlushThreshold) {
LOG_DBG("EHP", "Text block soft flush (%u words)", static_cast<unsigned>(blockWordCount));
const int horizontalInset = self->currentTextBlock->getBlockStyle().totalHorizontalInset();
const uint16_t effectiveWidth = (horizontalInset < self->viewportWidth)
? static_cast<uint16_t>(self->viewportWidth - horizontalInset)
+131
View File
@@ -213,6 +213,61 @@ static inline void rotateCoordinates(const GfxRenderer::Orientation orientation,
}
}
// Output of screenRectToAlignedMemRect: a rectangle in panel-memory
// coordinates whose x and width are guaranteed to be multiples of 8 (the
// SDK's EInkDisplay::displayWindow alignment requirement). `valid == false`
// means the input was empty or fully outside the panel.
struct AlignedMemRect {
uint16_t x = 0;
uint16_t y = 0;
uint16_t w = 0;
uint16_t h = 0;
bool valid = false;
};
// Translate a screen-coordinate rectangle (the coordinate system used by
// fillRect / drawText / the rest of the renderer's public API) into a
// panel-memory rectangle suitable for direct framebuffer indexing. Rotates
// the rectangle's two opposite corners with rotateCoordinates(), takes the
// bounding box (which naturally swaps width/height in Portrait /
// PortraitInverted), then snaps the x extent outward to multiples of 8 and
// clamps to panel bounds. Precondition: panel dims are multiples of 8 (true
// for the 800x480 panel), so clamping cannot re-break alignment.
static AlignedMemRect screenRectToAlignedMemRect(GfxRenderer::Orientation orientation, int sx, int sy, int sw, int sh,
uint16_t panelWidth, uint16_t panelHeight) {
AlignedMemRect out;
if (sw <= 0 || sh <= 0) return out;
int x0, y0, x1, y1;
rotateCoordinates(orientation, sx, sy, &x0, &y0, panelWidth, panelHeight);
rotateCoordinates(orientation, sx + sw - 1, sy + sh - 1, &x1, &y1, panelWidth, panelHeight);
const int memXLo = std::min(x0, x1);
const int memYLo = std::min(y0, y1);
const int memXHi = std::max(x0, x1) + 1; // exclusive upper bound
const int memYHi = std::max(y0, y1) + 1;
// Snap x outward to multiples of 8.
int alignedXLo = memXLo & ~0x7; // round down
int alignedXHi = (memXHi + 7) & ~0x7; // round up
if (alignedXLo < 0) alignedXLo = 0;
if (alignedXHi > panelWidth) alignedXHi = panelWidth;
int clampedYLo = memYLo;
int clampedYHi = memYHi;
if (clampedYLo < 0) clampedYLo = 0;
if (clampedYHi > panelHeight) clampedYHi = panelHeight;
if (alignedXHi <= alignedXLo || clampedYHi <= clampedYLo) return out;
out.x = static_cast<uint16_t>(alignedXLo);
out.y = static_cast<uint16_t>(clampedYLo);
out.w = static_cast<uint16_t>(alignedXHi - alignedXLo);
out.h = static_cast<uint16_t>(clampedYHi - clampedYLo);
out.valid = true;
return out;
}
enum class TextRotation { None, Rotated90CW };
// Shared glyph rendering logic for normal and rotated text.
@@ -1451,6 +1506,53 @@ void GfxRenderer::displayBuffer(const HalDisplay::RefreshMode refreshMode) const
display.displayBuffer(refreshMode, fadingFix);
}
void GfxRenderer::displayBufferAsync(const HalDisplay::RefreshMode refreshMode) const {
// The async path has no turn-off-screen hook, which the sunlight fading fix
// relies on; keep those users on the blocking path.
if (fadingFix) {
display.displayBuffer(refreshMode, fadingFix);
return;
}
display.displayBufferAsync(refreshMode);
}
void GfxRenderer::waitRefreshComplete() const { display.waitRefreshComplete(); }
bool GfxRenderer::supportsAsyncRefresh() const { return !fadingFix && display.supportsAsyncRefresh(); }
size_t GfxRenderer::readFramebufferRegion(int x, int y, int w, int h, uint8_t* dst, size_t dstCapacity) const {
if (dst == nullptr || w <= 0 || h <= 0) return 0;
const AlignedMemRect mem = screenRectToAlignedMemRect(orientation, x, y, w, h, panelWidth, panelHeight);
if (!mem.valid) return 0;
const size_t rowBytes = mem.w / 8; // exact: mem.w is a multiple of 8
const size_t needed = rowBytes * mem.h;
if (needed > dstCapacity) return 0;
for (uint16_t row = 0; row < mem.h; ++row) {
const uint8_t* srcRow = frameBuffer + (static_cast<uint32_t>(mem.y + row) * panelWidthBytes) + (mem.x / 8);
uint8_t* dstRow = dst + (static_cast<size_t>(row) * rowBytes);
memcpy(dstRow, srcRow, rowBytes);
}
return needed;
}
void GfxRenderer::writeFramebufferRegion(int x, int y, int w, int h, const uint8_t* src) {
if (src == nullptr || w <= 0 || h <= 0) return;
const AlignedMemRect mem = screenRectToAlignedMemRect(orientation, x, y, w, h, panelWidth, panelHeight);
if (!mem.valid) return;
const size_t rowBytes = mem.w / 8; // exact: mem.w is a multiple of 8
for (uint16_t row = 0; row < mem.h; ++row) {
const uint8_t* srcRow = src + (static_cast<size_t>(row) * rowBytes);
uint8_t* dstRow = frameBuffer + (static_cast<uint32_t>(mem.y + row) * panelWidthBytes) + (mem.x / 8);
memcpy(dstRow, srcRow, rowBytes);
}
}
std::string GfxRenderer::truncatedText(const int fontId, const char* text, const int maxWidth,
const EpdFontFamily::Style style) const {
if (!text || maxWidth <= 0) return "";
@@ -1564,6 +1666,35 @@ int GfxRenderer::getScreenHeight() const {
return panelWidth;
}
void GfxRenderer::tapToLogical(float nx, float ny, int& outX, int& outY) const {
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;
switch (orientation) {
case Portrait:
outX = panelHeight - 1 - phyY;
outY = phyX;
break;
case PortraitInverted:
outX = phyY;
outY = panelWidth - 1 - phyX;
break;
case LandscapeClockwise:
outX = panelWidth - 1 - phyX;
outY = panelHeight - 1 - phyY;
break;
case LandscapeCounterClockwise:
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.
+21
View File
@@ -134,7 +134,19 @@ class GfxRenderer {
// Screen ops
int getScreenWidth() const;
int getScreenHeight() const;
void tapToLogical(float nx, float ny, int& outX, int& outY) const;
void displayBuffer(HalDisplay::RefreshMode refreshMode = HalDisplay::FAST_REFRESH) const;
// Non-blocking refresh: starts the waveform and returns so CPU work (e.g.
// grayscale strip rendering) can overlap the panel's refresh time. The
// framebuffer must stay untouched until waitRefreshComplete(). Falls back to
// a blocking refresh when fadingFix is enabled or the panel lacks deferral
// support. See HalDisplay::displayBufferAsync for the baseline contract.
void displayBufferAsync(HalDisplay::RefreshMode refreshMode = HalDisplay::FAST_REFRESH) const;
void waitRefreshComplete() const;
// True when displayBufferAsync() genuinely overlaps: panel defers and
// fadingFix isn't forcing the blocking path. Callers can skip overlap
// scaffolding (e.g. whole-plane grayscale buffers) when false.
bool supportsAsyncRefresh() const;
// EXPERIMENTAL: Windowed update - display only a rectangular region
// void displayWindow(int x, int y, int width, int height) const;
void invertScreen() const;
@@ -189,6 +201,15 @@ class GfxRenderer {
void drawBitmap1Bit(const Bitmap& bitmap, int x, int y, int maxWidth, int maxHeight) const;
void fillPolygon(const int* xPoints, const int* yPoints, int numPoints, bool state = true) const;
// Snapshot / restore a screen-coordinate framebuffer region (byte-aligned in
// panel memory). readFramebufferRegion returns the bytes written to dst, or
// 0 when the region is empty, offscreen, or exceeds dstCapacity. Pass the
// same rectangle to writeFramebufferRegion to restore the saved pixels.
// Enables partial-repaint patterns (e.g. moving a selection highlight)
// without re-rendering the whole page.
size_t readFramebufferRegion(int x, int y, int w, int h, uint8_t* dst, size_t dstCapacity) const;
void writeFramebufferRegion(int x, int y, int w, int h, const uint8_t* src);
// Text
int getTextWidth(int fontId, const char* text, EpdFontFamily::Style style = EpdFontFamily::REGULAR,
BidiUtils::BidiBaseDir baseDir = BidiUtils::BidiBaseDir::AUTO) const;
+1
View File
@@ -68,6 +68,7 @@ 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: "У Іўі худы жвавы чорт у зялёнай камізэльцы пабег пад'есці фаршу з юшкай"
+1
View File
@@ -78,6 +78,7 @@ STR_EOB_CONTINUE_WITH: "Continua amb"
STR_SHORT_PWR_BTN: "Pulsació curta 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: "Acció en mantenir premut un botó"
STR_LONG_PRESS_BEHAVIOR_OFF: "Desactivat"
+1
View File
@@ -68,6 +68,7 @@ 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"
+1
View File
@@ -72,6 +72,7 @@ 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: "Touchkontroller i 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"
+1
View File
@@ -72,6 +72,7 @@ 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"
+23
View File
@@ -10,6 +10,7 @@ STR_BROWSE_FILES: "Browse Files"
STR_FILE_TRANSFER: "File Transfer"
STR_SETTINGS_TITLE: "Settings"
STR_CONTINUE_READING: "Continue Reading"
STR_RESUME: "Resume"
STR_NO_OPEN_BOOK: "No open book"
STR_START_READING: "Start reading below"
STR_NO_FILES_FOUND: "No files found"
@@ -25,6 +26,12 @@ STR_EMPTY_FILE: "Empty file"
STR_OUT_OF_BOUNDS: "Out of bounds"
STR_LOADING: "Loading..."
STR_LOADING_POPUP: "Loading"
STR_LOOKUP: "Look Up"
STR_DICT_LOOKING_UP: "Looking up..."
STR_DICT_INDEXING: "Indexing dictionary..."
STR_DICT_NOT_FOUND: "Not found"
STR_DICT_NO_DICT_SET: "No dictionary set"
STR_DICT_ERROR: "Dictionary error"
STR_WIFI_NETWORKS: "Wi-Fi Networks"
STR_NO_NETWORKS: "No networks found"
STR_NETWORKS_FOUND: "%zu networks found"
@@ -81,6 +88,7 @@ STR_EOB_CONTINUE_WITH: "Continue with"
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"
@@ -108,7 +116,15 @@ STR_PASSWORD: "Password"
STR_SYNC_SERVER_URL: "Sync Server URL"
STR_DOCUMENT_MATCHING: "Document Matching"
STR_SEND_METADATA: "Send Document Metadata"
STR_SYNC_BEHAVIOR: "Sync Behavior"
STR_ASK_EVERY_TIME: "Ask every time"
STR_SMART_SYNC: "Smart sync"
STR_AUTHENTICATE: "Authenticate"
STR_SIGN_UP: "Sign Up"
STR_CREATING_ACCOUNT: "Creating account..."
STR_ACCOUNT_CREATED: "Account created"
STR_SIGNUP_FAILED: "Sign up failed"
STR_USERNAME_TAKEN: "Username is already registered"
STR_KOREADER_USERNAME: "KOReader Username"
STR_KOREADER_PASSWORD: "KOReader Password"
STR_FILENAME: "Filename"
@@ -154,6 +170,7 @@ STR_PREV_NEXT: "Prev/Next"
STR_NEXT_PREV: "Next/Prev"
STR_KOSYNC: "KOSync"
STR_BOOKMARK_OPTION: "Bookmark"
STR_DICTIONARY: "Dictionary"
STR_DISABLED: "Disabled"
STR_NOTO_SERIF: "Noto Serif"
STR_NOTO_SANS: "Noto Sans"
@@ -214,6 +231,7 @@ STR_CONNECT: "Connect"
STR_OPEN: "Open"
STR_DOWNLOAD: "Download"
STR_RETRY: "Retry"
STR_TAP_TO_RETRY: "Tap to retry"
STR_YES: "Yes"
STR_NO: "No"
STR_SHOW: "Show"
@@ -226,6 +244,9 @@ STR_DIR_RIGHT: "Right"
STR_DIR_UP: "Up"
STR_DIR_DOWN: "Down"
STR_OK_BUTTON: "OK"
STR_KEY_SHIFT: "Shift"
STR_KEY_MODE_SYMBOLS: "?123"
STR_KEY_MODE_ABC: "abc"
STR_SLEEP_COVER_FILTER: "Sleep Screen Cover Filter"
STR_FILTER_CONTRAST: "Contrast"
STR_CUSTOMISE_STATUS_BAR: "Customise Status Bar"
@@ -324,6 +345,7 @@ STR_UPLOAD_LOCAL: "Upload local progress"
STR_NO_REMOTE_MSG: "No remote progress found"
STR_UPLOAD_PROMPT: "Upload current position?"
STR_UPLOAD_SUCCESS: "Progress uploaded!"
STR_ALREADY_SYNCED: "Already synced"
STR_SYNC_FAILED_MSG: "Sync failed"
STR_SAVE_PROGRESS_FAILED: "Could not save progress"
STR_SECTION_PREFIX: "Section "
@@ -333,6 +355,7 @@ STR_EMBEDDED_STYLE: "Embedded Style"
STR_FOCUS_READING: "Focus Reading"
STR_OPDS_SERVER_URL: "OPDS Server URL"
STR_PWR_BTN_FOOTNOTE_BACK: "Quick-return from footnotes"
STR_BACK_SHORT_TO_FILE_BROWSER: "Short Back to File Browser"
STR_SET_SLEEP_COVER: "Set Cover"
STR_FOOTNOTES: "Footnotes"
STR_NO_FOOTNOTES: "No footnotes on this page"
+1
View File
@@ -68,6 +68,7 @@ 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: "Lukijan kosketusohjaus"
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Suuntaa etupainikkeet"
STR_LONG_PRESS_BEHAVIOR: "Long-press button behavior"
STR_LONG_PRESS_BEHAVIOR_OFF: "OFF"
+1
View File
@@ -72,6 +72,7 @@ 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é"
+2
View File
@@ -68,6 +68,7 @@ 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 beim Lesen"
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Vordere Tasten ausrichten"
STR_LONG_PRESS_BEHAVIOR: "Verhalten bei langem Tastendruck"
STR_LONG_PRESS_BEHAVIOR_OFF: "Aus"
@@ -315,6 +316,7 @@ STR_BOOK_S_STYLE: "Buch-Stil"
STR_EMBEDDED_STYLE: "Eingebetteter Stil"
STR_FOCUS_READING: "Fokus-Lesen"
STR_OPDS_SERVER_URL: "OPDS-Server-URL"
STR_BACK_SHORT_TO_FILE_BROWSER: "Kurz zurück drücken zum Datei-Browser"
STR_SET_SLEEP_COVER: "Wähle Cover"
STR_SCREENSHOT_BUTTON: "Screenshot aufnehmen"
STR_FOOTNOTES: "Fußnoten"
+1
View File
@@ -73,6 +73,7 @@ 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: "דלג פרק"
+1
View File
@@ -74,6 +74,7 @@ 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ős olvasóvezérlés"
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"
+1
View File
@@ -72,6 +72,7 @@ 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"
+1
View File
@@ -67,6 +67,7 @@ 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: "Канагаттандырылмагандыктарыныздан"
+1
View File
@@ -72,6 +72,7 @@ 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: "Lietimo valdikliai skaityklėje"
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ą"
+1
View File
@@ -72,6 +72,7 @@ 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 czytnika"
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Orientuj przednie przyciski"
STR_LONG_PRESS_BEHAVIOR: "Funkcja długiego przyciśnięcia"
STR_LONG_PRESS_BEHAVIOR_OFF: "Wył."
+1
View File
@@ -73,6 +73,7 @@ STR_IMAGES_SUPPRESS: "Ocultar"
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: "Controles táteis do leitor"
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Orientar botões frontais"
STR_LONG_PRESS_BEHAVIOR: "Comportamento de Pressionar e segurar"
STR_LONG_PRESS_BEHAVIOR_OFF: "DESL."
+7 -8
View File
@@ -18,7 +18,6 @@ STR_NO_CHAPTERS: "Sem capítulos"
STR_END_OF_BOOK: "Fim do livro"
STR_EMPTY_CHAPTER: "Capítulo vazio"
STR_INDEXING: "A indexar"
STR_INDEX_FAILED: "Falha ao indexar - livro inválido"
STR_MEMORY_ERROR: "Erro de memória"
STR_PAGE_LOAD_ERROR: "Erro ao carregar a página"
STR_EMPTY_FILE: "Ficheiro vazio"
@@ -29,10 +28,7 @@ STR_WIFI_NETWORKS: "Redes Wi-Fi"
STR_NO_NETWORKS: "Nenhuma rede encontrada"
STR_NETWORKS_FOUND: "%zu redes encontradas"
STR_SCANNING: "A procurar..."
STR_FINDING_SAVED_WIFI: "A procurar Wi-Fi guardado..."
STR_CONNECTING: "A ligar..."
STR_CONNECTING_SAVED_WIFI: "A ligar ao Wi-Fi guardado..."
STR_SHOW_NETWORKS: "Mostrar"
STR_CONNECTED: "Ligado!"
STR_CONNECTION_FAILED: "Falha na ligação"
STR_FORGET_NETWORK: "Esquecer rede?"
@@ -53,8 +49,6 @@ STR_NETWORK_LEGEND: "* = Encriptada | + = Guardada"
STR_MAC_ADDRESS: "Endereço MAC:"
STR_CHECKING_WIFI: "A verificar o Wi-Fi..."
STR_ENTER_WIFI_PASSWORD: "Introduza a palavra-passe do Wi-Fi"
STR_ADD_HIDDEN_NETWORK: "Adicionar rede oculta..."
STR_ENTER_WIFI_SSID: "Introduza o nome da rede (SSID)"
STR_TO_PREFIX: "para "
STR_CALIBRE_RECEIVING: "A receber: "
STR_CALIBRE_RECEIVED: "Recebido: "
@@ -76,8 +70,6 @@ STR_IMAGES: "Imagens"
STR_IMAGES_DISPLAY: "Exibição"
STR_IMAGES_PLACEHOLDER: "Espaço reservado"
STR_IMAGES_SUPPRESS: "Suprimir"
STR_EOB_HOME: "Início"
STR_EOB_CONTINUE_WITH: "Continuar com"
STR_SHORT_PWR_BTN: "Clique curto no botão de energia"
STR_ORIENTATION: "Orientação de leitura"
STR_SIDE_BTN_LAYOUT: "Disposição dos botões laterais (leitor)"
@@ -347,6 +339,11 @@ STR_SERVER_NAME: "Nome do servidor"
STR_NO_SERVERS: "Nenhum servidor OPDS configurado"
STR_DELETE_SERVER: "Eliminar servidor"
STR_OPDS_SERVERS: "Servidores OPDS"
STR_OPDS_DOWNLOAD_FOLDER: "Pasta de downloads"
STR_OPDS_FILENAME_FORMAT: "Formato do nome do ficheiro"
STR_FMT_AUTHOR_TITLE: "Autor - Título"
STR_FMT_TITLE: "Título"
STR_FMT_TITLE_AUTHOR: "Título - Autor"
STR_AUTO_TURN_ENABLED: "Virar página automático ativado: "
STR_AUTO_TURN_PAGES_PER_MIN: "Virar página automático (Páginas por minuto)"
STR_MANAGE_FONTS: "Gerir tipos de letra"
@@ -392,3 +389,5 @@ STR_FIRMWARE_WRITE_FAILED: "Falha na gravação do firmware"
STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "Não desligue o dispositivo!"
STR_RECOVERY_MODE: "Modo de Recuperação"
STR_RECOVERY_MODE_HINT: "Coloque o ficheiro firmware.bin na raiz do cartão SD e selecione-o"
STR_ADD_HIDDEN_NETWORK: "Adicionar rede oculta..."
STR_ENTER_WIFI_SSID: "Introduza o nome da rede (SSID)"
+1
View File
@@ -72,6 +72,7 @@ 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: "Comenzi tactile cititor"
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Orientare butoane frontale"
STR_LONG_PRESS_BEHAVIOR: "Comportament buton apăsat lung"
STR_LONG_PRESS_BEHAVIOR_OFF: "Dezactivat"
+1
View File
@@ -73,6 +73,7 @@ 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: "Ничего"
+1
View File
@@ -73,6 +73,7 @@ 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"
+1
View File
@@ -72,6 +72,7 @@ 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: "Upravljanje bralnika na dotik"
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"
+1
View File
@@ -78,6 +78,7 @@ STR_EOB_CONTINUE_WITH: "Continuar con"
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 del 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"
+25 -2
View File
@@ -10,6 +10,7 @@ STR_BROWSE_FILES: "Bläddra filer…"
STR_FILE_TRANSFER: "Filöverföring"
STR_SETTINGS_TITLE: "Inställningar"
STR_CONTINUE_READING: "Fortsätt läsa"
STR_RESUME: "Återuppta"
STR_NO_OPEN_BOOK: "Ingen öppen bok"
STR_START_READING: "Börja läsa nedan"
STR_NO_FILES_FOUND: "Inga filer hittades"
@@ -25,11 +26,20 @@ STR_EMPTY_FILE: "Tom fil"
STR_OUT_OF_BOUNDS: "Utanför gränserna"
STR_LOADING: "Laddar…"
STR_LOADING_POPUP: "Laddar"
STR_LOOKUP: "Slå upp"
STR_DICT_LOOKING_UP: "Slår upp..."
STR_DICT_INDEXING: "Indexerar ordbok..."
STR_DICT_NOT_FOUND: "Hittades inte"
STR_DICT_NO_DICT_SET: "Ingen ordbok angiven"
STR_DICT_ERROR: "Ordboksfel"
STR_WIFI_NETWORKS: "Trådlösa nätverk"
STR_NO_NETWORKS: "Inga nätverk funna"
STR_NETWORKS_FOUND: "%zu nätverk funna"
STR_SCANNING: "Scannar…"
STR_FINDING_SAVED_WIFI: "Hittar sparade Wi-Fi..."
STR_CONNECTING: "Ansluter…"
STR_CONNECTING_SAVED_WIFI: "Ansluter till sparat Wi-Fi..."
STR_SHOW_NETWORKS: "Visa"
STR_CONNECTED: "Ansluten!"
STR_CONNECTION_FAILED: "Anslutning misslyckades"
STR_FORGET_NETWORK: "Glöm nätverk?"
@@ -50,6 +60,8 @@ STR_NETWORK_LEGEND: "* = Krypterad | + = Sparad"
STR_MAC_ADDRESS: "MAC-adress:"
STR_CHECKING_WIFI: "Kontrollerar trådlöst nätverk…"
STR_ENTER_WIFI_PASSWORD: "Skriv in Wi-Fi-lösenord"
STR_ADD_HIDDEN_NETWORK: "Lägg till dolt nätverk..."
STR_ENTER_WIFI_SSID: "Ange nätverksnamn (SSID)"
STR_TO_PREFIX: "till "
STR_CALIBRE_RECEIVING: "Tar emot:"
STR_CALIBRE_RECEIVED: "Mottaget:"
@@ -76,6 +88,7 @@ STR_EOB_CONTINUE_WITH: "Fortsätt med"
STR_SHORT_PWR_BTN: "Kort strömknappsklick"
STR_ORIENTATION: "Läsrikting"
STR_SIDE_BTN_LAYOUT: "Sidoknappslayout (Läsare)"
STR_TOUCH_READER_CONTROLS: "Pekkontroller i läsaren"
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Rikta främre knappar"
STR_LONG_PRESS_BEHAVIOR: "Beteende vid lång knapptryckning"
STR_LONG_PRESS_BEHAVIOR_OFF: "AV"
@@ -103,7 +116,15 @@ STR_PASSWORD: "Lösenord"
STR_SYNC_SERVER_URL: "Synkronisera serveradress"
STR_DOCUMENT_MATCHING: "Dokumentmatchning"
STR_SEND_METADATA: "Skicka dokumentmetadata"
STR_SYNC_BEHAVIOR: "Synkroniseringsbeteende"
STR_ASK_EVERY_TIME: "Fråga varje gång"
STR_SMART_SYNC: "Smart synkronisering"
STR_AUTHENTICATE: "Autentisera "
STR_SIGN_UP: "Registrera dig"
STR_CREATING_ACCOUNT: "Skapar konto..."
STR_ACCOUNT_CREATED: "Konto skapat"
STR_SIGNUP_FAILED: "Registreringen misslyckades"
STR_USERNAME_TAKEN: "Användarnamnet är redan registrerat"
STR_KOREADER_USERNAME: "KOReader användarnamn"
STR_KOREADER_PASSWORD: "KOReader lösenord"
STR_FILENAME: "Filnamn"
@@ -149,6 +170,7 @@ STR_PREV_NEXT: "Förra/Nästa"
STR_NEXT_PREV: "Nästa/Förra"
STR_KOSYNC: "KOSync"
STR_BOOKMARK_OPTION: "Bokmärke"
STR_DICTIONARY: "Ordbok"
STR_DISABLED: "Inaktiverad"
STR_NOTO_SERIF: "Noto Serif"
STR_NOTO_SANS: "Noto Sans"
@@ -319,6 +341,7 @@ STR_UPLOAD_LOCAL: "Ladda upp lokala framsteg"
STR_NO_REMOTE_MSG: "Inga fjärrframsteg funna"
STR_UPLOAD_PROMPT: "Ladda upp nuvarande position?"
STR_UPLOAD_SUCCESS: "Framsteg uppladdade!"
STR_ALREADY_SYNCED: "Redan synkroniserad"
STR_SYNC_FAILED_MSG: "Synkronisering misslyckades"
STR_SAVE_PROGRESS_FAILED: "Kunde inte spara framsteg"
STR_SECTION_PREFIX: "Sektion"
@@ -328,6 +351,7 @@ STR_EMBEDDED_STYLE: "Inbäddad stil"
STR_FOCUS_READING: "Fokusläsning"
STR_OPDS_SERVER_URL: "OPDS-serveradress"
STR_PWR_BTN_FOOTNOTE_BACK: "Snabbåtergång från fotnoter"
STR_BACK_SHORT_TO_FILE_BROWSER: "Snabb tillbaka till filläsaren"
STR_SET_SLEEP_COVER: "Ställ in omslag"
STR_FOOTNOTES: "Fotnoter"
STR_NO_FOOTNOTES: "Inga fotnoter på den här sidan"
@@ -347,6 +371,7 @@ STR_OPDS_FILENAME_FORMAT: "Filnamnsformat"
STR_FMT_AUTHOR_TITLE: "Författare - Titel"
STR_FMT_TITLE_AUTHOR: "Titel - Författare"
STR_FMT_TITLE: "Titel"
STR_OPDS_SD_ROOT: "SD-rot"
STR_AUTO_TURN_ENABLED: "Automatisk vändning aktiverad: "
STR_AUTO_TURN_PAGES_PER_MIN: "Automatisk vändning (sidor per minut)"
STR_MANAGE_FONTS: "Hantera teckensnitt"
@@ -392,5 +417,3 @@ 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_ADD_HIDDEN_NETWORK: "Lägg till dolt nätverk..."
STR_ENTER_WIFI_SSID: "Ange nätverksnamn (SSID)"
+1
View File
@@ -67,6 +67,7 @@ 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: "Uzun basma tuş davranışı"
STR_LONG_PRESS_BEHAVIOR_OFF: "KAPALI"
+1
View File
@@ -73,6 +73,7 @@ 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: "Немає"
+1
View File
@@ -79,6 +79,7 @@ STR_EOB_CONTINUE_WITH: "Continua amb"
STR_SHORT_PWR_BTN: "Pulsació curta 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: "Acció en mantindre premut un botó"
STR_LONG_PRESS_BEHAVIOR_OFF: "Desactivat"
+1
View File
@@ -73,6 +73,7 @@ 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 bằng cảm ứng"
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"
@@ -5,6 +5,8 @@
#include <JPEGDEC.h>
#include <Logging.h>
#include <Memory.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <cstdio>
#include <cstring>
@@ -169,11 +171,22 @@ constexpr uint32_t FP_ONE = 1UL << 16;
// Static file pointer for JPEGDEC open callback.
// Safe in single-threaded embedded context; never accessed concurrently.
static HalFile* s_jpegFile = nullptr;
static uint8_t s_jpegIoSinceYield = 0;
static void yieldToIdle() { vTaskDelay(1); }
static void yieldDuringJpegIo() {
if (++s_jpegIoSinceYield < 4) return;
s_jpegIoSinceYield = 0;
yieldToIdle();
}
void* bmpJpegOpen(const char* /*filename*/, int32_t* size) {
if (!s_jpegFile || !*s_jpegFile) return nullptr;
s_jpegIoSinceYield = 0;
s_jpegFile->seek(0);
*size = static_cast<int32_t>(s_jpegFile->size());
yieldDuringJpegIo();
return s_jpegFile;
}
@@ -187,6 +200,7 @@ int32_t bmpJpegRead(JPEGFILE* pFile, uint8_t* pBuf, int32_t len) {
int32_t n = f->read(pBuf, len);
if (n < 0) n = 0;
pFile->iPos += n;
yieldDuringJpegIo();
return n;
}
@@ -194,6 +208,7 @@ int32_t bmpJpegSeek(JPEGFILE* pFile, int32_t pos) {
auto* f = reinterpret_cast<HalFile*>(pFile->fHandle);
if (!f || !f->seek(pos)) return -1;
pFile->iPos = pos;
yieldDuringJpegIo();
return pos;
}
@@ -236,9 +251,23 @@ struct BmpConvertCtx {
std::unique_ptr<FloydSteinbergDitherer> fsDitherer;
std::unique_ptr<Atkinson1BitDitherer> atkinson1BitDitherer;
uint8_t rowsSinceYield;
uint8_t blocksSinceYield;
bool error;
};
static void yieldDuringDecode(BmpConvertCtx* ctx) {
if (++ctx->rowsSinceYield < 8) return;
ctx->rowsSinceYield = 0;
yieldToIdle();
}
static void yieldDuringDecodeBlock(BmpConvertCtx* ctx) {
if (++ctx->blocksSinceYield < 16) return;
ctx->blocksSinceYield = 0;
yieldToIdle();
}
// Write a fully-assembled output row (grayscale bytes, length outWidth) to BMP
static void writeOutputRow(BmpConvertCtx* ctx, const uint8_t* srcRow, int outY) {
memset(ctx->bmpRow.get(), 0, ctx->bytesPerRow);
@@ -274,6 +303,7 @@ static void writeOutputRow(BmpConvertCtx* ctx, const uint8_t* srcRow, int outY)
}
ctx->bmpOut->write(ctx->bmpRow.get(), ctx->bytesPerRow);
yieldDuringDecode(ctx);
}
// Matches the progressive-JPEG smoothing used by JpegToFramebufferConverter, but stays
@@ -396,6 +426,7 @@ static void flushScaledRow(BmpConvertCtx* ctx) {
ctx->bmpOut->write(ctx->bmpRow.get(), ctx->bytesPerRow);
ctx->currentOutY++;
yieldDuringDecode(ctx);
}
// JPEGDEC draw callback — receives one MCU-width × MCU-height block at a time,
@@ -405,6 +436,7 @@ static void flushScaledRow(BmpConvertCtx* ctx) {
int bmpDrawCallback(JPEGDRAW* pDraw) {
auto* ctx = reinterpret_cast<BmpConvertCtx*>(pDraw->pUser);
if (!ctx || ctx->error) return 0;
yieldDuringDecodeBlock(ctx);
const uint8_t* pixels = reinterpret_cast<uint8_t*>(pDraw->pPixels);
const int stride = pDraw->iWidth;
@@ -599,6 +631,8 @@ bool JpegToBmpConverter::jpegFileToBmpStreamInternal(HalFile& jpegFile, Print& b
ctx.smoothScaleY_fp = interpolationStep(ctx.srcHeight, outHeight);
ctx.smoothNextOutY = 0;
ctx.smoothPrevY = -1;
ctx.rowsSinceYield = 0;
ctx.blocksSinceYield = 0;
ctx.error = false;
// MCU row buffer: MAX_MCU_HEIGHT rows × decoded srcWidth columns of grayscale
+46 -2
View File
@@ -5,16 +5,27 @@
#include <ObfuscationUtils.h>
namespace {
// Default sync server URL
constexpr char DEFAULT_SERVER_URL[] = "https://sync.koreader.rocks:443";
// Default sync server URL. crosspoint-sync speaks the full KOSync protocol, so
// pointing at any other kosync server (e.g. https://sync.koreader.rocks:443)
// still works via the custom server URL setting.
constexpr char DEFAULT_SERVER_URL[] = "https://sync.crosspointreader.com";
// Default before config version 2. Configs saved without a version stamp and an
// empty serverUrl were implicitly syncing here — they get pinned on upgrade.
constexpr char LEGACY_DEFAULT_SERVER_URL[] = "https://sync.koreader.rocks:443";
// Bumped when a change to defaults would alter behavior for existing configs.
constexpr uint8_t CONFIG_VERSION = 2;
} // namespace
void KOReaderCredentialStore::toJson(JsonDocument& doc) const {
doc["cfgVersion"] = CONFIG_VERSION;
doc["username"] = getUsername();
doc["password_obf"] = obfuscation::obfuscateToBase64(getPassword());
doc["serverUrl"] = getServerUrl();
doc["matchMethod"] = static_cast<uint8_t>(getMatchMethod());
doc["sendMetadata"] = getSendMetadata();
doc["syncBehavior"] = static_cast<uint8_t>(getSyncBehavior());
}
bool KOReaderCredentialStore::fromJson(JsonVariantConst doc) {
@@ -26,6 +37,19 @@ bool KOReaderCredentialStore::fromJson(JsonVariantConst doc) {
setCredentials(user, pass);
setServerUrl(doc["serverUrl"] | "");
// The default server changed in config v2 (sync.koreader.rocks -> crosspoint-sync).
// A pre-v2 config with credentials and no explicit URL was actively syncing
// against the old default — pin that URL so the upgrade doesn't switch servers
// out from under the user. Fresh setups get the new default.
const uint8_t cfgVersion = doc["cfgVersion"] | (uint8_t)1;
if (cfgVersion < CONFIG_VERSION) {
if (getServerUrl().empty() && hasCredentials()) {
LOG_DBG("KRS", "Pre-v2 config used the old default server; pinning %s", LEGACY_DEFAULT_SERVER_URL);
setServerUrl(LEGACY_DEFAULT_SERVER_URL);
}
needsResave = true; // stamp cfgVersion so this migration runs once
}
uint8_t method = doc["matchMethod"] | (uint8_t)0;
if (method <= static_cast<uint8_t>(DocumentMatchMethod::BINARY)) {
setMatchMethod(static_cast<DocumentMatchMethod>(method));
@@ -35,6 +59,18 @@ bool KOReaderCredentialStore::fromJson(JsonVariantConst doc) {
}
setSendMetadata(doc["sendMetadata"] | false);
const JsonVariantConst behaviorValue = doc["syncBehavior"];
const bool missingBehavior = behaviorValue.isNull();
uint8_t behavior = behaviorValue | static_cast<uint8_t>(KOReaderSyncBehavior::ASK_EVERY_TIME);
if (behavior <= static_cast<uint8_t>(KOReaderSyncBehavior::SMART)) {
setSyncBehavior(static_cast<KOReaderSyncBehavior>(behavior));
needsResave = needsResave || missingBehavior;
} else {
LOG_DBG("KRS", "Invalid syncBehavior %u in JSON, resetting to ASK_EVERY_TIME", behavior);
setSyncBehavior(KOReaderSyncBehavior::ASK_EVERY_TIME);
needsResave = true;
}
if (needsResave) {
LOG_DBG("KRS", "Resaved KOReader credentials to update format");
saveToFile();
@@ -105,3 +141,11 @@ void KOReaderCredentialStore::setSendMetadata(bool enabled) {
sendMetadata = enabled;
LOG_DBG("KRS", "Set send metadata: %s", enabled ? "true" : "false");
}
void KOReaderCredentialStore::setSyncBehavior(KOReaderSyncBehavior behavior) {
if (static_cast<uint8_t>(behavior) > static_cast<uint8_t>(KOReaderSyncBehavior::SMART)) {
behavior = KOReaderSyncBehavior::ASK_EVERY_TIME;
}
syncBehavior = behavior;
LOG_DBG("KRS", "Set sync behavior: %s", behavior == KOReaderSyncBehavior::SMART ? "Smart" : "Ask");
}
@@ -11,6 +11,12 @@ enum class DocumentMatchMethod : uint8_t {
BINARY = 1, // Match by partial MD5 of file content (more accurate, but files must be identical)
};
// How manual "Sync Progress" resolves differences after fetching remote progress.
enum class KOReaderSyncBehavior : uint8_t {
ASK_EVERY_TIME = 0, // Preserve legacy behavior: always show Apply/Upload choices.
SMART = 1, // Auto-resolve simple cases using furthest progress.
};
/**
* Singleton class for storing KOReader sync credentials on the SD card.
* Passwords are XOR-obfuscated with the device's unique hardware MAC address
@@ -25,6 +31,7 @@ class KOReaderCredentialStore : public PersistableStore<KOReaderCredentialStore>
std::string serverUrl; // Custom sync server URL (empty = default)
DocumentMatchMethod matchMethod = DocumentMatchMethod::FILENAME; // Default to filename for compatibility
bool sendMetadata = false; // Send document metadata with progress sync
KOReaderSyncBehavior syncBehavior = KOReaderSyncBehavior::SMART;
// Private constructor for singleton
KOReaderCredentialStore() = default;
@@ -65,6 +72,10 @@ class KOReaderCredentialStore : public PersistableStore<KOReaderCredentialStore>
// Send metadata setting
void setSendMetadata(bool enabled);
bool getSendMetadata() const { return sendMetadata; }
// Sync behavior
void setSyncBehavior(KOReaderSyncBehavior behavior);
KOReaderSyncBehavior getSyncBehavior() const { return syncBehavior; }
};
// Helper macro to access credential store
+67
View File
@@ -78,6 +78,43 @@ KOReaderSyncClient::Error KOReaderSyncClient::authenticate() {
return SERVER_ERROR;
}
KOReaderSyncClient::Error KOReaderSyncClient::createUser() {
lastHttpCode = 0;
if (!KOREADER_STORE.hasCredentials()) {
LOG_DBG("KOSync", "No credentials configured");
return NO_CREDENTIALS;
}
const std::string url = KOREADER_STORE.getBaseUrl() + "/users/create";
LOG_DBG("KOSync", "Creating account: %s (heap: %u)", url.c_str(), (unsigned)ESP.getFreeHeap());
if (insufficientHeap()) return LOW_MEMORY;
JsonDocument doc;
doc["username"] = KOREADER_STORE.getUsername();
doc["password"] = KOREADER_STORE.getMd5Password();
std::string body;
serializeJson(doc, body);
freeink::SecureHttpClient http;
http.setInsecure();
if (!http.begin(url)) {
LOG_ERR("KOSync", "Bad URL: %s", url.c_str());
return NETWORK_ERROR;
}
http.addHeader("Accept", "application/vnd.koreader.v1+json");
http.addHeader("Content-Type", "application/json");
const int httpCode = http.sendRequest("POST", body);
http.end();
lastHttpCode = httpCode;
LOG_DBG("KOSync", "Create user response: %d", httpCode);
if (httpCode <= 0) return NETWORK_ERROR;
if (httpCode == 200 || httpCode == 201) return OK;
if (httpCode == 402) return USER_EXISTS;
return SERVER_ERROR;
}
KOReaderSyncClient::Error KOReaderSyncClient::getProgress(const std::string& documentHash,
KOReaderProgress& outProgress) {
lastHttpCode = 0;
@@ -124,6 +161,24 @@ KOReaderSyncClient::Error KOReaderSyncClient::getProgress(const std::string& doc
outProgress.deviceId = doc["device_id"].as<std::string>();
outProgress.timestamp = doc["timestamp"].as<int64_t>();
// Extended crosspoint-sync field; absent on plain kosync servers.
outProgress.position.reset();
const JsonObjectConst pos = doc["position"].as<JsonObjectConst>();
if (!pos.isNull()) {
KOReaderRichPosition rich;
rich.pctQ = pos["pctQ"].as<uint32_t>();
rich.spineIndex = pos["spine"].as<uint16_t>();
rich.pageNumber = pos["page"].as<uint16_t>();
const uint16_t pages = pos["pages"].as<uint16_t>();
rich.totalPages = pages > 0 ? pages : 1;
const uint16_t para = pos["para"].as<uint16_t>();
if (para > 0) rich.paragraphIndex = para;
rich.xpath = pos["xpath"].as<const char*>() ? pos["xpath"].as<const char*>() : "";
LOG_DBG("KOSync", "Got rich position: spine=%u page=%u/%u para=%u", rich.spineIndex, rich.pageNumber,
rich.totalPages, para);
outProgress.position = std::move(rich);
}
LOG_DBG("KOSync", "Got progress: %.2f%% at %s", outProgress.percentage * 100, outProgress.progress.c_str());
return OK;
}
@@ -158,6 +213,18 @@ KOReaderSyncClient::Error KOReaderSyncClient::updateProgress(const KOReaderProgr
doc["percentage"] = progress.percentage;
doc["device"] = DEVICE_NAME;
doc["device_id"] = DEVICE_ID;
if (progress.position.has_value()) {
// Extended crosspoint-sync field; kosync servers ignore unknown keys.
const auto& p = *progress.position;
auto pos = doc["position"].to<JsonObject>();
pos["pctQ"] = p.pctQ;
pos["spine"] = p.spineIndex;
pos["page"] = p.pageNumber;
pos["pages"] = p.totalPages;
if (p.paragraphIndex.has_value()) pos["para"] = *p.paragraphIndex;
// Server rejects the whole position object if xpath exceeds 120 bytes.
if (!p.xpath.empty() && p.xpath.size() <= 120) pos["xpath"] = p.xpath;
}
std::string body;
serializeJson(doc, body);
+35 -1
View File
@@ -14,6 +14,21 @@ struct KOReaderMetadata {
std::string authors; // Author(s) from EPUB metadata
};
/**
* Rich CrossPoint position sent alongside progress uploads. Maps 1:1 onto the
* crosspoint-sync extended `position` object (see crosspoint-sync docs/API.md).
* The official KOSync server ignores unknown fields; crosspoint-sync stores it
* so CrossPoint<->CrossPoint sync is lossless instead of xpath-approximated.
*/
struct KOReaderRichPosition {
uint32_t pctQ = 0; // Percentage quantized 0..1,000,000 (authoritative)
uint16_t spineIndex = 0; // Spine (chapter) index
uint16_t pageNumber = 0; // Page within spine (layout-dependent hint)
uint16_t totalPages = 1; // Spine page count (layout-dependent hint)
std::optional<uint16_t> paragraphIndex; // Synthetic 1-based paragraph index
std::string xpath; // KOReader-style xpath (server cap: 120 bytes)
};
/**
* Progress data from KOReader sync server.
*/
@@ -25,6 +40,7 @@ struct KOReaderProgress {
std::string deviceId; // Device ID
int64_t timestamp; // Unix timestamp of last update
std::optional<KOReaderMetadata> metadata; // Optional document metadata
std::optional<KOReaderRichPosition> position; // Optional rich position (crosspoint-sync servers only)
};
/**
@@ -43,7 +59,17 @@ struct KOReaderProgress {
*/
class KOReaderSyncClient {
public:
enum Error { OK = 0, NO_CREDENTIALS, NETWORK_ERROR, AUTH_FAILED, SERVER_ERROR, JSON_ERROR, NOT_FOUND, LOW_MEMORY };
enum Error {
OK = 0,
NO_CREDENTIALS,
NETWORK_ERROR,
AUTH_FAILED,
SERVER_ERROR,
JSON_ERROR,
NOT_FOUND,
LOW_MEMORY,
USER_EXISTS
};
/**
* Authenticate with the sync server (validate credentials).
@@ -51,6 +77,14 @@ class KOReaderSyncClient {
*/
static Error authenticate();
/**
* Register a new account on the sync server using the stored credentials
* (POST /users/create with the MD5 auth key — the server never sees the
* plain password).
* @return OK on success, USER_EXISTS if the username is taken
*/
static Error createUser();
/**
* Get reading progress for a document.
* @param documentHash The document hash (from KOReaderDocumentId)
+53
View File
@@ -724,6 +724,59 @@ SavedProgressPosition ProgressMapper::toSavedProgress(const std::shared_ptr<Epub
return result;
}
std::optional<CrossPointPosition> ProgressMapper::fromRichPosition(const std::shared_ptr<Epub>& epub,
const KOReaderRichPosition& rich,
GfxRenderer& renderer) {
const int spineCount = epub->getSpineItemsCount();
if (static_cast<int>(rich.spineIndex) >= spineCount) {
LOG_DBG("PM", "Rich position spine %u out of range (%d spine items)", rich.spineIndex, spineCount);
return std::nullopt;
}
CrossPointPosition result{};
result.spineIndex = rich.spineIndex;
Section tempSection(epub, result.spineIndex, renderer);
const auto cachedCount = tempSection.getCachedPageCount();
if (!cachedCount || *cachedCount <= 0) {
// No local layout for the target spine yet; the percentage/xpath mapping
// handles density estimation better than a blind copy of remote pages.
LOG_DBG("PM", "Rich position spine %u has no cached page count", rich.spineIndex);
return std::nullopt;
}
result.totalPages = *cachedCount;
const int remotePages = rich.totalPages > 0 ? rich.totalPages : 1;
if (result.totalPages == remotePages) {
// Identical layout (same render settings) — the page transfers losslessly.
result.pageNumber = std::min<int>(rich.pageNumber, result.totalPages - 1);
LOG_DBG("PM", "Rich position exact: spine=%d page=%d/%d", result.spineIndex, result.pageNumber, result.totalPages);
return result;
}
// Layout differs; the paragraph LUT is the most accurate anchor we have.
if (rich.paragraphIndex.has_value()) {
const auto lutPage = tempSection.getPageForParagraphIndex(*rich.paragraphIndex);
if (lutPage.has_value()) {
result.paragraphIndex = *rich.paragraphIndex;
result.hasParagraphIndex = true;
result.pageNumber = std::min<int>(*lutPage, result.totalPages - 1);
LOG_DBG("PM", "Rich position para %u -> spine=%d page=%d/%d", *rich.paragraphIndex, result.spineIndex,
result.pageNumber, result.totalPages);
return result;
}
}
// Fall back to the intra-spine page fraction.
const float intra =
(remotePages > 1) ? static_cast<float>(rich.pageNumber) / static_cast<float>(remotePages - 1) : 0.0f;
result.pageNumber = std::max(
0, std::min(static_cast<int>(intra * static_cast<float>(result.totalPages - 1) + 0.5f), result.totalPages - 1));
LOG_DBG("PM", "Rich position scaled: spine=%d remote %u/%d -> page=%d/%d", result.spineIndex, rich.pageNumber,
remotePages, result.pageNumber, result.totalPages);
return result;
}
CrossPointPosition ProgressMapper::toCrossPoint(const std::shared_ptr<Epub>& epub, const SavedProgressPosition& koPos,
GfxRenderer& renderer, int currentSpineIndex,
int totalPagesInCurrentSpine, int fallbackTotalPages) {
+17
View File
@@ -3,8 +3,11 @@
#include <GfxRenderer.h>
#include <memory>
#include <optional>
#include <string>
#include "KOReaderSyncClient.h"
/**
* CrossPoint position representation.
*/
@@ -65,6 +68,20 @@ class ProgressMapper {
GfxRenderer& renderer, int currentSpineIndex = -1,
int totalPagesInCurrentSpine = 0, int fallbackTotalPages = 0);
/**
* Convert a rich CrossPoint position (downloaded from a crosspoint-sync
* server) directly to a CrossPoint position, without XPath approximation.
* When the local layout matches the uploader's (same spine page count) the
* page transfers losslessly; otherwise the paragraph LUT or the intra-spine
* page fraction is used.
*
* @return The position, or std::nullopt when the rich position cannot be
* applied (spine out of range, no section cache) and the caller
* should fall back to toCrossPoint().
*/
static std::optional<CrossPointPosition> fromRichPosition(const std::shared_ptr<Epub>& epub,
const KOReaderRichPosition& rich, GfxRenderer& renderer);
private:
/**
* Generate a fallback XPath by streaming the spine item's XHTML and resolving
+10
View File
@@ -1,5 +1,8 @@
#include "Logging.h"
#include <BoardConfig.h>
#include <esp_rom_sys.h>
#include <string>
#define MAX_ENTRY_LEN 256
@@ -59,9 +62,16 @@ void logPrintf(const char* level, const char* origin, const char* format, ...) {
}
}
va_end(args);
#if FREEINK_LOG_TRANSPORT == FREEINK_LOG_TRANSPORT_ROM_PRINTF
// IDF/ROM console path for boards monitored over USB-Serial-JTAG, where the
// HWCDC `operator bool` reads false under `pio device monitor` and logs would
// otherwise be silently dropped (e.g. Sticky).
esp_rom_printf("%s", buf);
#else
if (logSerial) {
logSerial.print(buf);
}
#endif
addToLogRingBuffer(buf);
}
+10
View File
@@ -1,6 +1,10 @@
#pragma once
#include <Arduino.h>
#include <HardwareSerial.h>
#if defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT
#include <HWCDC.h>
#endif
#include <string>
@@ -27,7 +31,13 @@ won't trigger deprecation warnings.
#define LOG_LEVEL 0
#endif
#if defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT
static HWCDC& logSerial = Serial;
#define LOG_SERIAL_HAS_TX_TIMEOUT 1
#else
static HardwareSerial& logSerial = Serial;
#define LOG_SERIAL_HAS_TX_TIMEOUT 0
#endif
void logPrintf(const char* level, const char* origin, const char* format, ...);
@@ -4,6 +4,8 @@
#include <HalStorage.h>
#include <InflateStream.h>
#include <Logging.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <cstdio>
#include <cstring>
@@ -72,6 +74,12 @@ enum PngFilter : uint8_t {
PNG_FILTER_PAETH = 4,
};
void yieldDuringDecode(uint8_t& rowsSinceYield) {
if (++rowsSinceYield < 8) return;
rowsSinceYield = 0;
vTaskDelay(1);
}
// Read a big-endian 32-bit value from file
bool readBE32(HalFile& file, uint32_t& value) {
uint8_t buf[4];
@@ -659,6 +667,7 @@ bool PngToBmpConverter::pngFileToBmpStreamInternal(HalFile& pngFile, Print& bmpO
}
bool success = true;
uint8_t rowsSinceYield = 0;
// Process each scanline
for (uint32_t y = 0; y < height; y++) {
@@ -710,6 +719,7 @@ bool PngToBmpConverter::pngFileToBmpStreamInternal(HalFile& pngFile, Print& bmpO
fsDitherer->nextRow();
}
bmpOut.write(rowBuffer, bytesPerRow);
yieldDuringDecode(rowsSinceYield);
} else {
// Area-averaging scaling (same as JpegToBmpConverter)
for (int outX = 0; outX < outWidth; outX++) {
@@ -778,6 +788,7 @@ bool PngToBmpConverter::pngFileToBmpStreamInternal(HalFile& pngFile, Print& bmpO
bmpOut.write(rowBuffer, bytesPerRow);
currentOutY++;
yieldDuringDecode(rowsSinceYield);
nextOutY_srcStart = static_cast<uint32_t>(currentOutY + 1) * scaleY_fp;
+12
View File
@@ -10,6 +10,16 @@
#include <Bitmap.h>
#include <HalStorage.h>
#include <Logging.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
namespace {
void yieldDuringThumbnail(uint8_t& rowsSinceYield) {
if (++rowsSinceYield < 8) return;
rowsSinceYield = 0;
vTaskDelay(1);
}
} // namespace
bool Xtc::load() {
LOG_DBG("XTC", "Loading XTC: %s", filepath.c_str());
@@ -380,6 +390,7 @@ bool Xtc::generateThumbBmp(int height) const {
const uint8_t* plane2 = (bitDepth == 2) ? pageBuffer + planeSize : nullptr;
const size_t colBytes = (bitDepth == 2) ? ((pageInfo.height + 7) / 8) : 0;
const size_t srcRowBytes = (bitDepth == 1) ? ((pageInfo.width + 7) / 8) : 0;
uint8_t rowsSinceYield = 0;
for (uint16_t dstY = 0; dstY < thumbHeight; dstY++) {
memset(rowBuffer, 0xFF, rowSize); // Start with all white (bit 1)
@@ -471,6 +482,7 @@ bool Xtc::generateThumbBmp(int height) const {
// Write row (already padded to 4-byte boundary by rowSize)
thumbBmp.write(rowBuffer, rowSize);
yieldDuringThumbnail(rowsSinceYield);
}
free(rowBuffer);
+21 -91
View File
@@ -5,46 +5,11 @@
#include <esp_sntp.h>
#include <time.h>
#include <cassert>
HalClock halClock; // Singleton instance
// DS3231 register layout (BCD encoded):
// 0x00: Seconds (bits 6-4 = tens, bits 3-0 = ones)
// 0x01: Minutes (bits 6-4 = tens, bits 3-0 = ones)
// 0x02: Hours (bit 6 = 12/24 mode, bits 5-4 = tens, bits 3-0 = ones)
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() {
if (!gpio.deviceIsX3()) {
_available = false;
return;
}
// I2C is already initialised by HalPowerManager::begin() for X3.
// Probe the DS3231 by reading the seconds register.
Wire.beginTransmission(I2C_ADDR_DS3231);
Wire.write(DS3231_SEC_REG);
if (Wire.endTransmission(false) != 0) {
LOG_INF("CLK", "DS3231 RTC not found");
_available = false;
return;
}
Wire.requestFrom(I2C_ADDR_DS3231, (uint8_t)1);
if (Wire.available() < 1) {
_available = false;
return;
}
Wire.read(); // discard — just testing connectivity
_available = true;
LOG_INF("CLK", "DS3231 RTC found");
// Prime the cache with an initial read
uint8_t h, m;
getTime(h, m);
_available = _sdkRtc.begin();
LOG_INF("CLK", _available ? "SDK RTC found" : "RTC not found");
}
bool HalClock::getTime(uint8_t& hour, uint8_t& minute) const {
@@ -57,44 +22,18 @@ bool HalClock::getTime(uint8_t& hour, uint8_t& minute) const {
return true;
}
// Read 3 bytes starting at register 0x00: seconds, minutes, hours
Wire.beginTransmission(I2C_ADDR_DS3231);
Wire.write(DS3231_SEC_REG);
if (Wire.endTransmission(false) != 0) {
Rtc::DateTime dt;
if (!_sdkRtc.now(dt)) {
if (!_hasCachedTime) return false;
_lastPollMs = now;
hour = _cachedHour;
minute = _cachedMinute;
return true;
}
Wire.requestFrom(I2C_ADDR_DS3231, (uint8_t)3);
if (Wire.available() < 3) {
if (!_hasCachedTime) return false;
_lastPollMs = now;
hour = _cachedHour;
minute = _cachedMinute;
return true;
}
Wire.read(); // seconds — not needed
const uint8_t rawMin = Wire.read();
const uint8_t rawHour = Wire.read();
_cachedMinute = bcdToDec(rawMin & 0x7F);
// Handle 12/24h mode: bit 6 high = 12h mode
if (rawHour & 0x40) {
// 12h mode: bit 5 = PM, bits 4-0 = hours (1-12)
uint8_t h12 = bcdToDec(rawHour & 0x1F);
bool pm = rawHour & 0x20;
if (h12 == 12) h12 = 0;
_cachedHour = pm ? (h12 + 12) : h12;
} else {
// 24h mode: bits 5-0 = hours (0-23)
_cachedHour = bcdToDec(rawHour & 0x3F);
}
_cachedHour = dt.hour;
_cachedMinute = dt.minute;
_lastPollMs = now;
_hasCachedTime = true;
hour = _cachedHour;
minute = _cachedMinute;
return true;
@@ -127,28 +66,6 @@ bool HalClock::formatTime(char* buf, size_t bufSize, uint8_t utcOffsetQuarterHou
return true;
}
bool HalClock::writeTimeToRTC(uint8_t hour, uint8_t minute, uint8_t second) {
assert(hour < 24);
assert(minute < 60);
assert(second < 60);
Wire.beginTransmission(I2C_ADDR_DS3231);
Wire.write(DS3231_SEC_REG); // Start at register 0x00
Wire.write(decToBcd(second)); // 0x00: Seconds
Wire.write(decToBcd(minute)); // 0x01: Minutes
Wire.write(decToBcd(hour)); // 0x02: Hours (24h mode, bit 6 = 0)
if (Wire.endTransmission() != 0) {
LOG_ERR("CLK", "Failed to write time to DS3231");
return false;
}
// Invalidate cache so next read fetches fresh data
_lastPollMs = 0;
_cachedHour = hour;
_cachedMinute = minute;
_hasCachedTime = true;
return true;
}
bool HalClock::syncFromNTP() {
if (!_available) return false;
@@ -168,8 +85,21 @@ bool HalClock::syncFromNTP() {
struct tm timeinfo;
gmtime_r(&now, &timeinfo);
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);
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;
+5 -9
View File
@@ -1,15 +1,14 @@
#pragma once
#include <Arduino.h>
#include <Wire.h>
#include "HalGPIO.h"
#include <Rtc.h>
class HalClock;
extern HalClock halClock; // Singleton
class HalClock {
bool _available = false;
mutable Rtc _sdkRtc;
mutable uint8_t _cachedHour = 0;
mutable uint8_t _cachedMinute = 0;
mutable bool _hasCachedTime = false;
@@ -18,10 +17,10 @@ class HalClock {
static constexpr unsigned long CLOCK_POLL_MS = 10000; // 10 seconds
public:
// Call after gpio.begin() and powerManager.begin() (I2C already initialised for X3)
// Call after BoardConfig has selected the active device.
void begin();
// True if the DS3231 RTC is present on this device
// True if an RTC is present on this device
bool isAvailable() const { return _available; }
// Get current hour (0-23) and minute (0-59).
@@ -35,14 +34,11 @@ class HalClock {
// Returns false if RTC is not available.
bool formatTime(char* buf, size_t bufSize, uint8_t utcOffsetQuarterHoursBiased = 48, bool use12Hour = false) const;
// Sync the DS3231 RTC from an NTP server. Requires WiFi to be connected.
// Sync the RTC from an NTP server. Requires WiFi to be connected.
// Blocks for up to ~5s while waiting for SNTP response.
// Returns true if the RTC was successfully updated.
//
// Debouncing (skip if already synced once) is enforced by the caller, not here,
// so the HAL stays free of any app-layer settings dependency.
bool syncFromNTP();
private:
bool writeTimeToRTC(uint8_t hour, uint8_t minute, uint8_t second);
};
+12
View File
@@ -65,6 +65,18 @@ void HalDisplay::displayBuffer(HalDisplay::RefreshMode mode, bool turnOffScreen)
einkDisplay.displayBuffer(convertRefreshMode(mode), turnOffScreen);
}
void HalDisplay::displayBufferAsync(HalDisplay::RefreshMode mode) {
if (gpio.deviceIsX3() && mode == RefreshMode::HALF_REFRESH) {
einkDisplay.requestResync(1);
}
einkDisplay.displayBufferAsyncNoShadow(convertRefreshMode(mode));
}
void HalDisplay::waitRefreshComplete() { einkDisplay.waitRefreshComplete(); }
bool HalDisplay::supportsAsyncRefresh() const { return einkDisplay.supportsAsyncRefresh(); }
void HalDisplay::refreshDisplay(HalDisplay::RefreshMode mode, bool turnOffScreen) {
if (gpio.deviceIsX3() && mode == RefreshMode::HALF_REFRESH) {
einkDisplay.requestResync(1);
+11
View File
@@ -39,6 +39,17 @@ class HalDisplay {
bool fromProgmem = false) const;
void displayBuffer(RefreshMode mode = RefreshMode::FAST_REFRESH, bool turnOffScreen = false);
// Non-blocking refresh (shadow-free): starts the panel waveform and returns
// while the panel refreshes on its own. The framebuffer must stay untouched
// until waitRefreshComplete(), and the caller must rebuild the differential
// baseline before the next differential update (the tiled grayscale cleanup
// does). Panels without deferral fall back to a blocking refresh.
void displayBufferAsync(RefreshMode mode = RefreshMode::FAST_REFRESH);
// Block until a pending deferred refresh completes (no-op when none is).
void waitRefreshComplete();
// True when displayBufferAsync() genuinely overlaps (panel driver defers);
// false where it falls back to a blocking refresh.
bool supportsAsyncRefresh() const;
void refreshDisplay(RefreshMode mode = RefreshMode::FAST_REFRESH, bool turnOffScreen = false);
// Power management
+53 -5
View File
@@ -1,5 +1,6 @@
#include <HalGPIO.h>
#include <Logging.h>
#include <PowerManager.h>
#include <Preferences.h>
#include <SPI.h>
#include <Wire.h>
@@ -191,15 +192,20 @@ HalGPIO::DeviceType detectDeviceTypeWithFingerprint() {
} // namespace
void HalGPIO::begin() {
inputMgr.begin();
#if FREEINK_MCU_C3
SPI.begin(EPD_SCLK, SPI_MISO, EPD_MOSI, EPD_CS);
_deviceType = detectDeviceTypeWithFingerprint();
BoardConfig::selectDevice(deviceIsX3() ? BoardConfig::Board::XteinkX3 : BoardConfig::Board::XteinkX4);
if (deviceIsX4()) {
pinMode(BAT_GPIO0, INPUT);
pinMode(UART0_RXD, INPUT);
}
#else
_deviceType = DeviceType::X4;
#endif
inputMgr.begin();
}
void HalGPIO::update() {
@@ -225,7 +231,44 @@ unsigned long HalGPIO::getHeldTime() const { return inputMgr.getHeldTime(); }
unsigned long HalGPIO::getPowerButtonHeldTime() const { return inputMgr.getPowerButtonHeldTime(); }
bool HalGPIO::hasTouch() const { return inputMgr.hasTouch(); }
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);
}
bool HalGPIO::isTouchHeldAt(float& nx, float& ny) const { return inputMgr.isTouchHeldAt(nx, ny); }
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::wasTouchActivity() const { return inputMgr.wasTouchActivity(); }
void HalGPIO::setSharedConfirmPowerShortPressEmitsPower(const bool enabled) {
InputManager::setSharedConfirmPowerShortPressEmitsPower(enabled);
}
bool HalGPIO::isXteinkDevice() const {
return BoardConfig::ACTIVE.board == BoardConfig::Board::XteinkX3 ||
BoardConfig::ACTIVE.board == BoardConfig::Board::XteinkX4;
}
bool HalGPIO::verifyPowerButtonWakeup(uint16_t requiredDurationMs, bool shortPressAllowed) {
// Boards without a power button (or M5Paper's latch circuit) cannot verify a
// hold; treat the wake as valid.
if (BoardConfig::ACTIVE.input.power < 0) {
return true;
}
#if defined(FREEINK_DEVICE_M5PAPER) && FREEINK_DEVICE_M5PAPER
return true;
#endif
if (shortPressAllowed) {
// Fast path - no duration check needed
return true;
@@ -271,8 +314,10 @@ bool HalGPIO::isUsbConnected() const {
}
return false;
}
// U0RXD/GPIO20 reads HIGH when USB is connected
return digitalRead(UART0_RXD) == HIGH;
if (BoardConfig::ACTIVE.usbDetect < 0) {
return false;
}
return digitalRead(BoardConfig::ACTIVE.usbDetect) == HIGH;
}
HalGPIO::WakeupReason HalGPIO::getWakeupReason() const {
@@ -281,8 +326,11 @@ HalGPIO::WakeupReason HalGPIO::getWakeupReason() const {
const bool usbConnected = isUsbConnected();
if ((wakeupCause == ESP_SLEEP_WAKEUP_UNDEFINED && resetReason == ESP_RST_POWERON && !usbConnected) ||
(wakeupCause == ESP_SLEEP_WAKEUP_GPIO && resetReason == ESP_RST_DEEPSLEEP && usbConnected)) {
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) {
return WakeupReason::PowerButton;
}
if (wakeupCause == ESP_SLEEP_WAKEUP_UNDEFINED && resetReason == ESP_RST_UNKNOWN && usbConnected) {
+10
View File
@@ -58,6 +58,7 @@ class HalGPIO {
// Inline device type helpers for cleaner downstream checks
inline bool deviceIsX3() const { return _deviceType == DeviceType::X3; }
inline bool deviceIsX4() const { return _deviceType == DeviceType::X4; }
bool isXteinkDevice() const;
// Start button GPIO and setup SPI for screen and SD card
void begin();
@@ -71,6 +72,15 @@ class HalGPIO {
bool wasAnyReleased() const;
unsigned long getHeldTime() const;
unsigned long getPowerButtonHeldTime() const;
bool hasTouch() const;
bool wasTouchTap(float& nx, float& ny) const;
bool wasTouchDown(float& nx, float& ny) const;
bool isTouchTapCandidate(float& nx, float& ny, unsigned long& heldMs) const;
bool isTouchHeldAt(float& nx, float& ny) const;
unsigned long lastTouchHeldMs() const;
bool wasSwipe(float& nxStart, float& nyStart, float& nxEnd, float& nyEnd) const;
bool wasTouchActivity() const;
void setSharedConfirmPowerShortPressEmitsPower(bool enabled);
// Verify power button was held long enough after wakeup.
// Returns true if verification succeeded, false if device should return to sleep.
+29 -44
View File
@@ -1,8 +1,11 @@
#include "HalPowerManager.h"
#include <BoardConfig.h>
#include <Logging.h>
#include <PowerManager.h>
#include <WiFi.h>
#include <esp_sleep.h>
#include <soc/soc_caps.h>
#include <cassert>
@@ -11,14 +14,8 @@
HalPowerManager powerManager; // Singleton instance
void HalPowerManager::begin() {
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 {
pinMode(BAT_GPIO0, INPUT);
if (BoardConfig::ACTIVE.batteryAdc >= 0) {
pinMode(BoardConfig::ACTIVE.batteryAdc, INPUT);
}
normalFreq = getCpuFrequencyMhz();
modeMutex = xSemaphoreCreateMutex();
@@ -61,12 +58,6 @@ void HalPowerManager::setPowerSaving(bool enabled) {
}
void HalPowerManager::startDeepSleep(HalGPIO& gpio) const {
// Ensure that the power button has been released to avoid immediately turning back on if you're holding it
while (gpio.isPressed(HalGPIO::BTN_POWER)) {
delay(50);
gpio.update();
}
#ifdef ENABLE_SERIAL_LOG
// Tear down HWCDC so the host sees a clean disconnect and the peripheral
// doesn't hold power domains that interfere with USB-powered GPIO wake.
@@ -75,53 +66,47 @@ void HalPowerManager::startDeepSleep(HalGPIO& gpio) const {
logSerial.end();
#endif
// Pre-sleep routines from the original firmware
// 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
#if !SOC_PM_SUPPORT_EXT1_WAKEUP
if (gpio.isXteinkDevice() && !gpio.deviceIsX3()) {
// X4 GPIO13 is connected to the battery latch MOSFET. Keeping it low powers
// the MCU off on battery, while the SDK wake source still handles USB power.
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);
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();
}
#endif
// Cut the gated peripheral rails (touch/SD/EPD on boards like the Sticky) and
// hold the enables off through deep sleep — otherwise the GT911 and SD card
// stay powered all through "off" and drain the battery. No-op on boards with
// no switched rails (X4/X3). Trade-off: no touch-to-wake; wake is the power
// button. Must run after display.deepSleep() so the panel controller gets its
// deep-sleep command while its rail is still up (enterDeepSleep() in main.cpp
// guarantees that ordering).
freeink::PowerManager::powerDownRailsForSleep();
// Waits for the power button to be physically released (so holding it doesn't
// immediately wake the device again), then arms the wake source and sleeps.
freeink::PowerManager::deepSleepUntilPowerButton();
}
uint16_t HalPowerManager::getBatteryPercentage() const {
if (_batteryUseI2C) {
static const BatteryMonitor battery;
if (BoardConfig::ACTIVE.batteryGauge.gaugeAddr != 0) {
const unsigned long now = millis();
if (_batteryLastPollMs != 0 && (now - _batteryLastPollMs) < BATTERY_POLL_MS) {
return _batteryCachedPercent;
}
// 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;
uint16_t percent = 0;
if (!battery.readPercentageChecked(percent)) {
return _batteryCachedPercent;
}
Wire.requestFrom(I2C_ADDR_BQ27220, (uint8_t)2);
if (Wire.available() < 2) {
_batteryLastPollMs = now;
_batteryCachedPercent = percent;
return _batteryCachedPercent;
}
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;
}
static const BatteryMonitor battery = BatteryMonitor(BAT_GPIO0);
// smooth the battery %.
if (_batteryCachedPercent == 0) {
+4 -3
View File
@@ -4,7 +4,6 @@
#include <BatteryMonitor.h>
#include <InputManager.h>
#include <Logging.h>
#include <Wire.h>
#include <freertos/semphr.h>
#include <cassert>
@@ -18,8 +17,6 @@ class HalPowerManager {
int normalFreq = 0; // MHz
bool isLowPower = false;
// I2C fuel gauge configuration for X3 battery monitoring
bool _batteryUseI2C = false; // True if using I2C fuel gauge (X3), false for ADC (X4)
mutable int _batteryCachedPercent = 0; // Last read battery percentage (0-100)
mutable unsigned long _batteryLastPollMs = 0; // Timestamp of last battery read in milliseconds
@@ -28,7 +25,11 @@ class HalPowerManager {
SemaphoreHandle_t modeMutex = nullptr; // Protect access to currentLockMode
public:
#if BOARD_HAS_PSRAM
static constexpr int LOW_POWER_FREQ = 80; // MHz
#else
static constexpr int LOW_POWER_FREQ = 10; // MHz
#endif
static constexpr unsigned long IDLE_POWER_SAVING_MS = 3000; // ms
static constexpr unsigned long BATTERY_POLL_MS = 1500; // ms
+6
View File
@@ -38,6 +38,11 @@ void IRAM_ATTR __wrap_panic_print_backtrace(const void* frame, int core) {
__real_panic_print_backtrace(frame, core);
return;
}
#if !__riscv
__real_panic_print_backtrace(frame, core);
return;
#else
for (size_t i = 0; i < MAX_PANIC_STACK_DEPTH; i++) {
panicStack[i].sp = 0;
}
@@ -65,6 +70,7 @@ void IRAM_ATTR __wrap_panic_print_backtrace(const void* frame, int core) {
}
__real_panic_print_backtrace(frame, core);
#endif
}
}
+22 -87
View File
@@ -4,84 +4,29 @@
HalTiltSensor halTiltSensor; // Singleton instance
bool HalTiltSensor::writeReg(uint8_t reg, uint8_t val) const {
Wire.beginTransmission(_i2cAddr);
Wire.write(reg);
Wire.write(val);
return Wire.endTransmission() == 0;
}
bool HalTiltSensor::readReg(uint8_t reg, uint8_t* val) const {
Wire.beginTransmission(_i2cAddr);
Wire.write(reg);
if (Wire.endTransmission(false) != 0) {
return false;
}
Wire.requestFrom(_i2cAddr, (uint8_t)1);
if (Wire.available() < 1) {
return false;
}
*val = Wire.read();
return true;
}
bool HalTiltSensor::readGyro(float& gx, float& gy, float& gz) const {
Wire.beginTransmission(_i2cAddr);
Wire.write(REG_GX_L); // Start reading at Gyro X Low
if (Wire.endTransmission(false) != 0) {
return false;
}
Wire.requestFrom(_i2cAddr, (uint8_t)6);
if (Wire.available() < 6) {
return false;
}
auto readInt16 = [&]() -> int16_t {
const uint8_t lo = Wire.read();
const uint8_t hi = Wire.read();
return static_cast<int16_t>((hi << 8) | lo);
};
// If Full Scale is ±512 dps, the scale factor is 32768 / 512 = 64 LSB/dps
constexpr float SCALE = 1.0f / 64.0f;
gx = readInt16() * SCALE;
gy = readInt16() * SCALE;
gz = readInt16() * SCALE;
Imu::Sample sample;
if (!_sdkImu.read(sample)) return false;
gx = sample.gx;
gy = sample.gy;
gz = sample.gz;
return true;
}
void HalTiltSensor::begin() {
if (!gpio.deviceIsX3()) {
_available = false;
return;
}
// Try primary address, then alternate
uint8_t whoami = 0;
_i2cAddr = I2C_ADDR_QMI8658;
if (!readReg(QMI8658_WHO_AM_I_REG, &whoami) || whoami != QMI8658_WHO_AM_I_VALUE) {
_i2cAddr = I2C_ADDR_QMI8658_ALT;
if (!readReg(QMI8658_WHO_AM_I_REG, &whoami) || whoami != QMI8658_WHO_AM_I_VALUE) {
LOG_ERR("GYR", "QMI8658 IMU not found");
_available = false;
return;
}
}
LOG_INF("GYR", "QMI8658 IMU found at 0x%02X", _i2cAddr);
if (!writeReg(REG_CTRL7, CTRL7_DISABLE_ALL) || !writeReg(REG_CTRL3, CTRL3_FS_512DPS | CTRL3_ODR_28HZ) ||
!writeReg(REG_CTRL1, CTRL1_BASE | CTRL1_SENSOR_DISABLE)) {
LOG_ERR("GYR", "QMI8658 register configuration failed");
_available = false;
return;
}
_available = true;
_available = _sdkImu.begin();
if (_available) {
_initMs = millis();
_lastPollMs = millis();
LOG_INF("GYR", "QMI8658 gyro initialized and put to sleep");
// begin() leaves the sensors sampling; stand them by until tilt page turn
// actually wakes them, so a disabled IMU doesn't drain the battery.
if (!_sdkImu.sleep()) {
LOG_ERR("GYR", "IMU standby failed");
}
LOG_INF("GYR", "SDK IMU initialized");
return;
}
LOG_ERR("GYR", "SDK IMU not found");
}
bool HalTiltSensor::wake() {
@@ -89,21 +34,16 @@ bool HalTiltSensor::wake() {
return false;
}
// Wait for init to complete before waking
if ((millis() - _initMs) < SLEEP_STABILIZE_MS) {
if (!_sdkImu.wake()) {
LOG_ERR("GYR", "IMU wake failed");
return false;
}
if (writeReg(REG_CTRL1, CTRL1_BASE) && writeReg(REG_CTRL7, CTRL7_GYRO_ENABLE)) {
_lastPollMs = millis();
_lastTiltMs = millis();
_wakeMs = millis();
LOG_INF("GYR", "QMI8658 woke up");
_isAwake = true;
return true;
} else {
LOG_ERR("GYR", "Failed to wake QMI8658");
return false;
}
}
bool HalTiltSensor::deepSleep() {
@@ -111,20 +51,15 @@ bool HalTiltSensor::deepSleep() {
return false;
}
if ((millis() - _wakeMs) < SLEEP_STABILIZE_MS) {
if (!_sdkImu.sleep()) {
LOG_ERR("GYR", "IMU sleep failed");
return false;
}
if (writeReg(REG_CTRL7, CTRL7_DISABLE_ALL) && writeReg(REG_CTRL1, CTRL1_BASE | CTRL1_SENSOR_DISABLE)) {
// Clear any residual state so it doesn't immediately trigger upon waking
clearPendingEvents();
_inTilt = false;
LOG_INF("GYR", "QMI8658 entered sleep mode");
_isAwake = false;
return true;
} else {
LOG_ERR("GYR", "Failed to put QMI8658 to sleep");
return false;
}
}
void HalTiltSensor::update(const uint8_t mode, const uint8_t orientation, const bool inReader) {
+6 -33
View File
@@ -1,9 +1,7 @@
#pragma once
#include <Arduino.h>
#include <Wire.h>
#include "HalGPIO.h"
#include <Imu.h>
// TODO: Move enums into new header and share with CrossPointSettings.h
namespace CrossPointOrientation {
@@ -19,7 +17,7 @@ extern HalTiltSensor halTiltSensor; // Singleton
class HalTiltSensor {
bool _available = false;
uint8_t _i2cAddr = 0;
mutable Imu _sdkImu;
// Tilt gesture state machine
bool _tiltForwardEvent = false; // Consumed by wasTiltedForward()
@@ -37,47 +35,22 @@ class HalTiltSensor {
static constexpr unsigned long COOLDOWN_MS = 600; // Minimum ms between triggers
static constexpr unsigned long POLL_INTERVAL_MS = 50; // 20 Hz polling
static constexpr unsigned long WAKE_STABILIZE_MS = 300; // Ignore readings after wake
static constexpr unsigned long SLEEP_STABILIZE_MS = 15; // Sleep turn on/off delay
mutable unsigned long _lastPollMs = 0;
// --- QMI8658 registers ---
static constexpr uint8_t REG_CTRL1 = 0x02;
static constexpr uint8_t REG_CTRL3 = 0x04;
static constexpr uint8_t REG_CTRL7 = 0x08;
static constexpr uint8_t REG_GX_L = 0x3B;
// --- Register Bit Flags ---
// REG_CTRL1 (0x02)
static constexpr uint8_t CTRL1_BIG_ENDIAN = (1 << 5); // 0x20: Default state (1 = Big Endian)
static constexpr uint8_t CTRL1_AUTO_INC = (1 << 6); // 0x40: Enable address auto-increment
static constexpr uint8_t CTRL1_SENSOR_DISABLE = (1 << 0); // 0x01: Power down sensor engine
static constexpr uint8_t CTRL1_BASE = CTRL1_AUTO_INC | CTRL1_BIG_ENDIAN; // 0x60
// REG_CTRL3 (0x04) - Gyro Config
static constexpr uint8_t CTRL3_FS_512DPS = (0b101 << 4); // Bits 6:4 = 101
static constexpr uint8_t CTRL3_ODR_28HZ = 0b1000; // Bits 3:0 = 1000 (28.025 Hz)
// REG_CTRL7 (0x08) - Enable
static constexpr uint8_t CTRL7_DISABLE_ALL = 0x00;
static constexpr uint8_t CTRL7_GYRO_ENABLE = (1 << 1); // Bit 1 = 1
bool writeReg(uint8_t reg, uint8_t val) const;
bool readReg(uint8_t reg, uint8_t* val) const;
bool readGyro(float& gx, float& gy, float& gz) const;
public:
// Call after gpio.begin() and powerManager.begin() (I2C already initialised for X3)
// Call after BoardConfig has selected the active device.
void begin();
// Enables the QMI8658 internal sensor engine
// Enables tilt polling state
bool wake();
// Puts the QMI8658 into a low-power standby state
// Puts tilt polling state to sleep
bool deepSleep();
// True if the QMI8658 IMU is present on this device
// True if an IMU is present on this device
bool isAvailable() const { return _available; }
// Poll the accelerometer and update tilt gesture state.
+1
View File
@@ -0,0 +1 @@
shell.nix
+43
View File
@@ -0,0 +1,43 @@
{
"nodes": {
"flake-compat": {
"locked": {
"lastModified": 1767039857,
"narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=",
"owner": "NixOS",
"repo": "flake-compat",
"rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab",
"type": "github"
},
"original": {
"owner": "NixOS",
"repo": "flake-compat",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1784356753,
"narHash": "sha256-12KrbMiWLcf8m7pCvAtZh1ZrgF85ZXDXvfR/fWTKy84=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "61b7c44c4073f0b827768aff0049561b5110ea5a",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-compat": "flake-compat",
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
+79
View File
@@ -0,0 +1,79 @@
{
description = "CrossPoint Reader development environment";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-compat.url = "github:NixOS/flake-compat";
};
outputs =
{ nixpkgs, ... }:
let
systems = [
"x86_64-linux"
"aarch64-linux"
];
forAllSystems = nixpkgs.lib.genAttrs systems;
in
{
devShells = forAllSystems (
system:
let
pkgs = import nixpkgs { inherit system; };
# Detect the project root from wherever the user entered the shell,
# so commands work from the repository root or any subdirectory.
# Using `git rev-parse` to do so (assuming git is installed
# system-wide); user can overwrite this by setting PROJECT_ROOT env.
setEnvs = ''
PROJECT_ROOT="''${PROJECT_ROOT:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"
export PROJECT_ROOT
export PLATFORMIO_CORE_DIR="$PROJECT_ROOT/.cache/platformio"
'';
fhsEnv = pkgs.buildFHSEnv {
name = "crosspoint-reader-shell";
targetPkgs =
pkgs: with pkgs; [
python3
uv
# Runtime libraries used by PlatformIO's downloaded ESP32 toolchain binaries.
stdenv.cc.cc.lib
zlib
ncurses
];
profile = ''
${setEnvs}
export PATH="$PROJECT_ROOT/.venv/bin:$PATH"
if [ ! -x "$PROJECT_ROOT/.venv/bin/pio" ]; then
echo "Creating .venv and installing pioarduino PlatformIO Core..."
# Forcing python3 from fhsEnv, otherwise we get the following
# exception while running `pip check`
# ModuleNotFoundError: No module named 'littlefs'
uv venv --python /usr/bin/python3 "$PROJECT_ROOT/.venv" &&
uv pip install --python "$PROJECT_ROOT/.venv/bin/python" \
-U https://github.com/pioarduino/platformio-core/archive/refs/tags/v6.1.19.zip \
-r "$PROJECT_ROOT/requirements.txt" ||
echo "Failed to install pioarduino PlatformIO Core" >&2
fi
'';
};
pio = pkgs.writeShellScriptBin "pio" ''
exec ${fhsEnv}/bin/crosspoint-reader-shell -c 'exec pio "$@"' pio "$@"
'';
in
{
default = pkgs.mkShell {
packages = [
pio
fhsEnv
];
shellHook = setEnvs;
};
}
);
};
}
+12
View File
@@ -0,0 +1,12 @@
(import (
let
lock = builtins.fromJSON (builtins.readFile ./flake.lock);
nodeName = lock.nodes.root.inputs.flake-compat;
in
fetchTarball {
url =
lock.nodes.${nodeName}.locked.url
or "https://github.com/NixOS/flake-compat/archive/${lock.nodes.${nodeName}.locked.rev}.tar.gz";
sha256 = lock.nodes.${nodeName}.locked.narHash;
}
) { src = ./.; }).shellNix
+27 -2
View File
@@ -35,8 +35,6 @@ 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
-DFREEINK_NET_WOLFSSL=1
-DWOLFSSL_USER_SETTINGS
-DWOLFSSL_OPTIONS_H
@@ -79,6 +77,8 @@ 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
SecureNet=symlink://freeink-sdk/libs/network/SecureNet
FreeInkUI=symlink://freeink-sdk/libs/ui/FreeInkUI
Icons=symlink://freeink-sdk/libs/assets/Icons
@@ -97,6 +97,8 @@ extends = base
build_flags =
${base.build_flags}
; CROSSPOINT_VERSION is set by scripts/git_branch.py (includes branch + short SHA)
-DFREEINK_DEVICE_X4=1
-DFREEINK_DEVICE_X3=1
-DENABLE_SERIAL_LOG
-DLOG_LEVEL=2 ; Set log level to debug for development builds
@@ -105,6 +107,8 @@ build_flags =
extends = base
build_flags =
${base.build_flags}
-DFREEINK_DEVICE_X4=1
-DFREEINK_DEVICE_X3=1
-DCROSSPOINT_VERSION=\"${crosspoint.version}\"
-DENABLE_SERIAL_LOG
-DLOG_LEVEL=1 ; Set log level to info for release builds
@@ -113,6 +117,8 @@ build_flags =
extends = base
build_flags =
${base.build_flags}
-DFREEINK_DEVICE_X4=1
-DFREEINK_DEVICE_X3=1
-DCROSSPOINT_VERSION=\"${crosspoint.version}-rc+${sysenv.CROSSPOINT_RC_HASH}\"
-DENABLE_SERIAL_LOG
-DLOG_LEVEL=1 ; Set log level to info for release candidate builds
@@ -121,6 +127,25 @@ build_flags =
extends = base
build_flags =
${base.build_flags}
-DFREEINK_DEVICE_X4=1
-DFREEINK_DEVICE_X3=1
-DCROSSPOINT_VERSION=\"${crosspoint.version}-slim\"
; serial output is disabled in slim builds to save space
-UENABLE_SERIAL_LOG
; --- Seeed Sticky — ESP32-S3R8, 3.97" 800x480 SSD1677 + GT911 touch -----------
; Different MCU family than the C3 envs (one binary per family). The SDK
; auto-enables CAP_TOUCH (GT911) and the BQ27220 gauge for this device; PSRAM is
; intentionally left off (48KB framebuffer fits in DRAM, same as X4).
; pio run -e sticky -t upload
[env:sticky]
extends = base
board = esp32-s3-devkitc1-n16r8
board_build.mcu = esp32s3
build_flags =
${base.build_flags}
-DFREEINK_DEVICE_STICKY=1
; git_branch.py only injects CROSSPOINT_VERSION for the default env; set it here.
-DCROSSPOINT_VERSION=\"${crosspoint.version}-sticky\"
-DENABLE_SERIAL_LOG
-DLOG_LEVEL=2
+11 -2
View File
@@ -29,8 +29,8 @@ class CrossPointSettings {
LIGHT = 1,
CUSTOM = 2,
COVER = 3,
BLANK = 4,
COVER_CUSTOM = 5,
COVER_CUSTOM = 4,
BLANK = 5,
QUICK_RESUME = 6,
SLEEP_SCREEN_MODE_COUNT
};
@@ -153,6 +153,7 @@ class CrossPointSettings {
LP_MENU_KOSYNC = 0,
LP_MENU_DISABLED = 1,
LP_MENU_BOOKMARK = 2,
LP_MENU_DICTIONARY = 3,
LONG_PRESS_MENU_FUNCTION_COUNT
};
@@ -175,6 +176,8 @@ class CrossPointSettings {
enum TILT_PAGE_TURN { TILT_OFF = 0, TILT_NORMAL = 1, TILT_NVERTED = 2, TILT_PAGE_TURN_COUNT };
enum TOUCH_READER_CONTROLS { TOUCH_READER_OFF = 0, TOUCH_READER_ON = 1, TOUCH_READER_CONTROLS_COUNT };
enum QUICK_RESUME_SLEEP_SCREEN {
QUICK_RESUME_NEVER = 0,
QUICK_RESUME_AFTER_TIMEOUT = 1,
@@ -265,16 +268,22 @@ class CrossPointSettings {
uint8_t focusReadingEnabled = 0;
// SD card font family name (empty = use built-in fontFamily)
char sdFontFamilyName[32] = "";
// Dictionary folder name under /dictionaries (empty = no dictionary)
char dictionaryName[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)
uint8_t removeReadBooksFromRecents = 0;
// Move epub to /Read/ folder on SD card when finished (0 = disabled, 1 = enabled)
uint8_t moveFinishedToReadFolder = 0;
// Short press Back goes to file browser instead of home (0 = disabled, 1 = enabled)
uint8_t backShortToFileBrowser = 0;
// Image rendering mode in EPUB reader
uint8_t imageRendering = IMAGES_DISPLAY;
// Tilt-based page turning (X3 only — requires QMI8658 IMU)
uint8_t tiltPageTurn = TILT_OFF;
// Touch screen reader zones/gestures on boards with a touch controller.
uint8_t touchReaderControls = TOUCH_READER_ON;
// Language setting (Language enum index, default 0 = EN)
uint8_t language = 0;
// Quick Resume: keep current content visible with moon icon instead of showing a static sleep screen.
+9
View File
@@ -149,6 +149,10 @@ bool JsonSettingsIO::saveSettings(const CrossPointSettings& s, const char* path)
if (s.sdFontFamilyName[0] != '\0') {
doc["sdFontFamilyName"] = s.sdFontFamilyName;
}
// Dictionary folder name — uses dynamic getter/setter in SettingsList, save manually
if (s.dictionaryName[0] != '\0') {
doc["dictionaryName"] = s.dictionaryName;
}
// Language -- managed by LanguageSelectActivity, not in SettingsList.
// Stored as ISO code string ("EN", "DE", ...) for stability across enum reorders.
@@ -256,6 +260,11 @@ bool JsonSettingsIO::loadSettings(CrossPointSettings& s, const char* json, bool*
if (needsResave) *needsResave = true;
}
// Dictionary folder name — uses dynamic getter/setter in SettingsList, load manually
const char* dictName = doc["dictionaryName"] | "";
strncpy(s.dictionaryName, dictName, sizeof(s.dictionaryName) - 1);
s.dictionaryName[sizeof(s.dictionaryName) - 1] = '\0';
// Language -- stored as code string for stability across enum reorders.
if (doc["language"].is<const char*>()) {
s.language = static_cast<uint8_t>(I18n::languageFromCode(doc["language"].as<const char*>()));
+214 -3
View File
@@ -2,7 +2,11 @@
#include <GfxRenderer.h>
#include <algorithm>
#include <cstdlib>
#include "CrossPointSettings.h"
#include "components/UITheme.h"
bool MappedInputManager::isNavDirectionSwapped() const {
// Key the swap on the orientation the screen is *actually* rendered at, not the persisted reader
@@ -74,9 +78,209 @@ bool MappedInputManager::mapButton(const Button button, bool (HalGPIO::*fn)(uint
return false;
}
bool MappedInputManager::wasPressed(const Button button) const { return mapButton(button, &HalGPIO::wasPressed); }
namespace {
constexpr float LEFT_EDGE_BACK_GESTURE_FRAC_X = 0.25f;
constexpr float BOTTOM_EDGE_BACK_GESTURE_FRAC_Y = 0.14f;
constexpr float TOP_EDGE_MENU_GESTURE_FRAC_Y = 0.14f;
constexpr unsigned long TOUCH_DOWN_SELECT_DELAY_MS = 90;
constexpr unsigned long TOUCH_HELD_OVERRIDE_WINDOW_MS = 250;
} // namespace
bool MappedInputManager::wasReleased(const Button button) const { return mapButton(button, &HalGPIO::wasReleased); }
bool MappedInputManager::hasTouch() const { return gpio.hasTouch(); }
void MappedInputManager::rememberTouchHeldTime() const {
touchHeldOverrideValid = true;
touchHeldOverrideMs = gpio.lastTouchHeldMs();
touchHeldOverrideAt = millis();
}
bool MappedInputManager::wasScreenTapped(int& x, int& y) const {
float nx = 0.0f;
float ny = 0.0f;
if (!gpio.wasTouchTap(nx, ny)) return false;
renderer.tapToLogical(nx, ny, x, y);
rememberTouchHeldTime();
return true;
}
bool MappedInputManager::wasScreenTouchDown(int& x, int& y) const {
float nx = 0.0f;
float ny = 0.0f;
unsigned long heldMs = 0;
if (!gpio.isTouchTapCandidate(nx, ny, heldMs)) return false;
if (heldMs < TOUCH_DOWN_SELECT_DELAY_MS) return false;
renderer.tapToLogical(nx, ny, x, y);
return true;
}
bool MappedInputManager::isScreenTouchHeld(int& x, int& y) const {
// Live contact position while the finger is down (no tap-slop gate) — drag tracking.
float nx = 0.0f;
float ny = 0.0f;
if (!gpio.isTouchHeldAt(nx, ny)) return false;
renderer.tapToLogical(nx, ny, x, y);
return true;
}
bool MappedInputManager::wasTapInRect(const int x, const int y, const int width, const int height) const {
int tx = 0;
int ty = 0;
return wasScreenTapped(tx, ty) && tx >= x && tx < x + width && ty >= y && ty < y + height;
}
bool MappedInputManager::listItemFromPoint(const int x, const int y, int& index, const int itemCount,
const int selectedIndex, const int listTop, const int listHeight,
const bool hasSubtitle) const {
(void)x;
if (itemCount <= 0) return false;
if (y < listTop || y >= listTop + listHeight) return false;
const auto& theme = UITheme::getInstance().getTheme();
const int rowStep = theme.getListRowStep(hasSubtitle);
if (rowStep <= 0) return false;
const int pageItems = theme.getListPageItems(listHeight, hasSubtitle);
if (pageItems <= 0) return false;
const int pageStart = std::max(0, selectedIndex / pageItems) * pageItems;
const int row = (y - listTop) / rowStep;
const int tapped = pageStart + row;
if (row < 0 || row >= pageItems || tapped >= itemCount) return false;
index = tapped;
return true;
}
bool MappedInputManager::wasListItemTapped(int& index, const int itemCount, const int selectedIndex, const int listTop,
const int listHeight, const bool hasSubtitle) const {
int tx = 0;
int ty = 0;
return wasScreenTapped(tx, ty) &&
listItemFromPoint(tx, ty, index, itemCount, selectedIndex, listTop, listHeight, hasSubtitle);
}
bool MappedInputManager::wasListItemTouchedDown(int& index, const int itemCount, const int selectedIndex,
const int listTop, const int listHeight, const bool hasSubtitle) const {
int tx = 0;
int ty = 0;
return wasScreenTouchDown(tx, ty) &&
listItemFromPoint(tx, ty, index, itemCount, selectedIndex, listTop, listHeight, hasSubtitle);
}
MappedInputManager::RowTouch MappedInputManager::rowTouch(int& row, const int top, const int rowStep,
const int rowCount, const int xStart, const int xEnd,
const int rowHeight) const {
if (rowStep <= 0 || rowCount <= 0) return RowTouch::None;
const auto hit = [&](const int x, const int y) {
if (x < xStart || x >= xEnd || y < top) return false;
const int r = (y - top) / rowStep;
if (r >= rowCount) return false;
if (rowHeight > 0 && (y - top) % rowStep >= rowHeight) return false;
row = r;
return true;
};
int x = 0;
int y = 0;
if (wasScreenTouchDown(x, y) && hit(x, y)) return RowTouch::Down;
if (wasScreenTapped(x, y) && hit(x, y)) return RowTouch::Tap;
return RowTouch::None;
}
MappedInputManager::RowTouch MappedInputManager::colTouch(int& col, const int left, const int colStep,
const int colCount, const int yStart, const int yEnd,
const int colWidth) const {
if (colStep <= 0 || colCount <= 0) return RowTouch::None;
const auto hit = [&](const int x, const int y) {
if (y < yStart || y >= yEnd || x < left) return false;
const int c = (x - left) / colStep;
if (c >= colCount) return false;
if (colWidth > 0 && (x - left) % colStep >= colWidth) return false;
col = c;
return true;
};
int x = 0;
int y = 0;
if (wasScreenTouchDown(x, y) && hit(x, y)) return RowTouch::Down;
if (wasScreenTapped(x, y) && hit(x, y)) return RowTouch::Tap;
return RowTouch::None;
}
bool MappedInputManager::decodeSwipe(int& sx, int& sy, int& ex, int& ey) const {
float nxs = 0.0f;
float nys = 0.0f;
float nxe = 0.0f;
float nye = 0.0f;
if (!gpio.wasSwipe(nxs, nys, nxe, nye)) return false;
renderer.tapToLogical(nxs, nys, sx, sy);
renderer.tapToLogical(nxe, nye, ex, ey);
return true;
}
MappedInputManager::SwipeDir MappedInputManager::wasSwipe() const {
int sx = 0;
int sy = 0;
int ex = 0;
int ey = 0;
if (!decodeSwipe(sx, sy, ex, ey)) return SwipeDir::None;
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::wasBackGesture() const {
// Back = left-to-right swipe starting near the left edge. Edge-anchored so that
// mid-screen horizontal swipes stay available to activities that consume
// SwipeDir::Left/Right (e.g. percent selection, image viewer).
int sx = 0;
int sy = 0;
int ex = 0;
int ey = 0;
if (!decodeSwipe(sx, sy, ex, ey)) return false;
const bool hit = sx <= renderer.getScreenWidth() * LEFT_EDGE_BACK_GESTURE_FRAC_X && ex > sx &&
std::abs(ex - sx) > std::abs(ey - sy);
if (hit) rememberTouchHeldTime();
return hit;
}
bool MappedInputManager::wasMenuGesture() const {
// Downward swipe starting at the top edge (mirror of the bottom-edge home gesture).
int sx = 0;
int sy = 0;
int ex = 0;
int ey = 0;
if (!decodeSwipe(sx, sy, ex, ey)) return false;
const int topEdgeBottom = static_cast<int>(renderer.getScreenHeight() * TOP_EDGE_MENU_GESTURE_FRAC_Y);
const bool hit = sy <= topEdgeBottom && ey > sy && std::abs(ey - sy) > std::abs(ex - sx);
if (hit) rememberTouchHeldTime();
return hit;
}
bool MappedInputManager::wasHomeGesture() const {
int sx = 0;
int sy = 0;
int ex = 0;
int ey = 0;
if (decodeSwipe(sx, sy, ex, ey)) {
const int bottomEdgeTop =
renderer.getScreenHeight() - static_cast<int>(renderer.getScreenHeight() * BOTTOM_EDGE_BACK_GESTURE_FRAC_Y);
if (sy >= bottomEdgeTop && ey < sy && std::abs(ey - sy) > std::abs(ex - sx)) {
rememberTouchHeldTime();
return true;
}
}
return false;
}
bool MappedInputManager::wasPressed(const Button button) const {
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::isPressed(const Button button) const { return mapButton(button, &HalGPIO::isPressed); }
@@ -84,7 +288,14 @@ bool MappedInputManager::wasAnyPressed() const { return gpio.wasAnyPressed(); }
bool MappedInputManager::wasAnyReleased() const { return gpio.wasAnyReleased(); }
unsigned long MappedInputManager::getHeldTime() const { return gpio.getHeldTime(); }
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();
}
MappedInputManager::Labels MappedInputManager::mapLabels(const char* back, const char* confirm, const char* previous,
const char* next) const {
+37
View File
@@ -7,6 +7,7 @@ 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;
@@ -21,9 +22,35 @@ class MappedInputManager {
bool wasPressed(Button button) const;
bool wasReleased(Button button) const;
bool isPressed(Button button) const;
bool hasTouch() const;
bool wasScreenTapped(int& x, int& y) const;
bool wasScreenTouchDown(int& x, int& y) const;
bool isScreenTouchHeld(int& x, int& y) const;
bool wasTapInRect(int x, int y, int width, int height) const;
bool wasListItemTapped(int& index, int itemCount, int selectedIndex, int listTop, int listHeight,
bool hasSubtitle) const;
bool wasListItemTouchedDown(int& index, int itemCount, int selectedIndex, int listTop, int listHeight,
bool hasSubtitle) const;
// Combined touch interaction for a band of equal rows with caller-supplied
// geometry — the shared hit-test for lists the theme helpers above do not
// cover (custom row heights, option prompts, menus). Down = a held
// tap-candidate is on a row (update the selection highlight); Tap = a tap
// released on one (activate). rowHeight limits the hit to the top rowHeight
// px of each step (0 = the full step, no gap band).
enum class RowTouch : uint8_t { None, Down, Tap };
RowTouch rowTouch(int& row, int top, int rowStep, int rowCount, int xStart = 0, int xEnd = INT32_MAX,
int rowHeight = 0) const;
// Horizontal variant for side-by-side button pairs (confirmation prompts).
RowTouch colTouch(int& col, int left, int colStep, int colCount, int yStart, int yEnd, int colWidth = 0) const;
SwipeDir wasSwipe() const;
bool wasHomeGesture() const;
bool wasMenuGesture() const;
bool wasAnyPressed() const;
bool wasAnyReleased() const;
unsigned long getHeldTime() const;
const GfxRenderer& getRenderer() const { return renderer; }
Labels mapLabels(const char* back, const char* confirm, const char* previous, const char* next) const;
// Returns the raw front button index that was pressed this frame (or -1 if none).
int getPressedFrontButton() const;
@@ -44,4 +71,14 @@ class MappedInputManager {
const GfxRenderer& renderer;
bool mapButton(Button button, bool (HalGPIO::*fn)(uint8_t) const) const;
bool wasBackGesture() const;
// Fetch the pending swipe (if any) and map both endpoints to logical screen coords
bool decodeSwipe(int& sx, int& sy, int& ex, int& ey) const;
bool listItemFromPoint(int x, int y, int& index, int itemCount, int selectedIndex, int listTop, int listHeight,
bool hasSubtitle) const;
void rememberTouchHeldTime() const;
mutable bool touchHeldOverrideValid = false;
mutable unsigned long touchHeldOverrideMs = 0;
mutable unsigned long touchHeldOverrideAt = 0;
};
+78 -3
View File
@@ -1,5 +1,6 @@
#pragma once
#include <BoardConfig.h>
#include <HalClock.h>
#include <HalTiltSensor.h>
#include <I18n.h>
@@ -13,6 +14,7 @@
#include "CrossPointSettings.h"
#include "KOReaderCredentialStore.h"
#include "activities/settings/SettingsActivity.h"
#include "util/DictionaryRegistry.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.
@@ -90,6 +92,47 @@ inline SettingInfo buildFontFamilySetting(const SdCardFontRegistry* registry) {
return s;
}
// Build the dictionary selection setting dynamically from the folders discovered
// under /dictionaries. "None" plus one option per dictionary; the selected folder
// name persists in SETTINGS.dictionaryName (saved/loaded manually in
// JsonSettingsIO — the generic loop skips dynamic entries).
inline SettingInfo buildDictionarySetting(const std::vector<DictionaryEntry>& dictionaries) {
std::vector<std::string> folderNames;
folderNames.reserve(dictionaries.size());
std::transform(dictionaries.begin(), dictionaries.end(), std::back_inserter(folderNames),
[](const DictionaryEntry& d) { return d.name; });
SettingInfo s;
s.nameId = StrId::STR_DICTIONARY;
s.type = SettingType::ENUM;
s.enumStringValues.reserve(folderNames.size() + 1);
s.enumStringValues.push_back(I18N.get(StrId::STR_NONE_OPT));
s.enumStringValues.insert(s.enumStringValues.end(), folderNames.begin(), folderNames.end());
s.category = StrId::STR_CAT_READER;
s.valueGetter = [folderNames]() -> uint8_t {
for (size_t i = 0; i < folderNames.size(); i++) {
// Compare within the settings field capacity: an over-long folder name is
// stored truncated, and must still match its list entry.
if (strncmp(folderNames[i].c_str(), SETTINGS.dictionaryName, sizeof(SETTINGS.dictionaryName) - 1) == 0) {
return static_cast<uint8_t>(i + 1);
}
}
return 0; // "None", also when the stored folder no longer exists
};
s.valueSetter = [folderNames](uint8_t v) {
if (v == 0 || v > folderNames.size()) {
SETTINGS.dictionaryName[0] = '\0';
return;
}
strncpy(SETTINGS.dictionaryName, folderNames[v - 1].c_str(), sizeof(SETTINGS.dictionaryName) - 1);
SETTINGS.dictionaryName[sizeof(SETTINGS.dictionaryName) - 1] = '\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.
@@ -99,7 +142,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* registry = nullptr,
const std::vector<DictionaryEntry>* dictionaries = nullptr) {
static const std::vector<SettingInfo> baseList = [] {
std::vector<SettingInfo> v = {
// --- Display ---
@@ -166,6 +210,8 @@ inline std::vector<SettingInfo> getSettingsList(const SdCardFontRegistry* regist
SettingInfo::Enum(StrId::STR_SIDE_BTN_LAYOUT, &CrossPointSettings::sideButtonLayout,
{StrId::STR_PREV_NEXT, StrId::STR_NEXT_PREV, StrId::STR_DISABLED}, "sideButtonLayout",
StrId::STR_CAT_CONTROLS),
SettingInfo::Enum(StrId::STR_TOUCH_READER_CONTROLS, &CrossPointSettings::touchReaderControls,
{StrId::STR_STATE_OFF, StrId::STR_STATE_ON}, "touchReaderControls", StrId::STR_CAT_CONTROLS),
SettingInfo::Toggle(StrId::STR_FRONT_BTN_FOLLOW_ORIENTATION, &CrossPointSettings::frontButtonFollowOrientation,
"frontButtonFollowOrientation", StrId::STR_CAT_CONTROLS),
SettingInfo::Enum(StrId::STR_LONG_PRESS_BEHAVIOR, &CrossPointSettings::longPressButtonBehavior,
@@ -173,14 +219,16 @@ inline std::vector<SettingInfo> getSettingsList(const SdCardFontRegistry* regist
StrId::STR_LONG_PRESS_BEHAVIOR_ORIENTATION},
"longPressButtonBehavior", StrId::STR_CAT_CONTROLS),
SettingInfo::Enum(StrId::STR_LONG_PRESS_MENU, &CrossPointSettings::longPressMenuFunction,
{StrId::STR_KOSYNC, StrId::STR_DISABLED, StrId::STR_BOOKMARK_OPTION}, "longPressMenuFunction",
StrId::STR_CAT_CONTROLS),
{StrId::STR_KOSYNC, StrId::STR_DISABLED, StrId::STR_BOOKMARK_OPTION, StrId::STR_DICTIONARY},
"longPressMenuFunction", StrId::STR_CAT_CONTROLS),
SettingInfo::Enum(
StrId::STR_SHORT_PWR_BTN, &CrossPointSettings::shortPwrBtn,
{StrId::STR_IGNORE, StrId::STR_SLEEP, StrId::STR_PAGE_TURN, StrId::STR_FORCE_REFRESH, StrId::STR_FOOTNOTES},
"shortPwrBtn", StrId::STR_CAT_CONTROLS),
SettingInfo::Toggle(StrId::STR_PWR_BTN_FOOTNOTE_BACK, &CrossPointSettings::pwrBtnFootnoteBack,
"pwrBtnFootnoteBack", StrId::STR_CAT_CONTROLS),
SettingInfo::Toggle(StrId::STR_BACK_SHORT_TO_FILE_BROWSER, &CrossPointSettings::backShortToFileBrowser,
"backShortToFileBrowser", StrId::STR_CAT_CONTROLS),
// --- System ---
SettingInfo::Value(
@@ -242,6 +290,14 @@ inline std::vector<SettingInfo> getSettingsList(const SdCardFontRegistry* regist
KOREADER_STORE.saveToFile();
},
"koSendMetadata", StrId::STR_KOREADER_SYNC),
SettingInfo::DynamicEnum(
StrId::STR_SYNC_BEHAVIOR, {StrId::STR_ASK_EVERY_TIME, StrId::STR_SMART_SYNC},
[] { return static_cast<uint8_t>(KOREADER_STORE.getSyncBehavior()); },
[](uint8_t v) {
KOREADER_STORE.setSyncBehavior(static_cast<KOReaderSyncBehavior>(v));
KOREADER_STORE.saveToFile();
},
"koSyncBehavior", StrId::STR_KOREADER_SYNC),
// --- Status Bar Settings (web-only, uses StatusBarSettingsActivity) ---
SettingInfo::Toggle(StrId::STR_CHAPTER_PAGE_COUNT, &CrossPointSettings::statusBarChapterPageCount,
"statusBarChapterPageCount", StrId::STR_CUSTOMISE_STATUS_BAR),
@@ -292,11 +348,30 @@ inline std::vector<SettingInfo> getSettingsList(const SdCardFontRegistry* regist
}();
std::vector<SettingInfo> v = baseList;
if (!BoardConfig::hasTouch()) {
v.erase(std::remove_if(v.begin(), v.end(),
[](const SettingInfo& s) {
return s.nameId == StrId::STR_TOUCH_READER_CONTROLS ||
s.nameId == StrId::STR_SUNLIGHT_FADING_FIX;
}),
v.end());
}
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());
}
if (registry && registry->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);
}
}
if (dictionaries && !dictionaries->empty()) {
// Insert at the end of the Reader category (just before the first Controls entry).
auto it =
std::find_if(v.begin(), v.end(), [](const SettingInfo& s) { return s.category == StrId::STR_CAT_CONTROLS; });
v.insert(it, buildDictionarySetting(*dictionaries));
}
return v;
}
+17
View File
@@ -22,3 +22,20 @@ void Activity::startActivityForResult(std::unique_ptr<Activity>&& activity, Acti
void Activity::setResult(ActivityResult&& result) { this->result = std::move(result); }
void Activity::finish() { activityManager.popActivity(); }
Activity::ListTouchResult Activity::handleListTouch(int& selectedIndex, const int itemCount, const int listTop,
const int listHeight, const bool hasSubtitle) {
int touched = -1;
if (mappedInput.wasListItemTouchedDown(touched, itemCount, selectedIndex, listTop, listHeight, hasSubtitle)) {
if (selectedIndex != touched) {
selectedIndex = touched;
requestUpdate();
}
return ListTouchResult::Consumed;
}
if (mappedInput.wasListItemTapped(touched, itemCount, selectedIndex, listTop, listHeight, hasSubtitle)) {
selectedIndex = touched;
return ListTouchResult::Activated;
}
return ListTouchResult::None;
}
+14
View File
@@ -44,6 +44,8 @@ class Activity {
virtual bool skipLoopDelay() { return false; }
virtual bool preventAutoSleep() { return false; }
virtual bool isReaderActivity() const { return false; }
virtual bool isHomeActivity() const { return false; }
virtual bool handleHomeGesture() { return false; }
virtual ScreenshotInfo getScreenshotInfo() const { return {}; }
// Start a new activity without destroying the current one
@@ -60,4 +62,16 @@ class Activity {
// TODO: remove this in near future
void onGoHome(HomeMenuItem item = HomeMenuItem::NONE);
void onSelectBook(const std::string& path);
protected:
enum class ListTouchResult : uint8_t {
None, // touch did not hit the list
Consumed, // touchdown moved the highlight (repaint already requested)
Activated // tap landed on a row: selectedIndex is updated, caller activates it
};
// Shared touch handling for selectable list screens: touchdown highlights the
// touched row, a tap selects and reports Activated. The caller supplies the
// list band and runs its own activate action on Activated.
ListTouchResult handleListTouch(int& selectedIndex, int itemCount, int listTop, int listHeight, bool hasSubtitle);
};
+14 -1
View File
@@ -22,12 +22,17 @@
static portMUX_TYPE activityManagerSpinlock = portMUX_INITIALIZER_UNLOCKED;
void ActivityManager::begin() {
#if defined(configNUM_CORES) && configNUM_CORES > 1
constexpr BaseType_t renderTaskCore = 1;
#else
constexpr BaseType_t renderTaskCore = 0;
#endif
xTaskCreatePinnedToCore(&renderTaskTrampoline, "ActivityManagerRender",
8192, // Stack size
this, // Parameters
1, // Priority
&renderTaskHandle, // Task handle
0 // Pin to core 0 (PRO_CPU)
renderTaskCore // Keep long renders/cover decodes off CPU 0's idle watchdog when available
);
assert(renderTaskHandle != nullptr && "Failed to create render task");
}
@@ -61,6 +66,14 @@ void ActivityManager::renderTaskLoop() {
void ActivityManager::loop() {
if (currentActivity) {
if (!currentActivity->isHomeActivity() && mappedInput.wasHomeGesture()) {
if (currentActivity->handleHomeGesture()) {
return;
}
goHome();
return;
}
// Note: do not hold a lock here, the loop() method must be responsible for acquire one if needed
currentActivity->loop();
}
@@ -14,6 +14,7 @@
#include "activities/network/WifiSelectionActivity.h"
#include "activities/util/KeyboardEntryActivity.h"
#include "components/UITheme.h"
#include "components/icons/search24.h"
#include "fontIds.h"
#include "network/HttpDownloader.h"
#include "util/BookCacheUtils.h"
@@ -23,8 +24,22 @@
namespace {
constexpr int PAGE_ITEMS = 23;
constexpr int HEADER_Y = 15;
constexpr int HEADER_X = 16;
constexpr int SEARCH_ICON_SIZE = 24;
constexpr int SEARCH_ICON_MARGIN = 14;
constexpr int SEARCH_ICON_Y = 15;
constexpr int DOWNLOAD_PROGRESS_STEP_PERCENT = 5;
constexpr unsigned long DOWNLOAD_PROGRESS_MIN_UPDATE_MS = 5000;
Rect searchIconRect(const GfxRenderer& renderer) {
return Rect{renderer.getScreenWidth() - SEARCH_ICON_SIZE - SEARCH_ICON_MARGIN, SEARCH_ICON_Y, SEARCH_ICON_SIZE + 8,
SEARCH_ICON_SIZE + 8};
}
bool contains(const Rect& rect, const int x, const int y) {
return x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height;
}
} // namespace
void OpdsBookBrowserActivity::onEnter() {
@@ -72,7 +87,9 @@ void OpdsBookBrowserActivity::loop() {
}
if (state == BrowserState::ERROR) {
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
int tx = 0;
int ty = 0;
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm) || mappedInput.wasScreenTapped(tx, ty)) {
if (WiFi.status() == WL_CONNECTED && WiFi.localIP() != IPAddress(0, 0, 0, 0)) {
state = BrowserState::LOADING;
statusMessage = tr(STR_LOADING);
@@ -97,18 +114,59 @@ void OpdsBookBrowserActivity::loop() {
if (state == BrowserState::DOWNLOADING) return;
if (state == BrowserState::BROWSING) {
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
auto activateSelected = [this] {
if (!entries.empty()) {
const auto& entry = entries[selectorIndex];
entry.type == OpdsEntryType::BOOK ? downloadBook(entry) : navigateToEntry(entry);
}
};
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
activateSelected();
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
navigateBack();
} else if (mappedInput.wasReleased(MappedInputManager::Button::Left)) {
if (!searchTemplate.empty() && selectorIndex == 0) launchSearch();
}
int tx = 0;
int ty = 0;
if (!searchTemplate.empty() && mappedInput.wasScreenTapped(tx, ty) && contains(searchIconRect(renderer), tx, ty)) {
launchSearch();
return;
}
if (!entries.empty()) {
int row = -1;
const auto touch = mappedInput.rowTouch(row, /*top=*/60, /*rowStep=*/30, PAGE_ITEMS);
if (touch != MappedInputManager::RowTouch::None) {
const int touched = selectorIndex / PAGE_ITEMS * PAGE_ITEMS + row;
if (touched >= 0 && touched < static_cast<int>(entries.size())) {
if (touch == MappedInputManager::RowTouch::Down) {
if (selectorIndex != touched) {
selectorIndex = touched;
requestUpdate();
}
} else {
selectorIndex = touched;
activateSelected();
}
return;
}
}
const auto swipe = mappedInput.wasSwipe();
if (swipe == MappedInputManager::SwipeDir::Up) {
selectorIndex = ButtonNavigator::nextPageIndex(selectorIndex, entries.size(), PAGE_ITEMS);
requestUpdate();
return;
}
if (swipe == MappedInputManager::SwipeDir::Down) {
selectorIndex = ButtonNavigator::previousPageIndex(selectorIndex, entries.size(), PAGE_ITEMS);
requestUpdate();
return;
}
buttonNavigator.onNextRelease([this] {
selectorIndex = ButtonNavigator::nextIndex(selectorIndex, entries.size());
requestUpdate();
@@ -136,7 +194,14 @@ void OpdsBookBrowserActivity::render(RenderLock&&) {
// Show server name in header if available, otherwise generic title
const char* headerTitle = server.name.empty() ? tr(STR_OPDS_BROWSER) : server.name.c_str();
renderer.drawCenteredText(UI_12_FONT_ID, 15, headerTitle, true, EpdFontFamily::BOLD);
const int headerRightInset = searchTemplate.empty() ? HEADER_X : (SEARCH_ICON_SIZE + SEARCH_ICON_MARGIN * 2 + 8);
const auto clippedHeader =
renderer.truncatedText(UI_12_FONT_ID, headerTitle, pageWidth - HEADER_X - headerRightInset, EpdFontFamily::BOLD);
renderer.drawText(UI_12_FONT_ID, HEADER_X, HEADER_Y, clippedHeader.c_str(), true, EpdFontFamily::BOLD);
if (!searchTemplate.empty()) {
const auto rect = searchIconRect(renderer);
renderer.drawIcon(Search24Icon.bits, rect.x + 4, rect.y + 4, Search24Icon.w);
}
if (state == BrowserState::CHECK_WIFI || state == BrowserState::LOADING) {
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, statusMessage.c_str());
@@ -149,6 +214,9 @@ void OpdsBookBrowserActivity::render(RenderLock&&) {
if (state == BrowserState::ERROR) {
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 20, tr(STR_ERROR_MSG));
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 10, errorMessage.c_str());
if (mappedInput.hasTouch()) {
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 40, tr(STR_TAP_TO_RETRY));
}
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_RETRY), "", "");
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer();
+3 -1
View File
@@ -20,7 +20,9 @@ void CrashActivity::onEnter() {
}
void CrashActivity::loop() {
if (mappedInput.isPressed(MappedInputManager::Button::Back)) {
int x = 0;
int y = 0;
if (mappedInput.wasPressed(MappedInputManager::Button::Back) || mappedInput.wasScreenTapped(x, y)) {
finish();
}
}
+35 -1
View File
@@ -205,8 +205,12 @@ 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);
const auto& metrics = UITheme::getInstance().getMetrics();
const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
const int contentHeight =
renderer.getScreenHeight() - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing - pathReserved;
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
auto activateSelected = [this] {
if (lockNextConfirmRelease) {
lockNextConfirmRelease = false;
return;
@@ -234,6 +238,11 @@ void FileBrowserActivity::loop() {
const std::string fullPath = cleanBasePath + entry;
auto handler = [this, fullPath](const ActivityResult& res) {
// The confirmation popup acts on button press; if that button is still
// held when we resume, swallow its release so it doesn't also act here
// (Back would go up a directory, Confirm would open the selection).
lockLongPressBack = mappedInput.isPressed(MappedInputManager::Button::Back);
lockNextConfirmRelease = mappedInput.isPressed(MappedInputManager::Button::Confirm);
if (!res.isCancelled) {
LOG_DBG("FileBrowser", "Attempting to delete: %s", fullPath.c_str());
if (removeDirFile(fullPath)) {
@@ -273,6 +282,19 @@ void FileBrowserActivity::loop() {
}
}
return;
};
int touchSel = static_cast<int>(selectorIndex);
const auto listTouch = handleListTouch(touchSel, static_cast<int>(files.size()), contentTop, contentHeight, false);
if (listTouch != ListTouchResult::None) {
selectorIndex = static_cast<size_t>(touchSel);
if (listTouch == ListTouchResult::Activated) activateSelected();
return;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
activateSelected();
return;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
@@ -303,6 +325,18 @@ void FileBrowserActivity::loop() {
}
int listSize = static_cast<int>(files.size());
const auto swipe = mappedInput.wasSwipe();
if (swipe == MappedInputManager::SwipeDir::Up) {
selectorIndex = ButtonNavigator::nextPageIndex(static_cast<int>(selectorIndex), listSize, pageItems);
requestUpdate();
return;
}
if (swipe == MappedInputManager::SwipeDir::Down) {
selectorIndex = ButtonNavigator::previousPageIndex(static_cast<int>(selectorIndex), listSize, pageItems);
requestUpdate();
return;
}
buttonNavigator.onNextRelease([this, listSize] {
selectorIndex = ButtonNavigator::nextIndex(static_cast<int>(selectorIndex), listSize);
requestUpdate();
+83 -13
View File
@@ -168,21 +168,13 @@ void HomeActivity::freeCoverBuffer() {
void HomeActivity::loop() {
const int menuCount = getMenuItemCount();
const auto& metrics = UITheme::getInstance().getMetrics();
buttonNavigator.onNext([this, menuCount] {
selectorIndex = ButtonNavigator::nextIndex(selectorIndex, menuCount);
requestUpdate();
});
buttonNavigator.onPrevious([this, menuCount] {
selectorIndex = ButtonNavigator::previousIndex(selectorIndex, menuCount);
requestUpdate();
});
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
auto activateSelection = [this] {
if (selectorIndex < recentBooks.size()) {
onSelectBook(recentBooks[selectorIndex].path);
} else {
return;
}
const int menuIndex = selectorIndex - static_cast<int>(recentBooks.size());
switch (indexToMenuItem(menuIndex, hasOpdsServers)) {
case HomeMenuItem::FILE_BROWSER:
@@ -203,7 +195,84 @@ void HomeActivity::loop() {
default:
break;
}
};
buttonNavigator.onNext([this, menuCount] {
selectorIndex = ButtonNavigator::nextIndex(selectorIndex, menuCount);
requestUpdate();
});
buttonNavigator.onPrevious([this, menuCount] {
selectorIndex = ButtonNavigator::previousIndex(selectorIndex, menuCount);
requestUpdate();
});
const auto swipe = mappedInput.wasSwipe();
if (swipe == MappedInputManager::SwipeDir::Up) {
selectorIndex = ButtonNavigator::nextIndex(selectorIndex, menuCount);
requestUpdate();
return;
}
if (swipe == MappedInputManager::SwipeDir::Down) {
selectorIndex = ButtonNavigator::previousIndex(selectorIndex, menuCount);
requestUpdate();
return;
}
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) backPressSeen = true;
// Back is otherwise unused on the home menu: open the most recently read
// book directly (recentBooks is most-recent-first and already pruned of
// files missing from the SD card). backPressSeen guards against the stale
// release of the Back press that closed the previous activity.
if (mappedInput.wasReleased(MappedInputManager::Button::Back) && backPressSeen && !recentBooks.empty()) {
onSelectBook(recentBooks[0].path);
return;
}
int tx = 0;
int ty = 0;
if (!recentBooks.empty() && mappedInput.wasScreenTouchDown(tx, ty) && tx >= 0 && tx < renderer.getScreenWidth() &&
ty >= metrics.homeTopPadding && ty < metrics.homeTopPadding + metrics.homeCoverTileHeight) {
if (selectorIndex != 0) {
selectorIndex = 0;
requestUpdate();
}
return;
}
if (!recentBooks.empty() &&
mappedInput.wasTapInRect(0, metrics.homeTopPadding, renderer.getScreenWidth(), metrics.homeCoverTileHeight)) {
selectorIndex = 0;
activateSelection();
return;
}
const int menuTop = metrics.homeTopPadding + metrics.homeCoverTileHeight + metrics.homeMenuTopOffset;
const int renderedMenuSelection =
metrics.homeContinueReadingInMenu ? selectorIndex : selectorIndex - recentBooks.size();
const int renderedMenuCount =
menuCount - (metrics.homeContinueReadingInMenu ? 0 : static_cast<int>(recentBooks.size()));
int menuRow = -1;
const auto menuTouch = mappedInput.rowTouch(menuRow, menuTop, metrics.menuRowHeight + metrics.menuSpacing,
renderedMenuCount, 0, INT32_MAX, metrics.menuRowHeight);
if (menuTouch != MappedInputManager::RowTouch::None) {
const int touchedIndex =
metrics.homeContinueReadingInMenu ? menuRow : menuRow + static_cast<int>(recentBooks.size());
if (menuTouch == MappedInputManager::RowTouch::Down) {
if (selectorIndex != touchedIndex) {
selectorIndex = touchedIndex;
requestUpdate();
}
} else {
selectorIndex = touchedIndex;
activateSelection();
}
return;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
activateSelection();
}
}
@@ -256,7 +325,8 @@ void HomeActivity::render(RenderLock&&) {
[&menuItems](int index) { return std::string(menuItems[index]); },
[&menuIcons](int index) { return menuIcons[index]; });
const auto labels = mappedInput.mapLabels("", tr(STR_SELECT), tr(STR_DIR_UP), tr(STR_DIR_DOWN));
const auto labels = mappedInput.mapLabels(recentBooks.empty() ? "" : tr(STR_RESUME), tr(STR_SELECT), tr(STR_DIR_UP),
tr(STR_DIR_DOWN));
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer();
+4
View File
@@ -18,6 +18,9 @@ class HomeActivity final : public Activity {
bool hasOpdsServers = false;
bool coverRendered = false; // Track if cover has been rendered once
bool coverBufferStored = false; // Track if cover buffer is stored
// Home can be entered while Back is still held (e.g. leaving Settings with
// Back): ignore that stale release until a fresh press is seen here.
bool backPressSeen = false;
uint8_t* coverBuffer = nullptr; // HomeActivity's own buffer for cover image
size_t coverBufferSize = 0; // Bytes allocated to coverBuffer
// Logical rect last passed to drawRecentBookCover. The cover snapshot only
@@ -77,4 +80,5 @@ class HomeActivity final : public Activity {
void onExit() override;
void loop() override;
void render(RenderLock&&) override;
bool isHomeActivity() const override { return true; }
};
@@ -43,6 +43,10 @@ void RecentBooksActivity::onExit() {
void RecentBooksActivity::loop() {
const int pageItems = UITheme::getInstance().getNumberOfItemsPerPage(renderer, true, false, true, true);
const auto& metrics = UITheme::getInstance().getMetrics();
const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
const int contentHeight =
renderer.getScreenHeight() - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing;
// 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,11 +75,34 @@ void RecentBooksActivity::loop() {
}
}
int touchSel = static_cast<int>(selectorIndex);
const auto listTouch =
handleListTouch(touchSel, static_cast<int>(recentBooks.size()), contentTop, contentHeight, true);
if (listTouch != ListTouchResult::None) {
selectorIndex = static_cast<size_t>(touchSel);
if (listTouch == ListTouchResult::Activated) {
LOG_DBG("RBA", "Tapped recent book: %s", recentBooks[selectorIndex].path.c_str());
onSelectBook(recentBooks[selectorIndex].path);
}
return;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
onGoHome();
}
int listSize = static_cast<int>(recentBooks.size());
const auto swipe = mappedInput.wasSwipe();
if (swipe == MappedInputManager::SwipeDir::Up) {
selectorIndex = ButtonNavigator::nextPageIndex(static_cast<int>(selectorIndex), listSize, pageItems);
requestUpdate();
return;
}
if (swipe == MappedInputManager::SwipeDir::Down) {
selectorIndex = ButtonNavigator::previousPageIndex(static_cast<int>(selectorIndex), listSize, pageItems);
requestUpdate();
return;
}
buttonNavigator.onNextRelease([this, listSize] {
selectorIndex = ButtonNavigator::nextIndex(static_cast<int>(selectorIndex), listSize);
@@ -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 "util/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);
}
esp_task_wdt_reset();
resetTaskWatchdogIfSubscribed();
constexpr int MAX_ITERATIONS = 80;
for (int i = 0; i < MAX_ITERATIONS && webServer->isRunning(); i++) {
webServer->handleClient();
if ((i & 0x07) == 0x07) {
esp_task_wdt_reset();
resetTaskWatchdogIfSubscribed();
}
if ((i & 0x0F) == 0x0F) {
yield();
@@ -5,7 +5,6 @@
#include <GfxRenderer.h>
#include <I18n.h>
#include <WiFi.h>
#include <esp_task_wdt.h>
#include <cstddef>
@@ -17,6 +16,7 @@
#include "components/UITheme.h"
#include "fontIds.h"
#include "util/QrUtils.h"
#include "util/TaskWatchdog.h"
namespace {
// AP Mode configuration
@@ -328,7 +328,7 @@ void CrossPointWebServerActivity::loop() {
}
// Reset watchdog BEFORE processing - HTTP header parsing can be slow
esp_task_wdt_reset();
resetTaskWatchdogIfSubscribed();
// 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) {
esp_task_wdt_reset();
resetTaskWatchdogIfSubscribed();
}
// Yield and check for exit button every 64 iterations
if ((i & 0x3F) == 0x3F) {
@@ -24,6 +24,16 @@ void NetworkModeSelectionActivity::onEnter() {
void NetworkModeSelectionActivity::onExit() { Activity::onExit(); }
void NetworkModeSelectionActivity::loop() {
auto selectCurrent = [this] {
NetworkMode mode = NetworkMode::JOIN_NETWORK;
if (selectedIndex == 1) {
mode = NetworkMode::CONNECT_CALIBRE;
} else if (selectedIndex == 2) {
mode = NetworkMode::CREATE_HOTSPOT;
}
onModeSelected(mode);
};
// Handle back button - cancel
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
onCancel();
@@ -32,16 +42,24 @@ void NetworkModeSelectionActivity::loop() {
// Handle confirm button - select current option
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
NetworkMode mode = NetworkMode::JOIN_NETWORK;
if (selectedIndex == 1) {
mode = NetworkMode::CONNECT_CALIBRE;
} else if (selectedIndex == 2) {
mode = NetworkMode::CREATE_HOTSPOT;
}
onModeSelected(mode);
selectCurrent();
return;
}
const auto& metrics = UITheme::getInstance().getMetrics();
const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
const int contentHeight =
renderer.getScreenHeight() - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing * 2;
switch (handleListTouch(selectedIndex, MENU_ITEM_COUNT, contentTop, contentHeight, true)) {
case ListTouchResult::Activated:
selectCurrent();
return;
case ListTouchResult::Consumed:
return;
case ListTouchResult::None:
break;
}
// Handle navigation
buttonNavigator.onNext([this] {
selectedIndex = ButtonNavigator::nextIndex(selectedIndex, MENU_ITEM_COUNT);
@@ -354,6 +354,11 @@ void WifiSelectionActivity::attemptConnection() {
WiFi.disconnect(true, true); // Abort any in-progress SDK auto-connect and clear NVS-saved SSID
delay(100);
// Scan all channels so networks with multiple APs use the strongest matching
// BSSID instead of the first match found by the framework's default fast scan.
WiFi.setScanMethod(WIFI_ALL_CHANNEL_SCAN);
WiFi.setSortMethod(WIFI_CONNECT_AP_BY_SIGNAL);
// Set hostname so routers show "CrossPoint-Reader-AABBCCDDEEFF" instead of "esp32-XXXXXXXXXXXX"
String mac = WiFi.macAddress();
mac.replace(":", "");
@@ -382,6 +387,16 @@ void WifiSelectionActivity::checkConnectionStatus() {
connectedIP = ipStr;
autoConnecting = false;
#if defined(ENABLE_SERIAL_LOG) && LOG_LEVEL >= 2
uint8_t connectedBssid[6] = {};
WiFi.BSSID(connectedBssid);
LOG_DBG("WIFI", "Connected BSSID: %02x:%02x:%02x:%02x:%02x:%02x, channel: %d, RSSI: %d dBm",
static_cast<unsigned>(connectedBssid[0]), static_cast<unsigned>(connectedBssid[1]),
static_cast<unsigned>(connectedBssid[2]), static_cast<unsigned>(connectedBssid[3]),
static_cast<unsigned>(connectedBssid[4]), static_cast<unsigned>(connectedBssid[5]), WiFi.channel(),
WiFi.RSSI());
#endif
// Sync RTC from NTP on the first successful WiFi connection only. The DS3231
// drifts ~2 ppm so one sync is enough; users can force a re-sync from
// Settings > Customise Status Bar > Sync clock now.
@@ -502,6 +517,34 @@ void WifiSelectionActivity::loop() {
// Handle save prompt state
if (state == WifiSelectionState::SAVE_PROMPT) {
{
const Rect screen = UITheme::getInstance().getScreenSafeArea(renderer, true, false);
const auto height = renderer.getLineHeight(UI_10_FONT_ID);
const int buttonY = screen.y + (screen.height - height * 3) / 2 + 80;
constexpr int buttonWidth = 60;
constexpr int buttonSpacing = 30;
const int startX = screen.x + (screen.width - (buttonWidth * 2 + buttonSpacing)) / 2;
int touchedOption = -1;
const auto touch = mappedInput.colTouch(touchedOption, startX - 8, buttonWidth + buttonSpacing, 2, buttonY - 8,
buttonY + height + 8, buttonWidth + 16);
if (touch == MappedInputManager::RowTouch::Down) {
if (savePromptSelection != touchedOption) {
savePromptSelection = touchedOption;
requestUpdate();
}
return;
}
if (touch == MappedInputManager::RowTouch::Tap) {
savePromptSelection = touchedOption;
if (savePromptSelection == 0) {
RenderLock lock(*this);
WIFI_STORE.addCredential(selectedSSID, enteredPassword);
}
onComplete(true);
return;
}
}
if (mappedInput.wasPressed(MappedInputManager::Button::Up) ||
mappedInput.wasPressed(MappedInputManager::Button::Left)) {
if (savePromptSelection > 0) {
@@ -531,6 +574,39 @@ void WifiSelectionActivity::loop() {
// Handle forget prompt state (connection failed with saved credentials)
if (state == WifiSelectionState::FORGET_PROMPT) {
{
const Rect screen = UITheme::getInstance().getScreenSafeArea(renderer, true, false);
const auto height = renderer.getLineHeight(UI_10_FONT_ID);
const int buttonY = screen.y + (screen.height - height * 3) / 2 + 80;
constexpr int buttonWidth = 120;
constexpr int buttonSpacing = 30;
const int startX = screen.x + (screen.width - (buttonWidth * 2 + buttonSpacing)) / 2;
int touchedOption = -1;
const auto touch = mappedInput.colTouch(touchedOption, startX - 8, buttonWidth + buttonSpacing, 2, buttonY - 8,
buttonY + height + 8, buttonWidth + 16);
if (touch == MappedInputManager::RowTouch::Down) {
if (forgetPromptSelection != touchedOption) {
forgetPromptSelection = touchedOption;
requestUpdate();
}
return;
}
if (touch == MappedInputManager::RowTouch::Tap) {
forgetPromptSelection = touchedOption;
if (forgetPromptSelection == 1) {
RenderLock lock(*this);
WIFI_STORE.removeCredential(selectedSSID);
const auto network = find_if(networks.begin(), networks.end(),
[this](const WifiNetworkInfo& net) { return net.ssid == selectedSSID; });
if (network != networks.end()) {
network->hasSavedPassword = false;
}
}
startWifiScan();
return;
}
}
if (mappedInput.wasPressed(MappedInputManager::Button::Up) ||
mappedInput.wasPressed(MappedInputManager::Button::Left)) {
if (forgetPromptSelection > 0) {
@@ -626,6 +702,35 @@ void WifiSelectionActivity::loop() {
}
}
if (!networks.empty()) {
const auto& metrics = UITheme::getInstance().getMetrics();
Rect screen = UITheme::getInstance().getScreenSafeArea(renderer, true, false);
const int contentTop =
screen.y + metrics.topPadding + metrics.headerHeight + metrics.tabBarHeight + metrics.verticalSpacing;
const int contentHeight = screen.height - contentTop - metrics.verticalSpacing * 2;
int touchSel = static_cast<int>(selectedNetworkIndex);
const auto listTouch =
handleListTouch(touchSel, static_cast<int>(networks.size()), contentTop, contentHeight, false);
if (listTouch != ListTouchResult::None) {
selectedNetworkIndex = static_cast<size_t>(touchSel);
if (listTouch == ListTouchResult::Activated) selectNetwork(selectedNetworkIndex);
return;
}
const int pageItems = GUI.getListPageItems(contentHeight, false);
const auto swipe = mappedInput.wasSwipe();
if (swipe == MappedInputManager::SwipeDir::Up) {
selectedNetworkIndex = ButtonNavigator::nextPageIndex(selectedNetworkIndex, networks.size(), pageItems);
requestUpdate();
return;
}
if (swipe == MappedInputManager::SwipeDir::Down) {
selectedNetworkIndex = ButtonNavigator::previousPageIndex(selectedNetworkIndex, networks.size(), pageItems);
requestUpdate();
return;
}
}
// Handle navigation
buttonNavigator.onNext([this] {
selectedNetworkIndex = ButtonNavigator::nextIndex(selectedNetworkIndex, networks.size());
@@ -0,0 +1,254 @@
#include "DictionaryDefinitionActivity.h"
#include <FontCacheManager.h>
#include <GfxRenderer.h>
#include <I18n.h>
#include <algorithm>
#include <cstdint>
#include <cstdio>
#include "CrossPointSettings.h"
#include "components/UITheme.h"
#include "fontIds.h"
#include "util/HtmlToPlainText.h"
namespace {
// Longest measurable/drawable span. Wrapped lines stay under the screen width
// (far below this); only pathological unbreakable tokens are split at this cap.
constexpr size_t MAX_LINE_BYTES = 191;
// Body text left/right inset, matching the reader's default feel.
constexpr int SIDE_PADDING = 20;
} // namespace
void DictionaryDefinitionActivity::onEnter() {
Activity::onEnter();
// Normalize StarDict multi-type separators so the wrap loop and the
// C-string font APIs below both see the whole definition.
std::replace(definition.begin(), definition.end(), '\0', '\n');
definition = htmlToPlainText(definition);
wrapText();
requestUpdate();
}
int DictionaryDefinitionActivity::measureSpan(const int fontId, const char* text, size_t len) const {
char buf[MAX_LINE_BYTES + 1];
len = std::min(len, MAX_LINE_BYTES);
memcpy(buf, text, len);
buf[len] = '\0';
return renderer.getTextAdvanceX(fontId, buf, EpdFontFamily::REGULAR);
}
// Greedy word-wrap of `definition` into byte spans. '\n' breaks lines (blank
// lines survive as paragraph spacing; NULs from multi-type StarDict entries
// were normalized to newlines in onEnter); '\r' is dropped by treating it as
// a space at a token edge.
void DictionaryDefinitionActivity::wrapText() {
lines.clear();
lines.reserve(definition.size() / 32 + 8);
const int fontId = SETTINGS.getReaderFontId();
// SD-card fonts: merge every definition codepoint into the persistent
// advance table up front. Otherwise each unseen codepoint measured below
// falls back to an on-demand glyph load from SD (8-slot overflow ring).
renderer.ensureSdCardFontReady(fontId, definition.c_str(), 0x01 /* REGULAR */);
const auto& metrics = UITheme::getInstance().getMetrics();
const auto orientation = renderer.getOrientation();
const bool isLandscape = orientation == GfxRenderer::Orientation::LandscapeClockwise ||
orientation == GfxRenderer::Orientation::LandscapeCounterClockwise;
const bool isInverted = orientation == GfxRenderer::Orientation::PortraitInverted;
const int hintGutterWidth = isLandscape ? metrics.sideButtonHintsWidth : 0;
const int maxWidth = renderer.getScreenWidth() - hintGutterWidth - 2 * SIDE_PADDING;
const int spaceWidth = renderer.getSpaceWidth(fontId, EpdFontFamily::REGULAR);
const int lineHeight = renderer.getLineHeight(fontId);
const int topArea = (isInverted ? metrics.buttonHintsHeight : 0) + metrics.topPadding + metrics.headerHeight;
const int bottomArea = metrics.buttonHintsHeight + metrics.verticalSpacing;
linesPerPage = std::max(1, (renderer.getScreenHeight() - topArea - bottomArea) / lineHeight);
const char* text = definition.c_str();
const uint32_t n = static_cast<uint32_t>(definition.size());
uint32_t lineStart = 0;
uint32_t lineEnd = 0; // one past the last token byte on the current line
int lineWidth = 0;
const auto flushLine = [&](uint32_t nextStart) {
lines.push_back({lineStart, static_cast<uint16_t>(lineEnd - lineStart)});
lineStart = nextStart;
lineEnd = nextStart;
lineWidth = 0;
};
uint32_t i = 0;
while (i < n) {
const char c = text[i];
if (c == '\n' || c == '\0') {
flushLine(i + 1);
i++;
continue;
}
if (c == ' ' || c == '\t' || c == '\r') {
i++;
continue;
}
// Token: run of non-whitespace bytes, capped at the measure buffer.
const uint32_t tokenStart = i;
while (i < n && text[i] != ' ' && text[i] != '\t' && text[i] != '\r' && text[i] != '\n' && text[i] != '\0' &&
i - tokenStart < MAX_LINE_BYTES) {
i++;
}
// If the byte cap cut the token mid-UTF-8-sequence, back off to the last
// complete codepoint so measure/draw never see a partial sequence. A
// natural stop lands on whitespace or the terminating NUL, never on a
// continuation byte, so this is a no-op there.
while (i - tokenStart > 1 && (text[i] & 0xC0) == 0x80) i--;
const uint32_t tokenLen = i - tokenStart;
const int tokenWidth = measureSpan(fontId, text + tokenStart, tokenLen);
if (lineEnd == lineStart) {
lineStart = tokenStart;
lineEnd = tokenStart + tokenLen;
lineWidth = tokenWidth;
} else if (lineWidth + spaceWidth + tokenWidth <= maxWidth &&
tokenStart + tokenLen - lineStart <= UINT16_MAX) { // span len must fit Line::len
lineEnd = tokenStart + tokenLen;
lineWidth += spaceWidth + tokenWidth;
} else {
flushLine(tokenStart);
lineEnd = tokenStart + tokenLen;
lineWidth = tokenWidth;
}
// An unbreakable token wider than the screen is now alone on the line
// (any previous content was flushed above): split it at the widest
// fitting UTF-8 boundary and carry the remainder forward.
while (lineWidth > maxWidth && lineEnd - lineStart > 1) {
const uint32_t len = lineEnd - lineStart;
uint32_t lastFit = 0;
for (uint32_t f = 1; f <= len; f++) {
if (f == len || (text[lineStart + f] & 0xC0) != 0x80) { // codepoint boundary
if (measureSpan(fontId, text + lineStart, f) > maxWidth) break;
lastFit = f;
}
}
if (lastFit == 0) {
// Even a single over-wide glyph must make progress; consume its whole
// UTF-8 sequence rather than splitting it into invalid fragments.
lastFit = 1;
while (lastFit < len && (text[lineStart + lastFit] & 0xC0) == 0x80) lastFit++;
}
const uint32_t rest = lineStart + lastFit;
lineEnd = rest;
flushLine(rest);
lineEnd = rest + (len - lastFit);
lineWidth = measureSpan(fontId, text + lineStart, lineEnd - lineStart);
}
}
if (lineEnd > lineStart) flushLine(n);
// Trim trailing blank lines so the last page is not empty padding.
while (!lines.empty() && lines.back().len == 0) lines.pop_back();
totalPages = std::max(1, (static_cast<int>(lines.size()) + linesPerPage - 1) / linesPerPage);
currentPage = 0;
}
void DictionaryDefinitionActivity::loop() {
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
finish();
return;
}
// Same tap zones as the reader page turns: left third = previous page,
// the rest = next. Back is the usual left-edge swipe.
int tx = 0;
int ty = 0;
if (mappedInput.wasScreenTapped(tx, ty)) {
if (tx < renderer.getScreenWidth() / 3) {
if (currentPage > 0) {
currentPage--;
requestUpdate();
}
} else if (currentPage + 1 < totalPages) {
currentPage++;
requestUpdate();
}
return;
}
buttonNavigator.onNext([this] {
if (currentPage + 1 < totalPages) {
currentPage++;
requestUpdate();
}
});
buttonNavigator.onPrevious([this] {
if (currentPage > 0) {
currentPage--;
requestUpdate();
}
});
}
// Draws the current page's line spans (copied into a stack buffer for NUL
// termination). Called twice per render: once in font-cache scan mode, once
// for the real paint.
void DictionaryDefinitionActivity::drawBody(const int fontId, const int x, const int startY) const {
const int lineHeight = renderer.getLineHeight(fontId);
char buf[MAX_LINE_BYTES + 1];
const int firstLine = currentPage * linesPerPage;
const int lastLine = std::min(firstLine + linesPerPage, static_cast<int>(lines.size()));
for (int i = firstLine; i < lastLine; i++) {
if (lines[i].len == 0) continue;
const size_t len = std::min(static_cast<size_t>(lines[i].len), MAX_LINE_BYTES);
memcpy(buf, definition.c_str() + lines[i].start, len);
buf[len] = '\0';
renderer.drawText(fontId, x, startY + (i - firstLine) * lineHeight, buf);
}
}
void DictionaryDefinitionActivity::render(RenderLock&&) {
renderer.clearScreen();
const auto& metrics = UITheme::getInstance().getMetrics();
const auto orientation = renderer.getOrientation();
const bool isLandscapeCw = orientation == GfxRenderer::Orientation::LandscapeClockwise;
const bool isLandscapeCcw = orientation == GfxRenderer::Orientation::LandscapeCounterClockwise;
const bool isInverted = orientation == GfxRenderer::Orientation::PortraitInverted;
const int hintGutterWidth = (isLandscapeCw || isLandscapeCcw) ? metrics.sideButtonHintsWidth : 0;
const int contentX = isLandscapeCw ? hintGutterWidth : 0;
const int contentWidth = renderer.getScreenWidth() - hintGutterWidth;
const int contentY = isInverted ? metrics.buttonHintsHeight : 0;
// Header: matched headword left, page counter right.
const int headerY = contentY + metrics.topPadding + 10;
renderer.drawText(UI_12_FONT_ID, contentX + SIDE_PADDING, headerY, headword.c_str(), true, EpdFontFamily::BOLD);
if (totalPages > 1) {
char counter[16];
snprintf(counter, sizeof(counter), "%d/%d", currentPage + 1, totalPages);
const int counterWidth = renderer.getTextWidth(UI_10_FONT_ID, counter);
renderer.drawText(UI_10_FONT_ID, contentX + contentWidth - SIDE_PADDING - counterWidth, headerY, counter);
}
// Body: two-pass draw inside a prewarm scope (same pattern as the reader's
// renderContents) so SD-card font glyphs load from SD in one batch instead
// of one on-demand overflow read per character on every page turn.
const int fontId = SETTINGS.getReaderFontId();
const int bodyStartY = contentY + metrics.topPadding + metrics.headerHeight;
auto* fcm = renderer.getFontCacheManager();
auto scope = fcm->createPrewarmScope();
drawBody(fontId, contentX + SIDE_PADDING, bodyStartY); // scan pass: records codepoints only
scope.endScanAndPrewarm();
drawBody(fontId, contentX + SIDE_PADDING, bodyStartY);
const auto labels =
mappedInput.mapLabels(tr(STR_BACK), "", (currentPage > 0 ? "<" : ""), (currentPage + 1 < totalPages ? ">" : ""));
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer();
}
@@ -0,0 +1,46 @@
#pragma once
#include <cstdint>
#include <string>
#include <vector>
#include "activities/Activity.h"
#include "util/ButtonNavigator.h"
// Paged plain-text viewer for one dictionary definition. The definition is
// word-wrapped once on entry; each page renders spans of the original string,
// so no per-line copies are held.
class DictionaryDefinitionActivity final : public Activity {
public:
explicit DictionaryDefinitionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string headword,
std::string definition)
: Activity("DictionaryDefinition", renderer, mappedInput),
headword(std::move(headword)),
definition(std::move(definition)) {}
void onEnter() override;
void loop() override;
void render(RenderLock&&) override;
private:
// One wrapped display line: a byte span of `definition`. Wrapping keeps
// lines under the screen width, so uint16_t length is ample.
struct Line {
uint32_t start;
uint16_t len;
};
void wrapText();
int measureSpan(int fontId, const char* text, size_t len) const;
void drawBody(int fontId, int x, int startY) const;
const std::string headword;
// Not const: onEnter() normalizes embedded NULs (StarDict multi-type
// separators) to newlines so C-string APIs see the whole text.
std::string definition;
std::vector<Line> lines;
int currentPage = 0;
int totalPages = 1;
int linesPerPage = 1;
ButtonNavigator buttonNavigator;
};
@@ -0,0 +1,337 @@
#include "DictionaryWordSelectActivity.h"
#include <FontCacheManager.h>
#include <GfxRenderer.h>
#include <Memory.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <cctype>
#include <climits>
#include <cstdlib>
#include "CrossPointSettings.h"
#include "DictionaryDefinitionActivity.h"
#include "components/UITheme.h"
namespace {
constexpr unsigned long POPUP_DURATION_MS = 1500;
// A token is selectable when it has an ASCII alphanumeric or a non-ASCII
// codepoint outside U+2000-U+206F (dashes, bullets and other General
// Punctuation that appear as standalone tokens are not words).
bool isSelectableToken(const char* text) {
for (const uint8_t* p = reinterpret_cast<const uint8_t*>(text); *p != 0; p++) {
if (*p < 0x80) {
if (std::isalnum(*p)) return true;
} else if (*p == 0xE2 && (p[1] == 0x80 || p[1] == 0x81)) {
if (p[2] == 0) break; // truncated sequence: skipping would step past the NUL
p += 2; // skip the 3-byte General Punctuation codepoint
} else {
return true;
}
}
return false;
}
void indexBuildYield(void*) { vTaskDelay(1); }
} // namespace
void DictionaryWordSelectActivity::onEnter() {
Activity::onEnter();
fontId = SETTINGS.getReaderFontId();
lineHeight = renderer.getLineHeight(fontId);
// No null check: a failed allocation just disables the differential
// fast path (drawHighlightWithSnapshot skips the read), keeping the
// full-repaint path as the fallback.
snapshot = makeUniqueNoThrow<uint8_t[]>(SNAPSHOT_CAPACITY);
extractWords();
// Start on the middle row's word nearest mid-screen instead of top-left:
// any word on the page is then at most half a page of moves away.
if (!words.empty()) {
const int initial = closestInRow(rowCount / 2, renderer.getScreenWidth() / 2);
if (initial >= 0) selected = initial;
}
requestUpdate();
}
void DictionaryWordSelectActivity::extractWords() {
words.clear();
words.reserve(128);
rowCount = 0;
// Single walk: collect the selectable words while accumulating their text
// and styles (~2KB transient string, freed on return). Widths are measured
// afterwards: merging the page's codepoints into the SD font's persistent
// advance table first keeps getTextAdvanceX on the in-RAM path instead of
// loading glyphs from SD one overflow slot at a time.
std::string pageText;
pageText.reserve(2048);
uint8_t styleMask = 0;
for (const auto& element : page->elements) {
if (element->getTag() != TAG_PageLine) continue;
const auto* line = static_cast<const PageLine*>(element.get());
const auto& block = line->getBlock();
if (!block || !block->valid()) continue;
bool rowHasWords = false;
for (uint16_t i = 0; i < block->wordCount(); i++) {
const char* text = block->wordText(i);
if (!isSelectableToken(text)) continue;
WordBox box;
box.x = static_cast<int16_t>(line->xPos + block->wordXpos(i) + marginLeft);
box.y = static_cast<int16_t>(line->yPos + marginTop);
box.style = block->wordStyle(i);
box.width = 0; // measured below, once the advance table is ready
box.row = rowCount;
box.text = text;
words.push_back(box);
rowHasWords = true;
pageText.append(text);
pageText.push_back(' ');
styleMask |= static_cast<uint8_t>(1u << (static_cast<uint8_t>(box.style) & 0x03));
}
if (rowHasWords) rowCount++;
}
if (styleMask == 0) styleMask = 0x01; // REGULAR
renderer.ensureSdCardFontReady(fontId, pageText.c_str(), styleMask);
for (auto& word : words) {
word.width = static_cast<int16_t>(renderer.getTextAdvanceX(fontId, word.text, word.style));
}
}
// Index of the word whose box (with finger-sized slop) contains the touch
// point; -1 when the touch lands on no word. Boxes never overlap after the
// slop grows them, at worst they touch, so first hit wins.
int DictionaryWordSelectActivity::wordAt(const int x, const int y) const {
constexpr int SLOP = 4; // matches the highlight box (+2) plus finger error
for (int i = 0; i < static_cast<int>(words.size()); i++) {
const WordBox& word = words[i];
if (x >= word.x - SLOP && x < word.x + word.width + SLOP && y >= word.y - SLOP && y < word.y + lineHeight + SLOP) {
return i;
}
}
return -1;
}
// Index of the word in `row` whose horizontal center is closest to centerX;
// -1 when the row has no words.
int DictionaryWordSelectActivity::closestInRow(const uint16_t row, const int centerX) const {
int best = -1;
int bestDistance = INT_MAX;
for (int i = 0; i < static_cast<int>(words.size()); i++) {
if (words[i].row != row) continue;
const int distance = std::abs(words[i].x + words[i].width / 2 - centerX);
if (distance < bestDistance) {
bestDistance = distance;
best = i;
}
}
return best;
}
void DictionaryWordSelectActivity::moveVertical(const int direction) {
const WordBox& current = words[selected];
const int targetRow = static_cast<int>(current.row) + direction;
if (targetRow < 0 || targetRow >= static_cast<int>(rowCount)) return;
const int best = closestInRow(static_cast<uint16_t>(targetRow), current.x + current.width / 2);
if (best >= 0 && best != selected) {
selected = best;
requestUpdate();
}
}
void DictionaryWordSelectActivity::performLookup() {
popup = Popup::Busy;
if (!dictOpenAttempted) {
dictOpenAttempted = true;
dictOpenOk = dict.open(SETTINGS.dictionaryName);
}
const bool indexing = dictOpenOk && dict.needsIndex();
popupMsg = indexing ? StrId::STR_DICT_INDEXING : StrId::STR_DICT_LOOKING_UP;
requestUpdateAndWait(); // paint the page + busy popup before blocking on SD
bool ok = dictOpenOk;
if (ok && indexing) ok = dict.buildIndex(&indexBuildYield);
std::string definition;
std::string headword;
const bool found = ok && dict.lookup(words[selected].text, definition, headword);
if (found) {
popup = Popup::None;
startActivityForResult(std::make_unique<DictionaryDefinitionActivity>(renderer, mappedInput, std::move(headword),
std::move(definition)),
[this](const ActivityResult&) { requestUpdate(); });
return;
}
popup = ok ? Popup::NotFound : Popup::Error;
popupMsg = ok ? StrId::STR_DICT_NOT_FOUND : StrId::STR_DICT_ERROR;
popupTime = millis();
requestUpdate();
}
void DictionaryWordSelectActivity::loop() {
if (popup == Popup::NotFound || popup == Popup::Error) {
if (millis() - popupTime >= POPUP_DURATION_MS) {
popup = Popup::None;
requestUpdate();
}
return;
}
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) confirmPressSeen = true;
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
finish();
return;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm) && confirmPressSeen && !words.empty()) {
performLookup();
return;
}
if (words.empty()) return;
// Touch: a touch-down moves the highlight to the touched word (differential
// repaint), a tap on a word selects and looks it up in one go.
int tx = 0;
int ty = 0;
if (mappedInput.wasScreenTouchDown(tx, ty)) {
const int hit = wordAt(tx, ty);
if (hit >= 0 && hit != selected) {
selected = hit;
requestUpdate();
}
return;
}
if (mappedInput.wasScreenTapped(tx, ty)) {
const int hit = wordAt(tx, ty);
if (hit >= 0) {
selected = hit;
performLookup();
}
return;
}
if (mappedInput.wasPressed(MappedInputManager::Button::Left) && selected > 0) {
selected--;
requestUpdate();
} else if (mappedInput.wasPressed(MappedInputManager::Button::Right) &&
selected + 1 < static_cast<int>(words.size())) {
selected++;
requestUpdate();
} else if (mappedInput.wasPressed(MappedInputManager::Button::Up)) {
moveVertical(-1);
} else if (mappedInput.wasPressed(MappedInputManager::Button::Down)) {
moveVertical(1);
}
}
// Saves the pixels under words[selected]'s highlight box, then draws the
// highlight over them. Returns false when the pixels could not be saved
// (no buffer / oversize box) — the highlight is drawn regardless, but the
// next cursor move must do a full repaint.
bool DictionaryWordSelectActivity::drawHighlightWithSnapshot() {
const WordBox& word = words[selected];
int hx = word.x - 2;
int hy = word.y - 2;
int hw = word.width + 4;
int hh = lineHeight + 4;
// Clamp to the panel so save, draw and restore all use the same box.
if (hx < 0) {
hw += hx;
hx = 0;
}
if (hy < 0) {
hh += hy;
hy = 0;
}
bool saved = false;
if (snapshot && hw > 0 && hh > 0) {
saved = renderer.readFramebufferRegion(hx, hy, hw, hh, snapshot.get(), SNAPSHOT_CAPACITY) > 0;
}
snapshotX = static_cast<int16_t>(hx);
snapshotY = static_cast<int16_t>(hy);
snapshotW = static_cast<int16_t>(hw);
snapshotH = static_cast<int16_t>(hh);
snapshotIdx = saved ? selected : -1;
renderer.fillRect(hx, hy, hw, hh, true);
renderer.drawText(fontId, word.x, word.y, word.text, false, word.style);
return saved;
}
// Front-button bar (Back/Confirm/Left/Right). Drawn last on every repaint
// path, including the differential highlight-only path, so it always ends
// up as the top layer even when a highlighted word's box falls under a
// hint's screen area. No side-button hints: Up/Down row jump has no spare
// screen area on this page (it reuses the reader's full-bleed layout), and
// a hint box there would hide text instead of sitting in a reserved gutter.
void DictionaryWordSelectActivity::drawHints() const {
// No selectable word on this page: Confirm/Left/Right are all no-ops
// (guarded by words.empty() in loop()/performLookup), so only Back does
// anything and only Back is hinted.
if (words.empty()) {
const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", "");
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
return;
}
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_LOOKUP), tr(STR_DIR_LEFT), tr(STR_DIR_RIGHT));
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
}
void DictionaryWordSelectActivity::render(RenderLock&&) {
// Differential fast path: only the highlight moved and the framebuffer
// still holds a clean page (no popup or sub-activity since the last full
// repaint). Restore the pixels under the old highlight, draw the new one,
// and push — skipping the two-pass page render entirely.
if (popup == Popup::None && snapshotIdx >= 0 && !words.empty() && selected != snapshotIdx) {
renderer.writeFramebufferRegion(snapshotX, snapshotY, snapshotW, snapshotH, snapshot.get());
// The full path's PrewarmScope cleared the glyph cache on exit; batch-load
// just the highlighted word's glyphs before drawing them white-on-black.
renderer.getFontCacheManager()->prewarmCache(
fontId, words[selected].text, static_cast<uint8_t>(1u << (static_cast<uint8_t>(words[selected].style) & 0x03)));
if (drawHighlightWithSnapshot()) {
drawHints();
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
return;
}
// Snapshot failed (oversize box) — fall through to a full repaint.
}
renderer.clearScreen();
// Same prewarm-scan-then-render pass the reader uses, so SD-card fonts hit
// the in-RAM glyph cache during the real draw.
auto* fcm = renderer.getFontCacheManager();
auto scope = fcm->createPrewarmScope();
page->render(renderer, fontId, marginLeft, marginTop);
scope.endScanAndPrewarm();
page->render(renderer, fontId, marginLeft, marginTop);
if (!words.empty()) {
drawHighlightWithSnapshot();
}
drawHints();
if (popup != Popup::None) {
// The popup overdraws the page, so the snapshot no longer matches the
// framebuffer — force the next render onto the full-repaint path.
snapshotIdx = -1;
// drawPopup overlays the framebuffer and refreshes the display itself.
// I18N.get directly: tr() only accepts literal key names.
GUI.drawPopup(renderer, I18N.get(popupMsg));
return;
}
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
}
@@ -0,0 +1,86 @@
#pragma once
#include <Epub/Page.h>
#include <I18n.h>
#include <memory>
#include <vector>
#include "activities/Activity.h"
#include "util/Dictionary.h"
// Word selection over the current reader page: Left/Right step through words
// in reading order, Up/Down jump rows, Confirm looks the word up and opens
// DictionaryDefinitionActivity, Back returns to the reader. On touch devices a
// touch-down moves the highlight and a tap on a word looks it up directly.
class DictionaryWordSelectActivity final : public Activity {
public:
explicit DictionaryWordSelectActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
std::unique_ptr<Page> page, int marginLeft, int marginTop)
: Activity("DictionaryWordSelect", renderer, mappedInput),
page(std::move(page)),
marginLeft(marginLeft),
marginTop(marginTop) {}
void onEnter() override;
void loop() override;
void render(RenderLock&&) override;
private:
// Screen box of one selectable word. `text` points into the owned Page's
// TextBlock arena (NUL-terminated), valid for this activity's lifetime.
struct WordBox {
int16_t x;
int16_t y;
int16_t width;
uint16_t row;
const char* text;
EpdFontFamily::Style style;
};
enum class Popup : uint8_t { None, Busy, NotFound, Error };
void extractWords();
int closestInRow(uint16_t row, int centerX) const;
int wordAt(int x, int y) const;
void moveVertical(int direction);
void performLookup();
bool drawHighlightWithSnapshot();
void drawHints() const;
std::unique_ptr<Page> page;
const int marginLeft;
const int marginTop;
int fontId = 0;
int lineHeight = 0;
std::vector<WordBox> words;
int selected = 0;
uint16_t rowCount = 0;
Dictionary dict;
bool dictOpenAttempted = false;
bool dictOpenOk = false;
Popup popup = Popup::None;
StrId popupMsg = StrId::STR_DICT_NOT_FOUND;
unsigned long popupTime = 0;
// Differential highlight repaint: the pixels under the current highlight
// box, so a cursor move restores them and repaints only the two affected
// boxes instead of re-running the full two-pass page render (which also
// reloads every SD-font glyph on the page). snapshotIdx is the word whose
// under-pixels are saved; -1 means the framebuffer no longer holds a clean
// page (popup drawn, sub-activity shown) and the next render must be full.
static constexpr size_t SNAPSHOT_CAPACITY = 4096;
std::unique_ptr<uint8_t[]> snapshot;
int16_t snapshotX = 0;
int16_t snapshotY = 0;
int16_t snapshotW = 0;
int16_t snapshotH = 0;
int snapshotIdx = -1;
// The activity is entered while Confirm is still held (long-press trigger):
// ignore the stale release until a fresh press is seen.
bool confirmPressSeen = false;
};
@@ -6,6 +6,10 @@
#include "CrossPointSettings.h"
#include "ReaderUtils.h"
// ReaderUtils.h pulls in ActivityManager.h, which only forward-declares Activity while holding
// std::unique_ptr<Activity> members. Destroying that unique_ptr needs the complete type, so the
// definition must be visible here.
#include "activities/Activity.h"
#include "components/UITheme.h"
#include "fontIds.h"
#include "util/ButtonNavigator.h"
+143 -28
View File
@@ -20,6 +20,7 @@
#include "BookmarkEntry.h"
#include "CrossPointSettings.h"
#include "CrossPointState.h"
#include "DictionaryWordSelectActivity.h"
#include "EpubReaderBookmarksActivity.h"
#include "EpubReaderChapterSelectionActivity.h"
#include "EpubReaderFootnotesActivity.h"
@@ -257,6 +258,29 @@ void EpubReaderActivity::openReaderMenu() {
});
}
void EpubReaderActivity::openDictionaryWordSelect() {
if (SETTINGS.dictionaryName[0] == '\0') {
showDictionaryMessage = true;
dictionaryMessageTime = millis();
requestUpdate();
return;
}
if (!section) return;
auto page = section->loadPage(section->currentPage);
if (!page) return;
// Word geometry must match render(): viewable-area margins plus screen margin.
int orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft;
renderer.getOrientedViewableTRBL(&orientedMarginTop, &orientedMarginRight, &orientedMarginBottom,
&orientedMarginLeft);
orientedMarginTop += SETTINGS.screenMargin;
orientedMarginLeft += SETTINGS.screenMargin;
startActivityForResult(std::make_unique<DictionaryWordSelectActivity>(renderer, mappedInput, std::move(page),
orientedMarginLeft, orientedMarginTop),
[this](const ActivityResult&) { requestUpdate(); });
}
void EpubReaderActivity::loop() {
if (!epub) {
// Should never happen
@@ -348,9 +372,11 @@ void EpubReaderActivity::loop() {
pendingReadFolderMove = false;
}
const auto touch = ReaderUtils::detectTouchPageTurn(renderer, mappedInput);
if (automaticPageTurnActive) {
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm) ||
mappedInput.wasReleased(MappedInputManager::Button::Back)) {
mappedInput.wasReleased(MappedInputManager::Button::Back) || ReaderUtils::isTouchMenuGesture(mappedInput)) {
automaticPageTurnActive = false;
// updates chapter title space to indicate page turn disabled
requestUpdate();
@@ -379,6 +405,11 @@ void EpubReaderActivity::loop() {
requestUpdate();
}
if (showDictionaryMessage && (millis() - dictionaryMessageTime) >= ReaderUtils::BOOKMARK_MESSAGE_DURATION_MS) {
showDictionaryMessage = false;
requestUpdate();
}
// While the end screen suggestion menu is showing it owns Confirm/Back/navigation
// input. Anything it doesn't handle (e.g. long-press Back to the file browser) falls
// through to the regular handlers below; page turns are absorbed by the end-of-book
@@ -408,10 +439,10 @@ void EpubReaderActivity::loop() {
}
}
// 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
// Enter reader menu activity on short-press Confirm or a downward swipe from the top edge. 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 (mappedInput.wasReleased(MappedInputManager::Button::Confirm) || ReaderUtils::isTouchMenuGesture(mappedInput)) {
if (ignoreNextConfirmRelease) {
ignoreNextConfirmRelease = false;
} else {
@@ -442,26 +473,29 @@ void EpubReaderActivity::loop() {
}
}
break;
case CrossPointSettings::LP_MENU_DICTIONARY:
// Hold ~0.4s starts dictionary word selection on the current page.
if (mappedInput.getHeldTime() >= ReaderUtils::BOOKMARK_HOLD_MS && !showDictionaryMessage) {
ignoreNextConfirmRelease = true; // Prevent menu open on the release that follows
openDictionaryWordSelect();
return;
}
break;
case CrossPointSettings::LP_MENU_DISABLED:
default:
break;
}
}
// Long press BACK (1s+) goes to file selection
if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= ReaderUtils::GO_HOME_MS) {
activityManager.goToFileBrowser(epub ? epub->getPath() : "");
return;
}
// Short press BACK goes directly to home (or restores position if viewing footnote)
if (mappedInput.wasReleased(MappedInputManager::Button::Back) &&
mappedInput.getHeldTime() < ReaderUtils::GO_HOME_MS) {
if (footnoteDepth > 0) {
// Short press Back restores position when viewing a footnote (takes priority over navigation)
if (footnoteDepth > 0 && mappedInput.wasReleased(MappedInputManager::Button::Back) &&
mappedInput.getHeldTime() < ReaderUtils::GO_BACK_OR_HOME_MS) {
restoreSavedPosition();
return;
}
onGoHome();
if (ReaderUtils::handleBackNavigation(mappedInput, activityManager, epub ? epub->getPath().c_str() : "",
{this, [](void* ctx) { static_cast<EpubReaderActivity*>(ctx)->onGoHome(); }})) {
return;
}
@@ -491,7 +525,9 @@ void EpubReaderActivity::loop() {
return;
}
const auto [prevTriggered, nextTriggered, fromTilt] = ReaderUtils::detectPageTurn(mappedInput);
auto [prevTriggered, nextTriggered, fromTilt] = ReaderUtils::detectPageTurn(mappedInput);
prevTriggered = prevTriggered || touch.prev;
nextTriggered = nextTriggered || touch.next;
if (!prevTriggered && !nextTriggered) {
return;
}
@@ -515,7 +551,8 @@ void EpubReaderActivity::loop() {
return;
}
const bool longPress = !fromTilt && mappedInput.getHeldTime() > ReaderUtils::SKIP_HOLD_MS;
const unsigned long heldMs = (touch.prev || touch.next) ? touch.heldMs : mappedInput.getHeldTime();
const bool longPress = !fromTilt && heldMs > ReaderUtils::SKIP_HOLD_MS;
// Don't skip chapter after screenshot
if (gpio.wasReleased(HalGPIO::BTN_POWER) && gpio.wasReleased(HalGPIO::BTN_DOWN)) {
@@ -715,6 +752,10 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
});
break;
}
case EpubReaderMenuActivity::MenuAction::DICTIONARY: {
openDictionaryWordSelect();
break;
}
case EpubReaderMenuActivity::MenuAction::DISPLAY_QR: {
if (section && section->currentPage >= 0 && section->currentPage < section->pageCount) {
std::string fullText = section->getTextFromSectionFile();
@@ -1305,6 +1346,10 @@ void EpubReaderActivity::render(RenderLock&& lock) {
if (showBookmarkMessage) {
GUI.drawPopup(renderer, bookmarkRemoved ? tr(STR_BOOKMARK_REMOVED) : tr(STR_BOOKMARK_ADDED));
}
if (showDictionaryMessage) {
GUI.drawPopup(renderer, tr(STR_DICT_NO_DICT_SET));
}
}
bool EpubReaderActivity::applyDeferredReposition() {
@@ -1351,6 +1396,11 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
const bool pageHasImagesNeedingDecode = pageHasImages && page->hasImagesNeedingDecode();
const bool needsTextGrayscale = SETTINGS.textAntiAliasing;
const bool needsAnyGrayscale = needsTextGrayscale || pageHasImages;
const bool tiledGrayscale = needsAnyGrayscale && renderer.supportsStripGrayscale();
// Whole-plane buffering only pays when the BW refresh genuinely runs async
// underneath it; on blocking panels it would just spend ~50 KB for the
// identical serial timing.
const bool overlapRefresh = tiledGrayscale && renderer.supportsAsyncRefresh();
auto renderGrayscalePass = [&]() {
if (needsTextGrayscale) {
page->render(renderer, fontId, orientedMarginLeft, orientedMarginTop);
@@ -1396,28 +1446,92 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
// regardless of residue.
pagesUntilFullRefresh = 1;
} else {
ReaderUtils::displayWithRefreshCycle(renderer, pagesUntilFullRefresh);
// Deferred when a tiled grayscale pass follows: the plane rendering below
// then overlaps the panel's refresh time instead of following it.
ReaderUtils::displayWithRefreshCycle(renderer, pagesUntilFullRefresh, /*async=*/overlapRefresh);
}
const auto tDisplay = millis();
// Tiled grayscale: render each plane band-by-band into a small scratch and
// stream straight to the controller, leaving the BW framebuffer intact so no
// full-frame storeBwBuffer is needed; controller RAM is re-synced from the
// live framebuffer afterward. The page is re-rendered ceil(H/STRIP_ROWS) times
// per plane, but renderCharImpl culls out-of-band glyphs before decode so the
// cost stays close to one render. Both text (drawPixel) and images
// (DirectPixelWriter) honor the active strip target.
if (needsAnyGrayscale && renderer.supportsStripGrayscale()) {
// Tiled grayscale: render each plane band-by-band, leaving the BW
// framebuffer intact so no full-frame storeBwBuffer is needed; controller
// RAM is re-synced from the live framebuffer afterward. The page is
// re-rendered ceil(H/STRIP_ROWS) times per plane, but renderCharImpl culls
// out-of-band glyphs before decode so the cost stays close to one render.
// Both text (drawPixel) and images (DirectPixelWriter) honor the active
// strip target. When the BW refresh above went out async, the plane
// rendering below overlaps the panel's refresh time; only the controller
// RAM writes wait for BUSY.
if (tiledGrayscale) {
constexpr int STRIP_ROWS = 80;
const int gh = renderer.getDisplayHeight();
const int gwBytes = renderer.getDisplayWidthBytes();
const size_t planeBytes = static_cast<size_t>(gwBytes) * gh;
// Render one plane band-by-band into a whole-plane buffer without touching
// the controller, so it can run while the refresh is still in flight.
auto renderPlaneToBuffer = [&](const bool lsbPlane, uint8_t* buf) {
renderer.setRenderMode(lsbPlane ? GfxRenderer::GRAYSCALE_LSB : GfxRenderer::GRAYSCALE_MSB);
for (int y = 0; y < gh; y += STRIP_ROWS) {
const int rows = (gh - y < STRIP_ROWS) ? (gh - y) : STRIP_ROWS;
renderer.beginStripTarget(buf + static_cast<size_t>(y) * gwBytes, y, rows);
renderer.clearScreen(0x00);
renderGrayscalePass();
renderer.endStripTarget();
}
};
// Tiered on heap pressure: two plane buffers hide both plane renders
// inside the refresh wait; one hides the LSB render (its buffer is reused
// for MSB after streaming); none falls back to the strip-scratch flow with
// no overlap. The MSB buffer is only attempted when it leaves ~60 KB free
// so the pass never starves concurrent allocations. Blocking panels skip
// the buffers entirely (nothing to overlap).
auto lsbPlaneBuf = overlapRefresh ? makeUniqueNoThrow<uint8_t[]>(planeBytes) : nullptr;
auto msbPlaneBuf =
(lsbPlaneBuf && ESP.getFreeHeap() >= planeBytes + 60000) ? makeUniqueNoThrow<uint8_t[]>(planeBytes) : nullptr;
if (lsbPlaneBuf) {
renderPlaneToBuffer(true, lsbPlaneBuf.get());
if (msbPlaneBuf) renderPlaneToBuffer(false, msbPlaneBuf.get());
const auto tGrayRender = millis();
renderer.waitRefreshComplete();
const auto tWait = millis();
renderer.writeGrayscalePlaneStrip(true, lsbPlaneBuf.get(), 0, gh);
if (msbPlaneBuf) {
renderer.writeGrayscalePlaneStrip(false, msbPlaneBuf.get(), 0, gh);
} else {
renderPlaneToBuffer(false, lsbPlaneBuf.get());
renderer.writeGrayscalePlaneStrip(false, lsbPlaneBuf.get(), 0, gh);
}
const auto tGrayWrite = millis();
renderer.setRenderMode(GfxRenderer::BW);
renderer.displayGrayBuffer();
const auto tGrayDisplay = millis();
// BW framebuffer is intact; re-sync controller RAM for the next
// differential page turn directly from it.
renderer.cleanupGrayscaleWithFrameBuffer();
const auto tEnd = millis();
LOG_DBG("ERS",
"Page render (tiled async): prewarm=%lums bw_render=%lums display=%lums gray_render=%lums "
"wait=%lums gray_write=%lums gray_display=%lums cleanup=%lums total=%lums (planes buffered: %d)",
tPrewarm - t0, tBwRender - tPrewarm, tDisplay - tBwRender, tGrayRender - tDisplay, tWait - tGrayRender,
tGrayWrite - tWait, tGrayDisplay - tGrayWrite, tEnd - tGrayDisplay, tEnd - t0, msbPlaneBuf ? 2 : 1);
} else {
// Per-strip scratch tier: blocking panels and the OOM fallback. The
// strip writes below need the panel idle, so wait out any pending async
// refresh first (no-op on blocking panels).
auto scratch = makeUniqueNoThrow<uint8_t[]>(static_cast<size_t>(gwBytes) * STRIP_ROWS);
renderer.waitRefreshComplete();
if (!scratch) {
LOG_ERR("ERS", "OOM: grayscale strip scratch (%d bytes); skipping AA this page", gwBytes * STRIP_ROWS);
} else {
// Bands may be streamed in any order: X4 windows each via setRamArea, X3
// via PTL.
// Bands may be streamed in any order: X4 windows each via setRamArea,
// X3 via PTL.
renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB);
for (int y = 0; y < gh; y += STRIP_ROWS) {
const int rows = (gh - y < STRIP_ROWS) ? (gh - y) : STRIP_ROWS;
@@ -1457,6 +1571,7 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
tPrewarm - t0, tBwRender - tPrewarm, tDisplay - tBwRender, tGrayLsb - tDisplay, tGrayMsb - tGrayLsb,
tGrayDisplay - tGrayMsb, tCleanup - tGrayDisplay, tEnd - t0);
}
}
} else {
// Fallback path for a controller without strip support. grayscale rendering
// TODO: Only do this if font supports it
@@ -39,6 +39,9 @@ class EpubReaderActivity final : public Activity {
bool skipNextButtonCheck = false; // Skip button processing for one frame after subactivity exit
bool automaticPageTurnActive = false;
bool showBookmarkMessage = false;
// "No dictionary set" popup, shown when a lookup is triggered without a configured dictionary.
bool showDictionaryMessage = false;
unsigned long dictionaryMessageTime = 0UL;
bool ignoreNextConfirmRelease = false;
bool currentPageBookmarked = false;
bool bookmarkRemoved = false; // true when last toggle removed (controls popup text)
@@ -119,6 +122,7 @@ class EpubReaderActivity final : public Activity {
void onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action);
// Opens the reader menu for the current position (short-press Confirm)
void openReaderMenu();
void openDictionaryWordSelect();
// Returns true if sync acted (launched, or surfaced a save error); false if it was a no-op
// because no KOReader credentials are stored.
bool launchKOReaderSync();
@@ -14,9 +14,6 @@
namespace {
constexpr int ENTER_DELETE_MODE_MS = 700;
constexpr int DELETE_MODE_OFF = 0;
constexpr int DELETE_MODE_DISPLAY = 1;
constexpr int DELETE_MODE_CONFIRM = 2;
// Layout constants used in renderScreen
constexpr int LINE_HEIGHT = 60;
@@ -64,45 +61,7 @@ int EpubReaderBookmarksActivity::getListHeight(const GfxRenderer& renderer) {
}
void EpubReaderBookmarksActivity::loop() {
// Delete confirmation mode
if (confirmingDelete >= DELETE_MODE_DISPLAY) {
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (confirmingDelete == DELETE_MODE_DISPLAY) {
confirmingDelete = DELETE_MODE_CONFIRM; // first confirmation, update text
requestUpdate();
return;
}
bookmarks.erase(bookmarks.begin() + selectorIndex);
const std::string path = BookmarkUtil::getBookmarkPath(epubPath);
Storage.mkdir(BookmarkUtil::getBookmarksDir().c_str());
if (!JsonSettingsIO::saveBookmarks(bookmarks, path.c_str())) {
LOG_ERR("EPB", "Failed to save bookmarks after delete");
}
// Move selector up if we deleted the last item
if (selectorIndex >= bookmarks.size() && selectorIndex > 0) {
selectorIndex--;
}
if (bookmarks.empty()) {
ActivityResult result;
result.isCancelled = true;
setResult(std::move(result));
finish();
return;
}
requestUpdate();
confirmingDelete = DELETE_MODE_OFF;
return;
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
requestUpdate();
confirmingDelete = DELETE_MODE_OFF;
return;
}
}
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { // Open
auto openBookmark = [this] {
if (bookmarks.empty()) {
return;
}
@@ -119,8 +78,18 @@ void EpubReaderBookmarksActivity::loop() {
}
setResult(std::move(result));
finish();
};
// Delete confirmation popup
if (confirmPopup.handleInput(mappedInput, [this] { requestUpdate(); })) return;
if (confirmingDelete) {
// Popup dismissed without a selection (Back button or tap outside): cancel delete
confirmingDelete = false;
requestUpdate();
return;
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
}
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
ActivityResult result;
result.isCancelled = true;
setResult(std::move(result));
@@ -128,11 +97,68 @@ void EpubReaderBookmarksActivity::loop() {
return;
}
const auto orientation = renderer.getOrientation();
const bool isPortraitInverted = orientation == GfxRenderer::Orientation::PortraitInverted;
const bool isLandscapeCw = orientation == GfxRenderer::Orientation::LandscapeClockwise;
const bool isLandscapeCcw = orientation == GfxRenderer::Orientation::LandscapeCounterClockwise;
const int hintGutterWidth = (isLandscapeCw || isLandscapeCcw) ? 40 : 0;
const int contentX = isLandscapeCw ? hintGutterWidth : 0;
const int contentWidth = renderer.getScreenWidth() - hintGutterWidth;
const int contentY = isPortraitInverted ? 50 : 0;
const int listY = contentY + LINE_HEIGHT;
const int listHeight = getListHeight(renderer);
int tapped = 0;
int tx = 0;
int ty = 0;
if (mappedInput.wasScreenTouchDown(tx, ty) && tx >= contentX && tx < contentX + contentWidth &&
mappedInput.wasListItemTouchedDown(tapped, static_cast<int>(bookmarks.size()), selectorIndex, listY, listHeight,
true)) {
if (selectorIndex != tapped) {
selectorIndex = tapped;
requestUpdate();
}
return;
}
if (mappedInput.wasScreenTapped(tx, ty) && tx >= contentX && tx < contentX + contentWidth &&
mappedInput.wasListItemTapped(tapped, static_cast<int>(bookmarks.size()), selectorIndex, listY, listHeight,
true)) {
selectorIndex = tapped;
openBookmark();
return;
}
const auto swipe = mappedInput.wasSwipe();
if (swipe == MappedInputManager::SwipeDir::Up && !bookmarks.empty()) {
selectorIndex =
ButtonNavigator::nextPageIndex(selectorIndex, bookmarks.size(), GUI.getListPageItems(listHeight, true));
requestUpdate();
return;
}
if (swipe == MappedInputManager::SwipeDir::Down && !bookmarks.empty()) {
selectorIndex =
ButtonNavigator::previousPageIndex(selectorIndex, bookmarks.size(), GUI.getListPageItems(listHeight, true));
requestUpdate();
return;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { // Open
openBookmark();
return;
}
if (mappedInput.isPressed(MappedInputManager::Button::Confirm) && mappedInput.getHeldTime() > ENTER_DELETE_MODE_MS) {
if (bookmarks.empty()) {
return;
}
confirmingDelete = DELETE_MODE_DISPLAY;
confirmingDelete = true;
const char* options[] = {tr(STR_CANCEL), tr(STR_DELETE)};
confirmPopup.show(tr(STR_CONFIRM_DELETE_BOOKMARK), options, 2, 0, [this](int idx) {
confirmingDelete = false;
if (idx == 1) {
deleteSelectedBookmark();
}
requestUpdate();
});
requestUpdate();
}
@@ -159,6 +185,27 @@ void EpubReaderBookmarksActivity::loop() {
});
}
void EpubReaderBookmarksActivity::deleteSelectedBookmark() {
bookmarks.erase(bookmarks.begin() + selectorIndex);
const std::string path = BookmarkUtil::getBookmarkPath(epubPath);
Storage.mkdir(BookmarkUtil::getBookmarksDir().c_str());
if (!JsonSettingsIO::saveBookmarks(bookmarks, path.c_str())) {
LOG_ERR("EPB", "Failed to save bookmarks after delete");
}
// Move selector up if we deleted the last item
if (selectorIndex >= bookmarks.size() && selectorIndex > 0) {
selectorIndex--;
}
if (bookmarks.empty()) {
ActivityResult result;
result.isCancelled = true;
setResult(std::move(result));
finish();
}
}
void EpubReaderBookmarksActivity::render(RenderLock&&) {
renderer.clearScreen();
@@ -188,10 +235,10 @@ void EpubReaderBookmarksActivity::render(RenderLock&&) {
renderer.drawText(UI_12_FONT_ID, titleX, 15 + contentY, tr(STR_BOOKMARKS), true, EpdFontFamily::BOLD);
const auto getBookmarkTitle = [this](int index) {
return bookmarks.at(confirmingDelete >= DELETE_MODE_DISPLAY ? selectorIndex : index).summary;
return bookmarks.at(confirmingDelete ? selectorIndex : index).summary;
};
const auto getBookmarkSubtitle = [this](int index) {
auto bookmark = bookmarks.at(confirmingDelete >= DELETE_MODE_DISPLAY ? selectorIndex : index);
auto bookmark = bookmarks.at(confirmingDelete ? selectorIndex : index);
auto tocIndex = epub->getTocIndexForSpineIndex(bookmark.computedSpineIndex);
auto tocTitle = (tocIndex >= 0) ? (epub->getTocItem(tocIndex)).title : tr(STR_UNNAMED);
std::string subtitle = std::to_string((int)(std::clamp(bookmark.percentage, 0.0f, 1.0f) * 100.0f + 0.5f)) + "% - ";
@@ -207,12 +254,9 @@ void EpubReaderBookmarksActivity::render(RenderLock&&) {
};
if (numBookmarks > 0) {
if (confirmingDelete >= DELETE_MODE_DISPLAY) {
GUI.drawHelpText(renderer, Rect{0, pageHeight / 2 - LINE_HEIGHT * 2, contentWidth, LINE_HEIGHT},
tr(STR_CONFIRM_DELETE_BOOKMARK));
// render list with just the selected item for the user to confirm to delete
GUI.drawList(renderer, Rect{contentX, pageHeight / 2, contentWidth, LINE_HEIGHT}, 1, 0, getBookmarkTitle,
if (confirmingDelete) {
// Render just the selected item near the top; the confirmation popup occupies the center
GUI.drawList(renderer, Rect{contentX, listY, contentWidth, LINE_HEIGHT}, 1, 0, getBookmarkTitle,
getBookmarkSubtitle, getBookmarkIcon);
} else {
GUI.drawList(renderer, Rect{contentX, listY, contentWidth, listHeight}, numBookmarks, selectorIndex,
@@ -223,10 +267,10 @@ void EpubReaderBookmarksActivity::render(RenderLock&&) {
}
}
const auto backLabel = confirmingDelete >= DELETE_MODE_DISPLAY ? tr(STR_CANCEL) : tr(STR_BACK);
const auto confirmLabel =
bookmarks.size() > 0 ? (confirmingDelete >= DELETE_MODE_DISPLAY ? tr(STR_DELETE) : tr(STR_SELECT)) : "";
const auto labels = mappedInput.mapLabels(backLabel, confirmLabel, tr(STR_DIR_UP), tr(STR_DIR_DOWN));
if (confirmPopup.processRender(renderer, mappedInput)) return;
const auto confirmLabel = bookmarks.size() > 0 ? tr(STR_SELECT) : "";
const auto labels = mappedInput.mapLabels(tr(STR_BACK), confirmLabel, tr(STR_DIR_UP), tr(STR_DIR_DOWN));
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer();
@@ -5,6 +5,7 @@
#include "../../BookmarkEntry.h"
#include "../Activity.h"
#include "components/OptionPopup.h"
#include "util/ButtonNavigator.h"
class EpubReaderBookmarksActivity final : public Activity {
@@ -13,7 +14,8 @@ class EpubReaderBookmarksActivity final : public Activity {
ButtonNavigator buttonNavigator;
int selectorIndex = 0;
std::vector<BookmarkEntry> bookmarks;
int confirmingDelete = 0; // 0 = hide dialog, 1 = show dialog, 2 = allow confirmation to delete
bool confirmingDelete = false;
OptionPopup confirmPopup;
public:
explicit EpubReaderBookmarksActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
@@ -30,4 +32,7 @@ class EpubReaderBookmarksActivity final : public Activity {
// Calculate the height available for the bookmark list based on orientation
int getListHeight(const GfxRenderer& renderer);
// Delete the currently selected bookmark and persist the list
void deleteSelectedBookmark();
};
@@ -31,7 +31,15 @@ void EpubReaderChapterSelectionActivity::loop() {
const int pageItems = UITheme::getInstance().getNumberOfItemsPerPage(renderer, true, false, true, false);
const int totalItems = getTotalItems();
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
ActivityResult result;
result.isCancelled = true;
setResult(std::move(result));
finish();
return;
}
auto selectChapter = [this] {
const auto tocItem = epub->getTocItem(selectorIndex);
if (tocItem.spineIndex == -1) {
ActivityResult result;
@@ -42,11 +50,36 @@ void EpubReaderChapterSelectionActivity::loop() {
setResult(ChapterResult{tocItem.spineIndex, tocItem.anchor});
finish();
}
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
ActivityResult result;
result.isCancelled = true;
setResult(std::move(result));
finish();
};
auto metrics = UITheme::getInstance().getMetrics();
Rect screen = UITheme::getInstance().getScreenSafeArea(renderer, true, false);
const int contentTop = screen.y + metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
const int contentHeight = screen.height - contentTop - metrics.verticalSpacing;
switch (handleListTouch(selectorIndex, totalItems, contentTop, contentHeight, false)) {
case ListTouchResult::Activated:
selectChapter();
return;
case ListTouchResult::Consumed:
return;
case ListTouchResult::None:
break;
}
const auto swipe = mappedInput.wasSwipe();
if (swipe == MappedInputManager::SwipeDir::Up) {
selectorIndex = ButtonNavigator::nextPageIndex(selectorIndex, totalItems, pageItems);
requestUpdate();
return;
}
if (swipe == MappedInputManager::SwipeDir::Down) {
selectorIndex = ButtonNavigator::previousPageIndex(selectorIndex, totalItems, pageItems);
requestUpdate();
return;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
selectChapter();
}
buttonNavigator.onNextRelease([this, totalItems] {
@@ -18,6 +18,13 @@ void EpubReaderFootnotesActivity::onEnter() {
void EpubReaderFootnotesActivity::onExit() { Activity::onExit(); }
void EpubReaderFootnotesActivity::loop() {
auto selectFootnote = [this] {
if (selectedIndex >= 0 && selectedIndex < static_cast<int>(footnotes.size())) {
setResult(FootnoteResult{footnotes[selectedIndex].href});
finish();
}
};
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
ActivityResult result;
result.isCancelled = true;
@@ -28,12 +35,52 @@ void EpubReaderFootnotesActivity::loop() {
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm) ||
mappedInput.wasReleased(MappedInputManager::Button::Power)) {
if (selectedIndex >= 0 && selectedIndex < static_cast<int>(footnotes.size())) {
setResult(FootnoteResult{footnotes[selectedIndex].href});
finish();
selectFootnote();
return;
}
if (!footnotes.empty()) {
const auto orientation = renderer.getOrientation();
const bool isLandscapeCw = orientation == GfxRenderer::Orientation::LandscapeClockwise;
const bool isLandscapeCcw = orientation == GfxRenderer::Orientation::LandscapeCounterClockwise;
const bool isPortraitInverted = orientation == GfxRenderer::Orientation::PortraitInverted;
const int hintGutterWidth = (isLandscapeCw || isLandscapeCcw) ? 30 : 0;
const int contentX = isLandscapeCw ? hintGutterWidth : 0;
const int contentWidth = renderer.getScreenWidth() - hintGutterWidth;
const int contentY = isPortraitInverted ? 50 : 0;
constexpr int lineHeight = 36;
const int listTop = 60 + contentY;
const int visibleCount = std::max(1, (renderer.getScreenHeight() - listTop) / lineHeight);
int row = -1;
const auto touch = mappedInput.rowTouch(row, listTop, lineHeight, visibleCount, contentX, contentX + contentWidth);
if (touch != MappedInputManager::RowTouch::None) {
const int touched = scrollOffset + row;
if (touched >= 0 && touched < static_cast<int>(footnotes.size())) {
if (touch == MappedInputManager::RowTouch::Down) {
if (selectedIndex != touched) {
selectedIndex = touched;
requestUpdate();
}
} else {
selectedIndex = touched;
selectFootnote();
}
return;
}
}
const auto swipe = mappedInput.wasSwipe();
if (swipe == MappedInputManager::SwipeDir::Up) {
selectedIndex = std::min(static_cast<int>(footnotes.size()) - 1, selectedIndex + visibleCount);
requestUpdate();
return;
}
if (swipe == MappedInputManager::SwipeDir::Down) {
selectedIndex = std::max(0, selectedIndex - visibleCount);
requestUpdate();
return;
}
}
buttonNavigator.onNext([this] {
if (!footnotes.empty()) {
@@ -83,13 +130,14 @@ void EpubReaderFootnotesActivity::render(RenderLock&&) {
constexpr int lineHeight = 36;
const int screenWidth = renderer.getScreenWidth();
const int marginLeft = contentX + 20;
const int listTop = 60 + contentY;
const int visibleCount = std::max(1, (renderer.getScreenHeight() - contentY) / lineHeight);
const int visibleCount = std::max(1, (renderer.getScreenHeight() - listTop) / lineHeight);
if (selectedIndex < scrollOffset) scrollOffset = selectedIndex;
if (selectedIndex >= scrollOffset + visibleCount) scrollOffset = selectedIndex - visibleCount + 1;
for (int i = scrollOffset; i < static_cast<int>(footnotes.size()) && i < scrollOffset + visibleCount; i++) {
const int y = 60 + contentY + (i - scrollOffset) * lineHeight;
const int y = listTop + (i - scrollOffset) * lineHeight;
const bool isSelected = (i == selectedIndex);
if (isSelected) {

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