feat: Add touch coordinate mapping and RTOS task yielding (#2481)
Co-authored-by: Julia Nguyen <julia@uxj.io>
This commit is contained in:
co-authored by
Julia Nguyen
parent
c9188a7347
commit
f42fab1c66
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -7,5 +7,6 @@ It is written for software developers who may be new to embedded development.
|
||||
- [Architecture Overview](./architecture.md)
|
||||
- [Development Workflow](./development-workflow.md)
|
||||
- [Testing and Debugging](./testing-debugging.md)
|
||||
- [Touch and UI Development](./touch-and-ui.md)
|
||||
|
||||
If you are new, start with [Getting Started](./getting-started.md).
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
# Touch and UI Development
|
||||
|
||||
CrossPoint now runs on touch devices (Seeed Sticky, M5Paper, LilyGo T5) alongside the button-only Xteink X3/X4. Every screen must work with both input styles. There are two supported ways to get there:
|
||||
|
||||
1. **New screens: build them with FreeInkUI components.** Touch hit-testing, tap highlighting, long-press, and button focus navigation come with the component; you never hand-roll coordinate math.
|
||||
2. **Existing screens and in-flight features: use the MappedInputManager touch bridge.** A small set of helpers adds tap/hold/swipe support to hand-rolled rendering without restructuring the activity.
|
||||
|
||||
If you are starting a new activity, use FreeInkUI. If you have a feature branch with a hand-rolled screen already working on buttons, use the bridge; do not rewrite mid-flight.
|
||||
|
||||
---
|
||||
|
||||
## Path 1: New screens with FreeInkUI
|
||||
|
||||
FreeInkUI (`freeink-sdk/libs/ui/FreeInkUI`, namespace `freeink::ui`) is an immediate-mode component library. The core idea:
|
||||
|
||||
- While a component renders, it registers its tappable areas ("hit rects") into the frame's interaction buffer via `Frame::hit(rect, action, value, inputMask, state)`.
|
||||
- Each loop you build an `InputSnapshot` from the input manager and route it against that buffer. If a tap (or mapped button press) lands in a registered rect, the routed `ActionEvent` tells you which action fired and with what value.
|
||||
- Components query `frame.stateFor(action, value)` while painting, so touch-down highlight and focus states render correctly without any per-activity code.
|
||||
|
||||
You get touch, hold highlighting, long-press, minimum touch-target sizing, and orientation-aware coordinates for free. The same action IDs fire from physical buttons, so one code path serves both input styles.
|
||||
|
||||
### Component inventory
|
||||
|
||||
All under `freeink-sdk/libs/ui/FreeInkUI/include/components/`:
|
||||
|
||||
| Category | Components |
|
||||
|---|---|
|
||||
| Controls | `button`, `checkbox`, `slider`, `progress-bar`, `header` |
|
||||
| Lists | `list` (virtualized), `table`, `dropdown`, `radio-group`, `setting-row`, `toggle-row`, `stepper-row` |
|
||||
| Keyboard | `keyboard` (QWERTY/AZERTY/QWERTZ/ES layouts), `key-grid` |
|
||||
| Overlays | `popup`, `option-dialog`, `context-menu`, `message-panel`, `toast` |
|
||||
| Bars | `status-bar`, `tab-bar`, `reader-chrome`, `battery-indicator`, `gesture-bar`, `tap-zones` |
|
||||
| Media | `book-card`, `cover-grid`, `cover-carousel`, `metric-card` |
|
||||
| Text | `text-field`, `text-area` |
|
||||
|
||||
### Integration skeleton
|
||||
|
||||
The in-tree reference is [`src/activities/util/KeyboardEntryActivity.cpp`](../../src/activities/util/KeyboardEntryActivity.cpp); it drives the FreeInkUI keyboard component inside a normal Activity. The shape:
|
||||
|
||||
```cpp
|
||||
#include <FreeInkUIGfxRenderer.h>
|
||||
namespace fui = freeink::ui;
|
||||
|
||||
// Render step: draw the component and (re)register its hit rects.
|
||||
fui::GfxRendererTarget target(renderer); // adapts GfxRenderer to FreeInkUI's DrawTarget
|
||||
target.setFont(fui::GfxRendererTarget::FONT_BODY, UI_12_FONT_ID);
|
||||
const fui::DeviceContext device = target.deviceContext(); // orientation, safe area, touch sizing
|
||||
|
||||
fui::Frame<48> frame(target, device, fui::InputSnapshot{}, interactions); // 48 = max hit rects
|
||||
fui::KeyboardProps props;
|
||||
props.layout = ¤tLayout();
|
||||
props.keyAction = ACTION_KEY; // one action id; the key is the event value
|
||||
props.inputMask = fui::InputTouch | fui::InputLongPress;
|
||||
fui::keyboard(frame, kbRect, props); // draws AND registers hit rects
|
||||
```
|
||||
|
||||
```cpp
|
||||
// Input step (each loop): feed touch state, act on routed events.
|
||||
int tx, ty, tapX, tapY, hx, hy;
|
||||
const bool pressedDown = mappedInput.wasScreenTouchDown(tx, ty);
|
||||
const bool tapped = mappedInput.wasScreenTapped(tapX, tapY);
|
||||
const bool inContact = mappedInput.isScreenTouchHeld(hx, hy);
|
||||
|
||||
const auto result = touchRouter.update(interactions, pressedDown, tx, ty,
|
||||
tapped, tapX, tapY, inContact, millis());
|
||||
if (result.event) {
|
||||
activateValue(result.event.value, result.event.longPress);
|
||||
requestUpdate();
|
||||
} else if (result.activeChanged) {
|
||||
requestUpdate(); // repaint touch-down highlight
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `Frame<N>` is templated on the max number of hit rects; the buffer is stack/static, no heap.
|
||||
- `fui::TouchHoldRouter` (see `components/keyboard/keyboard.h`) synthesizes long-press while the finger is still down and swallows the eventual release. Use it whenever a component distinguishes tap from long-press.
|
||||
- For simpler screens without long-press, `FreeInkApp` / `Screen<N>` (`FreeInkApp.h`) is the ergonomic layer: it owns the interaction buffer, offers a top-to-bottom builder (`takeTop`, footer helpers, etc.), and its `render(input)` call routes the snapshot and dispatches registered action callbacks in one step. `snapshotFrom(input, device)` in `FreeInkUIInputManager.h` builds the `InputSnapshot` with orientation-aware touch mapping.
|
||||
- Everything stays allocation-free and works on the button-only devices unchanged: physical buttons route through the same interaction table via each hit rect's `inputMask`.
|
||||
|
||||
---
|
||||
|
||||
## Path 2: Adding touch to existing hand-rolled screens
|
||||
|
||||
For activities that draw their own rows, menus, and buttons, `MappedInputManager` ([src/MappedInputManager.h](../../src/MappedInputManager.h)) exposes bridge helpers. They all return **logical screen coordinates** (orientation already applied via `GfxRenderer::tapToLogical`); never touch raw normalized panel coordinates or the SDK `InputManager` directly.
|
||||
|
||||
| Helper | Use for |
|
||||
|---|---|
|
||||
| `wasScreenTapped(x, y)` | A completed tap (press + release), with logical coords |
|
||||
| `wasScreenTouchDown(x, y)` | Touch-down (held > 90 ms, not yet released): draw selection highlight |
|
||||
| `isScreenTouchHeld(x, y)` | Live contact position while the finger is down (drag tracking) |
|
||||
| `wasTapInRect(x, y, w, h)` | One-off hit test on a rectangle (a single button, a banner) |
|
||||
| `wasListItemTapped(index, count, selected, listTop, listHeight, hasSubtitle)` | Taps on a standard UITheme list; does the row/paging math for you |
|
||||
| `wasListItemTouchedDown(...)` | Same geometry, touch-down phase (highlight before activate) |
|
||||
| `rowTouch(row, top, rowStep, rowCount, xStart, xEnd, rowHeight)` | Any custom band of equal-height rows; returns `RowTouch::None/Down/Tap` |
|
||||
| `colTouch(col, left, colStep, colCount, yStart, yEnd, colWidth)` | Horizontal button bands (dialogs, prompts) |
|
||||
| `wasSwipe()` | Raw swipe direction if you need one beyond the global gestures |
|
||||
| `hasTouch()` | True when the device has a touch panel (rarely needed; helpers simply never fire without one) |
|
||||
|
||||
### Pattern A: standard themed list
|
||||
|
||||
One call per loop; UITheme owns the row geometry ([EpubReaderBookmarksActivity.cpp:123](../../src/activities/reader/EpubReaderBookmarksActivity.cpp)):
|
||||
|
||||
```cpp
|
||||
int tapped = -1;
|
||||
if (mappedInput.wasListItemTapped(tapped, bookmarks.size(), selectorIndex, listY, listHeight, true)) {
|
||||
selectorIndex = tapped;
|
||||
openBookmark();
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern B: custom rows with hold highlight
|
||||
|
||||
For non-theme row layouts, use `rowTouch` and distinguish `Down` (highlight) from `Tap` (activate), as in [EpubReaderFootnotesActivity.cpp](../../src/activities/reader/EpubReaderFootnotesActivity.cpp):
|
||||
|
||||
```cpp
|
||||
int row = -1;
|
||||
const auto touch = mappedInput.rowTouch(row, listTop, lineHeight, visibleCount,
|
||||
contentX, contentX + contentWidth);
|
||||
if (touch != MappedInputManager::RowTouch::None) {
|
||||
const int touched = scrollOffset + row;
|
||||
if (touch == MappedInputManager::RowTouch::Down) {
|
||||
if (selectedIndex != touched) { selectedIndex = touched; requestUpdate(); }
|
||||
} else { // RowTouch::Tap
|
||||
selectedIndex = touched;
|
||||
activateSelection();
|
||||
}
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
The `Down` state exists because e-ink repaints are slow: highlight on touch-down gives immediate feedback, activation happens on release.
|
||||
|
||||
### Global gestures: do not reimplement these
|
||||
|
||||
Three gestures are handled once, for every screen. Activities must not add their own edge-swipe handling:
|
||||
|
||||
| Gesture | Trigger | Where it is handled |
|
||||
|---|---|---|
|
||||
| Back | Right-swipe starting in the left 25% of the screen | Folded into `Button::Back`, so the existing `wasPressed(Button::Back)` in your activity already fires |
|
||||
| Home | Up-swipe starting in the bottom 14% | `ActivityManager::loop()`; pops to Home (activities can override via `handleHomeGesture()`) |
|
||||
| Menu | Down-swipe starting in the top 14% | Activities that have a menu check `wasMenuGesture()` themselves (the reader does this) |
|
||||
|
||||
Because the back gesture arrives as `Button::Back`, most button-era activities gain back-swipe support with zero changes. That is the bar to aim for: bridge helpers should make touch an additive layer over the button flow, not a second input state machine.
|
||||
|
||||
### Bridge rules
|
||||
|
||||
- Handle touch in `loop()` next to the existing button handling, one helper call per interaction zone, and `return` after consuming an event (mirrors the button pattern).
|
||||
- Never call the SDK `InputManager` or read GPIO directly; the HAL rule from the main guide applies to touch too.
|
||||
- Coordinates from the helpers are logical and orientation-correct on all four rotations; test at least Portrait and one Landscape mode before PR.
|
||||
- Nothing to clean up in `onExit()`; the helpers are stateless from the activity's point of view.
|
||||
|
||||
---
|
||||
|
||||
## Building and testing on non-Xteink devices
|
||||
|
||||
Each MCU family is its own binary: X3/X4 are ESP32-C3, Sticky and LilyGo T5 are ESP32-S3, M5Paper v1.1 is a classic ESP32. The Sticky env ships in `platformio.ini` (`pio run -e sticky`). Envs for other devices go in **`platformio.local.ini`**, a gitignored file that PlatformIO merges over `platformio.ini` (see `extra_configs`). Create it next to `platformio.ini`; personal envs, ports, and debug flags live there and never get committed.
|
||||
|
||||
Both envs below extend the repo's `[base]`, so they build against the `freeink-sdk` submodule with all the normal deps and scripts.
|
||||
|
||||
### M5Paper v1.1 (classic ESP32, IT8951 panel)
|
||||
|
||||
```ini
|
||||
[env:m5paper_v11]
|
||||
extends = base
|
||||
board = esp32dev
|
||||
board_build.mcu = esp32
|
||||
board_build.flash_mode = qio
|
||||
; CP2104 UART bridge: 921600 drops out on macOS after the stub baud switch
|
||||
upload_speed = 460800
|
||||
build_unflags =
|
||||
${base.build_unflags}
|
||||
; classic ESP32 has UART serial, not USB CDC; Logging.h keys off these
|
||||
-DARDUINO_USB_MODE=1
|
||||
-DARDUINO_USB_CDC_ON_BOOT=1
|
||||
build_flags =
|
||||
${base.build_flags}
|
||||
-DFREEINK_DEVICE_M5PAPER=1
|
||||
; the 63KB 540x960 framebuffer lives in PSRAM (FREEINK_FB_PSRAM auto-on)
|
||||
-DBOARD_HAS_PSRAM
|
||||
-DCROSSPOINT_VERSION=\"${crosspoint.version}-m5paper\"
|
||||
-DENABLE_SERIAL_LOG
|
||||
-DLOG_LEVEL=2
|
||||
; touch-first device: hide front-button hint labels
|
||||
-DCROSSPOINT_SHOW_BUTTON_HINTS=0
|
||||
; archive-scan-order workaround: without these a full relink drops Wire's i2c symbols
|
||||
-Wl,-u,i2cInit
|
||||
-Wl,-u,i2cSlaveInit
|
||||
```
|
||||
|
||||
### LilyGo T5 S3 (ESP32-S3, controller-less panel via LovyanGFX)
|
||||
|
||||
```ini
|
||||
[env:lilygo_t5s3]
|
||||
extends = base
|
||||
board = esp32-s3-devkitc1-n16r8
|
||||
board_build.mcu = esp32s3
|
||||
build_flags =
|
||||
${base.build_flags}
|
||||
-DFREEINK_DEVICE_LILYGO=1
|
||||
; board injects the parallel-bus pins + PMIC power hooks (BoardT5S3)
|
||||
-DFREEINK_LGFX_EPD_CONFIG=lilygoT5S3LgfxConfig
|
||||
-DCROSSPOINT_VERSION=\"${crosspoint.version}-lilygo\"
|
||||
-DENABLE_SERIAL_LOG
|
||||
-DLOG_LEVEL=2
|
||||
-DCROSSPOINT_SHOW_BUTTON_HINTS=0
|
||||
lib_deps =
|
||||
${base.lib_deps}
|
||||
; LgfxEpdConfig for the T5 S3 (pins, PCA9535/TPS65185 power sequence)
|
||||
BoardT5S3=symlink://freeink-sdk/libs/hardware/BoardT5S3
|
||||
; LovyanGFX Panel_EPD drives the controller-less ED047TC1 panel
|
||||
m5stack/M5GFX @ 0.2.20
|
||||
```
|
||||
|
||||
Then `pio run -e m5paper_v11 -t upload` (or `-e lilygo_t5s3`). Gotchas worth knowing:
|
||||
|
||||
- **Flash mode matters.** The M5Paper is `qio`; the X4-family standalone envs need `dio`. A wrong flash-mode header boots into a `partition 0 invalid magic number 0xffff` loop even though esptool verified the write.
|
||||
- **One `FREEINK_DEVICE_*` flag per env** selects the board profile (pins, panel, touch controller) from the SDK's `BoardConfig`. See `freeink-sdk/platformio.sample.ini` for reference envs of every supported device.
|
||||
- **Serial logs:** `[base]` does not enable logging; without `-DENABLE_SERIAL_LOG` a non-default env prints nothing.
|
||||
- No touch hardware on your desk? The X4 build still exercises the same code paths through buttons; touch-specific behavior (tap zones, gestures) needs a real device.
|
||||
+1
-1
Submodule freeink-sdk updated: e7d336191b...566fce3d4f
@@ -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<int>(nx * panelWidth);
|
||||
int phyY = static_cast<int>(ny * panelHeight);
|
||||
if (phyX < 0) phyX = 0;
|
||||
if (phyX > panelWidth - 1) phyX = panelWidth - 1;
|
||||
if (phyY < 0) phyY = 0;
|
||||
if (phyY > panelHeight - 1) phyY = panelHeight - 1;
|
||||
|
||||
switch (orientation) {
|
||||
case Portrait:
|
||||
outX = panelHeight - 1 - phyY;
|
||||
outY = phyX;
|
||||
break;
|
||||
case PortraitInverted:
|
||||
outX = phyY;
|
||||
outY = panelWidth - 1 - phyX;
|
||||
break;
|
||||
case LandscapeClockwise:
|
||||
outX = panelWidth - 1 - phyX;
|
||||
outY = panelHeight - 1 - phyY;
|
||||
break;
|
||||
case LandscapeCounterClockwise:
|
||||
default:
|
||||
outX = phyX;
|
||||
outY = phyY;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Translate a logical rect through rotateCoordinates and take the bounding
|
||||
// box of its four corners on the physical panel. Output coords are inclusive
|
||||
// and clamped. Returns false if the rect ends up fully off-panel.
|
||||
|
||||
@@ -134,7 +134,19 @@ class GfxRenderer {
|
||||
// Screen ops
|
||||
int getScreenWidth() const;
|
||||
int getScreenHeight() const;
|
||||
void tapToLogical(float nx, float ny, int& outX, int& outY) const;
|
||||
void displayBuffer(HalDisplay::RefreshMode refreshMode = HalDisplay::FAST_REFRESH) const;
|
||||
// Non-blocking refresh: starts the waveform and returns so CPU work (e.g.
|
||||
// grayscale strip rendering) can overlap the panel's refresh time. The
|
||||
// framebuffer must stay untouched until waitRefreshComplete(). Falls back to
|
||||
// a blocking refresh when fadingFix is enabled or the panel lacks deferral
|
||||
// support. See HalDisplay::displayBufferAsync for the baseline contract.
|
||||
void displayBufferAsync(HalDisplay::RefreshMode refreshMode = HalDisplay::FAST_REFRESH) const;
|
||||
void waitRefreshComplete() const;
|
||||
// True when displayBufferAsync() genuinely overlaps: panel defers and
|
||||
// fadingFix isn't forcing the blocking path. Callers can skip overlap
|
||||
// scaffolding (e.g. whole-plane grayscale buffers) when false.
|
||||
bool supportsAsyncRefresh() const;
|
||||
// EXPERIMENTAL: Windowed update - display only a rectangular region
|
||||
// void displayWindow(int x, int y, int width, int height) const;
|
||||
void invertScreen() const;
|
||||
|
||||
@@ -68,6 +68,7 @@ STR_TEXT_AA: "Згладжванне тэксту"
|
||||
STR_SHORT_PWR_BTN: "Кароткае націсканне PWR"
|
||||
STR_ORIENTATION: "Арыентацыя чытання"
|
||||
STR_SIDE_BTN_LAYOUT: "Бакавыя кнопкі"
|
||||
STR_TOUCH_READER_CONTROLS: "Сэнсарнае кіраванне чытаннем"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Арыентаваць пярэднія кнопкі"
|
||||
STR_LONG_PRESS_SKIP: "Доўгае націсканне - змена раздзела"
|
||||
STR_FONT_PREVIEW_TEXT: "У Іўі худы жвавы чорт у зялёнай камізэльцы пабег пад'есці фаршу з юшкай"
|
||||
|
||||
@@ -78,6 +78,7 @@ STR_EOB_CONTINUE_WITH: "Continua amb"
|
||||
STR_SHORT_PWR_BTN: "Pulsació curta del botó d'engegada"
|
||||
STR_ORIENTATION: "Orientació de lectura"
|
||||
STR_SIDE_BTN_LAYOUT: "Disposició botons laterals"
|
||||
STR_TOUCH_READER_CONTROLS: "Controls tàctils del lector"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Orientar botons frontals"
|
||||
STR_LONG_PRESS_BEHAVIOR: "Acció en mantenir premut un botó"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "Desactivat"
|
||||
|
||||
@@ -68,6 +68,7 @@ STR_TEXT_AA: "Vyhlazování textu"
|
||||
STR_SHORT_PWR_BTN: "Krátké stisknutí tlačítka napájení"
|
||||
STR_ORIENTATION: "Orientace čtení"
|
||||
STR_SIDE_BTN_LAYOUT: "Rozvržení bočních tlačítek (čtečka)"
|
||||
STR_TOUCH_READER_CONTROLS: "Dotykové ovládání čtečky"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Orientovat přední tlačítka"
|
||||
STR_LONG_PRESS_BEHAVIOR: "Chování při dlouhém stisknutí tlačítka"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "VYP"
|
||||
|
||||
@@ -72,6 +72,7 @@ STR_IMAGES_SUPPRESS: "Skjul"
|
||||
STR_SHORT_PWR_BTN: "Kort tryk på tænd/sluk-knap"
|
||||
STR_ORIENTATION: "Læseretning"
|
||||
STR_SIDE_BTN_LAYOUT: "Knaplayout på siden (læser)"
|
||||
STR_TOUCH_READER_CONTROLS: "Touchkontroller i læser"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Orientér forreste knapper"
|
||||
STR_LONG_PRESS_BEHAVIOR: "Comportamiento al mantener pulsado el botón"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "Desactivado"
|
||||
|
||||
@@ -72,6 +72,7 @@ STR_IMAGES_SUPPRESS: "Verbergen"
|
||||
STR_SHORT_PWR_BTN: "Korte klik aan/uit-knop"
|
||||
STR_ORIENTATION: "Leesstand"
|
||||
STR_SIDE_BTN_LAYOUT: "Indeling zijknoppen (lezer)"
|
||||
STR_TOUCH_READER_CONTROLS: "Aanraakbediening lezer"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Richt voorste knoppen"
|
||||
STR_LONG_PRESS_BEHAVIOR: "Long-press button behavior"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "OFF"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -68,6 +68,7 @@ STR_TEXT_AA: "Tekstin reunanpehmennys"
|
||||
STR_SHORT_PWR_BTN: "Lyhyt virtapainikkeen painallus"
|
||||
STR_ORIENTATION: "Lukusuunta"
|
||||
STR_SIDE_BTN_LAYOUT: "Sivupainikkeiden asettelu (lukija)"
|
||||
STR_TOUCH_READER_CONTROLS: "Lukijan kosketusohjaus"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Suuntaa etupainikkeet"
|
||||
STR_LONG_PRESS_BEHAVIOR: "Long-press button behavior"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "OFF"
|
||||
|
||||
@@ -72,6 +72,7 @@ STR_IMAGES_SUPPRESS: "Masquer"
|
||||
STR_SHORT_PWR_BTN: "Appui court alim."
|
||||
STR_ORIENTATION: "Orientation de lecture"
|
||||
STR_SIDE_BTN_LAYOUT: "Boutons latéraux"
|
||||
STR_TOUCH_READER_CONTROLS: "Commandes tactiles lecteur"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Orienter boutons avant"
|
||||
STR_LONG_PRESS_BEHAVIOR: "Comportement lors d'un appui long"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "Désactivé"
|
||||
|
||||
@@ -68,6 +68,7 @@ STR_TEXT_AA: "Schriftglättung"
|
||||
STR_SHORT_PWR_BTN: "An-Taste kurz drücken"
|
||||
STR_ORIENTATION: "Leseausrichtung"
|
||||
STR_SIDE_BTN_LAYOUT: "Seitliche Tasten (Lesen)"
|
||||
STR_TOUCH_READER_CONTROLS: "Touch-Steuerung beim Lesen"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Vordere Tasten ausrichten"
|
||||
STR_LONG_PRESS_BEHAVIOR: "Verhalten bei langem Tastendruck"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "Aus"
|
||||
|
||||
@@ -73,6 +73,7 @@ STR_IMAGES_SUPPRESS: "הסתר תמונות"
|
||||
STR_SHORT_PWR_BTN: "לחיצה קצרה על כפתור ההפעלה"
|
||||
STR_ORIENTATION: "כיוון קריאה (מסך)"
|
||||
STR_SIDE_BTN_LAYOUT: "פריסת כפתורי צד (בקריאה)"
|
||||
STR_TOUCH_READER_CONTROLS: "שליטה במגע (קורא)"
|
||||
STR_LONG_PRESS_BEHAVIOR: "פעולת לחיצה ארוכה"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "כבוי"
|
||||
STR_LONG_PRESS_BEHAVIOR_SKIP: "דלג פרק"
|
||||
|
||||
@@ -74,6 +74,7 @@ STR_IMAGES_SUPPRESS: "Elnyomás"
|
||||
STR_SHORT_PWR_BTN: "Rövid bekapcsológomb nyomás"
|
||||
STR_ORIENTATION: "Olvasási irány"
|
||||
STR_SIDE_BTN_LAYOUT: "Oldalsó gomb elrendezés (olvasó)"
|
||||
STR_TOUCH_READER_CONTROLS: "Érintős olvasóvezérlés"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Elülső gombok tájolása"
|
||||
STR_LONG_PRESS_SKIP: "Hosszú nyomás - fejezet ugrás"
|
||||
STR_FONT_PREVIEW_TEXT: "Egy hűtlen vejét fülöncsípő, dühös mexikói úr Wesselényinél mázol Quitóban"
|
||||
|
||||
@@ -72,6 +72,7 @@ STR_IMAGES_SUPPRESS: "Nascondi"
|
||||
STR_SHORT_PWR_BTN: "Press. breve pul. accensione"
|
||||
STR_ORIENTATION: "Orientamento lettura"
|
||||
STR_SIDE_BTN_LAYOUT: "Pul. laterali (lettore)"
|
||||
STR_TOUCH_READER_CONTROLS: "Controlli touch lettore"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Orienta pul. frontali"
|
||||
STR_LONG_PRESS_BEHAVIOR: "Press. lunga pul. laterali"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "OFF"
|
||||
|
||||
@@ -67,6 +67,7 @@ STR_TEXT_AA: "Мәтін сырғытпасы"
|
||||
STR_SHORT_PWR_BTN: "Қуат түймесін қысқа басу"
|
||||
STR_ORIENTATION: "Оқу бағдары"
|
||||
STR_SIDE_BTN_LAYOUT: "Бүйірлік түймелер орналасуы (оқырман)"
|
||||
STR_TOUCH_READER_CONTROLS: "Оқырманның сенсорлық басқаруы"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Алдыңғы түймелерді бағдарлау"
|
||||
STR_LONG_PRESS_SKIP: "Ұзақ басу арқылы тарау өткізу"
|
||||
STR_FONT_PREVIEW_TEXT: "Канагаттандырылмагандыктарыныздан"
|
||||
|
||||
@@ -72,6 +72,7 @@ STR_IMAGES_SUPPRESS: "Slėpti"
|
||||
STR_SHORT_PWR_BTN: "Trumpas įjungimo pasp."
|
||||
STR_ORIENTATION: "Orientacija"
|
||||
STR_SIDE_BTN_LAYOUT: "Šoniniai mygtukai"
|
||||
STR_TOUCH_READER_CONTROLS: "Lietimo valdikliai skaityklėje"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Orientuoti priekinius mygtukus"
|
||||
STR_LONG_PRESS_SKIP: "Praleisti skyrių (ilgai)"
|
||||
STR_FONT_PREVIEW_TEXT: "Įlinkdama fechtuotojo špaga sublykčiojusi pragręžė apvalų arbūzą"
|
||||
|
||||
@@ -72,6 +72,7 @@ STR_IMAGES_SUPPRESS: "Pomijaj"
|
||||
STR_SHORT_PWR_BTN: "Krótkie naciśnięcie zasilania"
|
||||
STR_ORIENTATION: "Układ czytania"
|
||||
STR_SIDE_BTN_LAYOUT: "Układ przycisków bocznych"
|
||||
STR_TOUCH_READER_CONTROLS: "Sterowanie dotykowe czytnika"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Orientuj przednie przyciski"
|
||||
STR_LONG_PRESS_BEHAVIOR: "Funkcja długiego przyciśnięcia"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "Wył."
|
||||
|
||||
@@ -73,6 +73,7 @@ STR_IMAGES_SUPPRESS: "Ocultar"
|
||||
STR_SHORT_PWR_BTN: "Clique curto botão ligar"
|
||||
STR_ORIENTATION: "Orientação de leitura"
|
||||
STR_SIDE_BTN_LAYOUT: "Disposição botões laterais"
|
||||
STR_TOUCH_READER_CONTROLS: "Controles táteis do leitor"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Orientar botões frontais"
|
||||
STR_LONG_PRESS_BEHAVIOR: "Comportamento de Pressionar e segurar"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "DESL."
|
||||
|
||||
@@ -72,6 +72,7 @@ STR_IMAGES_SUPPRESS: "Suprimare"
|
||||
STR_SHORT_PWR_BTN: "Apăsare scurtă întrerupător"
|
||||
STR_ORIENTATION: "Orientare lectură"
|
||||
STR_SIDE_BTN_LAYOUT: "Aspect butoane laterale (lectură)"
|
||||
STR_TOUCH_READER_CONTROLS: "Comenzi tactile cititor"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Orientare butoane frontale"
|
||||
STR_LONG_PRESS_BEHAVIOR: "Comportament buton apăsat lung"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "Dezactivat"
|
||||
|
||||
@@ -73,6 +73,7 @@ STR_IMAGES_SUPPRESS: "Скрыть"
|
||||
STR_SHORT_PWR_BTN: "Короткое нажатие PWR"
|
||||
STR_ORIENTATION: "Ориентация чтения"
|
||||
STR_SIDE_BTN_LAYOUT: "Боковые кнопки"
|
||||
STR_TOUCH_READER_CONTROLS: "Сенсорное управление чтением"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Ориентировать передние кнопки"
|
||||
STR_LONG_PRESS_BEHAVIOR: "Долгое нажатие"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "Ничего"
|
||||
|
||||
@@ -73,6 +73,7 @@ STR_IMAGES_SUPPRESS: "Potlačiť"
|
||||
STR_SHORT_PWR_BTN: "Krátke stlačenie tlačidla napájania"
|
||||
STR_ORIENTATION: "Orientácia čítania"
|
||||
STR_SIDE_BTN_LAYOUT: "Rozloženie bočných tlačidiel (čítačka)"
|
||||
STR_TOUCH_READER_CONTROLS: "Dotykové ovládanie čítačky"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Prispôsobiť predné tlačidlá orientácii"
|
||||
STR_LONG_PRESS_BEHAVIOR: "Správanie pri dlhom stlačení tlačidla"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "VYP"
|
||||
|
||||
@@ -72,6 +72,7 @@ STR_IMAGES_SUPPRESS: "Zatdi"
|
||||
STR_SHORT_PWR_BTN: "Kratek pritisk na gumb za vklop"
|
||||
STR_ORIENTATION: "Orientacija branja"
|
||||
STR_SIDE_BTN_LAYOUT: "Razpored stranskih gumbov"
|
||||
STR_TOUCH_READER_CONTROLS: "Upravljanje bralnika na dotik"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Usmeri sprednje gumbe"
|
||||
STR_LONG_PRESS_SKIP: "Dolgi pritisk za preskok poglavja"
|
||||
STR_FONT_PREVIEW_TEXT: "V kožuščku hudobnega fanta stopiclja mizar"
|
||||
|
||||
@@ -78,6 +78,7 @@ STR_EOB_CONTINUE_WITH: "Continuar con"
|
||||
STR_SHORT_PWR_BTN: "Toque corto del encendido"
|
||||
STR_ORIENTATION: "Orientación"
|
||||
STR_SIDE_BTN_LAYOUT: "Función botones laterales (lector)"
|
||||
STR_TOUCH_READER_CONTROLS: "Controles táctiles del lector"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Orientar botones frontales"
|
||||
STR_LONG_PRESS_BEHAVIOR: "Al mantener pulsado un botón"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "No hacer nada"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -67,6 +67,7 @@ STR_TEXT_AA: "Metin Yumuşatma (AA)"
|
||||
STR_SHORT_PWR_BTN: "Kısa Güç Tuşu Tıklaması"
|
||||
STR_ORIENTATION: "Okuma Yönü"
|
||||
STR_SIDE_BTN_LAYOUT: "Yan Tuş Dizilimi (okuyucu)"
|
||||
STR_TOUCH_READER_CONTROLS: "Dokunmatik okuyucu kontrolleri"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Ön düğmeleri yönlendir"
|
||||
STR_LONG_PRESS_BEHAVIOR: "Uzun basma tuş davranışı"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "KAPALI"
|
||||
|
||||
@@ -73,6 +73,7 @@ STR_IMAGES_SUPPRESS: "Приховати"
|
||||
STR_SHORT_PWR_BTN: "Короткий натиск кн. живл."
|
||||
STR_ORIENTATION: "Орієнтація читання"
|
||||
STR_SIDE_BTN_LAYOUT: "Схема бічних кнопок"
|
||||
STR_TOUCH_READER_CONTROLS: "Сенсорне керування читанням"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Орієнтувати передні кнопки"
|
||||
STR_LONG_PRESS_BEHAVIOR: "Поведінка при довгому настику"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "Немає"
|
||||
|
||||
@@ -79,6 +79,7 @@ STR_EOB_CONTINUE_WITH: "Continua amb"
|
||||
STR_SHORT_PWR_BTN: "Pulsació curta del botó d'engegada"
|
||||
STR_ORIENTATION: "Orientació de lectura"
|
||||
STR_SIDE_BTN_LAYOUT: "Disposició botons laterals"
|
||||
STR_TOUCH_READER_CONTROLS: "Controls tàctils del lector"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Orientar botons frontals"
|
||||
STR_LONG_PRESS_BEHAVIOR: "Acció en mantindre premut un botó"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "Desactivat"
|
||||
|
||||
@@ -73,6 +73,7 @@ STR_IMAGES_SUPPRESS: "Ẩn đi"
|
||||
STR_SHORT_PWR_BTN: "Nhấn nhanh nút nguồn"
|
||||
STR_ORIENTATION: "Hướng đọc"
|
||||
STR_SIDE_BTN_LAYOUT: "Bố trí nút bên (trình đọc)"
|
||||
STR_TOUCH_READER_CONTROLS: "Điều khiển đọc bằng cảm ứng"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Xoay nút trước theo hướng"
|
||||
STR_LONG_PRESS_BEHAVIOR: "Hành vi nhấn giữ nút"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "TẮT"
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
#include <JPEGDEC.h>
|
||||
#include <Logging.h>
|
||||
#include <Memory.h>
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/task.h>
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
@@ -169,11 +171,22 @@ constexpr uint32_t FP_ONE = 1UL << 16;
|
||||
// Static file pointer for JPEGDEC open callback.
|
||||
// Safe in single-threaded embedded context; never accessed concurrently.
|
||||
static HalFile* s_jpegFile = nullptr;
|
||||
static uint8_t s_jpegIoSinceYield = 0;
|
||||
|
||||
static void yieldToIdle() { vTaskDelay(1); }
|
||||
|
||||
static void yieldDuringJpegIo() {
|
||||
if (++s_jpegIoSinceYield < 4) return;
|
||||
s_jpegIoSinceYield = 0;
|
||||
yieldToIdle();
|
||||
}
|
||||
|
||||
void* bmpJpegOpen(const char* /*filename*/, int32_t* size) {
|
||||
if (!s_jpegFile || !*s_jpegFile) return nullptr;
|
||||
s_jpegIoSinceYield = 0;
|
||||
s_jpegFile->seek(0);
|
||||
*size = static_cast<int32_t>(s_jpegFile->size());
|
||||
yieldDuringJpegIo();
|
||||
return s_jpegFile;
|
||||
}
|
||||
|
||||
@@ -187,6 +200,7 @@ int32_t bmpJpegRead(JPEGFILE* pFile, uint8_t* pBuf, int32_t len) {
|
||||
int32_t n = f->read(pBuf, len);
|
||||
if (n < 0) n = 0;
|
||||
pFile->iPos += n;
|
||||
yieldDuringJpegIo();
|
||||
return n;
|
||||
}
|
||||
|
||||
@@ -194,6 +208,7 @@ int32_t bmpJpegSeek(JPEGFILE* pFile, int32_t pos) {
|
||||
auto* f = reinterpret_cast<HalFile*>(pFile->fHandle);
|
||||
if (!f || !f->seek(pos)) return -1;
|
||||
pFile->iPos = pos;
|
||||
yieldDuringJpegIo();
|
||||
return pos;
|
||||
}
|
||||
|
||||
@@ -236,9 +251,23 @@ struct BmpConvertCtx {
|
||||
std::unique_ptr<FloydSteinbergDitherer> fsDitherer;
|
||||
std::unique_ptr<Atkinson1BitDitherer> atkinson1BitDitherer;
|
||||
|
||||
uint8_t rowsSinceYield;
|
||||
uint8_t blocksSinceYield;
|
||||
bool error;
|
||||
};
|
||||
|
||||
static void yieldDuringDecode(BmpConvertCtx* ctx) {
|
||||
if (++ctx->rowsSinceYield < 8) return;
|
||||
ctx->rowsSinceYield = 0;
|
||||
yieldToIdle();
|
||||
}
|
||||
|
||||
static void yieldDuringDecodeBlock(BmpConvertCtx* ctx) {
|
||||
if (++ctx->blocksSinceYield < 16) return;
|
||||
ctx->blocksSinceYield = 0;
|
||||
yieldToIdle();
|
||||
}
|
||||
|
||||
// Write a fully-assembled output row (grayscale bytes, length outWidth) to BMP
|
||||
static void writeOutputRow(BmpConvertCtx* ctx, const uint8_t* srcRow, int outY) {
|
||||
memset(ctx->bmpRow.get(), 0, ctx->bytesPerRow);
|
||||
@@ -274,6 +303,7 @@ static void writeOutputRow(BmpConvertCtx* ctx, const uint8_t* srcRow, int outY)
|
||||
}
|
||||
|
||||
ctx->bmpOut->write(ctx->bmpRow.get(), ctx->bytesPerRow);
|
||||
yieldDuringDecode(ctx);
|
||||
}
|
||||
|
||||
// Matches the progressive-JPEG smoothing used by JpegToFramebufferConverter, but stays
|
||||
@@ -396,6 +426,7 @@ static void flushScaledRow(BmpConvertCtx* ctx) {
|
||||
|
||||
ctx->bmpOut->write(ctx->bmpRow.get(), ctx->bytesPerRow);
|
||||
ctx->currentOutY++;
|
||||
yieldDuringDecode(ctx);
|
||||
}
|
||||
|
||||
// JPEGDEC draw callback — receives one MCU-width × MCU-height block at a time,
|
||||
@@ -405,6 +436,7 @@ static void flushScaledRow(BmpConvertCtx* ctx) {
|
||||
int bmpDrawCallback(JPEGDRAW* pDraw) {
|
||||
auto* ctx = reinterpret_cast<BmpConvertCtx*>(pDraw->pUser);
|
||||
if (!ctx || ctx->error) return 0;
|
||||
yieldDuringDecodeBlock(ctx);
|
||||
|
||||
const uint8_t* pixels = reinterpret_cast<uint8_t*>(pDraw->pPixels);
|
||||
const int stride = pDraw->iWidth;
|
||||
@@ -599,6 +631,8 @@ bool JpegToBmpConverter::jpegFileToBmpStreamInternal(HalFile& jpegFile, Print& b
|
||||
ctx.smoothScaleY_fp = interpolationStep(ctx.srcHeight, outHeight);
|
||||
ctx.smoothNextOutY = 0;
|
||||
ctx.smoothPrevY = -1;
|
||||
ctx.rowsSinceYield = 0;
|
||||
ctx.blocksSinceYield = 0;
|
||||
ctx.error = false;
|
||||
|
||||
// MCU row buffer: MAX_MCU_HEIGHT rows × decoded srcWidth columns of grayscale
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
#include "Logging.h"
|
||||
|
||||
#include <BoardConfig.h>
|
||||
#include <esp_rom_sys.h>
|
||||
|
||||
#include <string>
|
||||
|
||||
#define MAX_ENTRY_LEN 256
|
||||
@@ -59,9 +62,16 @@ void logPrintf(const char* level, const char* origin, const char* format, ...) {
|
||||
}
|
||||
}
|
||||
va_end(args);
|
||||
#if FREEINK_LOG_TRANSPORT == FREEINK_LOG_TRANSPORT_ROM_PRINTF
|
||||
// IDF/ROM console path for boards monitored over USB-Serial-JTAG, where the
|
||||
// HWCDC `operator bool` reads false under `pio device monitor` and logs would
|
||||
// otherwise be silently dropped (e.g. Sticky).
|
||||
esp_rom_printf("%s", buf);
|
||||
#else
|
||||
if (logSerial) {
|
||||
logSerial.print(buf);
|
||||
}
|
||||
#endif
|
||||
addToLogRingBuffer(buf);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <HardwareSerial.h>
|
||||
#if defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT
|
||||
#include <HWCDC.h>
|
||||
#endif
|
||||
|
||||
#include <string>
|
||||
|
||||
@@ -27,7 +31,13 @@ won't trigger deprecation warnings.
|
||||
#define LOG_LEVEL 0
|
||||
#endif
|
||||
|
||||
#if defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT
|
||||
static HWCDC& logSerial = Serial;
|
||||
#define LOG_SERIAL_HAS_TX_TIMEOUT 1
|
||||
#else
|
||||
static HardwareSerial& logSerial = Serial;
|
||||
#define LOG_SERIAL_HAS_TX_TIMEOUT 0
|
||||
#endif
|
||||
|
||||
void logPrintf(const char* level, const char* origin, const char* format, ...);
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
#include <HalStorage.h>
|
||||
#include <InflateStream.h>
|
||||
#include <Logging.h>
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/task.h>
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
@@ -72,6 +74,12 @@ enum PngFilter : uint8_t {
|
||||
PNG_FILTER_PAETH = 4,
|
||||
};
|
||||
|
||||
void yieldDuringDecode(uint8_t& rowsSinceYield) {
|
||||
if (++rowsSinceYield < 8) return;
|
||||
rowsSinceYield = 0;
|
||||
vTaskDelay(1);
|
||||
}
|
||||
|
||||
// Read a big-endian 32-bit value from file
|
||||
bool readBE32(HalFile& file, uint32_t& value) {
|
||||
uint8_t buf[4];
|
||||
@@ -659,6 +667,7 @@ bool PngToBmpConverter::pngFileToBmpStreamInternal(HalFile& pngFile, Print& bmpO
|
||||
}
|
||||
|
||||
bool success = true;
|
||||
uint8_t rowsSinceYield = 0;
|
||||
|
||||
// Process each scanline
|
||||
for (uint32_t y = 0; y < height; y++) {
|
||||
@@ -710,6 +719,7 @@ bool PngToBmpConverter::pngFileToBmpStreamInternal(HalFile& pngFile, Print& bmpO
|
||||
fsDitherer->nextRow();
|
||||
}
|
||||
bmpOut.write(rowBuffer, bytesPerRow);
|
||||
yieldDuringDecode(rowsSinceYield);
|
||||
} else {
|
||||
// Area-averaging scaling (same as JpegToBmpConverter)
|
||||
for (int outX = 0; outX < outWidth; outX++) {
|
||||
@@ -778,6 +788,7 @@ bool PngToBmpConverter::pngFileToBmpStreamInternal(HalFile& pngFile, Print& bmpO
|
||||
|
||||
bmpOut.write(rowBuffer, bytesPerRow);
|
||||
currentOutY++;
|
||||
yieldDuringDecode(rowsSinceYield);
|
||||
|
||||
nextOutY_srcStart = static_cast<uint32_t>(currentOutY + 1) * scaleY_fp;
|
||||
|
||||
|
||||
@@ -10,6 +10,16 @@
|
||||
#include <Bitmap.h>
|
||||
#include <HalStorage.h>
|
||||
#include <Logging.h>
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/task.h>
|
||||
|
||||
namespace {
|
||||
void yieldDuringThumbnail(uint8_t& rowsSinceYield) {
|
||||
if (++rowsSinceYield < 8) return;
|
||||
rowsSinceYield = 0;
|
||||
vTaskDelay(1);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
bool Xtc::load() {
|
||||
LOG_DBG("XTC", "Loading XTC: %s", filepath.c_str());
|
||||
@@ -380,6 +390,7 @@ bool Xtc::generateThumbBmp(int height) const {
|
||||
const uint8_t* plane2 = (bitDepth == 2) ? pageBuffer + planeSize : nullptr;
|
||||
const size_t colBytes = (bitDepth == 2) ? ((pageInfo.height + 7) / 8) : 0;
|
||||
const size_t srcRowBytes = (bitDepth == 1) ? ((pageInfo.width + 7) / 8) : 0;
|
||||
uint8_t rowsSinceYield = 0;
|
||||
|
||||
for (uint16_t dstY = 0; dstY < thumbHeight; dstY++) {
|
||||
memset(rowBuffer, 0xFF, rowSize); // Start with all white (bit 1)
|
||||
@@ -471,6 +482,7 @@ bool Xtc::generateThumbBmp(int height) const {
|
||||
|
||||
// Write row (already padded to 4-byte boundary by rowSize)
|
||||
thumbBmp.write(rowBuffer, rowSize);
|
||||
yieldDuringThumbnail(rowsSinceYield);
|
||||
}
|
||||
|
||||
free(rowBuffer);
|
||||
|
||||
+21
-91
@@ -5,46 +5,11 @@
|
||||
#include <esp_sntp.h>
|
||||
#include <time.h>
|
||||
|
||||
#include <cassert>
|
||||
|
||||
HalClock halClock; // Singleton instance
|
||||
|
||||
// DS3231 register layout (BCD encoded):
|
||||
// 0x00: Seconds (bits 6-4 = tens, bits 3-0 = ones)
|
||||
// 0x01: Minutes (bits 6-4 = tens, bits 3-0 = ones)
|
||||
// 0x02: Hours (bit 6 = 12/24 mode, bits 5-4 = tens, bits 3-0 = ones)
|
||||
|
||||
static uint8_t bcdToDec(uint8_t bcd) { return ((bcd >> 4) * 10) + (bcd & 0x0F); }
|
||||
static uint8_t decToBcd(uint8_t dec) { return ((dec / 10) << 4) | (dec % 10); }
|
||||
|
||||
void HalClock::begin() {
|
||||
if (!gpio.deviceIsX3()) {
|
||||
_available = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// I2C is already initialised by HalPowerManager::begin() for X3.
|
||||
// Probe the DS3231 by reading the seconds register.
|
||||
Wire.beginTransmission(I2C_ADDR_DS3231);
|
||||
Wire.write(DS3231_SEC_REG);
|
||||
if (Wire.endTransmission(false) != 0) {
|
||||
LOG_INF("CLK", "DS3231 RTC not found");
|
||||
_available = false;
|
||||
return;
|
||||
}
|
||||
Wire.requestFrom(I2C_ADDR_DS3231, (uint8_t)1);
|
||||
if (Wire.available() < 1) {
|
||||
_available = false;
|
||||
return;
|
||||
}
|
||||
Wire.read(); // discard — just testing connectivity
|
||||
|
||||
_available = true;
|
||||
LOG_INF("CLK", "DS3231 RTC found");
|
||||
|
||||
// Prime the cache with an initial read
|
||||
uint8_t h, m;
|
||||
getTime(h, m);
|
||||
_available = _sdkRtc.begin();
|
||||
LOG_INF("CLK", _available ? "SDK RTC found" : "RTC not found");
|
||||
}
|
||||
|
||||
bool HalClock::getTime(uint8_t& hour, uint8_t& minute) const {
|
||||
@@ -57,44 +22,18 @@ bool HalClock::getTime(uint8_t& hour, uint8_t& minute) const {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Read 3 bytes starting at register 0x00: seconds, minutes, hours
|
||||
Wire.beginTransmission(I2C_ADDR_DS3231);
|
||||
Wire.write(DS3231_SEC_REG);
|
||||
if (Wire.endTransmission(false) != 0) {
|
||||
Rtc::DateTime dt;
|
||||
if (!_sdkRtc.now(dt)) {
|
||||
if (!_hasCachedTime) return false;
|
||||
_lastPollMs = now;
|
||||
hour = _cachedHour;
|
||||
minute = _cachedMinute;
|
||||
return true;
|
||||
}
|
||||
Wire.requestFrom(I2C_ADDR_DS3231, (uint8_t)3);
|
||||
if (Wire.available() < 3) {
|
||||
if (!_hasCachedTime) return false;
|
||||
_lastPollMs = now;
|
||||
hour = _cachedHour;
|
||||
minute = _cachedMinute;
|
||||
return true;
|
||||
}
|
||||
|
||||
Wire.read(); // seconds — not needed
|
||||
const uint8_t rawMin = Wire.read();
|
||||
const uint8_t rawHour = Wire.read();
|
||||
|
||||
_cachedMinute = bcdToDec(rawMin & 0x7F);
|
||||
// Handle 12/24h mode: bit 6 high = 12h mode
|
||||
if (rawHour & 0x40) {
|
||||
// 12h mode: bit 5 = PM, bits 4-0 = hours (1-12)
|
||||
uint8_t h12 = bcdToDec(rawHour & 0x1F);
|
||||
bool pm = rawHour & 0x20;
|
||||
if (h12 == 12) h12 = 0;
|
||||
_cachedHour = pm ? (h12 + 12) : h12;
|
||||
} else {
|
||||
// 24h mode: bits 5-0 = hours (0-23)
|
||||
_cachedHour = bcdToDec(rawHour & 0x3F);
|
||||
}
|
||||
_cachedHour = dt.hour;
|
||||
_cachedMinute = dt.minute;
|
||||
_lastPollMs = now;
|
||||
_hasCachedTime = true;
|
||||
|
||||
hour = _cachedHour;
|
||||
minute = _cachedMinute;
|
||||
return true;
|
||||
@@ -127,28 +66,6 @@ bool HalClock::formatTime(char* buf, size_t bufSize, uint8_t utcOffsetQuarterHou
|
||||
return true;
|
||||
}
|
||||
|
||||
bool HalClock::writeTimeToRTC(uint8_t hour, uint8_t minute, uint8_t second) {
|
||||
assert(hour < 24);
|
||||
assert(minute < 60);
|
||||
assert(second < 60);
|
||||
Wire.beginTransmission(I2C_ADDR_DS3231);
|
||||
Wire.write(DS3231_SEC_REG); // Start at register 0x00
|
||||
Wire.write(decToBcd(second)); // 0x00: Seconds
|
||||
Wire.write(decToBcd(minute)); // 0x01: Minutes
|
||||
Wire.write(decToBcd(hour)); // 0x02: Hours (24h mode, bit 6 = 0)
|
||||
if (Wire.endTransmission() != 0) {
|
||||
LOG_ERR("CLK", "Failed to write time to DS3231");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Invalidate cache so next read fetches fresh data
|
||||
_lastPollMs = 0;
|
||||
_cachedHour = hour;
|
||||
_cachedMinute = minute;
|
||||
_hasCachedTime = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool HalClock::syncFromNTP() {
|
||||
if (!_available) return false;
|
||||
|
||||
@@ -168,8 +85,21 @@ bool HalClock::syncFromNTP() {
|
||||
struct tm timeinfo;
|
||||
gmtime_r(&now, &timeinfo);
|
||||
|
||||
if (writeTimeToRTC(timeinfo.tm_hour, timeinfo.tm_min, timeinfo.tm_sec)) {
|
||||
LOG_INF("CLK", "RTC set to %02d:%02d:%02d UTC", timeinfo.tm_hour, timeinfo.tm_min, timeinfo.tm_sec);
|
||||
Rtc::DateTime dt;
|
||||
dt.year = static_cast<uint16_t>(timeinfo.tm_year + 1900);
|
||||
dt.month = static_cast<uint8_t>(timeinfo.tm_mon + 1);
|
||||
dt.day = static_cast<uint8_t>(timeinfo.tm_mday);
|
||||
dt.hour = static_cast<uint8_t>(timeinfo.tm_hour);
|
||||
dt.minute = static_cast<uint8_t>(timeinfo.tm_min);
|
||||
dt.second = static_cast<uint8_t>(timeinfo.tm_sec);
|
||||
dt.weekday = static_cast<uint8_t>(timeinfo.tm_wday);
|
||||
if (_sdkRtc.set(dt)) {
|
||||
_lastPollMs = 0;
|
||||
_cachedHour = dt.hour;
|
||||
_cachedMinute = dt.minute;
|
||||
_hasCachedTime = true;
|
||||
LOG_INF("CLK", "RTC set to %04u-%02u-%02u %02u:%02u:%02u UTC", dt.year, dt.month, dt.day, dt.hour, dt.minute,
|
||||
dt.second);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
|
||||
+5
-9
@@ -1,15 +1,14 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <Wire.h>
|
||||
|
||||
#include "HalGPIO.h"
|
||||
#include <Rtc.h>
|
||||
|
||||
class HalClock;
|
||||
extern HalClock halClock; // Singleton
|
||||
|
||||
class HalClock {
|
||||
bool _available = false;
|
||||
mutable Rtc _sdkRtc;
|
||||
mutable uint8_t _cachedHour = 0;
|
||||
mutable uint8_t _cachedMinute = 0;
|
||||
mutable bool _hasCachedTime = false;
|
||||
@@ -18,10 +17,10 @@ class HalClock {
|
||||
static constexpr unsigned long CLOCK_POLL_MS = 10000; // 10 seconds
|
||||
|
||||
public:
|
||||
// Call after gpio.begin() and powerManager.begin() (I2C already initialised for X3)
|
||||
// Call after BoardConfig has selected the active device.
|
||||
void begin();
|
||||
|
||||
// True if the DS3231 RTC is present on this device
|
||||
// True if an RTC is present on this device
|
||||
bool isAvailable() const { return _available; }
|
||||
|
||||
// Get current hour (0-23) and minute (0-59).
|
||||
@@ -35,14 +34,11 @@ class HalClock {
|
||||
// Returns false if RTC is not available.
|
||||
bool formatTime(char* buf, size_t bufSize, uint8_t utcOffsetQuarterHoursBiased = 48, bool use12Hour = false) const;
|
||||
|
||||
// Sync the DS3231 RTC from an NTP server. Requires WiFi to be connected.
|
||||
// Sync the RTC from an NTP server. Requires WiFi to be connected.
|
||||
// Blocks for up to ~5s while waiting for SNTP response.
|
||||
// Returns true if the RTC was successfully updated.
|
||||
//
|
||||
// Debouncing (skip if already synced once) is enforced by the caller, not here,
|
||||
// so the HAL stays free of any app-layer settings dependency.
|
||||
bool syncFromNTP();
|
||||
|
||||
private:
|
||||
bool writeTimeToRTC(uint8_t hour, uint8_t minute, uint8_t second);
|
||||
};
|
||||
|
||||
@@ -65,6 +65,18 @@ void HalDisplay::displayBuffer(HalDisplay::RefreshMode mode, bool turnOffScreen)
|
||||
einkDisplay.displayBuffer(convertRefreshMode(mode), turnOffScreen);
|
||||
}
|
||||
|
||||
void HalDisplay::displayBufferAsync(HalDisplay::RefreshMode mode) {
|
||||
if (gpio.deviceIsX3() && mode == RefreshMode::HALF_REFRESH) {
|
||||
einkDisplay.requestResync(1);
|
||||
}
|
||||
|
||||
einkDisplay.displayBufferAsyncNoShadow(convertRefreshMode(mode));
|
||||
}
|
||||
|
||||
void HalDisplay::waitRefreshComplete() { einkDisplay.waitRefreshComplete(); }
|
||||
|
||||
bool HalDisplay::supportsAsyncRefresh() const { return einkDisplay.supportsAsyncRefresh(); }
|
||||
|
||||
void HalDisplay::refreshDisplay(HalDisplay::RefreshMode mode, bool turnOffScreen) {
|
||||
if (gpio.deviceIsX3() && mode == RefreshMode::HALF_REFRESH) {
|
||||
einkDisplay.requestResync(1);
|
||||
|
||||
@@ -39,6 +39,17 @@ class HalDisplay {
|
||||
bool fromProgmem = false) const;
|
||||
|
||||
void displayBuffer(RefreshMode mode = RefreshMode::FAST_REFRESH, bool turnOffScreen = false);
|
||||
// Non-blocking refresh (shadow-free): starts the panel waveform and returns
|
||||
// while the panel refreshes on its own. The framebuffer must stay untouched
|
||||
// until waitRefreshComplete(), and the caller must rebuild the differential
|
||||
// baseline before the next differential update (the tiled grayscale cleanup
|
||||
// does). Panels without deferral fall back to a blocking refresh.
|
||||
void displayBufferAsync(RefreshMode mode = RefreshMode::FAST_REFRESH);
|
||||
// Block until a pending deferred refresh completes (no-op when none is).
|
||||
void waitRefreshComplete();
|
||||
// True when displayBufferAsync() genuinely overlaps (panel driver defers);
|
||||
// false where it falls back to a blocking refresh.
|
||||
bool supportsAsyncRefresh() const;
|
||||
void refreshDisplay(RefreshMode mode = RefreshMode::FAST_REFRESH, bool turnOffScreen = false);
|
||||
|
||||
// Power management
|
||||
|
||||
+53
-5
@@ -1,5 +1,6 @@
|
||||
#include <HalGPIO.h>
|
||||
#include <Logging.h>
|
||||
#include <PowerManager.h>
|
||||
#include <Preferences.h>
|
||||
#include <SPI.h>
|
||||
#include <Wire.h>
|
||||
@@ -191,15 +192,20 @@ HalGPIO::DeviceType detectDeviceTypeWithFingerprint() {
|
||||
} // namespace
|
||||
|
||||
void HalGPIO::begin() {
|
||||
inputMgr.begin();
|
||||
#if FREEINK_MCU_C3
|
||||
SPI.begin(EPD_SCLK, SPI_MISO, EPD_MOSI, EPD_CS);
|
||||
|
||||
_deviceType = detectDeviceTypeWithFingerprint();
|
||||
BoardConfig::selectDevice(deviceIsX3() ? BoardConfig::Board::XteinkX3 : BoardConfig::Board::XteinkX4);
|
||||
|
||||
if (deviceIsX4()) {
|
||||
pinMode(BAT_GPIO0, INPUT);
|
||||
pinMode(UART0_RXD, INPUT);
|
||||
}
|
||||
#else
|
||||
_deviceType = DeviceType::X4;
|
||||
#endif
|
||||
inputMgr.begin();
|
||||
}
|
||||
|
||||
void HalGPIO::update() {
|
||||
@@ -225,7 +231,44 @@ unsigned long HalGPIO::getHeldTime() const { return inputMgr.getHeldTime(); }
|
||||
|
||||
unsigned long HalGPIO::getPowerButtonHeldTime() const { return inputMgr.getPowerButtonHeldTime(); }
|
||||
|
||||
bool HalGPIO::hasTouch() const { return inputMgr.hasTouch(); }
|
||||
|
||||
bool HalGPIO::wasTouchTap(float& nx, float& ny) const { return inputMgr.wasTouchTap(nx, ny); }
|
||||
|
||||
bool HalGPIO::wasTouchDown(float& nx, float& ny) const { return inputMgr.wasTouchPressedAt(nx, ny); }
|
||||
|
||||
bool HalGPIO::isTouchTapCandidate(float& nx, float& ny, unsigned long& heldMs) const {
|
||||
return inputMgr.isTouchTapCandidate(nx, ny, heldMs);
|
||||
}
|
||||
|
||||
bool HalGPIO::isTouchHeldAt(float& nx, float& ny) const { return inputMgr.isTouchHeldAt(nx, ny); }
|
||||
|
||||
unsigned long HalGPIO::lastTouchHeldMs() const { return inputMgr.lastTouchHeldMs(); }
|
||||
|
||||
bool HalGPIO::wasSwipe(float& nxStart, float& nyStart, float& nxEnd, float& nyEnd) const {
|
||||
return inputMgr.wasSwipe(nxStart, nyStart, nxEnd, nyEnd);
|
||||
}
|
||||
|
||||
bool HalGPIO::wasTouchActivity() const { return inputMgr.wasTouchActivity(); }
|
||||
|
||||
void HalGPIO::setSharedConfirmPowerShortPressEmitsPower(const bool enabled) {
|
||||
InputManager::setSharedConfirmPowerShortPressEmitsPower(enabled);
|
||||
}
|
||||
|
||||
bool HalGPIO::isXteinkDevice() const {
|
||||
return BoardConfig::ACTIVE.board == BoardConfig::Board::XteinkX3 ||
|
||||
BoardConfig::ACTIVE.board == BoardConfig::Board::XteinkX4;
|
||||
}
|
||||
|
||||
bool HalGPIO::verifyPowerButtonWakeup(uint16_t requiredDurationMs, bool shortPressAllowed) {
|
||||
// Boards without a power button (or M5Paper's latch circuit) cannot verify a
|
||||
// hold; treat the wake as valid.
|
||||
if (BoardConfig::ACTIVE.input.power < 0) {
|
||||
return true;
|
||||
}
|
||||
#if defined(FREEINK_DEVICE_M5PAPER) && FREEINK_DEVICE_M5PAPER
|
||||
return true;
|
||||
#endif
|
||||
if (shortPressAllowed) {
|
||||
// Fast path - no duration check needed
|
||||
return true;
|
||||
@@ -271,8 +314,10 @@ bool HalGPIO::isUsbConnected() const {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// U0RXD/GPIO20 reads HIGH when USB is connected
|
||||
return digitalRead(UART0_RXD) == HIGH;
|
||||
if (BoardConfig::ACTIVE.usbDetect < 0) {
|
||||
return false;
|
||||
}
|
||||
return digitalRead(BoardConfig::ACTIVE.usbDetect) == HIGH;
|
||||
}
|
||||
|
||||
HalGPIO::WakeupReason HalGPIO::getWakeupReason() const {
|
||||
@@ -281,8 +326,11 @@ HalGPIO::WakeupReason HalGPIO::getWakeupReason() const {
|
||||
|
||||
const bool usbConnected = isUsbConnected();
|
||||
|
||||
if ((wakeupCause == ESP_SLEEP_WAKEUP_UNDEFINED && resetReason == ESP_RST_POWERON && !usbConnected) ||
|
||||
(wakeupCause == ESP_SLEEP_WAKEUP_GPIO && resetReason == ESP_RST_DEEPSLEEP && usbConnected)) {
|
||||
if (resetReason == ESP_RST_DEEPSLEEP &&
|
||||
(wakeupCause == ESP_SLEEP_WAKEUP_GPIO || wakeupCause == ESP_SLEEP_WAKEUP_EXT1)) {
|
||||
return WakeupReason::PowerButton;
|
||||
}
|
||||
if (wakeupCause == ESP_SLEEP_WAKEUP_UNDEFINED && resetReason == ESP_RST_POWERON && !usbConnected) {
|
||||
return WakeupReason::PowerButton;
|
||||
}
|
||||
if (wakeupCause == ESP_SLEEP_WAKEUP_UNDEFINED && resetReason == ESP_RST_UNKNOWN && usbConnected) {
|
||||
|
||||
@@ -58,6 +58,7 @@ class HalGPIO {
|
||||
// Inline device type helpers for cleaner downstream checks
|
||||
inline bool deviceIsX3() const { return _deviceType == DeviceType::X3; }
|
||||
inline bool deviceIsX4() const { return _deviceType == DeviceType::X4; }
|
||||
bool isXteinkDevice() const;
|
||||
|
||||
// Start button GPIO and setup SPI for screen and SD card
|
||||
void begin();
|
||||
@@ -71,6 +72,15 @@ class HalGPIO {
|
||||
bool wasAnyReleased() const;
|
||||
unsigned long getHeldTime() const;
|
||||
unsigned long getPowerButtonHeldTime() const;
|
||||
bool hasTouch() const;
|
||||
bool wasTouchTap(float& nx, float& ny) const;
|
||||
bool wasTouchDown(float& nx, float& ny) const;
|
||||
bool isTouchTapCandidate(float& nx, float& ny, unsigned long& heldMs) const;
|
||||
bool isTouchHeldAt(float& nx, float& ny) const;
|
||||
unsigned long lastTouchHeldMs() const;
|
||||
bool wasSwipe(float& nxStart, float& nyStart, float& nxEnd, float& nyEnd) const;
|
||||
bool wasTouchActivity() const;
|
||||
void setSharedConfirmPowerShortPressEmitsPower(bool enabled);
|
||||
|
||||
// Verify power button was held long enough after wakeup.
|
||||
// Returns true if verification succeeded, false if device should return to sleep.
|
||||
|
||||
+35
-50
@@ -1,8 +1,11 @@
|
||||
#include "HalPowerManager.h"
|
||||
|
||||
#include <BoardConfig.h>
|
||||
#include <Logging.h>
|
||||
#include <PowerManager.h>
|
||||
#include <WiFi.h>
|
||||
#include <esp_sleep.h>
|
||||
#include <soc/soc_caps.h>
|
||||
|
||||
#include <cassert>
|
||||
|
||||
@@ -11,14 +14,8 @@
|
||||
HalPowerManager powerManager; // Singleton instance
|
||||
|
||||
void HalPowerManager::begin() {
|
||||
if (gpio.deviceIsX3()) {
|
||||
// X3 uses an I2C fuel gauge for battery monitoring.
|
||||
// I2C init must come AFTER gpio.begin() so early hardware detection/probes are finished.
|
||||
Wire.begin(X3_I2C_SDA, X3_I2C_SCL, X3_I2C_FREQ);
|
||||
Wire.setTimeOut(4);
|
||||
_batteryUseI2C = true;
|
||||
} else {
|
||||
pinMode(BAT_GPIO0, INPUT);
|
||||
if (BoardConfig::ACTIVE.batteryAdc >= 0) {
|
||||
pinMode(BoardConfig::ACTIVE.batteryAdc, INPUT);
|
||||
}
|
||||
normalFreq = getCpuFrequencyMhz();
|
||||
modeMutex = xSemaphoreCreateMutex();
|
||||
@@ -61,12 +58,6 @@ void HalPowerManager::setPowerSaving(bool enabled) {
|
||||
}
|
||||
|
||||
void HalPowerManager::startDeepSleep(HalGPIO& gpio) const {
|
||||
// Ensure that the power button has been released to avoid immediately turning back on if you're holding it
|
||||
while (gpio.isPressed(HalGPIO::BTN_POWER)) {
|
||||
delay(50);
|
||||
gpio.update();
|
||||
}
|
||||
|
||||
#ifdef ENABLE_SERIAL_LOG
|
||||
// Tear down HWCDC so the host sees a clean disconnect and the peripheral
|
||||
// doesn't hold power domains that interfere with USB-powered GPIO wake.
|
||||
@@ -75,53 +66,47 @@ void HalPowerManager::startDeepSleep(HalGPIO& gpio) const {
|
||||
logSerial.end();
|
||||
#endif
|
||||
|
||||
// Pre-sleep routines from the original firmware
|
||||
// GPIO13 is connected to battery latch MOSFET, we need to make sure it's low during sleep
|
||||
// Note that this means the MCU will be completely powered off during sleep, including RTC
|
||||
constexpr gpio_num_t GPIO_SPIWP = GPIO_NUM_13;
|
||||
gpio_set_direction(GPIO_SPIWP, GPIO_MODE_OUTPUT);
|
||||
gpio_set_level(GPIO_SPIWP, 0);
|
||||
esp_sleep_config_gpio_isolate();
|
||||
gpio_deep_sleep_hold_en();
|
||||
gpio_hold_en(GPIO_SPIWP);
|
||||
pinMode(InputManager::POWER_BUTTON_PIN, INPUT_PULLUP);
|
||||
// Arm the wakeup trigger *after* the button is released
|
||||
// Note: this is only useful for waking up on USB power. On battery, the MCU will be completely powered off, so the
|
||||
// power button is hard-wired to briefly provide power to the MCU, waking it up regardless of the wakeup source
|
||||
// configuration
|
||||
esp_deep_sleep_enable_gpio_wakeup(1ULL << InputManager::POWER_BUTTON_PIN, ESP_GPIO_WAKEUP_GPIO_LOW);
|
||||
// Enter Deep Sleep
|
||||
esp_deep_sleep_start();
|
||||
#if !SOC_PM_SUPPORT_EXT1_WAKEUP
|
||||
if (gpio.isXteinkDevice() && !gpio.deviceIsX3()) {
|
||||
// X4 GPIO13 is connected to the battery latch MOSFET. Keeping it low powers
|
||||
// the MCU off on battery, while the SDK wake source still handles USB power.
|
||||
constexpr gpio_num_t GPIO_SPIWP = GPIO_NUM_13;
|
||||
gpio_set_direction(GPIO_SPIWP, GPIO_MODE_OUTPUT);
|
||||
gpio_set_level(GPIO_SPIWP, 0);
|
||||
gpio_hold_en(GPIO_SPIWP);
|
||||
}
|
||||
#endif
|
||||
|
||||
// Cut the gated peripheral rails (touch/SD/EPD on boards like the Sticky) and
|
||||
// hold the enables off through deep sleep — otherwise the GT911 and SD card
|
||||
// stay powered all through "off" and drain the battery. No-op on boards with
|
||||
// no switched rails (X4/X3). Trade-off: no touch-to-wake; wake is the power
|
||||
// button. Must run after display.deepSleep() so the panel controller gets its
|
||||
// deep-sleep command while its rail is still up (enterDeepSleep() in main.cpp
|
||||
// guarantees that ordering).
|
||||
freeink::PowerManager::powerDownRailsForSleep();
|
||||
|
||||
// Waits for the power button to be physically released (so holding it doesn't
|
||||
// immediately wake the device again), then arms the wake source and sleeps.
|
||||
freeink::PowerManager::deepSleepUntilPowerButton();
|
||||
}
|
||||
|
||||
uint16_t HalPowerManager::getBatteryPercentage() const {
|
||||
if (_batteryUseI2C) {
|
||||
static const BatteryMonitor battery;
|
||||
if (BoardConfig::ACTIVE.batteryGauge.gaugeAddr != 0) {
|
||||
const unsigned long now = millis();
|
||||
if (_batteryLastPollMs != 0 && (now - _batteryLastPollMs) < BATTERY_POLL_MS) {
|
||||
return _batteryCachedPercent;
|
||||
}
|
||||
|
||||
// Read SOC directly from I2C fuel gauge (16-bit LE register).
|
||||
// On I2C error, keep last known value to avoid UI jitter/slowdowns.
|
||||
Wire.beginTransmission(I2C_ADDR_BQ27220);
|
||||
Wire.write(BQ27220_SOC_REG);
|
||||
if (Wire.endTransmission(false) != 0) {
|
||||
_batteryLastPollMs = now;
|
||||
return _batteryCachedPercent;
|
||||
}
|
||||
Wire.requestFrom(I2C_ADDR_BQ27220, (uint8_t)2);
|
||||
if (Wire.available() < 2) {
|
||||
_batteryLastPollMs = now;
|
||||
return _batteryCachedPercent;
|
||||
}
|
||||
const uint8_t lo = Wire.read();
|
||||
const uint8_t hi = Wire.read();
|
||||
const uint16_t soc = (hi << 8) | lo;
|
||||
_batteryCachedPercent = soc > 100 ? 100 : soc;
|
||||
_batteryLastPollMs = now;
|
||||
uint16_t percent = 0;
|
||||
if (!battery.readPercentageChecked(percent)) {
|
||||
return _batteryCachedPercent;
|
||||
}
|
||||
_batteryCachedPercent = percent;
|
||||
return _batteryCachedPercent;
|
||||
}
|
||||
static const BatteryMonitor battery = BatteryMonitor(BAT_GPIO0);
|
||||
|
||||
// smooth the battery %.
|
||||
if (_batteryCachedPercent == 0) {
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
#include <BatteryMonitor.h>
|
||||
#include <InputManager.h>
|
||||
#include <Logging.h>
|
||||
#include <Wire.h>
|
||||
#include <freertos/semphr.h>
|
||||
|
||||
#include <cassert>
|
||||
@@ -18,8 +17,6 @@ class HalPowerManager {
|
||||
int normalFreq = 0; // MHz
|
||||
bool isLowPower = false;
|
||||
|
||||
// I2C fuel gauge configuration for X3 battery monitoring
|
||||
bool _batteryUseI2C = false; // True if using I2C fuel gauge (X3), false for ADC (X4)
|
||||
mutable int _batteryCachedPercent = 0; // Last read battery percentage (0-100)
|
||||
mutable unsigned long _batteryLastPollMs = 0; // Timestamp of last battery read in milliseconds
|
||||
|
||||
@@ -28,7 +25,11 @@ class HalPowerManager {
|
||||
SemaphoreHandle_t modeMutex = nullptr; // Protect access to currentLockMode
|
||||
|
||||
public:
|
||||
static constexpr int LOW_POWER_FREQ = 10; // MHz
|
||||
#if BOARD_HAS_PSRAM
|
||||
static constexpr int LOW_POWER_FREQ = 80; // MHz
|
||||
#else
|
||||
static constexpr int LOW_POWER_FREQ = 10; // MHz
|
||||
#endif
|
||||
static constexpr unsigned long IDLE_POWER_SAVING_MS = 3000; // ms
|
||||
static constexpr unsigned long BATTERY_POLL_MS = 1500; // ms
|
||||
|
||||
|
||||
@@ -38,6 +38,11 @@ void IRAM_ATTR __wrap_panic_print_backtrace(const void* frame, int core) {
|
||||
__real_panic_print_backtrace(frame, core);
|
||||
return;
|
||||
}
|
||||
|
||||
#if !__riscv
|
||||
__real_panic_print_backtrace(frame, core);
|
||||
return;
|
||||
#else
|
||||
for (size_t i = 0; i < MAX_PANIC_STACK_DEPTH; i++) {
|
||||
panicStack[i].sp = 0;
|
||||
}
|
||||
@@ -65,6 +70,7 @@ void IRAM_ATTR __wrap_panic_print_backtrace(const void* frame, int core) {
|
||||
}
|
||||
|
||||
__real_panic_print_backtrace(frame, core);
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+28
-93
@@ -4,84 +4,29 @@
|
||||
|
||||
HalTiltSensor halTiltSensor; // Singleton instance
|
||||
|
||||
bool HalTiltSensor::writeReg(uint8_t reg, uint8_t val) const {
|
||||
Wire.beginTransmission(_i2cAddr);
|
||||
Wire.write(reg);
|
||||
Wire.write(val);
|
||||
return Wire.endTransmission() == 0;
|
||||
}
|
||||
|
||||
bool HalTiltSensor::readReg(uint8_t reg, uint8_t* val) const {
|
||||
Wire.beginTransmission(_i2cAddr);
|
||||
Wire.write(reg);
|
||||
if (Wire.endTransmission(false) != 0) {
|
||||
return false;
|
||||
}
|
||||
Wire.requestFrom(_i2cAddr, (uint8_t)1);
|
||||
if (Wire.available() < 1) {
|
||||
return false;
|
||||
}
|
||||
*val = Wire.read();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool HalTiltSensor::readGyro(float& gx, float& gy, float& gz) const {
|
||||
Wire.beginTransmission(_i2cAddr);
|
||||
Wire.write(REG_GX_L); // Start reading at Gyro X Low
|
||||
if (Wire.endTransmission(false) != 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Wire.requestFrom(_i2cAddr, (uint8_t)6);
|
||||
if (Wire.available() < 6) {
|
||||
return false;
|
||||
}
|
||||
|
||||
auto readInt16 = [&]() -> int16_t {
|
||||
const uint8_t lo = Wire.read();
|
||||
const uint8_t hi = Wire.read();
|
||||
return static_cast<int16_t>((hi << 8) | lo);
|
||||
};
|
||||
|
||||
// If Full Scale is ±512 dps, the scale factor is 32768 / 512 = 64 LSB/dps
|
||||
constexpr float SCALE = 1.0f / 64.0f;
|
||||
gx = readInt16() * SCALE;
|
||||
gy = readInt16() * SCALE;
|
||||
gz = readInt16() * SCALE;
|
||||
Imu::Sample sample;
|
||||
if (!_sdkImu.read(sample)) return false;
|
||||
gx = sample.gx;
|
||||
gy = sample.gy;
|
||||
gz = sample.gz;
|
||||
return true;
|
||||
}
|
||||
|
||||
void HalTiltSensor::begin() {
|
||||
if (!gpio.deviceIsX3()) {
|
||||
_available = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Try primary address, then alternate
|
||||
uint8_t whoami = 0;
|
||||
_i2cAddr = I2C_ADDR_QMI8658;
|
||||
if (!readReg(QMI8658_WHO_AM_I_REG, &whoami) || whoami != QMI8658_WHO_AM_I_VALUE) {
|
||||
_i2cAddr = I2C_ADDR_QMI8658_ALT;
|
||||
if (!readReg(QMI8658_WHO_AM_I_REG, &whoami) || whoami != QMI8658_WHO_AM_I_VALUE) {
|
||||
LOG_ERR("GYR", "QMI8658 IMU not found");
|
||||
_available = false;
|
||||
return;
|
||||
_available = _sdkImu.begin();
|
||||
if (_available) {
|
||||
_initMs = millis();
|
||||
_lastPollMs = millis();
|
||||
// begin() leaves the sensors sampling; stand them by until tilt page turn
|
||||
// actually wakes them, so a disabled IMU doesn't drain the battery.
|
||||
if (!_sdkImu.sleep()) {
|
||||
LOG_ERR("GYR", "IMU standby failed");
|
||||
}
|
||||
}
|
||||
|
||||
LOG_INF("GYR", "QMI8658 IMU found at 0x%02X", _i2cAddr);
|
||||
|
||||
if (!writeReg(REG_CTRL7, CTRL7_DISABLE_ALL) || !writeReg(REG_CTRL3, CTRL3_FS_512DPS | CTRL3_ODR_28HZ) ||
|
||||
!writeReg(REG_CTRL1, CTRL1_BASE | CTRL1_SENSOR_DISABLE)) {
|
||||
LOG_ERR("GYR", "QMI8658 register configuration failed");
|
||||
_available = false;
|
||||
LOG_INF("GYR", "SDK IMU initialized");
|
||||
return;
|
||||
}
|
||||
|
||||
_available = true;
|
||||
_initMs = millis();
|
||||
_lastPollMs = millis();
|
||||
LOG_INF("GYR", "QMI8658 gyro initialized and put to sleep");
|
||||
LOG_ERR("GYR", "SDK IMU not found");
|
||||
}
|
||||
|
||||
bool HalTiltSensor::wake() {
|
||||
@@ -89,21 +34,16 @@ bool HalTiltSensor::wake() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Wait for init to complete before waking
|
||||
if ((millis() - _initMs) < SLEEP_STABILIZE_MS) {
|
||||
if (!_sdkImu.wake()) {
|
||||
LOG_ERR("GYR", "IMU wake failed");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (writeReg(REG_CTRL1, CTRL1_BASE) && writeReg(REG_CTRL7, CTRL7_GYRO_ENABLE)) {
|
||||
_lastPollMs = millis();
|
||||
_lastTiltMs = millis();
|
||||
_wakeMs = millis();
|
||||
LOG_INF("GYR", "QMI8658 woke up");
|
||||
return true;
|
||||
} else {
|
||||
LOG_ERR("GYR", "Failed to wake QMI8658");
|
||||
return false;
|
||||
}
|
||||
_lastPollMs = millis();
|
||||
_lastTiltMs = millis();
|
||||
_wakeMs = millis();
|
||||
_isAwake = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool HalTiltSensor::deepSleep() {
|
||||
@@ -111,20 +51,15 @@ bool HalTiltSensor::deepSleep() {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((millis() - _wakeMs) < SLEEP_STABILIZE_MS) {
|
||||
if (!_sdkImu.sleep()) {
|
||||
LOG_ERR("GYR", "IMU sleep failed");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (writeReg(REG_CTRL7, CTRL7_DISABLE_ALL) && writeReg(REG_CTRL1, CTRL1_BASE | CTRL1_SENSOR_DISABLE)) {
|
||||
// Clear any residual state so it doesn't immediately trigger upon waking
|
||||
clearPendingEvents();
|
||||
_inTilt = false;
|
||||
LOG_INF("GYR", "QMI8658 entered sleep mode");
|
||||
return true;
|
||||
} else {
|
||||
LOG_ERR("GYR", "Failed to put QMI8658 to sleep");
|
||||
return false;
|
||||
}
|
||||
clearPendingEvents();
|
||||
_inTilt = false;
|
||||
_isAwake = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
void HalTiltSensor::update(const uint8_t mode, const uint8_t orientation, const bool inReader) {
|
||||
|
||||
+6
-33
@@ -1,9 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <Wire.h>
|
||||
|
||||
#include "HalGPIO.h"
|
||||
#include <Imu.h>
|
||||
|
||||
// TODO: Move enums into new header and share with CrossPointSettings.h
|
||||
namespace CrossPointOrientation {
|
||||
@@ -19,7 +17,7 @@ extern HalTiltSensor halTiltSensor; // Singleton
|
||||
|
||||
class HalTiltSensor {
|
||||
bool _available = false;
|
||||
uint8_t _i2cAddr = 0;
|
||||
mutable Imu _sdkImu;
|
||||
|
||||
// Tilt gesture state machine
|
||||
bool _tiltForwardEvent = false; // Consumed by wasTiltedForward()
|
||||
@@ -37,47 +35,22 @@ class HalTiltSensor {
|
||||
static constexpr unsigned long COOLDOWN_MS = 600; // Minimum ms between triggers
|
||||
static constexpr unsigned long POLL_INTERVAL_MS = 50; // 20 Hz polling
|
||||
static constexpr unsigned long WAKE_STABILIZE_MS = 300; // Ignore readings after wake
|
||||
static constexpr unsigned long SLEEP_STABILIZE_MS = 15; // Sleep turn on/off delay
|
||||
|
||||
mutable unsigned long _lastPollMs = 0;
|
||||
|
||||
// --- QMI8658 registers ---
|
||||
static constexpr uint8_t REG_CTRL1 = 0x02;
|
||||
static constexpr uint8_t REG_CTRL3 = 0x04;
|
||||
static constexpr uint8_t REG_CTRL7 = 0x08;
|
||||
static constexpr uint8_t REG_GX_L = 0x3B;
|
||||
|
||||
// --- Register Bit Flags ---
|
||||
|
||||
// REG_CTRL1 (0x02)
|
||||
static constexpr uint8_t CTRL1_BIG_ENDIAN = (1 << 5); // 0x20: Default state (1 = Big Endian)
|
||||
static constexpr uint8_t CTRL1_AUTO_INC = (1 << 6); // 0x40: Enable address auto-increment
|
||||
static constexpr uint8_t CTRL1_SENSOR_DISABLE = (1 << 0); // 0x01: Power down sensor engine
|
||||
static constexpr uint8_t CTRL1_BASE = CTRL1_AUTO_INC | CTRL1_BIG_ENDIAN; // 0x60
|
||||
|
||||
// REG_CTRL3 (0x04) - Gyro Config
|
||||
static constexpr uint8_t CTRL3_FS_512DPS = (0b101 << 4); // Bits 6:4 = 101
|
||||
static constexpr uint8_t CTRL3_ODR_28HZ = 0b1000; // Bits 3:0 = 1000 (28.025 Hz)
|
||||
|
||||
// REG_CTRL7 (0x08) - Enable
|
||||
static constexpr uint8_t CTRL7_DISABLE_ALL = 0x00;
|
||||
static constexpr uint8_t CTRL7_GYRO_ENABLE = (1 << 1); // Bit 1 = 1
|
||||
|
||||
bool writeReg(uint8_t reg, uint8_t val) const;
|
||||
bool readReg(uint8_t reg, uint8_t* val) const;
|
||||
bool readGyro(float& gx, float& gy, float& gz) const;
|
||||
|
||||
public:
|
||||
// Call after gpio.begin() and powerManager.begin() (I2C already initialised for X3)
|
||||
// Call after BoardConfig has selected the active device.
|
||||
void begin();
|
||||
|
||||
// Enables the QMI8658 internal sensor engine
|
||||
// Enables tilt polling state
|
||||
bool wake();
|
||||
|
||||
// Puts the QMI8658 into a low-power standby state
|
||||
// Puts tilt polling state to sleep
|
||||
bool deepSleep();
|
||||
|
||||
// True if the QMI8658 IMU is present on this device
|
||||
// True if an IMU is present on this device
|
||||
bool isAvailable() const { return _available; }
|
||||
|
||||
// Poll the accelerometer and update tilt gesture state.
|
||||
|
||||
+27
-2
@@ -35,8 +35,6 @@ build_flags =
|
||||
# Increase PNG scanline buffer to support up to 2048px wide images
|
||||
# Default is (320*4+1)*2=2562, we need more for larger images
|
||||
-DPNG_MAX_BUFFERED_PIXELS=16416
|
||||
-DFREEINK_DEVICE_X4=1
|
||||
-DFREEINK_DEVICE_X3=1
|
||||
-DFREEINK_NET_WOLFSSL=1
|
||||
-DWOLFSSL_USER_SETTINGS
|
||||
-DWOLFSSL_OPTIONS_H
|
||||
@@ -79,6 +77,8 @@ lib_deps =
|
||||
SDCardManager=symlink://freeink-sdk/libs/hardware/SDCardManager
|
||||
BoardConfig=symlink://freeink-sdk/libs/hardware/BoardConfig
|
||||
PowerManager=symlink://freeink-sdk/libs/hardware/PowerManager
|
||||
Rtc=symlink://freeink-sdk/libs/hardware/Rtc
|
||||
Imu=symlink://freeink-sdk/libs/hardware/Imu
|
||||
SecureNet=symlink://freeink-sdk/libs/network/SecureNet
|
||||
FreeInkUI=symlink://freeink-sdk/libs/ui/FreeInkUI
|
||||
Icons=symlink://freeink-sdk/libs/assets/Icons
|
||||
@@ -97,6 +97,8 @@ extends = base
|
||||
build_flags =
|
||||
${base.build_flags}
|
||||
; CROSSPOINT_VERSION is set by scripts/git_branch.py (includes branch + short SHA)
|
||||
-DFREEINK_DEVICE_X4=1
|
||||
-DFREEINK_DEVICE_X3=1
|
||||
-DENABLE_SERIAL_LOG
|
||||
-DLOG_LEVEL=2 ; Set log level to debug for development builds
|
||||
|
||||
@@ -105,6 +107,8 @@ build_flags =
|
||||
extends = base
|
||||
build_flags =
|
||||
${base.build_flags}
|
||||
-DFREEINK_DEVICE_X4=1
|
||||
-DFREEINK_DEVICE_X3=1
|
||||
-DCROSSPOINT_VERSION=\"${crosspoint.version}\"
|
||||
-DENABLE_SERIAL_LOG
|
||||
-DLOG_LEVEL=1 ; Set log level to info for release builds
|
||||
@@ -113,6 +117,8 @@ build_flags =
|
||||
extends = base
|
||||
build_flags =
|
||||
${base.build_flags}
|
||||
-DFREEINK_DEVICE_X4=1
|
||||
-DFREEINK_DEVICE_X3=1
|
||||
-DCROSSPOINT_VERSION=\"${crosspoint.version}-rc+${sysenv.CROSSPOINT_RC_HASH}\"
|
||||
-DENABLE_SERIAL_LOG
|
||||
-DLOG_LEVEL=1 ; Set log level to info for release candidate builds
|
||||
@@ -121,6 +127,25 @@ build_flags =
|
||||
extends = base
|
||||
build_flags =
|
||||
${base.build_flags}
|
||||
-DFREEINK_DEVICE_X4=1
|
||||
-DFREEINK_DEVICE_X3=1
|
||||
-DCROSSPOINT_VERSION=\"${crosspoint.version}-slim\"
|
||||
; serial output is disabled in slim builds to save space
|
||||
-UENABLE_SERIAL_LOG
|
||||
|
||||
; --- Seeed Sticky — ESP32-S3R8, 3.97" 800x480 SSD1677 + GT911 touch -----------
|
||||
; Different MCU family than the C3 envs (one binary per family). The SDK
|
||||
; auto-enables CAP_TOUCH (GT911) and the BQ27220 gauge for this device; PSRAM is
|
||||
; intentionally left off (48KB framebuffer fits in DRAM, same as X4).
|
||||
; pio run -e sticky -t upload
|
||||
[env:sticky]
|
||||
extends = base
|
||||
board = esp32-s3-devkitc1-n16r8
|
||||
board_build.mcu = esp32s3
|
||||
build_flags =
|
||||
${base.build_flags}
|
||||
-DFREEINK_DEVICE_STICKY=1
|
||||
; git_branch.py only injects CROSSPOINT_VERSION for the default env; set it here.
|
||||
-DCROSSPOINT_VERSION=\"${crosspoint.version}-sticky\"
|
||||
-DENABLE_SERIAL_LOG
|
||||
-DLOG_LEVEL=2
|
||||
|
||||
@@ -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.
|
||||
|
||||
+214
-3
@@ -2,7 +2,11 @@
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdlib>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "components/UITheme.h"
|
||||
|
||||
bool MappedInputManager::isNavDirectionSwapped() const {
|
||||
// Key the swap on the orientation the screen is *actually* rendered at, not the persisted reader
|
||||
@@ -74,9 +78,209 @@ bool MappedInputManager::mapButton(const Button button, bool (HalGPIO::*fn)(uint
|
||||
return false;
|
||||
}
|
||||
|
||||
bool MappedInputManager::wasPressed(const Button button) const { return mapButton(button, &HalGPIO::wasPressed); }
|
||||
namespace {
|
||||
constexpr float LEFT_EDGE_BACK_GESTURE_FRAC_X = 0.25f;
|
||||
constexpr float BOTTOM_EDGE_BACK_GESTURE_FRAC_Y = 0.14f;
|
||||
constexpr float TOP_EDGE_MENU_GESTURE_FRAC_Y = 0.14f;
|
||||
constexpr unsigned long TOUCH_DOWN_SELECT_DELAY_MS = 90;
|
||||
constexpr unsigned long TOUCH_HELD_OVERRIDE_WINDOW_MS = 250;
|
||||
} // namespace
|
||||
|
||||
bool MappedInputManager::wasReleased(const Button button) const { return mapButton(button, &HalGPIO::wasReleased); }
|
||||
bool MappedInputManager::hasTouch() const { return gpio.hasTouch(); }
|
||||
|
||||
void MappedInputManager::rememberTouchHeldTime() const {
|
||||
touchHeldOverrideValid = true;
|
||||
touchHeldOverrideMs = gpio.lastTouchHeldMs();
|
||||
touchHeldOverrideAt = millis();
|
||||
}
|
||||
|
||||
bool MappedInputManager::wasScreenTapped(int& x, int& y) const {
|
||||
float nx = 0.0f;
|
||||
float ny = 0.0f;
|
||||
if (!gpio.wasTouchTap(nx, ny)) return false;
|
||||
renderer.tapToLogical(nx, ny, x, y);
|
||||
rememberTouchHeldTime();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool MappedInputManager::wasScreenTouchDown(int& x, int& y) const {
|
||||
float nx = 0.0f;
|
||||
float ny = 0.0f;
|
||||
unsigned long heldMs = 0;
|
||||
if (!gpio.isTouchTapCandidate(nx, ny, heldMs)) return false;
|
||||
if (heldMs < TOUCH_DOWN_SELECT_DELAY_MS) return false;
|
||||
renderer.tapToLogical(nx, ny, x, y);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool MappedInputManager::isScreenTouchHeld(int& x, int& y) const {
|
||||
// Live contact position while the finger is down (no tap-slop gate) — drag tracking.
|
||||
float nx = 0.0f;
|
||||
float ny = 0.0f;
|
||||
if (!gpio.isTouchHeldAt(nx, ny)) return false;
|
||||
renderer.tapToLogical(nx, ny, x, y);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool MappedInputManager::wasTapInRect(const int x, const int y, const int width, const int height) const {
|
||||
int tx = 0;
|
||||
int ty = 0;
|
||||
return wasScreenTapped(tx, ty) && tx >= x && tx < x + width && ty >= y && ty < y + height;
|
||||
}
|
||||
|
||||
bool MappedInputManager::listItemFromPoint(const int x, const int y, int& index, const int itemCount,
|
||||
const int selectedIndex, const int listTop, const int listHeight,
|
||||
const bool hasSubtitle) const {
|
||||
(void)x;
|
||||
if (itemCount <= 0) return false;
|
||||
if (y < listTop || y >= listTop + listHeight) return false;
|
||||
|
||||
const auto& theme = UITheme::getInstance().getTheme();
|
||||
const int rowStep = theme.getListRowStep(hasSubtitle);
|
||||
if (rowStep <= 0) return false;
|
||||
|
||||
const int pageItems = theme.getListPageItems(listHeight, hasSubtitle);
|
||||
if (pageItems <= 0) return false;
|
||||
const int pageStart = std::max(0, selectedIndex / pageItems) * pageItems;
|
||||
const int row = (y - listTop) / rowStep;
|
||||
const int tapped = pageStart + row;
|
||||
if (row < 0 || row >= pageItems || tapped >= itemCount) return false;
|
||||
index = tapped;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool MappedInputManager::wasListItemTapped(int& index, const int itemCount, const int selectedIndex, const int listTop,
|
||||
const int listHeight, const bool hasSubtitle) const {
|
||||
int tx = 0;
|
||||
int ty = 0;
|
||||
return wasScreenTapped(tx, ty) &&
|
||||
listItemFromPoint(tx, ty, index, itemCount, selectedIndex, listTop, listHeight, hasSubtitle);
|
||||
}
|
||||
|
||||
bool MappedInputManager::wasListItemTouchedDown(int& index, const int itemCount, const int selectedIndex,
|
||||
const int listTop, const int listHeight, const bool hasSubtitle) const {
|
||||
int tx = 0;
|
||||
int ty = 0;
|
||||
return wasScreenTouchDown(tx, ty) &&
|
||||
listItemFromPoint(tx, ty, index, itemCount, selectedIndex, listTop, listHeight, hasSubtitle);
|
||||
}
|
||||
|
||||
MappedInputManager::RowTouch MappedInputManager::rowTouch(int& row, const int top, const int rowStep,
|
||||
const int rowCount, const int xStart, const int xEnd,
|
||||
const int rowHeight) const {
|
||||
if (rowStep <= 0 || rowCount <= 0) return RowTouch::None;
|
||||
const auto hit = [&](const int x, const int y) {
|
||||
if (x < xStart || x >= xEnd || y < top) return false;
|
||||
const int r = (y - top) / rowStep;
|
||||
if (r >= rowCount) return false;
|
||||
if (rowHeight > 0 && (y - top) % rowStep >= rowHeight) return false;
|
||||
row = r;
|
||||
return true;
|
||||
};
|
||||
int x = 0;
|
||||
int y = 0;
|
||||
if (wasScreenTouchDown(x, y) && hit(x, y)) return RowTouch::Down;
|
||||
if (wasScreenTapped(x, y) && hit(x, y)) return RowTouch::Tap;
|
||||
return RowTouch::None;
|
||||
}
|
||||
|
||||
MappedInputManager::RowTouch MappedInputManager::colTouch(int& col, const int left, const int colStep,
|
||||
const int colCount, const int yStart, const int yEnd,
|
||||
const int colWidth) const {
|
||||
if (colStep <= 0 || colCount <= 0) return RowTouch::None;
|
||||
const auto hit = [&](const int x, const int y) {
|
||||
if (y < yStart || y >= yEnd || x < left) return false;
|
||||
const int c = (x - left) / colStep;
|
||||
if (c >= colCount) return false;
|
||||
if (colWidth > 0 && (x - left) % colStep >= colWidth) return false;
|
||||
col = c;
|
||||
return true;
|
||||
};
|
||||
int x = 0;
|
||||
int y = 0;
|
||||
if (wasScreenTouchDown(x, y) && hit(x, y)) return RowTouch::Down;
|
||||
if (wasScreenTapped(x, y) && hit(x, y)) return RowTouch::Tap;
|
||||
return RowTouch::None;
|
||||
}
|
||||
|
||||
bool MappedInputManager::decodeSwipe(int& sx, int& sy, int& ex, int& ey) const {
|
||||
float nxs = 0.0f;
|
||||
float nys = 0.0f;
|
||||
float nxe = 0.0f;
|
||||
float nye = 0.0f;
|
||||
if (!gpio.wasSwipe(nxs, nys, nxe, nye)) return false;
|
||||
renderer.tapToLogical(nxs, nys, sx, sy);
|
||||
renderer.tapToLogical(nxe, nye, ex, ey);
|
||||
return true;
|
||||
}
|
||||
|
||||
MappedInputManager::SwipeDir MappedInputManager::wasSwipe() const {
|
||||
int sx = 0;
|
||||
int sy = 0;
|
||||
int ex = 0;
|
||||
int ey = 0;
|
||||
if (!decodeSwipe(sx, sy, ex, ey)) return SwipeDir::None;
|
||||
const int dx = ex - sx;
|
||||
const int dy = ey - sy;
|
||||
if (std::abs(dx) >= std::abs(dy)) {
|
||||
return dx < 0 ? SwipeDir::Left : SwipeDir::Right;
|
||||
}
|
||||
return dy < 0 ? SwipeDir::Up : SwipeDir::Down;
|
||||
}
|
||||
|
||||
bool MappedInputManager::wasBackGesture() const {
|
||||
// Back = left-to-right swipe starting near the left edge. Edge-anchored so that
|
||||
// mid-screen horizontal swipes stay available to activities that consume
|
||||
// SwipeDir::Left/Right (e.g. percent selection, image viewer).
|
||||
int sx = 0;
|
||||
int sy = 0;
|
||||
int ex = 0;
|
||||
int ey = 0;
|
||||
if (!decodeSwipe(sx, sy, ex, ey)) return false;
|
||||
const bool hit = sx <= renderer.getScreenWidth() * LEFT_EDGE_BACK_GESTURE_FRAC_X && ex > sx &&
|
||||
std::abs(ex - sx) > std::abs(ey - sy);
|
||||
if (hit) rememberTouchHeldTime();
|
||||
return hit;
|
||||
}
|
||||
|
||||
bool MappedInputManager::wasMenuGesture() const {
|
||||
// Downward swipe starting at the top edge (mirror of the bottom-edge home gesture).
|
||||
int sx = 0;
|
||||
int sy = 0;
|
||||
int ex = 0;
|
||||
int ey = 0;
|
||||
if (!decodeSwipe(sx, sy, ex, ey)) return false;
|
||||
const int topEdgeBottom = static_cast<int>(renderer.getScreenHeight() * TOP_EDGE_MENU_GESTURE_FRAC_Y);
|
||||
const bool hit = sy <= topEdgeBottom && ey > sy && std::abs(ey - sy) > std::abs(ex - sx);
|
||||
if (hit) rememberTouchHeldTime();
|
||||
return hit;
|
||||
}
|
||||
|
||||
bool MappedInputManager::wasHomeGesture() const {
|
||||
int sx = 0;
|
||||
int sy = 0;
|
||||
int ex = 0;
|
||||
int ey = 0;
|
||||
if (decodeSwipe(sx, sy, ex, ey)) {
|
||||
const int bottomEdgeTop =
|
||||
renderer.getScreenHeight() - static_cast<int>(renderer.getScreenHeight() * BOTTOM_EDGE_BACK_GESTURE_FRAC_Y);
|
||||
if (sy >= bottomEdgeTop && ey < sy && std::abs(ey - sy) > std::abs(ex - sx)) {
|
||||
rememberTouchHeldTime();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool MappedInputManager::wasPressed(const Button button) const {
|
||||
if (button == Button::Back && wasBackGesture()) return true;
|
||||
return mapButton(button, &HalGPIO::wasPressed);
|
||||
}
|
||||
|
||||
bool MappedInputManager::wasReleased(const Button button) const {
|
||||
if (button == Button::Back && wasBackGesture()) return true;
|
||||
return mapButton(button, &HalGPIO::wasReleased);
|
||||
}
|
||||
|
||||
bool MappedInputManager::isPressed(const Button button) const { return mapButton(button, &HalGPIO::isPressed); }
|
||||
|
||||
@@ -84,7 +288,14 @@ bool MappedInputManager::wasAnyPressed() const { return gpio.wasAnyPressed(); }
|
||||
|
||||
bool MappedInputManager::wasAnyReleased() const { return gpio.wasAnyReleased(); }
|
||||
|
||||
unsigned long MappedInputManager::getHeldTime() const { return gpio.getHeldTime(); }
|
||||
unsigned long MappedInputManager::getHeldTime() const {
|
||||
if (!gpio.wasAnyPressed() && !gpio.wasAnyReleased() && touchHeldOverrideValid &&
|
||||
millis() - touchHeldOverrideAt <= TOUCH_HELD_OVERRIDE_WINDOW_MS) {
|
||||
return touchHeldOverrideMs;
|
||||
}
|
||||
touchHeldOverrideValid = false;
|
||||
return gpio.getHeldTime();
|
||||
}
|
||||
|
||||
MappedInputManager::Labels MappedInputManager::mapLabels(const char* back, const char* confirm, const char* previous,
|
||||
const char* next) const {
|
||||
|
||||
@@ -7,6 +7,7 @@ class GfxRenderer;
|
||||
class MappedInputManager {
|
||||
public:
|
||||
enum class Button { Back, Confirm, Left, Right, Up, Down, Power, PageBack, PageForward, NavNext, NavPrevious };
|
||||
enum class SwipeDir { None, Left, Right, Up, Down };
|
||||
|
||||
struct Labels {
|
||||
const char* btn1;
|
||||
@@ -21,9 +22,35 @@ class MappedInputManager {
|
||||
bool wasPressed(Button button) const;
|
||||
bool wasReleased(Button button) const;
|
||||
bool isPressed(Button button) const;
|
||||
bool hasTouch() const;
|
||||
bool wasScreenTapped(int& x, int& y) const;
|
||||
bool wasScreenTouchDown(int& x, int& y) const;
|
||||
bool isScreenTouchHeld(int& x, int& y) const;
|
||||
bool wasTapInRect(int x, int y, int width, int height) const;
|
||||
bool wasListItemTapped(int& index, int itemCount, int selectedIndex, int listTop, int listHeight,
|
||||
bool hasSubtitle) const;
|
||||
bool wasListItemTouchedDown(int& index, int itemCount, int selectedIndex, int listTop, int listHeight,
|
||||
bool hasSubtitle) const;
|
||||
|
||||
// Combined touch interaction for a band of equal rows with caller-supplied
|
||||
// geometry — the shared hit-test for lists the theme helpers above do not
|
||||
// cover (custom row heights, option prompts, menus). Down = a held
|
||||
// tap-candidate is on a row (update the selection highlight); Tap = a tap
|
||||
// released on one (activate). rowHeight limits the hit to the top rowHeight
|
||||
// px of each step (0 = the full step, no gap band).
|
||||
enum class RowTouch : uint8_t { None, Down, Tap };
|
||||
RowTouch rowTouch(int& row, int top, int rowStep, int rowCount, int xStart = 0, int xEnd = INT32_MAX,
|
||||
int rowHeight = 0) const;
|
||||
// Horizontal variant for side-by-side button pairs (confirmation prompts).
|
||||
RowTouch colTouch(int& col, int left, int colStep, int colCount, int yStart, int yEnd, int colWidth = 0) const;
|
||||
|
||||
SwipeDir wasSwipe() const;
|
||||
bool wasHomeGesture() const;
|
||||
bool wasMenuGesture() const;
|
||||
bool wasAnyPressed() const;
|
||||
bool wasAnyReleased() const;
|
||||
unsigned long getHeldTime() const;
|
||||
const GfxRenderer& getRenderer() const { return renderer; }
|
||||
Labels mapLabels(const char* back, const char* confirm, const char* previous, const char* next) const;
|
||||
// Returns the raw front button index that was pressed this frame (or -1 if none).
|
||||
int getPressedFrontButton() const;
|
||||
@@ -44,4 +71,14 @@ class MappedInputManager {
|
||||
const GfxRenderer& renderer;
|
||||
|
||||
bool mapButton(Button button, bool (HalGPIO::*fn)(uint8_t) const) const;
|
||||
bool wasBackGesture() const;
|
||||
// Fetch the pending swipe (if any) and map both endpoints to logical screen coords
|
||||
bool decodeSwipe(int& sx, int& sy, int& ex, int& ey) const;
|
||||
bool listItemFromPoint(int x, int y, int& index, int itemCount, int selectedIndex, int listTop, int listHeight,
|
||||
bool hasSubtitle) const;
|
||||
void rememberTouchHeldTime() const;
|
||||
|
||||
mutable bool touchHeldOverrideValid = false;
|
||||
mutable unsigned long touchHeldOverrideMs = 0;
|
||||
mutable unsigned long touchHeldOverrideAt = 0;
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#include <BoardConfig.h>
|
||||
#include <HalClock.h>
|
||||
#include <HalTiltSensor.h>
|
||||
#include <I18n.h>
|
||||
@@ -209,6 +210,8 @@ inline std::vector<SettingInfo> getSettingsList(const SdCardFontRegistry* regist
|
||||
SettingInfo::Enum(StrId::STR_SIDE_BTN_LAYOUT, &CrossPointSettings::sideButtonLayout,
|
||||
{StrId::STR_PREV_NEXT, StrId::STR_NEXT_PREV, StrId::STR_DISABLED}, "sideButtonLayout",
|
||||
StrId::STR_CAT_CONTROLS),
|
||||
SettingInfo::Enum(StrId::STR_TOUCH_READER_CONTROLS, &CrossPointSettings::touchReaderControls,
|
||||
{StrId::STR_STATE_OFF, StrId::STR_STATE_ON}, "touchReaderControls", StrId::STR_CAT_CONTROLS),
|
||||
SettingInfo::Toggle(StrId::STR_FRONT_BTN_FOLLOW_ORIENTATION, &CrossPointSettings::frontButtonFollowOrientation,
|
||||
"frontButtonFollowOrientation", StrId::STR_CAT_CONTROLS),
|
||||
SettingInfo::Enum(StrId::STR_LONG_PRESS_BEHAVIOR, &CrossPointSettings::longPressButtonBehavior,
|
||||
@@ -345,6 +348,19 @@ inline std::vector<SettingInfo> getSettingsList(const SdCardFontRegistry* regist
|
||||
}();
|
||||
|
||||
std::vector<SettingInfo> v = baseList;
|
||||
if (!BoardConfig::hasTouch()) {
|
||||
v.erase(std::remove_if(v.begin(), v.end(),
|
||||
[](const SettingInfo& s) {
|
||||
return s.nameId == StrId::STR_TOUCH_READER_CONTROLS ||
|
||||
s.nameId == StrId::STR_SUNLIGHT_FADING_FIX;
|
||||
}),
|
||||
v.end());
|
||||
}
|
||||
if (BoardConfig::hasTouch()) {
|
||||
v.erase(std::remove_if(v.begin(), v.end(),
|
||||
[](const SettingInfo& s) { return s.nameId == StrId::STR_FRONT_BTN_FOLLOW_ORIENTATION; }),
|
||||
v.end());
|
||||
}
|
||||
if (registry && registry->getFamilyCount() > 0) {
|
||||
auto it = std::find_if(v.begin(), v.end(), [](const SettingInfo& s) { return s.nameId == StrId::STR_FONT_FAMILY; });
|
||||
if (it != v.end()) {
|
||||
|
||||
@@ -22,3 +22,20 @@ void Activity::startActivityForResult(std::unique_ptr<Activity>&& activity, Acti
|
||||
void Activity::setResult(ActivityResult&& result) { this->result = std::move(result); }
|
||||
|
||||
void Activity::finish() { activityManager.popActivity(); }
|
||||
|
||||
Activity::ListTouchResult Activity::handleListTouch(int& selectedIndex, const int itemCount, const int listTop,
|
||||
const int listHeight, const bool hasSubtitle) {
|
||||
int touched = -1;
|
||||
if (mappedInput.wasListItemTouchedDown(touched, itemCount, selectedIndex, listTop, listHeight, hasSubtitle)) {
|
||||
if (selectedIndex != touched) {
|
||||
selectedIndex = touched;
|
||||
requestUpdate();
|
||||
}
|
||||
return ListTouchResult::Consumed;
|
||||
}
|
||||
if (mappedInput.wasListItemTapped(touched, itemCount, selectedIndex, listTop, listHeight, hasSubtitle)) {
|
||||
selectedIndex = touched;
|
||||
return ListTouchResult::Activated;
|
||||
}
|
||||
return ListTouchResult::None;
|
||||
}
|
||||
|
||||
@@ -44,6 +44,8 @@ class Activity {
|
||||
virtual bool skipLoopDelay() { return false; }
|
||||
virtual bool preventAutoSleep() { return false; }
|
||||
virtual bool isReaderActivity() const { return false; }
|
||||
virtual bool isHomeActivity() const { return false; }
|
||||
virtual bool handleHomeGesture() { return false; }
|
||||
virtual ScreenshotInfo getScreenshotInfo() const { return {}; }
|
||||
|
||||
// Start a new activity without destroying the current one
|
||||
@@ -60,4 +62,16 @@ class Activity {
|
||||
// TODO: remove this in near future
|
||||
void onGoHome(HomeMenuItem item = HomeMenuItem::NONE);
|
||||
void onSelectBook(const std::string& path);
|
||||
|
||||
protected:
|
||||
enum class ListTouchResult : uint8_t {
|
||||
None, // touch did not hit the list
|
||||
Consumed, // touchdown moved the highlight (repaint already requested)
|
||||
Activated // tap landed on a row: selectedIndex is updated, caller activates it
|
||||
};
|
||||
|
||||
// Shared touch handling for selectable list screens: touchdown highlights the
|
||||
// touched row, a tap selects and reports Activated. The caller supplies the
|
||||
// list band and runs its own activate action on Activated.
|
||||
ListTouchResult handleListTouch(int& selectedIndex, int itemCount, int listTop, int listHeight, bool hasSubtitle);
|
||||
};
|
||||
|
||||
@@ -22,12 +22,17 @@
|
||||
static portMUX_TYPE activityManagerSpinlock = portMUX_INITIALIZER_UNLOCKED;
|
||||
|
||||
void ActivityManager::begin() {
|
||||
#if defined(configNUM_CORES) && configNUM_CORES > 1
|
||||
constexpr BaseType_t renderTaskCore = 1;
|
||||
#else
|
||||
constexpr BaseType_t renderTaskCore = 0;
|
||||
#endif
|
||||
xTaskCreatePinnedToCore(&renderTaskTrampoline, "ActivityManagerRender",
|
||||
8192, // Stack size
|
||||
this, // Parameters
|
||||
1, // Priority
|
||||
&renderTaskHandle, // Task handle
|
||||
0 // Pin to core 0 (PRO_CPU)
|
||||
renderTaskCore // Keep long renders/cover decodes off CPU 0's idle watchdog when available
|
||||
);
|
||||
assert(renderTaskHandle != nullptr && "Failed to create render task");
|
||||
}
|
||||
@@ -61,6 +66,14 @@ void ActivityManager::renderTaskLoop() {
|
||||
|
||||
void ActivityManager::loop() {
|
||||
if (currentActivity) {
|
||||
if (!currentActivity->isHomeActivity() && mappedInput.wasHomeGesture()) {
|
||||
if (currentActivity->handleHomeGesture()) {
|
||||
return;
|
||||
}
|
||||
goHome();
|
||||
return;
|
||||
}
|
||||
|
||||
// Note: do not hold a lock here, the loop() method must be responsible for acquire one if needed
|
||||
currentActivity->loop();
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
#include "activities/network/WifiSelectionActivity.h"
|
||||
#include "activities/util/KeyboardEntryActivity.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "components/icons/search24.h"
|
||||
#include "fontIds.h"
|
||||
#include "network/HttpDownloader.h"
|
||||
#include "util/BookCacheUtils.h"
|
||||
@@ -23,8 +24,22 @@
|
||||
|
||||
namespace {
|
||||
constexpr int PAGE_ITEMS = 23;
|
||||
constexpr int HEADER_Y = 15;
|
||||
constexpr int HEADER_X = 16;
|
||||
constexpr int SEARCH_ICON_SIZE = 24;
|
||||
constexpr int SEARCH_ICON_MARGIN = 14;
|
||||
constexpr int SEARCH_ICON_Y = 15;
|
||||
constexpr int DOWNLOAD_PROGRESS_STEP_PERCENT = 5;
|
||||
constexpr unsigned long DOWNLOAD_PROGRESS_MIN_UPDATE_MS = 5000;
|
||||
|
||||
Rect searchIconRect(const GfxRenderer& renderer) {
|
||||
return Rect{renderer.getScreenWidth() - SEARCH_ICON_SIZE - SEARCH_ICON_MARGIN, SEARCH_ICON_Y, SEARCH_ICON_SIZE + 8,
|
||||
SEARCH_ICON_SIZE + 8};
|
||||
}
|
||||
|
||||
bool contains(const Rect& rect, const int x, const int y) {
|
||||
return x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void OpdsBookBrowserActivity::onEnter() {
|
||||
@@ -72,7 +87,9 @@ void OpdsBookBrowserActivity::loop() {
|
||||
}
|
||||
|
||||
if (state == BrowserState::ERROR) {
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
int tx = 0;
|
||||
int ty = 0;
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm) || mappedInput.wasScreenTapped(tx, ty)) {
|
||||
if (WiFi.status() == WL_CONNECTED && WiFi.localIP() != IPAddress(0, 0, 0, 0)) {
|
||||
state = BrowserState::LOADING;
|
||||
statusMessage = tr(STR_LOADING);
|
||||
@@ -97,18 +114,59 @@ void OpdsBookBrowserActivity::loop() {
|
||||
if (state == BrowserState::DOWNLOADING) return;
|
||||
|
||||
if (state == BrowserState::BROWSING) {
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
auto activateSelected = [this] {
|
||||
if (!entries.empty()) {
|
||||
const auto& entry = entries[selectorIndex];
|
||||
entry.type == OpdsEntryType::BOOK ? downloadBook(entry) : navigateToEntry(entry);
|
||||
}
|
||||
};
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
activateSelected();
|
||||
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
navigateBack();
|
||||
} else if (mappedInput.wasReleased(MappedInputManager::Button::Left)) {
|
||||
if (!searchTemplate.empty() && selectorIndex == 0) launchSearch();
|
||||
}
|
||||
|
||||
int tx = 0;
|
||||
int ty = 0;
|
||||
if (!searchTemplate.empty() && mappedInput.wasScreenTapped(tx, ty) && contains(searchIconRect(renderer), tx, ty)) {
|
||||
launchSearch();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!entries.empty()) {
|
||||
int row = -1;
|
||||
const auto touch = mappedInput.rowTouch(row, /*top=*/60, /*rowStep=*/30, PAGE_ITEMS);
|
||||
if (touch != MappedInputManager::RowTouch::None) {
|
||||
const int touched = selectorIndex / PAGE_ITEMS * PAGE_ITEMS + row;
|
||||
if (touched >= 0 && touched < static_cast<int>(entries.size())) {
|
||||
if (touch == MappedInputManager::RowTouch::Down) {
|
||||
if (selectorIndex != touched) {
|
||||
selectorIndex = touched;
|
||||
requestUpdate();
|
||||
}
|
||||
} else {
|
||||
selectorIndex = touched;
|
||||
activateSelected();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const auto swipe = mappedInput.wasSwipe();
|
||||
if (swipe == MappedInputManager::SwipeDir::Up) {
|
||||
selectorIndex = ButtonNavigator::nextPageIndex(selectorIndex, entries.size(), PAGE_ITEMS);
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
if (swipe == MappedInputManager::SwipeDir::Down) {
|
||||
selectorIndex = ButtonNavigator::previousPageIndex(selectorIndex, entries.size(), PAGE_ITEMS);
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
buttonNavigator.onNextRelease([this] {
|
||||
selectorIndex = ButtonNavigator::nextIndex(selectorIndex, entries.size());
|
||||
requestUpdate();
|
||||
@@ -136,7 +194,14 @@ void OpdsBookBrowserActivity::render(RenderLock&&) {
|
||||
|
||||
// Show server name in header if available, otherwise generic title
|
||||
const char* headerTitle = server.name.empty() ? tr(STR_OPDS_BROWSER) : server.name.c_str();
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, 15, headerTitle, true, EpdFontFamily::BOLD);
|
||||
const int headerRightInset = searchTemplate.empty() ? HEADER_X : (SEARCH_ICON_SIZE + SEARCH_ICON_MARGIN * 2 + 8);
|
||||
const auto clippedHeader =
|
||||
renderer.truncatedText(UI_12_FONT_ID, headerTitle, pageWidth - HEADER_X - headerRightInset, EpdFontFamily::BOLD);
|
||||
renderer.drawText(UI_12_FONT_ID, HEADER_X, HEADER_Y, clippedHeader.c_str(), true, EpdFontFamily::BOLD);
|
||||
if (!searchTemplate.empty()) {
|
||||
const auto rect = searchIconRect(renderer);
|
||||
renderer.drawIcon(Search24Icon.bits, rect.x + 4, rect.y + 4, Search24Icon.w);
|
||||
}
|
||||
|
||||
if (state == BrowserState::CHECK_WIFI || state == BrowserState::LOADING) {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, statusMessage.c_str());
|
||||
@@ -149,6 +214,9 @@ void OpdsBookBrowserActivity::render(RenderLock&&) {
|
||||
if (state == BrowserState::ERROR) {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 20, tr(STR_ERROR_MSG));
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 10, errorMessage.c_str());
|
||||
if (mappedInput.hasTouch()) {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 40, tr(STR_TAP_TO_RETRY));
|
||||
}
|
||||
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_RETRY), "", "");
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
renderer.displayBuffer();
|
||||
|
||||
@@ -20,7 +20,9 @@ void CrashActivity::onEnter() {
|
||||
}
|
||||
|
||||
void CrashActivity::loop() {
|
||||
if (mappedInput.isPressed(MappedInputManager::Button::Back)) {
|
||||
int x = 0;
|
||||
int y = 0;
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back) || mappedInput.wasScreenTapped(x, y)) {
|
||||
finish();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,8 +205,12 @@ void FileBrowserActivity::loop() {
|
||||
|
||||
const int pathReserved = renderer.getLineHeight(SMALL_FONT_ID) + UITheme::getInstance().getMetrics().verticalSpacing;
|
||||
const int pageItems = UITheme::getNumberOfItemsPerPage(renderer, true, false, true, false, pathReserved);
|
||||
const auto& metrics = UITheme::getInstance().getMetrics();
|
||||
const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
|
||||
const int contentHeight =
|
||||
renderer.getScreenHeight() - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing - pathReserved;
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
auto activateSelected = [this] {
|
||||
if (lockNextConfirmRelease) {
|
||||
lockNextConfirmRelease = false;
|
||||
return;
|
||||
@@ -234,6 +238,11 @@ void FileBrowserActivity::loop() {
|
||||
const std::string fullPath = cleanBasePath + entry;
|
||||
|
||||
auto handler = [this, fullPath](const ActivityResult& res) {
|
||||
// The confirmation popup acts on button press; if that button is still
|
||||
// held when we resume, swallow its release so it doesn't also act here
|
||||
// (Back would go up a directory, Confirm would open the selection).
|
||||
lockLongPressBack = mappedInput.isPressed(MappedInputManager::Button::Back);
|
||||
lockNextConfirmRelease = mappedInput.isPressed(MappedInputManager::Button::Confirm);
|
||||
if (!res.isCancelled) {
|
||||
LOG_DBG("FileBrowser", "Attempting to delete: %s", fullPath.c_str());
|
||||
if (removeDirFile(fullPath)) {
|
||||
@@ -273,6 +282,19 @@ void FileBrowserActivity::loop() {
|
||||
}
|
||||
}
|
||||
return;
|
||||
};
|
||||
|
||||
int touchSel = static_cast<int>(selectorIndex);
|
||||
const auto listTouch = handleListTouch(touchSel, static_cast<int>(files.size()), contentTop, contentHeight, false);
|
||||
if (listTouch != ListTouchResult::None) {
|
||||
selectorIndex = static_cast<size_t>(touchSel);
|
||||
if (listTouch == ListTouchResult::Activated) activateSelected();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
activateSelected();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
@@ -303,6 +325,18 @@ void FileBrowserActivity::loop() {
|
||||
}
|
||||
|
||||
int listSize = static_cast<int>(files.size());
|
||||
const auto swipe = mappedInput.wasSwipe();
|
||||
if (swipe == MappedInputManager::SwipeDir::Up) {
|
||||
selectorIndex = ButtonNavigator::nextPageIndex(static_cast<int>(selectorIndex), listSize, pageItems);
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
if (swipe == MappedInputManager::SwipeDir::Down) {
|
||||
selectorIndex = ButtonNavigator::previousPageIndex(static_cast<int>(selectorIndex), listSize, pageItems);
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
buttonNavigator.onNextRelease([this, listSize] {
|
||||
selectorIndex = ButtonNavigator::nextIndex(static_cast<int>(selectorIndex), listSize);
|
||||
requestUpdate();
|
||||
|
||||
@@ -168,6 +168,34 @@ void HomeActivity::freeCoverBuffer() {
|
||||
|
||||
void HomeActivity::loop() {
|
||||
const int menuCount = getMenuItemCount();
|
||||
const auto& metrics = UITheme::getInstance().getMetrics();
|
||||
|
||||
auto activateSelection = [this] {
|
||||
if (selectorIndex < recentBooks.size()) {
|
||||
onSelectBook(recentBooks[selectorIndex].path);
|
||||
return;
|
||||
}
|
||||
const int menuIndex = selectorIndex - static_cast<int>(recentBooks.size());
|
||||
switch (indexToMenuItem(menuIndex, hasOpdsServers)) {
|
||||
case HomeMenuItem::FILE_BROWSER:
|
||||
onFileBrowserOpen();
|
||||
break;
|
||||
case HomeMenuItem::RECENTS:
|
||||
onRecentsOpen();
|
||||
break;
|
||||
case HomeMenuItem::OPDS_BROWSER:
|
||||
onOpdsBrowserOpen();
|
||||
break;
|
||||
case HomeMenuItem::FILE_TRANSFER:
|
||||
onFileTransferOpen();
|
||||
break;
|
||||
case HomeMenuItem::SETTINGS_MENU:
|
||||
onSettingsOpen();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
buttonNavigator.onNext([this, menuCount] {
|
||||
selectorIndex = ButtonNavigator::nextIndex(selectorIndex, menuCount);
|
||||
@@ -179,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<int>(recentBooks.size());
|
||||
switch (indexToMenuItem(menuIndex, hasOpdsServers)) {
|
||||
case HomeMenuItem::FILE_BROWSER:
|
||||
onFileBrowserOpen();
|
||||
break;
|
||||
case HomeMenuItem::RECENTS:
|
||||
onRecentsOpen();
|
||||
break;
|
||||
case HomeMenuItem::OPDS_BROWSER:
|
||||
onOpdsBrowserOpen();
|
||||
break;
|
||||
case HomeMenuItem::FILE_TRANSFER:
|
||||
onFileTransferOpen();
|
||||
break;
|
||||
case HomeMenuItem::SETTINGS_MENU:
|
||||
onSettingsOpen();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
int tx = 0;
|
||||
int ty = 0;
|
||||
if (!recentBooks.empty() && mappedInput.wasScreenTouchDown(tx, ty) && tx >= 0 && tx < renderer.getScreenWidth() &&
|
||||
ty >= metrics.homeTopPadding && ty < metrics.homeTopPadding + metrics.homeCoverTileHeight) {
|
||||
if (selectorIndex != 0) {
|
||||
selectorIndex = 0;
|
||||
requestUpdate();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!recentBooks.empty() &&
|
||||
mappedInput.wasTapInRect(0, metrics.homeTopPadding, renderer.getScreenWidth(), metrics.homeCoverTileHeight)) {
|
||||
selectorIndex = 0;
|
||||
activateSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
const int menuTop = metrics.homeTopPadding + metrics.homeCoverTileHeight + metrics.homeMenuTopOffset;
|
||||
const int renderedMenuSelection =
|
||||
metrics.homeContinueReadingInMenu ? selectorIndex : selectorIndex - recentBooks.size();
|
||||
const int renderedMenuCount =
|
||||
menuCount - (metrics.homeContinueReadingInMenu ? 0 : static_cast<int>(recentBooks.size()));
|
||||
int menuRow = -1;
|
||||
const auto menuTouch = mappedInput.rowTouch(menuRow, menuTop, metrics.menuRowHeight + metrics.menuSpacing,
|
||||
renderedMenuCount, 0, INT32_MAX, metrics.menuRowHeight);
|
||||
if (menuTouch != MappedInputManager::RowTouch::None) {
|
||||
const int touchedIndex =
|
||||
metrics.homeContinueReadingInMenu ? menuRow : menuRow + static_cast<int>(recentBooks.size());
|
||||
if (menuTouch == MappedInputManager::RowTouch::Down) {
|
||||
if (selectorIndex != touchedIndex) {
|
||||
selectorIndex = touchedIndex;
|
||||
requestUpdate();
|
||||
}
|
||||
} else {
|
||||
selectorIndex = touchedIndex;
|
||||
activateSelection();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
activateSelection();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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; }
|
||||
};
|
||||
|
||||
@@ -43,6 +43,10 @@ void RecentBooksActivity::onExit() {
|
||||
|
||||
void RecentBooksActivity::loop() {
|
||||
const int pageItems = UITheme::getInstance().getNumberOfItemsPerPage(renderer, true, false, true, true);
|
||||
const auto& metrics = UITheme::getInstance().getMetrics();
|
||||
const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
|
||||
const int contentHeight =
|
||||
renderer.getScreenHeight() - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing;
|
||||
|
||||
// After a long-press has fired, swallow input until Confirm is physically released
|
||||
// (so the release doesn't also open the book; re-arm only once the button is up).
|
||||
@@ -71,11 +75,34 @@ void RecentBooksActivity::loop() {
|
||||
}
|
||||
}
|
||||
|
||||
int touchSel = static_cast<int>(selectorIndex);
|
||||
const auto listTouch =
|
||||
handleListTouch(touchSel, static_cast<int>(recentBooks.size()), contentTop, contentHeight, true);
|
||||
if (listTouch != ListTouchResult::None) {
|
||||
selectorIndex = static_cast<size_t>(touchSel);
|
||||
if (listTouch == ListTouchResult::Activated) {
|
||||
LOG_DBG("RBA", "Tapped recent book: %s", recentBooks[selectorIndex].path.c_str());
|
||||
onSelectBook(recentBooks[selectorIndex].path);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
onGoHome();
|
||||
}
|
||||
|
||||
int listSize = static_cast<int>(recentBooks.size());
|
||||
const auto swipe = mappedInput.wasSwipe();
|
||||
if (swipe == MappedInputManager::SwipeDir::Up) {
|
||||
selectorIndex = ButtonNavigator::nextPageIndex(static_cast<int>(selectorIndex), listSize, pageItems);
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
if (swipe == MappedInputManager::SwipeDir::Down) {
|
||||
selectorIndex = ButtonNavigator::previousPageIndex(static_cast<int>(selectorIndex), listSize, pageItems);
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
buttonNavigator.onNextRelease([this, listSize] {
|
||||
selectorIndex = ButtonNavigator::nextIndex(static_cast<int>(selectorIndex), listSize);
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
#include <GfxRenderer.h>
|
||||
#include <I18n.h>
|
||||
#include <WiFi.h>
|
||||
#include <esp_task_wdt.h>
|
||||
|
||||
#include "MappedInputManager.h"
|
||||
#include "SilentRestart.h"
|
||||
#include "WifiSelectionActivity.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
#include "util/TaskWatchdog.h"
|
||||
|
||||
namespace {
|
||||
constexpr const char* HOSTNAME = "crosspoint";
|
||||
@@ -110,12 +110,12 @@ void CalibreConnectActivity::loop() {
|
||||
LOG_DBG("CAL", "WARNING: %lu ms gap since last handleClient", timeSinceLastHandleClient);
|
||||
}
|
||||
|
||||
esp_task_wdt_reset();
|
||||
resetTaskWatchdogIfSubscribed();
|
||||
constexpr int MAX_ITERATIONS = 80;
|
||||
for (int i = 0; i < MAX_ITERATIONS && webServer->isRunning(); i++) {
|
||||
webServer->handleClient();
|
||||
if ((i & 0x07) == 0x07) {
|
||||
esp_task_wdt_reset();
|
||||
resetTaskWatchdogIfSubscribed();
|
||||
}
|
||||
if ((i & 0x0F) == 0x0F) {
|
||||
yield();
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
#include <GfxRenderer.h>
|
||||
#include <I18n.h>
|
||||
#include <WiFi.h>
|
||||
#include <esp_task_wdt.h>
|
||||
|
||||
#include <cstddef>
|
||||
|
||||
@@ -17,6 +16,7 @@
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
#include "util/QrUtils.h"
|
||||
#include "util/TaskWatchdog.h"
|
||||
|
||||
namespace {
|
||||
// AP Mode configuration
|
||||
@@ -328,7 +328,7 @@ void CrossPointWebServerActivity::loop() {
|
||||
}
|
||||
|
||||
// Reset watchdog BEFORE processing - HTTP header parsing can be slow
|
||||
esp_task_wdt_reset();
|
||||
resetTaskWatchdogIfSubscribed();
|
||||
|
||||
// Process HTTP requests in tight loop for maximum throughput
|
||||
// More iterations = more data processed per main loop cycle
|
||||
@@ -337,7 +337,7 @@ void CrossPointWebServerActivity::loop() {
|
||||
webServer->handleClient();
|
||||
// Reset watchdog every 32 iterations
|
||||
if ((i & 0x1F) == 0x1F) {
|
||||
esp_task_wdt_reset();
|
||||
resetTaskWatchdogIfSubscribed();
|
||||
}
|
||||
// Yield and check for exit button every 64 iterations
|
||||
if ((i & 0x3F) == 0x3F) {
|
||||
|
||||
@@ -24,6 +24,16 @@ void NetworkModeSelectionActivity::onEnter() {
|
||||
void NetworkModeSelectionActivity::onExit() { Activity::onExit(); }
|
||||
|
||||
void NetworkModeSelectionActivity::loop() {
|
||||
auto selectCurrent = [this] {
|
||||
NetworkMode mode = NetworkMode::JOIN_NETWORK;
|
||||
if (selectedIndex == 1) {
|
||||
mode = NetworkMode::CONNECT_CALIBRE;
|
||||
} else if (selectedIndex == 2) {
|
||||
mode = NetworkMode::CREATE_HOTSPOT;
|
||||
}
|
||||
onModeSelected(mode);
|
||||
};
|
||||
|
||||
// Handle back button - cancel
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
|
||||
onCancel();
|
||||
@@ -32,16 +42,24 @@ void NetworkModeSelectionActivity::loop() {
|
||||
|
||||
// Handle confirm button - select current option
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
|
||||
NetworkMode mode = NetworkMode::JOIN_NETWORK;
|
||||
if (selectedIndex == 1) {
|
||||
mode = NetworkMode::CONNECT_CALIBRE;
|
||||
} else if (selectedIndex == 2) {
|
||||
mode = NetworkMode::CREATE_HOTSPOT;
|
||||
}
|
||||
onModeSelected(mode);
|
||||
selectCurrent();
|
||||
return;
|
||||
}
|
||||
|
||||
const auto& metrics = UITheme::getInstance().getMetrics();
|
||||
const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
|
||||
const int contentHeight =
|
||||
renderer.getScreenHeight() - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing * 2;
|
||||
switch (handleListTouch(selectedIndex, MENU_ITEM_COUNT, contentTop, contentHeight, true)) {
|
||||
case ListTouchResult::Activated:
|
||||
selectCurrent();
|
||||
return;
|
||||
case ListTouchResult::Consumed:
|
||||
return;
|
||||
case ListTouchResult::None:
|
||||
break;
|
||||
}
|
||||
|
||||
// Handle navigation
|
||||
buttonNavigator.onNext([this] {
|
||||
selectedIndex = ButtonNavigator::nextIndex(selectedIndex, MENU_ITEM_COUNT);
|
||||
|
||||
@@ -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<int>(selectedNetworkIndex);
|
||||
const auto listTouch =
|
||||
handleListTouch(touchSel, static_cast<int>(networks.size()), contentTop, contentHeight, false);
|
||||
if (listTouch != ListTouchResult::None) {
|
||||
selectedNetworkIndex = static_cast<size_t>(touchSel);
|
||||
if (listTouch == ListTouchResult::Activated) selectNetwork(selectedNetworkIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
const int pageItems = GUI.getListPageItems(contentHeight, false);
|
||||
const auto swipe = mappedInput.wasSwipe();
|
||||
if (swipe == MappedInputManager::SwipeDir::Up) {
|
||||
selectedNetworkIndex = ButtonNavigator::nextPageIndex(selectedNetworkIndex, networks.size(), pageItems);
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
if (swipe == MappedInputManager::SwipeDir::Down) {
|
||||
selectedNetworkIndex = ButtonNavigator::previousPageIndex(selectedNetworkIndex, networks.size(), pageItems);
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle navigation
|
||||
buttonNavigator.onNext([this] {
|
||||
selectedNetworkIndex = ButtonNavigator::nextIndex(selectedNetworkIndex, networks.size());
|
||||
|
||||
@@ -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++;
|
||||
|
||||
@@ -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<int>(words.size()); i++) {
|
||||
const WordBox& word = words[i];
|
||||
if (x >= word.x - SLOP && x < word.x + word.width + SLOP && y >= word.y - SLOP && y < word.y + lineHeight + SLOP) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Index of the word in `row` whose horizontal center is closest to centerX;
|
||||
// -1 when the row has no words.
|
||||
int DictionaryWordSelectActivity::closestInRow(const uint16_t row, const int centerX) const {
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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> 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> page, const int or
|
||||
// regardless of residue.
|
||||
pagesUntilFullRefresh = 1;
|
||||
} else {
|
||||
ReaderUtils::displayWithRefreshCycle(renderer, pagesUntilFullRefresh);
|
||||
// Deferred when a tiled grayscale pass follows: the plane rendering below
|
||||
// then overlaps the panel's refresh time instead of following it.
|
||||
ReaderUtils::displayWithRefreshCycle(renderer, pagesUntilFullRefresh, /*async=*/overlapRefresh);
|
||||
}
|
||||
const auto tDisplay = millis();
|
||||
|
||||
// Tiled grayscale: render each plane band-by-band into a small scratch and
|
||||
// stream straight to the controller, leaving the BW framebuffer intact so no
|
||||
// full-frame storeBwBuffer is needed; controller RAM is re-synced from the
|
||||
// live framebuffer afterward. The page is re-rendered ceil(H/STRIP_ROWS) times
|
||||
// per plane, but renderCharImpl culls out-of-band glyphs before decode so the
|
||||
// cost stays close to one render. Both text (drawPixel) and images
|
||||
// (DirectPixelWriter) honor the active strip target.
|
||||
if (needsAnyGrayscale && renderer.supportsStripGrayscale()) {
|
||||
// Tiled grayscale: render each plane band-by-band, leaving the BW
|
||||
// framebuffer intact so no full-frame storeBwBuffer is needed; controller
|
||||
// RAM is re-synced from the live framebuffer afterward. The page is
|
||||
// re-rendered ceil(H/STRIP_ROWS) times per plane, but renderCharImpl culls
|
||||
// out-of-band glyphs before decode so the cost stays close to one render.
|
||||
// Both text (drawPixel) and images (DirectPixelWriter) honor the active
|
||||
// strip target. When the BW refresh above went out async, the plane
|
||||
// rendering below overlaps the panel's refresh time; only the controller
|
||||
// RAM writes wait for BUSY.
|
||||
if (tiledGrayscale) {
|
||||
constexpr int STRIP_ROWS = 80;
|
||||
const int gh = renderer.getDisplayHeight();
|
||||
const int gwBytes = renderer.getDisplayWidthBytes();
|
||||
const size_t planeBytes = static_cast<size_t>(gwBytes) * gh;
|
||||
|
||||
auto scratch = makeUniqueNoThrow<uint8_t[]>(static_cast<size_t>(gwBytes) * STRIP_ROWS);
|
||||
if (!scratch) {
|
||||
LOG_ERR("ERS", "OOM: grayscale strip scratch (%d bytes); skipping AA this page", gwBytes * STRIP_ROWS);
|
||||
} else {
|
||||
// Bands may be streamed in any order: X4 windows each via setRamArea, X3
|
||||
// via PTL.
|
||||
renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB);
|
||||
// Render one plane band-by-band into a whole-plane buffer without touching
|
||||
// the controller, so it can run while the refresh is still in flight.
|
||||
auto renderPlaneToBuffer = [&](const bool lsbPlane, uint8_t* buf) {
|
||||
renderer.setRenderMode(lsbPlane ? GfxRenderer::GRAYSCALE_LSB : GfxRenderer::GRAYSCALE_MSB);
|
||||
for (int y = 0; y < gh; y += STRIP_ROWS) {
|
||||
const int rows = (gh - y < STRIP_ROWS) ? (gh - y) : STRIP_ROWS;
|
||||
renderer.beginStripTarget(scratch.get(), y, rows);
|
||||
renderer.beginStripTarget(buf + static_cast<size_t>(y) * gwBytes, y, rows);
|
||||
renderer.clearScreen(0x00);
|
||||
renderGrayscalePass();
|
||||
renderer.endStripTarget();
|
||||
renderer.writeGrayscalePlaneStrip(true, scratch.get(), y, rows);
|
||||
}
|
||||
const auto tGrayLsb = millis();
|
||||
};
|
||||
|
||||
// MSB plane.
|
||||
renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB);
|
||||
for (int y = 0; y < gh; y += STRIP_ROWS) {
|
||||
const int rows = (gh - y < STRIP_ROWS) ? (gh - y) : STRIP_ROWS;
|
||||
renderer.beginStripTarget(scratch.get(), y, rows);
|
||||
renderer.clearScreen(0x00);
|
||||
renderGrayscalePass();
|
||||
renderer.endStripTarget();
|
||||
renderer.writeGrayscalePlaneStrip(false, scratch.get(), y, rows);
|
||||
// Tiered on heap pressure: two plane buffers hide both plane renders
|
||||
// inside the refresh wait; one hides the LSB render (its buffer is reused
|
||||
// for MSB after streaming); none falls back to the strip-scratch flow with
|
||||
// no overlap. The MSB buffer is only attempted when it leaves ~60 KB free
|
||||
// so the pass never starves concurrent allocations. Blocking panels skip
|
||||
// the buffers entirely (nothing to overlap).
|
||||
auto lsbPlaneBuf = overlapRefresh ? makeUniqueNoThrow<uint8_t[]>(planeBytes) : nullptr;
|
||||
auto msbPlaneBuf =
|
||||
(lsbPlaneBuf && ESP.getFreeHeap() >= planeBytes + 60000) ? makeUniqueNoThrow<uint8_t[]>(planeBytes) : nullptr;
|
||||
|
||||
if (lsbPlaneBuf) {
|
||||
renderPlaneToBuffer(true, lsbPlaneBuf.get());
|
||||
if (msbPlaneBuf) renderPlaneToBuffer(false, msbPlaneBuf.get());
|
||||
const auto tGrayRender = millis();
|
||||
|
||||
renderer.waitRefreshComplete();
|
||||
const auto tWait = millis();
|
||||
|
||||
renderer.writeGrayscalePlaneStrip(true, lsbPlaneBuf.get(), 0, gh);
|
||||
if (msbPlaneBuf) {
|
||||
renderer.writeGrayscalePlaneStrip(false, msbPlaneBuf.get(), 0, gh);
|
||||
} else {
|
||||
renderPlaneToBuffer(false, lsbPlaneBuf.get());
|
||||
renderer.writeGrayscalePlaneStrip(false, lsbPlaneBuf.get(), 0, gh);
|
||||
}
|
||||
const auto tGrayMsb = millis();
|
||||
const auto tGrayWrite = millis();
|
||||
|
||||
renderer.setRenderMode(GfxRenderer::BW);
|
||||
renderer.displayGrayBuffer();
|
||||
@@ -1488,14 +1514,63 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
|
||||
// BW framebuffer is intact; re-sync controller RAM for the next
|
||||
// differential page turn directly from it.
|
||||
renderer.cleanupGrayscaleWithFrameBuffer();
|
||||
const auto tCleanup = millis();
|
||||
|
||||
const auto tEnd = millis();
|
||||
|
||||
LOG_DBG("ERS",
|
||||
"Page render (tiled): prewarm=%lums bw_render=%lums display=%lums gray_lsb=%lums "
|
||||
"gray_msb=%lums gray_display=%lums cleanup=%lums total=%lums",
|
||||
tPrewarm - t0, tBwRender - tPrewarm, tDisplay - tBwRender, tGrayLsb - tDisplay, tGrayMsb - tGrayLsb,
|
||||
tGrayDisplay - tGrayMsb, tCleanup - tGrayDisplay, tEnd - t0);
|
||||
"Page render (tiled async): prewarm=%lums bw_render=%lums display=%lums gray_render=%lums "
|
||||
"wait=%lums gray_write=%lums gray_display=%lums cleanup=%lums total=%lums (planes buffered: %d)",
|
||||
tPrewarm - t0, tBwRender - tPrewarm, tDisplay - tBwRender, tGrayRender - tDisplay, tWait - tGrayRender,
|
||||
tGrayWrite - tWait, tGrayDisplay - tGrayWrite, tEnd - tGrayDisplay, tEnd - t0, msbPlaneBuf ? 2 : 1);
|
||||
} else {
|
||||
// Per-strip scratch tier: blocking panels and the OOM fallback. The
|
||||
// strip writes below need the panel idle, so wait out any pending async
|
||||
// refresh first (no-op on blocking panels).
|
||||
auto scratch = makeUniqueNoThrow<uint8_t[]>(static_cast<size_t>(gwBytes) * STRIP_ROWS);
|
||||
renderer.waitRefreshComplete();
|
||||
if (!scratch) {
|
||||
LOG_ERR("ERS", "OOM: grayscale strip scratch (%d bytes); skipping AA this page", gwBytes * STRIP_ROWS);
|
||||
} else {
|
||||
// Bands may be streamed in any order: X4 windows each via setRamArea,
|
||||
// X3 via PTL.
|
||||
renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB);
|
||||
for (int y = 0; y < gh; y += STRIP_ROWS) {
|
||||
const int rows = (gh - y < STRIP_ROWS) ? (gh - y) : STRIP_ROWS;
|
||||
renderer.beginStripTarget(scratch.get(), y, rows);
|
||||
renderer.clearScreen(0x00);
|
||||
renderGrayscalePass();
|
||||
renderer.endStripTarget();
|
||||
renderer.writeGrayscalePlaneStrip(true, scratch.get(), y, rows);
|
||||
}
|
||||
const auto tGrayLsb = millis();
|
||||
|
||||
// MSB plane.
|
||||
renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB);
|
||||
for (int y = 0; y < gh; y += STRIP_ROWS) {
|
||||
const int rows = (gh - y < STRIP_ROWS) ? (gh - y) : STRIP_ROWS;
|
||||
renderer.beginStripTarget(scratch.get(), y, rows);
|
||||
renderer.clearScreen(0x00);
|
||||
renderGrayscalePass();
|
||||
renderer.endStripTarget();
|
||||
renderer.writeGrayscalePlaneStrip(false, scratch.get(), y, rows);
|
||||
}
|
||||
const auto tGrayMsb = millis();
|
||||
|
||||
renderer.setRenderMode(GfxRenderer::BW);
|
||||
renderer.displayGrayBuffer();
|
||||
const auto tGrayDisplay = millis();
|
||||
|
||||
// BW framebuffer is intact; re-sync controller RAM for the next
|
||||
// differential page turn directly from it.
|
||||
renderer.cleanupGrayscaleWithFrameBuffer();
|
||||
const auto tCleanup = millis();
|
||||
|
||||
const auto tEnd = millis();
|
||||
LOG_DBG("ERS",
|
||||
"Page render (tiled): prewarm=%lums bw_render=%lums display=%lums gray_lsb=%lums "
|
||||
"gray_msb=%lums gray_display=%lums cleanup=%lums total=%lums",
|
||||
tPrewarm - t0, tBwRender - tPrewarm, tDisplay - tBwRender, tGrayLsb - tDisplay, tGrayMsb - tGrayLsb,
|
||||
tGrayDisplay - tGrayMsb, tCleanup - tGrayDisplay, tEnd - t0);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback path for a controller without strip support. grayscale rendering
|
||||
|
||||
@@ -14,9 +14,6 @@
|
||||
|
||||
namespace {
|
||||
constexpr int ENTER_DELETE_MODE_MS = 700;
|
||||
constexpr int DELETE_MODE_OFF = 0;
|
||||
constexpr int DELETE_MODE_DISPLAY = 1;
|
||||
constexpr int DELETE_MODE_CONFIRM = 2;
|
||||
|
||||
// Layout constants used in renderScreen
|
||||
constexpr int LINE_HEIGHT = 60;
|
||||
@@ -64,45 +61,7 @@ int EpubReaderBookmarksActivity::getListHeight(const GfxRenderer& renderer) {
|
||||
}
|
||||
|
||||
void EpubReaderBookmarksActivity::loop() {
|
||||
// Delete confirmation mode
|
||||
if (confirmingDelete >= DELETE_MODE_DISPLAY) {
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
if (confirmingDelete == DELETE_MODE_DISPLAY) {
|
||||
confirmingDelete = DELETE_MODE_CONFIRM; // first confirmation, update text
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
bookmarks.erase(bookmarks.begin() + selectorIndex);
|
||||
const std::string path = BookmarkUtil::getBookmarkPath(epubPath);
|
||||
Storage.mkdir(BookmarkUtil::getBookmarksDir().c_str());
|
||||
if (!JsonSettingsIO::saveBookmarks(bookmarks, path.c_str())) {
|
||||
LOG_ERR("EPB", "Failed to save bookmarks after delete");
|
||||
}
|
||||
|
||||
// Move selector up if we deleted the last item
|
||||
if (selectorIndex >= bookmarks.size() && selectorIndex > 0) {
|
||||
selectorIndex--;
|
||||
}
|
||||
|
||||
if (bookmarks.empty()) {
|
||||
ActivityResult result;
|
||||
result.isCancelled = true;
|
||||
setResult(std::move(result));
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
requestUpdate();
|
||||
confirmingDelete = DELETE_MODE_OFF;
|
||||
return;
|
||||
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
requestUpdate();
|
||||
confirmingDelete = DELETE_MODE_OFF;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { // Open
|
||||
auto openBookmark = [this] {
|
||||
if (bookmarks.empty()) {
|
||||
return;
|
||||
}
|
||||
@@ -119,8 +78,18 @@ void EpubReaderBookmarksActivity::loop() {
|
||||
}
|
||||
setResult(std::move(result));
|
||||
finish();
|
||||
};
|
||||
|
||||
// Delete confirmation popup
|
||||
if (confirmPopup.handleInput(mappedInput, [this] { requestUpdate(); })) return;
|
||||
if (confirmingDelete) {
|
||||
// Popup dismissed without a selection (Back button or tap outside): cancel delete
|
||||
confirmingDelete = false;
|
||||
requestUpdate();
|
||||
return;
|
||||
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
}
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
ActivityResult result;
|
||||
result.isCancelled = true;
|
||||
setResult(std::move(result));
|
||||
@@ -128,11 +97,68 @@ void EpubReaderBookmarksActivity::loop() {
|
||||
return;
|
||||
}
|
||||
|
||||
const auto orientation = renderer.getOrientation();
|
||||
const bool isPortraitInverted = orientation == GfxRenderer::Orientation::PortraitInverted;
|
||||
const bool isLandscapeCw = orientation == GfxRenderer::Orientation::LandscapeClockwise;
|
||||
const bool isLandscapeCcw = orientation == GfxRenderer::Orientation::LandscapeCounterClockwise;
|
||||
const int hintGutterWidth = (isLandscapeCw || isLandscapeCcw) ? 40 : 0;
|
||||
const int contentX = isLandscapeCw ? hintGutterWidth : 0;
|
||||
const int contentWidth = renderer.getScreenWidth() - hintGutterWidth;
|
||||
const int contentY = isPortraitInverted ? 50 : 0;
|
||||
const int listY = contentY + LINE_HEIGHT;
|
||||
const int listHeight = getListHeight(renderer);
|
||||
int tapped = 0;
|
||||
int tx = 0;
|
||||
int ty = 0;
|
||||
if (mappedInput.wasScreenTouchDown(tx, ty) && tx >= contentX && tx < contentX + contentWidth &&
|
||||
mappedInput.wasListItemTouchedDown(tapped, static_cast<int>(bookmarks.size()), selectorIndex, listY, listHeight,
|
||||
true)) {
|
||||
if (selectorIndex != tapped) {
|
||||
selectorIndex = tapped;
|
||||
requestUpdate();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (mappedInput.wasScreenTapped(tx, ty) && tx >= contentX && tx < contentX + contentWidth &&
|
||||
mappedInput.wasListItemTapped(tapped, static_cast<int>(bookmarks.size()), selectorIndex, listY, listHeight,
|
||||
true)) {
|
||||
selectorIndex = tapped;
|
||||
openBookmark();
|
||||
return;
|
||||
}
|
||||
|
||||
const auto swipe = mappedInput.wasSwipe();
|
||||
if (swipe == MappedInputManager::SwipeDir::Up && !bookmarks.empty()) {
|
||||
selectorIndex =
|
||||
ButtonNavigator::nextPageIndex(selectorIndex, bookmarks.size(), GUI.getListPageItems(listHeight, true));
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
if (swipe == MappedInputManager::SwipeDir::Down && !bookmarks.empty()) {
|
||||
selectorIndex =
|
||||
ButtonNavigator::previousPageIndex(selectorIndex, bookmarks.size(), GUI.getListPageItems(listHeight, true));
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { // Open
|
||||
openBookmark();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.isPressed(MappedInputManager::Button::Confirm) && mappedInput.getHeldTime() > ENTER_DELETE_MODE_MS) {
|
||||
if (bookmarks.empty()) {
|
||||
return;
|
||||
}
|
||||
confirmingDelete = DELETE_MODE_DISPLAY;
|
||||
confirmingDelete = true;
|
||||
const char* options[] = {tr(STR_CANCEL), tr(STR_DELETE)};
|
||||
confirmPopup.show(tr(STR_CONFIRM_DELETE_BOOKMARK), options, 2, 0, [this](int idx) {
|
||||
confirmingDelete = false;
|
||||
if (idx == 1) {
|
||||
deleteSelectedBookmark();
|
||||
}
|
||||
requestUpdate();
|
||||
});
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
@@ -159,6 +185,27 @@ void EpubReaderBookmarksActivity::loop() {
|
||||
});
|
||||
}
|
||||
|
||||
void EpubReaderBookmarksActivity::deleteSelectedBookmark() {
|
||||
bookmarks.erase(bookmarks.begin() + selectorIndex);
|
||||
const std::string path = BookmarkUtil::getBookmarkPath(epubPath);
|
||||
Storage.mkdir(BookmarkUtil::getBookmarksDir().c_str());
|
||||
if (!JsonSettingsIO::saveBookmarks(bookmarks, path.c_str())) {
|
||||
LOG_ERR("EPB", "Failed to save bookmarks after delete");
|
||||
}
|
||||
|
||||
// Move selector up if we deleted the last item
|
||||
if (selectorIndex >= bookmarks.size() && selectorIndex > 0) {
|
||||
selectorIndex--;
|
||||
}
|
||||
|
||||
if (bookmarks.empty()) {
|
||||
ActivityResult result;
|
||||
result.isCancelled = true;
|
||||
setResult(std::move(result));
|
||||
finish();
|
||||
}
|
||||
}
|
||||
|
||||
void EpubReaderBookmarksActivity::render(RenderLock&&) {
|
||||
renderer.clearScreen();
|
||||
|
||||
@@ -188,10 +235,10 @@ void EpubReaderBookmarksActivity::render(RenderLock&&) {
|
||||
renderer.drawText(UI_12_FONT_ID, titleX, 15 + contentY, tr(STR_BOOKMARKS), true, EpdFontFamily::BOLD);
|
||||
|
||||
const auto getBookmarkTitle = [this](int index) {
|
||||
return bookmarks.at(confirmingDelete >= DELETE_MODE_DISPLAY ? selectorIndex : index).summary;
|
||||
return bookmarks.at(confirmingDelete ? selectorIndex : index).summary;
|
||||
};
|
||||
const auto getBookmarkSubtitle = [this](int index) {
|
||||
auto bookmark = bookmarks.at(confirmingDelete >= DELETE_MODE_DISPLAY ? selectorIndex : index);
|
||||
auto bookmark = bookmarks.at(confirmingDelete ? selectorIndex : index);
|
||||
auto tocIndex = epub->getTocIndexForSpineIndex(bookmark.computedSpineIndex);
|
||||
auto tocTitle = (tocIndex >= 0) ? (epub->getTocItem(tocIndex)).title : tr(STR_UNNAMED);
|
||||
std::string subtitle = std::to_string((int)(std::clamp(bookmark.percentage, 0.0f, 1.0f) * 100.0f + 0.5f)) + "% - ";
|
||||
@@ -207,12 +254,9 @@ void EpubReaderBookmarksActivity::render(RenderLock&&) {
|
||||
};
|
||||
|
||||
if (numBookmarks > 0) {
|
||||
if (confirmingDelete >= DELETE_MODE_DISPLAY) {
|
||||
GUI.drawHelpText(renderer, Rect{0, pageHeight / 2 - LINE_HEIGHT * 2, contentWidth, LINE_HEIGHT},
|
||||
tr(STR_CONFIRM_DELETE_BOOKMARK));
|
||||
|
||||
// render list with just the selected item for the user to confirm to delete
|
||||
GUI.drawList(renderer, Rect{contentX, pageHeight / 2, contentWidth, LINE_HEIGHT}, 1, 0, getBookmarkTitle,
|
||||
if (confirmingDelete) {
|
||||
// Render just the selected item near the top; the confirmation popup occupies the center
|
||||
GUI.drawList(renderer, Rect{contentX, listY, contentWidth, LINE_HEIGHT}, 1, 0, getBookmarkTitle,
|
||||
getBookmarkSubtitle, getBookmarkIcon);
|
||||
} else {
|
||||
GUI.drawList(renderer, Rect{contentX, listY, contentWidth, listHeight}, numBookmarks, selectorIndex,
|
||||
@@ -223,10 +267,10 @@ void EpubReaderBookmarksActivity::render(RenderLock&&) {
|
||||
}
|
||||
}
|
||||
|
||||
const auto backLabel = confirmingDelete >= DELETE_MODE_DISPLAY ? tr(STR_CANCEL) : tr(STR_BACK);
|
||||
const auto confirmLabel =
|
||||
bookmarks.size() > 0 ? (confirmingDelete >= DELETE_MODE_DISPLAY ? tr(STR_DELETE) : tr(STR_SELECT)) : "";
|
||||
const auto labels = mappedInput.mapLabels(backLabel, confirmLabel, tr(STR_DIR_UP), tr(STR_DIR_DOWN));
|
||||
if (confirmPopup.processRender(renderer, mappedInput)) return;
|
||||
|
||||
const auto confirmLabel = bookmarks.size() > 0 ? tr(STR_SELECT) : "";
|
||||
const auto labels = mappedInput.mapLabels(tr(STR_BACK), confirmLabel, tr(STR_DIR_UP), tr(STR_DIR_DOWN));
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
|
||||
renderer.displayBuffer();
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
#include "../../BookmarkEntry.h"
|
||||
#include "../Activity.h"
|
||||
#include "components/OptionPopup.h"
|
||||
#include "util/ButtonNavigator.h"
|
||||
|
||||
class EpubReaderBookmarksActivity final : public Activity {
|
||||
@@ -13,7 +14,8 @@ class EpubReaderBookmarksActivity final : public Activity {
|
||||
ButtonNavigator buttonNavigator;
|
||||
int selectorIndex = 0;
|
||||
std::vector<BookmarkEntry> bookmarks;
|
||||
int confirmingDelete = 0; // 0 = hide dialog, 1 = show dialog, 2 = allow confirmation to delete
|
||||
bool confirmingDelete = false;
|
||||
OptionPopup confirmPopup;
|
||||
|
||||
public:
|
||||
explicit EpubReaderBookmarksActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
||||
@@ -30,4 +32,7 @@ class EpubReaderBookmarksActivity final : public Activity {
|
||||
|
||||
// Calculate the height available for the bookmark list based on orientation
|
||||
int getListHeight(const GfxRenderer& renderer);
|
||||
|
||||
// Delete the currently selected bookmark and persist the list
|
||||
void deleteSelectedBookmark();
|
||||
};
|
||||
|
||||
@@ -31,7 +31,15 @@ void EpubReaderChapterSelectionActivity::loop() {
|
||||
const int pageItems = UITheme::getInstance().getNumberOfItemsPerPage(renderer, true, false, true, false);
|
||||
const int totalItems = getTotalItems();
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
ActivityResult result;
|
||||
result.isCancelled = true;
|
||||
setResult(std::move(result));
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
auto selectChapter = [this] {
|
||||
const auto tocItem = epub->getTocItem(selectorIndex);
|
||||
if (tocItem.spineIndex == -1) {
|
||||
ActivityResult result;
|
||||
@@ -42,11 +50,36 @@ void EpubReaderChapterSelectionActivity::loop() {
|
||||
setResult(ChapterResult{tocItem.spineIndex, tocItem.anchor});
|
||||
finish();
|
||||
}
|
||||
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
ActivityResult result;
|
||||
result.isCancelled = true;
|
||||
setResult(std::move(result));
|
||||
finish();
|
||||
};
|
||||
|
||||
auto metrics = UITheme::getInstance().getMetrics();
|
||||
Rect screen = UITheme::getInstance().getScreenSafeArea(renderer, true, false);
|
||||
const int contentTop = screen.y + metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
|
||||
const int contentHeight = screen.height - contentTop - metrics.verticalSpacing;
|
||||
switch (handleListTouch(selectorIndex, totalItems, contentTop, contentHeight, false)) {
|
||||
case ListTouchResult::Activated:
|
||||
selectChapter();
|
||||
return;
|
||||
case ListTouchResult::Consumed:
|
||||
return;
|
||||
case ListTouchResult::None:
|
||||
break;
|
||||
}
|
||||
|
||||
const auto swipe = mappedInput.wasSwipe();
|
||||
if (swipe == MappedInputManager::SwipeDir::Up) {
|
||||
selectorIndex = ButtonNavigator::nextPageIndex(selectorIndex, totalItems, pageItems);
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
if (swipe == MappedInputManager::SwipeDir::Down) {
|
||||
selectorIndex = ButtonNavigator::previousPageIndex(selectorIndex, totalItems, pageItems);
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
selectChapter();
|
||||
}
|
||||
|
||||
buttonNavigator.onNextRelease([this, totalItems] {
|
||||
|
||||
@@ -18,6 +18,13 @@ void EpubReaderFootnotesActivity::onEnter() {
|
||||
void EpubReaderFootnotesActivity::onExit() { Activity::onExit(); }
|
||||
|
||||
void EpubReaderFootnotesActivity::loop() {
|
||||
auto selectFootnote = [this] {
|
||||
if (selectedIndex >= 0 && selectedIndex < static_cast<int>(footnotes.size())) {
|
||||
setResult(FootnoteResult{footnotes[selectedIndex].href});
|
||||
finish();
|
||||
}
|
||||
};
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
ActivityResult result;
|
||||
result.isCancelled = true;
|
||||
@@ -28,13 +35,53 @@ void EpubReaderFootnotesActivity::loop() {
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm) ||
|
||||
mappedInput.wasReleased(MappedInputManager::Button::Power)) {
|
||||
if (selectedIndex >= 0 && selectedIndex < static_cast<int>(footnotes.size())) {
|
||||
setResult(FootnoteResult{footnotes[selectedIndex].href});
|
||||
finish();
|
||||
}
|
||||
selectFootnote();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!footnotes.empty()) {
|
||||
const auto orientation = renderer.getOrientation();
|
||||
const bool isLandscapeCw = orientation == GfxRenderer::Orientation::LandscapeClockwise;
|
||||
const bool isLandscapeCcw = orientation == GfxRenderer::Orientation::LandscapeCounterClockwise;
|
||||
const bool isPortraitInverted = orientation == GfxRenderer::Orientation::PortraitInverted;
|
||||
const int hintGutterWidth = (isLandscapeCw || isLandscapeCcw) ? 30 : 0;
|
||||
const int contentX = isLandscapeCw ? hintGutterWidth : 0;
|
||||
const int contentWidth = renderer.getScreenWidth() - hintGutterWidth;
|
||||
const int contentY = isPortraitInverted ? 50 : 0;
|
||||
constexpr int lineHeight = 36;
|
||||
const int listTop = 60 + contentY;
|
||||
const int visibleCount = std::max(1, (renderer.getScreenHeight() - listTop) / lineHeight);
|
||||
int row = -1;
|
||||
const auto touch = mappedInput.rowTouch(row, listTop, lineHeight, visibleCount, contentX, contentX + contentWidth);
|
||||
if (touch != MappedInputManager::RowTouch::None) {
|
||||
const int touched = scrollOffset + row;
|
||||
if (touched >= 0 && touched < static_cast<int>(footnotes.size())) {
|
||||
if (touch == MappedInputManager::RowTouch::Down) {
|
||||
if (selectedIndex != touched) {
|
||||
selectedIndex = touched;
|
||||
requestUpdate();
|
||||
}
|
||||
} else {
|
||||
selectedIndex = touched;
|
||||
selectFootnote();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const auto swipe = mappedInput.wasSwipe();
|
||||
if (swipe == MappedInputManager::SwipeDir::Up) {
|
||||
selectedIndex = std::min(static_cast<int>(footnotes.size()) - 1, selectedIndex + visibleCount);
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
if (swipe == MappedInputManager::SwipeDir::Down) {
|
||||
selectedIndex = std::max(0, selectedIndex - visibleCount);
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
buttonNavigator.onNext([this] {
|
||||
if (!footnotes.empty()) {
|
||||
selectedIndex = (selectedIndex + 1) % footnotes.size();
|
||||
@@ -83,13 +130,14 @@ void EpubReaderFootnotesActivity::render(RenderLock&&) {
|
||||
constexpr int lineHeight = 36;
|
||||
const int screenWidth = renderer.getScreenWidth();
|
||||
const int marginLeft = contentX + 20;
|
||||
const int listTop = 60 + contentY;
|
||||
|
||||
const int visibleCount = std::max(1, (renderer.getScreenHeight() - contentY) / lineHeight);
|
||||
const int visibleCount = std::max(1, (renderer.getScreenHeight() - listTop) / lineHeight);
|
||||
if (selectedIndex < scrollOffset) scrollOffset = selectedIndex;
|
||||
if (selectedIndex >= scrollOffset + visibleCount) scrollOffset = selectedIndex - visibleCount + 1;
|
||||
|
||||
for (int i = scrollOffset; i < static_cast<int>(footnotes.size()) && i < scrollOffset + visibleCount; i++) {
|
||||
const int y = 60 + contentY + (i - scrollOffset) * lineHeight;
|
||||
const int y = listTop + (i - scrollOffset) * lineHeight;
|
||||
const bool isSelected = (i == selectedIndex);
|
||||
|
||||
if (isSelected) {
|
||||
|
||||
@@ -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<int>(menuItems.size()));
|
||||
requestUpdate();
|
||||
});
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
closeCancelled();
|
||||
return;
|
||||
}
|
||||
|
||||
buttonNavigator.onPrevious([this] {
|
||||
selectedIndex = ButtonNavigator::previousIndex(selectedIndex, static_cast<int>(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<int>(orientationLabels.size()),
|
||||
@@ -88,13 +112,48 @@ void EpubReaderMenuActivity::loop() {
|
||||
|
||||
setResult(MenuResult{static_cast<int>(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<int>(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<int>(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<int>(menuItems.size()));
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle navigation
|
||||
buttonNavigator.onNext([this] {
|
||||
selectedIndex = ButtonNavigator::nextIndex(selectedIndex, static_cast<int>(menuItems.size()));
|
||||
requestUpdate();
|
||||
});
|
||||
|
||||
buttonNavigator.onPrevious([this] {
|
||||
selectedIndex = ButtonNavigator::previousIndex(selectedIndex, static_cast<int>(menuItems.size()));
|
||||
requestUpdate();
|
||||
});
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
activateSelected();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<MenuItem> buildMenuItems(bool hasFootnotes, bool hasBookmarks);
|
||||
void closeCancelled();
|
||||
|
||||
// Fixed menu layout
|
||||
const std::vector<MenuItem> 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;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include <HalGPIO.h>
|
||||
#include <I18n.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
|
||||
#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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
#include <CrossPointSettings.h>
|
||||
#include <GfxRenderer.h>
|
||||
#include <HalGPIO.h>
|
||||
#include <HalTiltSensor.h>
|
||||
#include <Logging.h>
|
||||
#include <components/bars/tap-zones.h>
|
||||
|
||||
#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<int16_t>(renderer.getScreenWidth());
|
||||
const int16_t height = static_cast<int16_t>(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<int16_t>(width - previousZoneWidth), height},
|
||||
READER_TOUCH_NEXT},
|
||||
};
|
||||
|
||||
for (const auto& zone : zones) {
|
||||
if (!zone.enabled || !zone.rect.contains(static_cast<int16_t>(x), static_cast<int16_t>(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--;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -56,17 +56,63 @@ void XtcReaderChapterSelectionActivity::loop() {
|
||||
const int pageItems = getPageItems();
|
||||
const int totalItems = static_cast<int>(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<int>(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] {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include <functional>
|
||||
|
||||
#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();
|
||||
};
|
||||
|
||||
@@ -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<uint8_t>(signedQuarter / 4);
|
||||
quarter = static_cast<uint8_t>(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];
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
#pragma once
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
|
||||
#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;
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<int>(families_.size())) {
|
||||
downloadFamily(families_[downloadingFamilyIndex_]);
|
||||
requestUpdateAndWait();
|
||||
return;
|
||||
}
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
state_ = FAMILY_LIST;
|
||||
}
|
||||
requestUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<int>(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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<int>(selectedIndex);
|
||||
const auto listTouch = handleListTouch(touchSel, MENU_ITEMS, contentTop, contentHeight, false);
|
||||
if (listTouch != ListTouchResult::None) {
|
||||
selectedIndex = static_cast<size_t>(touchSel);
|
||||
if (listTouch == ListTouchResult::Activated) activateSelected();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<int>(selectedIndex), totalItems, pageItems);
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
if (swipe == MappedInputManager::SwipeDir::Down) {
|
||||
selectedIndex = ButtonNavigator::previousPageIndex(static_cast<int>(selectedIndex), totalItems, pageItems);
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle navigation
|
||||
buttonNavigator.onNextRelease([this] {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<int>(selectedIndex);
|
||||
const auto listTouch = handleListTouch(touchSel, menuItems, contentTop, contentHeight, false);
|
||||
if (listTouch != ListTouchResult::None) {
|
||||
selectedIndex = static_cast<size_t>(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();
|
||||
|
||||
@@ -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<OtaUpdateActivity*>(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<OtaUpdateActivity*>(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;
|
||||
|
||||
@@ -23,6 +23,7 @@ class OtaUpdateActivity : public Activity {
|
||||
OtaUpdater updater;
|
||||
|
||||
void onWifiSelectionComplete(bool success);
|
||||
void runUpdateInstall();
|
||||
|
||||
public:
|
||||
explicit OtaUpdateActivity(GfxRenderer& renderer, MappedInputManager& mappedInput)
|
||||
|
||||
@@ -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;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user