diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..187ca843 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -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. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 00000000..4838988c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -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 diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 21b09aaf..0159b9e4 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -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 >**_ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cfe848fb..42cfd249 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/README.md b/README.md index ddf70792..926e57e5 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 00000000..5b0b21f3 --- /dev/null +++ b/ROADMAP.md @@ -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. diff --git a/SCOPE.md b/SCOPE.md index f115e0b4..e380bfee 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -1,62 +1,174 @@ # 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?** If yes, we will not duplicate it. +* **Is another popular CrossPoint fork already solving this well?** If yes, we generally defer to that fork rather than + fragmenting the ecosystem. +* **Does this directly improve the reading experience or the firmware's long-term maintainability?** If no, it is out + of scope. + +### Language Priority + +English is the priority language for new features and UI work. Other languages are considered, with preference given +to cases where the stock firmware fails (rendering, input, fonts, layout) or where existing CrossPoint forks have not +addressed the gap. + +## 3. Current Focus (Until Further Notice) + +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. + +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. +* **Local Transfer:** Simple, pull-based loading via the existing web server or widely used standards. New connectors + are currently paused (see Section 3). +* **Reference Tools:** Local, offline dictionary lookup. +* **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. +* **Media Playback:** No audio players or audiobooks. +* **Complex Annotation:** No typed-out notes. +* **Duplication of stock firmware behavior** that already works well. +* **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. Idea Evaluation -*These features align with CrossPoint's goals but are impractical on the current hardware or produce poor UX.* +Before proposing a feature, ask: -* **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. +1. Does the stock firmware already handle this well? We should hit that bar or surpass it. +2. Does another popular CrossPoint fork already handle this well? If yes, we usually defer. +3. Does it improve the core reading experience, or reduce memory / flash / code complexity? +4. Is it in one of the temporarily closed areas (new themes, new network connectors)? If yes, wait. +5. Would adding it make the codebase harder to clean up or harder to port to other devices? If yes, rework or defer. -## 3. Idea Evaluation +If the idea passes those filters, it is a candidate. If you are not sure, open a **Discussion** before writing any +code. -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. +> **Note to Contributors:** CrossPoint is intentionally narrow. "It would be cool if..." features are not enough; the +> bar is "this fixes something the stock firmware does poorly, or it makes the firmware leaner and easier to maintain." -> **Note to Contributors:** If you are unsure if your idea fits the scope, please open a **Discussion** before you start -> coding! +## 6. Calls to Action + +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. + +### Theme System: Move Themes Off-Firmware + +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. + +* **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. +* **How to claim:** Comment on the relevant Discussion (or open one) before starting. + +### SDK Abstraction: Generalize the Lower Layers for All ESP32 E-Readers + +To support the broader range of ESP32-based e-reader hardware, the SDK layers (display, input, storage, battery, +etc.) need to be abstracted away from Xteink-specific assumptions and made pluggable per device. + +* **Status:** [@itsthisjustin](https://github.com/itsthisjustin) is actively working on this. +* **How to help:** Coordinate with itsthisjustin before opening PRs that touch `freeink-sdk/` or `lib/hal/` so the + work does not collide. Smaller adjacent cleanups (removing hardcoded assumptions, tightening HAL boundaries) are + welcome if scoped against the in-flight direction. + +### 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. + +* **Status:** [@uxjulia](https://github.com/uxjulia) is coordinating this effort. + +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. + +## 7. 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 days after opening up funding, which is a signal the demand is real. +* The exact scoring methodology is published at . + +**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. diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 8c314a6e..1b8dc531 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -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://: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. diff --git a/docs/contributing/README.md b/docs/contributing/README.md index eae9334f..540d3712 100644 --- a/docs/contributing/README.md +++ b/docs/contributing/README.md @@ -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). diff --git a/docs/contributing/touch-and-ui.md b/docs/contributing/touch-and-ui.md new file mode 100644 index 00000000..ec4211dc --- /dev/null +++ b/docs/contributing/touch-and-ui.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 +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` 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` (`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. diff --git a/docs/dictionary.md b/docs/dictionary.md new file mode 100644 index 00000000..c32a763f --- /dev/null +++ b/docs/dictionary.md @@ -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 diff --git a/freeink-sdk b/freeink-sdk index 4350bc7a..566fce3d 160000 --- a/freeink-sdk +++ b/freeink-sdk @@ -1 +1 @@ -Subproject commit 4350bc7a0e1fa7ff52c0cf7671b535d55a939535 +Subproject commit 566fce3d4fe45fdcf76853be788e4d497547b592 diff --git a/lib/Epub/Epub/ParsedText.cpp b/lib/Epub/Epub/ParsedText.cpp index 2ae204a4..bf6ff8d7 100644 --- a/lib/Epub/Epub/ParsedText.cpp +++ b/lib/Epub/Epub/ParsedText.cpp @@ -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 diff --git a/lib/Epub/Epub/Section.cpp b/lib/Epub/Epub/Section.cpp index 56daeed0..10d2b4f3 100644 --- a/lib/Epub/Epub/Section.cpp +++ b/lib/Epub/Epub/Section.cpp @@ -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 diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp index 76a6901d..a686b366 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp @@ -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(blockWordCount)); const int horizontalInset = self->currentTextBlock->getBlockStyle().totalHorizontalInset(); const uint16_t effectiveWidth = (horizontalInset < self->viewportWidth) ? static_cast(self->viewportWidth - horizontalInset) diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index 14d17977..17f0ca3d 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -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(alignedXLo); + out.y = static_cast(clampedYLo); + out.w = static_cast(alignedXHi - alignedXLo); + out.h = static_cast(clampedYHi - clampedYLo); + out.valid = true; + return out; +} + enum class TextRotation { None, Rotated90CW }; // Shared glyph rendering logic for normal and rotated text. @@ -1465,6 +1520,39 @@ 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(mem.y + row) * panelWidthBytes) + (mem.x / 8); + uint8_t* dstRow = dst + (static_cast(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(row) * rowBytes); + uint8_t* dstRow = frameBuffer + (static_cast(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 ""; @@ -1578,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(nx * panelWidth); + int phyY = static_cast(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. diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index 9c3d0878..a23c01bd 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -134,6 +134,7 @@ 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 @@ -200,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; diff --git a/lib/I18n/translations/belarusian.yaml b/lib/I18n/translations/belarusian.yaml index 11b36192..88d27867 100644 --- a/lib/I18n/translations/belarusian.yaml +++ b/lib/I18n/translations/belarusian.yaml @@ -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: "У Іўі худы жвавы чорт у зялёнай камізэльцы пабег пад'есці фаршу з юшкай" @@ -298,6 +299,11 @@ STR_SLEEP_TIMER_VALUE_FORMAT: "%u хв" STR_SLEEP_NEVER: "Ніколі" STR_STEP_HINT_FRONT: "Пярэднія кнопкі:" STR_STEP_HINT_SIDE: "Бакавыя кнопкі:" +STR_OPDS_DOWNLOAD_FOLDER: "Папка для спампоўкі" +STR_OPDS_FILENAME_FORMAT: "Фармат назвы файла" +STR_FMT_AUTHOR_TITLE: "Аўтар - Назва" +STR_FMT_TITLE_AUTHOR: "Назва - Аўтар" +STR_FMT_TITLE: "Назва" STR_AUTO_TURN_ENABLED: "Аўтаперагортванне: " STR_AUTO_TURN_PAGES_PER_MIN: "Аўтаперагортванне (старонак за хвіліну)" STR_TILT_PAGE_TURN: "Перагортванне нахілам" diff --git a/lib/I18n/translations/bosnian.yaml b/lib/I18n/translations/bosnian.yaml new file mode 100644 index 00000000..5ac44e68 --- /dev/null +++ b/lib/I18n/translations/bosnian.yaml @@ -0,0 +1,393 @@ +_language_name: "Bosanski" +_language_code: "BS" +_order: "29" +STR_CROSSPOINT: "CrossPoint" +STR_BOOTING: "POKRETANJE" +STR_SLEEPING: "REŽIM MIROVANJA" +STR_ENTERING_SLEEP: "Prelazak u režim mirovanja" +STR_BROWSE_FILES: "Pregled datoteka" +STR_FILE_TRANSFER: "Prijenos datoteka" +STR_SETTINGS_TITLE: "Postavke" +STR_CONTINUE_READING: "Nastavi čitanje" +STR_NO_OPEN_BOOK: "Nema otvorene knjige" +STR_START_READING: "Počni čitati ispod" +STR_NO_FILES_FOUND: "Nema pronađenih datoteka" +STR_SELECT_CHAPTER: "Odaberi poglavlje" +STR_NO_CHAPTERS: "Nema poglavlja" +STR_END_OF_BOOK: "Kraj knjige" +STR_EMPTY_CHAPTER: "Prazno poglavlje" +STR_INDEXING: "Indeksiranje" +STR_INDEX_FAILED: "Indeksiranje nije uspjelo - nevažeća knjiga" +STR_MEMORY_ERROR: "Greška memorije" +STR_PAGE_LOAD_ERROR: "Greška pri učitavanju stranice" +STR_EMPTY_FILE: "Prazna datoteka" +STR_OUT_OF_BOUNDS: "Izvan granica" +STR_LOADING: "Učitavanje..." +STR_LOADING_POPUP: "Učitavanje" +STR_WIFI_NETWORKS: "Wi-Fi mreže" +STR_NO_NETWORKS: "Nema pronađenih mreža" +STR_NETWORKS_FOUND: "%zu pronađenih mreža" +STR_SCANNING: "Skeniranje..." +STR_FINDING_SAVED_WIFI: "Traženje sačuvanog Wi-Fi-a..." +STR_CONNECTING: "Povezivanje..." +STR_CONNECTING_SAVED_WIFI: "Povezivanje na sačuvani Wi-Fi..." +STR_SHOW_NETWORKS: "Prikaži" +STR_CONNECTED: "Povezano!" +STR_CONNECTION_FAILED: "Povezivanje nije uspjelo" +STR_FORGET_NETWORK: "Zaboraviti mrežu?" +STR_SAVE_PASSWORD: "Sačuvati lozinku za sljedeći put?" +STR_PRESS_OK_SCAN: "Pritisni OK za ponovno skeniranje" +STR_JOIN_NETWORK: "Pridruži se mreži" +STR_CREATE_HOTSPOT: "Kreiraj hotspot" +STR_JOIN_DESC: "Poveži se na postojeću Wi-Fi mrežu" +STR_HOTSPOT_DESC: "Kreiraj Wi-Fi mrežu kojoj se drugi mogu pridružiti" +STR_STARTING_HOTSPOT: "Pokretanje hotspota..." +STR_HOTSPOT_MODE: "Hotspot način rada" +STR_CONNECT_WIFI_HINT: "Poveži svoj uređaj na ovu Wi-Fi mrežu" +STR_OPEN_URL_HINT: "Otvori ovaj URL u svom pregledniku" +STR_OR_HTTP_PREFIX: "ili http://" +STR_SCAN_QR_HINT: "ili skeniraj QR kod svojim telefonom:" +STR_CALIBRE_WIRELESS: "Calibre bežično" +STR_NETWORK_LEGEND: "* = Šifrovano | + = Sačuvano" +STR_MAC_ADDRESS: "MAC adresa:" +STR_CHECKING_WIFI: "Provjera Wi-Fi-a..." +STR_ENTER_WIFI_PASSWORD: "Unesi Wi-Fi lozinku" +STR_ADD_HIDDEN_NETWORK: "Dodaj skrivenu mrežu..." +STR_ENTER_WIFI_SSID: "Unesi naziv mreže (SSID)" +STR_TO_PREFIX: "na " +STR_CALIBRE_RECEIVING: "Primanje: " +STR_CALIBRE_RECEIVED: "Primljeno: " +STR_CALIBRE_INSTRUCTION_1: "1) Instaliraj CrossPoint Reader dodatak" +STR_CALIBRE_INSTRUCTION_2: "2) Budi na istoj Wi-Fi mreži" +STR_CALIBRE_INSTRUCTION_3: "3) U Calibreu: \"Pošalji na uređaj\"" +STR_CALIBRE_INSTRUCTION_4: "\"Drži ovaj ekran otvorenim tokom slanja\"" +STR_CAT_DISPLAY: "Ekran" +STR_CAT_READER: "Čitač" +STR_CAT_CONTROLS: "Kontrole" +STR_CAT_SYSTEM: "Sistem" +STR_SLEEP_SCREEN: "Ekran mirovanja" +STR_QUICK_RESUME_TIMEOUT: "Brzi nastavak nakon isteka vremena" +STR_SLEEP_COVER_MODE: "Način prikaza korica na ekranu mirovanja" +STR_HIDE_BATTERY: "Sakrij % baterije" +STR_EXTRA_SPACING: "Dodatni razmak između paragrafa" +STR_TEXT_AA: "Zaglađivanje teksta" +STR_IMAGES: "Slike" +STR_IMAGES_DISPLAY: "Prikaz" +STR_IMAGES_PLACEHOLDER: "Rezervisano mjesto" +STR_IMAGES_SUPPRESS: "Sakrij" +STR_EOB_HOME: "Početna" +STR_EOB_CONTINUE_WITH: "Nastavi sa" +STR_SHORT_PWR_BTN: "Kratak klik dugmeta napajanja" +STR_ORIENTATION: "Orijentacija čitanja" +STR_SIDE_BTN_LAYOUT: "Raspored bočnih dugmadi (čitač)" +STR_FRONT_BTN_FOLLOW_ORIENTATION: "Orijentiši prednja dugmad" +STR_LONG_PRESS_BEHAVIOR: "Ponašanje dugog pritiska dugmeta" +STR_LONG_PRESS_BEHAVIOR_OFF: "ISKLJUČENO" +STR_LONG_PRESS_BEHAVIOR_SKIP: "Preskakanje poglavlja" +STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Promjena orijentacije" +STR_LONG_PRESS_MENU: "Meni dugog pritiska" +STR_FONT_PREVIEW_TEXT: "Brza smeđa lisica preskače lijenog psa" +STR_FONT_FAMILY: "Porodica fonta čitača" +STR_FONT_SIZE: "Veličina fonta čitača" +STR_LINE_SPACING: "Razmak između redova čitača" +STR_SCREEN_MARGIN: "Margina ekrana čitača" +STR_PARA_ALIGNMENT: "Poravnanje paragrafa čitača" +STR_HYPHENATION: "Rastavljanje riječi" +STR_TIME_TO_SLEEP: "Vrijeme do mirovanja" +STR_SHOW_HIDDEN_FILES: "Prikaži skrivene datoteke" +STR_REMOVE_READ_FROM_RECENTS: "Ukloni pročitane knjige sa liste nedavnih" +STR_MOVE_FINISHED_TO_READ: "Premjesti završene knjige u fasciklu Read" +STR_REFRESH_FREQ: "Učestalost osvježavanja" +STR_KOREADER_SYNC: "KOReader sinhronizacija" +STR_CHECK_UPDATES: "Provjeri ažuriranja" +STR_LANGUAGE: "Jezik" +STR_CLEAR_READING_CACHE: "Očisti keš čitanja" +STR_USERNAME: "Korisničko ime" +STR_PASSWORD: "Lozinka" +STR_SYNC_SERVER_URL: "URL servera za sinhronizaciju" +STR_DOCUMENT_MATCHING: "Podudaranje dokumenata" +STR_SEND_METADATA: "Pošalji metapodatke dokumenta" +STR_AUTHENTICATE: "Autentifikuj" +STR_KOREADER_USERNAME: "KOReader korisničko ime" +STR_KOREADER_PASSWORD: "KOReader lozinka" +STR_FILENAME: "Naziv datoteke" +STR_BINARY: "Binarno" +STR_SET_CREDENTIALS_FIRST: "Prvo postavi vjerodajnice" +STR_WIFI_CONN_FAILED: "Wi-Fi povezivanje nije uspjelo" +STR_AUTHENTICATING: "Autentifikacija..." +STR_AUTH_SUCCESS: "Uspješno autentifikovano!" +STR_KOREADER_AUTH: "KOReader autentifikacija" +STR_SYNC_READY: "KOReader sinhronizacija je spremna za upotrebu" +STR_AUTH_FAILED: "Autentifikacija nije uspjela" +STR_DONE: "Gotovo" +STR_CLEAR_CACHE_WARNING_1: "Ovo će očistiti sve keširane podatke knjiga." +STR_CLEAR_CACHE_WARNING_2: "Sav napredak čitanja će biti izgubljen!" +STR_CLEAR_CACHE_WARNING_3: "Knjige će morati biti ponovo indeksirane" +STR_CLEAR_CACHE_WARNING_4: "kada se ponovo otvore." +STR_CLEARING_CACHE: "Čišćenje keša..." +STR_CACHE_CLEARED: "Keš očišćen" +STR_ITEMS_REMOVED: "stavki uklonjeno" +STR_FAILED_LOWER: "nije uspjelo" +STR_CLEAR_CACHE_FAILED: "Čišćenje keša nije uspjelo" +STR_CHECK_SERIAL_OUTPUT: "Provjeri serijski izlaz za detalje" +STR_DARK: "Tamno" +STR_LIGHT: "Svijetlo" +STR_CUSTOM: "Prilagođeno" +STR_COVER: "Korice" +STR_NONE_OPT: "Ništa" +STR_FIT: "Prilagodi" +STR_CROP: "Isjeci" +STR_NEVER: "Nikad" +STR_IN_READER: "U čitaču" +STR_ALWAYS: "Uvijek" +STR_IGNORE: "Ignoriši" +STR_SLEEP: "Mirovanje" +STR_PAGE_TURN: "Okretanje stranice" +STR_FORCE_REFRESH: "Osvježi ekran" +STR_PORTRAIT: "Uspravno" +STR_LANDSCAPE_CW: "Položeno (u smjeru kazaljke na satu)" +STR_INVERTED: "Obrnuto" +STR_ORIENTATION_INVERTED: "Uspravno 180°" +STR_LANDSCAPE_CCW: "Položeno (suprotno smjeru kazaljke na satu)" +STR_PREV_NEXT: "Prethodno/Sljedeće" +STR_NEXT_PREV: "Sljedeće/Prethodno" +STR_KOSYNC: "KOSync" +STR_BOOKMARK_OPTION: "Obilježivač" +STR_DISABLED: "Onemogućeno" +STR_NOTO_SERIF: "Noto Serif" +STR_NOTO_SANS: "Noto Sans" +STR_SMALL: "Malo" +STR_MEDIUM: "Srednje" +STR_LARGE: "Veliko" +STR_X_LARGE: "Vrlo veliko" +STR_TIGHT: "Usko" +STR_NORMAL: "Normalno" +STR_WIDE: "Široko" +STR_JUSTIFY: "Poravnaj obostrano" +STR_ALIGN_LEFT: "Lijevo" +STR_CENTER: "Centrirano" +STR_ALIGN_RIGHT: "Desno" +STR_PAGES_1: "1 stranica" +STR_PAGES_5: "5 stranica" +STR_PAGES_10: "10 stranica" +STR_PAGES_15: "15 stranica" +STR_PAGES_30: "30 stranica" +STR_UPDATE: "Ažuriraj" +STR_CHECKING_UPDATE: "Provjera ažuriranja..." +STR_NEW_UPDATE: "Dostupno je novo ažuriranje!" +STR_CURRENT_VERSION: "Trenutna verzija: " +STR_NEW_VERSION: "Nova verzija: " +STR_UPDATING: "Ažuriranje..." +STR_NO_UPDATE: "Nema dostupnih ažuriranja" +STR_UPDATE_FAILED: "Ažuriranje nije uspjelo" +STR_UPDATE_COMPLETE: "Ažuriranje završeno" +STR_POWER_ON_HINT: "Pritisni i drži dugme napajanja da ponovo uključiš" +STR_RESTARTING_HINT: "Ponovno pokretanje... Ako se uređaj ne pokrene ponovo, drži dugme napajanja nekoliko sekundi." +STR_NO_ENTRIES: "Nema pronađenih unosa" +STR_DOWNLOADING: "Preuzimanje..." +STR_DOWNLOAD_FAILED: "Preuzimanje nije uspjelo" +STR_ERROR_MSG: "Greška:" +STR_UNNAMED: "Neimenovano" +STR_HOLD_OPEN_TO_DELETE: "Drži Otvori za brisanje" +STR_NO_SERVER_URL: "Nije podešen URL servera" +STR_FETCH_FEED_FAILED: "Dohvatanje feeda nije uspjelo" +STR_PARSE_FEED_FAILED: "Obrada feeda nije uspjela" +STR_NEXT_PAGE: "Sljedeća stranica »" +STR_PREV_PAGE: "« Prethodna stranica" +STR_NETWORK_PREFIX: "Mreža: " +STR_IP_ADDRESS_PREFIX: "IP adresa: " +STR_ERROR_GENERAL_FAILURE: "Greška: Opšta greška" +STR_ERROR_NETWORK_NOT_FOUND: "Greška: Mreža nije pronađena" +STR_ERROR_CONNECTION_TIMEOUT: "Greška: Isteklo vrijeme veze" +STR_SD_CARD: "SD kartica" +STR_BACK: "« Nazad" +STR_EXIT: "« Izlaz" +STR_HOME: "« Početna" +STR_SELECT: "Odaberi" +STR_SELECTED: "Odabrano" +STR_TOGGLE: "Promijeni" +STR_TOGGLE_BOOKMARK: "Promijeni obilježivač" +STR_CONFIRM: "Potvrdi" +STR_CANCEL: "Otkaži" +STR_CONNECT: "Poveži" +STR_OPEN: "Otvori" +STR_DOWNLOAD: "Preuzmi" +STR_RETRY: "Pokušaj ponovo" +STR_YES: "Da" +STR_NO: "Ne" +STR_SHOW: "Prikaži" +STR_HIDE: "Sakrij" +STR_STATE_ON: "UKLJUČENO" +STR_STATE_OFF: "ISKLJUČENO" +STR_NOT_SET: "Nije postavljeno" +STR_DIR_LEFT: "Lijevo" +STR_DIR_RIGHT: "Desno" +STR_DIR_UP: "Gore" +STR_DIR_DOWN: "Dolje" +STR_OK_BUTTON: "OK" +STR_SLEEP_COVER_FILTER: "Filter korica ekrana mirovanja" +STR_FILTER_CONTRAST: "Kontrast" +STR_CUSTOMISE_STATUS_BAR: "Prilagodi statusnu traku" +STR_CHAPTER_PAGE_COUNT: "Broj stranica poglavlja" +STR_BOOK_PROGRESS_PERCENTAGE: "Procenat napretka knjige" +STR_PROGRESS_BAR: "Traka napretka" +STR_PROGRESS_BAR_THICKNESS: "Debljina trake napretka" +STR_PROGRESS_BAR_THIN: "Tanka" +STR_PROGRESS_BAR_MEDIUM: "Srednja" +STR_PROGRESS_BAR_THICK: "Debela" +STR_BOOK: "Knjiga" +STR_CHAPTER: "Poglavlje" +STR_EXAMPLE_CHAPTER: "Poglavlje 21" +STR_EXAMPLE_BOOK: "Naslov knjige" +STR_PREVIEW: "Pregled" +STR_TITLE: "Naslov" +STR_BATTERY: "Baterija" +STR_XTC_STATUS_BAR: "XTC statusna traka" +STR_BOTTOM: "Dolje" +STR_TOP: "Gore" +STR_CLOCK: "Sat" +STR_CLOCK_UTC_OFFSET: "UTC odstupanje sata" +STR_CLOCK_FORMAT: "Format sata" +STR_CLOCK_FORMAT_24H: "24-časovni" +STR_CLOCK_FORMAT_12H: "12-časovni" +STR_CURRENT_TIME: "Trenutno vrijeme:" +STR_NEXT_FIELD: "Sljedeće" +STR_CLOCK_SYNC: "Sinhronizuj sat" +STR_CLOCK_SYNC_NOW: "Sinhronizuj sat sada" +STR_CLOCK_SYNCING: "Sinhronizacija sa NTP-a..." +STR_CLOCK_SYNC_OK: "Sat sinhronizovan" +STR_CLOCK_SYNC_FAIL: "Sinhronizacija nije uspjela" +STR_CLOCK_SYNC_NO_WIFI: "Wi-Fi nije povezan" +STR_CLOCK_SYNC_NO_WIFI_HINT: "Prvo se poveži na Wi-Fi, pa pokušaj ponovo." +STR_CLOCK_SYNCED: "Sat sinhronizovan" +STR_UI_THEME: "Tema korisničkog interfejsa" +STR_THEME_CLASSIC: "Klasična" +STR_THEME_LYRA: "Lyra" +STR_THEME_ROUNDEDRAFF: "RoundedRaff" +STR_THEME_LYRA_EXTENDED: "Lyra proširena" +STR_SUNLIGHT_FADING_FIX: "Ispravka blijeđenja na suncu" +STR_REMAP_FRONT_BUTTONS: "Premapiraj prednja dugmad" +STR_BOOKMARKS: "Obilježivači" +STR_BOOKMARK_ADDED: "Obilježivač dodan." +STR_BOOKMARK_REMOVED: "Obilježivač uklonjen." +STR_OPDS_BROWSER: "OPDS pregledač" +STR_SEARCH: "Pretraga" +STR_COVER_CUSTOM: "Korice + Prilagođeno" +STR_QUICK_RESUME: "Brzi nastavak" +STR_MENU_RECENT_BOOKS: "Nedavne knjige" +STR_REMOVE_FROM_RECENTS: "Ukloniti iz nedavnih knjiga?" +STR_NO_RECENT_BOOKS: "Nema nedavnih knjiga" +STR_CALIBRE_DESC: "Koristi Calibre bežični prijenos uređaja" +STR_FORGET_AND_REMOVE: "Zaboraviti mrežu i ukloniti sačuvanu lozinku?" +STR_FORGET_BUTTON: "Zaboravi" +STR_CALIBRE_STARTING: "Pokretanje Calibrea..." +STR_CALIBRE_SETUP: "Podešavanje" +STR_CALIBRE_STATUS: "Status" +STR_CLEAR_BUTTON: "Očisti" +STR_DEFAULT_VALUE: "Zadano" +STR_REMAP_PROMPT: "Pritisni prednje dugme za svaku ulogu" +STR_UNASSIGNED: "Nedodijeljeno" +STR_ALREADY_ASSIGNED: "Već dodijeljeno" +STR_REMAP_RESET_HINT: "Bočno dugme Gore: Vrati na zadani raspored" +STR_REMAP_CANCEL_HINT: "Bočno dugme Dolje: Otkaži premapiranje" +STR_HW_BACK_LABEL: "Nazad (1. dugme)" +STR_HW_CONFIRM_LABEL: "Potvrdi (2. dugme)" +STR_HW_LEFT_LABEL: "Lijevo (3. dugme)" +STR_HW_RIGHT_LABEL: "Desno (4. dugme)" +STR_GO_TO_PERCENT: "Idi na %" +STR_GO_HOME_BUTTON: "Idi na početnu" +STR_SYNC_PROGRESS: "Sinhronizuj napredak" +STR_DELETE_CACHE: "Obriši keš knjige" +STR_DELETE: "Obriši" +STR_CONFIRM_DELETE_BOOKMARK: "Obrisati ovaj obilježivač?" +STR_DISPLAY_QR: "Prikaži stranicu kao QR" +STR_CHAPTER_PREFIX: "Poglavlje: " +STR_PAGES_SEPARATOR: " stranica | " +STR_BOOK_PREFIX: "Knjiga: " +STR_CALIBRE_URL_HINT: "Za Calibre, dodaj /opds na svoj URL" +STR_SYNCING_TIME: "Sinhronizacija vremena..." +STR_CALC_HASH: "Izračunavanje heša dokumenta..." +STR_HASH_FAILED: "Izračunavanje heša dokumenta nije uspjelo" +STR_FETCH_PROGRESS: "Dohvatanje udaljenog napretka..." +STR_UPLOAD_PROGRESS: "Slanje napretka..." +STR_NO_CREDENTIALS_MSG: "Nisu podešene vjerodajnice" +STR_KOREADER_SETUP_HINT: "Podesi KOReader nalog u Postavkama" +STR_PROGRESS_FOUND: "Napredak pronađen!" +STR_REMOTE_LABEL: "Udaljeno:" +STR_LOCAL_LABEL: "Lokalno:" +STR_PAGE_OVERALL_FORMAT: "Stranica %d, %.2f%% ukupno" +STR_PAGE_TOTAL_OVERALL_FORMAT: "Stranica %d/%d, %.2f%% ukupno" +STR_DEVICE_FROM_FORMAT: " Sa: %s" +STR_APPLY_REMOTE: "Primijeni udaljeni napredak" +STR_UPLOAD_LOCAL: "Pošalji lokalni napredak" +STR_NO_REMOTE_MSG: "Nije pronađen udaljeni napredak" +STR_UPLOAD_PROMPT: "Poslati trenutnu poziciju?" +STR_UPLOAD_SUCCESS: "Napredak poslan!" +STR_SYNC_FAILED_MSG: "Sinhronizacija nije uspjela" +STR_SAVE_PROGRESS_FAILED: "Napredak nije mogao biti sačuvan" +STR_SECTION_PREFIX: "Sekcija " +STR_UPLOAD: "Pošalji" +STR_BOOK_S_STYLE: "Stil knjige" +STR_EMBEDDED_STYLE: "Ugrađeni stil" +STR_FOCUS_READING: "Fokusirano čitanje" +STR_OPDS_SERVER_URL: "URL OPDS servera" +STR_PWR_BTN_FOOTNOTE_BACK: "Brzi povratak iz fusnota" +STR_SET_SLEEP_COVER: "Postavi korice" +STR_FOOTNOTES: "Fusnote" +STR_NO_FOOTNOTES: "Nema fusnota na ovoj stranici" +STR_LINK: "[link]" +STR_SCREENSHOT_BUTTON: "Snimi ekran" +STR_SLEEP_TIMER_VALUE_FORMAT: "%u min" +STR_SLEEP_NEVER: "Nikad" +STR_STEP_HINT_FRONT: "Prednja dugmad:" +STR_STEP_HINT_SIDE: "Bočna dugmad:" +STR_ADD_SERVER: "Dodaj server" +STR_SERVER_NAME: "Naziv servera" +STR_NO_SERVERS: "Nema podešenih OPDS servera" +STR_DELETE_SERVER: "Obriši server" +STR_OPDS_SERVERS: "OPDS serveri" +STR_AUTO_TURN_ENABLED: "Automatsko okretanje omogućeno: " +STR_AUTO_TURN_PAGES_PER_MIN: "Automatsko okretanje (stranica po minuti)" +STR_MANAGE_FONTS: "Upravljaj fontovima" +STR_FONT_BROWSER: "Pregledač fontova" +STR_LOADING_FONT_LIST: "Učitavanje liste fontova..." +STR_NO_FONTS_AVAILABLE: "Nema dostupnih fontova" +STR_FONT_INSTALLED: "Font instaliran!" +STR_FONT_INSTALL_FAILED: "Instalacija fonta nije uspjela" +STR_INSTALLED: "Instalirano" +STR_DOWNLOAD_ALL: "Preuzmi sve" +STR_UPDATE_ALL: "Ažuriraj sve" +STR_UPDATE_AVAILABLE: "Ažuriranje" +STR_CRASH_TITLE: "Sistemski pad" +STR_CRASH_DESCRIPTION: "Detaljan izvještaj je sačuvan u crash_report.txt. Molimo priloži ovu datoteku u svoj izvještaj o grešci." +STR_CRASH_REASON: "Razlog pada:" +STR_CRASH_NO_REASON: "(Razlog nije zabilježen)" +STR_TILT_PAGE_TURN: "Okretanje stranice naginjanjem" +STR_KB_HINT_MOVE_CURSOR: "Pritisni LIJEVO ili DESNO za pomjeranje kursora" +STR_KB_HINT_RETURN_CURSOR: "Pritisni LIJEVO za povratak na poziciju kursora" +STR_KB_HINT_HIDE_PASSWORD: "Drži DESNO pa pritisni [***] za skrivanje lozinke" +STR_KB_HINT_SHOW_PASSWORD: "Drži DESNO pa pritisni [abc] za prikaz lozinke" +STR_KB_HINT_TOGGLE_HIDE_PASSWORD: "Pritisni [***] za skrivanje lozinke" +STR_KB_HINT_TOGGLE_SHOW_PASSWORD: "Pritisni [abc] za prikaz lozinke" +STR_KB_HINT_EDIT_ENTRY: "Drži GORE za uređivanje unosa" +STR_KB_TIPS: "Savjeti:" +STR_KB_HINT_RETURN_KEYBOARD: "Pritisni DOLJE za povratak na tastaturu" +STR_KB_HINT_EXIT_URL_MODE: "Pritisni ABC za izlazak iz URL načina" +STR_KB_HINT_CLEAR_TEXT: "Drži DEL za brisanje cijelog teksta" +STR_KB_HINT_SECONDARY_CHAR: "Drži SELECT za sekundarni znak" +STR_KB_HINT_UPPER_SECONDARY: "Drži SELECT za VELIKA SLOVA ili sekundarni znak" +STR_KB_HINT_LOWER_SECONDARY: "Drži SELECT za mala slova ili sekundarni znak" +STR_KB_HINT_URL_SNIPPETS: "Pritisni URL za isječke" +STR_SD_FIRMWARE_UPDATE: "Ažuriranje firmvera sa SD kartice" +STR_SELECT_FIRMWARE_FILE: "Odaberi datoteku firmvera (.bin)" +STR_NO_BIN_FILES: "Nema pronađenih .bin datoteka" +STR_VALIDATING_FIRMWARE: "Provjera firmvera..." +STR_INVALID_FIRMWARE: "Nevažeća datoteka firmvera" +STR_FIRMWARE_TOO_LARGE: "Firmver je prevelik za particiju" +STR_FIRMWARE_TOO_SMALL: "Datoteka firmvera je premala" +STR_FIRMWARE_UPDATE_PROMPT: "Ažurirati firmver?" +STR_FIRMWARE_FILE_OPEN_FAILED: "Datoteka se ne može otvoriti" +STR_FIRMWARE_WRITE_FAILED: "Upisivanje firmvera nije uspjelo" +STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "Ne isključuj napajanje!" +STR_RECOVERY_MODE: "Način oporavka" +STR_RECOVERY_MODE_HINT: "Stavi firmware.bin u korijenski direktorij SD kartice i odaberi ga" diff --git a/lib/I18n/translations/catalan.yaml b/lib/I18n/translations/catalan.yaml index ff5db58a..eb1a5abc 100644 --- a/lib/I18n/translations/catalan.yaml +++ b/lib/I18n/translations/catalan.yaml @@ -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" @@ -347,6 +348,11 @@ STR_SERVER_NAME: "Nom del servidor" STR_NO_SERVERS: "No hi ha servidors OPDS configurats" STR_DELETE_SERVER: "Suprimeix el servidor" STR_OPDS_SERVERS: "Servidors OPDS" +STR_OPDS_DOWNLOAD_FOLDER: "Carpeta de baixades" +STR_OPDS_FILENAME_FORMAT: "Format del nom de fitxer" +STR_FMT_AUTHOR_TITLE: "Autor - Títol" +STR_FMT_TITLE_AUTHOR: "Títol - Autor" +STR_FMT_TITLE: "Títol" STR_MANAGE_FONTS: "Gestiona les fonts" STR_FONT_BROWSER: "Navegador de fonts" STR_LOADING_FONT_LIST: "S'està carregant la llista de fonts..." diff --git a/lib/I18n/translations/czech.yaml b/lib/I18n/translations/czech.yaml index f77cd325..3a096319 100644 --- a/lib/I18n/translations/czech.yaml +++ b/lib/I18n/translations/czech.yaml @@ -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" @@ -270,6 +271,11 @@ STR_BOOK_S_STYLE: "Styl knihy" STR_EMBEDDED_STYLE: "Vložený styl" STR_FOCUS_READING: "Soustředěné čtení" STR_OPDS_SERVER_URL: "URL serveru OPDS" +STR_OPDS_DOWNLOAD_FOLDER: "Složka pro stahování" +STR_OPDS_FILENAME_FORMAT: "Formát názvu souboru" +STR_FMT_AUTHOR_TITLE: "Autor - Název" +STR_FMT_TITLE_AUTHOR: "Název - Autor" +STR_FMT_TITLE: "Název" STR_SCREENSHOT_BUTTON: "Udělat snímek obrazovky" STR_SLEEP_TIMER_VALUE_FORMAT: "%u min" STR_SLEEP_NEVER: "Nikdy" diff --git a/lib/I18n/translations/danish.yaml b/lib/I18n/translations/danish.yaml index cf6d7d21..fdcb4312 100644 --- a/lib/I18n/translations/danish.yaml +++ b/lib/I18n/translations/danish.yaml @@ -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" @@ -300,6 +301,11 @@ STR_SLEEP_TIMER_VALUE_FORMAT: "%u min" STR_SLEEP_NEVER: "Aldrig" STR_STEP_HINT_FRONT: "Frontknapper:" STR_STEP_HINT_SIDE: "Sideknapper:" +STR_OPDS_DOWNLOAD_FOLDER: "Downloadmappe" +STR_OPDS_FILENAME_FORMAT: "Filnavnsformat" +STR_FMT_AUTHOR_TITLE: "Forfatter - Titel" +STR_FMT_TITLE_AUTHOR: "Titel - Forfatter" +STR_FMT_TITLE: "Titel" STR_SCREENSHOT_BUTTON: "Tag skærmbillede" STR_AUTO_TURN_ENABLED: "Automatisk sidevendning aktiveret: " STR_AUTO_TURN_PAGES_PER_MIN: "Automatisk vending (sider per minut)" diff --git a/lib/I18n/translations/dutch.yaml b/lib/I18n/translations/dutch.yaml index 08ae4e1f..fe6cc61c 100644 --- a/lib/I18n/translations/dutch.yaml +++ b/lib/I18n/translations/dutch.yaml @@ -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" @@ -300,6 +301,11 @@ STR_SLEEP_TIMER_VALUE_FORMAT: "%u min" STR_SLEEP_NEVER: "Nooit" STR_STEP_HINT_FRONT: "Voorknoppen:" STR_STEP_HINT_SIDE: "Zijknoppen:" +STR_OPDS_DOWNLOAD_FOLDER: "Downloadmap" +STR_OPDS_FILENAME_FORMAT: "Bestandsnaamformaat" +STR_FMT_AUTHOR_TITLE: "Auteur - Titel" +STR_FMT_TITLE_AUTHOR: "Titel - Auteur" +STR_FMT_TITLE: "Titel" STR_SCREENSHOT_BUTTON: "Screenshot maken" STR_AUTO_TURN_ENABLED: "Automatisch omslaan ingeschakeld: " STR_AUTO_TURN_PAGES_PER_MIN: "Autom. omslaan (pagina's per minuut)" diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index aac1ea0c..7a77dcc6 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -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" @@ -347,6 +370,12 @@ STR_SERVER_NAME: "Server Name" STR_NO_SERVERS: "No OPDS servers configured" STR_DELETE_SERVER: "Delete Server" STR_OPDS_SERVERS: "OPDS Servers" +STR_OPDS_DOWNLOAD_FOLDER: "Download folder" +STR_OPDS_FILENAME_FORMAT: "Filename format" +STR_FMT_AUTHOR_TITLE: "Author - Title" +STR_FMT_TITLE_AUTHOR: "Title - Author" +STR_FMT_TITLE: "Title" +STR_OPDS_SD_ROOT: "SD root" STR_AUTO_TURN_ENABLED: "Auto Turn Enabled: " STR_AUTO_TURN_PAGES_PER_MIN: "Auto Turn (Pages Per Minute)" STR_MANAGE_FONTS: "Manage Fonts" diff --git a/lib/I18n/translations/finnish.yaml b/lib/I18n/translations/finnish.yaml index aa9b7064..3fe0e480 100644 --- a/lib/I18n/translations/finnish.yaml +++ b/lib/I18n/translations/finnish.yaml @@ -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" @@ -268,6 +269,11 @@ STR_BOOK_S_STYLE: "Kirjan tyyli" STR_EMBEDDED_STYLE: "Upotettu tyyli" STR_FOCUS_READING: "Keskittynyt lukeminen" STR_OPDS_SERVER_URL: "OPDS-palvelimen osoite" +STR_OPDS_DOWNLOAD_FOLDER: "Latauskansio" +STR_OPDS_FILENAME_FORMAT: "Tiedostonimen muoto" +STR_FMT_AUTHOR_TITLE: "Tekijä - Nimi" +STR_FMT_TITLE_AUTHOR: "Nimi - Tekijä" +STR_FMT_TITLE: "Nimi" STR_SCREENSHOT_BUTTON: "Ota kuvakaappaus" STR_SLEEP_TIMER_VALUE_FORMAT: "%u min" STR_SLEEP_NEVER: "Ei koskaan" diff --git a/lib/I18n/translations/french.yaml b/lib/I18n/translations/french.yaml index 0ddce253..beee855e 100644 --- a/lib/I18n/translations/french.yaml +++ b/lib/I18n/translations/french.yaml @@ -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é" @@ -301,6 +302,11 @@ STR_SLEEP_TIMER_VALUE_FORMAT: "%u min" STR_SLEEP_NEVER: "Jamais" STR_STEP_HINT_FRONT: "Boutons avant :" STR_STEP_HINT_SIDE: "Boutons latéraux :" +STR_OPDS_DOWNLOAD_FOLDER: "Dossier de téléchargement" +STR_OPDS_FILENAME_FORMAT: "Format du nom de fichier" +STR_FMT_AUTHOR_TITLE: "Auteur - Titre" +STR_FMT_TITLE_AUTHOR: "Titre - Auteur" +STR_FMT_TITLE: "Titre" STR_SCREENSHOT_BUTTON: "Capture d'écran" STR_AUTO_TURN_ENABLED: "Tourne-page auto : " STR_AUTO_TURN_PAGES_PER_MIN: "Tourne-page auto (pages par minute)" diff --git a/lib/I18n/translations/german.yaml b/lib/I18n/translations/german.yaml index 06078b85..a4457a73 100644 --- a/lib/I18n/translations/german.yaml +++ b/lib/I18n/translations/german.yaml @@ -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" @@ -329,6 +331,11 @@ STR_SERVER_NAME: "Servername" STR_NO_SERVERS: "Keine OPDS-Server konfiguriert" STR_DELETE_SERVER: "Server entfernen" STR_OPDS_SERVERS: "OPDS-Server" +STR_OPDS_DOWNLOAD_FOLDER: "Download-Ordner" +STR_OPDS_FILENAME_FORMAT: "Dateinamenformat" +STR_FMT_AUTHOR_TITLE: "Autor - Titel" +STR_FMT_TITLE_AUTHOR: "Titel - Autor" +STR_FMT_TITLE: "Titel" STR_AUTO_TURN_ENABLED: "Auto-Umblättern aktiv: " STR_AUTO_TURN_PAGES_PER_MIN: "Auto-Umblättern (Seiten/Min.)" STR_IMAGES: "Bilder" diff --git a/lib/I18n/translations/hebrew.yaml b/lib/I18n/translations/hebrew.yaml index da9bbc30..9d4b813b 100644 --- a/lib/I18n/translations/hebrew.yaml +++ b/lib/I18n/translations/hebrew.yaml @@ -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: "דלג פרק" @@ -306,6 +307,11 @@ STR_NO_SERVERS: "לא הוגדרו שרתי OPDS" STR_DELETE_SERVER: "מחק שרת" STR_DELETE_CONFIRM: "למחוק שרת זה?" STR_OPDS_SERVERS: "שרתי OPDS" +STR_OPDS_DOWNLOAD_FOLDER: "תיקיית הורדות" +STR_OPDS_FILENAME_FORMAT: "תבנית שם קובץ" +STR_FMT_AUTHOR_TITLE: "מחבר - כותרת" +STR_FMT_TITLE_AUTHOR: "כותרת - מחבר" +STR_FMT_TITLE: "כותרת" STR_AUTO_TURN_ENABLED: "דפדוף אוטומטי פועל: " STR_AUTO_TURN_PAGES_PER_MIN: "דפדוף אוטומטי (דפים בדקה)" STR_MANAGE_FONTS: "ניהול גופנים" diff --git a/lib/I18n/translations/hungarian.yaml b/lib/I18n/translations/hungarian.yaml index b2909e84..50040c81 100644 --- a/lib/I18n/translations/hungarian.yaml +++ b/lib/I18n/translations/hungarian.yaml @@ -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" @@ -299,6 +300,11 @@ STR_SLEEP_TIMER_VALUE_FORMAT: "%u perc" STR_SLEEP_NEVER: "Soha" STR_STEP_HINT_FRONT: "Elülső gombok:" STR_STEP_HINT_SIDE: "Oldalsó gombok:" +STR_OPDS_DOWNLOAD_FOLDER: "Letöltési mappa" +STR_OPDS_FILENAME_FORMAT: "Fájlnév formátuma" +STR_FMT_AUTHOR_TITLE: "Szerző - Cím" +STR_FMT_TITLE_AUTHOR: "Cím - Szerző" +STR_FMT_TITLE: "Cím" STR_SCREENSHOT_BUTTON: "Képernyőkép készítése" STR_AUTO_TURN_ENABLED: "Automatikus lapozás bekapcsolva: " STR_AUTO_TURN_PAGES_PER_MIN: "Automatikus lapozás (oldal/perc)" diff --git a/lib/I18n/translations/italian.yaml b/lib/I18n/translations/italian.yaml index eee6767e..7ae622a4 100644 --- a/lib/I18n/translations/italian.yaml +++ b/lib/I18n/translations/italian.yaml @@ -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" @@ -315,6 +316,11 @@ STR_SERVER_NAME: "Nome server" STR_NO_SERVERS: "Nessun server OPDS configurato" STR_DELETE_SERVER: "Elimina server" STR_OPDS_SERVERS: "Server OPDS" +STR_OPDS_DOWNLOAD_FOLDER: "Cartella download" +STR_OPDS_FILENAME_FORMAT: "Formato nome file" +STR_FMT_AUTHOR_TITLE: "Autore - Titolo" +STR_FMT_TITLE_AUTHOR: "Titolo - Autore" +STR_FMT_TITLE: "Titolo" STR_AUTO_TURN_ENABLED: "Volta pagina automatico: " STR_AUTO_TURN_PAGES_PER_MIN: "Volta pagina automatico (pag/min)" STR_MANAGE_FONTS: "Gestisci font" diff --git a/lib/I18n/translations/kazakh.yaml b/lib/I18n/translations/kazakh.yaml index 6abbba5c..430f94ae 100644 --- a/lib/I18n/translations/kazakh.yaml +++ b/lib/I18n/translations/kazakh.yaml @@ -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: "Канагаттандырылмагандыктарыныздан" @@ -296,6 +297,11 @@ STR_SLEEP_TIMER_VALUE_FORMAT: "%u мин" STR_SLEEP_NEVER: "Ешқашан" STR_STEP_HINT_FRONT: "Алдыңғы түймелер:" STR_STEP_HINT_SIDE: "Бүйір түймелері:" +STR_OPDS_DOWNLOAD_FOLDER: "Жүктеп алу қалтасы" +STR_OPDS_FILENAME_FORMAT: "Файл атауының пішімі" +STR_FMT_AUTHOR_TITLE: "Автор - Атауы" +STR_FMT_TITLE_AUTHOR: "Атауы - Автор" +STR_FMT_TITLE: "Атауы" STR_SCREENSHOT_BUTTON: "Скриншот түсіру" STR_AUTO_TURN_ENABLED: "Автоматты бет аудару қосулы: " STR_AUTO_TURN_PAGES_PER_MIN: "Автоматты бет аудару (минутына бет саны)" diff --git a/lib/I18n/translations/lithuanian.yaml b/lib/I18n/translations/lithuanian.yaml index 7626d6b7..793a807b 100644 --- a/lib/I18n/translations/lithuanian.yaml +++ b/lib/I18n/translations/lithuanian.yaml @@ -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ą" @@ -297,6 +298,11 @@ STR_SLEEP_TIMER_VALUE_FORMAT: "%u min." STR_SLEEP_NEVER: "Niekada" STR_STEP_HINT_FRONT: "Priekiniai mygtukai:" STR_STEP_HINT_SIDE: "Šoniniai mygtukai:" +STR_OPDS_DOWNLOAD_FOLDER: "Atsisiuntimų aplankas" +STR_OPDS_FILENAME_FORMAT: "Failo pavadinimo formatas" +STR_FMT_AUTHOR_TITLE: "Autorius - Pavadinimas" +STR_FMT_TITLE_AUTHOR: "Pavadinimas - Autorius" +STR_FMT_TITLE: "Pavadinimas" STR_SCREENSHOT_BUTTON: "Ekrano nuotrauka" STR_AUTO_TURN_ENABLED: "Auto-vertimas: " STR_AUTO_TURN_PAGES_PER_MIN: "Auto-vertimas (psl/min)" diff --git a/lib/I18n/translations/polish.yaml b/lib/I18n/translations/polish.yaml index e1336e45..82e87d34 100644 --- a/lib/I18n/translations/polish.yaml +++ b/lib/I18n/translations/polish.yaml @@ -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ł." @@ -316,6 +317,11 @@ STR_SERVER_NAME: "Nazwa serwera" STR_NO_SERVERS: "Brak skonfigurowanych serwerów OPDS" STR_DELETE_SERVER: "Usuń serwer" STR_OPDS_SERVERS: "Serwery OPDS" +STR_OPDS_DOWNLOAD_FOLDER: "Folder pobierania" +STR_OPDS_FILENAME_FORMAT: "Format nazwy pliku" +STR_FMT_AUTHOR_TITLE: "Autor - Tytuł" +STR_FMT_TITLE_AUTHOR: "Tytuł - Autor" +STR_FMT_TITLE: "Tytuł" STR_AUTO_TURN_ENABLED: "Auto-kartkowanie: " STR_AUTO_TURN_PAGES_PER_MIN: "Auto-kartkowanie (str./min)" STR_DOWNLOAD_FONTS: "Pobierz czcionki" diff --git a/lib/I18n/translations/portuguese-BR.yaml b/lib/I18n/translations/portuguese-BR.yaml index 1668eb7b..876c877c 100644 --- a/lib/I18n/translations/portuguese-BR.yaml +++ b/lib/I18n/translations/portuguese-BR.yaml @@ -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." @@ -339,6 +340,11 @@ STR_SERVER_NAME: "Nome do Servidor" STR_NO_SERVERS: "Nenhum servidor OPDS configurado" STR_DELETE_SERVER: "Excluir Servidor" STR_OPDS_SERVERS: "Servidores OPDS" +STR_OPDS_DOWNLOAD_FOLDER: "Pasta de downloads" +STR_OPDS_FILENAME_FORMAT: "Formato do nome do arquivo" +STR_FMT_AUTHOR_TITLE: "Autor - Título" +STR_FMT_TITLE_AUTHOR: "Título - Autor" +STR_FMT_TITLE: "Título" STR_AUTO_TURN_ENABLED: "Virada automática ativada: " STR_AUTO_TURN_PAGES_PER_MIN: "Virada automática (páginas por minuto)" STR_MANAGE_FONTS: "Gerenciar fontes" diff --git a/lib/I18n/translations/portuguese-PT.yaml b/lib/I18n/translations/portuguese-PT.yaml index ca9637e1..f65791b5 100644 --- a/lib/I18n/translations/portuguese-PT.yaml +++ b/lib/I18n/translations/portuguese-PT.yaml @@ -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)" diff --git a/lib/I18n/translations/romanian.yaml b/lib/I18n/translations/romanian.yaml index 827175ad..fb9cae51 100644 --- a/lib/I18n/translations/romanian.yaml +++ b/lib/I18n/translations/romanian.yaml @@ -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" @@ -300,6 +301,11 @@ STR_SLEEP_TIMER_VALUE_FORMAT: "%u min" STR_SLEEP_NEVER: "Niciodată" STR_STEP_HINT_FRONT: "Butoane frontale:" STR_STEP_HINT_SIDE: "Butoane laterale:" +STR_OPDS_DOWNLOAD_FOLDER: "Dosar descărcări" +STR_OPDS_FILENAME_FORMAT: "Format nume fișier" +STR_FMT_AUTHOR_TITLE: "Autor - Titlu" +STR_FMT_TITLE_AUTHOR: "Titlu - Autor" +STR_FMT_TITLE: "Titlu" STR_SCREENSHOT_BUTTON: "Captură ecran" STR_AUTO_TURN_ENABLED: "Răsfoire automată: " STR_AUTO_TURN_PAGES_PER_MIN: "Pagini pe minut" diff --git a/lib/I18n/translations/russian.yaml b/lib/I18n/translations/russian.yaml index 6db7d4b2..ab6cb88e 100644 --- a/lib/I18n/translations/russian.yaml +++ b/lib/I18n/translations/russian.yaml @@ -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: "Ничего" @@ -339,6 +340,11 @@ STR_SERVER_NAME: "Имя сервера" STR_NO_SERVERS: "Нет настроенных серверов OPDS" STR_DELETE_SERVER: "Удалить сервер" STR_OPDS_SERVERS: "Серверы OPDS" +STR_OPDS_DOWNLOAD_FOLDER: "Папка загрузок" +STR_OPDS_FILENAME_FORMAT: "Формат имени файла" +STR_FMT_AUTHOR_TITLE: "Автор - Название" +STR_FMT_TITLE_AUTHOR: "Название - Автор" +STR_FMT_TITLE: "Название" STR_AUTO_TURN_ENABLED: "Автоперелистывание: " STR_AUTO_TURN_PAGES_PER_MIN: "Автоперелистывание (стр./мин)" STR_MANAGE_FONTS: "Управление шрифтами" diff --git a/lib/I18n/translations/slovak.yaml b/lib/I18n/translations/slovak.yaml index fce33cbf..f5644b39 100644 --- a/lib/I18n/translations/slovak.yaml +++ b/lib/I18n/translations/slovak.yaml @@ -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" @@ -335,6 +336,11 @@ STR_SERVER_NAME: "Názov servera" STR_NO_SERVERS: "Nie sú nakonfigurované žiadne OPDS servery" STR_DELETE_SERVER: "Odstrániť server" STR_OPDS_SERVERS: "OPDS servery" +STR_OPDS_DOWNLOAD_FOLDER: "Priečinok sťahovania" +STR_OPDS_FILENAME_FORMAT: "Formát názvu súboru" +STR_FMT_AUTHOR_TITLE: "Autor - Názov" +STR_FMT_TITLE_AUTHOR: "Názov - Autor" +STR_FMT_TITLE: "Názov" STR_AUTO_TURN_ENABLED: "Automatické otáčanie strán zapnuté: " STR_AUTO_TURN_PAGES_PER_MIN: "Automatické otáčanie (strán za minútu)" STR_MANAGE_FONTS: "Správa písiem" diff --git a/lib/I18n/translations/slovenian.yaml b/lib/I18n/translations/slovenian.yaml index 09780706..bf38ebf7 100644 --- a/lib/I18n/translations/slovenian.yaml +++ b/lib/I18n/translations/slovenian.yaml @@ -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" @@ -297,6 +298,11 @@ STR_SLEEP_TIMER_VALUE_FORMAT: "%u min" STR_SLEEP_NEVER: "Nikoli" STR_STEP_HINT_FRONT: "Sprednji gumbi:" STR_STEP_HINT_SIDE: "Stranski gumbi:" +STR_OPDS_DOWNLOAD_FOLDER: "Mapa za prenose" +STR_OPDS_FILENAME_FORMAT: "Oblika imena datoteke" +STR_FMT_AUTHOR_TITLE: "Avtor - Naslov" +STR_FMT_TITLE_AUTHOR: "Naslov - Avtor" +STR_FMT_TITLE: "Naslov" STR_SCREENSHOT_BUTTON: "Naredi posnetek zaslona" STR_AUTO_TURN_ENABLED: "Samodejno obračanje: " STR_AUTO_TURN_PAGES_PER_MIN: "Samodejno obračanje (strani na minuto)" diff --git a/lib/I18n/translations/spanish.yaml b/lib/I18n/translations/spanish.yaml index d2720471..a258075f 100644 --- a/lib/I18n/translations/spanish.yaml +++ b/lib/I18n/translations/spanish.yaml @@ -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" @@ -345,6 +346,11 @@ STR_SERVER_NAME: "Nombre de servidor" STR_NO_SERVERS: "No se configuraron servidores OPDS" STR_DELETE_SERVER: "Borrar servidor" STR_OPDS_SERVERS: "Servidores OPDS" +STR_OPDS_DOWNLOAD_FOLDER: "Carpeta de descargas" +STR_OPDS_FILENAME_FORMAT: "Formato del nombre de archivo" +STR_FMT_AUTHOR_TITLE: "Autor - Título" +STR_FMT_TITLE_AUTHOR: "Título - Autor" +STR_FMT_TITLE: "Título" STR_AUTO_TURN_ENABLED: "Avance activado: " STR_AUTO_TURN_PAGES_PER_MIN: "Avance auto. (pág./min)" STR_MANAGE_FONTS: "Gestionar tipografías" diff --git a/lib/I18n/translations/swedish.yaml b/lib/I18n/translations/swedish.yaml index ba34feb0..0198666b 100644 --- a/lib/I18n/translations/swedish.yaml +++ b/lib/I18n/translations/swedish.yaml @@ -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" @@ -342,6 +366,12 @@ STR_SERVER_NAME: "Servernamn" STR_NO_SERVERS: "Inga OPDS-servrar konfigurerade" STR_DELETE_SERVER: "Ta bort server" STR_OPDS_SERVERS: "OPDS-servrar" +STR_OPDS_DOWNLOAD_FOLDER: "Hämtningsmapp" +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" @@ -387,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)" diff --git a/lib/I18n/translations/turkish.yaml b/lib/I18n/translations/turkish.yaml index 9f8d47c1..efe2075f 100644 --- a/lib/I18n/translations/turkish.yaml +++ b/lib/I18n/translations/turkish.yaml @@ -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" @@ -291,6 +292,11 @@ STR_SLEEP_TIMER_VALUE_FORMAT: "%u dak" STR_SLEEP_NEVER: "Asla" STR_STEP_HINT_FRONT: "Ön tuşlar:" STR_STEP_HINT_SIDE: "Yan tuşlar:" +STR_OPDS_DOWNLOAD_FOLDER: "İndirme klasörü" +STR_OPDS_FILENAME_FORMAT: "Dosya adı biçimi" +STR_FMT_AUTHOR_TITLE: "Yazar - Başlık" +STR_FMT_TITLE_AUTHOR: "Başlık - Yazar" +STR_FMT_TITLE: "Başlık" STR_NO_FILES_FOUND: "Dosya bulunamadı" STR_NO_FOOTNOTES: "Bu sayfada dipnot yok" STR_PREVIEW: "Önizleme" diff --git a/lib/I18n/translations/ukrainian.yaml b/lib/I18n/translations/ukrainian.yaml index 1d47c835..8a5fc389 100644 --- a/lib/I18n/translations/ukrainian.yaml +++ b/lib/I18n/translations/ukrainian.yaml @@ -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: "Немає" @@ -336,6 +337,11 @@ STR_SERVER_NAME: "Назва сервера" STR_NO_SERVERS: "Не налаштовано жодного сервера OPDS" STR_DELETE_SERVER: "Видалити сервер" STR_OPDS_SERVERS: "Сервери OPDS" +STR_OPDS_DOWNLOAD_FOLDER: "Папка завантажень" +STR_OPDS_FILENAME_FORMAT: "Формат імені файлу" +STR_FMT_AUTHOR_TITLE: "Автор - Назва" +STR_FMT_TITLE_AUTHOR: "Назва - Автор" +STR_FMT_TITLE: "Назва" STR_AUTO_TURN_ENABLED: "Автоперегортання увімк: " STR_AUTO_TURN_PAGES_PER_MIN: "Автоперегортання (ст/хв)" STR_MANAGE_FONTS: "Керування шрифтами" diff --git a/lib/I18n/translations/valencian.yaml b/lib/I18n/translations/valencian.yaml index 2c5ad86e..9ef1160e 100644 --- a/lib/I18n/translations/valencian.yaml +++ b/lib/I18n/translations/valencian.yaml @@ -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" @@ -348,6 +349,11 @@ STR_SERVER_NAME: "Nom del servidor" STR_NO_SERVERS: "No hi ha servidors OPDS configurats" STR_DELETE_SERVER: "Elimina el servidor" STR_OPDS_SERVERS: "Servidors OPDS" +STR_OPDS_DOWNLOAD_FOLDER: "Carpeta de baixades" +STR_OPDS_FILENAME_FORMAT: "Format del nom de fitxer" +STR_FMT_AUTHOR_TITLE: "Autor - Títol" +STR_FMT_TITLE_AUTHOR: "Títol - Autor" +STR_FMT_TITLE: "Títol" STR_MANAGE_FONTS: "Gestiona les fonts" STR_FONT_BROWSER: "Navegador de fonts" STR_LOADING_FONT_LIST: "S'està carregant la llista de fonts..." diff --git a/lib/I18n/translations/vietnamese.yaml b/lib/I18n/translations/vietnamese.yaml index 3ce82416..83b7738e 100644 --- a/lib/I18n/translations/vietnamese.yaml +++ b/lib/I18n/translations/vietnamese.yaml @@ -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" @@ -335,6 +336,11 @@ STR_SERVER_NAME: "Tên máy chủ" STR_NO_SERVERS: "Chưa cấu hình máy chủ OPDS" STR_DELETE_SERVER: "Xóa máy chủ" STR_OPDS_SERVERS: "Máy chủ OPDS" +STR_OPDS_DOWNLOAD_FOLDER: "Thư mục tải xuống" +STR_OPDS_FILENAME_FORMAT: "Định dạng tên tệp" +STR_FMT_AUTHOR_TITLE: "Tác giả - Tựa đề" +STR_FMT_TITLE_AUTHOR: "Tựa đề - Tác giả" +STR_FMT_TITLE: "Tựa đề" STR_AUTO_TURN_ENABLED: "Tự lật trang: " STR_AUTO_TURN_PAGES_PER_MIN: "Tự lật (số trang mỗi phút)" STR_MANAGE_FONTS: "Quản lý phông chữ" diff --git a/lib/JpegToBmpConverter/JpegToBmpConverter.cpp b/lib/JpegToBmpConverter/JpegToBmpConverter.cpp index a0e8b574..0cf47c1d 100644 --- a/lib/JpegToBmpConverter/JpegToBmpConverter.cpp +++ b/lib/JpegToBmpConverter/JpegToBmpConverter.cpp @@ -5,6 +5,8 @@ #include #include #include +#include +#include #include #include @@ -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(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(pFile->fHandle); if (!f || !f->seek(pos)) return -1; pFile->iPos = pos; + yieldDuringJpegIo(); return pos; } @@ -236,9 +251,23 @@ struct BmpConvertCtx { std::unique_ptr fsDitherer; std::unique_ptr 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(pDraw->pUser); if (!ctx || ctx->error) return 0; + yieldDuringDecodeBlock(ctx); const uint8_t* pixels = reinterpret_cast(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 diff --git a/lib/KOReaderSync/KOReaderCredentialStore.cpp b/lib/KOReaderSync/KOReaderCredentialStore.cpp index 6b9c1695..51c23a3e 100644 --- a/lib/KOReaderSync/KOReaderCredentialStore.cpp +++ b/lib/KOReaderSync/KOReaderCredentialStore.cpp @@ -5,16 +5,27 @@ #include 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(getMatchMethod()); doc["sendMetadata"] = getSendMetadata(); + doc["syncBehavior"] = static_cast(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(DocumentMatchMethod::BINARY)) { setMatchMethod(static_cast(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(KOReaderSyncBehavior::ASK_EVERY_TIME); + if (behavior <= static_cast(KOReaderSyncBehavior::SMART)) { + setSyncBehavior(static_cast(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(behavior) > static_cast(KOReaderSyncBehavior::SMART)) { + behavior = KOReaderSyncBehavior::ASK_EVERY_TIME; + } + syncBehavior = behavior; + LOG_DBG("KRS", "Set sync behavior: %s", behavior == KOReaderSyncBehavior::SMART ? "Smart" : "Ask"); +} diff --git a/lib/KOReaderSync/KOReaderCredentialStore.h b/lib/KOReaderSync/KOReaderCredentialStore.h index 0aa4a576..00a5f17c 100644 --- a/lib/KOReaderSync/KOReaderCredentialStore.h +++ b/lib/KOReaderSync/KOReaderCredentialStore.h @@ -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 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 // 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 diff --git a/lib/KOReaderSync/KOReaderSyncClient.cpp b/lib/KOReaderSync/KOReaderSyncClient.cpp index 2c20054e..d4d2c013 100644 --- a/lib/KOReaderSync/KOReaderSyncClient.cpp +++ b/lib/KOReaderSync/KOReaderSyncClient.cpp @@ -92,6 +92,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; @@ -138,6 +175,24 @@ KOReaderSyncClient::Error KOReaderSyncClient::getProgress(const std::string& doc outProgress.deviceId = doc["device_id"].as(); outProgress.timestamp = doc["timestamp"].as(); + // Extended crosspoint-sync field; absent on plain kosync servers. + outProgress.position.reset(); + const JsonObjectConst pos = doc["position"].as(); + if (!pos.isNull()) { + KOReaderRichPosition rich; + rich.pctQ = pos["pctQ"].as(); + rich.spineIndex = pos["spine"].as(); + rich.pageNumber = pos["page"].as(); + const uint16_t pages = pos["pages"].as(); + rich.totalPages = pages > 0 ? pages : 1; + const uint16_t para = pos["para"].as(); + if (para > 0) rich.paragraphIndex = para; + rich.xpath = pos["xpath"].as() ? pos["xpath"].as() : ""; + 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; } @@ -172,6 +227,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(); + 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); diff --git a/lib/KOReaderSync/KOReaderSyncClient.h b/lib/KOReaderSync/KOReaderSyncClient.h index 8a67e991..643a0d27 100644 --- a/lib/KOReaderSync/KOReaderSyncClient.h +++ b/lib/KOReaderSync/KOReaderSyncClient.h @@ -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 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 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 metadata; // Optional document metadata + std::optional 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) diff --git a/lib/KOReaderSync/ProgressMapper.cpp b/lib/KOReaderSync/ProgressMapper.cpp index 114af6a5..cdd72eed 100644 --- a/lib/KOReaderSync/ProgressMapper.cpp +++ b/lib/KOReaderSync/ProgressMapper.cpp @@ -724,6 +724,59 @@ SavedProgressPosition ProgressMapper::toSavedProgress(const std::shared_ptr ProgressMapper::fromRichPosition(const std::shared_ptr& epub, + const KOReaderRichPosition& rich, + GfxRenderer& renderer) { + const int spineCount = epub->getSpineItemsCount(); + if (static_cast(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(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(*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(rich.pageNumber) / static_cast(remotePages - 1) : 0.0f; + result.pageNumber = std::max( + 0, std::min(static_cast(intra * static_cast(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, const SavedProgressPosition& koPos, GfxRenderer& renderer, int currentSpineIndex, int totalPagesInCurrentSpine, int fallbackTotalPages) { diff --git a/lib/KOReaderSync/ProgressMapper.h b/lib/KOReaderSync/ProgressMapper.h index d48da2e3..74dbf850 100644 --- a/lib/KOReaderSync/ProgressMapper.h +++ b/lib/KOReaderSync/ProgressMapper.h @@ -3,8 +3,11 @@ #include #include +#include #include +#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 fromRichPosition(const std::shared_ptr& epub, + const KOReaderRichPosition& rich, GfxRenderer& renderer); + private: /** * Generate a fallback XPath by streaming the spine item's XHTML and resolving diff --git a/lib/Logging/Logging.cpp b/lib/Logging/Logging.cpp index 7b483657..52837f45 100644 --- a/lib/Logging/Logging.cpp +++ b/lib/Logging/Logging.cpp @@ -1,5 +1,8 @@ #include "Logging.h" +#include +#include + #include #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); } diff --git a/lib/Logging/Logging.h b/lib/Logging/Logging.h index 8784a341..3b4283a9 100644 --- a/lib/Logging/Logging.h +++ b/lib/Logging/Logging.h @@ -1,6 +1,10 @@ #pragma once +#include #include +#if defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT +#include +#endif #include @@ -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, ...); diff --git a/lib/PngToBmpConverter/PngToBmpConverter.cpp b/lib/PngToBmpConverter/PngToBmpConverter.cpp index 6c0013ba..30babeb4 100644 --- a/lib/PngToBmpConverter/PngToBmpConverter.cpp +++ b/lib/PngToBmpConverter/PngToBmpConverter.cpp @@ -4,6 +4,8 @@ #include #include #include +#include +#include #include #include @@ -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(currentOutY + 1) * scaleY_fp; diff --git a/lib/Xtc/Xtc.cpp b/lib/Xtc/Xtc.cpp index 1142c763..1d7c30f5 100644 --- a/lib/Xtc/Xtc.cpp +++ b/lib/Xtc/Xtc.cpp @@ -10,6 +10,16 @@ #include #include #include +#include +#include + +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); diff --git a/lib/hal/HalClock.cpp b/lib/hal/HalClock.cpp index 6e3c3a90..c793c9e2 100644 --- a/lib/hal/HalClock.cpp +++ b/lib/hal/HalClock.cpp @@ -5,46 +5,11 @@ #include #include -#include - 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(timeinfo.tm_year + 1900); + dt.month = static_cast(timeinfo.tm_mon + 1); + dt.day = static_cast(timeinfo.tm_mday); + dt.hour = static_cast(timeinfo.tm_hour); + dt.minute = static_cast(timeinfo.tm_min); + dt.second = static_cast(timeinfo.tm_sec); + dt.weekday = static_cast(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; diff --git a/lib/hal/HalClock.h b/lib/hal/HalClock.h index a0cc4825..7fd2f6bf 100644 --- a/lib/hal/HalClock.h +++ b/lib/hal/HalClock.h @@ -1,15 +1,14 @@ #pragma once #include -#include - -#include "HalGPIO.h" +#include 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); }; diff --git a/lib/hal/HalGPIO.cpp b/lib/hal/HalGPIO.cpp index b150dc8c..f623e4c9 100644 --- a/lib/hal/HalGPIO.cpp +++ b/lib/hal/HalGPIO.cpp @@ -1,5 +1,6 @@ #include #include +#include #include #include #include @@ -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,29 +231,54 @@ unsigned long HalGPIO::getHeldTime() const { return inputMgr.getHeldTime(); } unsigned long HalGPIO::getPowerButtonHeldTime() const { return inputMgr.getPowerButtonHeldTime(); } -void HalGPIO::startDeepSleep() { - // Ensure that the power button has been released to avoid immediately turning back on if you're holding it - while (inputMgr.isPressed(BTN_POWER)) { - delay(50); - inputMgr.update(); - } - // Arm the wakeup trigger *after* the button is released - esp_deep_sleep_enable_gpio_wakeup(1ULL << InputManager::POWER_BUTTON_PIN, ESP_GPIO_WAKEUP_GPIO_LOW); - // Enter Deep Sleep - esp_deep_sleep_start(); +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); } -void HalGPIO::verifyPowerButtonWakeup(uint16_t requiredDurationMs, bool shortPressAllowed) { +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; + return true; } // TODO: Intermittent edge case remains: a single tap followed by another single tap // can still power on the device. Tighten wake debounce/state handling here. - // Calibrate: subtract boot time already elapsed, assuming button held since boot - const uint16_t calibration = millis(); - const uint16_t calibratedDuration = (calibration < requiredDurationMs) ? (requiredDurationMs - calibration) : 1; + // Calibrate: subtract boot time already elapsed, assuming button held since boot. + const unsigned long calibration = millis(); + const unsigned long calibratedDuration = (calibration < requiredDurationMs) ? (requiredDurationMs - calibration) : 1; const auto start = millis(); inputMgr.update(); @@ -262,11 +293,12 @@ void HalGPIO::verifyPowerButtonWakeup(uint16_t requiredDurationMs, bool shortPre inputMgr.update(); } while (inputMgr.isPressed(BTN_POWER) && inputMgr.getPowerButtonHeldTime() < calibratedDuration); if (inputMgr.getPowerButtonHeldTime() < calibratedDuration) { - startDeepSleep(); + return false; } } else { - startDeepSleep(); + return false; } + return true; } bool HalGPIO::isUsbConnected() const { @@ -282,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 { @@ -292,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) { diff --git a/lib/hal/HalGPIO.h b/lib/hal/HalGPIO.h index 94e9bcd7..69755bd4 100644 --- a/lib/hal/HalGPIO.h +++ b/lib/hal/HalGPIO.h @@ -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,14 +72,20 @@ class HalGPIO { bool wasAnyReleased() const; unsigned long getHeldTime() const; unsigned long getPowerButtonHeldTime() const; - - // Setup wake up GPIO and enter deep sleep - void startDeepSleep(); + 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. - // If verification fails, enters deep sleep and does not return. + // Returns true if verification succeeded, false if device should return to sleep. // Should only be called when wakeup reason is PowerButton. - void verifyPowerButtonWakeup(uint16_t requiredDurationMs, bool shortPressAllowed); + bool verifyPowerButtonWakeup(uint16_t requiredDurationMs, bool shortPressAllowed); // Check if USB is connected bool isUsbConnected() const; diff --git a/lib/hal/HalPowerManager.cpp b/lib/hal/HalPowerManager.cpp index 78f0018f..358c921c 100644 --- a/lib/hal/HalPowerManager.cpp +++ b/lib/hal/HalPowerManager.cpp @@ -1,8 +1,11 @@ #include "HalPowerManager.h" +#include #include +#include #include #include +#include #include @@ -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) { diff --git a/lib/hal/HalPowerManager.h b/lib/hal/HalPowerManager.h index bf78623c..d2cc99c1 100644 --- a/lib/hal/HalPowerManager.h +++ b/lib/hal/HalPowerManager.h @@ -4,7 +4,6 @@ #include #include #include -#include #include #include @@ -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 diff --git a/lib/hal/HalSystem.cpp b/lib/hal/HalSystem.cpp index b92c95a1..23845720 100644 --- a/lib/hal/HalSystem.cpp +++ b/lib/hal/HalSystem.cpp @@ -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 } } diff --git a/lib/hal/HalTiltSensor.cpp b/lib/hal/HalTiltSensor.cpp index b5808cca..d7f3edfc 100644 --- a/lib/hal/HalTiltSensor.cpp +++ b/lib/hal/HalTiltSensor.cpp @@ -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((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) { diff --git a/lib/hal/HalTiltSensor.h b/lib/hal/HalTiltSensor.h index ef132183..3c2ba0ff 100644 --- a/lib/hal/HalTiltSensor.h +++ b/lib/hal/HalTiltSensor.h @@ -1,9 +1,7 @@ #pragma once #include -#include - -#include "HalGPIO.h" +#include // 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. diff --git a/nix/default.nix b/nix/default.nix new file mode 120000 index 00000000..593a000b --- /dev/null +++ b/nix/default.nix @@ -0,0 +1 @@ +shell.nix \ No newline at end of file diff --git a/nix/flake.lock b/nix/flake.lock new file mode 100644 index 00000000..2d51f2ec --- /dev/null +++ b/nix/flake.lock @@ -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 +} diff --git a/nix/flake.nix b/nix/flake.nix new file mode 100644 index 00000000..08ad390d --- /dev/null +++ b/nix/flake.nix @@ -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; + }; + } + ); + }; +} diff --git a/nix/shell.nix b/nix/shell.nix new file mode 100644 index 00000000..692cd4df --- /dev/null +++ b/nix/shell.nix @@ -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 diff --git a/platformio.ini b/platformio.ini index 10b72ebe..2b8205ba 100644 --- a/platformio.ini +++ b/platformio.ini @@ -38,8 +38,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 @@ -130,6 +128,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 @@ -148,6 +148,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 @@ -156,6 +158,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 @@ -164,6 +168,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 @@ -172,6 +178,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 diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index 5df277c6..a8140b06 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -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, @@ -238,6 +241,14 @@ class CrossPointSettings { // Reader screen margin settings uint8_t screenMargin = 5; + // OPDS download destination folder ("" = SD root). Global; edited from the + // OPDS server list. Persisted via a category-less SettingInfo::String in + // SettingsList.h, so it stays out of the on-device Settings screen. + char opdsDownloadFolder[64] = ""; + // On-disk filename format for OPDS downloads (0=Author-Title default, 1=Title-Author, + // 2=Title). See OpdsFilenameFormat. Persisted via a category-less SettingInfo::Enum, + // edited from the OPDS server list; hidden from the on-device Settings screen. + uint8_t opdsFilenameFormat = 0; // Hide battery percentage uint8_t hideBatteryPercentage = HIDE_NEVER; // Long-press page turn button behavior @@ -257,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. diff --git a/src/JsonSettingsIO.cpp b/src/JsonSettingsIO.cpp index 21701efd..57ad5f22 100644 --- a/src/JsonSettingsIO.cpp +++ b/src/JsonSettingsIO.cpp @@ -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()) { s.language = static_cast(I18n::languageFromCode(doc["language"].as())); diff --git a/src/MappedInputManager.cpp b/src/MappedInputManager.cpp index f8b5f0cc..eb159863 100644 --- a/src/MappedInputManager.cpp +++ b/src/MappedInputManager.cpp @@ -2,7 +2,11 @@ #include +#include +#include + #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(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(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 { diff --git a/src/MappedInputManager.h b/src/MappedInputManager.h index 89e6b862..134f61bc 100644 --- a/src/MappedInputManager.h +++ b/src/MappedInputManager.h @@ -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; }; diff --git a/src/SettingsList.h b/src/SettingsList.h index c171cc5f..48f8a612 100644 --- a/src/SettingsList.h +++ b/src/SettingsList.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -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& dictionaries) { + std::vector 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(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 getSettingsList(const SdCardFontRegistry* registry = nullptr) { +inline std::vector getSettingsList(const SdCardFontRegistry* registry = nullptr, + const std::vector* dictionaries = nullptr) { static const std::vector baseList = [] { std::vector v = { // --- Display --- @@ -166,6 +210,8 @@ inline std::vector 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 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( @@ -194,6 +242,16 @@ inline std::vector getSettingsList(const SdCardFontRegistry* regist SettingInfo::Toggle(StrId::STR_MOVE_FINISHED_TO_READ, &CrossPointSettings::moveFinishedToReadFolder, "moveFinishedToReadFolder", StrId::STR_CAT_SYSTEM), + // OPDS download folder: persisted + web-exposed, but category-less so it + // is hidden from the on-device Settings screen (edited via OPDS UI). + SettingInfo::String(StrId::STR_OPDS_DOWNLOAD_FOLDER, &SETTINGS.opdsDownloadFolder[0], + sizeof(SETTINGS.opdsDownloadFolder), "opdsDownloadFolder"), + // OPDS download filename format: persisted + web-exposed, category-less so it + // is hidden from the on-device Settings screen (cycled from the OPDS UI). + SettingInfo::Enum(StrId::STR_OPDS_FILENAME_FORMAT, &CrossPointSettings::opdsFilenameFormat, + {StrId::STR_FMT_AUTHOR_TITLE, StrId::STR_FMT_TITLE_AUTHOR, StrId::STR_FMT_TITLE}, + "opdsFilenameFormat"), + // --- KOReader Sync (web-only, uses KOReaderCredentialStore) --- SettingInfo::DynamicString( StrId::STR_KOREADER_USERNAME, [] { return KOREADER_STORE.getUsername(); }, @@ -232,6 +290,14 @@ inline std::vector 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(KOREADER_STORE.getSyncBehavior()); }, + [](uint8_t v) { + KOREADER_STORE.setSyncBehavior(static_cast(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), @@ -282,11 +348,30 @@ inline std::vector getSettingsList(const SdCardFontRegistry* regist }(); std::vector 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; } diff --git a/src/activities/Activity.cpp b/src/activities/Activity.cpp index eaabc2aa..cb26f1ad 100644 --- a/src/activities/Activity.cpp +++ b/src/activities/Activity.cpp @@ -22,3 +22,20 @@ void Activity::startActivityForResult(std::unique_ptr&& 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; +} diff --git a/src/activities/Activity.h b/src/activities/Activity.h index 070eb4fd..d068e619 100644 --- a/src/activities/Activity.h +++ b/src/activities/Activity.h @@ -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); }; diff --git a/src/activities/ActivityManager.cpp b/src/activities/ActivityManager.cpp index 7757d050..8647ec01 100644 --- a/src/activities/ActivityManager.cpp +++ b/src/activities/ActivityManager.cpp @@ -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(); } diff --git a/src/activities/browser/OpdsBookBrowserActivity.cpp b/src/activities/browser/OpdsBookBrowserActivity.cpp index b2da2bdc..ccbc1d0a 100644 --- a/src/activities/browser/OpdsBookBrowserActivity.cpp +++ b/src/activities/browser/OpdsBookBrowserActivity.cpp @@ -2,26 +2,44 @@ #include #include +#include #include #include #include #include +#include "CrossPointSettings.h" #include "MappedInputManager.h" #include "SilentRestart.h" #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" +#include "util/OpdsFilename.h" #include "util/StringUtils.h" #include "util/UrlUtils.h" 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() { @@ -69,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); @@ -94,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(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(); @@ -133,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()); @@ -146,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(); @@ -279,8 +350,26 @@ void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book) { // Build full download URL relative to the current feed, not the root server URL const std::string feedUrl = UrlUtils::buildUrl(server.url, currentPath); std::string downloadUrl = UrlUtils::buildUrl(feedUrl, book.href); - std::string filename = - "/" + StringUtils::sanitizeFilename((book.author.empty() ? "" : book.author + " - ") + book.title) + ".epub"; + // opdsDownloadFolder is already a null-terminated char[64]; use it directly — + // no std::string copy. exists()/mkdir() take const char*. + const char* folder = SETTINGS.opdsDownloadFolder; // "" => SD root + bool haveFolder = folder[0] != '\0'; + if (haveFolder && !Storage.exists(folder) && !Storage.mkdir(folder)) { + // exists()-guard first: mkdir's return-on-existing is unconfirmed, and every + // existing caller checks exists() before mkdir. On real failure, fall back + // to SD root so the download is never lost. + LOG_ERR("OPDS", "mkdir failed for %s, using SD root", folder); + haveFolder = false; + } + + // downloadToFile() needs a std::string, and titles are unbounded (a fixed + // char[] would truncate). Cold path (a multi-second download follows), so one + // reserve'd, in-place-appended owning string is the right call. + std::string filename; + filename.reserve(96); + if (haveFolder) filename += folder; + filename += '/'; + filename += opdsBookFilename(book.author, book.title, static_cast(SETTINGS.opdsFilenameFormat)); LOG_DBG("OPDS", "Downloading: %s -> %s", downloadUrl.c_str(), filename.c_str()); int lastRenderedPercent = -1; diff --git a/src/activities/home/CrashActivity.cpp b/src/activities/home/CrashActivity.cpp index 4f5a392b..3303e0a1 100644 --- a/src/activities/home/CrashActivity.cpp +++ b/src/activities/home/CrashActivity.cpp @@ -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(); } } diff --git a/src/activities/home/FileBrowserActivity.cpp b/src/activities/home/FileBrowserActivity.cpp index 18577923..db696ece 100644 --- a/src/activities/home/FileBrowserActivity.cpp +++ b/src/activities/home/FileBrowserActivity.cpp @@ -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(selectorIndex); + const auto listTouch = handleListTouch(touchSel, static_cast(files.size()), contentTop, contentHeight, false); + if (listTouch != ListTouchResult::None) { + selectorIndex = static_cast(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(files.size()); + const auto swipe = mappedInput.wasSwipe(); + if (swipe == MappedInputManager::SwipeDir::Up) { + selectorIndex = ButtonNavigator::nextPageIndex(static_cast(selectorIndex), listSize, pageItems); + requestUpdate(); + return; + } + if (swipe == MappedInputManager::SwipeDir::Down) { + selectorIndex = ButtonNavigator::previousPageIndex(static_cast(selectorIndex), listSize, pageItems); + requestUpdate(); + return; + } + buttonNavigator.onNextRelease([this, listSize] { selectorIndex = ButtonNavigator::nextIndex(static_cast(selectorIndex), listSize); requestUpdate(); diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp index 28743264..c15059ad 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -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(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(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(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(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(); diff --git a/src/activities/home/HomeActivity.h b/src/activities/home/HomeActivity.h index 6d170cdd..84bd62ec 100644 --- a/src/activities/home/HomeActivity.h +++ b/src/activities/home/HomeActivity.h @@ -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; } }; diff --git a/src/activities/home/RecentBooksActivity.cpp b/src/activities/home/RecentBooksActivity.cpp index 7b4a0117..f99b4540 100644 --- a/src/activities/home/RecentBooksActivity.cpp +++ b/src/activities/home/RecentBooksActivity.cpp @@ -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(selectorIndex); + const auto listTouch = + handleListTouch(touchSel, static_cast(recentBooks.size()), contentTop, contentHeight, true); + if (listTouch != ListTouchResult::None) { + selectorIndex = static_cast(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(recentBooks.size()); + const auto swipe = mappedInput.wasSwipe(); + if (swipe == MappedInputManager::SwipeDir::Up) { + selectorIndex = ButtonNavigator::nextPageIndex(static_cast(selectorIndex), listSize, pageItems); + requestUpdate(); + return; + } + if (swipe == MappedInputManager::SwipeDir::Down) { + selectorIndex = ButtonNavigator::previousPageIndex(static_cast(selectorIndex), listSize, pageItems); + requestUpdate(); + return; + } buttonNavigator.onNextRelease([this, listSize] { selectorIndex = ButtonNavigator::nextIndex(static_cast(selectorIndex), listSize); diff --git a/src/activities/network/CalibreConnectActivity.cpp b/src/activities/network/CalibreConnectActivity.cpp index ff89cf27..7d821c6b 100644 --- a/src/activities/network/CalibreConnectActivity.cpp +++ b/src/activities/network/CalibreConnectActivity.cpp @@ -4,13 +4,13 @@ #include #include #include -#include #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(); diff --git a/src/activities/network/CrossPointWebServerActivity.cpp b/src/activities/network/CrossPointWebServerActivity.cpp index 8be6d1e0..f4dae62a 100644 --- a/src/activities/network/CrossPointWebServerActivity.cpp +++ b/src/activities/network/CrossPointWebServerActivity.cpp @@ -5,7 +5,6 @@ #include #include #include -#include #include @@ -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) { diff --git a/src/activities/network/NetworkModeSelectionActivity.cpp b/src/activities/network/NetworkModeSelectionActivity.cpp index f8e45307..e7720801 100644 --- a/src/activities/network/NetworkModeSelectionActivity.cpp +++ b/src/activities/network/NetworkModeSelectionActivity.cpp @@ -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); diff --git a/src/activities/network/WifiSelectionActivity.cpp b/src/activities/network/WifiSelectionActivity.cpp index 119ae4a8..f6436977 100644 --- a/src/activities/network/WifiSelectionActivity.cpp +++ b/src/activities/network/WifiSelectionActivity.cpp @@ -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(connectedBssid[0]), static_cast(connectedBssid[1]), + static_cast(connectedBssid[2]), static_cast(connectedBssid[3]), + static_cast(connectedBssid[4]), static_cast(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(selectedNetworkIndex); + const auto listTouch = + handleListTouch(touchSel, static_cast(networks.size()), contentTop, contentHeight, false); + if (listTouch != ListTouchResult::None) { + selectedNetworkIndex = static_cast(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()); diff --git a/src/activities/reader/DictionaryDefinitionActivity.cpp b/src/activities/reader/DictionaryDefinitionActivity.cpp new file mode 100644 index 00000000..963c23f2 --- /dev/null +++ b/src/activities/reader/DictionaryDefinitionActivity.cpp @@ -0,0 +1,254 @@ +#include "DictionaryDefinitionActivity.h" + +#include +#include +#include + +#include +#include +#include + +#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(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(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(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(lines.size())); + for (int i = firstLine; i < lastLine; i++) { + if (lines[i].len == 0) continue; + const size_t len = std::min(static_cast(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(); +} diff --git a/src/activities/reader/DictionaryDefinitionActivity.h b/src/activities/reader/DictionaryDefinitionActivity.h new file mode 100644 index 00000000..ef2c1330 --- /dev/null +++ b/src/activities/reader/DictionaryDefinitionActivity.h @@ -0,0 +1,46 @@ +#pragma once + +#include +#include +#include + +#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 lines; + int currentPage = 0; + int totalPages = 1; + int linesPerPage = 1; + ButtonNavigator buttonNavigator; +}; diff --git a/src/activities/reader/DictionaryWordSelectActivity.cpp b/src/activities/reader/DictionaryWordSelectActivity.cpp new file mode 100644 index 00000000..5a4390ce --- /dev/null +++ b/src/activities/reader/DictionaryWordSelectActivity.cpp @@ -0,0 +1,337 @@ +#include "DictionaryWordSelectActivity.h" + +#include +#include +#include +#include +#include + +#include +#include +#include + +#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(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(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(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(line->xPos + block->wordXpos(i) + marginLeft); + box.y = static_cast(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(1u << (static_cast(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(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(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(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(current.row) + direction; + if (targetRow < 0 || targetRow >= static_cast(rowCount)) return; + + const int best = closestInRow(static_cast(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(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(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(hx); + snapshotY = static_cast(hy); + snapshotW = static_cast(hw); + snapshotH = static_cast(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(1u << (static_cast(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); +} diff --git a/src/activities/reader/DictionaryWordSelectActivity.h b/src/activities/reader/DictionaryWordSelectActivity.h new file mode 100644 index 00000000..2ca842c9 --- /dev/null +++ b/src/activities/reader/DictionaryWordSelectActivity.h @@ -0,0 +1,86 @@ +#pragma once + +#include +#include + +#include +#include + +#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, 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; + const int marginLeft; + const int marginTop; + int fontId = 0; + int lineHeight = 0; + + std::vector 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 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; +}; diff --git a/src/activities/reader/EndOfBookOptions.cpp b/src/activities/reader/EndOfBookOptions.cpp index e76b0e9c..1e6e14db 100644 --- a/src/activities/reader/EndOfBookOptions.cpp +++ b/src/activities/reader/EndOfBookOptions.cpp @@ -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 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" diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 99dc39ac..6057b13a 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -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" @@ -269,6 +270,29 @@ bool EpubReaderActivity::buildTickHeapGate() { return !buildHeapPaused; } +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(renderer, mappedInput, std::move(page), + orientedMarginLeft, orientedMarginTop), + [this](const ActivityResult&) { requestUpdate(); }); +} + void EpubReaderActivity::loop() { if (!epub) { // Should never happen @@ -397,9 +421,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(); @@ -428,6 +454,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 @@ -457,10 +488,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 { @@ -491,26 +522,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(ctx)->onGoHome(); }})) { return; } @@ -540,7 +574,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; } @@ -564,7 +600,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)) { @@ -764,6 +801,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(); @@ -1355,6 +1396,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() { diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index 4dbe85be..a6dbf1cc 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -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; // Idle-time glyph prewarm: after a page settles, scan the LIKELY next page @@ -153,6 +156,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(); diff --git a/src/activities/reader/EpubReaderBookmarksActivity.cpp b/src/activities/reader/EpubReaderBookmarksActivity.cpp index fcf44cf3..0d244782 100644 --- a/src/activities/reader/EpubReaderBookmarksActivity.cpp +++ b/src/activities/reader/EpubReaderBookmarksActivity.cpp @@ -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(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(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(); diff --git a/src/activities/reader/EpubReaderBookmarksActivity.h b/src/activities/reader/EpubReaderBookmarksActivity.h index 56e57711..78f7a46c 100644 --- a/src/activities/reader/EpubReaderBookmarksActivity.h +++ b/src/activities/reader/EpubReaderBookmarksActivity.h @@ -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 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(); }; diff --git a/src/activities/reader/EpubReaderChapterSelectionActivity.cpp b/src/activities/reader/EpubReaderChapterSelectionActivity.cpp index 6306346c..9042f33b 100644 --- a/src/activities/reader/EpubReaderChapterSelectionActivity.cpp +++ b/src/activities/reader/EpubReaderChapterSelectionActivity.cpp @@ -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] { diff --git a/src/activities/reader/EpubReaderFootnotesActivity.cpp b/src/activities/reader/EpubReaderFootnotesActivity.cpp index 390dd3c5..d8e109a1 100644 --- a/src/activities/reader/EpubReaderFootnotesActivity.cpp +++ b/src/activities/reader/EpubReaderFootnotesActivity.cpp @@ -18,6 +18,13 @@ void EpubReaderFootnotesActivity::onEnter() { void EpubReaderFootnotesActivity::onExit() { Activity::onExit(); } void EpubReaderFootnotesActivity::loop() { + auto selectFootnote = [this] { + if (selectedIndex >= 0 && selectedIndex < static_cast(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(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(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(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(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) { diff --git a/src/activities/reader/EpubReaderMenuActivity.cpp b/src/activities/reader/EpubReaderMenuActivity.cpp index 2bf1df41..b194b1e2 100644 --- a/src/activities/reader/EpubReaderMenuActivity.cpp +++ b/src/activities/reader/EpubReaderMenuActivity.cpp @@ -22,7 +22,7 @@ EpubReaderMenuActivity::EpubReaderMenuActivity(GfxRenderer& renderer, MappedInpu std::vector EpubReaderMenuActivity::buildMenuItems(bool hasFootnotes, bool hasBookmarks) { std::vector items; - items.reserve(12); + items.reserve(13); items.push_back({MenuAction::SELECT_CHAPTER, StrId::STR_SELECT_CHAPTER}); if (hasFootnotes) { items.push_back({MenuAction::FOOTNOTES, StrId::STR_FOOTNOTES}); @@ -31,6 +31,7 @@ std::vector EpubReaderMenuActivity::buildMenuI items.push_back({MenuAction::BOOKMARKS, StrId::STR_BOOKMARKS}); } items.push_back({MenuAction::TOGGLE_BOOKMARK, StrId::STR_TOGGLE_BOOKMARK}); + items.push_back({MenuAction::DICTIONARY, StrId::STR_LOOKUP}); items.push_back({MenuAction::ROTATE_SCREEN, StrId::STR_ORIENTATION}); items.push_back({MenuAction::AUTO_PAGE_TURN, StrId::STR_AUTO_TURN_PAGES_PER_MIN}); items.push_back({MenuAction::GO_TO_PERCENT, StrId::STR_GO_TO_PERCENT}); @@ -49,21 +50,45 @@ void EpubReaderMenuActivity::onEnter() { void EpubReaderMenuActivity::onExit() { Activity::onExit(); } +void EpubReaderMenuActivity::closeCancelled() { + ActivityResult result; + result.isCancelled = true; + result.data = MenuResult{-1, pendingOrientation, selectedPageTurnOption}; + setResult(std::move(result)); + finish(); +} + +bool EpubReaderMenuActivity::handleHomeGesture() { + closeCancelled(); + return true; +} + void EpubReaderMenuActivity::loop() { - if (optionPopup.handleInput(mappedInput, [this] { requestUpdate(); })) return; + if (optionPopup.handleInput(mappedInput, [this] { requestUpdate(); })) { + // The popup acts on button press; if that input closed it, the trailing + // release must be swallowed below (Back would close the menu, Confirm + // would re-activate the selected item). + popupClosing = !optionPopup.isActive(); + return; + } + if (popupClosing) { + if (mappedInput.isPressed(MappedInputManager::Button::Back) || + mappedInput.isPressed(MappedInputManager::Button::Confirm)) { + return; // closing press still held + } + popupClosing = false; + if (mappedInput.wasReleased(MappedInputManager::Button::Back) || + mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + return; // swallow the release that closed the popup + } + } - // Handle navigation - buttonNavigator.onNext([this] { - selectedIndex = ButtonNavigator::nextIndex(selectedIndex, static_cast(menuItems.size())); - requestUpdate(); - }); + if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { + closeCancelled(); + return; + } - buttonNavigator.onPrevious([this] { - selectedIndex = ButtonNavigator::previousIndex(selectedIndex, static_cast(menuItems.size())); - requestUpdate(); - }); - - if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + auto activateSelected = [this] { const auto selectedAction = menuItems[selectedIndex].action; if (selectedAction == MenuAction::ROTATE_SCREEN) { optionPopup.show(StrId::STR_ORIENTATION, orientationLabels.data(), static_cast(orientationLabels.size()), @@ -87,13 +112,48 @@ void EpubReaderMenuActivity::loop() { setResult(MenuResult{static_cast(selectedAction), pendingOrientation, selectedPageTurnOption}); finish(); + }; + + 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; + switch (handleListTouch(selectedIndex, static_cast(menuItems.size()), contentTop, contentHeight, false)) { + case ListTouchResult::Activated: + activateSelected(); + return; + case ListTouchResult::Consumed: + return; + case ListTouchResult::None: + break; + } + + const auto swipe = mappedInput.wasSwipe(); + if (swipe == MappedInputManager::SwipeDir::Up) { + selectedIndex = ButtonNavigator::nextIndex(selectedIndex, static_cast(menuItems.size())); + requestUpdate(); return; - } else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { - ActivityResult result; - result.isCancelled = true; - result.data = MenuResult{-1, pendingOrientation, selectedPageTurnOption}; - setResult(std::move(result)); - finish(); + } + if (swipe == MappedInputManager::SwipeDir::Down) { + selectedIndex = ButtonNavigator::previousIndex(selectedIndex, static_cast(menuItems.size())); + requestUpdate(); + return; + } + + // Handle navigation + buttonNavigator.onNext([this] { + selectedIndex = ButtonNavigator::nextIndex(selectedIndex, static_cast(menuItems.size())); + requestUpdate(); + }); + + buttonNavigator.onPrevious([this] { + selectedIndex = ButtonNavigator::previousIndex(selectedIndex, static_cast(menuItems.size())); + requestUpdate(); + }); + + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + activateSelected(); return; } } diff --git a/src/activities/reader/EpubReaderMenuActivity.h b/src/activities/reader/EpubReaderMenuActivity.h index 1920ac8e..5850f6eb 100644 --- a/src/activities/reader/EpubReaderMenuActivity.h +++ b/src/activities/reader/EpubReaderMenuActivity.h @@ -24,7 +24,8 @@ class EpubReaderMenuActivity final : public Activity { DISPLAY_QR, GO_HOME, SYNC, - DELETE_CACHE + DELETE_CACHE, + DICTIONARY }; explicit EpubReaderMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& title, @@ -35,6 +36,7 @@ class EpubReaderMenuActivity final : public Activity { void onExit() override; void loop() override; void render(RenderLock&&) override; + bool handleHomeGesture() override; private: struct MenuItem { @@ -43,6 +45,7 @@ class EpubReaderMenuActivity final : public Activity { }; static std::vector buildMenuItems(bool hasFootnotes, bool hasBookmarks); + void closeCancelled(); // Fixed menu layout const std::vector menuItems; @@ -51,6 +54,9 @@ class EpubReaderMenuActivity final : public Activity { ButtonNavigator buttonNavigator; OptionPopup optionPopup; + // True while the button press that closed the popup is still held; its release + // must not fall through to the menu's own Back/Confirm handlers. + bool popupClosing = false; std::string title = "Reader Menu"; uint8_t pendingOrientation = 0; uint8_t selectedPageTurnOption = 0; diff --git a/src/activities/reader/EpubReaderPercentSelectionActivity.cpp b/src/activities/reader/EpubReaderPercentSelectionActivity.cpp index 3ef5088a..1beae9a6 100644 --- a/src/activities/reader/EpubReaderPercentSelectionActivity.cpp +++ b/src/activities/reader/EpubReaderPercentSelectionActivity.cpp @@ -4,6 +4,7 @@ #include #include +#include #include #include "MappedInputManager.h" @@ -36,6 +37,38 @@ void EpubReaderPercentSelectionActivity::adjustPercent(const int delta) { } void EpubReaderPercentSelectionActivity::loop() { + auto& theme = UITheme::getInstance(); + auto metrics = theme.getMetrics(); + Rect screen = theme.getScreenSafeArea(renderer, true, false); + const int contentTop = screen.y + metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing * 4; + constexpr int barWidth = 360; + constexpr int barHeight = 16; + const int barX = screen.x + (screen.width - barWidth) / 2; + const int barY = contentTop + metrics.verticalSpacing * 2; + int tx = 0; + int ty = 0; + + // Live drag on the slider: once a touch lands on the bar, the percent follows the + // finger until release. Runs before the Back handler because the release of a drag + // can also register as a swipe (e.g. the left-edge rightward back gesture) — the + // drag must consume it so it can't cancel the dialog or step the percent. + if (mappedInput.isScreenTouchHeld(tx, ty)) { + if (draggingBar || + (tx >= barX - 20 && tx < barX + barWidth + 20 && ty >= barY - 24 && ty < barY + barHeight + 24)) { + draggingBar = true; + const int dragged = std::clamp((tx - barX) * 100 / barWidth, 0, 100); + if (dragged != percent) { + percent = dragged; + requestUpdate(); + } + return; + } + } else if (draggingBar) { + // Release frame of a drag: swallow the tap/swipe events it produced. + draggingBar = false; + return; + } + // Back cancels, confirm selects, arrows adjust the percent. if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { ActivityResult result; @@ -45,6 +78,23 @@ void EpubReaderPercentSelectionActivity::loop() { return; } + if (mappedInput.wasScreenTapped(tx, ty) && tx >= barX - 20 && tx < barX + barWidth + 20 && ty >= barY - 24 && + ty < barY + barHeight + 24) { + percent = std::clamp((tx - barX) * 100 / barWidth, 0, 100); + requestUpdate(); + return; + } + + const auto swipe = mappedInput.wasSwipe(); + if (swipe == MappedInputManager::SwipeDir::Right) { + adjustPercent(kLargeStep); + return; + } + if (swipe == MappedInputManager::SwipeDir::Left) { + adjustPercent(-kLargeStep); + return; + } + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { setResult(PercentResult{percent}); finish(); diff --git a/src/activities/reader/EpubReaderPercentSelectionActivity.h b/src/activities/reader/EpubReaderPercentSelectionActivity.h index 8cba8664..7c20c459 100644 --- a/src/activities/reader/EpubReaderPercentSelectionActivity.h +++ b/src/activities/reader/EpubReaderPercentSelectionActivity.h @@ -20,6 +20,9 @@ class EpubReaderPercentSelectionActivity final : public Activity { // Current percent value (0-100) shown on the slider. int percent = 0; + // True while a touch that landed on the slider bar is being dragged. + bool draggingBar = false; + ButtonNavigator buttonNavigator; // Change the current percent by a delta and clamp within bounds. diff --git a/src/activities/reader/KOReaderSyncActivity.cpp b/src/activities/reader/KOReaderSyncActivity.cpp index 9fb39c93..cb299083 100644 --- a/src/activities/reader/KOReaderSyncActivity.cpp +++ b/src/activities/reader/KOReaderSyncActivity.cpp @@ -10,6 +10,7 @@ #include #include +#include #include "Epub/Section.h" #include "EpubReaderUtils.h" @@ -24,6 +25,19 @@ #include "fontIds.h" namespace { +std::string calculateDocumentHashForMethod(const std::string& path, const DocumentMatchMethod method) { + return method == DocumentMatchMethod::FILENAME ? KOReaderDocumentId::calculateFromFilename(path) + : KOReaderDocumentId::calculate(path); +} + +DocumentMatchMethod alternateMatchMethod(const DocumentMatchMethod method) { + return method == DocumentMatchMethod::FILENAME ? DocumentMatchMethod::BINARY : DocumentMatchMethod::FILENAME; +} + +const char* matchMethodName(const DocumentMatchMethod method) { + return method == DocumentMatchMethod::FILENAME ? "filename" : "binary"; +} + void syncTimeWithNTP() { // Stop SNTP if already running (can't reconfigure while running) if (esp_sntp_enabled()) { @@ -84,6 +98,21 @@ void KOReaderSyncActivity::saveProgressAndReturn(int spineIndex, int page) { void KOReaderSyncActivity::returnToReader() { activityManager.goToReader(epubPath); } +bool KOReaderSyncActivity::smartSyncEnabled() const { + return KOREADER_STORE.getSyncBehavior() == KOReaderSyncBehavior::SMART; +} + +void KOReaderSyncActivity::markAutoReturn() { autoReturnAt = millis() + AUTO_RETURN_DELAY_MS; } + +void KOReaderSyncActivity::completeAlreadySynced() { + { + RenderLock lock(*this); + state = SYNC_COMPLETE; + } + markAutoReturn(); + requestUpdate(true); +} + void KOReaderSyncActivity::onWifiSelectionComplete(const bool success) { if (!success) { LOG_DBG("KOSync", "WiFi connection failed, exiting"); @@ -113,12 +142,8 @@ void KOReaderSyncActivity::onWifiSelectionComplete(const bool success) { } void KOReaderSyncActivity::performSync() { - // Calculate document hash based on user's preferred method - if (KOREADER_STORE.getMatchMethod() == DocumentMatchMethod::FILENAME) { - documentHash = KOReaderDocumentId::calculateFromFilename(epubPath); - } else { - documentHash = KOReaderDocumentId::calculate(epubPath); - } + const DocumentMatchMethod primaryMethod = KOREADER_STORE.getMatchMethod(); + documentHash = calculateDocumentHashForMethod(epubPath, primaryMethod); if (documentHash.empty()) { { RenderLock lock(*this); @@ -128,8 +153,9 @@ void KOReaderSyncActivity::performSync() { requestUpdate(true); return; } + const std::string primaryHash = documentHash; - LOG_DBG("KOSync", "Document hash: %s", documentHash.c_str()); + LOG_DBG("KOSync", "Document hash (%s): %s", matchMethodName(primaryMethod), documentHash.c_str()); { RenderLock lock(*this); @@ -137,10 +163,42 @@ void KOReaderSyncActivity::performSync() { } requestUpdateAndWait(); - // Fetch remote progress - const auto result = KOReaderSyncClient::getProgress(documentHash, remoteProgress); + // Fetch remote progress. In smart mode, also probe the alternate document-id + // method and use the furthest remote state we can find. This avoids a stale + // local upload when another KOReader device synced the same book with a + // different document matching method. + auto result = KOReaderSyncClient::getProgress(documentHash, remoteProgress); + LOG_DBG("KOSync", "Primary remote (%s): result=%d http=%d doc=%s local=%.6f remote=%.6f xpath=%s", + matchMethodName(primaryMethod), result, KOReaderSyncClient::lastHttpCode, documentHash.c_str(), + localProgress.percentage, remoteProgress.percentage, remoteProgress.progress.c_str()); + + if (smartSyncEnabled()) { + const DocumentMatchMethod altMethod = alternateMatchMethod(primaryMethod); + const std::string altHash = calculateDocumentHashForMethod(epubPath, altMethod); + if (!altHash.empty() && altHash != documentHash) { + KOReaderProgress altProgress; + const auto altResult = KOReaderSyncClient::getProgress(altHash, altProgress); + LOG_DBG("KOSync", "Alternate remote (%s): result=%d http=%d doc=%s local=%.6f remote=%.6f xpath=%s", + matchMethodName(altMethod), altResult, KOReaderSyncClient::lastHttpCode, altHash.c_str(), + localProgress.percentage, altProgress.percentage, altProgress.progress.c_str()); + + if (altResult == KOReaderSyncClient::OK && + (result == KOReaderSyncClient::NOT_FOUND || altProgress.percentage > remoteProgress.percentage)) { + documentHash = altHash; + remoteProgress = std::move(altProgress); + result = KOReaderSyncClient::OK; + } + } + } if (result == KOReaderSyncClient::NOT_FOUND) { + if (smartSyncEnabled()) { + LOG_DBG("KOSync", "Smart sync: no remote progress found for known document hashes; uploading local %.6f", + localProgress.percentage); + performUpload(); + return; + } + // No remote progress - offer to upload { RenderLock lock(*this); @@ -174,8 +232,42 @@ void KOReaderSyncActivity::performSync() { return; } - SavedProgressPosition koPos = {remoteProgress.progress, remoteProgress.percentage}; - remotePosition = ProgressMapper::toCrossPoint(epub, koPos, renderer, currentSpineIndex, totalPagesInSpine); + // Prefer the exact spine/page from a crosspoint-sync rich position (lossless + // CrossPoint<->CrossPoint sync); fall back to the approximate XPath mapping + // for plain kosync servers or when the rich position cannot be applied. + std::optional richMapped; + if (remoteProgress.position.has_value()) { + richMapped = ProgressMapper::fromRichPosition(epub, *remoteProgress.position, renderer); + } + if (richMapped.has_value()) { + remotePosition = *richMapped; + } else { + SavedProgressPosition koPos = {remoteProgress.progress, remoteProgress.percentage}; + remotePosition = ProgressMapper::toCrossPoint(epub, koPos, renderer, currentSpineIndex, totalPagesInSpine); + } + + if (smartSyncEnabled()) { + static constexpr float SAME_PROGRESS_EPSILON = 0.001f; // 0.1 percentage points + const float delta = localProgress.percentage - remoteProgress.percentage; + LOG_DBG("KOSync", "Smart decision: doc=%s local=%.6f remote=%.6f delta=%.6f remoteXpath=%s mapped=%d/%d", + documentHash.c_str(), localProgress.percentage, remoteProgress.percentage, delta, + remoteProgress.progress.c_str(), remotePosition.spineIndex, remotePosition.pageNumber); + if (std::fabs(delta) <= SAME_PROGRESS_EPSILON) { + completeAlreadySynced(); + return; + } + + if (delta > 0) { + // Alternate hashes are only probes for newer remote state. Keep uploads + // on the user's configured matching method so its primary record heals. + documentHash = primaryHash; + performUpload(); + return; + } + + saveProgressAndReturn(remotePosition.spineIndex, remotePosition.pageNumber); + return; + } // localProgress was pre-computed in EpubReaderActivity before the Epub was released. { @@ -206,17 +298,45 @@ void KOReaderSyncActivity::performUpload() { progress.progress = localProgress.xpath; progress.percentage = localProgress.percentage; + // Rich CrossPoint position for crosspoint-sync servers (lossless + // CrossPoint<->CrossPoint sync); plain kosync servers ignore the extra field. + { + KOReaderRichPosition pos; + const float pct = localProgress.percentage < 0.0f ? 0.0f + : localProgress.percentage > 1.0f ? 1.0f + : localProgress.percentage; + pos.pctQ = static_cast(pct * 1000000.0f + 0.5f); + pos.spineIndex = static_cast(currentSpineIndex); + pos.pageNumber = static_cast(currentPage); + pos.totalPages = static_cast(totalPagesInSpine > 0 ? totalPagesInSpine : 1); + pos.paragraphIndex = currentParagraphIndex; + pos.xpath = localProgress.xpath; + progress.position = std::move(pos); + } + // Optionally include document metadata (KOReader PR #15306) if (KOREADER_STORE.getSendMetadata()) { + // The Epub is released before the sync network calls and is only reloaded on the + // remote-progress path (performSync). When uploading from NO_REMOTE_PROGRESS the + // Epub is still null, so reload it here and guard the title/author reads to avoid + // dereferencing a null Epub. Filename is derived from the path and is always safe. + ensureEpubLoaded(); KOReaderMetadata meta; - // Extract filename from path const auto lastSlash = epubPath.rfind('/'); meta.filename = (lastSlash != std::string::npos) ? epubPath.substr(lastSlash + 1) : epubPath; - meta.title = epub->getTitle(); - meta.authors = epub->getAuthor(); + if (epub) { + meta.title = epub->getTitle(); + meta.authors = epub->getAuthor(); + } else { + LOG_ERR("KOSync", "Epub unavailable for metadata; sending filename only"); + } progress.metadata = std::move(meta); } + // Release the Epub before the network call so the TLS handshake has enough free heap + // (consistent with the release-before-sync pattern in performSync); nothing below needs it. + epub.reset(); + const auto result = KOReaderSyncClient::updateProgress(progress); // Drop the radio while user reads the result; full teardown happens at silent reboot. @@ -236,6 +356,7 @@ void KOReaderSyncActivity::performUpload() { RenderLock lock(*this); state = UPLOAD_COMPLETE; } + markAutoReturn(); requestUpdate(true); } @@ -379,10 +500,12 @@ void KOReaderSyncActivity::render(RenderLock&&) { return; } - if (state == UPLOAD_COMPLETE) { - UITheme::drawCenteredText(renderer, screen, UI_10_FONT_ID, top, tr(STR_UPLOAD_SUCCESS), true, EpdFontFamily::BOLD); + if (state == UPLOAD_COMPLETE || state == SYNC_COMPLETE) { + UITheme::drawCenteredText(renderer, screen, UI_10_FONT_ID, top, + state == UPLOAD_COMPLETE ? tr(STR_UPLOAD_SUCCESS) : tr(STR_ALREADY_SYNCED), true, + EpdFontFamily::BOLD); - const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", ""); + const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_DONE), "", ""); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); renderer.displayBuffer(); return; @@ -400,14 +523,48 @@ void KOReaderSyncActivity::render(RenderLock&&) { } void KOReaderSyncActivity::loop() { - if (state == NO_CREDENTIALS || state == SYNC_FAILED || state == UPLOAD_COMPLETE) { - if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { + if (state == NO_CREDENTIALS || state == SYNC_FAILED || state == UPLOAD_COMPLETE || state == SYNC_COMPLETE) { + if (autoReturnAt != 0 && millis() >= autoReturnAt) { + returnToReader(); + return; + } + if (mappedInput.wasReleased(MappedInputManager::Button::Back) || + mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { returnToReader(); } return; } if (state == SHOWING_RESULT) { + auto chooseSelected = [this] { + if (selectedOption == 0) { + saveProgressAndReturn(remotePosition.spineIndex, remotePosition.pageNumber); + } else if (selectedOption == 1) { + performUpload(); + } + }; + + { + const auto& metrics = UITheme::getInstance().getMetrics(); + const Rect screen = UITheme::getInstance().getScreenSafeArea(renderer, true, false); + const int top = screen.y + metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing; + constexpr int optionHeight = 30; + int touchedOption = -1; + const auto touch = mappedInput.rowTouch(touchedOption, top + 230 - 2, optionHeight, 2); + if (touch == MappedInputManager::RowTouch::Down) { + if (selectedOption != touchedOption) { + selectedOption = touchedOption; + requestUpdate(); + } + return; + } + if (touch == MappedInputManager::RowTouch::Tap) { + selectedOption = touchedOption; + chooseSelected(); + return; + } + } + // Navigate options if (mappedInput.wasReleased(MappedInputManager::Button::Up) || mappedInput.wasReleased(MappedInputManager::Button::Left)) { @@ -420,12 +577,7 @@ void KOReaderSyncActivity::loop() { } if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { - if (selectedOption == 0) { - saveProgressAndReturn(remotePosition.spineIndex, remotePosition.pageNumber); - } else if (selectedOption == 1) { - // Upload local progress - performUpload(); - } + chooseSelected(); } if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { @@ -435,6 +587,21 @@ void KOReaderSyncActivity::loop() { } if (state == NO_REMOTE_PROGRESS) { + int tx = 0; + int ty = 0; + if (mappedInput.wasScreenTapped(tx, ty) && ty > renderer.getScreenHeight() / 3 && + ty < renderer.getScreenHeight() * 2 / 3) { + if (documentHash.empty()) { + if (KOREADER_STORE.getMatchMethod() == DocumentMatchMethod::FILENAME) { + documentHash = KOReaderDocumentId::calculateFromFilename(epubPath); + } else { + documentHash = KOReaderDocumentId::calculate(epubPath); + } + } + performUpload(); + return; + } + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { // Calculate hash if not done yet if (documentHash.empty()) { diff --git a/src/activities/reader/KOReaderSyncActivity.h b/src/activities/reader/KOReaderSyncActivity.h index b3b82d01..39473198 100644 --- a/src/activities/reader/KOReaderSyncActivity.h +++ b/src/activities/reader/KOReaderSyncActivity.h @@ -40,7 +40,7 @@ class KOReaderSyncActivity final : public Activity { void onExit() override; void loop() override; void render(RenderLock&&) override; - bool preventAutoSleep() override { return state == CONNECTING || state == SYNCING; } + bool preventAutoSleep() override { return state == CONNECTING || state == SYNCING || state == UPLOADING; } private: enum State { @@ -50,6 +50,7 @@ class KOReaderSyncActivity final : public Activity { SHOWING_RESULT, UPLOADING, UPLOAD_COMPLETE, + SYNC_COMPLETE, NO_REMOTE_PROGRESS, SYNC_FAILED, NO_CREDENTIALS @@ -78,6 +79,10 @@ class KOReaderSyncActivity final : public Activity { // Selection in result screen (0=Apply, 1=Upload) int selectedOption = 0; + // Timed return for successful smart-sync terminal states. + unsigned long autoReturnAt = 0; + static constexpr unsigned long AUTO_RETURN_DELAY_MS = 1200; + // Tracks whether this session activated WiFi. Set in onEnter past the credentials // check; checked in onExit to decide whether to silent-reboot. Can't rely on // WiFi.getMode() because performUpload() calls esp_wifi_stop() on the way out, @@ -87,6 +92,9 @@ class KOReaderSyncActivity final : public Activity { void onWifiSelectionComplete(bool success); void performSync(); void performUpload(); + bool smartSyncEnabled() const; + void markAutoReturn(); + void completeAlreadySynced(); void ensureEpubLoaded(); void saveProgressAndReturn(int spineIndex, int page); void returnToReader(); diff --git a/src/activities/reader/QrDisplayActivity.cpp b/src/activities/reader/QrDisplayActivity.cpp index 2bcf330a..29cbdc18 100644 --- a/src/activities/reader/QrDisplayActivity.cpp +++ b/src/activities/reader/QrDisplayActivity.cpp @@ -16,8 +16,10 @@ void QrDisplayActivity::onEnter() { void QrDisplayActivity::onExit() { Activity::onExit(); } void QrDisplayActivity::loop() { + int x = 0; + int y = 0; if (mappedInput.wasReleased(MappedInputManager::Button::Back) || - mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + mappedInput.wasReleased(MappedInputManager::Button::Confirm) || mappedInput.wasScreenTapped(x, y)) { finish(); return; } diff --git a/src/activities/reader/ReaderUtils.h b/src/activities/reader/ReaderUtils.h index 8866635e..9ef55c20 100644 --- a/src/activities/reader/ReaderUtils.h +++ b/src/activities/reader/ReaderUtils.h @@ -2,18 +2,27 @@ #include #include +#include #include #include +#include #include "MappedInputManager.h" +#include "activities/ActivityManager.h" namespace ReaderUtils { constexpr unsigned long GO_HOME_MS = 1000; +constexpr unsigned long GO_BACK_OR_HOME_MS = GO_HOME_MS; constexpr unsigned long SKIP_HOLD_MS = 700; constexpr unsigned long BOOKMARK_HOLD_MS = 400; constexpr unsigned long BOOKMARK_MESSAGE_DURATION_MS = 2500; +enum ReaderTouchAction : freeink::ui::ActionId { + READER_TOUCH_PREV = 1, + READER_TOUCH_NEXT = 3, +}; + inline void applyOrientation(GfxRenderer& renderer, const uint8_t orientation) { switch (orientation) { case CrossPointSettings::ORIENTATION::PORTRAIT: @@ -59,6 +68,48 @@ inline PageTurnResult detectPageTurn(const MappedInputManager& input) { return {prev, next, tiltPrev || tiltNext}; } +struct TouchPageTurn { + bool prev; + bool next; + unsigned long heldMs; +}; + +inline TouchPageTurn detectTouchPageTurn(GfxRenderer& renderer, const MappedInputManager& input) { + TouchPageTurn result{false, false, 0}; + if (!SETTINGS.touchReaderControls || !input.hasTouch()) { + return result; + } + + int x = 0; + int y = 0; + if (!input.wasScreenTapped(x, y)) { + return result; + } + + const int16_t width = static_cast(renderer.getScreenWidth()); + const int16_t height = static_cast(renderer.getScreenHeight()); + const int16_t previousZoneWidth = width / 3; + const freeink::ui::TapZone zones[] = { + {freeink::ui::Rect{0, 0, previousZoneWidth, height}, READER_TOUCH_PREV}, + {freeink::ui::Rect{previousZoneWidth, 0, static_cast(width - previousZoneWidth), height}, + READER_TOUCH_NEXT}, + }; + + for (const auto& zone : zones) { + if (!zone.enabled || !zone.rect.contains(static_cast(x), static_cast(y))) continue; + result.prev = zone.action == READER_TOUCH_PREV; + result.next = zone.action == READER_TOUCH_NEXT; + break; + } + result.heldMs = gpio.lastTouchHeldMs(); + return result; +} + +// Reader menu opens on a downward swipe from the top edge (replaces the old center tap-and-hold). +inline bool isTouchMenuGesture(const MappedInputManager& input) { + return SETTINGS.touchReaderControls && input.hasTouch() && input.wasMenuGesture(); +} + // One helper, blocking or deferred: the async form starts the refresh and // returns so the caller can overlap CPU work with the panel's refresh time. // Async callers must not touch the framebuffer until @@ -105,4 +156,37 @@ void renderAntiAliased(GfxRenderer& renderer, RenderFn&& renderFn) { renderer.restoreBwBuffer(); } +struct BackNavCallback { + void* ctx; + void (*fn)(void*); +}; + +// Returns true if the back button was consumed (caller should return). +// Long press (>= GO_BACK_OR_HOME_MS): +// - default: go to file browser +// - with backShortToFileBrowser: go home +// Short press (< GO_BACK_OR_HOME_MS): +// - default: go home +// - with backShortToFileBrowser: go to file browser. +inline bool handleBackNavigation(const MappedInputManager& mappedInput, ActivityManager& activityManager, + const char* filePath, BackNavCallback goHome) { + if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= GO_BACK_OR_HOME_MS) { + if (SETTINGS.backShortToFileBrowser) { + goHome.fn(goHome.ctx); + } else { + activityManager.goToFileBrowser(filePath); + } + return true; + } + if (mappedInput.wasReleased(MappedInputManager::Button::Back) && mappedInput.getHeldTime() < GO_BACK_OR_HOME_MS) { + if (SETTINGS.backShortToFileBrowser) { + activityManager.goToFileBrowser(filePath); + } else { + goHome.fn(goHome.ctx); + } + return true; + } + return false; +} + } // namespace ReaderUtils diff --git a/src/activities/reader/TxtReaderActivity.cpp b/src/activities/reader/TxtReaderActivity.cpp index 0464c614..62c420f2 100644 --- a/src/activities/reader/TxtReaderActivity.cpp +++ b/src/activities/reader/TxtReaderActivity.cpp @@ -60,20 +60,15 @@ void TxtReaderActivity::onExit() { } void TxtReaderActivity::loop() { - // Long press BACK (1s+) goes to file selection - if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= ReaderUtils::GO_HOME_MS) { - activityManager.goToFileBrowser(txt ? txt->getPath() : ""); + if (ReaderUtils::handleBackNavigation(mappedInput, activityManager, txt ? txt->getPath().c_str() : "", + {this, [](void* ctx) { static_cast(ctx)->onGoHome(); }})) { return; } - // Short press BACK goes directly to home - if (mappedInput.wasReleased(MappedInputManager::Button::Back) && - mappedInput.getHeldTime() < ReaderUtils::GO_HOME_MS) { - onGoHome(); - return; - } - - const auto [prevTriggered, nextTriggered, fromTilt] = ReaderUtils::detectPageTurn(mappedInput); + const auto touch = ReaderUtils::detectTouchPageTurn(renderer, mappedInput); + auto [prevTriggered, nextTriggered, fromTilt] = ReaderUtils::detectPageTurn(mappedInput); + prevTriggered = prevTriggered || touch.prev; + nextTriggered = nextTriggered || touch.next; if (!prevTriggered && !nextTriggered) { return; } diff --git a/src/activities/reader/XtcReaderActivity.cpp b/src/activities/reader/XtcReaderActivity.cpp index 1d1923eb..78a702d7 100644 --- a/src/activities/reader/XtcReaderActivity.cpp +++ b/src/activities/reader/XtcReaderActivity.cpp @@ -69,6 +69,8 @@ void XtcReaderActivity::loop() { return; } + const auto touch = ReaderUtils::detectTouchPageTurn(renderer, mappedInput); + const bool atEndOfBook = currentPage >= xtc->getPageCount(); // While the end screen suggestion menu is showing it owns Confirm/Back/navigation @@ -97,24 +99,18 @@ void XtcReaderActivity::loop() { } // Enter chapter selection activity - if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm) || ReaderUtils::isTouchMenuGesture(mappedInput)) { openChapterSelection(); } - // Long press BACK (1s+) goes to file selection - if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= ReaderUtils::GO_HOME_MS) { - activityManager.goToFileBrowser(xtc ? xtc->getPath() : ""); + if (ReaderUtils::handleBackNavigation(mappedInput, activityManager, xtc ? xtc->getPath().c_str() : "", + {this, [](void* ctx) { static_cast(ctx)->onGoHome(); }})) { return; } - // Short press BACK goes directly to home - if (mappedInput.wasReleased(MappedInputManager::Button::Back) && - mappedInput.getHeldTime() < ReaderUtils::GO_HOME_MS) { - onGoHome(); - 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; } @@ -136,8 +132,9 @@ void XtcReaderActivity::loop() { return; } - const bool skipPages = !fromTilt && SETTINGS.longPressButtonBehavior == SETTINGS.CHAPTER_SKIP && - mappedInput.getHeldTime() > ReaderUtils::SKIP_HOLD_MS; + const unsigned long heldMs = (touch.prev || touch.next) ? touch.heldMs : mappedInput.getHeldTime(); + const bool skipPages = + !fromTilt && SETTINGS.longPressButtonBehavior == SETTINGS.CHAPTER_SKIP && heldMs > ReaderUtils::SKIP_HOLD_MS; const int skipAmount = skipPages ? 10 : 1; if (prevTriggered) { diff --git a/src/activities/reader/XtcReaderChapterSelectionActivity.cpp b/src/activities/reader/XtcReaderChapterSelectionActivity.cpp index 99d6b28d..605a1e1d 100644 --- a/src/activities/reader/XtcReaderChapterSelectionActivity.cpp +++ b/src/activities/reader/XtcReaderChapterSelectionActivity.cpp @@ -56,17 +56,63 @@ void XtcReaderChapterSelectionActivity::loop() { const int pageItems = getPageItems(); const int totalItems = static_cast(xtc->getChapters().size()); - 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& chapters = xtc->getChapters(); if (!chapters.empty() && selectorIndex >= 0 && selectorIndex < static_cast(chapters.size())) { setResult(PageResult{chapters[selectorIndex].startPage}); finish(); } - } else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { - ActivityResult result; - result.isCancelled = true; - setResult(std::move(result)); - finish(); + }; + + 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; + const int listTop = 60 + contentY; + int row = -1; + const auto touch = mappedInput.rowTouch(row, listTop, 30, pageItems, contentX, contentX + contentWidth); + if (touch != MappedInputManager::RowTouch::None) { + const int touched = selectorIndex / pageItems * pageItems + row; + if (touched >= 0 && touched < totalItems) { + if (touch == MappedInputManager::RowTouch::Down) { + if (selectorIndex != touched) { + selectorIndex = touched; + requestUpdate(); + } + } else { + selectorIndex = touched; + selectChapter(); + } + return; + } + } + + 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] { diff --git a/src/activities/settings/ClearCacheActivity.cpp b/src/activities/settings/ClearCacheActivity.cpp index b0dcbfbb..23ee1066 100644 --- a/src/activities/settings/ClearCacheActivity.cpp +++ b/src/activities/settings/ClearCacheActivity.cpp @@ -14,6 +14,14 @@ void ClearCacheActivity::onEnter() { Activity::onEnter(); state = WARNING; + const char* options[] = {tr(STR_CANCEL), tr(STR_CLEAR_BUTTON)}; + confirmPopup.show(tr(STR_CLEAR_READING_CACHE), options, 2, 0, [this](int idx) { + if (idx == 1) { + beginClear(); + } else { + goBack(); + } + }); requestUpdate(); } @@ -35,6 +43,8 @@ void ClearCacheActivity::render(RenderLock&&) { renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 10, tr(STR_CLEAR_CACHE_WARNING_3), true); renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 30, tr(STR_CLEAR_CACHE_WARNING_4), true); + if (confirmPopup.processRender(renderer, mappedInput)) return; + const auto labels = mappedInput.mapLabels(tr(STR_CANCEL), tr(STR_CLEAR_BUTTON), "", ""); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); renderer.displayBuffer(); @@ -73,6 +83,16 @@ void ClearCacheActivity::render(RenderLock&&) { } } +void ClearCacheActivity::beginClear() { + LOG_DBG("CLEAR_CACHE", "User confirmed, starting cache clear"); + { + RenderLock lock(*this); + state = CLEARING; + } + requestUpdateAndWait(); + clearCache(); +} + void ClearCacheActivity::clearCache() { LOG_DBG("CLEAR_CACHE", "Clearing cache..."); @@ -122,15 +142,10 @@ void ClearCacheActivity::clearCache() { void ClearCacheActivity::loop() { if (state == WARNING) { - if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { - LOG_DBG("CLEAR_CACHE", "User confirmed, starting cache clear"); - { - RenderLock lock(*this); - state = CLEARING; - } - requestUpdateAndWait(); + if (confirmPopup.handleInput(mappedInput, [this] { requestUpdate(); })) return; - clearCache(); + if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { + beginClear(); } if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { @@ -141,7 +156,9 @@ void ClearCacheActivity::loop() { } if (state == SUCCESS || state == FAILED) { - if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { + int x = 0; + int y = 0; + if (mappedInput.wasPressed(MappedInputManager::Button::Back) || mappedInput.wasScreenTapped(x, y)) { goBack(); } return; diff --git a/src/activities/settings/ClearCacheActivity.h b/src/activities/settings/ClearCacheActivity.h index 712f7a9c..fdb073e0 100644 --- a/src/activities/settings/ClearCacheActivity.h +++ b/src/activities/settings/ClearCacheActivity.h @@ -3,6 +3,7 @@ #include #include "activities/Activity.h" +#include "components/OptionPopup.h" class ClearCacheActivity final : public Activity { public: @@ -24,5 +25,7 @@ class ClearCacheActivity final : public Activity { int clearedCount = 0; int failedCount = 0; + OptionPopup confirmPopup; + void beginClear(); void clearCache(); }; diff --git a/src/activities/settings/ClockOffsetActivity.cpp b/src/activities/settings/ClockOffsetActivity.cpp index 2000dacf..614bbee4 100644 --- a/src/activities/settings/ClockOffsetActivity.cpp +++ b/src/activities/settings/ClockOffsetActivity.cpp @@ -18,6 +18,8 @@ constexpr uint8_t MAX_NEG_HOURS = 12; constexpr uint8_t MINUTE_STEPS = 4; // 0, 15, 30, 45 constexpr uint8_t MINUTES_PER_QUARTER = 15; constexpr uint8_t BIAS_QUARTER_HOURS = 48; // 0 stored = UTC-12, 48 stored = UTC+0 +constexpr int TOUCH_BUTTON_SIZE = 44; +constexpr int TOUCH_BUTTON_GAP = 18; // Convert a (sign, hours, quarter) triple into the biased storage value. // Returns a value in [0, 104]. @@ -43,6 +45,10 @@ void decodeOffset(uint8_t biased, uint8_t& sign, uint8_t& hours, uint8_t& quarte hours = static_cast(signedQuarter / 4); quarter = static_cast(signedQuarter % 4); } + +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 ClockOffsetActivity::onEnter() { @@ -108,6 +114,71 @@ void ClockOffsetActivity::adjustActiveField(int delta) { } } +bool ClockOffsetActivity::fieldFromPoint(const int x, const int y, Field& field) const { + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); + const int centreY = pageHeight / 2 - 40; + auto widthOf = [&](const char* s) { return renderer.getTextWidth(UI_12_FONT_ID, s, EpdFontFamily::BOLD); }; + constexpr int fieldPaddingX = 6; + constexpr int labelGap = 16; + constexpr int fieldGap = 12; + constexpr int colonGap = 5; + const int lineHeight = renderer.getLineHeight(UI_12_FONT_ID); + const int fieldHeight = lineHeight + 2; + + const int labelWidth = widthOf("UTC"); + const int signBoxW = std::max(widthOf("+"), widthOf("-")) + fieldPaddingX * 2; + const int hoursBoxW = std::max(widthOf("14"), widthOf("12")) + fieldPaddingX * 2; + const int colonWidth = widthOf(":"); + const int minutesBoxW = std::max({widthOf("00"), widthOf("15"), widthOf("30"), widthOf("45")}) + fieldPaddingX * 2; + const int totalWidth = + labelWidth + labelGap + signBoxW + fieldGap + hoursBoxW + colonGap + colonWidth + colonGap + minutesBoxW; + + int boxX = (pageWidth - totalWidth) / 2 + labelWidth + labelGap; + auto hit = [&](const int width) { + return x >= boxX && x < boxX + width && y >= centreY && y < centreY + fieldHeight; + }; + if (hit(signBoxW)) { + field = FIELD_SIGN; + return true; + } + boxX += signBoxW + fieldGap; + if (hit(hoursBoxW)) { + field = FIELD_HOURS; + return true; + } + boxX += hoursBoxW + colonGap + colonWidth + colonGap; + if (hit(minutesBoxW)) { + field = FIELD_MINUTES; + return true; + } + return false; +} + +void ClockOffsetActivity::getTouchControlRects(Rect& minusRect, Rect& plusRect) const { + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); + const int centreY = pageHeight / 2 - 40; + auto widthOf = [&](const char* s) { return renderer.getTextWidth(UI_12_FONT_ID, s, EpdFontFamily::BOLD); }; + constexpr int fieldPaddingX = 6; + constexpr int labelGap = 16; + constexpr int fieldGap = 12; + constexpr int colonGap = 5; + const int lineHeight = renderer.getLineHeight(UI_12_FONT_ID); + const int fieldHeight = lineHeight + 2; + const int labelWidth = widthOf("UTC"); + const int signBoxW = std::max(widthOf("+"), widthOf("-")) + fieldPaddingX * 2; + const int hoursBoxW = std::max(widthOf("14"), widthOf("12")) + fieldPaddingX * 2; + const int colonWidth = widthOf(":"); + const int minutesBoxW = std::max({widthOf("00"), widthOf("15"), widthOf("30"), widthOf("45")}) + fieldPaddingX * 2; + const int totalWidth = + labelWidth + labelGap + signBoxW + fieldGap + hoursBoxW + colonGap + colonWidth + colonGap + minutesBoxW; + const int offsetX = (pageWidth - totalWidth) / 2; + const int buttonY = centreY + (fieldHeight - TOUCH_BUTTON_SIZE) / 2; + minusRect = Rect{offsetX - TOUCH_BUTTON_GAP - TOUCH_BUTTON_SIZE, buttonY, TOUCH_BUTTON_SIZE, TOUCH_BUTTON_SIZE}; + plusRect = Rect{offsetX + totalWidth + TOUCH_BUTTON_GAP, buttonY, TOUCH_BUTTON_SIZE, TOUCH_BUTTON_SIZE}; +} + void ClockOffsetActivity::loop() { if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { finish(); @@ -120,6 +191,53 @@ void ClockOffsetActivity::loop() { return; } + if (mappedInput.hasTouch()) { + int tx = 0; + int ty = 0; + Rect minusRect; + Rect plusRect; + getTouchControlRects(minusRect, plusRect); + + if (mappedInput.wasScreenTouchDown(tx, ty)) { + if (contains(minusRect, tx, ty) || contains(plusRect, tx, ty)) { + return; + } + Field touchedField = FIELD_HOURS; + if (fieldFromPoint(tx, ty, touchedField)) { + if (activeField != touchedField) { + activeField = touchedField; + requestUpdate(); + } + return; + } + } + + if (mappedInput.wasScreenTapped(tx, ty)) { + if (contains(minusRect, tx, ty)) { + adjustActiveField(-1); + requestUpdate(); + return; + } + if (contains(plusRect, tx, ty)) { + adjustActiveField(+1); + requestUpdate(); + return; + } + + Field touchedField = FIELD_HOURS; + if (fieldFromPoint(tx, ty, touchedField)) { + if (touchedField == FIELD_SIGN) { + activeField = FIELD_SIGN; + adjustActiveField(+1); + } else { + activeField = touchedField; + } + requestUpdate(); + return; + } + } + } + buttonNavigator.onNextRelease([this] { adjustActiveField(+1); requestUpdate(); @@ -196,6 +314,21 @@ void ClockOffsetActivity::render(RenderLock&&) { drawField(minutesStr, x, minutesBoxW, FIELD_MINUTES); + if (mappedInput.hasTouch()) { + Rect minusRect; + Rect plusRect; + getTouchControlRects(minusRect, plusRect); + auto drawTouchButton = [&](const Rect& rect, const char* label) { + renderer.fillRectDither(rect.x, rect.y, rect.width, rect.height, Color::White); + renderer.drawRect(rect.x, rect.y, rect.width, rect.height, true); + const int textX = rect.x + (rect.width - widthOf(label)) / 2; + const int textY = rect.y + (rect.height - lineHeight) / 2; + renderer.drawText(UI_12_FONT_ID, textX, textY, label, true, EpdFontFamily::BOLD); + }; + drawTouchButton(minusRect, "-"); + drawTouchButton(plusRect, "+"); + } + // Live preview of the resulting wall-clock time, so users can verify against a watch. if (halClock.isAvailable()) { char timeBuf[9]; diff --git a/src/activities/settings/ClockOffsetActivity.h b/src/activities/settings/ClockOffsetActivity.h index 8f25ee4c..5e569a30 100644 --- a/src/activities/settings/ClockOffsetActivity.h +++ b/src/activities/settings/ClockOffsetActivity.h @@ -1,8 +1,12 @@ #pragma once +#include + #include "activities/Activity.h" #include "util/ButtonNavigator.h" +struct Rect; + // Dedicated UTC offset picker for the status bar clock. // Three editable fields (sign, hours, minutes); Confirm cycles fields, Up/Down adjust the active one. // Supports the full IANA UTC offset range in 15 minute steps, including oddball zones like Nepal (+5:45). @@ -34,4 +38,6 @@ class ClockOffsetActivity final : public Activity { void saveToSettings() const; void adjustActiveField(int delta); void clampForSign(); + bool fieldFromPoint(int x, int y, Field& field) const; + void getTouchControlRects(Rect& minusRect, Rect& plusRect) const; }; diff --git a/src/activities/settings/ClockSyncActivity.cpp b/src/activities/settings/ClockSyncActivity.cpp index 496ecde6..377e9396 100644 --- a/src/activities/settings/ClockSyncActivity.cpp +++ b/src/activities/settings/ClockSyncActivity.cpp @@ -93,7 +93,9 @@ void ClockSyncActivity::loop() { return; } - if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { + int x = 0; + int y = 0; + if (mappedInput.wasPressed(MappedInputManager::Button::Back) || mappedInput.wasScreenTapped(x, y)) { finish(); } } diff --git a/src/activities/settings/FontDownloadActivity.cpp b/src/activities/settings/FontDownloadActivity.cpp index 0ccae794..380bbcfa 100644 --- a/src/activities/settings/FontDownloadActivity.cpp +++ b/src/activities/settings/FontDownloadActivity.cpp @@ -424,6 +424,36 @@ bool FontDownloadActivity::isSelectedFamilyDeletable() const { void FontDownloadActivity::loop() { if (state_ == FAMILY_LIST) { + auto activateSelected = [this] { + if (families_.empty()) return; + if (isDownloadAllRow(selectedIndex_)) { + currentFileIndex_ = 0; + currentFileTotal_ = 0; + for (const auto& f : families_) { + if (!f.installed) currentFileTotal_ += f.files.size(); + } + downloadAll(); + } else if (isUpdateAllRow(selectedIndex_)) { + currentFileIndex_ = 0; + currentFileTotal_ = 0; + for (const auto& f : families_) { + if (f.hasUpdate) currentFileTotal_ += f.files.size(); + } + updateAll(); + } else { + auto& family = families_[familyIndexFromList(selectedIndex_)]; + if (!family.installed || family.hasUpdate) { + currentFileIndex_ = 0; + currentFileTotal_ = family.files.size(); + downloadFamily(family); + } else { + promptDeleteSelectedFamily(); + return; + } + } + requestUpdateAndWait(); + }; + if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { finish(); return; @@ -432,6 +462,34 @@ void FontDownloadActivity::loop() { const int listSize = listItemCount(); const int pageItems = UITheme::getNumberOfItemsPerPage(renderer, true, false, true, false); + if (!families_.empty()) { + 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; + switch (handleListTouch(selectedIndex_, listSize, contentTop, contentHeight, true)) { + case ListTouchResult::Activated: + activateSelected(); + return; + case ListTouchResult::Consumed: + return; + case ListTouchResult::None: + break; + } + + const auto swipe = mappedInput.wasSwipe(); + if (swipe == MappedInputManager::SwipeDir::Up) { + selectedIndex_ = ButtonNavigator::nextPageIndex(selectedIndex_, listSize, pageItems); + requestUpdate(); + return; + } + if (swipe == MappedInputManager::SwipeDir::Down) { + selectedIndex_ = ButtonNavigator::previousPageIndex(selectedIndex_, listSize, pageItems); + requestUpdate(); + return; + } + } + buttonNavigator_.onNextRelease([this, listSize] { selectedIndex_ = ButtonNavigator::nextIndex(selectedIndex_, listSize); requestUpdate(); @@ -453,40 +511,14 @@ void FontDownloadActivity::loop() { }); if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { - if (!families_.empty()) { - if (isDownloadAllRow(selectedIndex_)) { - currentFileIndex_ = 0; - currentFileTotal_ = 0; - for (const auto& f : families_) { - if (!f.installed) currentFileTotal_ += f.files.size(); - } - - downloadAll(); - } else if (isUpdateAllRow(selectedIndex_)) { - currentFileIndex_ = 0; - currentFileTotal_ = 0; - for (const auto& f : families_) { - if (f.hasUpdate) currentFileTotal_ += f.files.size(); - } - updateAll(); - } else { - auto& family = families_[familyIndexFromList(selectedIndex_)]; - if (!family.installed || family.hasUpdate) { - currentFileIndex_ = 0; - currentFileTotal_ = family.files.size(); - downloadFamily(family); - } else { - promptDeleteSelectedFamily(); - return; - } - } - requestUpdateAndWait(); - return; - } + activateSelected(); + return; } } else if (state_ == COMPLETE) { + int x = 0; + int y = 0; if (mappedInput.wasPressed(MappedInputManager::Button::Back) || - mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { + mappedInput.wasPressed(MappedInputManager::Button::Confirm) || mappedInput.wasScreenTapped(x, y)) { { RenderLock lock(*this); state_ = FAMILY_LIST; @@ -512,6 +544,21 @@ void FontDownloadActivity::loop() { } requestUpdate(); } + } else { + int x = 0; + int y = 0; + if (mappedInput.wasScreenTapped(x, y)) { + if (downloadingFamilyIndex_ >= 0 && downloadingFamilyIndex_ < static_cast(families_.size())) { + downloadFamily(families_[downloadingFamilyIndex_]); + requestUpdateAndWait(); + return; + } + { + RenderLock lock(*this); + state_ = FAMILY_LIST; + } + requestUpdate(); + } } } } diff --git a/src/activities/settings/FontSelectionActivity.cpp b/src/activities/settings/FontSelectionActivity.cpp index ca53f3a8..d3e6a341 100644 --- a/src/activities/settings/FontSelectionActivity.cpp +++ b/src/activities/settings/FontSelectionActivity.cpp @@ -71,16 +71,7 @@ void FontSelectionActivity::onEnter() { void FontSelectionActivity::onExit() { Activity::onExit(); } void FontSelectionActivity::loop() { - if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { - SETTINGS.fontFamily = originalFontFamily_; - strncpy(SETTINGS.sdFontFamilyName, originalSdFontFamilyName_, sizeof(SETTINGS.sdFontFamilyName) - 1); - SETTINGS.sdFontFamilyName[sizeof(SETTINGS.sdFontFamilyName) - 1] = '\0'; - sdFontSystem.ensureLoaded(renderer); - finish(); - return; - } - - if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { + auto activateSelected = [this] { if (selectedIndex_ == previewFontIndex_) { handleSelection(); } else { @@ -100,12 +91,48 @@ void FontSelectionActivity::loop() { } requestUpdate(); } + }; + + if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { + SETTINGS.fontFamily = originalFontFamily_; + strncpy(SETTINGS.sdFontFamilyName, originalSdFontFamilyName_, sizeof(SETTINGS.sdFontFamilyName) - 1); + SETTINGS.sdFontFamilyName[sizeof(SETTINGS.sdFontFamilyName) - 1] = '\0'; + sdFontSystem.ensureLoaded(renderer); + finish(); + return; + } + + if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { + activateSelected(); return; } const int listSize = static_cast(fonts_.size()); const int pageItems = UITheme::getNumberOfItemsPerPage(renderer, true, false, true, false, previewHeight + metrics_.verticalSpacing); + const int listTop = afterHeader + previewHeight + metrics_.verticalSpacing; + const int listHeight = usableHeight - previewHeight - metrics_.verticalSpacing; + switch (handleListTouch(selectedIndex_, listSize, listTop, listHeight, false)) { + case ListTouchResult::Activated: + activateSelected(); + return; + case ListTouchResult::Consumed: + return; + case ListTouchResult::None: + break; + } + + const auto swipe = mappedInput.wasSwipe(); + if (swipe == MappedInputManager::SwipeDir::Up) { + selectedIndex_ = ButtonNavigator::nextPageIndex(selectedIndex_, listSize, pageItems); + requestUpdate(); + return; + } + if (swipe == MappedInputManager::SwipeDir::Down) { + selectedIndex_ = ButtonNavigator::previousPageIndex(selectedIndex_, listSize, pageItems); + requestUpdate(); + return; + } buttonNavigator_.onNextRelease([this, listSize] { selectedIndex_ = ButtonNavigator::nextIndex(selectedIndex_, listSize); diff --git a/src/activities/settings/KOReaderAuthActivity.cpp b/src/activities/settings/KOReaderAuthActivity.cpp index 72921c16..90dd9451 100644 --- a/src/activities/settings/KOReaderAuthActivity.cpp +++ b/src/activities/settings/KOReaderAuthActivity.cpp @@ -26,7 +26,7 @@ void KOReaderAuthActivity::onWifiSelectionComplete(const bool success) { { RenderLock lock(*this); state = AUTHENTICATING; - statusMessage = tr(STR_AUTHENTICATING); + statusMessage = mode == Mode::SIGN_UP ? tr(STR_CREATING_ACCOUNT) : tr(STR_AUTHENTICATING); } requestUpdate(); @@ -34,16 +34,17 @@ void KOReaderAuthActivity::onWifiSelectionComplete(const bool success) { } void KOReaderAuthActivity::performAuthentication() { - const auto result = KOReaderSyncClient::authenticate(); + const auto result = mode == Mode::SIGN_UP ? KOReaderSyncClient::createUser() : KOReaderSyncClient::authenticate(); { RenderLock lock(*this); if (result == KOReaderSyncClient::OK) { state = SUCCESS; - statusMessage = tr(STR_AUTH_SUCCESS); + statusMessage = mode == Mode::SIGN_UP ? tr(STR_ACCOUNT_CREATED) : tr(STR_AUTH_SUCCESS); } else { state = FAILED; - errorMessage = KOReaderSyncClient::errorString(result); + errorMessage = + result == KOReaderSyncClient::USER_EXISTS ? tr(STR_USERNAME_TAKEN) : KOReaderSyncClient::errorString(result); } } requestUpdate(); @@ -80,17 +81,21 @@ void KOReaderAuthActivity::render(RenderLock&&) { const auto pageWidth = renderer.getScreenWidth(); const auto pageHeight = renderer.getScreenHeight(); - GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, tr(STR_KOREADER_AUTH)); + GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, + mode == Mode::SIGN_UP ? tr(STR_SIGN_UP) : tr(STR_KOREADER_AUTH)); const auto height = renderer.getLineHeight(UI_10_FONT_ID); const auto top = (pageHeight - height) / 2; if (state == AUTHENTICATING) { renderer.drawCenteredText(UI_10_FONT_ID, top, statusMessage.c_str()); } else if (state == SUCCESS) { - renderer.drawCenteredText(UI_10_FONT_ID, top, tr(STR_AUTH_SUCCESS), true, EpdFontFamily::BOLD); + renderer.drawCenteredText(UI_10_FONT_ID, top, + mode == Mode::SIGN_UP ? tr(STR_ACCOUNT_CREATED) : tr(STR_AUTH_SUCCESS), true, + EpdFontFamily::BOLD); renderer.drawCenteredText(UI_10_FONT_ID, top + height + 10, tr(STR_SYNC_READY)); } else if (state == FAILED) { - renderer.drawCenteredText(UI_10_FONT_ID, top, tr(STR_AUTH_FAILED), true, EpdFontFamily::BOLD); + renderer.drawCenteredText(UI_10_FONT_ID, top, mode == Mode::SIGN_UP ? tr(STR_SIGNUP_FAILED) : tr(STR_AUTH_FAILED), + true, EpdFontFamily::BOLD); renderer.drawCenteredText(UI_10_FONT_ID, top + height + 10, errorMessage.c_str()); } @@ -101,8 +106,10 @@ void KOReaderAuthActivity::render(RenderLock&&) { void KOReaderAuthActivity::loop() { if (state == SUCCESS || state == FAILED) { + int x = 0; + int y = 0; if (mappedInput.wasPressed(MappedInputManager::Button::Back) || - mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { + mappedInput.wasPressed(MappedInputManager::Button::Confirm) || mappedInput.wasScreenTapped(x, y)) { finish(); } } diff --git a/src/activities/settings/KOReaderAuthActivity.h b/src/activities/settings/KOReaderAuthActivity.h index b4e330e7..250eed5d 100644 --- a/src/activities/settings/KOReaderAuthActivity.h +++ b/src/activities/settings/KOReaderAuthActivity.h @@ -5,13 +5,16 @@ #include "activities/Activity.h" /** - * Activity for testing KOReader credentials. - * Connects to WiFi and authenticates with the KOReader sync server. + * Activity for testing KOReader credentials, or — in sign-up mode — creating a + * new account on the sync server with the entered username/password. + * Connects to WiFi, then authenticates or registers. */ class KOReaderAuthActivity final : public Activity { public: - explicit KOReaderAuthActivity(GfxRenderer& renderer, MappedInputManager& mappedInput) - : Activity("KOReaderAuth", renderer, mappedInput) {} + enum class Mode { AUTHENTICATE, SIGN_UP }; + + explicit KOReaderAuthActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, Mode mode = Mode::AUTHENTICATE) + : Activity("KOReaderAuth", renderer, mappedInput), mode(mode) {} void onEnter() override; void onExit() override; @@ -22,6 +25,7 @@ class KOReaderAuthActivity final : public Activity { private: enum State { WIFI_SELECTION, CONNECTING, AUTHENTICATING, SUCCESS, FAILED }; + Mode mode = Mode::AUTHENTICATE; State state = WIFI_SELECTION; std::string statusMessage; std::string errorMessage; diff --git a/src/activities/settings/KOReaderSettingsActivity.cpp b/src/activities/settings/KOReaderSettingsActivity.cpp index 48b14956..2f6fe0c9 100644 --- a/src/activities/settings/KOReaderSettingsActivity.cpp +++ b/src/activities/settings/KOReaderSettingsActivity.cpp @@ -13,9 +13,10 @@ #include "fontIds.h" namespace { -constexpr int MENU_ITEMS = 6; +constexpr int MENU_ITEMS = 8; const StrId menuNames[MENU_ITEMS] = {StrId::STR_USERNAME, StrId::STR_PASSWORD, StrId::STR_SYNC_SERVER_URL, - StrId::STR_DOCUMENT_MATCHING, StrId::STR_SEND_METADATA, StrId::STR_AUTHENTICATE}; + StrId::STR_DOCUMENT_MATCHING, StrId::STR_SEND_METADATA, StrId::STR_SYNC_BEHAVIOR, + StrId::STR_SIGN_UP, StrId::STR_AUTHENTICATE}; } // namespace void KOReaderSettingsActivity::onEnter() { @@ -28,13 +29,27 @@ void KOReaderSettingsActivity::onEnter() { void KOReaderSettingsActivity::onExit() { Activity::onExit(); } void KOReaderSettingsActivity::loop() { + auto activateSelected = [this] { handleSelection(); }; + if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { finish(); return; } if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { - handleSelection(); + activateSelected(); + 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; + int touchSel = static_cast(selectedIndex); + const auto listTouch = handleListTouch(touchSel, MENU_ITEMS, contentTop, contentHeight, false); + if (listTouch != ListTouchResult::None) { + selectedIndex = static_cast(touchSel); + if (listTouch == ListTouchResult::Activated) activateSelected(); return; } @@ -103,6 +118,22 @@ void KOReaderSettingsActivity::handleSelection() { KOREADER_STORE.saveToFile(); requestUpdate(); } else if (selectedIndex == 5) { + // Sync behavior - toggle between Ask and Smart + const auto current = KOREADER_STORE.getSyncBehavior(); + const auto newBehavior = (current == KOReaderSyncBehavior::ASK_EVERY_TIME) ? KOReaderSyncBehavior::SMART + : KOReaderSyncBehavior::ASK_EVERY_TIME; + KOREADER_STORE.setSyncBehavior(newBehavior); + KOREADER_STORE.saveToFile(); + requestUpdate(); + } else if (selectedIndex == 6) { + // Sign Up - create a new account on the sync server with the entered credentials + if (!KOREADER_STORE.hasCredentials()) { + return; + } + startActivityForResult( + std::make_unique(renderer, mappedInput, KOReaderAuthActivity::Mode::SIGN_UP), + [](const ActivityResult&) {}); + } else if (selectedIndex == 7) { // Authenticate if (!KOREADER_STORE.hasCredentials()) { // Can't authenticate without credentials - just show message briefly @@ -136,13 +167,25 @@ void KOReaderSettingsActivity::render(RenderLock&&) { return KOREADER_STORE.getPassword().empty() ? std::string(tr(STR_NOT_SET)) : std::string("******"); } else if (index == 2) { auto serverUrl = KOREADER_STORE.getServerUrl(); - return serverUrl.empty() ? std::string(tr(STR_DEFAULT_VALUE)) : serverUrl; + if (!serverUrl.empty()) { + return serverUrl; + } + // Show which server the default actually is, scheme stripped for space + std::string defaultUrl = KOREADER_STORE.getBaseUrl(); + const auto schemeEnd = defaultUrl.find("://"); + if (schemeEnd != std::string::npos) { + defaultUrl.erase(0, schemeEnd + 3); + } + return std::string(tr(STR_DEFAULT_VALUE)) + ": " + defaultUrl; } else if (index == 3) { return KOREADER_STORE.getMatchMethod() == DocumentMatchMethod::FILENAME ? std::string(tr(STR_FILENAME)) : std::string(tr(STR_BINARY)); } else if (index == 4) { return KOREADER_STORE.getSendMetadata() ? std::string(tr(STR_STATE_ON)) : std::string(tr(STR_STATE_OFF)); } else if (index == 5) { + return KOREADER_STORE.getSyncBehavior() == KOReaderSyncBehavior::SMART ? std::string(tr(STR_SMART_SYNC)) + : std::string(tr(STR_ASK_EVERY_TIME)); + } else if (index == 6 || index == 7) { return KOREADER_STORE.hasCredentials() ? "" : std::string("[") + tr(STR_SET_CREDENTIALS_FIRST) + "]"; } return std::string(tr(STR_NOT_SET)); diff --git a/src/activities/settings/LanguageSelectActivity.cpp b/src/activities/settings/LanguageSelectActivity.cpp index 231cba52..2837da9b 100644 --- a/src/activities/settings/LanguageSelectActivity.cpp +++ b/src/activities/settings/LanguageSelectActivity.cpp @@ -27,17 +27,44 @@ void LanguageSelectActivity::onEnter() { void LanguageSelectActivity::onExit() { Activity::onExit(); } void LanguageSelectActivity::loop() { + auto activateSelected = [this] { handleSelection(); }; + if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { onBack(); return; } if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { - handleSelection(); + activateSelected(); 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; + switch (handleListTouch(selectedIndex, totalItems, contentTop, contentHeight, false)) { + case ListTouchResult::Activated: + activateSelected(); + return; + case ListTouchResult::Consumed: + return; + case ListTouchResult::None: + break; + } + const int pageItems = UITheme::getNumberOfItemsPerPage(renderer, true, false, true, false); + const auto swipe = mappedInput.wasSwipe(); + if (swipe == MappedInputManager::SwipeDir::Up) { + selectedIndex = ButtonNavigator::nextPageIndex(static_cast(selectedIndex), totalItems, pageItems); + requestUpdate(); + return; + } + if (swipe == MappedInputManager::SwipeDir::Down) { + selectedIndex = ButtonNavigator::previousPageIndex(static_cast(selectedIndex), totalItems, pageItems); + requestUpdate(); + return; + } // Handle navigation buttonNavigator.onNextRelease([this] { diff --git a/src/activities/settings/OpdsServerListActivity.cpp b/src/activities/settings/OpdsServerListActivity.cpp index 30557703..9d52df9e 100644 --- a/src/activities/settings/OpdsServerListActivity.cpp +++ b/src/activities/settings/OpdsServerListActivity.cpp @@ -3,19 +3,51 @@ #include #include +#include + +#include "CrossPointSettings.h" #include "MappedInputManager.h" #include "OpdsServerStore.h" #include "OpdsSettingsActivity.h" #include "activities/ActivityManager.h" #include "activities/browser/OpdsBookBrowserActivity.h" +#include "activities/util/KeyboardEntryActivity.h" #include "components/UITheme.h" #include "fontIds.h" +#include "util/OpdsFilename.h" + +namespace { +// Normalizes a user-typed folder: trims spaces, "" => SD root, otherwise a +// single leading '/' and no trailing '/'. Cold path (runs once per edit). +std::string normalizeFolder(std::string v) { + while (!v.empty() && (v.front() == ' ' || v.front() == '\t')) v.erase(v.begin()); + while (!v.empty() && (v.back() == ' ' || v.back() == '\t')) v.pop_back(); + if (v.empty()) return ""; + if (v.front() != '/') v.insert(v.begin(), '/'); + while (v.size() > 1 && v.back() == '/') v.pop_back(); + if (v == "/") return ""; // a bare slash is SD root, same as empty + return v; +} + +// Label shown for the current OPDS filename format in the list subtitle. +StrId opdsFormatLabel(uint8_t format) { + switch (format) { + case static_cast(OpdsFilenameFormat::TitleAuthor): + return StrId::STR_FMT_TITLE_AUTHOR; + case static_cast(OpdsFilenameFormat::TitleOnly): + return StrId::STR_FMT_TITLE; + default: + return StrId::STR_FMT_AUTHOR_TITLE; + } +} +} // namespace int OpdsServerListActivity::getItemCount() const { int count = static_cast(OPDS_STORE.getCount()); - // In settings mode, append a virtual "Add Server" item; in picker mode, only show real servers + // Settings mode appends three virtual items: "Add Server", "Download folder" + // and "Filename format". if (!pickerMode) { - count++; + count += 3; } return count; } @@ -32,6 +64,8 @@ void OpdsServerListActivity::onEnter() { void OpdsServerListActivity::onExit() { Activity::onExit(); } void OpdsServerListActivity::loop() { + auto activateSelected = [this] { handleSelection(); }; + if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { if (pickerMode) { activityManager.goHome(HomeMenuItem::OPDS_BROWSER); @@ -42,12 +76,39 @@ void OpdsServerListActivity::loop() { } if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { - handleSelection(); + activateSelected(); return; } const int itemCount = getItemCount(); if (itemCount > 0) { + 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, itemCount, contentTop, contentHeight, true)) { + case ListTouchResult::Activated: + activateSelected(); + return; + case ListTouchResult::Consumed: + return; + case ListTouchResult::None: + break; + } + + const int pageItems = GUI.getListPageItems(contentHeight, true); + const auto swipe = mappedInput.wasSwipe(); + if (swipe == MappedInputManager::SwipeDir::Up) { + selectedIndex = ButtonNavigator::nextPageIndex(selectedIndex, itemCount, pageItems); + requestUpdate(); + return; + } + if (swipe == MappedInputManager::SwipeDir::Down) { + selectedIndex = ButtonNavigator::previousPageIndex(selectedIndex, itemCount, pageItems); + requestUpdate(); + return; + } + buttonNavigator.onNext([this, itemCount] { selectedIndex = ButtonNavigator::nextIndex(selectedIndex, itemCount); requestUpdate(); @@ -74,6 +135,34 @@ void OpdsServerListActivity::handleSelection() { return; } + // Index layout: [servers 0..serverCount-1], [Add Server], [Download folder], [Filename format]. + if (selectedIndex == serverCount + 1) { + auto folderHandler = [this](const ActivityResult& result) { + if (!result.isCancelled) { + const auto& kb = std::get(result.data); + const std::string norm = normalizeFolder(kb.text); + strncpy(SETTINGS.opdsDownloadFolder, norm.c_str(), sizeof(SETTINGS.opdsDownloadFolder) - 1); + SETTINGS.opdsDownloadFolder[sizeof(SETTINGS.opdsDownloadFolder) - 1] = '\0'; + SETTINGS.saveToFile(); + requestUpdate(); + } + }; + startActivityForResult( + std::make_unique(renderer, mappedInput, tr(STR_OPDS_DOWNLOAD_FOLDER), + std::string(SETTINGS.opdsDownloadFolder), 63, InputType::Text), + folderHandler); + return; + } + + // "Filename format": tap cycles through the available formats. + if (selectedIndex == serverCount + 2) { + SETTINGS.opdsFilenameFormat = + static_cast((SETTINGS.opdsFilenameFormat + 1) % static_cast(OpdsFilenameFormat::Count)); + SETTINGS.saveToFile(); + requestUpdate(); + return; + } + // Settings mode: open editor for selected server, or create a new one auto resultHandler = [this](const ActivityResult&) { // Reload server list when returning from editor @@ -111,17 +200,30 @@ void OpdsServerListActivity::render(RenderLock&&) { // Secondary label: server URL (shown as subtitle when name is set). GUI.drawList( renderer, Rect{0, contentTop, pageWidth, contentHeight}, itemCount, selectedIndex, - [&servers, serverCount](int index) { + [&servers, serverCount](int index) -> std::string { if (index < serverCount) { const auto& server = servers[index]; return server.name.empty() ? server.url : server.name; } - return std::string(I18n::getInstance().get(StrId::STR_ADD_SERVER)); + if (index == serverCount) { + return std::string(I18n::getInstance().get(StrId::STR_ADD_SERVER)); + } + if (index == serverCount + 1) { + return std::string(I18n::getInstance().get(StrId::STR_OPDS_DOWNLOAD_FOLDER)); + } + return std::string(I18n::getInstance().get(StrId::STR_OPDS_FILENAME_FORMAT)); }, - [&servers, serverCount](int index) { + [&servers, serverCount](int index) -> std::string { if (index < serverCount && !servers[index].name.empty()) { return servers[index].url; } + if (index == serverCount + 1) { + const char* f = SETTINGS.opdsDownloadFolder; + return f[0] ? std::string(f) : std::string(I18n::getInstance().get(StrId::STR_OPDS_SD_ROOT)); + } + if (index == serverCount + 2) { + return std::string(I18n::getInstance().get(opdsFormatLabel(SETTINGS.opdsFilenameFormat))); + } return std::string(""); }); } diff --git a/src/activities/settings/OpdsSettingsActivity.cpp b/src/activities/settings/OpdsSettingsActivity.cpp index ec07c1f9..f2b0772e 100644 --- a/src/activities/settings/OpdsSettingsActivity.cpp +++ b/src/activities/settings/OpdsSettingsActivity.cpp @@ -48,6 +48,20 @@ void OpdsSettingsActivity::onEnter() { void OpdsSettingsActivity::onExit() { Activity::onExit(); } void OpdsSettingsActivity::loop() { + const auto& metrics = UITheme::getInstance().getMetrics(); + const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing + metrics.tabBarHeight; + const int contentHeight = + renderer.getScreenHeight() - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing * 2; + const int menuItems = getMenuItemCount(); + + int touchSel = static_cast(selectedIndex); + const auto listTouch = handleListTouch(touchSel, menuItems, contentTop, contentHeight, false); + if (listTouch != ListTouchResult::None) { + selectedIndex = static_cast(touchSel); + if (listTouch == ListTouchResult::Activated) handleSelection(); + return; + } + if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { finish(); return; @@ -58,7 +72,6 @@ void OpdsSettingsActivity::loop() { return; } - const int menuItems = getMenuItemCount(); buttonNavigator.onNext([this, menuItems] { selectedIndex = (selectedIndex + 1) % menuItems; requestUpdate(); diff --git a/src/activities/settings/OtaUpdateActivity.cpp b/src/activities/settings/OtaUpdateActivity.cpp index 905e1933..18c3dccf 100644 --- a/src/activities/settings/OtaUpdateActivity.cpp +++ b/src/activities/settings/OtaUpdateActivity.cpp @@ -11,6 +11,23 @@ #include "fontIds.h" #include "network/OtaUpdater.h" +namespace { +struct OtaActionRects { + Rect cancel; + Rect update; +}; + +OtaActionRects getOtaActionRects(const GfxRenderer& renderer) { + const int top = renderer.getScreenHeight() - 80; + const int width = renderer.getScreenWidth() / 2; + return {Rect{0, top, width, 80}, Rect{width, top, renderer.getScreenWidth() - width, 80}}; +} + +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 OtaUpdateActivity::onWifiSelectionComplete(const bool success) { if (!success) { LOG_ERR("OTA", "WiFi connection failed, exiting"); @@ -109,6 +126,14 @@ void OtaUpdateActivity::render(RenderLock&&) { renderer.drawText(UI_10_FONT_ID, metrics.contentSidePadding, top + height * 2 + metrics.verticalSpacing * 2, (std::string(tr(STR_NEW_VERSION)) + updater.getLatestVersion()).c_str()); + const auto actionRects = getOtaActionRects(renderer); + const int cancelTextWidth = renderer.getTextWidth(UI_10_FONT_ID, tr(STR_CANCEL)); + renderer.drawText(UI_10_FONT_ID, actionRects.cancel.x + (actionRects.cancel.width - cancelTextWidth) / 2, + actionRects.cancel.y + 28, tr(STR_CANCEL)); + const int updateTextWidth = renderer.getTextWidth(UI_10_FONT_ID, tr(STR_UPDATE)); + renderer.drawText(UI_10_FONT_ID, actionRects.update.x + (actionRects.update.width - updateTextWidth) / 2, + actionRects.update.y + 28, tr(STR_UPDATE)); + const auto labels = mappedInput.mapLabels(tr(STR_CANCEL), tr(STR_UPDATE), "", ""); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); } else if (state == UPDATE_IN_PROGRESS) { @@ -143,45 +168,64 @@ void OtaUpdateActivity::render(RenderLock&&) { renderer.displayBuffer(); } +void OtaUpdateActivity::runUpdateInstall() { + LOG_DBG("OTA", "New update available, starting download..."); + { + RenderLock lock(*this); + state = UPDATE_IN_PROGRESS; + } + requestUpdateAndWait(); + const auto res = updater.installUpdate( + [](void* ctx) { + // immediate=true notifies the render task directly. The default deferred path only + // sets a flag consumed at the end of ActivityManager::loop(), which never runs while + // installUpdate() blocks this task. + static_cast(ctx)->requestUpdate(true); + }, + this); + + if (res != OtaUpdater::OK) { + LOG_DBG("OTA", "Update failed: %d", res); + { + RenderLock lock(*this); + state = FAILED; + } + requestUpdate(); + return; + } + + { + RenderLock lock(*this); + state = FINISHED; + } + requestUpdateAndWait(); + // Hold the completion screen briefly so the user sees it, then restart. + delay(3000); + { + RenderLock lock(*this); + state = SHUTTING_DOWN; + } +} + void OtaUpdateActivity::loop() { if (state == WAITING_CONFIRMATION) { - if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { - LOG_DBG("OTA", "New update available, starting download..."); - { - RenderLock lock(*this); - state = UPDATE_IN_PROGRESS; - } - requestUpdateAndWait(); - const auto res = updater.installUpdate( - [](void* ctx) { - // immediate=true notifies the render task directly. The default deferred path only - // sets a flag consumed at the end of ActivityManager::loop(), which never runs while - // installUpdate() blocks this task. - static_cast(ctx)->requestUpdate(true); - }, - this); - - if (res != OtaUpdater::OK) { - LOG_DBG("OTA", "Update failed: %d", res); - { - RenderLock lock(*this); - state = FAILED; - } - requestUpdate(); + int x = 0; + int y = 0; + if (mappedInput.wasScreenTapped(x, y)) { + const auto actionRects = getOtaActionRects(renderer); + if (contains(actionRects.cancel, x, y)) { + finish(); return; } + if (contains(actionRects.update, x, y)) { + runUpdateInstall(); + return; + } + } - { - RenderLock lock(*this); - state = FINISHED; - } - requestUpdateAndWait(); - // Hold the completion screen briefly so the user sees it, then restart. - delay(3000); - { - RenderLock lock(*this); - state = SHUTTING_DOWN; - } + if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { + runUpdateInstall(); + return; } if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { @@ -192,14 +236,18 @@ void OtaUpdateActivity::loop() { } if (state == FAILED) { - if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { + int x = 0; + int y = 0; + if (mappedInput.wasPressed(MappedInputManager::Button::Back) || mappedInput.wasScreenTapped(x, y)) { finish(); } return; } if (state == NO_UPDATE) { - if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { + int x = 0; + int y = 0; + if (mappedInput.wasPressed(MappedInputManager::Button::Back) || mappedInput.wasScreenTapped(x, y)) { finish(); } return; diff --git a/src/activities/settings/OtaUpdateActivity.h b/src/activities/settings/OtaUpdateActivity.h index 12afc80d..e5260f68 100644 --- a/src/activities/settings/OtaUpdateActivity.h +++ b/src/activities/settings/OtaUpdateActivity.h @@ -23,6 +23,7 @@ class OtaUpdateActivity : public Activity { OtaUpdater updater; void onWifiSelectionComplete(bool success); + void runUpdateInstall(); public: explicit OtaUpdateActivity(GfxRenderer& renderer, MappedInputManager& mappedInput) diff --git a/src/activities/settings/SdFirmwareUpdateActivity.cpp b/src/activities/settings/SdFirmwareUpdateActivity.cpp index b0ca1f63..e0ee7438 100644 --- a/src/activities/settings/SdFirmwareUpdateActivity.cpp +++ b/src/activities/settings/SdFirmwareUpdateActivity.cpp @@ -186,8 +186,10 @@ void SdFirmwareUpdateActivity::performUpdate() { void SdFirmwareUpdateActivity::loop() { if (state == State::FAILED) { + int x = 0; + int y = 0; if (mappedInput.wasPressed(MappedInputManager::Button::Back) || - mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { + mappedInput.wasPressed(MappedInputManager::Button::Confirm) || mappedInput.wasScreenTapped(x, y)) { if (recoveryMode) { // Go back to picker so user can try a different .bin state = State::PICKING; diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index 1294c8d9..9a03a81f 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -1,5 +1,6 @@ #include "SettingsActivity.h" +#include #include #include @@ -39,7 +40,12 @@ void SettingsActivity::rebuildSettingsLists() { // reader activity ran — otherwise the font-family picker shows stale list. sdFontSystem.refreshIfDirty(); - for (auto& setting : getSettingsList(&sdFontSystem.registry())) { + // Rescan /dictionaries on every rebuild: cheap (one directory listing) and + // picks up dictionaries copied to the SD card since the last visit. + std::vector dictionaries; + DictionaryRegistry::discover(dictionaries); + + for (auto& setting : getSettingsList(&sdFontSystem.registry(), &dictionaries)) { if (setting.category == StrId::STR_NONE_OPT) continue; if (setting.category == StrId::STR_CAT_DISPLAY) { displaySettings.push_back(setting); @@ -57,13 +63,18 @@ void SettingsActivity::rebuildSettingsLists() { } // Append device-only ACTION items - controlsSettings.insert(controlsSettings.begin(), - SettingInfo::Action(StrId::STR_REMAP_FRONT_BUTTONS, SettingAction::RemapFrontButtons)); + if (!BoardConfig::hasTouch()) { + controlsSettings.insert(controlsSettings.begin(), + SettingInfo::Action(StrId::STR_REMAP_FRONT_BUTTONS, SettingAction::RemapFrontButtons)); + } systemSettings.push_back(SettingInfo::Action(StrId::STR_WIFI_NETWORKS, SettingAction::Network)); systemSettings.push_back(SettingInfo::Action(StrId::STR_KOREADER_SYNC, SettingAction::KOReaderSync)); systemSettings.push_back(SettingInfo::Action(StrId::STR_OPDS_SERVERS, SettingAction::OPDSBrowser)); systemSettings.push_back(SettingInfo::Action(StrId::STR_CLEAR_READING_CACHE, SettingAction::ClearCache)); - systemSettings.push_back(SettingInfo::Action(StrId::STR_CHECK_UPDATES, SettingAction::CheckForUpdates)); + // TODO: Touch devices need their own firmware update path/artifacts before OTA is exposed. + if (!BoardConfig::hasTouch()) { + systemSettings.push_back(SettingInfo::Action(StrId::STR_CHECK_UPDATES, SettingAction::CheckForUpdates)); + } systemSettings.push_back(SettingInfo::Action(StrId::STR_SD_FIRMWARE_UPDATE, SettingAction::SdFirmwareUpdate)); systemSettings.push_back(SettingInfo::Action(StrId::STR_LANGUAGE, SettingAction::Language)); // Insert "Manage Fonts" right after the font family setting so users discover it naturally @@ -117,6 +128,24 @@ void SettingsActivity::loop() { bool hasChangedCategory = false; + auto applyCategorySelection = [this] { + switch (selectedCategoryIndex) { + case 0: + currentSettings = &displaySettings; + break; + case 1: + currentSettings = &readerSettings; + break; + case 2: + currentSettings = &controlsSettings; + break; + case 3: + currentSettings = &systemSettings; + break; + } + settingsCount = static_cast(currentSettings->size()); + }; + // Handle actions with early return if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { if (selectedSettingIndex == 0) { @@ -141,7 +170,103 @@ void SettingsActivity::loop() { return; } + const auto& metrics = UITheme::getInstance().getMetrics(); + int tx = 0; + int ty = 0; + const int tabTop = metrics.topPadding + metrics.headerHeight; + const int listTop = metrics.topPadding + metrics.headerHeight + metrics.tabBarHeight + metrics.verticalSpacing; + const int listHeight = + renderer.getScreenHeight() - (metrics.topPadding + metrics.headerHeight + metrics.tabBarHeight + + metrics.buttonHintsHeight + metrics.verticalSpacing * 2); + auto buildTabs = [&]() { + std::vector tabs; + tabs.reserve(categoryCount); + for (int i = 0; i < categoryCount; i++) { + tabs.push_back({I18N.get(categoryNames[i]), selectedCategoryIndex == i}); + } + return tabs; + }; + auto settingIndexFromPoint = [&](const int x, const int y, int& settingIndex) { + (void)x; + if (settingsCount <= 0 || y < listTop || y >= listTop + listHeight) return false; + const int rowStep = GUI.getListRowStep(false); + if (rowStep <= 0) return false; + const int pageItems = GUI.getListPageItems(listHeight, false); + const int selectedRow = std::max(0, selectedSettingIndex - 1); + const int pageStart = selectedRow / pageItems * pageItems; + const int row = (y - listTop) / rowStep; + const int touched = pageStart + row; + if (row < 0 || row >= pageItems || touched < 0 || touched >= settingsCount) return false; + settingIndex = touched + 1; + return true; + }; + + if (mappedInput.wasScreenTouchDown(tx, ty)) { + int touchedCategory = -1; + const auto tabs = buildTabs(); + if (GUI.tabIndexFromPoint(renderer, Rect{0, tabTop, renderer.getScreenWidth(), metrics.tabBarHeight}, tabs, tx, ty, + touchedCategory)) { + if (selectedCategoryIndex != touchedCategory || selectedSettingIndex != 0) { + selectedCategoryIndex = touchedCategory; + selectedSettingIndex = 0; + applyCategorySelection(); + requestUpdate(); + } + return; + } + + int touchedSetting = -1; + if (settingIndexFromPoint(tx, ty, touchedSetting)) { + if (selectedSettingIndex != touchedSetting) { + selectedSettingIndex = touchedSetting; + requestUpdate(); + } + return; + } + } + + if (mappedInput.wasScreenTapped(tx, ty)) { + int tappedCategory = -1; + const auto tabs = buildTabs(); + if (GUI.tabIndexFromPoint(renderer, Rect{0, tabTop, renderer.getScreenWidth(), metrics.tabBarHeight}, tabs, tx, ty, + tappedCategory)) { + selectedCategoryIndex = tappedCategory; + selectedSettingIndex = 0; + applyCategorySelection(); + requestUpdate(); + return; + } + + int tappedSetting = -1; + if (settingIndexFromPoint(tx, ty, tappedSetting)) { + selectedSettingIndex = tappedSetting; + toggleCurrentSetting(); + requestUpdate(); + return; + } + } + // Handle navigation + const auto& navMetrics = UITheme::getInstance().getMetrics(); + const int settingsListHeight = + renderer.getScreenHeight() - (navMetrics.topPadding + navMetrics.headerHeight + navMetrics.tabBarHeight + + navMetrics.buttonHintsHeight + navMetrics.verticalSpacing * 2); + const int settingsPageItems = GUI.getListPageItems(settingsListHeight, false); + const auto swipe = mappedInput.wasSwipe(); + if (swipe == MappedInputManager::SwipeDir::Up) { + selectedSettingIndex = selectedSettingIndex == 0 ? 1 + : ButtonNavigator::nextPageIndex( + selectedSettingIndex, settingsCount + 1, settingsPageItems); + requestUpdate(); + return; + } + if (swipe == MappedInputManager::SwipeDir::Down) { + selectedSettingIndex = + ButtonNavigator::previousPageIndex(selectedSettingIndex, settingsCount + 1, settingsPageItems); + requestUpdate(); + return; + } + buttonNavigator.onNextRelease([this] { selectedSettingIndex = ButtonNavigator::nextIndex(selectedSettingIndex, settingsCount + 1); requestUpdate(); @@ -166,21 +291,7 @@ void SettingsActivity::loop() { if (hasChangedCategory) { selectedSettingIndex = (selectedSettingIndex == 0) ? 0 : 1; - switch (selectedCategoryIndex) { - case 0: - currentSettings = &displaySettings; - break; - case 1: - currentSettings = &readerSettings; - break; - case 2: - currentSettings = &controlsSettings; - break; - case 3: - currentSettings = &systemSettings; - break; - } - settingsCount = static_cast(currentSettings->size()); + applyCategorySelection(); } } diff --git a/src/activities/settings/StatusBarSettingsActivity.cpp b/src/activities/settings/StatusBarSettingsActivity.cpp index 7bd755d6..26caba0f 100644 --- a/src/activities/settings/StatusBarSettingsActivity.cpp +++ b/src/activities/settings/StatusBarSettingsActivity.cpp @@ -127,6 +127,21 @@ void StatusBarSettingsActivity::onExit() { Activity::onExit(); } void StatusBarSettingsActivity::loop() { if (optionPopup.handleInput(mappedInput, [this] { requestUpdate(); })) 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, visibleItemCount, contentTop, contentHeight, false)) { + case ListTouchResult::Activated: + handleSelection(); + requestUpdate(); + return; + case ListTouchResult::Consumed: + return; + case ListTouchResult::None: + break; + } + if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { finish(); return; diff --git a/src/activities/util/BmpViewerActivity.cpp b/src/activities/util/BmpViewerActivity.cpp index def41015..edf32616 100644 --- a/src/activities/util/BmpViewerActivity.cpp +++ b/src/activities/util/BmpViewerActivity.cpp @@ -180,11 +180,37 @@ void BmpViewerActivity::loop() { // Keep CPU awake/polling so 1st click works Activity::loop(); + auto openSibling = [this](const int delta) { + if (currentImageIndex < 0) { + return false; + } + const int nextIndex = currentImageIndex + delta; + if (siblingImages.size() <= 1 || nextIndex < 0 || nextIndex >= static_cast(siblingImages.size())) { + return false; + } + currentImageIndex = nextIndex; + std::string dirPath = FsHelpers::extractFolderPath(filePath); + if (dirPath.back() != '/') dirPath += "/"; + filePath = dirPath + siblingImages[currentImageIndex]; + onEnter(); + return true; + }; + if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { activityManager.goToFileBrowser(filePath); return; } + const auto swipe = mappedInput.wasSwipe(); + if (swipe == MappedInputManager::SwipeDir::Left) { + openSibling(1); + return; + } + if (swipe == MappedInputManager::SwipeDir::Right) { + openSibling(-1); + return; + } + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { doSetSleepCover(); return; @@ -192,26 +218,13 @@ void BmpViewerActivity::loop() { if (mappedInput.wasReleased(MappedInputManager::Button::Left) || mappedInput.wasReleased(MappedInputManager::Button::Up)) { - if (siblingImages.size() > 1 && currentImageIndex > 0) { - currentImageIndex--; - std::string dirPath = FsHelpers::extractFolderPath(filePath); - if (dirPath.back() != '/') dirPath += "/"; - filePath = dirPath + siblingImages[currentImageIndex]; - onEnter(); - } + openSibling(-1); return; } if (mappedInput.wasReleased(MappedInputManager::Button::Right) || mappedInput.wasReleased(MappedInputManager::Button::Down)) { - if (siblingImages.size() > 1 && currentImageIndex != -1 && - currentImageIndex < static_cast(siblingImages.size()) - 1) { - currentImageIndex++; - std::string dirPath = FsHelpers::extractFolderPath(filePath); - if (dirPath.back() != '/') dirPath += "/"; - filePath = dirPath + siblingImages[currentImageIndex]; - onEnter(); - } + openSibling(1); return; } -} \ No newline at end of file +} diff --git a/src/activities/util/ConfirmationActivity.cpp b/src/activities/util/ConfirmationActivity.cpp index f37cfde8..4feecef0 100644 --- a/src/activities/util/ConfirmationActivity.cpp +++ b/src/activities/util/ConfirmationActivity.cpp @@ -22,12 +22,17 @@ void ConfirmationActivity::onEnter() { safeBody = renderer.truncatedText(fontId, body.c_str(), maxWidth, EpdFontFamily::REGULAR); } - int totalHeight = 0; - if (!safeHeading.empty()) totalHeight += lineHeight; - if (!safeBody.empty()) totalHeight += lineHeight; - if (!safeHeading.empty() && !safeBody.empty()) totalHeight += spacing; + // Text sits in the upper part of the screen so the confirmation popup + // (centered) doesn't cover it. + startY = renderer.getScreenHeight() / 6; - startY = (renderer.getScreenHeight() - totalHeight) / 2; + const char* options[] = {I18N.get(StrId::STR_CANCEL), I18N.get(StrId::STR_CONFIRM)}; + confirmPopup.show(safeHeading.c_str(), options, 2, 0, [this](int idx) { + ActivityResult res; + res.isCancelled = (idx != 1); + setResult(std::move(res)); + finish(); + }); requestUpdate(true); } @@ -48,27 +53,17 @@ void ConfirmationActivity::render(RenderLock&& lock) { renderer.drawCenteredText(fontId, currentY, safeBody.c_str(), true, EpdFontFamily::REGULAR); } - // Draw UI Elements - const auto labels = mappedInput.mapLabels("", "", I18N.get(StrId::STR_CANCEL), I18N.get(StrId::STR_CONFIRM)); - GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + if (confirmPopup.processRender(renderer, mappedInput)) return; renderer.displayBuffer(HalDisplay::RefreshMode::FAST_REFRESH); } void ConfirmationActivity::loop() { - if (mappedInput.wasReleased(MappedInputManager::Button::Right)) { - ActivityResult res; - res.isCancelled = false; - setResult(std::move(res)); - finish(); - return; - } + if (confirmPopup.handleInput(mappedInput, [this] { requestUpdate(); })) return; - if (mappedInput.wasReleased(MappedInputManager::Button::Left)) { - ActivityResult res; - res.isCancelled = true; - setResult(std::move(res)); - finish(); - return; - } -} \ No newline at end of file + // Popup dismissed without a selection (Back button or tap outside): cancel. + ActivityResult res; + res.isCancelled = true; + setResult(std::move(res)); + finish(); +} diff --git a/src/activities/util/ConfirmationActivity.h b/src/activities/util/ConfirmationActivity.h index 1a4051cb..29e81564 100644 --- a/src/activities/util/ConfirmationActivity.h +++ b/src/activities/util/ConfirmationActivity.h @@ -3,6 +3,7 @@ #include #include "activities/Activity.h" +#include "components/OptionPopup.h" #include "fontIds.h" class ConfirmationActivity : public Activity { @@ -17,6 +18,7 @@ class ConfirmationActivity : public Activity { std::string safeHeading; std::string safeBody; + OptionPopup confirmPopup; int startY = 0; int lineHeight = 0; diff --git a/src/activities/util/IntervalSelectionActivity.cpp b/src/activities/util/IntervalSelectionActivity.cpp index 687360f2..d5709822 100644 --- a/src/activities/util/IntervalSelectionActivity.cpp +++ b/src/activities/util/IntervalSelectionActivity.cpp @@ -49,6 +49,36 @@ void IntervalSelectionActivity::loop() { } } + int tx = 0; + int ty = 0; + const int screenWidth = renderer.getScreenWidth(); + const int barWidth = std::min(360, std::max(0, screenWidth - 40)); + constexpr int barHeight = 16; + const int barX = std::max(0, (screenWidth - barWidth) / 2); + const int barY = 140; + + // Live drag on the slider: once a touch lands on the bar, the value follows the + // finger until release. Runs before the Back/Confirm handlers because the release + // of a drag can also register as a swipe (e.g. the left-edge rightward back + // gesture) — the drag must consume it so it can't cancel or confirm the dialog. + if (mappedInput.isScreenTouchHeld(tx, ty)) { + if (draggingBar || (ty >= barY - 20 && ty < barY + barHeight + 20 && tx >= barX && tx < barX + barWidth)) { + draggingBar = true; + const int range = std::max(1, maxValue - minValue); + const int dragged = + clampedValue(minValue + std::clamp(tx - barX, 0, barWidth - 1) * range / std::max(1, barWidth - 1)); + if (dragged != value) { + value = dragged; + requestUpdate(); + } + return; + } + } else if (draggingBar) { + // Release frame of a drag: swallow the tap/swipe events it produced. + draggingBar = false; + return; + } + if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { ActivityResult result; result.isCancelled = true; @@ -63,6 +93,27 @@ void IntervalSelectionActivity::loop() { return; } + if (mappedInput.wasScreenTapped(tx, ty)) { + if (ty >= barY - 20 && ty < barY + barHeight + 20 && tx >= barX && tx < barX + barWidth) { + const int range = std::max(1, maxValue - minValue); + value = clampedValue(minValue + (tx - barX) * range / std::max(1, barWidth - 1)); + requestUpdate(); + return; + } + if (ty >= renderer.getScreenHeight() - 80) { + if (tx < renderer.getScreenWidth() / 3) { + ActivityResult result; + result.isCancelled = true; + setResult(std::move(result)); + finish(); + } else if (tx > renderer.getScreenWidth() * 2 / 3) { + setResult(IntervalResult{static_cast(value)}); + finish(); + } + return; + } + } + buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Left}, [this] { adjustValue(-smallStep); }); buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Right}, [this] { adjustValue(smallStep); }); diff --git a/src/activities/util/IntervalSelectionActivity.h b/src/activities/util/IntervalSelectionActivity.h index bacc78ab..6f347903 100644 --- a/src/activities/util/IntervalSelectionActivity.h +++ b/src/activities/util/IntervalSelectionActivity.h @@ -43,6 +43,7 @@ class IntervalSelectionActivity final : public Activity { int largeStep; bool readerActivity; bool ignoreConfirmRelease; + bool draggingBar = false; ButtonNavigator buttonNavigator; void adjustValue(int delta); diff --git a/src/activities/util/KeyboardEntryActivity.cpp b/src/activities/util/KeyboardEntryActivity.cpp index ec509a2a..e434fec6 100644 --- a/src/activities/util/KeyboardEntryActivity.cpp +++ b/src/activities/util/KeyboardEntryActivity.cpp @@ -4,24 +4,140 @@ #include #include +#include #include "MappedInputManager.h" #include "components/UITheme.h" #include "fontIds.h" -const char* const KeyboardEntryActivity::shiftString[2] = {"shift", "SHIFT"}; +namespace fui = freeink::ui; + +namespace { + +constexpr fui::ActionId ACTION_KEY = 1; + +// --------------------------------------------------------------------------- +// URL layers. The SDK builtin layouts have no URL variant (":", "/", ".", the +// snippet panel), so these are app-defined tables over the same public +// KeyboardLayout structs. URLs are ASCII, so the letter rows are EN-arranged +// regardless of UI language. +// --------------------------------------------------------------------------- + +#define UK(label, output, value) \ + fui::KeyboardKey { label, output, fui::KeyKind::Normal, fui::StateNormal, value, 1, true, nullptr } +#define UKA(label, output, value, alt) \ + fui::KeyboardKey { label, output, fui::KeyKind::Normal, fui::StateNormal, value, 1, true, alt } +#define UKW(label, output, value, units) \ + fui::KeyboardKey { label, output, fui::KeyKind::Normal, fui::StateNormal, value, units, true, nullptr } +#define UKS(label, kind, value, units) \ + fui::KeyboardKey { label, nullptr, kind, fui::StateNormal, value, units, true, nullptr } + +constexpr int16_t URL_PANEL_VALUE = -3; // mirrors KeyboardEntryActivity::URL_PANEL_KEY + +const fui::KeyboardKey URL_NUM_ROW[] = {UKA("1", "1", '1', "!"), UKA("2", "2", '2', "@"), UKA("3", "3", '3', "#"), + UKA("4", "4", '4', "$"), UKA("5", "5", '5', "%"), UKA("6", "6", '6', "^"), + UKA("7", "7", '7', "&"), UKA("8", "8", '8', "*"), UKA("9", "9", '9', "("), + UKA("0", "0", '0', ")")}; + +const fui::KeyboardKey URL_ROW1[] = {UK("q", "q", 'q'), UK("w", "w", 'w'), UK("e", "e", 'e'), UK("r", "r", 'r'), + UK("t", "t", 't'), UK("y", "y", 'y'), UK("u", "u", 'u'), UK("i", "i", 'i'), + UK("o", "o", 'o'), UK("p", "p", 'p')}; +const fui::KeyboardKey URL_ROW2[] = {UK("a", "a", 'a'), UK("s", "s", 's'), UK("d", "d", 'd'), + UK("f", "f", 'f'), UK("g", "g", 'g'), UK("h", "h", 'h'), + UK("j", "j", 'j'), UK("k", "k", 'k'), UK("l", "l", 'l')}; +const fui::KeyboardKey URL_ROW3[] = {UKS("Shift", fui::KeyKind::Shift, fui::QWERTY_KEY_SHIFT, 2), + UK("z", "z", 'z'), + UK("x", "x", 'x'), + UK("c", "c", 'c'), + UK("v", "v", 'v'), + UK("b", "b", 'b'), + UK("n", "n", 'n'), + UK("m", "m", 'm'), + UKS("Del", fui::KeyKind::Delete, fui::QWERTY_KEY_BACKSPACE, 2)}; +// URLs have no spaces, so the URL bottom row spends the space slot on ":", +// "/", "." and the snippet-panel toggle instead (the legacy keyboard did the +// same with its "URL" key). +const fui::KeyboardKey URL_BOTTOM[] = {UKS("?123", fui::KeyKind::Mode, fui::QWERTY_KEY_MODE, 2), + UK(":", ":", ':'), + UK("/", "/", '/'), + UK(".", ".", '.'), + UKW("URL", nullptr, URL_PANEL_VALUE, 2), + UKS("OK", fui::KeyKind::Ok, fui::QWERTY_KEY_ENTER, 2)}; + +const fui::KeyboardKey URL_SHIFT_ROW1[] = {UK("Q", "Q", 'Q'), UK("W", "W", 'W'), UK("E", "E", 'E'), UK("R", "R", 'R'), + UK("T", "T", 'T'), UK("Y", "Y", 'Y'), UK("U", "U", 'U'), UK("I", "I", 'I'), + UK("O", "O", 'O'), UK("P", "P", 'P')}; +const fui::KeyboardKey URL_SHIFT_ROW2[] = {UK("A", "A", 'A'), UK("S", "S", 'S'), UK("D", "D", 'D'), + UK("F", "F", 'F'), UK("G", "G", 'G'), UK("H", "H", 'H'), + UK("J", "J", 'J'), UK("K", "K", 'K'), UK("L", "L", 'L')}; +const fui::KeyboardKey URL_SHIFT_ROW3[] = {UKS("Shift", fui::KeyKind::Shift, fui::QWERTY_KEY_SHIFT, 2), + UK("Z", "Z", 'Z'), + UK("X", "X", 'X'), + UK("C", "C", 'C'), + UK("V", "V", 'V'), + UK("B", "B", 'B'), + UK("N", "N", 'N'), + UK("M", "M", 'M'), + UKS("Del", fui::KeyKind::Delete, fui::QWERTY_KEY_BACKSPACE, 2)}; + +// Snippet keys: multi-character outputs, stable ids above the localized-key +// range so they never collide with layout key ids. +const fui::KeyboardKey URL_SNIP_ROW1[] = {UK("https://", "https://", 2001), UK("www.", "www.", 2002), + UK(".com", ".com", 2003)}; +const fui::KeyboardKey URL_SNIP_ROW2[] = {UK("http://", "http://", 2004), UK("192.168.", "192.168.", 2005), + UK(".org", ".org", 2006)}; +const fui::KeyboardKey URL_SNIP_ROW3[] = {UK("/opds", "/opds", 2007), UK(":8080", ":8080", 2008), + UK(".net", ".net", 2009)}; +const fui::KeyboardKey URL_SNIP_BOTTOM[] = {UKS("abc", fui::KeyKind::Mode, fui::QWERTY_KEY_MODE, 2), + UKW("URL", nullptr, URL_PANEL_VALUE, 2), + UKS("Del", fui::KeyKind::Delete, fui::QWERTY_KEY_BACKSPACE, 2), + UKS("OK", fui::KeyKind::Ok, fui::QWERTY_KEY_ENTER, 2)}; + +#undef UK +#undef UKA +#undef UKW +#undef UKS + +const fui::KeyboardRow URL_ROWS[] = { + {URL_NUM_ROW, 10, 0}, {URL_ROW1, 10, 0}, {URL_ROW2, 9, 1}, {URL_ROW3, 9, 0}, {URL_BOTTOM, 6, 0}}; +const fui::KeyboardRow URL_SHIFT_ROWS[] = { + {URL_NUM_ROW, 10, 0}, {URL_SHIFT_ROW1, 10, 0}, {URL_SHIFT_ROW2, 9, 1}, {URL_SHIFT_ROW3, 9, 0}, {URL_BOTTOM, 6, 0}}; +const fui::KeyboardRow URL_SNIP_ROWS[] = { + {URL_SNIP_ROW1, 3, 0}, {URL_SNIP_ROW2, 3, 0}, {URL_SNIP_ROW3, 3, 0}, {URL_SNIP_BOTTOM, 4, 0}}; + +const fui::KeyboardLayout URL_LAYOUT{URL_ROWS, 5}; +const fui::KeyboardLayout URL_SHIFT_LAYOUT{URL_SHIFT_ROWS, 5}; +const fui::KeyboardLayout URL_SNIPPET_LAYOUT{URL_SNIP_ROWS, 4}; + +fui::KeyboardLayoutId layoutForLanguage(const Language language) { + switch (language) { + case Language::FR: + return fui::KeyboardLayoutId::AzertyFr; + case Language::DE: + return fui::KeyboardLayoutId::QwertzDe; + case Language::ES: + return fui::KeyboardLayoutId::SpanishEs; + default: + return fui::KeyboardLayoutId::QwertyEn; + } +} + +} // namespace void KeyboardEntryActivity::onEnter() { Activity::onEnter(); cursorPos = text.length(); - symMode = false; - urlMode = false; + // URL layers are EN-arranged app tables; everything else follows the UI + // language. + layoutId = inputType == InputType::Url ? fui::KeyboardLayoutId::QwertyEn : layoutForLanguage(I18N.getLanguage()); + shifted = false; + symbols = false; + urlPanel = false; cursorMode = false; togglePos = false; passwordVisible = false; - shiftState = 0; - selectedRow = 0; - selectedCol = 0; + selRow = 0; + selCol = 0; delPressCount = 0; hintVisible = false; hintShowTime = 0; @@ -29,167 +145,397 @@ void KeyboardEntryActivity::onEnter() { rightLongHandled = false; savedCursorPos = 0; rightStartCursorPos = 0; + touchRouter.reset(); + touchRouter.holdMs = TOUCH_LONG_PRESS_MS; + touchRouter.overrideHoldMs = TOUCH_DEL_LONG_PRESS_MS; + interactionsReady = false; requestUpdate(); } void KeyboardEntryActivity::onExit() { Activity::onExit(); } -int KeyboardEntryActivity::getContentRowCount() const { - if (urlMode) return 3; - return ABC_ROWS; +const fui::KeyboardLayout& KeyboardEntryActivity::currentLayout() const { + if (symbols) return fui::builtinKeyboardLayout(layoutId, shifted, true); + if (inputType == InputType::Url) { + if (urlPanel) return URL_SNIPPET_LAYOUT; + return shifted ? URL_SHIFT_LAYOUT : URL_LAYOUT; + } + return fui::builtinKeyboardLayout(layoutId, shifted, false, /*numberRow=*/true); } -int KeyboardEntryActivity::getContentColCount() const { - if (urlMode) return 3; - return COLS; +const fui::KeyboardKey* KeyboardEntryActivity::selectedKey() const { + const fui::KeyboardLayout& layout = currentLayout(); + if (selRow < 0 || selRow >= layout.rowCount) return nullptr; + const fui::KeyboardRow& row = layout.rows[selRow]; + if (selCol < 0 || selCol >= row.count) return nullptr; + return &row.keys[selCol]; } -int KeyboardEntryActivity::getTotalRowCount() const { return getContentRowCount() + 1; } - -bool KeyboardEntryActivity::isBottomRow(const int row) const { return row == getContentRowCount(); } - -char KeyboardEntryActivity::getSelectedChar() const { - const KeyDef(*layout)[COLS] = symMode ? symLayout : (inputType == InputType::Url ? urlLayout : abcLayout); - - if (selectedRow < 0 || selectedRow >= getContentRowCount()) return '\0'; - if (selectedCol < 0 || selectedCol >= COLS) return '\0'; - - const KeyDef& key = layout[selectedRow][selectedCol]; - return (shiftState > 0 && key.secondary != '\0') ? key.secondary : key.primary; +int KeyboardEntryActivity::selectedLogicalIndex() const { + const fui::KeyboardLayout& layout = currentLayout(); + int index = 0; + for (int r = 0; r < selRow && r < layout.rowCount; r++) { + index += layout.rows[r].count; + } + return index + selCol; } -char KeyboardEntryActivity::getAlternativeChar() const { - if (symMode || urlMode) return '\0'; - if (inputType == InputType::Url && selectedRow > 0) return '\0'; - - const KeyDef(*layout)[COLS] = abcLayout; - - if (selectedRow < 0 || selectedRow >= getContentRowCount()) return '\0'; - if (selectedCol < 0 || selectedCol >= COLS) return '\0'; - - const KeyDef& key = layout[selectedRow][selectedCol]; - const char current = getSelectedChar(); - if (current == key.primary && key.secondary != '\0') return key.secondary; - if (current == key.secondary) return key.primary; - return '\0'; +void KeyboardEntryActivity::clampSelection() { + const fui::KeyboardLayout& layout = currentLayout(); + if (layout.rowCount == 0) { + selRow = 0; + selCol = 0; + return; + } + if (selRow < 0) selRow = 0; + if (selRow >= layout.rowCount) selRow = layout.rowCount - 1; + const int cols = layout.rows[selRow].count; + if (selCol < 0) selCol = 0; + if (selCol >= cols) selCol = cols > 0 ? cols - 1 : 0; } -bool KeyboardEntryActivity::insertChar(char c) { - if (c == '\0') return true; - if (maxLength != 0 && text.length() >= maxLength) return true; +void KeyboardEntryActivity::moveSelectionRow(const int delta) { + const fui::KeyboardLayout& layout = currentLayout(); + if (layout.rowCount == 0) return; + const int oldCols = selRow < layout.rowCount ? layout.rows[selRow].count : 1; + selRow = (selRow + delta + layout.rowCount) % layout.rowCount; + const int newCols = layout.rows[selRow].count; + // Proportional column mapping keeps vertical travel intuitive between rows + // of different key counts (e.g. a 10-key letter row over a 6-key bottom row). + if (oldCols > 0 && newCols > 0 && oldCols != newCols) { + selCol = selCol * newCols / oldCols; + } + clampSelection(); +} + +void KeyboardEntryActivity::moveSelectionCol(const int delta) { + const fui::KeyboardLayout& layout = currentLayout(); + if (selRow < 0 || selRow >= layout.rowCount) return; + const int cols = layout.rows[selRow].count; + if (cols <= 0) return; + selCol = (selCol + delta + cols) % cols; +} + +bool KeyboardEntryActivity::syncSelectionToValue(const int16_t value) { + const fui::KeyboardLayout& layout = currentLayout(); + for (int r = 0; r < layout.rowCount; r++) { + for (int c = 0; c < layout.rows[r].count; c++) { + if (layout.rows[r].keys[c].value == value) { + selRow = r; + selCol = c; + return true; + } + } + } + return false; +} + +size_t KeyboardEntryActivity::utf8Prev(const std::string& s, size_t pos) { + if (pos == 0) return 0; + pos--; + while (pos > 0 && (static_cast(s[pos]) & 0xC0) == 0x80) pos--; + return pos; +} + +size_t KeyboardEntryActivity::utf8Next(const std::string& s, size_t pos) { + if (pos >= s.length()) return s.length(); + pos++; + while (pos < s.length() && (static_cast(s[pos]) & 0xC0) == 0x80) pos++; + return pos; +} + +void KeyboardEntryActivity::insertUtf8(const char* out) { + if (!out || !*out) return; + const size_t n = strlen(out); + if (maxLength != 0 && text.length() + n > maxLength) return; if (cursorPos > text.length()) cursorPos = text.length(); + text.insert(cursorPos, out, n); + cursorPos += n; +} - text.insert(cursorPos, 1, c); - cursorPos++; +bool KeyboardEntryActivity::backspaceUtf8() { + if (text.empty() || cursorPos == 0) return false; + const size_t prev = utf8Prev(text, cursorPos); + text.erase(prev, cursorPos - prev); + cursorPos = prev; return true; } -void KeyboardEntryActivity::insertString(const std::string& str) { - if (str.empty()) return; - if (maxLength != 0 && text.length() + str.length() > maxLength) return; - if (cursorPos > text.length()) cursorPos = text.length(); - - text.insert(cursorPos, str); - cursorPos += str.length(); -} - -bool KeyboardEntryActivity::handleKeyPress() { - if (isBottomRow(selectedRow)) { - switch (static_cast(selectedCol)) { - case SpecialKeyType::Shift: - delPressCount = 0; - hintVisible = false; - // Shift is meaningless in the URL-snippet panel and the symbol layout, but - // must work for the URL letter layout so uppercase letters can be entered (#2178). - if (urlMode) return true; - if (symMode) return true; - shiftState = (shiftState + 1) % 2; - return true; - case SpecialKeyType::Mode: { - delPressCount = 0; - hintVisible = false; - if (urlMode) { - urlMode = false; - symMode = false; - selectedRow = getTotalRowCount() - 1; - selectedCol = static_cast(SpecialKeyType::Mode); - requestUpdate(); - return true; - } - symMode = !symMode; - int maxRow = getTotalRowCount() - 1; - if (selectedRow > maxRow) selectedRow = maxRow; - if (isBottomRow(selectedRow)) { - if (selectedCol >= BOTTOM_KEY_COUNT) selectedCol = BOTTOM_KEY_COUNT - 1; - } else { - if (selectedCol >= getContentColCount()) selectedCol = getContentColCount() - 1; - } +bool KeyboardEntryActivity::activateValue(const int16_t value, const bool longPress) { + switch (value) { + case fui::QWERTY_KEY_SHIFT: + delPressCount = 0; + hintVisible = false; + // Letters: case toggle. Symbols: pages between "?123" and "#+=". + shifted = !shifted; + clampSelection(); + return true; + case fui::QWERTY_KEY_MODE: + delPressCount = 0; + hintVisible = false; + if (urlPanel) { + urlPanel = false; + } else { + symbols = !symbols; + shifted = false; + } + clampSelection(); + return true; + case URL_PANEL_KEY: + delPressCount = 0; + hintVisible = false; + urlPanel = !urlPanel; + symbols = false; + shifted = false; + clampSelection(); + return true; + case fui::QWERTY_KEY_ENTER: + onComplete(text); + return false; + case fui::QWERTY_KEY_BACKSPACE: + if (longPress) { + text.clear(); + cursorPos = 0; return true; } - case SpecialKeyType::Space: - delPressCount = 0; - hintVisible = false; - if (inputType == InputType::Url) { - urlMode = !urlMode; - if (urlMode) { - symMode = false; - } - selectedRow = getTotalRowCount() - 1; - selectedCol = static_cast(SpecialKeyType::Space); - requestUpdate(); - } else { - return insertChar(' '); - } - return true; - case SpecialKeyType::Del: - delPressCount++; - if (delPressCount >= 2) { - hintVisible = true; - hintShowTime = millis(); - } - if (cursorPos > 0 && !text.empty()) { - text.erase(cursorPos - 1, 1); - cursorPos--; - } - return true; - case SpecialKeyType::Ok: - delPressCount = 0; - hintVisible = false; - onComplete(text); - return false; - default: - return true; + delPressCount++; + if (delPressCount >= 2) { + hintVisible = true; + hintShowTime = millis(); + } + backspaceUtf8(); + return true; + default: { + delPressCount = 0; + hintVisible = false; + const fui::KeyboardLayout& layer = currentLayout(); + // keyboardAltOutputFor covers explicit alts and the letter case-flip. + const char* out = longPress ? fui::keyboardAltOutputFor(layer, value) : nullptr; + if (!out) out = fui::keyboardOutputFor(layer, value); + if (!out) return false; + insertUtf8(out); + if (shifted && !symbols) { + shifted = false; // shift auto-releases after one character + clampSelection(); + } + return true; } } +} - if (urlMode) { - delPressCount = 0; - hintVisible = false; - const int idx = selectedCol + selectedRow * 3; - if (idx < URL_SNIPPET_COUNT) { - insertString(urlSnippets[idx]); +bool KeyboardEntryActivity::clearAllOrAltOnSelected() { + const fui::KeyboardKey* key = selectedKey(); + if (!key) return false; + if (key->value == fui::QWERTY_KEY_BACKSPACE) { + text.clear(); + cursorPos = 0; + return true; + } + // Explicit alts and the letter case-flip, same as touch long-press. + const char* alt = fui::keyboardAltOutputFor(currentLayout(), key->value); + if (alt) { + insertUtf8(alt); + return true; + } + return false; +} + +std::string KeyboardEntryActivity::displayTextForCurrentState() const { + std::string displayText = text; + if (inputType != InputType::Password || passwordVisible) { + return displayText; + } + + size_t revealPos; + if (cursorMode) { + revealPos = text.length(); // no reveal in displayText; block draws actual char directly + } else { + revealPos = (text.length() > 0 && cursorPos > 0) ? cursorPos - 1 : std::string::npos; + } + for (size_t i = 0; i < displayText.length(); i++) { + if (i != revealPos) { + displayText[i] = '*'; } + } + return displayText; +} + +int KeyboardEntryActivity::measureRange(std::string& s, const int start, const int end) const { + if (end <= start) return 0; + // s[end] is writable even at s.length() (the terminator slot); only '\0' may + // be written there, which is exactly what the measurement needs. + const char saved = s[end]; + s[end] = '\0'; + const int width = renderer.getTextAdvanceX(UI_12_FONT_ID, s.c_str() + start, EpdFontFamily::REGULAR); + s[end] = saved; + return width; +} + +int KeyboardEntryActivity::lineBreakEnd(std::string& s, const int start, const int maxWidth) const { + const int len = static_cast(s.length()); + if (measureRange(s, start, len) <= maxWidth) return len; + int lo = start + 1; + int hi = len - 1; + int best = start + 1; + while (lo <= hi) { + const int mid = lo + (hi - lo) / 2; + if (measureRange(s, start, mid) <= maxWidth) { + best = mid; + lo = mid + 1; + } else { + hi = mid - 1; + } + } + return best; +} + +bool KeyboardEntryActivity::cursorPositionFromPoint(const int x, const int y, size_t& position) const { + // Key taps are the overwhelmingly common case; they land on the keyboard, + // never the text field, so skip the wrap/measure work entirely. + if (y >= keyboardRect().y) return false; + + const int pageWidth = renderer.getScreenWidth(); + const auto& metrics = UITheme::getInstance().getMetrics(); + + const int lineHeight = renderer.getLineHeight(UI_12_FONT_ID); + const int inputStartY = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing + + metrics.verticalSpacing * 4 + metrics.keyboardVerticalOffset; + + int availableWidth = pageWidth; + if (gpio.deviceIsX3()) { + availableWidth -= 2 * metrics.sideButtonHintsWidth; + } + const int effectiveMargin = (pageWidth - availableWidth * metrics.keyboardTextFieldWidthPercent / 100) / 2; + const int toggleGap = inputType == InputType::Password ? 4 : 0; + const int toggleReserve = inputType == InputType::Password ? std::max(renderer.getTextWidth(UI_12_FONT_ID, "[abc]"), + renderer.getTextWidth(UI_12_FONT_ID, "[***]")) + + toggleGap + : 0; + const int textAreaWidth = pageWidth - 2 * effectiveMargin - toggleReserve; + const int maxLineWidth = textAreaWidth; + const bool centerText = metrics.keyboardCenteredText; + std::string displayText = displayTextForCurrentState(); + + int lineStartIdx = 0; + int lineY = inputStartY; + int lastLineStartIdx = 0; + int lastLineEndIdx = static_cast(displayText.length()); + int lastLineStartX = effectiveMargin; + int lastLineWidth = 0; + + while (true) { + const int lineEndIdx = lineBreakEnd(displayText, lineStartIdx, maxLineWidth); + const int textWidth = measureRange(displayText, lineStartIdx, lineEndIdx); + const int lineStartX = centerText ? effectiveMargin + (maxLineWidth - textWidth) / 2 : effectiveMargin; + lastLineStartIdx = lineStartIdx; + lastLineEndIdx = lineEndIdx; + lastLineStartX = lineStartX; + lastLineWidth = textWidth; + + if (y >= lineY - metrics.verticalSpacing && y < lineY + lineHeight + metrics.verticalSpacing) { + if (x <= lineStartX) { + position = static_cast(lineStartIdx); + return true; + } + if (x >= lineStartX + textWidth) { + position = static_cast(lineEndIdx); + return true; + } + + int previousWidth = 0; + for (int i = lineStartIdx; i < lineEndIdx; i++) { + const int nextWidth = measureRange(displayText, lineStartIdx, i + 1); + const int midpoint = lineStartX + previousWidth + (nextWidth - previousWidth) / 2; + if (x < midpoint) { + position = static_cast(i); + return true; + } + previousWidth = nextWidth; + } + position = static_cast(lineEndIdx); + return true; + } + + if (lineEndIdx == static_cast(displayText.length())) { + break; + } + + lineY += lineHeight; + lineStartIdx = lineEndIdx; + } + + const int underlineBottom = lineY + lineHeight + metrics.verticalSpacing + 8; + if (y >= inputStartY - metrics.verticalSpacing && y < underlineBottom && x >= effectiveMargin && + x < effectiveMargin + maxLineWidth + toggleReserve) { + position = x < lastLineStartX + lastLineWidth ? static_cast(lastLineStartIdx) + : static_cast(lastLineEndIdx); return true; } - delPressCount = 0; - hintVisible = false; - - return insertChar(getSelectedChar()); + return false; } -void KeyboardEntryActivity::mapColContentBottom(int& col, bool goingUp) const { - if (urlMode) { - col = goingUp ? col - 1 : col + 1; - if (col < 0) col = 0; - if (col >= 3) col = 2; - } else { - col = goingUp ? col * 2 : col / 2; - } +fui::Rect KeyboardEntryActivity::keyboardRect() const { + const auto& metrics = UITheme::getInstance().getMetrics(); + const int pageWidth = renderer.getScreenWidth(); + const int pageHeight = renderer.getScreenHeight(); + const int rows = currentLayout().rowCount; + const int gap = metrics.keyboardKeySpacing; + const int height = rows * metrics.keyboardKeyHeight + (rows > 1 ? (rows - 1) * gap : 0); + const int width = pageWidth * metrics.keyboardWidthPercent / 100; + const int x = (pageWidth - width) / 2; + const int y = + pageHeight - metrics.buttonHintsHeight - metrics.verticalSpacing - height + metrics.keyboardVerticalOffset; + return fui::Rect{static_cast(x), static_cast(y), static_cast(width), + static_cast(height)}; } void KeyboardEntryActivity::loop() { - const int totalRows = getTotalRowCount(); + int tx = 0; + int ty = 0; + + size_t touchedCursorPos = 0; + if (mappedInput.wasScreenTapped(tx, ty) && cursorPositionFromPoint(tx, ty, touchedCursorPos)) { + cursorPos = std::min(touchedCursorPos, text.length()); + // The masked text field maps taps per byte; snap back to a boundary so + // the cursor never lands inside a multi-byte character. + while (cursorPos > 0 && cursorPos < text.length() && (static_cast(text[cursorPos]) & 0xC0) == 0x80) { + cursorPos--; + } + cursorMode = false; + togglePos = false; + hintVisible = false; + touchRouter.reset(); + requestUpdate(); + return; + } + + if (!cursorMode && interactionsReady) { + const bool pressedDown = mappedInput.wasScreenTouchDown(tx, ty); + int tapX = 0; + int tapY = 0; + const bool tapped = mappedInput.wasScreenTapped(tapX, tapY); + int hx = 0; + int hy = 0; + const bool inContact = mappedInput.isScreenTouchHeld(hx, hy); + + const fui::TouchHoldRouter::Result result = + touchRouter.update(interactions, pressedDown, static_cast(tx), static_cast(ty), tapped, + static_cast(tapX), static_cast(tapY), inContact, millis()); + if (result.event) { + syncSelectionToValue(result.event.value); + if (activateValue(result.event.value, result.event.longPress)) { + requestUpdate(); + } + return; + } + if (result.activeChanged) { + requestUpdate(); + } + if (pressedDown || tapped) { + return; + } + } if (!cursorMode && mappedInput.wasPressed(MappedInputManager::Button::Up)) { upHeld = true; @@ -207,16 +553,7 @@ void KeyboardEntryActivity::loop() { if (mappedInput.wasReleased(MappedInputManager::Button::Up)) { if (upHeld && !upLongHandled && !cursorMode) { - bool wasBottom = isBottomRow(selectedRow); - const int contentCols = getContentColCount(); - selectedRow = ButtonNavigator::previousIndex(selectedRow, totalRows); - if (wasBottom && !isBottomRow(selectedRow)) { - mapColContentBottom(selectedCol, true); - } else if (!wasBottom && isBottomRow(selectedRow)) { - mapColContentBottom(selectedCol, false); - } - int maxCol = isBottomRow(selectedRow) ? BOTTOM_KEY_COUNT - 1 : contentCols - 1; - if (selectedCol > maxCol) selectedCol = maxCol; + moveSelectionRow(-1); requestUpdate(); } upHeld = false; @@ -239,16 +576,7 @@ void KeyboardEntryActivity::loop() { if (mappedInput.wasReleased(MappedInputManager::Button::Down)) { if (downHeld && !downLongHandled && !cursorMode) { - bool wasBottom = isBottomRow(selectedRow); - const int contentCols = getContentColCount(); - selectedRow = ButtonNavigator::nextIndex(selectedRow, totalRows); - if (wasBottom && !isBottomRow(selectedRow)) { - mapColContentBottom(selectedCol, true); - } else if (!wasBottom && isBottomRow(selectedRow)) { - mapColContentBottom(selectedCol, false); - } - int maxCol = isBottomRow(selectedRow) ? BOTTOM_KEY_COUNT - 1 : contentCols - 1; - if (selectedCol > maxCol) selectedCol = maxCol; + moveSelectionRow(1); requestUpdate(); } downHeld = false; @@ -257,8 +585,7 @@ void KeyboardEntryActivity::loop() { buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Left}, [this] { if (cursorMode) return; - int maxCol = isBottomRow(selectedRow) ? BOTTOM_KEY_COUNT - 1 : getContentColCount() - 1; - selectedCol = ButtonNavigator::previousIndex(selectedCol, maxCol + 1); + moveSelectionCol(-1); requestUpdate(); }); @@ -269,7 +596,7 @@ void KeyboardEntryActivity::loop() { togglePos = false; requestUpdate(); } else if (cursorPos > 0) { - cursorPos--; + cursorPos = utf8Prev(text, cursorPos); requestUpdate(); } } @@ -285,8 +612,7 @@ void KeyboardEntryActivity::loop() { buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Right}, [this] { if (cursorMode) return; - int maxCol = isBottomRow(selectedRow) ? BOTTOM_KEY_COUNT - 1 : getContentColCount() - 1; - selectedCol = ButtonNavigator::nextIndex(selectedCol, maxCol + 1); + moveSelectionCol(1); requestUpdate(); }); @@ -306,7 +632,7 @@ void KeyboardEntryActivity::loop() { rightLongHandled = false; } if (cursorMode && !togglePos && cursorPos < text.length()) { - cursorPos++; + cursorPos = utf8Next(text, cursorPos); requestUpdate(); } if (cursorMode) return; @@ -319,20 +645,19 @@ void KeyboardEntryActivity::loop() { confirmLongHandled = false; } + const fui::KeyboardKey* selKey = selectedKey(); + const bool selectedDel = selKey && selKey->value == fui::QWERTY_KEY_BACKSPACE; + if (confirmHeld && !confirmLongHandled && mappedInput.isPressed(MappedInputManager::Button::Confirm) && - mappedInput.getHeldTime() > DEL_LONG_PRESS_MS && isBottomRow(selectedRow) && - selectedCol == static_cast(SpecialKeyType::Del)) { - text.clear(); - cursorPos = 0; + mappedInput.getHeldTime() > DEL_LONG_PRESS_MS && selectedDel) { + clearAllOrAltOnSelected(); confirmLongHandled = true; requestUpdate(); } if (confirmHeld && !confirmLongHandled && mappedInput.isPressed(MappedInputManager::Button::Confirm) && mappedInput.getHeldTime() > LONG_PRESS_MS) { - char alt = getAlternativeChar(); - if (alt != '\0') { - insertChar(alt); + if (!selectedDel && clearAllOrAltOnSelected()) { requestUpdate(); confirmLongHandled = true; } @@ -340,7 +665,7 @@ void KeyboardEntryActivity::loop() { if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { if (confirmHeld && !confirmLongHandled && !cursorMode) { - if (handleKeyPress()) { + if (selKey && activateValue(selKey->value, false)) { requestUpdate(); } } else if (confirmHeld && !confirmLongHandled && cursorMode && inputType == InputType::Password && togglePos) { @@ -365,7 +690,6 @@ void KeyboardEntryActivity::render(RenderLock&&) { renderer.clearScreen(); const auto pageWidth = renderer.getScreenWidth(); - const auto pageHeight = renderer.getScreenHeight(); const auto& metrics = UITheme::getInstance().getMetrics(); GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, title.c_str()); @@ -375,23 +699,7 @@ void KeyboardEntryActivity::render(RenderLock&&) { metrics.verticalSpacing * 4 + metrics.keyboardVerticalOffset; int inputHeight = 0; - std::string displayText; - if (inputType == InputType::Password && !passwordVisible) { - size_t revealPos; - if (cursorMode) { - revealPos = text.length(); // no reveal in displayText; block draws actual char directly - } else { - revealPos = (text.length() > 0 && cursorPos > 0) ? cursorPos - 1 : std::string::npos; - } - displayText = text; - for (size_t i = 0; i < displayText.length(); i++) { - if (i != revealPos) { - displayText[i] = '*'; - } - } - } else { - displayText = text; - } + std::string displayText = displayTextForCurrentState(); const bool isPassword = (inputType == InputType::Password); int availableWidth = pageWidth; @@ -415,16 +723,16 @@ void KeyboardEntryActivity::render(RenderLock&&) { } int lineStartIdx = 0; - int lineEndIdx = displayText.length(); int textWidth = 0; int cursorPixelX = effectiveMargin; int cursorLineY = inputStartY; bool cursorDrawn = false; while (true) { - std::string lineText = displayText.substr(lineStartIdx, lineEndIdx - lineStartIdx); + const int lineEndIdx = lineBreakEnd(displayText, lineStartIdx, maxLineWidth); + const std::string lineText = displayText.substr(lineStartIdx, lineEndIdx - lineStartIdx); textWidth = renderer.getTextAdvanceX(UI_12_FONT_ID, lineText.c_str(), EpdFontFamily::REGULAR); - if (textWidth <= maxLineWidth) { + { const bool isLastLine = (lineEndIdx == static_cast(displayText.length())); bool isCursorLine = false; if (!cursorDrawn && cursorPos >= lineStartIdx && @@ -473,15 +781,12 @@ void KeyboardEntryActivity::render(RenderLock&&) { } else { renderer.drawText(UI_12_FONT_ID, lineStartX, inputStartY + inputHeight, lineText.c_str()); } - if (lineEndIdx == displayText.length()) { + if (lineEndIdx == static_cast(displayText.length())) { break; } inputHeight += lineHeight; lineStartIdx = lineEndIdx; - lineEndIdx = displayText.length(); - } else { - lineEndIdx -= 1; } } @@ -549,20 +854,7 @@ void KeyboardEntryActivity::render(RenderLock&&) { } } - const int keyHeight = metrics.keyboardKeyHeight; - const int bottomKeyHeight = metrics.keyboardBottomKeyHeight; - const int keySpacing = metrics.keyboardKeySpacing; - const int contentCols = getContentColCount(); - const int keyboardWidth = pageWidth * metrics.keyboardWidthPercent / 100; - const int keyWidth = (keyboardWidth - (contentCols - 1) * keySpacing) / contentCols; - const int leftMargin = (pageWidth - (contentCols * keyWidth + (contentCols - 1) * keySpacing)) / 2; - - const int bottomRowGap = metrics.keyboardBottomKeySpacing > 0 ? 4 : 0; - const int keyboardStartY = metrics.keyboardBottomAligned - ? pageHeight - metrics.buttonHintsHeight - metrics.verticalSpacing - - (keyHeight + keySpacing) * getContentRowCount() - bottomKeyHeight - - bottomRowGap + metrics.keyboardVerticalOffset - : inputStartY + inputHeight + lineHeight + metrics.verticalSpacing; + const fui::Rect kbRect = keyboardRect(); const int tipsLh = renderer.getLineHeight(SMALL_FONT_ID); const int underlineBottom = inputStartY + inputHeight + lineHeight + metrics.verticalSpacing + 4; @@ -571,27 +863,27 @@ void KeyboardEntryActivity::render(RenderLock&&) { int tipCount = 0; if (cursorMode) { tipCount = 1; - } else if (urlMode) { + } else if (urlPanel) { tipCount = 1 + (!text.empty() ? 1 : 0); - } else if (symMode) { + } else if (symbols) { tipCount = !text.empty() ? 1 : 0; } else { tipCount = 1 + (inputType == InputType::Url ? 1 : 0) + (!text.empty() ? 1 : 0); } if (tipCount > 0) { - int y = (underlineBottom + keyboardStartY) / 2 - (tipCount + 1) * tipsLh / 2; + int y = (underlineBottom + kbRect.y) / 2 - (tipCount + 1) * tipsLh / 2; drawTip(tr(STR_KB_TIPS), y); y += tipsLh; if (cursorMode) { drawTip(tr(STR_KB_HINT_RETURN_KEYBOARD), y); - } else if (urlMode) { + } else if (urlPanel) { drawTip(tr(STR_KB_HINT_EXIT_URL_MODE), y); y += tipsLh; if (!text.empty()) { drawTip(tr(STR_KB_HINT_CLEAR_TEXT), y); } - } else if (symMode) { + } else if (symbols) { if (!text.empty()) { drawTip(tr(STR_KB_HINT_CLEAR_TEXT), y); } @@ -599,7 +891,7 @@ void KeyboardEntryActivity::render(RenderLock&&) { const char* altCharTip; if (inputType == InputType::Url) { altCharTip = tr(STR_KB_HINT_SECONDARY_CHAR); - } else if (shiftState > 0) { + } else if (shifted) { altCharTip = tr(STR_KB_HINT_LOWER_SECONDARY); } else { altCharTip = tr(STR_KB_HINT_UPPER_SECONDARY); @@ -616,123 +908,38 @@ void KeyboardEntryActivity::render(RenderLock&&) { } } - const int bkSpacing = metrics.keyboardBottomKeySpacing; - const int abcKeyWidth = (keyboardWidth - (COLS - 1) * keySpacing) / COLS; - const int contentTotalWidth = COLS * abcKeyWidth + (COLS - 1) * keySpacing; - const int bottomKeyWidth = (contentTotalWidth - (BOTTOM_KEY_COUNT - 1) * bkSpacing) / BOTTOM_KEY_COUNT; - const int bottomLeftMargin = - (pageWidth - (BOTTOM_KEY_COUNT * bottomKeyWidth + (BOTTOM_KEY_COUNT - 1) * bkSpacing)) / 2; + // The FreeInkUI keyboard draws the keys and registers their hit rects into + // `interactions`; loop() routes touch snapshots against that table. + interactionsReady = false; + fui::GfxRendererTarget target(renderer); + target.setFont(fui::GfxRendererTarget::FONT_SMALL, SMALL_FONT_ID); + target.setFont(fui::GfxRendererTarget::FONT_BODY, UI_12_FONT_ID); + const fui::DeviceContext device = target.deviceContext(); + const fui::InputSnapshot noInput{}; + fui::Frame<48> frame(target, device, noInput, interactions); - int urlLeftMargin = leftMargin; - if (urlMode) { - const int urlTotalWidth = 3 * keyWidth + 2 * keySpacing; - const int urlCenterX = - bottomLeftMargin + static_cast(SpecialKeyType::Space) * (bottomKeyWidth + bkSpacing) + bottomKeyWidth / 2; - urlLeftMargin = urlCenterX - urlTotalWidth / 2; - } - - const KeyDef(*layout)[COLS] = symMode ? symLayout : (inputType == InputType::Url ? urlLayout : abcLayout); - const int contentRows = getContentRowCount(); - - for (int row = 0; row < contentRows; row++) { - const int rowY = keyboardStartY + row * (keyHeight + keySpacing); - const int rowLeftMargin = urlMode ? urlLeftMargin : leftMargin; - - for (int col = 0; col < contentCols; col++) { - const int keyX = rowLeftMargin + col * (keyWidth + keySpacing); - const bool isSelected = row == selectedRow && col == selectedCol; - const bool activeKeySelected = isSelected && !cursorMode; - - if (urlMode) { - const int snippetIdx = col + row * 3; - if (snippetIdx < URL_SNIPPET_COUNT) { - GUI.drawKeyboardKey(renderer, Rect{keyX, rowY, keyWidth, keyHeight}, urlSnippets[snippetIdx], - activeKeySelected, nullptr); - } - } else { - const KeyDef& key = layout[row][col]; - - char primaryChar = key.primary; - char secondaryChar = key.secondary; - - if (!symMode && shiftState > 0 && key.secondary != '\0') { - primaryChar = key.secondary; - secondaryChar = key.primary; - } - - const char primaryBuf[2] = {primaryChar, '\0'}; - const char secondaryBuf[2] = {secondaryChar, '\0'}; - const bool showSecondary = !symMode && row == 0 && secondaryChar != '\0'; - GUI.drawKeyboardKey(renderer, Rect{keyX, rowY, keyWidth, keyHeight}, primaryBuf, activeKeySelected, - showSecondary ? secondaryBuf : nullptr); - } - } - } - - const int bottomRowY = keyboardStartY + contentRows * (keyHeight + keySpacing) + bottomRowGap; - const bool bottomSelected = isBottomRow(selectedRow); - - struct BottomKeyInfo { - KeyboardKeyType themeType; - const char* label; - }; - const BottomKeyInfo bottomKeys[BOTTOM_KEY_COUNT] = { - {(symMode || urlMode) ? KeyboardKeyType::Disabled : KeyboardKeyType::Shift, - (symMode || urlMode) ? shiftString[0] : shiftString[shiftState]}, - {KeyboardKeyType::Mode, urlMode ? "abc" : (symMode ? "abc" : "#@!")}, - {inputType == InputType::Url ? KeyboardKeyType::Mode : KeyboardKeyType::Space, - inputType == InputType::Url ? "URL" : nullptr}, - {KeyboardKeyType::Del, nullptr}, - {KeyboardKeyType::Ok, tr(STR_OK_BUTTON)}, - }; - - for (int i = 0; i < BOTTOM_KEY_COUNT; i++) { - const int keyX = bottomLeftMargin + i * (bottomKeyWidth + bkSpacing); - const bool isSelected = bottomSelected && i == selectedCol; - - const bool activeKeySelected = isSelected && !cursorMode; - GUI.drawKeyboardKey(renderer, Rect{keyX, bottomRowY, bottomKeyWidth, bottomKeyHeight}, bottomKeys[i].label, - activeKeySelected, nullptr, bottomKeys[i].themeType); - } - - if (cursorMode) { - int selKeyX, selKeyY, selKeyW, selKeyH; - if (isBottomRow(selectedRow)) { - selKeyX = bottomLeftMargin + selectedCol * (bottomKeyWidth + bkSpacing); - selKeyY = bottomRowY; - selKeyW = bottomKeyWidth; - selKeyH = bottomKeyHeight; - } else { - const int rowLM = urlMode ? urlLeftMargin : leftMargin; - selKeyX = rowLM + selectedCol * (keyWidth + keySpacing); - selKeyY = keyboardStartY + selectedRow * (keyHeight + keySpacing); - selKeyW = keyWidth; - selKeyH = keyHeight; - } - if (isBottomRow(selectedRow)) { - GUI.drawKeyboardKey(renderer, Rect{selKeyX, selKeyY, selKeyW, selKeyH}, bottomKeys[selectedCol].label, true, - nullptr, bottomKeys[selectedCol].themeType, true); - } else if (urlMode) { - const int idx = selectedCol + selectedRow * 3; - if (idx < URL_SNIPPET_COUNT) { - GUI.drawKeyboardKey(renderer, Rect{selKeyX, selKeyY, selKeyW, selKeyH}, urlSnippets[idx], true, nullptr, - KeyboardKeyType::Normal, true); - } - } else { - const KeyDef& selKey = layout[selectedRow][selectedCol]; - char selPrimary = selKey.primary; - char selSecondary = selKey.secondary; - if (!symMode && shiftState > 0 && selKey.secondary != '\0') { - selPrimary = selKey.secondary; - selSecondary = selKey.primary; - } - const char selPrimaryBuf[2] = {selPrimary, '\0'}; - const char selSecondaryBuf[2] = {selSecondary, '\0'}; - const bool selShowSecondary = !symMode && selectedRow == 0 && selSecondary != '\0'; - GUI.drawKeyboardKey(renderer, Rect{selKeyX, selKeyY, selKeyW, selKeyH}, selPrimaryBuf, true, - selShowSecondary ? selSecondaryBuf : nullptr, KeyboardKeyType::Normal, true); - } - } + fui::KeyboardProps props; + const fui::KeyboardLayout& layout = currentLayout(); + props.layout = &layout; + props.keyAction = ACTION_KEY; // one action id; loop() dispatches on key value + props.okLabel = tr(STR_OK_BUTTON); + props.shiftLabel = tr(STR_KEY_SHIFT); + // Match the label to the layer the mode key leads back from: the symbols + // layer and the URL snippet panel both label it "abc" in the static tables. + props.modeLabel = + (symbols || (inputType == InputType::Url && urlPanel)) ? tr(STR_KEY_MODE_ABC) : tr(STR_KEY_MODE_SYMBOLS); + props.inputMask = static_cast(fui::InputTouch | fui::InputLongPress); + props.selectedIndex = cursorMode ? -1 : static_cast(selectedLogicalIndex()); + props.labelText.font = fui::GfxRendererTarget::FONT_BODY; + props.altText.font = fui::GfxRendererTarget::FONT_SMALL; + props.gap = static_cast(metrics.keyboardKeySpacing); + props.padding = fui::Insets{0, 0, 0, 0}; + // Fingers land low on the bottom row (occlusion) and there is no key below + // to catch the miss — extend its hit band down to the button hints bar. + const int hintsTop = renderer.getScreenHeight() - metrics.buttonHintsHeight; + props.bottomHitOverflow = static_cast(std::max(0, hintsTop - (kbRect.y + kbRect.height))); + fui::keyboard(frame, kbRect, props); + interactionsReady = true; const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SELECT), tr(STR_DIR_LEFT), tr(STR_DIR_RIGHT)); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); diff --git a/src/activities/util/KeyboardEntryActivity.h b/src/activities/util/KeyboardEntryActivity.h index 128808ac..c04e535d 100644 --- a/src/activities/util/KeyboardEntryActivity.h +++ b/src/activities/util/KeyboardEntryActivity.h @@ -1,23 +1,21 @@ #pragma once +#include #include +#include #include -#include #include #include #include "activities/Activity.h" #include "util/ButtonNavigator.h" -struct KeyDef { - char primary; - char secondary; -}; - -enum class SpecialKeyType { Shift, Mode, Space, Del, Ok }; - enum class InputType { Text, Password, Url }; +// Text entry on the FreeInkUI keyboard component: the SDK layout tables and +// keyboard() do the key rendering and hit-rect registration, InteractionBuffer +// routes taps/long-presses, and this activity owns the text field, cursor +// editing, and the URL snippet layouts. class KeyboardEntryActivity : public Activity { public: explicit KeyboardEntryActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, @@ -43,16 +41,30 @@ class KeyboardEntryActivity : public Activity { ButtonNavigator buttonNavigator; - int selectedRow = 0; - int selectedCol = 0; - int shiftState = 0; - bool symMode = false; + // Keyboard layers. The letter/symbol layers come from the SDK's builtin + // layouts (with the always-visible number row); the URL layers are + // app-defined tables in the .cpp. + freeink::ui::KeyboardLayoutId layoutId = freeink::ui::KeyboardLayoutId::QwertyEn; + bool shifted = false; + bool symbols = false; + bool urlPanel = false; // URL snippet panel replaces the letter layer + + // Key hit rects registered by the keyboard component during render(); + // loop() routes touch snapshots against them. 5-row EN layout registers 41 + // keys, so 48 leaves headroom. + freeink::ui::InteractionBuffer<48> interactions; + + // GPIO selection over the current layout grid (row/col in layout terms; + // the bottom action row is just the last row). + int selRow = 0; + int selCol = 0; + bool confirmHeld = false; bool confirmLongHandled = false; bool cursorMode = false; bool togglePos = false; - size_t cursorPos = 0; + size_t cursorPos = 0; // byte offset into text (always on a code point boundary) bool upHeld = false; bool upLongHandled = false; bool downHeld = false; @@ -62,10 +74,15 @@ class KeyboardEntryActivity : public Activity { size_t savedCursorPos = 0; size_t rightStartCursorPos = 0; - bool urlMode = false; - static constexpr int URL_SNIPPET_COUNT = 9; - static constexpr const char* const urlSnippets[URL_SNIPPET_COUNT] = { - "https://", "www.", ".com", "http://", "192.168.", ".org", "/opds", ":8080", ".net"}; + // Tap/hold routing (threshold long-press, release swallow, slide re-arm) + // lives in the SDK; loop() feeds it the level-triggered touch state. + freeink::ui::TouchHoldRouter touchRouter; + + // loop() runs on the main task while render() rebuilds the interaction + // table on the render task; routing against a half-built table would read + // torn entries, so taps are dropped during the rebuild window. atomic (not + // volatile) so the flag also orders the table writes on dual-core targets. + std::atomic interactionsReady{false}; int delPressCount = 0; bool hintVisible = false; @@ -73,154 +90,40 @@ class KeyboardEntryActivity : public Activity { void onComplete(std::string text); void onCancel(); + bool cursorPositionFromPoint(int x, int y, size_t& position) const; + std::string displayTextForCurrentState() const; + // Advance of s[start, end) measured in place by temporarily null-terminating + // at `end` — avoids a substr temporary per measurement. + int measureRange(std::string& s, int start, int end) const; + // Largest line end in (start, s.length()] whose advance fits maxWidth. + // Binary search over the monotonic prefix advance; always advances at least + // one byte so an oversized glyph cannot stall the wrap loop. + int lineBreakEnd(std::string& s, int start, int maxWidth) const; + + const freeink::ui::KeyboardLayout& currentLayout() const; + const freeink::ui::KeyboardKey* selectedKey() const; + int selectedLogicalIndex() const; + void clampSelection(); + void moveSelectionRow(int delta); + void moveSelectionCol(int delta); + bool syncSelectionToValue(int16_t value); + // Handles one key activation (by stable key id). Returns true when the + // screen needs a repaint; OK/cancel finish the activity instead. + bool activateValue(int16_t value, bool longPress); + bool clearAllOrAltOnSelected(); + + void insertUtf8(const char* out); + bool backspaceUtf8(); + static size_t utf8Prev(const std::string& s, size_t pos); + static size_t utf8Next(const std::string& s, size_t pos); + + freeink::ui::Rect keyboardRect() const; static constexpr uint16_t LONG_PRESS_MS = 500; static constexpr uint16_t DEL_LONG_PRESS_MS = 1500; + static constexpr uint16_t TOUCH_LONG_PRESS_MS = 350; + static constexpr uint16_t TOUCH_DEL_LONG_PRESS_MS = 900; - static constexpr int COLS = 10; - static constexpr int ABC_ROWS = 4; - static constexpr int SYM_ROWS = 4; - static constexpr int BOTTOM_KEY_COUNT = 5; - - static constexpr KeyDef abcLayout[ABC_ROWS][COLS] = { - {{'1', '!'}, - {'2', '@'}, - {'3', '#'}, - {'4', '$'}, - {'5', '%'}, - {'6', '^'}, - {'7', '&'}, - {'8', '*'}, - {'9', '('}, - {'0', ')'}}, - {{'q', 'Q'}, - {'w', 'W'}, - {'e', 'E'}, - {'r', 'R'}, - {'t', 'T'}, - {'y', 'Y'}, - {'u', 'U'}, - {'i', 'I'}, - {'o', 'O'}, - {'p', 'P'}}, - {{'a', 'A'}, - {'s', 'S'}, - {'d', 'D'}, - {'f', 'F'}, - {'g', 'G'}, - {'h', 'H'}, - {'j', 'J'}, - {'k', 'K'}, - {'l', 'L'}, - {'-', '_'}}, - {{'z', 'Z'}, - {'x', 'X'}, - {'c', 'C'}, - {'v', 'V'}, - {'b', 'B'}, - {'n', 'N'}, - {'m', 'M'}, - {'=', '+'}, - {'.', '>'}, - {',', '<'}}, - }; - - static constexpr KeyDef urlLayout[ABC_ROWS][COLS] = { - {{'1', '!'}, - {'2', '@'}, - {'3', '#'}, - {'4', '$'}, - {'5', '%'}, - {'6', '^'}, - {'7', '&'}, - {'8', '*'}, - {'9', '('}, - {'0', ')'}}, - {{'q', 'Q'}, - {'w', 'W'}, - {'e', 'E'}, - {'r', 'R'}, - {'t', 'T'}, - {'y', 'Y'}, - {'u', 'U'}, - {'i', 'I'}, - {'o', 'O'}, - {'p', 'P'}}, - {{'a', 'A'}, - {'s', 'S'}, - {'d', 'D'}, - {'f', 'F'}, - {'g', 'G'}, - {'h', 'H'}, - {'j', 'J'}, - {'k', 'K'}, - {'l', 'L'}, - {'-', '_'}}, - {{'z', 'Z'}, - {'x', 'X'}, - {'c', 'C'}, - {'v', 'V'}, - {'b', 'B'}, - {'n', 'N'}, - {'m', 'M'}, - {':', '+'}, - {'.', '>'}, - {'/', '<'}}, - }; - - static constexpr KeyDef symLayout[SYM_ROWS][COLS] = { - {{'1', '\0'}, - {'2', '\0'}, - {'3', '\0'}, - {'4', '\0'}, - {'5', '\0'}, - {'6', '\0'}, - {'7', '\0'}, - {'8', '\0'}, - {'9', '\0'}, - {'0', '\0'}}, - {{'!', '\0'}, - {'@', '\0'}, - {'#', '\0'}, - {'$', '\0'}, - {'%', '\0'}, - {'^', '\0'}, - {'&', '\0'}, - {'*', '\0'}, - {'(', '\0'}, - {')', '\0'}}, - {{'-', '\0'}, - {'_', '\0'}, - {'=', '\0'}, - {'+', '\0'}, - {'[', '\0'}, - {']', '\0'}, - {'{', '\0'}, - {'}', '\0'}, - {';', '\0'}, - {':', '\0'}}, - {{'\'', '\0'}, - {'"', '\0'}, - {'/', '\0'}, - {'\\', '\0'}, - {'|', '\0'}, - {'?', '\0'}, - {'.', '\0'}, - {',', '\0'}, - {'~', '\0'}, - {'`', '\0'}}, - }; - - static const char* const shiftString[2]; - - int getContentRowCount() const; - int getContentColCount() const; - int getTotalRowCount() const; - bool isBottomRow(int row) const; - char getSelectedChar() const; - char getAlternativeChar() const; - bool handleKeyPress(); - bool insertChar(char c); - void insertString(const std::string& str); - void mapColContentBottom(int& col, bool goingUp) const; + // App-specific key id: toggles the URL snippet panel (URL fields only). + static constexpr int16_t URL_PANEL_KEY = -3; }; diff --git a/src/components/OptionPopup.h b/src/components/OptionPopup.h index e98bfbd0..f9c2bc54 100644 --- a/src/components/OptionPopup.h +++ b/src/components/OptionPopup.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include @@ -8,6 +9,7 @@ #include "GfxRenderer.h" #include "MappedInputManager.h" #include "components/UITheme.h" +#include "fontIds.h" class OptionPopup { public: @@ -20,6 +22,7 @@ class OptionPopup { } selectedIndex = currentIndex; onSelectCallback = std::move(onSelect); + layoutValid = false; active = true; } @@ -32,6 +35,7 @@ class OptionPopup { } selectedIndex = currentIndex; onSelectCallback = std::move(onSelect); + layoutValid = false; active = true; } @@ -41,6 +45,7 @@ class OptionPopup { ownedStrings = options; selectedIndex = currentIndex; onSelectCallback = std::move(onSelect); + layoutValid = false; active = true; } @@ -48,6 +53,39 @@ class OptionPopup { if (!active) return false; const int count = static_cast(ownedStrings.size()); + int tx = 0; + int ty = 0; + if (input.wasScreenTouchDown(tx, ty)) { + const auto& hitLayout = getLayout(input.getRenderer()); + for (int i = 0; i < static_cast(hitLayout.options.size()); i++) { + if (contains(hitLayout.options[i], tx, ty)) { + if (selectedIndex != i) { + selectedIndex = i; + requestUpdate(); + } + break; + } + } + return true; + } + if (input.wasScreenTapped(tx, ty)) { + const auto& hitLayout = getLayout(input.getRenderer()); + for (int i = 0; i < static_cast(hitLayout.options.size()); i++) { + if (contains(hitLayout.options[i], tx, ty)) { + selectedIndex = i; + active = false; + if (onSelectCallback) onSelectCallback(selectedIndex); + requestUpdate(); + return true; + } + } + // Taps on the dialog chrome (title, padding) keep the popup open; taps outside dismiss it + if (contains(hitLayout.dialog, tx, ty)) return true; + active = false; + requestUpdate(); + return true; + } + if (input.wasPressed(MappedInputManager::Button::Up) || input.wasPressed(MappedInputManager::Button::Left)) { selectedIndex = (selectedIndex - 1 + count) % count; requestUpdate(); @@ -87,9 +125,69 @@ class OptionPopup { bool isActive() const { return active; } private: + struct Layout { + Rect dialog{0, 0, 0, 0}; + std::vector options; + }; + + // Text measurement is expensive and wasScreenTouchDown() is level-triggered, so the + // layout is computed once per show() and cached rather than rebuilt every loop(). + const Layout& getLayout(const GfxRenderer& renderer) const { + if (layoutValid) return layout; + + const auto& metrics = UITheme::getInstance().getMetrics(); + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); + const int optionFontId = metrics.optionPopupUseSmallFont ? UI_10_FONT_ID : UI_12_FONT_ID; + const EpdFontFamily::Style optionStyle = + metrics.optionPopupOptionFontBold ? EpdFontFamily::BOLD : EpdFontFamily::REGULAR; + + const int itemSpacing = metrics.optionPopupItemSpacing; + const int innerPadding = metrics.optionPopupInnerPadding; + const int selectionHPadding = metrics.optionPopupSelectionHPadding; + const int selectionVPadding = metrics.optionPopupSelectionVPadding; + + const int optionLineHeight = renderer.getLineHeight(optionFontId); + const int titleLineHeight = renderer.getLineHeight(UI_12_FONT_ID); + const int rowHeight = optionLineHeight + selectionVPadding * 2; + + int maxTextWidth = renderer.getTextWidth(UI_12_FONT_ID, title.c_str(), EpdFontFamily::BOLD); + for (const auto& opt : ownedStrings) { + const int width = renderer.getTextWidth(optionFontId, opt.c_str(), optionStyle); + if (width > maxTextWidth) maxTextWidth = width; + } + + const int optionCount = static_cast(ownedStrings.size()); + const int listHeight = rowHeight * optionCount + itemSpacing * (optionCount - 1); + const int dialogW = std::min((maxTextWidth + innerPadding * 2 + selectionHPadding * 2) * 12 / 10, + pageWidth - metrics.optionPopupDialogSideMargin * 2); + const int contentHeight = titleLineHeight + metrics.optionPopupTitleGap + listHeight; + const int dialogH = contentHeight + innerPadding * 2; + const int dialogX = (pageWidth - dialogW) / 2; + const int dialogY = (pageHeight - dialogH) / 2; + const int itemRectX = dialogX + innerPadding; + const int itemRectW = dialogW - innerPadding * 2; + const int firstItemY = dialogY + innerPadding + titleLineHeight + metrics.optionPopupTitleGap; + + layout.dialog = Rect{dialogX, dialogY, dialogW, dialogH}; + layout.options.clear(); + layout.options.reserve(optionCount); + for (int i = 0; i < optionCount; i++) { + layout.options.push_back(Rect{itemRectX, firstItemY + i * (rowHeight + itemSpacing), itemRectW, rowHeight}); + } + layoutValid = true; + return layout; + } + + static 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; + } + bool active = false; std::string title; std::vector ownedStrings; int selectedIndex = 0; std::function onSelectCallback; + mutable Layout layout; + mutable bool layoutValid = false; }; diff --git a/src/components/UITheme.cpp b/src/components/UITheme.cpp index 9220d43c..6076e6c8 100644 --- a/src/components/UITheme.cpp +++ b/src/components/UITheme.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include @@ -48,11 +49,27 @@ void UITheme::setTheme(CrossPointSettings::UI_THEME type) { currentMetrics = &Lyra3CoversMetrics::values; break; } + metricsValid = false; +} + +const ThemeMetrics& UITheme::getMetrics() const { + // hasTouch() can flip once touch init completes after static construction, so the + // cached copy is refreshed when the flag differs instead of copying the struct per call. + const bool touch = gpio.hasTouch(); + if (!metricsValid || touch != metricsForTouch) { + adjustedMetrics = *currentMetrics; + if (touch) { + adjustedMetrics.buttonHintsHeight = 0; + } + metricsForTouch = touch; + metricsValid = true; + } + return adjustedMetrics; } int UITheme::getNumberOfItemsPerPage(const GfxRenderer& renderer, bool hasHeader, bool hasTabBar, bool hasButtonHints, bool hasSubtitle, int extraReservedHeight) { - const ThemeMetrics& metrics = UITheme::getInstance().getMetrics(); + const ThemeMetrics metrics = UITheme::getInstance().getMetrics(); auto orientation = renderer.getOrientation(); int reservedHeight = metrics.topPadding; if (hasHeader) { @@ -66,8 +83,7 @@ int UITheme::getNumberOfItemsPerPage(const GfxRenderer& renderer, bool hasHeader reservedHeight += metrics.verticalSpacing + metrics.buttonHintsHeight; } const int availableHeight = renderer.getScreenHeight() - reservedHeight - extraReservedHeight; - int rowHeight = hasSubtitle ? metrics.listWithSubtitleRowHeight : metrics.listRowHeight; - return availableHeight / rowHeight; + return UITheme::getInstance().getTheme().getListPageItems(availableHeight, hasSubtitle); } // Screen area excluding the button hints @@ -76,27 +92,28 @@ Rect UITheme::getScreenSafeArea(const GfxRenderer& renderer, bool hasFrontButton const int screenWidth = renderer.getScreenWidth(); const int screenHeight = renderer.getScreenHeight(); Rect safeArea = Rect{0, 0, screenWidth, screenHeight}; + const ThemeMetrics metrics = getMetrics(); switch (orientation) { case GfxRenderer::Orientation::Portrait: if (hasFrontButtonHints) { - safeArea.height -= currentMetrics->buttonHintsHeight; + safeArea.height -= metrics.buttonHintsHeight; } break; case GfxRenderer::Orientation::LandscapeClockwise: if (hasFrontButtonHints) { - safeArea.x += currentMetrics->buttonHintsHeight; - safeArea.width -= currentMetrics->buttonHintsHeight; + safeArea.x += metrics.buttonHintsHeight; + safeArea.width -= metrics.buttonHintsHeight; } break; case GfxRenderer::Orientation::PortraitInverted: if (hasFrontButtonHints) { - safeArea.y += currentMetrics->buttonHintsHeight; - safeArea.height -= currentMetrics->buttonHintsHeight; + safeArea.y += metrics.buttonHintsHeight; + safeArea.height -= metrics.buttonHintsHeight; } break; case GfxRenderer::Orientation::LandscapeCounterClockwise: if (hasFrontButtonHints) { - safeArea.width -= currentMetrics->buttonHintsHeight; + safeArea.width -= metrics.buttonHintsHeight; } break; } @@ -128,7 +145,7 @@ UIIcon UITheme::getFileIcon(const std::string& filename) { } int UITheme::getStatusBarHeight() { - const ThemeMetrics& metrics = UITheme::getInstance().getMetrics(); + const ThemeMetrics metrics = UITheme::getInstance().getMetrics(); // Add status bar margin const bool showStatusBar = @@ -142,7 +159,7 @@ int UITheme::getStatusBarHeight() { } int UITheme::getProgressBarHeight() { - const ThemeMetrics& metrics = UITheme::getInstance().getMetrics(); + const ThemeMetrics metrics = UITheme::getInstance().getMetrics(); const bool showProgressBar = SETTINGS.statusBarProgressBar != CrossPointSettings::STATUS_BAR_PROGRESS_BAR::HIDE_PROGRESS; return (showProgressBar ? (((SETTINGS.statusBarProgressBarThickness + 1) * 2) + metrics.progressBarMarginTop) : 0); diff --git a/src/components/UITheme.h b/src/components/UITheme.h index 66602541..b5fefae6 100644 --- a/src/components/UITheme.h +++ b/src/components/UITheme.h @@ -16,7 +16,7 @@ class UITheme { UITheme(); static UITheme& getInstance() { return instance; } - const ThemeMetrics& getMetrics() const { return *currentMetrics; } + const ThemeMetrics& getMetrics() const; const BaseTheme& getTheme() const { return *currentTheme; } Rect getScreenSafeArea(const GfxRenderer& renderer, bool hasFrontButtonHints = false, bool hasSideButtonHints = false); @@ -34,6 +34,9 @@ class UITheme { private: const ThemeMetrics* currentMetrics; std::unique_ptr currentTheme; + mutable ThemeMetrics adjustedMetrics; + mutable bool metricsValid = false; + mutable bool metricsForTouch = false; }; // Helper macro to access current theme diff --git a/src/components/icons/search24.h b/src/components/icons/search24.h new file mode 100644 index 00000000..cbd9d483 --- /dev/null +++ b/src/components/icons/search24.h @@ -0,0 +1,13 @@ +#pragma once + +#include + +#include + +// Generated by freeink-sdk/libs/assets/Icons/tools/gen_icons.py from Lucide search.svg. +static const uint8_t Search24IconBits[] = { + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x03, 0xFF, 0xFC, 0x00, 0xFF, 0xF8, 0xFC, 0x7F, 0xF1, 0xFE, 0x3F, + 0xE3, 0xFF, 0x1F, 0xE7, 0xFF, 0x9F, 0xCF, 0xFF, 0xCF, 0xCF, 0xFF, 0xCF, 0xCF, 0xFF, 0xCF, 0xCF, 0xFF, 0xCF, + 0xCF, 0xFF, 0xCF, 0xCF, 0xFF, 0xCF, 0xE7, 0xFF, 0x9F, 0xE3, 0xFF, 0x1F, 0xF1, 0xFE, 0x3F, 0xF8, 0xFC, 0x1F, + 0xFC, 0x00, 0x8F, 0xFF, 0x03, 0xC7, 0xFF, 0xFF, 0xE3, 0xFF, 0xFF, 0xF3, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; +static const freeink::Icon Search24Icon = {24, 24, 11, Search24IconBits}; diff --git a/src/components/themes/BaseTheme.cpp b/src/components/themes/BaseTheme.cpp index 5fe6e545..361e1c00 100644 --- a/src/components/themes/BaseTheme.cpp +++ b/src/components/themes/BaseTheme.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -156,6 +157,10 @@ void BaseTheme::drawProgressBar(const GfxRenderer& renderer, Rect rect, const si void BaseTheme::drawButtonHints(GfxRenderer& renderer, const char* btn1, const char* btn2, const char* btn3, const char* btn4) const { + if (gpio.hasTouch()) { + return; + } + const GfxRenderer::Orientation orig_orientation = renderer.getOrientation(); renderer.setOrientation(GfxRenderer::Orientation::Portrait); @@ -186,6 +191,10 @@ void BaseTheme::drawButtonHints(GfxRenderer& renderer, const char* btn1, const c } void BaseTheme::drawSideButtonHints(const GfxRenderer& renderer, const char* topBtn, const char* bottomBtn) const { + if (gpio.hasTouch()) { + return; + } + const int screenWidth = renderer.getScreenWidth(); constexpr int buttonWidth = BaseMetrics::values.sideButtonHintsWidth; // Width on screen (height when rotated) constexpr int buttonHeight = 80; // Height on screen (width when rotated) @@ -250,9 +259,15 @@ void BaseTheme::drawSideButtonHints(const GfxRenderer& renderer, const char* top } } -int BaseTheme::getListPageItems(int contentHeight, bool hasSubtitle) const { +int BaseTheme::getListRowStep(bool hasSubtitle) const { int rowHeight = (hasSubtitle) ? BaseMetrics::values.listWithSubtitleRowHeight : BaseMetrics::values.listRowHeight; - return contentHeight / rowHeight; + return rowHeight; +} + +int BaseTheme::getListPageItems(int contentHeight, bool hasSubtitle) const { + const int rowStep = getListRowStep(hasSubtitle); + if (rowStep <= 0) return 1; + return std::max(1, contentHeight / rowStep); } void BaseTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex, @@ -263,7 +278,7 @@ void BaseTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount, const std::function& rowDimmed) const { int rowHeight = (rowSubtitle != nullptr) ? BaseMetrics::values.listWithSubtitleRowHeight : BaseMetrics::values.listRowHeight; - int pageItems = rect.height / rowHeight; + int pageItems = rowHeight > 0 ? std::max(1, rect.height / rowHeight) : 1; const int totalPages = (itemCount + pageItems - 1) / pageItems; if (totalPages > 1) { @@ -432,6 +447,29 @@ void BaseTheme::drawTabBar(const GfxRenderer& renderer, const Rect rect, const s } } +bool BaseTheme::tabIndexFromPoint(const GfxRenderer& renderer, const Rect rect, const std::vector& tabs, + const int x, const int y, int& index) const { + if (tabs.empty() || y < rect.y || y >= rect.y + rect.height) { + return false; + } + + int currentX = rect.x + BaseMetrics::values.contentSidePadding; + for (size_t i = 0; i < tabs.size(); i++) { + const auto& tab = tabs[i]; + const int textWidth = + renderer.getTextWidth(UI_12_FONT_ID, tab.label, tab.selected ? EpdFontFamily::BOLD : EpdFontFamily::REGULAR); + const int left = (i == 0) ? rect.x : currentX - BaseMetrics::values.tabSpacing / 2; + const int right = currentX + textWidth + BaseMetrics::values.tabSpacing / 2; + if (x >= left && x < right) { + index = static_cast(i); + return true; + } + currentX += textWidth + BaseMetrics::values.tabSpacing; + } + + return false; +} + // Draw the "Recent Book" cover card on the home screen // TODO: Refactor method to make it cleaner, split into smaller methods void BaseTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector& recentBooks, @@ -912,104 +950,6 @@ void BaseTheme::drawTextField(const GfxRenderer& renderer, Rect rect, const int } } -void BaseTheme::drawKeyboardKey(const GfxRenderer& renderer, Rect rect, const char* label, const bool isSelected, - const char* secondaryLabel, const KeyboardKeyType keyType, - const bool inactiveSelection) const { - const auto& metrics = UITheme::getInstance().getMetrics(); - const int cr = metrics.keyboardKeyCornerRadius; - const bool isSpecialKey = keyType == KeyboardKeyType::Shift || keyType == KeyboardKeyType::Mode || - keyType == KeyboardKeyType::Del || keyType == KeyboardKeyType::Space || - keyType == KeyboardKeyType::Ok || keyType == KeyboardKeyType::Disabled; - - if (isSelected) { - if (inactiveSelection) { - if (cr > 0) { - renderer.fillRoundedRect(rect.x, rect.y, rect.width, rect.height, cr, Color::LightGray); - } else { - renderer.drawRect(rect.x, rect.y, rect.width, rect.height, 2, true); - } - } else if (keyType == KeyboardKeyType::Disabled) { - if (cr > 0) { - renderer.fillRoundedRect(rect.x, rect.y, rect.width, rect.height, cr, Color::LightGray); - } else { - renderer.fillRectDither(rect.x, rect.y, rect.width, rect.height, Color::LightGray); - } - } else { - if (cr > 0) { - renderer.fillRoundedRect(rect.x, rect.y, rect.width, rect.height, cr, Color::Black); - } else { - renderer.fillRect(rect.x, rect.y, rect.width, rect.height, true); - } - } - } else { - if (metrics.keyboardFillUnselected) { - if (keyType == KeyboardKeyType::Disabled) { - if (cr > 0) { - renderer.fillRoundedRect(rect.x, rect.y, rect.width, rect.height, cr, Color::LightGray); - } else { - renderer.fillRectDither(rect.x, rect.y, rect.width, rect.height, Color::LightGray); - } - } else { - if (cr > 0) { - renderer.fillRoundedRect(rect.x, rect.y, rect.width, rect.height, cr, Color::White); - } else { - renderer.fillRect(rect.x, rect.y, rect.width, rect.height, false); - } - } - } - - const bool shouldDrawOutline = - (metrics.keyboardDrawSpecialOutlineWhenUnselected && isSpecialKey) || metrics.keyboardOutlineAllUnselected; - if (shouldDrawOutline) { - if (cr > 0) { - renderer.drawRoundedRect(rect.x, rect.y, rect.width, rect.height, 1, cr, true); - } else { - renderer.drawRect(rect.x, rect.y, rect.width, rect.height); - } - } - } - - const bool invert = isSelected && !inactiveSelection; - - if (keyType == KeyboardKeyType::Space) { - const int lineHalfWidth = rect.width * 3 / 10; - const int centerX = rect.x + rect.width / 2; - const int lineY = rect.y + rect.height / 2 + 3; - renderer.drawLine(centerX - lineHalfWidth, lineY, centerX + lineHalfWidth, lineY, 3, !invert); - return; - } - - if (keyType == KeyboardKeyType::Del) { - const int centerX = rect.x + rect.width / 2; - const int centerY = rect.y + rect.height / 2; - const int arrowLen = rect.width / 4; - const int arrowHead = std::max(metrics.keyboardMinArrowHeadSize, arrowLen / 2); - renderer.drawLine(centerX - arrowLen / 2, centerY, centerX + arrowLen / 2, centerY, 3, !invert); - renderer.drawLine(centerX - arrowLen / 2, centerY, centerX - arrowLen / 2 + arrowHead, centerY - arrowHead, 3, - !invert); - renderer.drawLine(centerX - arrowLen / 2, centerY, centerX - arrowLen / 2 + arrowHead, centerY + arrowHead, 3, - !invert); - return; - } - - if (label == nullptr || label[0] == '\0') { - return; - } - - const bool hasSecondary = secondaryLabel != nullptr && secondaryLabel[0] != '\0'; - const int itemWidth = renderer.getTextWidth(UI_12_FONT_ID, label); - const int textX = rect.x + (rect.width - itemWidth) / 2; - const int textY = rect.y + (rect.height - renderer.getLineHeight(UI_12_FONT_ID)) / 2; - - renderer.drawText(UI_12_FONT_ID, textX, textY, label, !invert); - - if (hasSecondary) { - const int secWidth = renderer.getTextWidth(SMALL_FONT_ID, secondaryLabel); - renderer.drawText(SMALL_FONT_ID, rect.x + rect.width - secWidth - metrics.keyboardSecondaryLabelRightPadding, - rect.y + metrics.keyboardSecondaryLabelTopPadding, secondaryLabel, !invert); - } -} - void BaseTheme::drawOptionPopup(const GfxRenderer& renderer, const char* title, const std::vector& options, int selectedIndex) const { const auto& metrics = UITheme::getInstance().getMetrics(); diff --git a/src/components/themes/BaseTheme.h b/src/components/themes/BaseTheme.h index b50bfc6d..1d321d75 100644 --- a/src/components/themes/BaseTheme.h +++ b/src/components/themes/BaseTheme.h @@ -61,24 +61,12 @@ struct ThemeMetrics { int progressBarMarginTop; int statusBarHorizontalMargin; int statusBarVerticalMargin; - - int keyboardKeyWidth; int keyboardKeyHeight; int keyboardKeySpacing; - int keyboardBottomKeyHeight; - int keyboardBottomKeySpacing; - bool keyboardBottomAligned; bool keyboardCenteredText; int keyboardVerticalOffset; int keyboardTextFieldWidthPercent; int keyboardWidthPercent; - int keyboardKeyCornerRadius; - bool keyboardFillUnselected; - bool keyboardOutlineAllUnselected; - bool keyboardDrawSpecialOutlineWhenUnselected; - int keyboardSecondaryLabelRightPadding; - int keyboardSecondaryLabelTopPadding; - int keyboardMinArrowHeadSize; float popupTopOffsetRatio; int popupMarginX; @@ -115,8 +103,6 @@ struct ThemeMetrics { enum UIIcon { None = 0, Folder, Text, Image, Book, File, Recent, Settings, Transfer, Library, Wifi, Hotspot, Bookmark }; -enum class KeyboardKeyType { Normal, Shift, Mode, Space, Del, Ok, Disabled }; - // Default theme implementation (Classic Theme) // Additional themes can inherit from this and override methods as needed @@ -150,23 +136,12 @@ constexpr ThemeMetrics values = {.batteryWidth = 15, .progressBarMarginTop = 1, .statusBarHorizontalMargin = 5, .statusBarVerticalMargin = 19, - .keyboardKeyWidth = 22, - .keyboardKeyHeight = 40, + .keyboardKeyHeight = 48, .keyboardKeySpacing = 0, - .keyboardBottomKeyHeight = 35, - .keyboardBottomKeySpacing = 5, - .keyboardBottomAligned = true, .keyboardCenteredText = false, .keyboardVerticalOffset = -13, .keyboardTextFieldWidthPercent = 85, - .keyboardWidthPercent = 90, - .keyboardKeyCornerRadius = 0, - .keyboardFillUnselected = false, - .keyboardOutlineAllUnselected = false, - .keyboardDrawSpecialOutlineWhenUnselected = true, - .keyboardSecondaryLabelRightPadding = 1, - .keyboardSecondaryLabelTopPadding = 0, - .keyboardMinArrowHeadSize = 0, + .keyboardWidthPercent = 94, .popupTopOffsetRatio = 0.075f, .popupMarginX = 15, .popupMarginY = 15, @@ -212,6 +187,7 @@ class BaseTheme { virtual void drawButtonHints(GfxRenderer& renderer, const char* btn1, const char* btn2, const char* btn3, const char* btn4) const; virtual void drawSideButtonHints(const GfxRenderer& renderer, const char* topBtn, const char* bottomBtn) const; + virtual int getListRowStep(bool hasSubtitle) const; virtual int getListPageItems(int contentHeight, bool hasSubtitle) const; virtual void drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex, const std::function& rowTitle, @@ -225,6 +201,8 @@ class BaseTheme { const char* rightLabel = nullptr) const; virtual void drawTabBar(const GfxRenderer& renderer, Rect rect, const std::vector& tabs, bool selected) const; + virtual bool tabIndexFromPoint(const GfxRenderer& renderer, Rect rect, const std::vector& tabs, int x, int y, + int& index) const; virtual void drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector& recentBooks, const int selectorIndex, bool& coverRendered, bool& coverBufferStored, bool& bufferRestored, std::function storeCoverBuffer) const; @@ -242,9 +220,6 @@ class BaseTheme { void drawHelpText(const GfxRenderer& renderer, Rect rect, const char* label) const; virtual void drawTextField(const GfxRenderer& renderer, Rect rect, const int textWidth, bool cursorMode = false, int contentStartX = 0, int contentWidth = 0) const; - virtual void drawKeyboardKey(const GfxRenderer& renderer, Rect rect, const char* label, const bool isSelected, - const char* secondaryLabel = nullptr, KeyboardKeyType keyType = KeyboardKeyType::Normal, - bool inactiveSelection = false) const; virtual bool showsFileIcons() const { return false; } // Shared constants and helpers for battery drawing (used by all themes) diff --git a/src/components/themes/lyra/LyraTheme.cpp b/src/components/themes/lyra/LyraTheme.cpp index c5af0a39..c3d772a5 100644 --- a/src/components/themes/lyra/LyraTheme.cpp +++ b/src/components/themes/lyra/LyraTheme.cpp @@ -6,6 +6,7 @@ #include #include +#include #include #include #include @@ -205,9 +206,37 @@ void LyraTheme::drawTabBar(const GfxRenderer& renderer, Rect rect, const std::ve renderer.drawLine(rect.x, rect.y + rect.height - 1, rect.x + rect.width - 1, rect.y + rect.height - 1, true); } -int LyraTheme::getListPageItems(int contentHeight, bool hasSubtitle) const { +bool LyraTheme::tabIndexFromPoint(const GfxRenderer& renderer, const Rect rect, const std::vector& tabs, + const int x, const int y, int& index) const { + if (tabs.empty() || y < rect.y || y >= rect.y + rect.height) { + return false; + } + + int currentX = rect.x + LyraMetrics::values.contentSidePadding; + for (size_t i = 0; i < tabs.size(); i++) { + const int textWidth = renderer.getTextWidth(UI_10_FONT_ID, tabs[i].label, EpdFontFamily::REGULAR); + const int tabWidth = textWidth + 2 * hPaddingInSelection; + const int left = (i == 0) ? rect.x : currentX - LyraMetrics::values.tabSpacing / 2; + const int right = currentX + tabWidth + LyraMetrics::values.tabSpacing / 2; + if (x >= left && x < right) { + index = static_cast(i); + return true; + } + currentX += tabWidth + LyraMetrics::values.tabSpacing; + } + + return false; +} + +int LyraTheme::getListRowStep(bool hasSubtitle) const { int rowHeight = (hasSubtitle) ? LyraMetrics::values.listWithSubtitleRowHeight : LyraMetrics::values.listRowHeight; - return contentHeight / rowHeight; + return rowHeight; +} + +int LyraTheme::getListPageItems(int contentHeight, bool hasSubtitle) const { + const int rowStep = getListRowStep(hasSubtitle); + if (rowStep <= 0) return 1; + return std::max(1, contentHeight / rowStep); } void LyraTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex, @@ -218,7 +247,7 @@ void LyraTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount, const std::function& rowDimmed) const { int rowHeight = (rowSubtitle != nullptr) ? LyraMetrics::values.listWithSubtitleRowHeight : LyraMetrics::values.listRowHeight; - int pageItems = rect.height / rowHeight; + int pageItems = rowHeight > 0 ? std::max(1, rect.height / rowHeight) : 1; const int totalPages = (itemCount + pageItems - 1) / pageItems; if (totalPages > 1) { @@ -319,6 +348,10 @@ void LyraTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount, void LyraTheme::drawButtonHints(GfxRenderer& renderer, const char* btn1, const char* btn2, const char* btn3, const char* btn4) const { + if (gpio.hasTouch()) { + return; + } + const GfxRenderer::Orientation orig_orientation = renderer.getOrientation(); renderer.setOrientation(GfxRenderer::Orientation::Portrait); @@ -357,6 +390,10 @@ void LyraTheme::drawButtonHints(GfxRenderer& renderer, const char* btn1, const c } void LyraTheme::drawSideButtonHints(const GfxRenderer& renderer, const char* topBtn, const char* bottomBtn) const { + if (gpio.hasTouch()) { + return; + } + const int screenWidth = renderer.getScreenWidth(); constexpr int buttonWidth = LyraMetrics::values.sideButtonHintsWidth; // Width on screen (height when rotated) constexpr int buttonHeight = 78; // Height on screen (width when rotated) diff --git a/src/components/themes/lyra/LyraTheme.h b/src/components/themes/lyra/LyraTheme.h index 15d94e66..95e15d0e 100644 --- a/src/components/themes/lyra/LyraTheme.h +++ b/src/components/themes/lyra/LyraTheme.h @@ -35,23 +35,12 @@ constexpr ThemeMetrics values = {.batteryWidth = 16, .progressBarMarginTop = 1, .statusBarHorizontalMargin = 5, .statusBarVerticalMargin = 19, - .keyboardKeyWidth = 31, - .keyboardKeyHeight = 40, + .keyboardKeyHeight = 48, .keyboardKeySpacing = 0, - .keyboardBottomKeyHeight = 35, - .keyboardBottomKeySpacing = 5, - .keyboardBottomAligned = true, .keyboardCenteredText = false, .keyboardVerticalOffset = -7, .keyboardTextFieldWidthPercent = 85, - .keyboardWidthPercent = 90, - .keyboardKeyCornerRadius = 6, - .keyboardFillUnselected = false, - .keyboardOutlineAllUnselected = false, - .keyboardDrawSpecialOutlineWhenUnselected = true, - .keyboardSecondaryLabelRightPadding = 1, - .keyboardSecondaryLabelTopPadding = 0, - .keyboardMinArrowHeadSize = 0, + .keyboardWidthPercent = 94, .popupTopOffsetRatio = 0.165f, .popupMarginX = 16, .popupMarginY = 12, @@ -92,6 +81,9 @@ class LyraTheme : public BaseTheme { const char* rightLabel = nullptr) const override; void drawTabBar(const GfxRenderer& renderer, Rect rect, const std::vector& tabs, bool selected) const override; + bool tabIndexFromPoint(const GfxRenderer& renderer, Rect rect, const std::vector& tabs, int x, int y, + int& index) const override; + int getListRowStep(bool hasSubtitle) const override; int getListPageItems(int contentHeight, bool hasSubtitle) const override; void drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex, const std::function& rowTitle, diff --git a/src/components/themes/roundedraff/RoundedRaffTheme.cpp b/src/components/themes/roundedraff/RoundedRaffTheme.cpp index 0e609ac9..999be97f 100644 --- a/src/components/themes/roundedraff/RoundedRaffTheme.cpp +++ b/src/components/themes/roundedraff/RoundedRaffTheme.cpp @@ -1,6 +1,7 @@ #include "RoundedRaffTheme.h" #include +#include #include #include @@ -112,6 +113,18 @@ void RoundedRaffTheme::drawTabBar(const GfxRenderer& renderer, Rect rect, const renderer.drawLine(rect.x, rect.y + rect.height - 1, rect.x + rect.width - 1, rect.y + rect.height - 1, true); } +bool RoundedRaffTheme::tabIndexFromPoint(const GfxRenderer& renderer, const Rect rect, const std::vector& tabs, + const int x, const int y, int& index) const { + (void)renderer; + if (tabs.empty() || y < rect.y || y >= rect.y + rect.height || x < rect.x || x >= rect.x + rect.width) { + return false; + } + + const int slotWidth = std::max(1, rect.width / static_cast(tabs.size())); + index = std::min(static_cast(tabs.size()) - 1, (x - rect.x) / slotWidth); + return true; +} + void RoundedRaffTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector& recentBooks, const int selectorIndex, bool& coverRendered, bool& coverBufferStored, bool& bufferRestored, std::function storeCoverBuffer) const { @@ -248,57 +261,14 @@ void RoundedRaffTheme::drawTextField(const GfxRenderer& renderer, Rect rect, con renderer.drawLine(lineStart, lineY, lineStart + lineW - 1, lineY, thickness, true); } -void RoundedRaffTheme::drawKeyboardKey(const GfxRenderer& renderer, Rect rect, const char* label, const bool isSelected, - const char* secondaryLabel, const KeyboardKeyType keyType, - const bool inactiveSelection) const { - constexpr int keyRadius = 10; - const bool disabled = keyType == KeyboardKeyType::Disabled; - const bool invert = isSelected && !inactiveSelection; +int RoundedRaffTheme::getListRowStep(bool hasSubtitle) const { + const int rowHeight = + hasSubtitle ? RoundedRaffMetrics::values.listWithSubtitleRowHeight : RoundedRaffMetrics::values.listRowHeight; + return rowHeight + kSelectableRowGap; +} - if (isSelected) { - const Color fillColor = (inactiveSelection || disabled) ? Color::LightGray : Color::Black; - renderer.fillRoundedRect(rect.x, rect.y, rect.width, rect.height, keyRadius, fillColor); - } else { - if (disabled) { - renderer.fillRoundedRect(rect.x, rect.y, rect.width, rect.height, keyRadius, Color::LightGray); - } else { - renderer.fillRoundedRect(rect.x, rect.y, rect.width, rect.height, keyRadius, Color::White); - } - renderer.drawRoundedRect(rect.x, rect.y, rect.width, rect.height, 1, keyRadius, true); - } - - if (keyType == KeyboardKeyType::Space) { - const int lineHalfWidth = rect.width * 3 / 10; - const int centerX = rect.x + rect.width / 2; - const int lineY = rect.y + rect.height / 2 + 3; - renderer.drawLine(centerX - lineHalfWidth, lineY, centerX + lineHalfWidth, lineY, 3, !invert); - return; - } - - if (keyType == KeyboardKeyType::Del) { - const int centerX = rect.x + rect.width / 2; - const int centerY = rect.y + rect.height / 2; - const int arrowLen = rect.width / 4; - const int arrowHead = std::max(1, arrowLen / 2); - renderer.drawLine(centerX - arrowLen / 2, centerY, centerX + arrowLen / 2, centerY, 3, !invert); - renderer.drawLine(centerX - arrowLen / 2, centerY, centerX - arrowLen / 2 + arrowHead, centerY - arrowHead, 3, - !invert); - renderer.drawLine(centerX - arrowLen / 2, centerY, centerX - arrowLen / 2 + arrowHead, centerY + arrowHead, 3, - !invert); - return; - } - - if (label != nullptr && label[0] != '\0') { - const int itemWidth = renderer.getTextWidth(UI_12_FONT_ID, label); - const int textX = rect.x + (rect.width - itemWidth) / 2; - const int textY = rect.y + (rect.height - renderer.getLineHeight(UI_12_FONT_ID)) / 2; - renderer.drawText(UI_12_FONT_ID, textX, textY, label, !invert); - } - - if (secondaryLabel != nullptr && secondaryLabel[0] != '\0') { - const int secWidth = renderer.getTextWidth(SMALL_FONT_ID, secondaryLabel); - renderer.drawText(SMALL_FONT_ID, rect.x + rect.width - secWidth - 3, rect.y + 1, secondaryLabel, !invert); - } +int RoundedRaffTheme::getListPageItems(int contentHeight, bool hasSubtitle) const { + return std::max(1, contentHeight / getListRowStep(hasSubtitle)); } void RoundedRaffTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex, @@ -383,6 +353,10 @@ void RoundedRaffTheme::drawList(const GfxRenderer& renderer, Rect rect, int item void RoundedRaffTheme::drawButtonHints(GfxRenderer& renderer, const char* btn1, const char* btn2, const char* btn3, const char* btn4) const { + if (gpio.hasTouch()) { + return; + } + const GfxRenderer::Orientation origOrientation = renderer.getOrientation(); renderer.setOrientation(GfxRenderer::Orientation::Portrait); diff --git a/src/components/themes/roundedraff/RoundedRaffTheme.h b/src/components/themes/roundedraff/RoundedRaffTheme.h index 316b8b05..d359c505 100644 --- a/src/components/themes/roundedraff/RoundedRaffTheme.h +++ b/src/components/themes/roundedraff/RoundedRaffTheme.h @@ -35,23 +35,12 @@ constexpr ThemeMetrics values = {.batteryWidth = 15, .progressBarMarginTop = 1, .statusBarHorizontalMargin = 5, .statusBarVerticalMargin = 19, - .keyboardKeyWidth = 22, - .keyboardKeyHeight = 30, + .keyboardKeyHeight = 36, .keyboardKeySpacing = 10, - .keyboardBottomKeyHeight = 30, - .keyboardBottomKeySpacing = 5, - .keyboardBottomAligned = true, .keyboardCenteredText = false, .keyboardVerticalOffset = 0, .keyboardTextFieldWidthPercent = 85, - .keyboardWidthPercent = 90, - .keyboardKeyCornerRadius = 10, - .keyboardFillUnselected = true, - .keyboardOutlineAllUnselected = true, - .keyboardDrawSpecialOutlineWhenUnselected = true, - .keyboardSecondaryLabelRightPadding = 3, - .keyboardSecondaryLabelTopPadding = 1, - .keyboardMinArrowHeadSize = 1, + .keyboardWidthPercent = 94, .popupTopOffsetRatio = 0.12f, .popupMarginX = 20, .popupMarginY = 14, @@ -89,6 +78,8 @@ class RoundedRaffTheme : public BaseTheme { const char* subtitle = nullptr) const override; void drawTabBar(const GfxRenderer& renderer, Rect rect, const std::vector& tabs, bool selected) const override; + bool tabIndexFromPoint(const GfxRenderer& renderer, Rect rect, const std::vector& tabs, int x, int y, + int& index) const override; void drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector& recentBooks, int selectorIndex, bool& coverRendered, bool& coverBufferStored, bool& bufferRestored, std::function storeCoverBuffer) const override; @@ -97,9 +88,8 @@ class RoundedRaffTheme : public BaseTheme { const std::function& rowIcon) const override; void drawTextField(const GfxRenderer& renderer, Rect rect, int textWidth, bool cursorMode = false, int contentStartX = 0, int contentWidth = 0) const override; - void drawKeyboardKey(const GfxRenderer& renderer, Rect rect, const char* label, bool isSelected, - const char* secondaryLabel = nullptr, KeyboardKeyType keyType = KeyboardKeyType::Normal, - bool inactiveSelection = false) const override; + int getListRowStep(bool hasSubtitle) const override; + int getListPageItems(int contentHeight, bool hasSubtitle) const override; void drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex, const std::function& rowTitle, const std::function& rowSubtitle = nullptr, diff --git a/src/main.cpp b/src/main.cpp index 13ba129b..6230caf4 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,4 +1,5 @@ #include +#include #include #include #include @@ -159,49 +160,6 @@ void silentRestartToReader() { ESP.restart(); } -// Verify power button press duration on wake-up from deep sleep -// Pre-condition: isWakeupByPowerButton() == true -void verifyPowerButtonDuration() { - if (SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::SLEEP) { - // Fast path for short press - // Needed because inputManager.isPressed() may take up to ~500ms to return the correct state - return; - } - - // Give the user up to 1000ms to start holding the power button, and must hold for SETTINGS.getPowerButtonDuration() - const auto start = millis(); - bool abort = false; - // Subtract the current time, because inputManager only starts counting the HeldTime from the first update() - // This way, we remove the time we already took to reach here from the duration, - // assuming the button was held until now from millis()==0 (i.e. device start time). - const uint16_t calibration = start; - const uint16_t calibratedPressDuration = - (calibration < SETTINGS.getPowerButtonDuration()) ? SETTINGS.getPowerButtonDuration() - calibration : 1; - - gpio.update(); - // Needed because inputManager.isPressed() may take up to ~500ms to return the correct state - while (!gpio.isPressed(HalGPIO::BTN_POWER) && millis() - start < 1000) { - delay(10); // only wait 10ms each iteration to not delay too much in case of short configured duration. - gpio.update(); - } - - t2 = millis(); - if (gpio.isPressed(HalGPIO::BTN_POWER)) { - do { - delay(10); - gpio.update(); - } while (gpio.isPressed(HalGPIO::BTN_POWER) && gpio.getPowerButtonHeldTime() < calibratedPressDuration); - abort = gpio.getPowerButtonHeldTime() < calibratedPressDuration; - } else { - abort = true; - } - - if (abort) { - // Button released too early. Returning to sleep. - // IMPORTANT: Re-arm the wakeup trigger before sleeping again - powerManager.startDeepSleep(gpio); - } -} void waitForPowerRelease() { gpio.update(); while (gpio.isPressed(HalGPIO::BTN_POWER)) { @@ -303,6 +261,8 @@ void setupDisplayAndFonts(bool seamless = false) { } void setup() { + BoardConfig::holdPowerRails(); + t1 = millis(); #ifdef ENABLE_SERIAL_LOG @@ -313,7 +273,9 @@ void setup() { // worked without the delay because USB was already enumerated. delay(250); Serial.begin(115200); +#if LOG_SERIAL_HAS_TX_TIMEOUT logSerial.setTxTimeoutMs(1); // This is a load-bearing 1. Do not modify. +#endif #endif HalSystem::begin(); @@ -357,8 +319,10 @@ void setup() { switch (wakeupReason) { case HalGPIO::WakeupReason::PowerButton: LOG_DBG("MAIN", "Verifying power button press duration"); - gpio.verifyPowerButtonWakeup(SETTINGS.getPowerButtonDuration(), - SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::SLEEP); + if (!gpio.verifyPowerButtonWakeup(SETTINGS.getPowerButtonDuration(), + SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::SLEEP)) { + powerManager.startDeepSleep(gpio); + } break; case HalGPIO::WakeupReason::AfterUSBPower: // If USB power caused a cold boot, go back to sleep @@ -485,6 +449,7 @@ void loop() { const unsigned long loopStartTime = millis(); static unsigned long lastMemPrint = 0; + gpio.setSharedConfirmPowerShortPressEmitsPower(SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::SLEEP); gpio.update(); halTiltSensor.update(SETTINGS.tiltPageTurn, SETTINGS.orientation, activityManager.isReaderActivity()); @@ -515,7 +480,7 @@ void loop() { // Check for any user activity (button press or release) or active background work static unsigned long lastActivityTime = millis(); - if (gpio.wasAnyPressed() || gpio.wasAnyReleased() || halTiltSensor.hadActivity() || + if (gpio.wasAnyPressed() || gpio.wasAnyReleased() || gpio.wasTouchActivity() || halTiltSensor.hadActivity() || activityManager.preventAutoSleep()) { lastActivityTime = millis(); // Reset inactivity timer powerManager.setPowerSaving(false); // Restore normal CPU frequency on user activity diff --git a/src/network/CrossPointWebServer.cpp b/src/network/CrossPointWebServer.cpp index 28f3ba57..1a11ddd0 100644 --- a/src/network/CrossPointWebServer.cpp +++ b/src/network/CrossPointWebServer.cpp @@ -8,7 +8,6 @@ #include #include #include -#include #include #include @@ -26,6 +25,7 @@ #include "html/SettingsPageHtml.generated.h" #include "html/js/jszip_minJs.generated.h" #include "util/BookCacheUtils.h" +#include "util/TaskWatchdog.h" namespace { // Folders/files to hide from the web interface file browser @@ -132,6 +132,11 @@ void CrossPointWebServer::begin() { return; } + // Add Access-Control-Allow-* headers to every response so web-based clients + // and PWAs on other origins can use the HTTP API. Preflight OPTIONS requests + // are answered in handleNotFound(). + server->enableCORS(true); + // Setup routes LOG_DBG("WEB", "Setting up routes..."); server->on("/", HTTP_GET, [this] { handleRoot(); }); @@ -370,6 +375,13 @@ void CrossPointWebServer::handleJszip() const { } void CrossPointWebServer::handleNotFound() const { + // CORS preflight: routes are registered per-method, so OPTIONS requests land + // here. The Access-Control-Allow-* headers are added by enableCORS(). + if (server->method() == HTTP_OPTIONS) { + server->send(204, "text/plain", ""); + return; + } + // in AP mode, redirect unmatched browser/captive-portal requests to "/" so the OS auto-opens the browser // API requests (/api/*) still return 404 so XHR errors surface correctly // see https://en.wikipedia.org/wiki/Captive_portal#Detection @@ -399,6 +411,8 @@ void CrossPointWebServer::handleStatus() const { char snBuf[33] = {0}; bool valid = false; +#if !CONFIG_IDF_TARGET_ESP32 + // Classic ESP32's efuse table has no USER_DATA block (C3/S3 only) if (esp_efuse_read_field_blob(ESP_EFUSE_USER_DATA, snBuf, 256) == ESP_OK) { valid = snBuf[0] != '\0' && snBuf[0] != (char)0xFF; for (int i = 0; i < 32 && snBuf[i] != '\0'; i++) { @@ -408,6 +422,7 @@ void CrossPointWebServer::handleStatus() const { } } } +#endif if (valid) { doc["serial"] = snBuf; @@ -471,8 +486,8 @@ void CrossPointWebServer::scanFiles(const char* path, const std::function(result); size_t totalWritten = 0; while (totalWritten < bytesRead) { - esp_task_wdt_reset(); + resetTaskWatchdogIfSubscribed(); size_t wrote = client.write(buffer + totalWritten, bytesRead - totalWritten); if (wrote == 0) { downloadOk = false; @@ -623,12 +638,12 @@ static size_t writeCount = 0; static bool flushUploadBuffer(CrossPointWebServer::UploadState& state) { if (state.bufferPos > 0 && state.file) { - esp_task_wdt_reset(); // Reset watchdog before potentially slow SD write + resetTaskWatchdogIfSubscribed(); // Reset watchdog before potentially slow SD write const unsigned long writeStart = millis(); const size_t written = state.file.write(state.buffer.data(), state.bufferPos); totalWriteTime += millis() - writeStart; writeCount++; - esp_task_wdt_reset(); // Reset watchdog after SD write + resetTaskWatchdogIfSubscribed(); // Reset watchdog after SD write if (written != state.bufferPos) { LOG_DBG("WEB", "[UPLOAD] Buffer flush failed: expected %d, wrote %d", state.bufferPos, written); @@ -644,7 +659,7 @@ void CrossPointWebServer::handleUpload(UploadState& state) const { static size_t lastLoggedSize = 0; // Reset watchdog at start of every upload callback - HTTP parsing can be slow - esp_task_wdt_reset(); + resetTaskWatchdogIfSubscribed(); // Safety check: ensure server is still valid if (!running || !server) { @@ -656,7 +671,7 @@ void CrossPointWebServer::handleUpload(UploadState& state) const { if (upload.status == UPLOAD_FILE_START) { // Reset watchdog - this is the critical 1% crash point - esp_task_wdt_reset(); + resetTaskWatchdogIfSubscribed(); state.fileName = upload.filename; state.size = 0; @@ -688,27 +703,26 @@ void CrossPointWebServer::handleUpload(UploadState& state) const { LOG_DBG("WEB", "[UPLOAD] START: %s to path: %s", state.fileName.c_str(), state.path.c_str()); LOG_DBG("WEB", "[UPLOAD] Free heap: %d bytes", ESP.getFreeHeap()); - // Create file path String filePath = state.path; if (!filePath.endsWith("/")) filePath += "/"; filePath += state.fileName; // Check if file already exists - SD operations can be slow - esp_task_wdt_reset(); + resetTaskWatchdogIfSubscribed(); if (Storage.exists(filePath.c_str())) { - LOG_DBG("WEB", "[UPLOAD] Overwriting existing file: %s", filePath.c_str()); - esp_task_wdt_reset(); - Storage.remove(filePath.c_str()); + state.error = "File already exists: " + state.fileName; + LOG_DBG("WEB", "[UPLOAD] Collision: %s", filePath.c_str()); + return; } // Open file for writing - this can be slow due to FAT cluster allocation - esp_task_wdt_reset(); + resetTaskWatchdogIfSubscribed(); if (!Storage.openFileForWrite("WEB", filePath, state.file)) { state.error = "Failed to create file on SD card"; LOG_DBG("WEB", "[UPLOAD] FAILED to create file: %s", filePath.c_str()); return; } - esp_task_wdt_reset(); + resetTaskWatchdogIfSubscribed(); LOG_DBG("WEB", "[UPLOAD] File created successfully: %s", filePath.c_str()); } else if (upload.status == UPLOAD_FILE_WRITE) { @@ -766,7 +780,7 @@ void CrossPointWebServer::handleUpload(UploadState& state) const { LOG_DBG("WEB", "[UPLOAD] Diagnostics: %d writes, total write time: %lu ms (%.1f%%)", writeCount, totalWriteTime, writePercent); - // Clear epub cache to prevent stale metadata issues when overwriting files + // Clear epub cache after uploading the file String filePath = state.path; if (!filePath.endsWith("/")) filePath += "/"; filePath += state.fileName; @@ -1641,29 +1655,29 @@ void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t* wsUploadPath = wsUploadPath.substring(0, wsUploadPath.length() - 1); } - // Build file path String filePath = wsUploadPath; if (!filePath.endsWith("/")) filePath += "/"; filePath += wsUploadFileName; + resetTaskWatchdogIfSubscribed(); + if (Storage.exists(filePath.c_str())) { + LOG_DBG("WS", "Upload collision: %s", filePath.c_str()); + wsServer->sendTXT(num, "ERROR:File already exists: " + wsUploadFileName); + return; + } + LOG_DBG("WS", "Starting upload: %s (%d bytes) to %s", wsUploadFileName.c_str(), wsUploadSize, filePath.c_str()); - // Check if file exists and remove it - esp_task_wdt_reset(); - if (Storage.exists(filePath.c_str())) { - Storage.remove(filePath.c_str()); - } - // Open file for writing - esp_task_wdt_reset(); + resetTaskWatchdogIfSubscribed(); if (!Storage.openFileForWrite("WS", filePath, wsUploadFile)) { wsServer->sendTXT(num, "ERROR:Failed to create file"); wsUploadInProgress = false; wsUploadClientNum = 255; return; } - esp_task_wdt_reset(); + resetTaskWatchdogIfSubscribed(); // Zero-byte upload: complete immediately without waiting for BIN frames if (wsUploadSize == 0) { @@ -1702,9 +1716,9 @@ void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t* wsServer->sendTXT(num, "ERROR:Upload overflow"); return; } - esp_task_wdt_reset(); + resetTaskWatchdogIfSubscribed(); size_t written = wsUploadFile.write(payload, length); - esp_task_wdt_reset(); + resetTaskWatchdogIfSubscribed(); if (written != length) { abortWsUpload("WS"); @@ -1738,7 +1752,7 @@ void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t* LOG_DBG("WS", "Upload complete: %s (%d bytes in %lu ms, %.1f KB/s)", wsUploadFileName.c_str(), wsUploadSize, elapsed, kbps); - // Clear epub cache to prevent stale metadata issues when overwriting files + // Clear epub cache after uploading the file String filePath = wsUploadPath; if (!filePath.endsWith("/")) filePath += "/"; filePath += wsUploadFileName; @@ -1808,7 +1822,7 @@ void CrossPointWebServer::handleFontUploadData() { switch (upload.status) { case UPLOAD_FILE_START: { - esp_task_wdt_reset(); + resetTaskWatchdogIfSubscribed(); String family = server->arg("family"); fontUpload.file = HalFile(); fontUpload.familyName.clear(); @@ -1859,7 +1873,7 @@ void CrossPointWebServer::handleFontUploadData() { case UPLOAD_FILE_WRITE: { if (!fontUpload.valid) break; - esp_task_wdt_reset(); + resetTaskWatchdogIfSubscribed(); // Validate magic bytes on first chunk only if (!fontUpload.magicChecked && upload.currentSize >= 8) { @@ -1886,7 +1900,7 @@ void CrossPointWebServer::handleFontUploadData() { fontUpload.file.write(fontUpload.buffer.data(), fontUpload.bufferPos); fontUpload.bytesWritten += fontUpload.bufferPos; fontUpload.bufferPos = 0; - esp_task_wdt_reset(); + resetTaskWatchdogIfSubscribed(); } } break; diff --git a/src/network/WebDAVHandler.cpp b/src/network/WebDAVHandler.cpp index 64fa3d09..81ca05f7 100644 --- a/src/network/WebDAVHandler.cpp +++ b/src/network/WebDAVHandler.cpp @@ -3,9 +3,9 @@ #include #include #include -#include #include "util/BookCacheUtils.h" +#include "util/TaskWatchdog.h" namespace { constexpr const char* HIDDEN_ITEMS[] = {"System Volume Information", "XTCache"}; @@ -84,7 +84,7 @@ void WebDAVHandler::raw(WebServer& server, const String& uri, HTTPRaw& raw) { } else if (raw.status == RAW_WRITE) { if (_putFile && _putOk) { - esp_task_wdt_reset(); + resetTaskWatchdogIfSubscribed(); size_t written = _putFile.write(raw.buf, raw.currentSize); if (written != raw.currentSize) { _putOk = false; @@ -252,7 +252,7 @@ void WebDAVHandler::handlePropfind(WebServer& s) { file.close(); yield(); - esp_task_wdt_reset(); + resetTaskWatchdogIfSubscribed(); file = root.openNextFile(); } } @@ -628,7 +628,7 @@ void WebDAVHandler::handleCopy(WebServer& s) { uint8_t buf[4096]; bool copyOk = true; while (srcFile.available()) { - esp_task_wdt_reset(); + resetTaskWatchdogIfSubscribed(); int bytesRead = srcFile.read(buf, sizeof(buf)); if (bytesRead <= 0) break; size_t written = dstFile.write(buf, bytesRead); diff --git a/src/network/html/FilesPage.html b/src/network/html/FilesPage.html index 23881a7e..5f2ceba3 100644 --- a/src/network/html/FilesPage.html +++ b/src/network/html/FilesPage.html @@ -1579,6 +1579,19 @@ +
+
+
Rename from Book Metadata
+
Use Title - Author.epub when available
+
+
+ +
+
+
⚫ True-Grayscale 📏 Max 480×800px @@ -1675,7 +1688,19 @@
+
+ +
+
+
Remember Settings
+
Store these upload options in this browser
+
+
+
@@ -2085,12 +2110,8 @@ // Modal functions function openUploadModal() { - // Reset converter variables to defaults - ENABLE_GRAYSCALE = true; - JPEG_QUALITY = 85; - HANDEDNESS = 'right'; - OVERLAP_PERCENT = 5; imageStates = {}; + restoreUploadSettingsFromStorage(); // Hide convert options when opening modal (no files selected initially) const convertOptions = document.getElementById('convertOptions'); @@ -2098,10 +2119,6 @@ convertOptions.style.display = 'none'; } - // Reset rotation and overlap UI - setHandedness('right'); - setOverlap(5); - // Hide log section from previous session const logSection = document.getElementById('log-section'); if (logSection) logSection.classList.remove('visible'); @@ -2160,7 +2177,8 @@ document.getElementById('progress-container').style.display = 'none'; document.getElementById('progress-fill').style.width = '0%'; document.getElementById('progress-fill').style.backgroundColor = '#27ae60'; - document.getElementById('convertBeforeUpload').checked = false; + const convertOptions = document.getElementById('convertOptions'); + if (convertOptions) convertOptions.style.display = 'none'; document.getElementById('convertInfo').style.display = 'none'; document.getElementById('convertWarning').style.display = 'none'; // Clear image picker cache and reset layout @@ -2183,15 +2201,7 @@ advancedOptionsToggle.style.opacity = '0.5'; advancedOptionsToggle.style.pointerEvents = 'none'; } - // Reset to defaults - document.getElementById('qualitySlider').value = 85; - document.getElementById('qualityInput').value = 85; - const autoCropToggle = document.getElementById('autoCropToggle'); - if (autoCropToggle) autoCropToggle.checked = false; - setHandedness('right'); - setOverlap(5); - // Update converter variables - updateQualitySettings(); + applyUploadSettings(); } function updateBatchModeUI(isBatch) { @@ -2228,6 +2238,7 @@ advancedOptionsToggle.style.opacity = checked ? '1' : '0.5'; advancedOptionsToggle.style.pointerEvents = checked ? 'auto' : 'none'; } + updateUploadSettingsPersistence(); } function toggleAdvancedOptions() { @@ -2265,12 +2276,8 @@ function setQualityPreset(value) { document.getElementById('qualitySlider').value = value; document.getElementById('qualityInput').value = value; - // Update active preset document.querySelectorAll('.quality-preset').forEach(btn => { - btn.classList.remove('active'); - if (parseInt(btn.dataset.value, 10) === value) { - btn.classList.add('active'); - } + btn.classList.toggle('active', parseInt(btn.dataset.value, 10) === value); }); updateQualitySettings(); } @@ -2292,6 +2299,7 @@ if (isImagePickerVisible()) { renderImageGrid(); } + updateUploadSettingsPersistence(); } function setHandedness(value) { @@ -2304,6 +2312,7 @@ if (isImagePickerVisible()) { renderImageGrid(); } + updateUploadSettingsPersistence(); } function setOverlap(value) { @@ -2312,6 +2321,7 @@ document.querySelectorAll('.overlap-btn').forEach(btn => { btn.classList.toggle('active', parseInt(btn.dataset.value) === value); }); + updateUploadSettingsPersistence(); } function isImagePickerVisible() { @@ -2980,7 +2990,12 @@ if (qualitySlider && qualityInput) { // Initialize converter variables with UI default values - updateQualitySettings(); + suppressUploadSettingsSave = true; + try { + updateQualitySettings(); + } finally { + suppressUploadSettingsSave = false; + } // Deselect all presets when slider is manually changed const deselectPresets = function() { @@ -3213,15 +3228,13 @@ const hasEpub = Array.from(files).some(f => f.name.toLowerCase().endsWith('.epub')); if (files.length > 0 && hasEpub) { convertOptions.style.display = 'block'; + toggleConvertOptions(); } else { convertOptions.style.display = 'none'; - // Clear stale checkbox state so the "Optimize & Upload" button doesn't linger - // when the user re-picks a non-EPUB after having ticked Optimize for an EPUB. - const cb = document.getElementById('convertBeforeUpload'); - if (cb && cb.checked) { - cb.checked = false; - toggleConvertOptions(); - } + uploadBtn.textContent = 'Upload'; + uploadBtn.classList.remove('optimize'); + document.getElementById('convertInfo').style.display = 'none'; + document.getElementById('convertWarning').style.display = 'none'; if (files.length === 0) clearImagePicker(); } @@ -3303,6 +3316,75 @@ let ENABLE_GRAYSCALE = DEFAULT_ENABLE_GRAYSCALE; let ENABLE_AUTO_CROP = DEFAULT_ENABLE_AUTO_CROP; let HANDEDNESS = 'right'; // 'right' = clockwise (right-handed), 'left' = counter-clockwise (left-handed) let OVERLAP_PERCENT = 5; // Minimum overlap percentage for splits (5%, 10%, 15%) +const UPLOAD_SETTINGS_STORAGE_KEY = 'crosspoint.files.uploadSettings.v1'; +const DEFAULT_UPLOAD_SETTINGS = Object.freeze({ + convertBeforeUpload: false, + renameFromMetadata: false, + quality: DEFAULT_JPEG_QUALITY, + autoCrop: DEFAULT_ENABLE_AUTO_CROP, + deviceTarget: 'auto', + handedness: 'right', + overlap: 5, + exportLog: false +}); +let suppressUploadSettingsSave = false; + +function getCurrentUploadSettings() { + return { + convertBeforeUpload: !!document.getElementById('convertBeforeUpload')?.checked, + renameFromMetadata: !!document.getElementById('renameFromMetadataToggle')?.checked, + quality: parseInt(document.getElementById('qualitySlider')?.value || JPEG_QUALITY, 10), + autoCrop: !!document.getElementById('autoCropToggle')?.checked, + deviceTarget: DEVICE_TARGET, + handedness: HANDEDNESS, + overlap: OVERLAP_PERCENT, + exportLog: !!document.getElementById('export-log-checkbox')?.checked + }; +} + +function applyUploadSettings(settings = {}) { + const merged = { ...DEFAULT_UPLOAD_SETTINGS, ...settings }; + suppressUploadSettingsSave = true; + try { + document.getElementById('convertBeforeUpload').checked = !!merged.convertBeforeUpload; + document.getElementById('renameFromMetadataToggle').checked = !!merged.renameFromMetadata; + document.getElementById('autoCropToggle').checked = !!merged.autoCrop; + document.getElementById('export-log-checkbox').checked = !!merged.exportLog; + document.getElementById('rememberUploadSettings').checked = !!settings.rememberSettings; + + setQualityPreset(Math.max(1, Math.min(95, parseInt(merged.quality, 10) || DEFAULT_JPEG_QUALITY))); + setDeviceTarget(['auto', 'X3', 'X4'].includes(merged.deviceTarget) ? merged.deviceTarget : 'auto'); + setHandedness(merged.handedness === 'left' ? 'left' : 'right'); + setOverlap([5, 10, 15].includes(Number(merged.overlap)) ? Number(merged.overlap) : 5); + toggleConvertOptions(); + } finally { + suppressUploadSettingsSave = false; + } +} + +function restoreUploadSettingsFromStorage() { + try { + const saved = JSON.parse(localStorage.getItem(UPLOAD_SETTINGS_STORAGE_KEY) || 'null'); + applyUploadSettings(saved?.rememberSettings ? saved : undefined); + } catch (e) { + console.warn('Could not read remembered upload settings:', e); + applyUploadSettings(); + } +} + +function updateUploadSettingsPersistence() { + if (suppressUploadSettingsSave) return; + try { + if (!document.getElementById('rememberUploadSettings')?.checked) { + localStorage.removeItem(UPLOAD_SETTINGS_STORAGE_KEY); + return; + } + const settings = { ...getCurrentUploadSettings(), rememberSettings: true }; + localStorage.setItem(UPLOAD_SETTINGS_STORAGE_KEY, JSON.stringify(settings)); + } catch (e) { + console.warn('Could not save upload settings:', e); + } +} // ============================================================================ // Image Picker State Management @@ -3380,6 +3462,7 @@ function applyDeviceTarget() { function setDeviceTarget(value) { DEVICE_TARGET = value; applyDeviceTarget(); + updateUploadSettingsPersistence(); } // Batch logging system for multiple files @@ -3788,6 +3871,104 @@ async function findOPFPath(zip) { return fallback; } +function sanitizeMetadataFilenamePart(value) { + const text = String(value || '').replace(/\s+/g, ' ').trim(); + return (text.normalize ? text.normalize('NFC') : text) + .replace(/[<>:"/\\|?*\x00-\x1F]/g, ' ') + .replace(/\s+/g, ' ') + .replace(/^[. ]+/g, '') + .replace(/[. ]+$/g, '') + .trim(); +} + +function buildMetadataFilename(title, author) { + title = sanitizeMetadataFilenamePart(title); + author = sanitizeMetadataFilenamePart(author); + if (!title) return ''; + let base = author ? `${title} - ${author}` : title; + if (base.length > 180) base = base.substring(0, 180).replace(/\s+\S*$/g, '').trim() || base.substring(0, 180).trim(); + return `${base}.epub`; +} + +async function getMetadataFilenameForEpub(file) { + if (typeof JSZip === 'undefined') return ''; + const zip = await JSZip.loadAsync(file); + const opfPath = await findOPFPath(zip); + if (!opfPath || !zip.files[opfPath]) return ''; + + const doc = new DOMParser().parseFromString(await safeReadText(zip.files[opfPath]), 'application/xml'); + if (doc.querySelector('parsererror')) return ''; + + const title = doc.getElementsByTagNameNS('*', 'title')[0]?.textContent || ''; + const creators = Array.from(doc.getElementsByTagNameNS('*', 'creator')); + const getCreatorRole = el => ( + el.getAttribute('role') || + el.getAttribute('opf:role') || + el.getAttributeNS('http://www.idpf.org/2007/opf', 'role') || + '' + ).toLowerCase(); + const authorEl = creators.find(el => getCreatorRole(el) === 'aut') || + creators.find(el => !getCreatorRole(el)); + const authorText = authorEl?.textContent?.trim(); + const author = authorText || + authorEl?.getAttribute('file-as') || + authorEl?.getAttribute('opf:file-as') || + authorEl?.getAttributeNS('http://www.idpf.org/2007/opf', 'file-as') || + ''; + return buildMetadataFilename(title, author); +} + +async function maybeRenameEbookFile(file) { + const renameToggle = document.getElementById('renameFromMetadataToggle'); + if (!renameToggle || !renameToggle.checked || !file.name.toLowerCase().endsWith('.epub')) { + return file; + } + + try { + const metadataName = await getMetadataFilenameForEpub(file); + if (!metadataName || metadataName === file.name) return file; + return new File([file], metadataName, { + type: file.type || 'application/epub+zip', + lastModified: file.lastModified + }); + } catch (e) { + console.warn('Could not rename EPUB from metadata:', e); + return file; + } +} + +function reserveAvailableUploadFilename(fileName, usedFileNames) { + const normalize = name => name.toLowerCase(); + if (!usedFileNames.has(normalize(fileName))) { + usedFileNames.add(normalize(fileName)); + return fileName; + } + + const dotIndex = fileName.lastIndexOf('.'); + const extensionIndex = dotIndex > 0 ? dotIndex : fileName.length; + const baseName = fileName.substring(0, extensionIndex); + const extension = fileName.substring(extensionIndex); + let nextSuffix = 2; + + let candidateName; + do { + candidateName = `${baseName} (${nextSuffix})${extension}`; + nextSuffix++; + } while (usedFileNames.has(normalize(candidateName))); + + usedFileNames.add(normalize(candidateName)); + return candidateName; +} + +async function fetchExistingUploadNames() { + const response = await fetch('/api/files?path=' + encodeURIComponent(currentPath) + '&_=' + Date.now()); + if (!response.ok) { + throw new Error(response.status + ' ' + response.statusText); + } + const entries = await response.json(); + return new Set(entries.map(entry => entry.name.toLowerCase())); +} + /** * Resolve a relative href against a base file path. * Handles multiple ../, ./, absolute /, and bare relative paths. @@ -5219,7 +5400,7 @@ function uploadFileHTTP(file, onProgress, onComplete, onError) { }); } -function uploadFile() { +async function uploadFile() { if (isUploadInProgress) return; const fileInput = document.getElementById('fileInput'); @@ -5231,8 +5412,17 @@ function uploadFile() { return; } - // Prevent modal close during upload isUploadInProgress = true; + let usedFileNames; + try { + usedFileNames = await fetchExistingUploadNames(); + } catch (error) { + isUploadInProgress = false; + alert('Failed to check existing files: ' + error.message); + return; + } + + // Prevent modal close during upload uploadGeneration++; const myGeneration = uploadGeneration; document.getElementById('uploadModalClose').classList.add('disabled'); @@ -5323,6 +5513,25 @@ function uploadFile() { let convOriginalSize = 0; // Picked-file size; 0 unless conversion succeeded let convNewSize = 0; // Generated blob size; 0 unless conversion succeeded + if (isEpub && document.getElementById('renameFromMetadataToggle').checked) { + const originalName = file.name; + progressText.style.color = ''; + progressText.textContent = `Reading metadata for ${file.name} (${currentIndex + 1}/${files.length})...`; + file = await maybeRenameEbookFile(file); + if (file.name !== originalName) { + console.log(`[Upload] Renamed from metadata: ${originalName} -> ${file.name}`); + } + } + + const availableName = reserveAvailableUploadFilename(file.name, usedFileNames); + if (availableName !== file.name) { + console.log(`[Upload] Renamed to avoid collision: ${file.name} -> ${availableName}`); + file = new File([file], availableName, { + type: file.type, + lastModified: file.lastModified + }); + } + const methodText = useWebSocket ? ' [WS]' : ' [HTTP]'; const stageText = needsConversion ? 'Converting & uploading' : 'Uploading'; progressText.style.color = ''; diff --git a/src/util/DictZip.cpp b/src/util/DictZip.cpp new file mode 100644 index 00000000..620310a7 --- /dev/null +++ b/src/util/DictZip.cpp @@ -0,0 +1,180 @@ +#include "DictZip.h" + +#include +#include + +namespace DictZip { +namespace { + +// Caps the chunk table at 32KB of heap (8192 * 4 bytes); at the typical ~58KB +// chunk length that still allows ~460MB of uncompressed dictionary data. +constexpr uint16_t MAX_CHUNK_COUNT = 8192; + +bool readLe16(HalFile& file, uint16_t* out) { + uint8_t raw[2]; + if (file.read(raw, 2) != 2) return false; + *out = static_cast(raw[0] | (static_cast(raw[1]) << 8)); + return true; +} + +bool extractChunkSlice(HalFile& file, uint32_t compressedOffset, uint32_t compressedSize, uint32_t discardSize, + uint32_t extractSize, HalFile& outFile) { + if (extractSize == 0) return true; + auto compBuf = makeUniqueNoThrow(compressedSize); + if (!compBuf) return false; + + file.seekSet(compressedOffset); + if (file.read(compBuf.get(), static_cast(compressedSize)) != static_cast(compressedSize)) return false; + + InflateReader reader; + if (!reader.init(true)) return false; + reader.setSource(compBuf.get(), compressedSize); + + auto buf = makeUniqueNoThrow(512); + if (!buf) return false; + + uint32_t batch; + while (discardSize > 0) { + batch = discardSize < 512 ? discardSize : 512; + if (!reader.read(buf.get(), batch)) return false; + discardSize -= batch; + } + + while (extractSize > 0) { + batch = extractSize < 512 ? extractSize : 512; + if (!reader.read(buf.get(), batch)) return false; + if (outFile.write(buf.get(), batch) != batch) return false; + extractSize -= batch; + } + + return true; +} + +} // namespace + +bool parse(HalFile& file, Info* info) { + if (!info) return false; + *info = {}; + + uint8_t header[10]; + if (file.read(header, sizeof(header)) != static_cast(sizeof(header))) return false; + if (header[0] != 0x1f || header[1] != 0x8b || header[2] != 8) return false; + + const uint8_t flags = header[3]; + if ((flags & 0x04) == 0) return false; // dictzip requires FEXTRA + + uint16_t xlen = 0; + if (!readLe16(file, &xlen)) return false; + + uint32_t extraRead = 0; + bool foundRa = false; + while (extraRead + 4 <= xlen) { + uint8_t subHeader[4]; + if (file.read(subHeader, sizeof(subHeader)) != static_cast(sizeof(subHeader))) return false; + extraRead += 4; + const uint16_t subLen = static_cast(subHeader[2] | (static_cast(subHeader[3]) << 8)); + if (extraRead + subLen > xlen) return false; + + if (subHeader[0] == 'R' && subHeader[1] == 'A') { + if (subLen < 6) return false; + + uint16_t version = 0; + uint16_t chunkLen = 0; + uint16_t chunkCount = 0; + if (!readLe16(file, &version) || !readLe16(file, &chunkLen) || !readLe16(file, &chunkCount)) return false; + extraRead += 6; + if (version != 1 || chunkLen == 0 || chunkCount == 0 || chunkCount > MAX_CHUNK_COUNT) return false; + if (subLen != static_cast(6 + chunkCount * 2)) return false; + + info->chunkLength = chunkLen; + info->chunkOffsets.reserve(static_cast(chunkCount) + 1); + info->chunkOffsets.push_back(0); + uint32_t cumulative = 0; + for (uint16_t i = 0; i < chunkCount; i++) { + uint16_t compLen = 0; + if (!readLe16(file, &compLen)) return false; + extraRead += 2; + cumulative += compLen; + info->chunkOffsets.push_back(cumulative); + } + foundRa = true; + } else { + file.seekSet(file.position() + subLen); + extraRead += subLen; + } + } + if (extraRead != xlen || !foundRa) return false; + + if (flags & 0x08) { // FNAME + int b; + do { + b = file.read(); + if (b < 0) return false; + } while (b != 0); + } + if (flags & 0x10) { // FCOMMENT + int b; + do { + b = file.read(); + if (b < 0) return false; + } while (b != 0); + } + if (flags & 0x02) { // FHCRC + uint8_t crc[2]; + if (file.read(crc, 2) != 2) return false; + } + + info->dataOffset = static_cast(file.position()); + const uint32_t fileSize = static_cast(file.fileSize()); + if (fileSize < 4) return false; + file.seekSet(fileSize - 4); + uint8_t isizeRaw[4]; + if (file.read(isizeRaw, 4) != 4) return false; + info->totalSize = static_cast(isizeRaw[0]) | (static_cast(isizeRaw[1]) << 8) | + (static_cast(isizeRaw[2]) << 16) | (static_cast(isizeRaw[3]) << 24); + if (info->totalSize == 0) return false; + info->valid = true; + return true; +} + +bool extractEntry(const char* path, uint32_t offset, uint32_t size, HalFile& outFile) { + if (size == 0) return true; + + HalFile file; + if (!Storage.openFileForRead("DICTZIP", path, file)) return false; + + Info info; + if (!parse(file, &info)) return false; + + // Reject ranges outside the uncompressed data (offset/size come from the + // untrusted .idx). Subtraction form avoids uint32 overflow in offset + size + // and guarantees localOffset < chunkOutSize in the loop below. + if (offset > info.totalSize || size > info.totalSize - offset) return false; + + const uint32_t startChunk = offset / info.chunkLength; + const uint32_t endChunk = (offset + size - 1) / info.chunkLength; + if (endChunk + 1 >= info.chunkOffsets.size()) return false; + + uint32_t remaining = size; + const uint32_t lastChunk = static_cast(info.chunkOffsets.size() - 2); + for (uint32_t chunk = startChunk; chunk <= endChunk; chunk++) { + uint32_t chunkOutSize = info.chunkLength; + if (chunk == lastChunk) chunkOutSize = info.totalSize - chunk * info.chunkLength; + if (chunkOutSize == 0 || chunkOutSize > info.chunkLength) chunkOutSize = info.chunkLength; + + const uint32_t localOffset = (chunk == startChunk) ? (offset % info.chunkLength) : 0; + const uint32_t available = chunkOutSize - localOffset; + const uint32_t take = remaining < available ? remaining : available; + + const uint32_t compOffset = info.dataOffset + info.chunkOffsets[chunk]; + const uint32_t compSize = info.chunkOffsets[chunk + 1] - info.chunkOffsets[chunk]; + if (!extractChunkSlice(file, compOffset, compSize, localOffset, take, outFile)) return false; + + remaining -= take; + if (remaining == 0) break; + } + + return remaining == 0; +} + +} // namespace DictZip diff --git a/src/util/DictZip.h b/src/util/DictZip.h new file mode 100644 index 00000000..47f41dfe --- /dev/null +++ b/src/util/DictZip.h @@ -0,0 +1,26 @@ +#pragma once + +#include + +#include +#include + +// Random-access reader for dictzip (.dict.dz) files: gzip with an extra "RA" +// field holding a chunk table, so any byte range can be decompressed without +// inflating the whole file. Format: https://linux.die.net/man/1/dictzip +namespace DictZip { + +struct Info { + uint32_t dataOffset = 0; // file offset where compressed chunk data starts + uint32_t totalSize = 0; // uncompressed size (gzip ISIZE trailer) + uint16_t chunkLength = 0; // uncompressed bytes per chunk + std::vector chunkOffsets; // cumulative compressed offsets, chunkCount+1 entries + bool valid = false; +}; + +bool parse(HalFile& file, Info* info); + +// Decompress the uncompressed byte range [offset, offset+size) into outFile. +bool extractEntry(const char* path, uint32_t offset, uint32_t size, HalFile& outFile); + +} // namespace DictZip diff --git a/src/util/Dictionary.cpp b/src/util/Dictionary.cpp new file mode 100644 index 00000000..4d4d2b1f --- /dev/null +++ b/src/util/Dictionary.cpp @@ -0,0 +1,378 @@ +#include "Dictionary.h" + +#include +#include +#include + +#include +#include +#include + +#include "DictZip.h" +#include "DictionaryRegistry.h" +#include "StringUtils.h" + +namespace { + +// Shared temp file for entries lazily extracted from .dict.dz. +constexpr const char* DICT_TMP_FILE = "/.crosspoint/dict.tmp"; + +// .qidx sidecar header: magic, version, sample interval, sample count, and the +// .idx file size the sidecar was built from (staleness check). +constexpr uint32_t QIDX_MAGIC = 0x58444951; // "QIDX" little-endian +constexpr uint32_t QIDX_VERSION = 1; +constexpr size_t QIDX_HEADER_BYTES = 5 * sizeof(uint32_t); + +struct QidxHeader { + uint32_t sampleCount = 0; + uint32_t idxFileSize = 0; + bool valid = false; +}; + +QidxHeader readQidxHeader(HalFile& qidx, uint32_t sampleInterval) { + QidxHeader header; + uint32_t raw[5]; + if (!qidx.seekSet(0) || qidx.read(raw, sizeof(raw)) != static_cast(sizeof(raw))) return header; + if (raw[0] != QIDX_MAGIC || raw[1] != QIDX_VERSION || raw[2] != sampleInterval) return header; + header.sampleCount = raw[3]; + header.idxFileSize = raw[4]; + header.valid = true; + return header; +} + +bool readSampleOffset(HalFile& qidx, uint32_t sampleIndex, uint32_t* out) { + if (!qidx.seekSet(QIDX_HEADER_BYTES + static_cast(sampleIndex) * sizeof(uint32_t))) return false; + return qidx.read(out, sizeof(*out)) == static_cast(sizeof(*out)); +} + +uint32_t readBe32(const uint8_t* p) { + return (static_cast(p[0]) << 24) | (static_cast(p[1]) << 16) | + (static_cast(p[2]) << 8) | static_cast(p[3]); +} + +// Word characters for cleaning: ASCII alphanumerics plus any UTF-8 +// continuation/lead byte, so accented words keep their edges. +bool isWordByte(unsigned char c) { return c >= 0x80 || std::isalnum(c) != 0; } + +// True when the .ifo declares 64-bit index offsets, which this reader does not +// support (only scans the first 2KB — idxoffsetbits always appears early). +bool ifoDeclares64BitOffsets(const std::string& ifoPath) { + HalFile ifo; + if (!Storage.openFileForRead("DICT", ifoPath, ifo)) return false; + char buf[2048]; + const int n = ifo.read(buf, sizeof(buf) - 1); + if (n <= 0) return false; + buf[n] = '\0'; + const char* line = strstr(buf, "idxoffsetbits"); + if (!line) return false; + const char* eq = strchr(line, '='); + return eq && strtol(eq + 1, nullptr, 10) == 64; +} + +} // namespace + +bool Dictionary::open(const char* folderName) { + basePath.clear(); + std::string resolved; + if (!DictionaryRegistry::resolveBasePath(folderName, resolved)) { + LOG_ERR("DICT", "No dictionary found in folder '%s'", folderName ? folderName : ""); + return false; + } + + if (!Storage.exists((resolved + ".idx").c_str())) { + LOG_ERR("DICT", "%s.idx missing (compressed .idx.gz is not supported)", resolved.c_str()); + return false; + } + hasPlainDict = Storage.exists((resolved + ".dict").c_str()); + if (!hasPlainDict && !Storage.exists((resolved + ".dict.dz").c_str())) { + LOG_ERR("DICT", "%s has no .dict or .dict.dz", resolved.c_str()); + return false; + } + if (ifoDeclares64BitOffsets(resolved + ".ifo")) { + LOG_ERR("DICT", "%s uses 64-bit index offsets (unsupported)", resolved.c_str()); + return false; + } + + basePath = std::move(resolved); + return true; +} + +bool Dictionary::needsIndex() { + if (!isOpen()) return false; + + HalFile idx; + if (!Storage.openFileForRead("DICT", basePath + ".idx", idx)) return false; + const uint32_t idxSize = static_cast(idx.fileSize()); + + HalFile qidx; + if (!Storage.openFileForRead("DICT", basePath + ".qidx", qidx)) return true; + const QidxHeader header = readQidxHeader(qidx, SAMPLE_INTERVAL); + return !header.valid || header.idxFileSize != idxSize; +} + +bool Dictionary::buildIndex(void (*yieldFn)(void*), void* ctx) { + if (!isOpen()) return false; + + HalFile idx; + if (!Storage.openFileForRead("DICT", basePath + ".idx", idx)) return false; + const uint32_t idxSize = static_cast(idx.fileSize()); + + constexpr size_t CHUNK_BYTES = 4096; + auto buf = makeUniqueNoThrow(CHUNK_BYTES); + if (!buf) { + LOG_ERR("DICT", "OOM: %u byte index scan buffer", CHUNK_BYTES); + return false; + } + + // Stream each sample offset straight to the sidecar instead of accumulating + // them in RAM: a large .idx would otherwise cost tens of KB of vector heap, + // and vector growth aborts on OOM under -fno-exceptions. The header slot is + // zero-filled until the scan succeeds, so an interrupted build leaves a file + // readQidxHeader rejects (magic mismatch) and needsIndex() triggers a rebuild. + const std::string qidxPath = basePath + ".qidx"; + HalFile out; + if (!Storage.openFileForWrite("DICT", qidxPath, out)) return false; + const auto writeU32 = [&out](uint32_t v) { return out.write(&v, sizeof(v)) == static_cast(sizeof(v)); }; + const uint32_t placeholder[5] = {}; + bool ok = out.write(placeholder, sizeof(placeholder)) == sizeof(placeholder); + uint32_t sampleCount = 0; + if (ok) { + ok = writeU32(0); // entry 0 always starts at byte 0 + sampleCount = 1; + } + + const unsigned long startMs = millis(); + uint32_t entryCount = 0; + uint32_t pos = 0; + uint32_t suffixLeft = 0; // 0 while scanning a headword, else suffix bytes remaining + uint32_t sinceYield = 0; + while (ok && pos < idxSize) { + const int n = idx.read(buf.get(), CHUNK_BYTES); + if (n <= 0) { + LOG_ERR("DICT", "Index scan read failed at %lu", static_cast(pos)); + ok = false; + break; + } + for (int i = 0; ok && i < n; i++) { + if (suffixLeft == 0) { + if (buf[i] == 0) suffixLeft = 8; + } else if (--suffixLeft == 0) { + entryCount++; + const uint32_t nextEntryStart = pos + i + 1; + if (entryCount % SAMPLE_INTERVAL == 0 && nextEntryStart < idxSize) { + ok = writeU32(nextEntryStart); + sampleCount++; + } + } + } + pos += n; + sinceYield += n; + if (yieldFn && sinceYield >= 64 * 1024) { + sinceYield = 0; + yieldFn(ctx); + } + } + + if (ok) { + // Backpatch the now-valid header over the placeholder. + const uint32_t header[5] = {QIDX_MAGIC, QIDX_VERSION, SAMPLE_INTERVAL, sampleCount, idxSize}; + ok = out.seekSet(0) && out.write(header, sizeof(header)) == sizeof(header); + } + if (!ok) { + LOG_ERR("DICT", "Index build failed, removing %s", qidxPath.c_str()); + out.close(); // close before remove of the same path + Storage.remove(qidxPath.c_str()); + return false; + } + + LOG_INF("DICT", "Indexed %lu entries (%lu samples) in %lu ms", static_cast(entryCount), + static_cast(sampleCount), millis() - startMs); + return true; +} + +int Dictionary::readWordInto(HalFile& file, char* buf, size_t bufSize) { + size_t i = 0; + while (i < bufSize - 1) { + const int ch = file.read(); + if (ch < 0) return -1; // EOF or I/O error + if (ch == 0) { + buf[i] = '\0'; + return static_cast(i); + } + buf[i++] = static_cast(ch); + } + // Word too long for buffer — consume remaining bytes to stay in sync + buf[bufSize - 1] = '\0'; + int ch; + do { + ch = file.read(); + } while (ch > 0); + return static_cast(bufSize - 1); +} + +DictLocation Dictionary::locate(const char* target, std::string* matchedHeadwordOut) { + DictLocation result; + if (!isOpen()) return result; + + HalFile idx; + if (!Storage.openFileForRead("DICT", basePath + ".idx", idx)) return result; + const uint32_t idxSize = static_cast(idx.fileSize()); + + // Bisect the sampled offsets to the last sample whose headword <= target. + // Falls back to a full scan from byte 0 when the sidecar is unusable. + uint32_t startByte = 0; + HalFile qidx; + if (Storage.openFileForRead("DICT", basePath + ".qidx", qidx)) { + const QidxHeader header = readQidxHeader(qidx, SAMPLE_INTERVAL); + if (header.valid && header.idxFileSize == idxSize && header.sampleCount > 0) { + uint32_t lo = 0; + uint32_t hi = header.sampleCount - 1; + while (lo < hi) { + const uint32_t mid = (lo + hi + 1) / 2; + uint32_t offset = 0; + if (!readSampleOffset(qidx, mid, &offset) || !idx.seekSet(offset) || + readWordInto(idx, wordBuf, sizeof(wordBuf)) < 0) { + lo = 0; + break; + } + if (StringUtils::asciiCaseCmp(wordBuf, target) <= 0) { + lo = mid; + } else { + hi = mid - 1; + } + } + readSampleOffset(qidx, lo, &startByte); + } + } + + // Linear scan of at most SAMPLE_INTERVAL entries: headword NUL, BE32 offset, + // BE32 size. The index is sorted, so stop at the first headword > target. + idx.seekSet(startByte); + while (static_cast(idx.position()) < idxSize) { + if (readWordInto(idx, wordBuf, sizeof(wordBuf)) < 0) break; + uint8_t suffix[8]; + if (idx.read(suffix, 8) != 8) break; + + const int cmp = StringUtils::asciiCaseCmp(wordBuf, target); + if (cmp == 0) { + result.offset = readBe32(suffix); + result.size = readBe32(suffix + 4); + result.found = true; + if (matchedHeadwordOut) *matchedHeadwordOut = wordBuf; + return result; + } + if (cmp > 0) break; + } + return result; +} + +bool Dictionary::readDefinition(const DictLocation& location, std::string& out) { + if (!location.found) return false; + const uint32_t size = std::min(location.size, MAX_DEFINITION_BYTES); + + std::string path; + uint32_t offset = 0; + if (hasPlainDict) { + path = basePath + ".dict"; + offset = location.offset; + } else { + HalFile tmp = Storage.open(DICT_TMP_FILE, O_WRITE | O_CREAT | O_TRUNC); + if (!tmp) { + LOG_ERR("DICT", "Failed to open %s", DICT_TMP_FILE); + return false; + } + if (!DictZip::extractEntry((basePath + ".dict.dz").c_str(), location.offset, size, tmp)) { + LOG_ERR("DICT", "dictzip extraction failed for %s", basePath.c_str()); + return false; + } + tmp.close(); // close before reopening the same path for read + path = DICT_TMP_FILE; + } + + HalFile dict; + if (!Storage.openFileForRead("DICT", path, dict)) return false; + const uint32_t dictSize = static_cast(dict.fileSize()); + if (offset > dictSize || size > dictSize - offset) { + LOG_ERR("DICT", "Definition out of bounds (%lu+%lu > %lu)", static_cast(offset), + static_cast(size), static_cast(dictSize)); + return false; + } + + // std::string growth aborts on OOM (-fno-exceptions); refuse up front unless + // the allocation fits comfortably in the largest free block. + if (ESP.getMaxAllocHeap() < size + 8 * 1024) { + LOG_ERR("DICT", "Low heap for %lu byte definition", static_cast(size)); + return false; + } + + dict.seekSet(offset); + out.assign(size, '\0'); + const int bytesRead = dict.read(&out[0], size); + if (bytesRead < 0) { + out.clear(); + return false; + } + if (static_cast(bytesRead) < size) out.resize(bytesRead); + return true; +} + +std::string Dictionary::cleanWord(const char* word) { + if (!word) return ""; + size_t start = 0; + size_t end = strlen(word); + while (start < end && !isWordByte(static_cast(word[start]))) start++; + while (end > start && !isWordByte(static_cast(word[end - 1]))) end--; + if (start >= end) return ""; + + std::string result(word + start, end - start); + std::transform(result.begin(), result.end(), result.begin(), + [](unsigned char c) { return c >= 0x80 ? c : static_cast(std::tolower(c)); }); + return result; +} + +void Dictionary::stemVariants(const std::string& word, std::vector& out) { + out.clear(); + out.reserve(6); + const size_t n = word.size(); + const auto add = [&out](std::string v) { + if (std::find(out.begin(), out.end(), v) == out.end()) out.push_back(std::move(v)); + }; + // endsWith requires a non-empty remainder so variants never come out empty. + const auto endsWith = [&word, n](const char* suffix) { + const size_t len = strlen(suffix); + return n > len && word.compare(n - len, len, suffix) == 0; + }; + + if (endsWith("'s")) add(word.substr(0, n - 2)); + if (endsWith("\xE2\x80\x99s")) add(word.substr(0, n - 4)); // U+2019 apostrophe + if (endsWith("ies")) add(word.substr(0, n - 3) + "y"); // stories -> story + if (endsWith("es")) add(word.substr(0, n - 2)); // boxes -> box + if (endsWith("s")) add(word.substr(0, n - 1)); // dogs -> dog + if (endsWith("ed")) { + add(word.substr(0, n - 2)); // walked -> walk + add(word.substr(0, n - 1)); // loved -> love + if (n >= 4 && word[n - 3] == word[n - 4]) add(word.substr(0, n - 3)); // stopped -> stop + } + if (endsWith("ing")) { + add(word.substr(0, n - 3)); // walking -> walk + add(word.substr(0, n - 3) + "e"); // making -> make + if (n >= 5 && word[n - 4] == word[n - 5]) add(word.substr(0, n - 4)); // running -> run + } +} + +bool Dictionary::lookup(const char* word, std::string& definitionOut, std::string& matchedHeadwordOut) { + const std::string cleaned = cleanWord(word); + if (cleaned.empty() || !isOpen()) return false; + + DictLocation location = locate(cleaned.c_str(), &matchedHeadwordOut); + if (!location.found) { + std::vector variants; + stemVariants(cleaned, variants); + for (const auto& variant : variants) { + location = locate(variant.c_str(), &matchedHeadwordOut); + if (location.found) break; + } + } + if (!location.found) return false; + return readDefinition(location, definitionOut); +} diff --git a/src/util/Dictionary.h b/src/util/Dictionary.h new file mode 100644 index 00000000..0a4c27e8 --- /dev/null +++ b/src/util/Dictionary.h @@ -0,0 +1,65 @@ +#pragma once + +#include + +#include +#include +#include + +// Result of an index search — file location of a definition without reading it. +struct DictLocation { + uint32_t offset = 0; // byte offset in .dict data + uint32_t size = 0; // byte length in .dict data + bool found = false; +}; + +// Slim StarDict reader: exact-match lookup with a mini stemming fallback. +// +// Expects /dictionaries//.idx (uncompressed) plus .dict or +// .dict.dz. Lookups binary-search a lazily built sampled-offset sidecar +// (.qidx, byte offset of every SAMPLE_INTERVAL-th .idx entry), then +// linear-scan at most SAMPLE_INTERVAL entries. Everything streams from SD; no +// index is held in RAM. +class Dictionary { + public: + // Resolve the dictionary folder and validate its files. Rejects + // dictionaries with 64-bit index offsets (idxoffsetbits=64 in .ifo). + bool open(const char* folderName); + bool isOpen() const { return !basePath.empty(); } + + // True when the .qidx sidecar is missing or stale — call buildIndex() first + // so the UI can show an "Indexing…" message for the slow first pass. + bool needsIndex(); + + // One streaming pass over .idx writing the .qidx sidecar. yieldFn (optional) + // is called every ~64KB consumed to feed the watchdog / repaint the UI. + bool buildIndex(void (*yieldFn)(void*) = nullptr, void* ctx = nullptr); + + // Clean the word, look it up, and on a miss retry mini stem variants + // (-'s/-s/-es/-ies/-ed/-ing). On a hit fills the definition text (capped at + // MAX_DEFINITION_BYTES) and the headword as stored in the index. + bool lookup(const char* word, std::string& definitionOut, std::string& matchedHeadwordOut); + + static std::string cleanWord(const char* word); + + static constexpr uint32_t MAX_DEFINITION_BYTES = 64 * 1024; + + private: + static constexpr uint32_t SAMPLE_INTERVAL = 256; + + DictLocation locate(const char* target, std::string* matchedHeadwordOut); + bool readDefinition(const DictLocation& location, std::string& out); + static void stemVariants(const std::string& word, std::vector& out); + + // Read a null-terminated word from an open file into buf (max bufSize-1 + // chars). Returns the number of characters read (excluding null), or -1 on + // EOF/error. Over-long words are truncated but the stream stays in sync. + static int readWordInto(HalFile& file, char* buf, size_t bufSize); + + std::string basePath; // "/dictionaries//", empty when not open + bool hasPlainDict = false; + + // Shared scan buffer: lookups are single-threaded and this avoids a + // 256-byte array on the stack of every locate() call. + char wordBuf[256] = {}; +}; diff --git a/src/util/DictionaryRegistry.cpp b/src/util/DictionaryRegistry.cpp new file mode 100644 index 00000000..239113be --- /dev/null +++ b/src/util/DictionaryRegistry.cpp @@ -0,0 +1,113 @@ +#include "DictionaryRegistry.h" + +#include +#include + +#include +#include + +#include "StringUtils.h" + +namespace DictionaryRegistry { +namespace { + +// Dictionaries are looked up in both roots, in order. The hidden variant +// lets users keep the folder out of the file browser (hidden by default, +// see FileBrowserActivity's showHiddenFiles check). +constexpr const char* DICT_ROOTS[] = {"/dictionaries", "/.dictionaries"}; + +// Find the single .idx stem inside one dictionary folder. Returns false when +// the folder holds no .idx or more than one distinct stem (ambiguous). +bool findStem(const char* folderPath, std::string& stemOut) { + auto dir = Storage.open(folderPath); + if (!dir || !dir.isDirectory()) return false; + + dir.rewindDirectory(); + char name[128]; + char foundStem[128]; + foundStem[0] = '\0'; + for (auto entry = dir.openNextFile(); entry; entry = dir.openNextFile()) { + entry.getName(name, sizeof(name)); + // Skip macOS metadata files (AppleDouble resource forks) + if (entry.isDirectory() || strncmp(name, "._", 2) == 0) continue; + + const size_t len = strlen(name); + if (len <= 4 || strcmp(name + len - 4, ".idx") != 0) continue; + + name[len - 4] = '\0'; + if (foundStem[0] != '\0' && strcmp(foundStem, name) != 0) { + LOG_DBG("DREG", "Skipping %s: multiple index stems found", folderPath); + return false; + } + strncpy(foundStem, name, sizeof(foundStem) - 1); + foundStem[sizeof(foundStem) - 1] = '\0'; + } + + if (foundStem[0] == '\0') return false; + + // Require dictionary data next to the index, so folders holding only an + // .idx never surface as selectable dictionaries that fail at lookup time. + const std::string base = std::string(folderPath) + "/" + foundStem; + if (!Storage.exists((base + ".dict").c_str()) && !Storage.exists((base + ".dict.dz").c_str())) { + LOG_DBG("DREG", "Skipping %s: no .dict or .dict.dz", folderPath); + return false; + } + + stemOut = foundStem; + return true; +} + +} // namespace + +void discover(std::vector& out) { + out.clear(); + out.reserve(8); + + for (const char* dictRoot : DICT_ROOTS) { + auto rootDir = Storage.open(dictRoot); + if (!rootDir || !rootDir.isDirectory()) { + LOG_DBG("DREG", "No %s directory on SD card", dictRoot); + continue; + } + + rootDir.rewindDirectory(); + char name[128]; + for (auto entry = rootDir.openNextFile(); entry; entry = rootDir.openNextFile()) { + entry.getName(name, sizeof(name)); + if (!entry.isDirectory() || name[0] == '.') continue; + + std::string folderPath = std::string(dictRoot) + "/" + name; + std::string stem; + if (!findStem(folderPath.c_str(), stem)) continue; + + DictionaryEntry e; + e.name = name; + e.stem = std::move(stem); + out.push_back(std::move(e)); + LOG_DBG("DREG", "Found dictionary: %s", name); + } + } + + // Case-insensitive sort by folder name (matches FileBrowserActivity ordering). + std::sort(out.begin(), out.end(), [](const DictionaryEntry& a, const DictionaryEntry& b) { + return StringUtils::asciiCaseCmp(a.name.c_str(), b.name.c_str()) < 0; + }); +} + +bool resolveBasePath(const char* folderName, std::string& basePathOut) { + if (!folderName || folderName[0] == '\0') return false; + // folderName is persisted in the settings JSON: reject separators and dot + // prefixes so a crafted value cannot escape the dictionary roots. + if (folderName[0] == '.' || strpbrk(folderName, "/\\") != nullptr) return false; + + for (const char* dictRoot : DICT_ROOTS) { + std::string folderPath = std::string(dictRoot) + "/" + folderName; + std::string stem; + if (!findStem(folderPath.c_str(), stem)) continue; + basePathOut = folderPath + "/" + stem; + return true; + } + return false; +} + +} // namespace DictionaryRegistry diff --git a/src/util/DictionaryRegistry.h b/src/util/DictionaryRegistry.h new file mode 100644 index 00000000..3fca48ed --- /dev/null +++ b/src/util/DictionaryRegistry.h @@ -0,0 +1,25 @@ +#pragma once + +#include +#include + +// One StarDict dictionary found under /dictionaries or /.dictionaries: a +// subfolder holding .idx plus .dict or .dict.dz. +struct DictionaryEntry { + std::string name; // subfolder name (shown to the user, stored in settings) + std::string stem; // index basename without .idx +}; + +namespace DictionaryRegistry { + +// Scan /dictionaries/*/ and /.dictionaries/*/ for dictionaries. Folders with +// multiple index stems are ambiguous and skipped. Result is sorted +// case-insensitively by name. +void discover(std::vector& out); + +// Resolve a folder name to its extensionless base path +// ("/dictionaries//" or "/.dictionaries//"). +// Returns false if the folder holds no usable dictionary in either root. +bool resolveBasePath(const char* folderName, std::string& basePathOut); + +} // namespace DictionaryRegistry diff --git a/src/util/HtmlToPlainText.cpp b/src/util/HtmlToPlainText.cpp new file mode 100644 index 00000000..a558a03b --- /dev/null +++ b/src/util/HtmlToPlainText.cpp @@ -0,0 +1,150 @@ +#include "HtmlToPlainText.h" + +#include + +#include +#include +#include + +namespace { + +bool isTagStart(const std::string& input, size_t pos) { + if (pos + 1 >= input.size()) return false; + const unsigned char next = input[pos + 1]; + return next == '/' || next == '!' || next == '?' || std::isalpha(next); +} + +enum class TagBreak : uint8_t { None, Line, Paragraph }; + +TagBreak tagBreak(const std::string& input, size_t start, size_t end) { + while (start < end && (input[start] == '/' || std::isspace(static_cast(input[start])))) start++; + const size_t nameStart = start; + while (start < end && std::isalpha(static_cast(input[start]))) start++; + const size_t len = start - nameStart; + if (len == 0) return TagBreak::None; + + const auto equals = [&](const char* name) { + const size_t nameLen = strlen(name); + if (len != nameLen) return false; + for (size_t i = 0; i < len; i++) { + if (std::tolower(static_cast(input[nameStart + i])) != name[i]) return false; + } + return true; + }; + + if (equals("p") || equals("h1") || equals("h2") || equals("h3") || equals("h4") || equals("h5") || equals("h6") || + equals("hr")) { + return TagBreak::Paragraph; + } + if (equals("br") || equals("div") || equals("li") || equals("tr")) return TagBreak::Line; + return TagBreak::None; +} + +void appendCodepoint(std::string& output, uint32_t codepoint) { + if (codepoint == 0 || codepoint > 0x10FFFF || (codepoint >= 0xD800 && codepoint <= 0xDFFF)) return; + if (codepoint <= 0x7F) { + output.push_back(static_cast(codepoint)); + } else if (codepoint <= 0x7FF) { + output.push_back(static_cast(0xC0 | (codepoint >> 6))); + output.push_back(static_cast(0x80 | (codepoint & 0x3F))); + } else if (codepoint <= 0xFFFF) { + output.push_back(static_cast(0xE0 | (codepoint >> 12))); + output.push_back(static_cast(0x80 | ((codepoint >> 6) & 0x3F))); + output.push_back(static_cast(0x80 | (codepoint & 0x3F))); + } else { + output.push_back(static_cast(0xF0 | (codepoint >> 18))); + output.push_back(static_cast(0x80 | ((codepoint >> 12) & 0x3F))); + output.push_back(static_cast(0x80 | ((codepoint >> 6) & 0x3F))); + output.push_back(static_cast(0x80 | (codepoint & 0x3F))); + } +} + +bool appendNumericEntity(std::string& output, const char* entity, size_t len) { + if (len < 4 || entity[0] != '&' || entity[1] != '#' || entity[len - 1] != ';') return false; + size_t pos = 2; + uint32_t base = 10; + if (entity[pos] == 'x' || entity[pos] == 'X') { + base = 16; + pos++; + } + if (pos == len - 1) return false; + + uint32_t value = 0; + for (; pos < len - 1; pos++) { + const unsigned char c = entity[pos]; + uint32_t digit; + if (c >= '0' && c <= '9') + digit = c - '0'; + else if (base == 16 && c >= 'a' && c <= 'f') + digit = c - 'a' + 10; + else if (base == 16 && c >= 'A' && c <= 'F') + digit = c - 'A' + 10; + else + return false; + if (value > (0x10FFFF - digit) / base) return false; + value = value * base + digit; + } + appendCodepoint(output, value); + return true; +} + +void appendBreak(std::string& output, size_t count = 1) { + while (!output.empty() && output.back() == ' ') output.pop_back(); + if (output.empty()) return; + size_t existing = 0; + while (existing < output.size() && output[output.size() - existing - 1] == '\n') existing++; + while (existing++ < count) output.push_back('\n'); +} + +} // namespace + +std::string htmlToPlainText(const std::string& html) { + std::string output; + output.reserve(html.size()); + + for (size_t i = 0; i < html.size();) { + if (html[i] == '<' && isTagStart(html, i)) { + const size_t close = html.find('>', i + 1); + if (close == std::string::npos) { + output.push_back(html[i++]); + continue; + } + const TagBreak separator = tagBreak(html, i + 1, close); + if (separator == TagBreak::Line) + appendBreak(output); + else if (separator == TagBreak::Paragraph) + appendBreak(output, 2); + i = close + 1; + continue; + } + + if (html[i] == '&') { + const size_t semicolon = html.find(';', i + 1); + if (semicolon != std::string::npos && semicolon - i <= 16) { + const size_t len = semicolon - i + 1; + const char* value = lookupHtmlEntity(html.data() + i, len); + if (value != nullptr) { + output.append(value); + i = semicolon + 1; + continue; + } + if (appendNumericEntity(output, html.data() + i, len)) { + i = semicolon + 1; + continue; + } + } + } + + const unsigned char c = html[i++]; + if (c == '\r' || c == '\t') { + if (!output.empty() && output.back() != ' ' && output.back() != '\n') output.push_back(' '); + } else if (c == '\n') { + appendBreak(output); + } else { + output.push_back(static_cast(c)); + } + } + + while (!output.empty() && (output.back() == ' ' || output.back() == '\n')) output.pop_back(); + return output; +} diff --git a/src/util/HtmlToPlainText.h b/src/util/HtmlToPlainText.h new file mode 100644 index 00000000..9f374712 --- /dev/null +++ b/src/util/HtmlToPlainText.h @@ -0,0 +1,7 @@ +#pragma once + +#include + +// Convert an HTML fragment to readable plain text. This intentionally ignores +// styling; block elements become line breaks and HTML entities are decoded. +std::string htmlToPlainText(const std::string& html); diff --git a/src/util/OpdsFilename.cpp b/src/util/OpdsFilename.cpp new file mode 100644 index 00000000..507730a7 --- /dev/null +++ b/src/util/OpdsFilename.cpp @@ -0,0 +1,23 @@ +#include "OpdsFilename.h" + +#include "StringUtils.h" + +std::string opdsBookFilename(const std::string& author, const std::string& title, OpdsFilenameFormat format) { + std::string base; + switch (format) { + case OpdsFilenameFormat::TitleAuthor: + base = author.empty() ? title : title + " - " + author; + break; + case OpdsFilenameFormat::TitleOnly: + base = title; + break; + case OpdsFilenameFormat::AuthorTitle: + default: + base = author.empty() ? title : author + " - " + title; + break; + } + // sanitizeFilename caps at 100 bytes and never returns empty (falls back to + // "book"); ".epub" is appended after so the extension is never truncated — + // identical treatment to the previous inline construction. + return StringUtils::sanitizeFilename(base) + ".epub"; +} diff --git a/src/util/OpdsFilename.h b/src/util/OpdsFilename.h new file mode 100644 index 00000000..33d3e424 --- /dev/null +++ b/src/util/OpdsFilename.h @@ -0,0 +1,18 @@ +#pragma once +#include +#include + +// On-disk filename format for books downloaded from an OPDS server. Stored as a +// uint8_t in CrossPointSettings; cast to this enum at the call sites. `Count` is +// the number of selectable formats (used to cycle the setting in the UI). +enum class OpdsFilenameFormat : uint8_t { + AuthorTitle = 0, // "Author - Title.epub" (default; matches legacy behaviour) + TitleAuthor = 1, // "Title - Author.epub" + TitleOnly = 2, // "Title.epub" + Count = 3, +}; + +// Composes and sanitizes the on-disk filename (including the ".epub" extension) +// for a downloaded OPDS book, according to `format`. When the author is empty, +// every format collapses to just the sanitized title. Pure: no I/O, no globals. +std::string opdsBookFilename(const std::string& author, const std::string& title, OpdsFilenameFormat format); diff --git a/src/util/StringUtils.h b/src/util/StringUtils.h index 1fa6cc01..f82ec97e 100644 --- a/src/util/StringUtils.h +++ b/src/util/StringUtils.h @@ -1,9 +1,32 @@ #pragma once +#include #include namespace StringUtils { +/** + * Case-insensitive ASCII strcmp. Returns <0, 0, or >0 like strcmp, comparing + * each byte by its lowercased value. + * + * Used wherever data is sorted case-insensitively: StarDict indexes (including + * wiktionary-derived dictionaries) and the on-disk dictionary folder list. + * Plain strcmp would land a binary search on the wrong page for any word whose + * alphabetic neighbourhood contains mixed-case boundaries. + * + * Inline (header) because Dictionary's binary search calls it per comparison + * step; a cross-TU call here would defeat inlining on a hot path. + */ +inline int asciiCaseCmp(const char* a, const char* b) { + while (*a && *b) { + int diff = std::tolower(static_cast(*a)) - std::tolower(static_cast(*b)); + if (diff != 0) return diff; + ++a; + ++b; + } + return std::tolower(static_cast(*a)) - std::tolower(static_cast(*b)); +} + /** * Sanitize a string for use as a filename. * Replaces invalid characters with underscores, trims spaces/dots, diff --git a/src/util/TaskWatchdog.h b/src/util/TaskWatchdog.h new file mode 100644 index 00000000..dd260692 --- /dev/null +++ b/src/util/TaskWatchdog.h @@ -0,0 +1,10 @@ +#pragma once + +#include +#include + +inline void resetTaskWatchdogIfSubscribed() { + if (esp_task_wdt_status(nullptr) == ESP_OK) { + esp_task_wdt_reset(); + } +} diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index db3a3f35..5fb011e1 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -43,5 +43,6 @@ add_subdirectory(release_json_parser) add_subdirectory(differential_rounding) add_subdirectory(hyphenation_eval) add_subdirectory(utf8_compose) +add_subdirectory(opds_filename) add_subdirectory(minibidi_arabic) add_subdirectory(combining_marks) diff --git a/test/opds_filename/CMakeLists.txt b/test/opds_filename/CMakeLists.txt new file mode 100644 index 00000000..f4842ff5 --- /dev/null +++ b/test/opds_filename/CMakeLists.txt @@ -0,0 +1,18 @@ +add_executable(OpdsFilenameTest + OpdsFilenameTest.cpp + ${REPO_ROOT}/src/util/OpdsFilename.cpp + ${REPO_ROOT}/src/util/StringUtils.cpp + ${REPO_ROOT}/lib/Utf8/Utf8.cpp +) + +target_include_directories(OpdsFilenameTest PRIVATE + ${REPO_ROOT}/src/util + ${REPO_ROOT}/lib/Utf8 +) + +target_link_libraries(OpdsFilenameTest PRIVATE + crosspoint_test_common + GTest::gtest_main +) + +gtest_discover_tests(OpdsFilenameTest) diff --git a/test/opds_filename/OpdsFilenameTest.cpp b/test/opds_filename/OpdsFilenameTest.cpp new file mode 100644 index 00000000..b1f3b616 --- /dev/null +++ b/test/opds_filename/OpdsFilenameTest.cpp @@ -0,0 +1,52 @@ +#include + +#include + +#include "OpdsFilename.h" + +namespace { + +TEST(OpdsFilename, AuthorTitleIsDefaultOrder) { + EXPECT_EQ(opdsBookFilename("J. Doe", "My Book", OpdsFilenameFormat::AuthorTitle), "J. Doe - My Book.epub"); +} + +TEST(OpdsFilename, TitleAuthorSwapsOrder) { + EXPECT_EQ(opdsBookFilename("J. Doe", "My Book", OpdsFilenameFormat::TitleAuthor), "My Book - J. Doe.epub"); +} + +TEST(OpdsFilename, TitleOnlyIgnoresAuthor) { + EXPECT_EQ(opdsBookFilename("J. Doe", "My Book", OpdsFilenameFormat::TitleOnly), "My Book.epub"); +} + +TEST(OpdsFilename, EmptyAuthorCollapsesToTitleForEveryFormat) { + EXPECT_EQ(opdsBookFilename("", "My Book", OpdsFilenameFormat::AuthorTitle), "My Book.epub"); + EXPECT_EQ(opdsBookFilename("", "My Book", OpdsFilenameFormat::TitleAuthor), "My Book.epub"); + EXPECT_EQ(opdsBookFilename("", "My Book", OpdsFilenameFormat::TitleOnly), "My Book.epub"); +} + +TEST(OpdsFilename, IllegalCharactersAreSanitized) { + // '/' ':' '*' '?' etc. are replaced with '_' by sanitizeFilename. + EXPECT_EQ(opdsBookFilename("A/B", "C:D*E?", OpdsFilenameFormat::AuthorTitle), "A_B - C_D_E_.epub"); +} + +TEST(OpdsFilename, EmptyAuthorAndTitleFallsBackToBook) { + // sanitizeFilename returns "book" when nothing usable remains. + EXPECT_EQ(opdsBookFilename("", "", OpdsFilenameFormat::AuthorTitle), "book.epub"); + EXPECT_EQ(opdsBookFilename("", "", OpdsFilenameFormat::TitleOnly), "book.epub"); +} + +TEST(OpdsFilename, LongNameIsTruncatedToByteBudgetBeforeExtension) { + // sanitizeFilename caps the base at 100 bytes; ".epub" is appended after. + const std::string longTitle(200, 'a'); + const std::string result = opdsBookFilename("", longTitle, OpdsFilenameFormat::TitleOnly); + EXPECT_EQ(result, std::string(100, 'a') + ".epub"); + EXPECT_EQ(result.size(), 105u); +} + +TEST(OpdsFilename, UnknownFormatValueFallsBackToAuthorTitle) { + // Defensive: a persisted value outside the enum still yields a valid name. + const auto bogus = static_cast(99); + EXPECT_EQ(opdsBookFilename("J. Doe", "My Book", bogus), "J. Doe - My Book.epub"); +} + +} // namespace