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 7038a3df..926e57e5 100644 --- a/README.md +++ b/README.md @@ -136,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 --- 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/freeink-sdk b/freeink-sdk index e7d33619..566fce3d 160000 --- a/freeink-sdk +++ b/freeink-sdk @@ -1 +1 @@ -Subproject commit e7d336191b5e45aa0ea9908fda7294fdd82a60b5 +Subproject commit 566fce3d4fe45fdcf76853be788e4d497547b592 diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index be580b10..17f0ca3d 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -1506,6 +1506,20 @@ void GfxRenderer::displayBuffer(const HalDisplay::RefreshMode refreshMode) const display.displayBuffer(refreshMode, fadingFix); } +void GfxRenderer::displayBufferAsync(const HalDisplay::RefreshMode refreshMode) const { + // The async path has no turn-off-screen hook, which the sunlight fading fix + // relies on; keep those users on the blocking path. + if (fadingFix) { + display.displayBuffer(refreshMode, fadingFix); + return; + } + display.displayBufferAsync(refreshMode); +} + +void GfxRenderer::waitRefreshComplete() const { display.waitRefreshComplete(); } + +bool GfxRenderer::supportsAsyncRefresh() const { return !fadingFix && display.supportsAsyncRefresh(); } + size_t GfxRenderer::readFramebufferRegion(int x, int y, int w, int h, uint8_t* dst, size_t dstCapacity) const { if (dst == nullptr || w <= 0 || h <= 0) return 0; @@ -1652,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 d4158910..a23c01bd 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -134,7 +134,19 @@ class GfxRenderer { // Screen ops int getScreenWidth() const; int getScreenHeight() const; + void tapToLogical(float nx, float ny, int& outX, int& outY) const; void displayBuffer(HalDisplay::RefreshMode refreshMode = HalDisplay::FAST_REFRESH) const; + // Non-blocking refresh: starts the waveform and returns so CPU work (e.g. + // grayscale strip rendering) can overlap the panel's refresh time. The + // framebuffer must stay untouched until waitRefreshComplete(). Falls back to + // a blocking refresh when fadingFix is enabled or the panel lacks deferral + // support. See HalDisplay::displayBufferAsync for the baseline contract. + void displayBufferAsync(HalDisplay::RefreshMode refreshMode = HalDisplay::FAST_REFRESH) const; + void waitRefreshComplete() const; + // True when displayBufferAsync() genuinely overlaps: panel defers and + // fadingFix isn't forcing the blocking path. Callers can skip overlap + // scaffolding (e.g. whole-plane grayscale buffers) when false. + bool supportsAsyncRefresh() const; // EXPERIMENTAL: Windowed update - display only a rectangular region // void displayWindow(int x, int y, int width, int height) const; void invertScreen() const; diff --git a/lib/I18n/translations/belarusian.yaml b/lib/I18n/translations/belarusian.yaml index 70228655..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: "У Іўі худы жвавы чорт у зялёнай камізэльцы пабег пад'есці фаршу з юшкай" diff --git a/lib/I18n/translations/catalan.yaml b/lib/I18n/translations/catalan.yaml index 776dbb67..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" diff --git a/lib/I18n/translations/czech.yaml b/lib/I18n/translations/czech.yaml index 774d286e..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" diff --git a/lib/I18n/translations/danish.yaml b/lib/I18n/translations/danish.yaml index cf3041e7..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" diff --git a/lib/I18n/translations/dutch.yaml b/lib/I18n/translations/dutch.yaml index b254c1fa..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" diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index 88e8feda..7a77dcc6 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -88,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" @@ -230,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" @@ -242,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" diff --git a/lib/I18n/translations/finnish.yaml b/lib/I18n/translations/finnish.yaml index f8872ba1..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" diff --git a/lib/I18n/translations/french.yaml b/lib/I18n/translations/french.yaml index 098f3ee3..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é" diff --git a/lib/I18n/translations/german.yaml b/lib/I18n/translations/german.yaml index d0c83dc5..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" diff --git a/lib/I18n/translations/hebrew.yaml b/lib/I18n/translations/hebrew.yaml index d0b20e1b..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: "דלג פרק" diff --git a/lib/I18n/translations/hungarian.yaml b/lib/I18n/translations/hungarian.yaml index c67f58e4..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" diff --git a/lib/I18n/translations/italian.yaml b/lib/I18n/translations/italian.yaml index 35d20e12..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" diff --git a/lib/I18n/translations/kazakh.yaml b/lib/I18n/translations/kazakh.yaml index cce8ce98..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: "Канагаттандырылмагандыктарыныздан" diff --git a/lib/I18n/translations/lithuanian.yaml b/lib/I18n/translations/lithuanian.yaml index 30b40174..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ą" diff --git a/lib/I18n/translations/polish.yaml b/lib/I18n/translations/polish.yaml index 4767815e..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ł." diff --git a/lib/I18n/translations/portuguese-BR.yaml b/lib/I18n/translations/portuguese-BR.yaml index f25b2ffe..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." diff --git a/lib/I18n/translations/romanian.yaml b/lib/I18n/translations/romanian.yaml index 111394d5..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" diff --git a/lib/I18n/translations/russian.yaml b/lib/I18n/translations/russian.yaml index f8e96197..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: "Ничего" diff --git a/lib/I18n/translations/slovak.yaml b/lib/I18n/translations/slovak.yaml index 2513fa02..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" diff --git a/lib/I18n/translations/slovenian.yaml b/lib/I18n/translations/slovenian.yaml index 821504b8..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" diff --git a/lib/I18n/translations/spanish.yaml b/lib/I18n/translations/spanish.yaml index 67fce2f7..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" diff --git a/lib/I18n/translations/swedish.yaml b/lib/I18n/translations/swedish.yaml index 24484b8b..0198666b 100644 --- a/lib/I18n/translations/swedish.yaml +++ b/lib/I18n/translations/swedish.yaml @@ -88,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" diff --git a/lib/I18n/translations/turkish.yaml b/lib/I18n/translations/turkish.yaml index cea580c6..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" diff --git a/lib/I18n/translations/ukrainian.yaml b/lib/I18n/translations/ukrainian.yaml index 3274b63d..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: "Немає" diff --git a/lib/I18n/translations/valencian.yaml b/lib/I18n/translations/valencian.yaml index e1b3a129..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" diff --git a/lib/I18n/translations/vietnamese.yaml b/lib/I18n/translations/vietnamese.yaml index 19753a12..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" 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/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/HalDisplay.cpp b/lib/hal/HalDisplay.cpp index f431bae7..c7a0ceb7 100644 --- a/lib/hal/HalDisplay.cpp +++ b/lib/hal/HalDisplay.cpp @@ -65,6 +65,18 @@ void HalDisplay::displayBuffer(HalDisplay::RefreshMode mode, bool turnOffScreen) einkDisplay.displayBuffer(convertRefreshMode(mode), turnOffScreen); } +void HalDisplay::displayBufferAsync(HalDisplay::RefreshMode mode) { + if (gpio.deviceIsX3() && mode == RefreshMode::HALF_REFRESH) { + einkDisplay.requestResync(1); + } + + einkDisplay.displayBufferAsyncNoShadow(convertRefreshMode(mode)); +} + +void HalDisplay::waitRefreshComplete() { einkDisplay.waitRefreshComplete(); } + +bool HalDisplay::supportsAsyncRefresh() const { return einkDisplay.supportsAsyncRefresh(); } + void HalDisplay::refreshDisplay(HalDisplay::RefreshMode mode, bool turnOffScreen) { if (gpio.deviceIsX3() && mode == RefreshMode::HALF_REFRESH) { einkDisplay.requestResync(1); diff --git a/lib/hal/HalDisplay.h b/lib/hal/HalDisplay.h index c57abfba..34e74a62 100644 --- a/lib/hal/HalDisplay.h +++ b/lib/hal/HalDisplay.h @@ -39,6 +39,17 @@ class HalDisplay { bool fromProgmem = false) const; void displayBuffer(RefreshMode mode = RefreshMode::FAST_REFRESH, bool turnOffScreen = false); + // Non-blocking refresh (shadow-free): starts the panel waveform and returns + // while the panel refreshes on its own. The framebuffer must stay untouched + // until waitRefreshComplete(), and the caller must rebuild the differential + // baseline before the next differential update (the tiled grayscale cleanup + // does). Panels without deferral fall back to a blocking refresh. + void displayBufferAsync(RefreshMode mode = RefreshMode::FAST_REFRESH); + // Block until a pending deferred refresh completes (no-op when none is). + void waitRefreshComplete(); + // True when displayBufferAsync() genuinely overlaps (panel driver defers); + // false where it falls back to a blocking refresh. + bool supportsAsyncRefresh() const; void refreshDisplay(RefreshMode mode = RefreshMode::FAST_REFRESH, bool turnOffScreen = false); // Power management diff --git a/lib/hal/HalGPIO.cpp b/lib/hal/HalGPIO.cpp index 67367cd1..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,7 +231,44 @@ unsigned long HalGPIO::getHeldTime() const { return inputMgr.getHeldTime(); } unsigned long HalGPIO::getPowerButtonHeldTime() const { return inputMgr.getPowerButtonHeldTime(); } +bool HalGPIO::hasTouch() const { return inputMgr.hasTouch(); } + +bool HalGPIO::wasTouchTap(float& nx, float& ny) const { return inputMgr.wasTouchTap(nx, ny); } + +bool HalGPIO::wasTouchDown(float& nx, float& ny) const { return inputMgr.wasTouchPressedAt(nx, ny); } + +bool HalGPIO::isTouchTapCandidate(float& nx, float& ny, unsigned long& heldMs) const { + return inputMgr.isTouchTapCandidate(nx, ny, heldMs); +} + +bool HalGPIO::isTouchHeldAt(float& nx, float& ny) const { return inputMgr.isTouchHeldAt(nx, ny); } + +unsigned long HalGPIO::lastTouchHeldMs() const { return inputMgr.lastTouchHeldMs(); } + +bool HalGPIO::wasSwipe(float& nxStart, float& nyStart, float& nxEnd, float& nyEnd) const { + return inputMgr.wasSwipe(nxStart, nyStart, nxEnd, nyEnd); +} + +bool HalGPIO::wasTouchActivity() const { return inputMgr.wasTouchActivity(); } + +void HalGPIO::setSharedConfirmPowerShortPressEmitsPower(const bool enabled) { + InputManager::setSharedConfirmPowerShortPressEmitsPower(enabled); +} + +bool HalGPIO::isXteinkDevice() const { + return BoardConfig::ACTIVE.board == BoardConfig::Board::XteinkX3 || + BoardConfig::ACTIVE.board == BoardConfig::Board::XteinkX4; +} + bool HalGPIO::verifyPowerButtonWakeup(uint16_t requiredDurationMs, bool shortPressAllowed) { + // Boards without a power button (or M5Paper's latch circuit) cannot verify a + // hold; treat the wake as valid. + if (BoardConfig::ACTIVE.input.power < 0) { + return true; + } +#if defined(FREEINK_DEVICE_M5PAPER) && FREEINK_DEVICE_M5PAPER + return true; +#endif if (shortPressAllowed) { // Fast path - no duration check needed return true; @@ -271,8 +314,10 @@ bool HalGPIO::isUsbConnected() const { } return false; } - // U0RXD/GPIO20 reads HIGH when USB is connected - return digitalRead(UART0_RXD) == HIGH; + if (BoardConfig::ACTIVE.usbDetect < 0) { + return false; + } + return digitalRead(BoardConfig::ACTIVE.usbDetect) == HIGH; } HalGPIO::WakeupReason HalGPIO::getWakeupReason() const { @@ -281,8 +326,11 @@ HalGPIO::WakeupReason HalGPIO::getWakeupReason() const { const bool usbConnected = isUsbConnected(); - if ((wakeupCause == ESP_SLEEP_WAKEUP_UNDEFINED && resetReason == ESP_RST_POWERON && !usbConnected) || - (wakeupCause == ESP_SLEEP_WAKEUP_GPIO && resetReason == ESP_RST_DEEPSLEEP && usbConnected)) { + if (resetReason == ESP_RST_DEEPSLEEP && + (wakeupCause == ESP_SLEEP_WAKEUP_GPIO || wakeupCause == ESP_SLEEP_WAKEUP_EXT1)) { + return WakeupReason::PowerButton; + } + if (wakeupCause == ESP_SLEEP_WAKEUP_UNDEFINED && resetReason == ESP_RST_POWERON && !usbConnected) { return WakeupReason::PowerButton; } if (wakeupCause == ESP_SLEEP_WAKEUP_UNDEFINED && resetReason == ESP_RST_UNKNOWN && usbConnected) { diff --git a/lib/hal/HalGPIO.h b/lib/hal/HalGPIO.h index 9456dcb8..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,6 +72,15 @@ class HalGPIO { bool wasAnyReleased() const; unsigned long getHeldTime() const; unsigned long getPowerButtonHeldTime() const; + bool hasTouch() const; + bool wasTouchTap(float& nx, float& ny) const; + bool wasTouchDown(float& nx, float& ny) const; + bool isTouchTapCandidate(float& nx, float& ny, unsigned long& heldMs) const; + bool isTouchHeldAt(float& nx, float& ny) const; + unsigned long lastTouchHeldMs() const; + bool wasSwipe(float& nxStart, float& nyStart, float& nxEnd, float& nyEnd) const; + bool wasTouchActivity() const; + void setSharedConfirmPowerShortPressEmitsPower(bool enabled); // Verify power button was held long enough after wakeup. // Returns true if verification succeeded, false if device should return to sleep. 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/platformio.ini b/platformio.ini index 89b3762d..ddd05440 100644 --- a/platformio.ini +++ b/platformio.ini @@ -35,8 +35,6 @@ build_flags = # Increase PNG scanline buffer to support up to 2048px wide images # Default is (320*4+1)*2=2562, we need more for larger images -DPNG_MAX_BUFFERED_PIXELS=16416 - -DFREEINK_DEVICE_X4=1 - -DFREEINK_DEVICE_X3=1 -DFREEINK_NET_WOLFSSL=1 -DWOLFSSL_USER_SETTINGS -DWOLFSSL_OPTIONS_H @@ -79,6 +77,8 @@ lib_deps = SDCardManager=symlink://freeink-sdk/libs/hardware/SDCardManager BoardConfig=symlink://freeink-sdk/libs/hardware/BoardConfig PowerManager=symlink://freeink-sdk/libs/hardware/PowerManager + Rtc=symlink://freeink-sdk/libs/hardware/Rtc + Imu=symlink://freeink-sdk/libs/hardware/Imu SecureNet=symlink://freeink-sdk/libs/network/SecureNet FreeInkUI=symlink://freeink-sdk/libs/ui/FreeInkUI Icons=symlink://freeink-sdk/libs/assets/Icons @@ -97,6 +97,8 @@ extends = base build_flags = ${base.build_flags} ; CROSSPOINT_VERSION is set by scripts/git_branch.py (includes branch + short SHA) + -DFREEINK_DEVICE_X4=1 + -DFREEINK_DEVICE_X3=1 -DENABLE_SERIAL_LOG -DLOG_LEVEL=2 ; Set log level to debug for development builds @@ -105,6 +107,8 @@ build_flags = extends = base build_flags = ${base.build_flags} + -DFREEINK_DEVICE_X4=1 + -DFREEINK_DEVICE_X3=1 -DCROSSPOINT_VERSION=\"${crosspoint.version}\" -DENABLE_SERIAL_LOG -DLOG_LEVEL=1 ; Set log level to info for release builds @@ -113,6 +117,8 @@ build_flags = extends = base build_flags = ${base.build_flags} + -DFREEINK_DEVICE_X4=1 + -DFREEINK_DEVICE_X3=1 -DCROSSPOINT_VERSION=\"${crosspoint.version}-rc+${sysenv.CROSSPOINT_RC_HASH}\" -DENABLE_SERIAL_LOG -DLOG_LEVEL=1 ; Set log level to info for release candidate builds @@ -121,6 +127,25 @@ build_flags = extends = base build_flags = ${base.build_flags} + -DFREEINK_DEVICE_X4=1 + -DFREEINK_DEVICE_X3=1 -DCROSSPOINT_VERSION=\"${crosspoint.version}-slim\" ; serial output is disabled in slim builds to save space -UENABLE_SERIAL_LOG + +; --- Seeed Sticky — ESP32-S3R8, 3.97" 800x480 SSD1677 + GT911 touch ----------- +; Different MCU family than the C3 envs (one binary per family). The SDK +; auto-enables CAP_TOUCH (GT911) and the BQ27220 gauge for this device; PSRAM is +; intentionally left off (48KB framebuffer fits in DRAM, same as X4). +; pio run -e sticky -t upload +[env:sticky] +extends = base +board = esp32-s3-devkitc1-n16r8 +board_build.mcu = esp32s3 +build_flags = + ${base.build_flags} + -DFREEINK_DEVICE_STICKY=1 + ; git_branch.py only injects CROSSPOINT_VERSION for the default env; set it here. + -DCROSSPOINT_VERSION=\"${crosspoint.version}-sticky\" + -DENABLE_SERIAL_LOG + -DLOG_LEVEL=2 diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index 6f2f71e3..a8140b06 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -176,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, @@ -280,6 +282,8 @@ class CrossPointSettings { 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/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 f57b8e2a..48f8a612 100644 --- a/src/SettingsList.h +++ b/src/SettingsList.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -209,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, @@ -345,6 +348,19 @@ 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()) { 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 f24da28d..ccbc1d0a 100644 --- a/src/activities/browser/OpdsBookBrowserActivity.cpp +++ b/src/activities/browser/OpdsBookBrowserActivity.cpp @@ -14,6 +14,7 @@ #include "activities/network/WifiSelectionActivity.h" #include "activities/util/KeyboardEntryActivity.h" #include "components/UITheme.h" +#include "components/icons/search24.h" #include "fontIds.h" #include "network/HttpDownloader.h" #include "util/BookCacheUtils.h" @@ -23,8 +24,22 @@ namespace { constexpr int PAGE_ITEMS = 23; +constexpr int HEADER_Y = 15; +constexpr int HEADER_X = 16; +constexpr int SEARCH_ICON_SIZE = 24; +constexpr int SEARCH_ICON_MARGIN = 14; +constexpr int SEARCH_ICON_Y = 15; constexpr int DOWNLOAD_PROGRESS_STEP_PERCENT = 5; constexpr unsigned long DOWNLOAD_PROGRESS_MIN_UPDATE_MS = 5000; + +Rect searchIconRect(const GfxRenderer& renderer) { + return Rect{renderer.getScreenWidth() - SEARCH_ICON_SIZE - SEARCH_ICON_MARGIN, SEARCH_ICON_Y, SEARCH_ICON_SIZE + 8, + SEARCH_ICON_SIZE + 8}; +} + +bool contains(const Rect& rect, const int x, const int y) { + return x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height; +} } // namespace void OpdsBookBrowserActivity::onEnter() { @@ -72,7 +87,9 @@ void OpdsBookBrowserActivity::loop() { } if (state == BrowserState::ERROR) { - if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + int tx = 0; + int ty = 0; + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm) || mappedInput.wasScreenTapped(tx, ty)) { if (WiFi.status() == WL_CONNECTED && WiFi.localIP() != IPAddress(0, 0, 0, 0)) { state = BrowserState::LOADING; statusMessage = tr(STR_LOADING); @@ -97,18 +114,59 @@ void OpdsBookBrowserActivity::loop() { if (state == BrowserState::DOWNLOADING) return; if (state == BrowserState::BROWSING) { - if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + auto activateSelected = [this] { if (!entries.empty()) { const auto& entry = entries[selectorIndex]; entry.type == OpdsEntryType::BOOK ? downloadBook(entry) : navigateToEntry(entry); } + }; + + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + activateSelected(); } else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { navigateBack(); } else if (mappedInput.wasReleased(MappedInputManager::Button::Left)) { if (!searchTemplate.empty() && selectorIndex == 0) launchSearch(); } + int tx = 0; + int ty = 0; + if (!searchTemplate.empty() && mappedInput.wasScreenTapped(tx, ty) && contains(searchIconRect(renderer), tx, ty)) { + launchSearch(); + return; + } + if (!entries.empty()) { + int row = -1; + const auto touch = mappedInput.rowTouch(row, /*top=*/60, /*rowStep=*/30, PAGE_ITEMS); + if (touch != MappedInputManager::RowTouch::None) { + const int touched = selectorIndex / PAGE_ITEMS * PAGE_ITEMS + row; + if (touched >= 0 && touched < static_cast(entries.size())) { + if (touch == MappedInputManager::RowTouch::Down) { + if (selectorIndex != touched) { + selectorIndex = touched; + requestUpdate(); + } + } else { + selectorIndex = touched; + activateSelected(); + } + return; + } + } + + const auto swipe = mappedInput.wasSwipe(); + if (swipe == MappedInputManager::SwipeDir::Up) { + selectorIndex = ButtonNavigator::nextPageIndex(selectorIndex, entries.size(), PAGE_ITEMS); + requestUpdate(); + return; + } + if (swipe == MappedInputManager::SwipeDir::Down) { + selectorIndex = ButtonNavigator::previousPageIndex(selectorIndex, entries.size(), PAGE_ITEMS); + requestUpdate(); + return; + } + buttonNavigator.onNextRelease([this] { selectorIndex = ButtonNavigator::nextIndex(selectorIndex, entries.size()); requestUpdate(); @@ -136,7 +194,14 @@ void OpdsBookBrowserActivity::render(RenderLock&&) { // Show server name in header if available, otherwise generic title const char* headerTitle = server.name.empty() ? tr(STR_OPDS_BROWSER) : server.name.c_str(); - renderer.drawCenteredText(UI_12_FONT_ID, 15, headerTitle, true, EpdFontFamily::BOLD); + const int headerRightInset = searchTemplate.empty() ? HEADER_X : (SEARCH_ICON_SIZE + SEARCH_ICON_MARGIN * 2 + 8); + const auto clippedHeader = + renderer.truncatedText(UI_12_FONT_ID, headerTitle, pageWidth - HEADER_X - headerRightInset, EpdFontFamily::BOLD); + renderer.drawText(UI_12_FONT_ID, HEADER_X, HEADER_Y, clippedHeader.c_str(), true, EpdFontFamily::BOLD); + if (!searchTemplate.empty()) { + const auto rect = searchIconRect(renderer); + renderer.drawIcon(Search24Icon.bits, rect.x + 4, rect.y + 4, Search24Icon.w); + } if (state == BrowserState::CHECK_WIFI || state == BrowserState::LOADING) { renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, statusMessage.c_str()); @@ -149,6 +214,9 @@ void OpdsBookBrowserActivity::render(RenderLock&&) { if (state == BrowserState::ERROR) { renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 20, tr(STR_ERROR_MSG)); renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 10, errorMessage.c_str()); + if (mappedInput.hasTouch()) { + renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 40, tr(STR_TAP_TO_RETRY)); + } const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_RETRY), "", ""); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); renderer.displayBuffer(); 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 bc5a5c50..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,6 +207,18 @@ void HomeActivity::loop() { requestUpdate(); }); + const auto swipe = mappedInput.wasSwipe(); + if (swipe == MappedInputManager::SwipeDir::Up) { + selectorIndex = ButtonNavigator::nextIndex(selectorIndex, menuCount); + requestUpdate(); + return; + } + if (swipe == MappedInputManager::SwipeDir::Down) { + selectorIndex = ButtonNavigator::previousIndex(selectorIndex, menuCount); + requestUpdate(); + return; + } + if (mappedInput.wasPressed(MappedInputManager::Button::Back)) backPressSeen = true; // Back is otherwise unused on the home menu: open the most recently read @@ -190,31 +230,49 @@ void HomeActivity::loop() { return; } - 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; - } + 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(); } } diff --git a/src/activities/home/HomeActivity.h b/src/activities/home/HomeActivity.h index 169bbbf4..84bd62ec 100644 --- a/src/activities/home/HomeActivity.h +++ b/src/activities/home/HomeActivity.h @@ -80,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 c577f04c..f6436977 100644 --- a/src/activities/network/WifiSelectionActivity.cpp +++ b/src/activities/network/WifiSelectionActivity.cpp @@ -517,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) { @@ -546,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) { @@ -641,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 index a9f65ebf..963c23f2 100644 --- a/src/activities/reader/DictionaryDefinitionActivity.cpp +++ b/src/activities/reader/DictionaryDefinitionActivity.cpp @@ -164,6 +164,23 @@ void DictionaryDefinitionActivity::loop() { 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++; diff --git a/src/activities/reader/DictionaryWordSelectActivity.cpp b/src/activities/reader/DictionaryWordSelectActivity.cpp index 42cb87ed..5a4390ce 100644 --- a/src/activities/reader/DictionaryWordSelectActivity.cpp +++ b/src/activities/reader/DictionaryWordSelectActivity.cpp @@ -106,6 +106,20 @@ void DictionaryWordSelectActivity::extractWords() { } } +// 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 { @@ -185,6 +199,28 @@ void DictionaryWordSelectActivity::loop() { } 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(); diff --git a/src/activities/reader/DictionaryWordSelectActivity.h b/src/activities/reader/DictionaryWordSelectActivity.h index 164cf323..2ca842c9 100644 --- a/src/activities/reader/DictionaryWordSelectActivity.h +++ b/src/activities/reader/DictionaryWordSelectActivity.h @@ -9,9 +9,10 @@ #include "activities/Activity.h" #include "util/Dictionary.h" -// Button-driven 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. +// 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, @@ -41,6 +42,7 @@ class DictionaryWordSelectActivity final : public Activity { 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(); diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index eaf310d4..654291d2 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -372,9 +372,11 @@ void EpubReaderActivity::loop() { pendingReadFolderMove = false; } + const auto touch = ReaderUtils::detectTouchPageTurn(renderer, mappedInput); + if (automaticPageTurnActive) { if (mappedInput.wasReleased(MappedInputManager::Button::Confirm) || - mappedInput.wasReleased(MappedInputManager::Button::Back)) { + mappedInput.wasReleased(MappedInputManager::Button::Back) || ReaderUtils::isTouchMenuGesture(mappedInput)) { automaticPageTurnActive = false; // updates chapter title space to indicate page turn disabled requestUpdate(); @@ -437,10 +439,10 @@ void EpubReaderActivity::loop() { } } - // Enter reader menu activity on short-press Confirm. A long-press that fired a bound - // function (bookmark or KOReader sync) sets ignoreNextConfirmRelease so the release + // Enter reader menu activity on short-press Confirm or a downward swipe from the top edge. A long-press + // that fired a bound function (bookmark or KOReader sync) sets ignoreNextConfirmRelease so the release // following the hold does not also open the menu. - if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm) || ReaderUtils::isTouchMenuGesture(mappedInput)) { if (ignoreNextConfirmRelease) { ignoreNextConfirmRelease = false; } else { @@ -523,7 +525,9 @@ void EpubReaderActivity::loop() { return; } - const auto [prevTriggered, nextTriggered, fromTilt] = ReaderUtils::detectPageTurn(mappedInput); + auto [prevTriggered, nextTriggered, fromTilt] = ReaderUtils::detectPageTurn(mappedInput); + prevTriggered = prevTriggered || touch.prev; + nextTriggered = nextTriggered || touch.next; if (!prevTriggered && !nextTriggered) { return; } @@ -547,7 +551,8 @@ void EpubReaderActivity::loop() { return; } - const bool longPress = !fromTilt && mappedInput.getHeldTime() > ReaderUtils::SKIP_HOLD_MS; + const unsigned long heldMs = (touch.prev || touch.next) ? touch.heldMs : mappedInput.getHeldTime(); + const bool longPress = !fromTilt && heldMs > ReaderUtils::SKIP_HOLD_MS; // Don't skip chapter after screenshot if (gpio.wasReleased(HalGPIO::BTN_POWER) && gpio.wasReleased(HalGPIO::BTN_DOWN)) { @@ -1391,6 +1396,11 @@ void EpubReaderActivity::renderContents(std::unique_ptr page, const int or const bool pageHasImagesNeedingDecode = pageHasImages && page->hasImagesNeedingDecode(); const bool needsTextGrayscale = SETTINGS.textAntiAliasing; const bool needsAnyGrayscale = needsTextGrayscale || pageHasImages; + const bool tiledGrayscale = needsAnyGrayscale && renderer.supportsStripGrayscale(); + // Whole-plane buffering only pays when the BW refresh genuinely runs async + // underneath it; on blocking panels it would just spend ~50 KB for the + // identical serial timing. + const bool overlapRefresh = tiledGrayscale && renderer.supportsAsyncRefresh(); auto renderGrayscalePass = [&]() { if (needsTextGrayscale) { page->render(renderer, fontId, orientedMarginLeft, orientedMarginTop); @@ -1436,50 +1446,66 @@ void EpubReaderActivity::renderContents(std::unique_ptr page, const int or // regardless of residue. pagesUntilFullRefresh = 1; } else { - ReaderUtils::displayWithRefreshCycle(renderer, pagesUntilFullRefresh); + // Deferred when a tiled grayscale pass follows: the plane rendering below + // then overlaps the panel's refresh time instead of following it. + ReaderUtils::displayWithRefreshCycle(renderer, pagesUntilFullRefresh, /*async=*/overlapRefresh); } const auto tDisplay = millis(); - // Tiled grayscale: render each plane band-by-band into a small scratch and - // stream straight to the controller, leaving the BW framebuffer intact so no - // full-frame storeBwBuffer is needed; controller RAM is re-synced from the - // live framebuffer afterward. The page is re-rendered ceil(H/STRIP_ROWS) times - // per plane, but renderCharImpl culls out-of-band glyphs before decode so the - // cost stays close to one render. Both text (drawPixel) and images - // (DirectPixelWriter) honor the active strip target. - if (needsAnyGrayscale && renderer.supportsStripGrayscale()) { + // Tiled grayscale: render each plane band-by-band, leaving the BW + // framebuffer intact so no full-frame storeBwBuffer is needed; controller + // RAM is re-synced from the live framebuffer afterward. The page is + // re-rendered ceil(H/STRIP_ROWS) times per plane, but renderCharImpl culls + // out-of-band glyphs before decode so the cost stays close to one render. + // Both text (drawPixel) and images (DirectPixelWriter) honor the active + // strip target. When the BW refresh above went out async, the plane + // rendering below overlaps the panel's refresh time; only the controller + // RAM writes wait for BUSY. + if (tiledGrayscale) { constexpr int STRIP_ROWS = 80; const int gh = renderer.getDisplayHeight(); const int gwBytes = renderer.getDisplayWidthBytes(); + const size_t planeBytes = static_cast(gwBytes) * gh; - auto scratch = makeUniqueNoThrow(static_cast(gwBytes) * STRIP_ROWS); - if (!scratch) { - LOG_ERR("ERS", "OOM: grayscale strip scratch (%d bytes); skipping AA this page", gwBytes * STRIP_ROWS); - } else { - // Bands may be streamed in any order: X4 windows each via setRamArea, X3 - // via PTL. - renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB); + // Render one plane band-by-band into a whole-plane buffer without touching + // the controller, so it can run while the refresh is still in flight. + auto renderPlaneToBuffer = [&](const bool lsbPlane, uint8_t* buf) { + renderer.setRenderMode(lsbPlane ? GfxRenderer::GRAYSCALE_LSB : GfxRenderer::GRAYSCALE_MSB); for (int y = 0; y < gh; y += STRIP_ROWS) { const int rows = (gh - y < STRIP_ROWS) ? (gh - y) : STRIP_ROWS; - renderer.beginStripTarget(scratch.get(), y, rows); + renderer.beginStripTarget(buf + static_cast(y) * gwBytes, y, rows); renderer.clearScreen(0x00); renderGrayscalePass(); renderer.endStripTarget(); - renderer.writeGrayscalePlaneStrip(true, scratch.get(), y, rows); } - const auto tGrayLsb = millis(); + }; - // MSB plane. - renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB); - for (int y = 0; y < gh; y += STRIP_ROWS) { - const int rows = (gh - y < STRIP_ROWS) ? (gh - y) : STRIP_ROWS; - renderer.beginStripTarget(scratch.get(), y, rows); - renderer.clearScreen(0x00); - renderGrayscalePass(); - renderer.endStripTarget(); - renderer.writeGrayscalePlaneStrip(false, scratch.get(), y, rows); + // Tiered on heap pressure: two plane buffers hide both plane renders + // inside the refresh wait; one hides the LSB render (its buffer is reused + // for MSB after streaming); none falls back to the strip-scratch flow with + // no overlap. The MSB buffer is only attempted when it leaves ~60 KB free + // so the pass never starves concurrent allocations. Blocking panels skip + // the buffers entirely (nothing to overlap). + auto lsbPlaneBuf = overlapRefresh ? makeUniqueNoThrow(planeBytes) : nullptr; + auto msbPlaneBuf = + (lsbPlaneBuf && ESP.getFreeHeap() >= planeBytes + 60000) ? makeUniqueNoThrow(planeBytes) : nullptr; + + if (lsbPlaneBuf) { + renderPlaneToBuffer(true, lsbPlaneBuf.get()); + if (msbPlaneBuf) renderPlaneToBuffer(false, msbPlaneBuf.get()); + const auto tGrayRender = millis(); + + renderer.waitRefreshComplete(); + const auto tWait = millis(); + + renderer.writeGrayscalePlaneStrip(true, lsbPlaneBuf.get(), 0, gh); + if (msbPlaneBuf) { + renderer.writeGrayscalePlaneStrip(false, msbPlaneBuf.get(), 0, gh); + } else { + renderPlaneToBuffer(false, lsbPlaneBuf.get()); + renderer.writeGrayscalePlaneStrip(false, lsbPlaneBuf.get(), 0, gh); } - const auto tGrayMsb = millis(); + const auto tGrayWrite = millis(); renderer.setRenderMode(GfxRenderer::BW); renderer.displayGrayBuffer(); @@ -1488,14 +1514,63 @@ void EpubReaderActivity::renderContents(std::unique_ptr page, const int or // BW framebuffer is intact; re-sync controller RAM for the next // differential page turn directly from it. renderer.cleanupGrayscaleWithFrameBuffer(); - const auto tCleanup = millis(); - const auto tEnd = millis(); + LOG_DBG("ERS", - "Page render (tiled): prewarm=%lums bw_render=%lums display=%lums gray_lsb=%lums " - "gray_msb=%lums gray_display=%lums cleanup=%lums total=%lums", - tPrewarm - t0, tBwRender - tPrewarm, tDisplay - tBwRender, tGrayLsb - tDisplay, tGrayMsb - tGrayLsb, - tGrayDisplay - tGrayMsb, tCleanup - tGrayDisplay, tEnd - t0); + "Page render (tiled async): prewarm=%lums bw_render=%lums display=%lums gray_render=%lums " + "wait=%lums gray_write=%lums gray_display=%lums cleanup=%lums total=%lums (planes buffered: %d)", + tPrewarm - t0, tBwRender - tPrewarm, tDisplay - tBwRender, tGrayRender - tDisplay, tWait - tGrayRender, + tGrayWrite - tWait, tGrayDisplay - tGrayWrite, tEnd - tGrayDisplay, tEnd - t0, msbPlaneBuf ? 2 : 1); + } else { + // Per-strip scratch tier: blocking panels and the OOM fallback. The + // strip writes below need the panel idle, so wait out any pending async + // refresh first (no-op on blocking panels). + auto scratch = makeUniqueNoThrow(static_cast(gwBytes) * STRIP_ROWS); + renderer.waitRefreshComplete(); + if (!scratch) { + LOG_ERR("ERS", "OOM: grayscale strip scratch (%d bytes); skipping AA this page", gwBytes * STRIP_ROWS); + } else { + // Bands may be streamed in any order: X4 windows each via setRamArea, + // X3 via PTL. + renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB); + for (int y = 0; y < gh; y += STRIP_ROWS) { + const int rows = (gh - y < STRIP_ROWS) ? (gh - y) : STRIP_ROWS; + renderer.beginStripTarget(scratch.get(), y, rows); + renderer.clearScreen(0x00); + renderGrayscalePass(); + renderer.endStripTarget(); + renderer.writeGrayscalePlaneStrip(true, scratch.get(), y, rows); + } + const auto tGrayLsb = millis(); + + // MSB plane. + renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB); + for (int y = 0; y < gh; y += STRIP_ROWS) { + const int rows = (gh - y < STRIP_ROWS) ? (gh - y) : STRIP_ROWS; + renderer.beginStripTarget(scratch.get(), y, rows); + renderer.clearScreen(0x00); + renderGrayscalePass(); + renderer.endStripTarget(); + renderer.writeGrayscalePlaneStrip(false, scratch.get(), y, rows); + } + const auto tGrayMsb = millis(); + + renderer.setRenderMode(GfxRenderer::BW); + renderer.displayGrayBuffer(); + const auto tGrayDisplay = millis(); + + // BW framebuffer is intact; re-sync controller RAM for the next + // differential page turn directly from it. + renderer.cleanupGrayscaleWithFrameBuffer(); + const auto tCleanup = millis(); + + const auto tEnd = millis(); + LOG_DBG("ERS", + "Page render (tiled): prewarm=%lums bw_render=%lums display=%lums gray_lsb=%lums " + "gray_msb=%lums gray_display=%lums cleanup=%lums total=%lums", + tPrewarm - t0, tBwRender - tPrewarm, tDisplay - tBwRender, tGrayLsb - tDisplay, tGrayMsb - tGrayLsb, + tGrayDisplay - tGrayMsb, tCleanup - tGrayDisplay, tEnd - t0); + } } } else { // Fallback path for a controller without strip support. grayscale rendering 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 293c055b..b194b1e2 100644 --- a/src/activities/reader/EpubReaderMenuActivity.cpp +++ b/src/activities/reader/EpubReaderMenuActivity.cpp @@ -50,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()), @@ -88,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 732fa144..5850f6eb 100644 --- a/src/activities/reader/EpubReaderMenuActivity.h +++ b/src/activities/reader/EpubReaderMenuActivity.h @@ -36,6 +36,7 @@ class EpubReaderMenuActivity final : public Activity { void onExit() override; void loop() override; void render(RenderLock&&) override; + bool handleHomeGesture() override; private: struct MenuItem { @@ -44,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; @@ -52,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 ff90ef73..cb299083 100644 --- a/src/activities/reader/KOReaderSyncActivity.cpp +++ b/src/activities/reader/KOReaderSyncActivity.cpp @@ -536,6 +536,35 @@ void KOReaderSyncActivity::loop() { } 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)) { @@ -548,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)) { @@ -563,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/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 bdec3a14..9ef55c20 100644 --- a/src/activities/reader/ReaderUtils.h +++ b/src/activities/reader/ReaderUtils.h @@ -2,8 +2,10 @@ #include #include +#include #include #include +#include #include "MappedInputManager.h" #include "activities/ActivityManager.h" @@ -16,6 +18,11 @@ 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: @@ -61,12 +68,63 @@ inline PageTurnResult detectPageTurn(const MappedInputManager& input) { return {prev, next, tiltPrev || tiltNext}; } -inline void displayWithRefreshCycle(const GfxRenderer& renderer, int& pagesUntilFullRefresh) { +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 +// renderer.waitRefreshComplete() and must rebuild the differential baseline +// before the next page turn (the tiled grayscale cleanup does). +inline void displayWithRefreshCycle(const GfxRenderer& renderer, int& pagesUntilFullRefresh, bool async = false) { + const auto mode = (pagesUntilFullRefresh <= 1) ? HalDisplay::HALF_REFRESH : HalDisplay::FAST_REFRESH; + if (async) { + renderer.displayBufferAsync(mode); + } else { + renderer.displayBuffer(mode); + } if (pagesUntilFullRefresh <= 1) { - renderer.displayBuffer(HalDisplay::HALF_REFRESH); pagesUntilFullRefresh = SETTINGS.getRefreshFrequency(); } else { - renderer.displayBuffer(); pagesUntilFullRefresh--; } } diff --git a/src/activities/reader/TxtReaderActivity.cpp b/src/activities/reader/TxtReaderActivity.cpp index 7911e1ca..62c420f2 100644 --- a/src/activities/reader/TxtReaderActivity.cpp +++ b/src/activities/reader/TxtReaderActivity.cpp @@ -65,7 +65,10 @@ void TxtReaderActivity::loop() { 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 12868253..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,7 +99,7 @@ void XtcReaderActivity::loop() { } // Enter chapter selection activity - if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm) || ReaderUtils::isTouchMenuGesture(mappedInput)) { openChapterSelection(); } @@ -106,7 +108,9 @@ void XtcReaderActivity::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; } @@ -128,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 3c781864..90dd9451 100644 --- a/src/activities/settings/KOReaderAuthActivity.cpp +++ b/src/activities/settings/KOReaderAuthActivity.cpp @@ -106,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/KOReaderSettingsActivity.cpp b/src/activities/settings/KOReaderSettingsActivity.cpp index 1c43daa5..2f6fe0c9 100644 --- a/src/activities/settings/KOReaderSettingsActivity.cpp +++ b/src/activities/settings/KOReaderSettingsActivity.cpp @@ -29,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; } 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 345c175d..9d52df9e 100644 --- a/src/activities/settings/OpdsServerListActivity.cpp +++ b/src/activities/settings/OpdsServerListActivity.cpp @@ -64,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); @@ -74,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(); 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 5212dab2..9a03a81f 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -1,5 +1,6 @@ #include "SettingsActivity.h" +#include #include #include @@ -62,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 @@ -122,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) { @@ -146,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(); @@ -171,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 36a960e1..6230caf4 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,4 +1,5 @@ #include +#include #include #include #include @@ -260,6 +261,8 @@ void setupDisplayAndFonts(bool seamless = false) { } void setup() { + BoardConfig::holdPowerRails(); + t1 = millis(); #ifdef ENABLE_SERIAL_LOG @@ -270,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(); @@ -444,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()); @@ -474,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 a6b2e103..4804ee9a 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 @@ -394,6 +394,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++) { @@ -403,6 +405,7 @@ void CrossPointWebServer::handleStatus() const { } } } +#endif if (valid) { doc["serial"] = snBuf; @@ -466,8 +469,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; @@ -618,12 +621,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); @@ -639,7 +642,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) { @@ -651,7 +654,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; @@ -687,7 +690,8 @@ void CrossPointWebServer::handleUpload(UploadState& state) const { if (!filePath.endsWith("/")) filePath += "/"; filePath += state.fileName; - esp_task_wdt_reset(); + // Check if file already exists - SD operations can be slow + resetTaskWatchdogIfSubscribed(); if (Storage.exists(filePath.c_str())) { state.error = "File already exists: " + state.fileName; LOG_DBG("WEB", "[UPLOAD] Collision: %s", filePath.c_str()); @@ -695,13 +699,13 @@ void CrossPointWebServer::handleUpload(UploadState& state) const { } // 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) { @@ -1638,7 +1642,7 @@ void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t* if (!filePath.endsWith("/")) filePath += "/"; filePath += wsUploadFileName; - esp_task_wdt_reset(); + resetTaskWatchdogIfSubscribed(); if (Storage.exists(filePath.c_str())) { LOG_DBG("WS", "Upload collision: %s", filePath.c_str()); wsServer->sendTXT(num, "ERROR:File already exists: " + wsUploadFileName); @@ -1649,14 +1653,14 @@ void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t* 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) { @@ -1695,9 +1699,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"); @@ -1801,7 +1805,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(); @@ -1852,7 +1856,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) { @@ -1879,7 +1883,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/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(); + } +}