Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
973a902332 | ||
|
|
cb9b971c26 | ||
|
|
f42fab1c66 | ||
|
|
c9188a7347 | ||
|
|
e03aa16330 | ||
|
|
3319aa1720 | ||
|
|
3dfbc0383f | ||
|
|
64364bec5d | ||
|
|
556b8aee5b | ||
|
|
b1d1d757ba | ||
|
|
5ba1d5747f | ||
|
|
9fe4dc5e38 | ||
|
|
b1d037569b | ||
|
|
9737cb335c | ||
|
|
fdffc2e5d9 | ||
|
|
a2db43d235 | ||
|
|
63093e606c | ||
|
|
a4ac3b2788 | ||
|
|
35a45f9c1e |
@@ -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
|
||||
@@ -3,16 +3,34 @@
|
||||
* **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,
|
||||
* 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.
|
||||
|
||||
---
|
||||
|
||||
### AI Usage
|
||||
|
||||
While CrossPoint doesn't have restrictions on AI tools in contributing, please be transparent about their usage as it
|
||||
While CrossPoint doesn't have restrictions on AI tools in contributing, please be transparent about their usage as it
|
||||
helps set the right context for reviewers.
|
||||
|
||||
Did you use AI tools to help write this code? _**< YES | PARTIALLY | NO >**_
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
+47
-30
@@ -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**.
|
||||
|
||||
- Choose **Apply Remote** to jump to remote progress.
|
||||
|
||||
- Choose **Upload Local** to push current progress.
|
||||
##### Syncing While Reading
|
||||
|
||||
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.
|
||||
|
||||
- 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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 = ¤tLayout();
|
||||
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.
|
||||
@@ -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
|
||||
+1
-1
Submodule freeink-sdk updated: e7d336191b...566fce3d4f
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: "У Іўі худы жвавы чорт у зялёнай камізэльцы пабег пад'есці фаршу з юшкай"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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é"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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: "דלג פרק"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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: "Канагаттандырылмагандыктарыныздан"
|
||||
|
||||
@@ -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ą"
|
||||
|
||||
@@ -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ł."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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: "Ничего"
|
||||
|
||||
@@ -72,7 +72,8 @@ STR_IMAGES_PLACEHOLDER: "Rezervované miesto"
|
||||
STR_IMAGES_SUPPRESS: "Potlačiť"
|
||||
STR_SHORT_PWR_BTN: "Krátke stlačenie tlačidla napájania"
|
||||
STR_ORIENTATION: "Orientácia čítania"
|
||||
STR_SIDE_BTN_LAYOUT: "Rozloženie bočných tlačidiel (čítačka)"
|
||||
STR_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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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: "Немає"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -14,17 +14,33 @@ 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.
|
||||
*/
|
||||
struct KOReaderProgress {
|
||||
std::string document; // Document hash
|
||||
std::string progress; // XPath-like progress string
|
||||
float percentage; // Progress percentage (0.0 to 1.0)
|
||||
std::string device; // Device name
|
||||
std::string deviceId; // Device ID
|
||||
int64_t timestamp; // Unix timestamp of last update
|
||||
std::optional<KOReaderMetadata> metadata; // Optional document metadata
|
||||
std::string document; // Document hash
|
||||
std::string progress; // XPath-like progress string
|
||||
float percentage; // Progress percentage (0.0 to 1.0)
|
||||
std::string device; // Device name
|
||||
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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
+35
-50
@@ -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
|
||||
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();
|
||||
#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);
|
||||
gpio_hold_en(GPIO_SPIWP);
|
||||
}
|
||||
#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;
|
||||
return _batteryCachedPercent;
|
||||
}
|
||||
Wire.requestFrom(I2C_ADDR_BQ27220, (uint8_t)2);
|
||||
if (Wire.available() < 2) {
|
||||
_batteryLastPollMs = now;
|
||||
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;
|
||||
uint16_t percent = 0;
|
||||
if (!battery.readPercentageChecked(percent)) {
|
||||
return _batteryCachedPercent;
|
||||
}
|
||||
_batteryCachedPercent = percent;
|
||||
return _batteryCachedPercent;
|
||||
}
|
||||
static const BatteryMonitor battery = BatteryMonitor(BAT_GPIO0);
|
||||
|
||||
// smooth the battery %.
|
||||
if (_batteryCachedPercent == 0) {
|
||||
|
||||
@@ -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:
|
||||
static constexpr int LOW_POWER_FREQ = 10; // MHz
|
||||
#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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+28
-93
@@ -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;
|
||||
_available = _sdkImu.begin();
|
||||
if (_available) {
|
||||
_initMs = millis();
|
||||
_lastPollMs = millis();
|
||||
// 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", "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;
|
||||
LOG_INF("GYR", "SDK IMU initialized");
|
||||
return;
|
||||
}
|
||||
|
||||
_available = true;
|
||||
_initMs = millis();
|
||||
_lastPollMs = millis();
|
||||
LOG_INF("GYR", "QMI8658 gyro initialized and put to sleep");
|
||||
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");
|
||||
return true;
|
||||
} else {
|
||||
LOG_ERR("GYR", "Failed to wake QMI8658");
|
||||
return false;
|
||||
}
|
||||
_lastPollMs = millis();
|
||||
_lastTiltMs = millis();
|
||||
_wakeMs = millis();
|
||||
_isAwake = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
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");
|
||||
return true;
|
||||
} else {
|
||||
LOG_ERR("GYR", "Failed to put QMI8658 to sleep");
|
||||
return false;
|
||||
}
|
||||
clearPendingEvents();
|
||||
_inTilt = false;
|
||||
_isAwake = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
void HalTiltSensor::update(const uint8_t mode, const uint8_t orientation, const bool inReader) {
|
||||
|
||||
+6
-33
@@ -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.
|
||||
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
shell.nix
|
||||
Generated
+43
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -168,6 +168,34 @@ void HomeActivity::freeCoverBuffer() {
|
||||
|
||||
void HomeActivity::loop() {
|
||||
const int menuCount = getMenuItemCount();
|
||||
const auto& metrics = UITheme::getInstance().getMetrics();
|
||||
|
||||
auto activateSelection = [this] {
|
||||
if (selectorIndex < recentBooks.size()) {
|
||||
onSelectBook(recentBooks[selectorIndex].path);
|
||||
return;
|
||||
}
|
||||
const int menuIndex = selectorIndex - static_cast<int>(recentBooks.size());
|
||||
switch (indexToMenuItem(menuIndex, hasOpdsServers)) {
|
||||
case HomeMenuItem::FILE_BROWSER:
|
||||
onFileBrowserOpen();
|
||||
break;
|
||||
case HomeMenuItem::RECENTS:
|
||||
onRecentsOpen();
|
||||
break;
|
||||
case HomeMenuItem::OPDS_BROWSER:
|
||||
onOpdsBrowserOpen();
|
||||
break;
|
||||
case HomeMenuItem::FILE_TRANSFER:
|
||||
onFileTransferOpen();
|
||||
break;
|
||||
case HomeMenuItem::SETTINGS_MENU:
|
||||
onSettingsOpen();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
buttonNavigator.onNext([this, menuCount] {
|
||||
selectorIndex = ButtonNavigator::nextIndex(selectorIndex, menuCount);
|
||||
@@ -179,31 +207,72 @@ void HomeActivity::loop() {
|
||||
requestUpdate();
|
||||
});
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
if (selectorIndex < recentBooks.size()) {
|
||||
onSelectBook(recentBooks[selectorIndex].path);
|
||||
} else {
|
||||
const int menuIndex = selectorIndex - static_cast<int>(recentBooks.size());
|
||||
switch (indexToMenuItem(menuIndex, hasOpdsServers)) {
|
||||
case HomeMenuItem::FILE_BROWSER:
|
||||
onFileBrowserOpen();
|
||||
break;
|
||||
case HomeMenuItem::RECENTS:
|
||||
onRecentsOpen();
|
||||
break;
|
||||
case HomeMenuItem::OPDS_BROWSER:
|
||||
onOpdsBrowserOpen();
|
||||
break;
|
||||
case HomeMenuItem::FILE_TRANSFER:
|
||||
onFileTransferOpen();
|
||||
break;
|
||||
case HomeMenuItem::SETTINGS_MENU:
|
||||
onSettingsOpen();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
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();
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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() : "");
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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,50 +1446,66 @@ 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;
|
||||
|
||||
auto scratch = makeUniqueNoThrow<uint8_t[]>(static_cast<size_t>(gwBytes) * STRIP_ROWS);
|
||||
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.
|
||||
renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB);
|
||||
// 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(scratch.get(), y, rows);
|
||||
renderer.beginStripTarget(buf + static_cast<size_t>(y) * gwBytes, y, rows);
|
||||
renderer.clearScreen(0x00);
|
||||
renderGrayscalePass();
|
||||
renderer.endStripTarget();
|
||||
renderer.writeGrayscalePlaneStrip(true, scratch.get(), y, rows);
|
||||
}
|
||||
const auto tGrayLsb = millis();
|
||||
};
|
||||
|
||||
// MSB plane.
|
||||
renderer.setRenderMode(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(scratch.get(), y, rows);
|
||||
renderer.clearScreen(0x00);
|
||||
renderGrayscalePass();
|
||||
renderer.endStripTarget();
|
||||
renderer.writeGrayscalePlaneStrip(false, scratch.get(), y, rows);
|
||||
// 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 tGrayMsb = millis();
|
||||
const auto tGrayWrite = millis();
|
||||
|
||||
renderer.setRenderMode(GfxRenderer::BW);
|
||||
renderer.displayGrayBuffer();
|
||||
@@ -1448,14 +1514,63 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
|
||||
// BW framebuffer is intact; re-sync controller RAM for the next
|
||||
// differential page turn directly from it.
|
||||
renderer.cleanupGrayscaleWithFrameBuffer();
|
||||
const auto tCleanup = millis();
|
||||
|
||||
const auto tEnd = millis();
|
||||
|
||||
LOG_DBG("ERS",
|
||||
"Page render (tiled): prewarm=%lums bw_render=%lums display=%lums gray_lsb=%lums "
|
||||
"gray_msb=%lums gray_display=%lums cleanup=%lums total=%lums",
|
||||
tPrewarm - t0, tBwRender - tPrewarm, tDisplay - tBwRender, tGrayLsb - tDisplay, tGrayMsb - tGrayLsb,
|
||||
tGrayDisplay - tGrayMsb, tCleanup - tGrayDisplay, tEnd - t0);
|
||||
"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.
|
||||
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;
|
||||
renderer.beginStripTarget(scratch.get(), y, rows);
|
||||
renderer.clearScreen(0x00);
|
||||
renderGrayscalePass();
|
||||
renderer.endStripTarget();
|
||||
renderer.writeGrayscalePlaneStrip(true, scratch.get(), y, rows);
|
||||
}
|
||||
const auto tGrayLsb = millis();
|
||||
|
||||
// MSB plane.
|
||||
renderer.setRenderMode(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(scratch.get(), y, rows);
|
||||
renderer.clearScreen(0x00);
|
||||
renderGrayscalePass();
|
||||
renderer.endStripTarget();
|
||||
renderer.writeGrayscalePlaneStrip(false, scratch.get(), y, rows);
|
||||
}
|
||||
const auto tGrayMsb = 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 tCleanup = millis();
|
||||
|
||||
const auto tEnd = millis();
|
||||
LOG_DBG("ERS",
|
||||
"Page render (tiled): prewarm=%lums bw_render=%lums display=%lums gray_lsb=%lums "
|
||||
"gray_msb=%lums gray_display=%lums cleanup=%lums total=%lums",
|
||||
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
|
||||
|
||||
@@ -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,13 +35,53 @@ 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()) {
|
||||
selectedIndex = (selectedIndex + 1) % footnotes.size();
|
||||
@@ -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
Reference in New Issue
Block a user