feat: Add touch coordinate mapping and RTOS task yielding (#2481)

Co-authored-by: Julia Nguyen <julia@uxj.io>
This commit is contained in:
Justin Mitchell
2026-07-20 16:31:07 -04:00
committed by GitHub
co-authored by Julia Nguyen
parent c9188a7347
commit f42fab1c66
123 changed files with 3564 additions and 1380 deletions
+4
View File
@@ -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
View File
@@ -2,7 +2,11 @@
#include <GfxRenderer.h>
#include <algorithm>
#include <cstdlib>
#include "CrossPointSettings.h"
#include "components/UITheme.h"
bool MappedInputManager::isNavDirectionSwapped() const {
// Key the swap on the orientation the screen is *actually* rendered at, not the persisted reader
@@ -74,9 +78,209 @@ bool MappedInputManager::mapButton(const Button button, bool (HalGPIO::*fn)(uint
return false;
}
bool MappedInputManager::wasPressed(const Button button) const { return mapButton(button, &HalGPIO::wasPressed); }
namespace {
constexpr float LEFT_EDGE_BACK_GESTURE_FRAC_X = 0.25f;
constexpr float BOTTOM_EDGE_BACK_GESTURE_FRAC_Y = 0.14f;
constexpr float TOP_EDGE_MENU_GESTURE_FRAC_Y = 0.14f;
constexpr unsigned long TOUCH_DOWN_SELECT_DELAY_MS = 90;
constexpr unsigned long TOUCH_HELD_OVERRIDE_WINDOW_MS = 250;
} // namespace
bool MappedInputManager::wasReleased(const Button button) const { return mapButton(button, &HalGPIO::wasReleased); }
bool MappedInputManager::hasTouch() const { return gpio.hasTouch(); }
void MappedInputManager::rememberTouchHeldTime() const {
touchHeldOverrideValid = true;
touchHeldOverrideMs = gpio.lastTouchHeldMs();
touchHeldOverrideAt = millis();
}
bool MappedInputManager::wasScreenTapped(int& x, int& y) const {
float nx = 0.0f;
float ny = 0.0f;
if (!gpio.wasTouchTap(nx, ny)) return false;
renderer.tapToLogical(nx, ny, x, y);
rememberTouchHeldTime();
return true;
}
bool MappedInputManager::wasScreenTouchDown(int& x, int& y) const {
float nx = 0.0f;
float ny = 0.0f;
unsigned long heldMs = 0;
if (!gpio.isTouchTapCandidate(nx, ny, heldMs)) return false;
if (heldMs < TOUCH_DOWN_SELECT_DELAY_MS) return false;
renderer.tapToLogical(nx, ny, x, y);
return true;
}
bool MappedInputManager::isScreenTouchHeld(int& x, int& y) const {
// Live contact position while the finger is down (no tap-slop gate) — drag tracking.
float nx = 0.0f;
float ny = 0.0f;
if (!gpio.isTouchHeldAt(nx, ny)) return false;
renderer.tapToLogical(nx, ny, x, y);
return true;
}
bool MappedInputManager::wasTapInRect(const int x, const int y, const int width, const int height) const {
int tx = 0;
int ty = 0;
return wasScreenTapped(tx, ty) && tx >= x && tx < x + width && ty >= y && ty < y + height;
}
bool MappedInputManager::listItemFromPoint(const int x, const int y, int& index, const int itemCount,
const int selectedIndex, const int listTop, const int listHeight,
const bool hasSubtitle) const {
(void)x;
if (itemCount <= 0) return false;
if (y < listTop || y >= listTop + listHeight) return false;
const auto& theme = UITheme::getInstance().getTheme();
const int rowStep = theme.getListRowStep(hasSubtitle);
if (rowStep <= 0) return false;
const int pageItems = theme.getListPageItems(listHeight, hasSubtitle);
if (pageItems <= 0) return false;
const int pageStart = std::max(0, selectedIndex / pageItems) * pageItems;
const int row = (y - listTop) / rowStep;
const int tapped = pageStart + row;
if (row < 0 || row >= pageItems || tapped >= itemCount) return false;
index = tapped;
return true;
}
bool MappedInputManager::wasListItemTapped(int& index, const int itemCount, const int selectedIndex, const int listTop,
const int listHeight, const bool hasSubtitle) const {
int tx = 0;
int ty = 0;
return wasScreenTapped(tx, ty) &&
listItemFromPoint(tx, ty, index, itemCount, selectedIndex, listTop, listHeight, hasSubtitle);
}
bool MappedInputManager::wasListItemTouchedDown(int& index, const int itemCount, const int selectedIndex,
const int listTop, const int listHeight, const bool hasSubtitle) const {
int tx = 0;
int ty = 0;
return wasScreenTouchDown(tx, ty) &&
listItemFromPoint(tx, ty, index, itemCount, selectedIndex, listTop, listHeight, hasSubtitle);
}
MappedInputManager::RowTouch MappedInputManager::rowTouch(int& row, const int top, const int rowStep,
const int rowCount, const int xStart, const int xEnd,
const int rowHeight) const {
if (rowStep <= 0 || rowCount <= 0) return RowTouch::None;
const auto hit = [&](const int x, const int y) {
if (x < xStart || x >= xEnd || y < top) return false;
const int r = (y - top) / rowStep;
if (r >= rowCount) return false;
if (rowHeight > 0 && (y - top) % rowStep >= rowHeight) return false;
row = r;
return true;
};
int x = 0;
int y = 0;
if (wasScreenTouchDown(x, y) && hit(x, y)) return RowTouch::Down;
if (wasScreenTapped(x, y) && hit(x, y)) return RowTouch::Tap;
return RowTouch::None;
}
MappedInputManager::RowTouch MappedInputManager::colTouch(int& col, const int left, const int colStep,
const int colCount, const int yStart, const int yEnd,
const int colWidth) const {
if (colStep <= 0 || colCount <= 0) return RowTouch::None;
const auto hit = [&](const int x, const int y) {
if (y < yStart || y >= yEnd || x < left) return false;
const int c = (x - left) / colStep;
if (c >= colCount) return false;
if (colWidth > 0 && (x - left) % colStep >= colWidth) return false;
col = c;
return true;
};
int x = 0;
int y = 0;
if (wasScreenTouchDown(x, y) && hit(x, y)) return RowTouch::Down;
if (wasScreenTapped(x, y) && hit(x, y)) return RowTouch::Tap;
return RowTouch::None;
}
bool MappedInputManager::decodeSwipe(int& sx, int& sy, int& ex, int& ey) const {
float nxs = 0.0f;
float nys = 0.0f;
float nxe = 0.0f;
float nye = 0.0f;
if (!gpio.wasSwipe(nxs, nys, nxe, nye)) return false;
renderer.tapToLogical(nxs, nys, sx, sy);
renderer.tapToLogical(nxe, nye, ex, ey);
return true;
}
MappedInputManager::SwipeDir MappedInputManager::wasSwipe() const {
int sx = 0;
int sy = 0;
int ex = 0;
int ey = 0;
if (!decodeSwipe(sx, sy, ex, ey)) return SwipeDir::None;
const int dx = ex - sx;
const int dy = ey - sy;
if (std::abs(dx) >= std::abs(dy)) {
return dx < 0 ? SwipeDir::Left : SwipeDir::Right;
}
return dy < 0 ? SwipeDir::Up : SwipeDir::Down;
}
bool MappedInputManager::wasBackGesture() const {
// Back = left-to-right swipe starting near the left edge. Edge-anchored so that
// mid-screen horizontal swipes stay available to activities that consume
// SwipeDir::Left/Right (e.g. percent selection, image viewer).
int sx = 0;
int sy = 0;
int ex = 0;
int ey = 0;
if (!decodeSwipe(sx, sy, ex, ey)) return false;
const bool hit = sx <= renderer.getScreenWidth() * LEFT_EDGE_BACK_GESTURE_FRAC_X && ex > sx &&
std::abs(ex - sx) > std::abs(ey - sy);
if (hit) rememberTouchHeldTime();
return hit;
}
bool MappedInputManager::wasMenuGesture() const {
// Downward swipe starting at the top edge (mirror of the bottom-edge home gesture).
int sx = 0;
int sy = 0;
int ex = 0;
int ey = 0;
if (!decodeSwipe(sx, sy, ex, ey)) return false;
const int topEdgeBottom = static_cast<int>(renderer.getScreenHeight() * TOP_EDGE_MENU_GESTURE_FRAC_Y);
const bool hit = sy <= topEdgeBottom && ey > sy && std::abs(ey - sy) > std::abs(ex - sx);
if (hit) rememberTouchHeldTime();
return hit;
}
bool MappedInputManager::wasHomeGesture() const {
int sx = 0;
int sy = 0;
int ex = 0;
int ey = 0;
if (decodeSwipe(sx, sy, ex, ey)) {
const int bottomEdgeTop =
renderer.getScreenHeight() - static_cast<int>(renderer.getScreenHeight() * BOTTOM_EDGE_BACK_GESTURE_FRAC_Y);
if (sy >= bottomEdgeTop && ey < sy && std::abs(ey - sy) > std::abs(ex - sx)) {
rememberTouchHeldTime();
return true;
}
}
return false;
}
bool MappedInputManager::wasPressed(const Button button) const {
if (button == Button::Back && wasBackGesture()) return true;
return mapButton(button, &HalGPIO::wasPressed);
}
bool MappedInputManager::wasReleased(const Button button) const {
if (button == Button::Back && wasBackGesture()) return true;
return mapButton(button, &HalGPIO::wasReleased);
}
bool MappedInputManager::isPressed(const Button button) const { return mapButton(button, &HalGPIO::isPressed); }
@@ -84,7 +288,14 @@ bool MappedInputManager::wasAnyPressed() const { return gpio.wasAnyPressed(); }
bool MappedInputManager::wasAnyReleased() const { return gpio.wasAnyReleased(); }
unsigned long MappedInputManager::getHeldTime() const { return gpio.getHeldTime(); }
unsigned long MappedInputManager::getHeldTime() const {
if (!gpio.wasAnyPressed() && !gpio.wasAnyReleased() && touchHeldOverrideValid &&
millis() - touchHeldOverrideAt <= TOUCH_HELD_OVERRIDE_WINDOW_MS) {
return touchHeldOverrideMs;
}
touchHeldOverrideValid = false;
return gpio.getHeldTime();
}
MappedInputManager::Labels MappedInputManager::mapLabels(const char* back, const char* confirm, const char* previous,
const char* next) const {
+37
View File
@@ -7,6 +7,7 @@ class GfxRenderer;
class MappedInputManager {
public:
enum class Button { Back, Confirm, Left, Right, Up, Down, Power, PageBack, PageForward, NavNext, NavPrevious };
enum class SwipeDir { None, Left, Right, Up, Down };
struct Labels {
const char* btn1;
@@ -21,9 +22,35 @@ class MappedInputManager {
bool wasPressed(Button button) const;
bool wasReleased(Button button) const;
bool isPressed(Button button) const;
bool hasTouch() const;
bool wasScreenTapped(int& x, int& y) const;
bool wasScreenTouchDown(int& x, int& y) const;
bool isScreenTouchHeld(int& x, int& y) const;
bool wasTapInRect(int x, int y, int width, int height) const;
bool wasListItemTapped(int& index, int itemCount, int selectedIndex, int listTop, int listHeight,
bool hasSubtitle) const;
bool wasListItemTouchedDown(int& index, int itemCount, int selectedIndex, int listTop, int listHeight,
bool hasSubtitle) const;
// Combined touch interaction for a band of equal rows with caller-supplied
// geometry — the shared hit-test for lists the theme helpers above do not
// cover (custom row heights, option prompts, menus). Down = a held
// tap-candidate is on a row (update the selection highlight); Tap = a tap
// released on one (activate). rowHeight limits the hit to the top rowHeight
// px of each step (0 = the full step, no gap band).
enum class RowTouch : uint8_t { None, Down, Tap };
RowTouch rowTouch(int& row, int top, int rowStep, int rowCount, int xStart = 0, int xEnd = INT32_MAX,
int rowHeight = 0) const;
// Horizontal variant for side-by-side button pairs (confirmation prompts).
RowTouch colTouch(int& col, int left, int colStep, int colCount, int yStart, int yEnd, int colWidth = 0) const;
SwipeDir wasSwipe() const;
bool wasHomeGesture() const;
bool wasMenuGesture() const;
bool wasAnyPressed() const;
bool wasAnyReleased() const;
unsigned long getHeldTime() const;
const GfxRenderer& getRenderer() const { return renderer; }
Labels mapLabels(const char* back, const char* confirm, const char* previous, const char* next) const;
// Returns the raw front button index that was pressed this frame (or -1 if none).
int getPressedFrontButton() const;
@@ -44,4 +71,14 @@ class MappedInputManager {
const GfxRenderer& renderer;
bool mapButton(Button button, bool (HalGPIO::*fn)(uint8_t) const) const;
bool wasBackGesture() const;
// Fetch the pending swipe (if any) and map both endpoints to logical screen coords
bool decodeSwipe(int& sx, int& sy, int& ex, int& ey) const;
bool listItemFromPoint(int x, int y, int& index, int itemCount, int selectedIndex, int listTop, int listHeight,
bool hasSubtitle) const;
void rememberTouchHeldTime() const;
mutable bool touchHeldOverrideValid = false;
mutable unsigned long touchHeldOverrideMs = 0;
mutable unsigned long touchHeldOverrideAt = 0;
};
+16
View File
@@ -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()) {
+17
View File
@@ -22,3 +22,20 @@ void Activity::startActivityForResult(std::unique_ptr<Activity>&& activity, Acti
void Activity::setResult(ActivityResult&& result) { this->result = std::move(result); }
void Activity::finish() { activityManager.popActivity(); }
Activity::ListTouchResult Activity::handleListTouch(int& selectedIndex, const int itemCount, const int listTop,
const int listHeight, const bool hasSubtitle) {
int touched = -1;
if (mappedInput.wasListItemTouchedDown(touched, itemCount, selectedIndex, listTop, listHeight, hasSubtitle)) {
if (selectedIndex != touched) {
selectedIndex = touched;
requestUpdate();
}
return ListTouchResult::Consumed;
}
if (mappedInput.wasListItemTapped(touched, itemCount, selectedIndex, listTop, listHeight, hasSubtitle)) {
selectedIndex = touched;
return ListTouchResult::Activated;
}
return ListTouchResult::None;
}
+14
View File
@@ -44,6 +44,8 @@ class Activity {
virtual bool skipLoopDelay() { return false; }
virtual bool preventAutoSleep() { return false; }
virtual bool isReaderActivity() const { return false; }
virtual bool isHomeActivity() const { return false; }
virtual bool handleHomeGesture() { return false; }
virtual ScreenshotInfo getScreenshotInfo() const { return {}; }
// Start a new activity without destroying the current one
@@ -60,4 +62,16 @@ class Activity {
// TODO: remove this in near future
void onGoHome(HomeMenuItem item = HomeMenuItem::NONE);
void onSelectBook(const std::string& path);
protected:
enum class ListTouchResult : uint8_t {
None, // touch did not hit the list
Consumed, // touchdown moved the highlight (repaint already requested)
Activated // tap landed on a row: selectedIndex is updated, caller activates it
};
// Shared touch handling for selectable list screens: touchdown highlights the
// touched row, a tap selects and reports Activated. The caller supplies the
// list band and runs its own activate action on Activated.
ListTouchResult handleListTouch(int& selectedIndex, int itemCount, int listTop, int listHeight, bool hasSubtitle);
};
+14 -1
View File
@@ -22,12 +22,17 @@
static portMUX_TYPE activityManagerSpinlock = portMUX_INITIALIZER_UNLOCKED;
void ActivityManager::begin() {
#if defined(configNUM_CORES) && configNUM_CORES > 1
constexpr BaseType_t renderTaskCore = 1;
#else
constexpr BaseType_t renderTaskCore = 0;
#endif
xTaskCreatePinnedToCore(&renderTaskTrampoline, "ActivityManagerRender",
8192, // Stack size
this, // Parameters
1, // Priority
&renderTaskHandle, // Task handle
0 // Pin to core 0 (PRO_CPU)
renderTaskCore // Keep long renders/cover decodes off CPU 0's idle watchdog when available
);
assert(renderTaskHandle != nullptr && "Failed to create render task");
}
@@ -61,6 +66,14 @@ void ActivityManager::renderTaskLoop() {
void ActivityManager::loop() {
if (currentActivity) {
if (!currentActivity->isHomeActivity() && mappedInput.wasHomeGesture()) {
if (currentActivity->handleHomeGesture()) {
return;
}
goHome();
return;
}
// Note: do not hold a lock here, the loop() method must be responsible for acquire one if needed
currentActivity->loop();
}
@@ -14,6 +14,7 @@
#include "activities/network/WifiSelectionActivity.h"
#include "activities/util/KeyboardEntryActivity.h"
#include "components/UITheme.h"
#include "components/icons/search24.h"
#include "fontIds.h"
#include "network/HttpDownloader.h"
#include "util/BookCacheUtils.h"
@@ -23,8 +24,22 @@
namespace {
constexpr int PAGE_ITEMS = 23;
constexpr int HEADER_Y = 15;
constexpr int HEADER_X = 16;
constexpr int SEARCH_ICON_SIZE = 24;
constexpr int SEARCH_ICON_MARGIN = 14;
constexpr int SEARCH_ICON_Y = 15;
constexpr int DOWNLOAD_PROGRESS_STEP_PERCENT = 5;
constexpr unsigned long DOWNLOAD_PROGRESS_MIN_UPDATE_MS = 5000;
Rect searchIconRect(const GfxRenderer& renderer) {
return Rect{renderer.getScreenWidth() - SEARCH_ICON_SIZE - SEARCH_ICON_MARGIN, SEARCH_ICON_Y, SEARCH_ICON_SIZE + 8,
SEARCH_ICON_SIZE + 8};
}
bool contains(const Rect& rect, const int x, const int y) {
return x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height;
}
} // namespace
void OpdsBookBrowserActivity::onEnter() {
@@ -72,7 +87,9 @@ void OpdsBookBrowserActivity::loop() {
}
if (state == BrowserState::ERROR) {
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
int tx = 0;
int ty = 0;
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm) || mappedInput.wasScreenTapped(tx, ty)) {
if (WiFi.status() == WL_CONNECTED && WiFi.localIP() != IPAddress(0, 0, 0, 0)) {
state = BrowserState::LOADING;
statusMessage = tr(STR_LOADING);
@@ -97,18 +114,59 @@ void OpdsBookBrowserActivity::loop() {
if (state == BrowserState::DOWNLOADING) return;
if (state == BrowserState::BROWSING) {
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
auto activateSelected = [this] {
if (!entries.empty()) {
const auto& entry = entries[selectorIndex];
entry.type == OpdsEntryType::BOOK ? downloadBook(entry) : navigateToEntry(entry);
}
};
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
activateSelected();
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
navigateBack();
} else if (mappedInput.wasReleased(MappedInputManager::Button::Left)) {
if (!searchTemplate.empty() && selectorIndex == 0) launchSearch();
}
int tx = 0;
int ty = 0;
if (!searchTemplate.empty() && mappedInput.wasScreenTapped(tx, ty) && contains(searchIconRect(renderer), tx, ty)) {
launchSearch();
return;
}
if (!entries.empty()) {
int row = -1;
const auto touch = mappedInput.rowTouch(row, /*top=*/60, /*rowStep=*/30, PAGE_ITEMS);
if (touch != MappedInputManager::RowTouch::None) {
const int touched = selectorIndex / PAGE_ITEMS * PAGE_ITEMS + row;
if (touched >= 0 && touched < static_cast<int>(entries.size())) {
if (touch == MappedInputManager::RowTouch::Down) {
if (selectorIndex != touched) {
selectorIndex = touched;
requestUpdate();
}
} else {
selectorIndex = touched;
activateSelected();
}
return;
}
}
const auto swipe = mappedInput.wasSwipe();
if (swipe == MappedInputManager::SwipeDir::Up) {
selectorIndex = ButtonNavigator::nextPageIndex(selectorIndex, entries.size(), PAGE_ITEMS);
requestUpdate();
return;
}
if (swipe == MappedInputManager::SwipeDir::Down) {
selectorIndex = ButtonNavigator::previousPageIndex(selectorIndex, entries.size(), PAGE_ITEMS);
requestUpdate();
return;
}
buttonNavigator.onNextRelease([this] {
selectorIndex = ButtonNavigator::nextIndex(selectorIndex, entries.size());
requestUpdate();
@@ -136,7 +194,14 @@ void OpdsBookBrowserActivity::render(RenderLock&&) {
// Show server name in header if available, otherwise generic title
const char* headerTitle = server.name.empty() ? tr(STR_OPDS_BROWSER) : server.name.c_str();
renderer.drawCenteredText(UI_12_FONT_ID, 15, headerTitle, true, EpdFontFamily::BOLD);
const int headerRightInset = searchTemplate.empty() ? HEADER_X : (SEARCH_ICON_SIZE + SEARCH_ICON_MARGIN * 2 + 8);
const auto clippedHeader =
renderer.truncatedText(UI_12_FONT_ID, headerTitle, pageWidth - HEADER_X - headerRightInset, EpdFontFamily::BOLD);
renderer.drawText(UI_12_FONT_ID, HEADER_X, HEADER_Y, clippedHeader.c_str(), true, EpdFontFamily::BOLD);
if (!searchTemplate.empty()) {
const auto rect = searchIconRect(renderer);
renderer.drawIcon(Search24Icon.bits, rect.x + 4, rect.y + 4, Search24Icon.w);
}
if (state == BrowserState::CHECK_WIFI || state == BrowserState::LOADING) {
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, statusMessage.c_str());
@@ -149,6 +214,9 @@ void OpdsBookBrowserActivity::render(RenderLock&&) {
if (state == BrowserState::ERROR) {
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 20, tr(STR_ERROR_MSG));
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 10, errorMessage.c_str());
if (mappedInput.hasTouch()) {
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 40, tr(STR_TAP_TO_RETRY));
}
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_RETRY), "", "");
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer();
+3 -1
View File
@@ -20,7 +20,9 @@ void CrashActivity::onEnter() {
}
void CrashActivity::loop() {
if (mappedInput.isPressed(MappedInputManager::Button::Back)) {
int x = 0;
int y = 0;
if (mappedInput.wasPressed(MappedInputManager::Button::Back) || mappedInput.wasScreenTapped(x, y)) {
finish();
}
}
+35 -1
View File
@@ -205,8 +205,12 @@ void FileBrowserActivity::loop() {
const int pathReserved = renderer.getLineHeight(SMALL_FONT_ID) + UITheme::getInstance().getMetrics().verticalSpacing;
const int pageItems = UITheme::getNumberOfItemsPerPage(renderer, true, false, true, false, pathReserved);
const auto& metrics = UITheme::getInstance().getMetrics();
const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
const int contentHeight =
renderer.getScreenHeight() - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing - pathReserved;
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
auto activateSelected = [this] {
if (lockNextConfirmRelease) {
lockNextConfirmRelease = false;
return;
@@ -234,6 +238,11 @@ void FileBrowserActivity::loop() {
const std::string fullPath = cleanBasePath + entry;
auto handler = [this, fullPath](const ActivityResult& res) {
// The confirmation popup acts on button press; if that button is still
// held when we resume, swallow its release so it doesn't also act here
// (Back would go up a directory, Confirm would open the selection).
lockLongPressBack = mappedInput.isPressed(MappedInputManager::Button::Back);
lockNextConfirmRelease = mappedInput.isPressed(MappedInputManager::Button::Confirm);
if (!res.isCancelled) {
LOG_DBG("FileBrowser", "Attempting to delete: %s", fullPath.c_str());
if (removeDirFile(fullPath)) {
@@ -273,6 +282,19 @@ void FileBrowserActivity::loop() {
}
}
return;
};
int touchSel = static_cast<int>(selectorIndex);
const auto listTouch = handleListTouch(touchSel, static_cast<int>(files.size()), contentTop, contentHeight, false);
if (listTouch != ListTouchResult::None) {
selectorIndex = static_cast<size_t>(touchSel);
if (listTouch == ListTouchResult::Activated) activateSelected();
return;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
activateSelected();
return;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
@@ -303,6 +325,18 @@ void FileBrowserActivity::loop() {
}
int listSize = static_cast<int>(files.size());
const auto swipe = mappedInput.wasSwipe();
if (swipe == MappedInputManager::SwipeDir::Up) {
selectorIndex = ButtonNavigator::nextPageIndex(static_cast<int>(selectorIndex), listSize, pageItems);
requestUpdate();
return;
}
if (swipe == MappedInputManager::SwipeDir::Down) {
selectorIndex = ButtonNavigator::previousPageIndex(static_cast<int>(selectorIndex), listSize, pageItems);
requestUpdate();
return;
}
buttonNavigator.onNextRelease([this, listSize] {
selectorIndex = ButtonNavigator::nextIndex(static_cast<int>(selectorIndex), listSize);
requestUpdate();
+82 -24
View File
@@ -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();
}
}
+1
View File
@@ -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();
+116 -41
View File
@@ -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.
+45 -6
View File
@@ -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()) {
+3 -1
View File
@@ -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;
}
+61 -3
View File
@@ -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--;
}
}
+4 -1
View File
@@ -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;
}
+9 -4
View File
@@ -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] {
+26 -9
View File
@@ -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();
+84 -36
View File
@@ -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;
+124 -18
View File
@@ -1,5 +1,6 @@
#include "SettingsActivity.h"
#include <BoardConfig.h>
#include <GfxRenderer.h>
#include <Logging.h>
@@ -62,13 +63,18 @@ void SettingsActivity::rebuildSettingsLists() {
}
// Append device-only ACTION items
controlsSettings.insert(controlsSettings.begin(),
SettingInfo::Action(StrId::STR_REMAP_FRONT_BUTTONS, SettingAction::RemapFrontButtons));
if (!BoardConfig::hasTouch()) {
controlsSettings.insert(controlsSettings.begin(),
SettingInfo::Action(StrId::STR_REMAP_FRONT_BUTTONS, SettingAction::RemapFrontButtons));
}
systemSettings.push_back(SettingInfo::Action(StrId::STR_WIFI_NETWORKS, SettingAction::Network));
systemSettings.push_back(SettingInfo::Action(StrId::STR_KOREADER_SYNC, SettingAction::KOReaderSync));
systemSettings.push_back(SettingInfo::Action(StrId::STR_OPDS_SERVERS, SettingAction::OPDSBrowser));
systemSettings.push_back(SettingInfo::Action(StrId::STR_CLEAR_READING_CACHE, SettingAction::ClearCache));
systemSettings.push_back(SettingInfo::Action(StrId::STR_CHECK_UPDATES, SettingAction::CheckForUpdates));
// TODO: Touch devices need their own firmware update path/artifacts before OTA is exposed.
if (!BoardConfig::hasTouch()) {
systemSettings.push_back(SettingInfo::Action(StrId::STR_CHECK_UPDATES, SettingAction::CheckForUpdates));
}
systemSettings.push_back(SettingInfo::Action(StrId::STR_SD_FIRMWARE_UPDATE, SettingAction::SdFirmwareUpdate));
systemSettings.push_back(SettingInfo::Action(StrId::STR_LANGUAGE, SettingAction::Language));
// Insert "Manage Fonts" right after the font family setting so users discover it naturally
@@ -122,6 +128,24 @@ void SettingsActivity::loop() {
bool hasChangedCategory = false;
auto applyCategorySelection = [this] {
switch (selectedCategoryIndex) {
case 0:
currentSettings = &displaySettings;
break;
case 1:
currentSettings = &readerSettings;
break;
case 2:
currentSettings = &controlsSettings;
break;
case 3:
currentSettings = &systemSettings;
break;
}
settingsCount = static_cast<int>(currentSettings->size());
};
// Handle actions with early return
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
if (selectedSettingIndex == 0) {
@@ -146,7 +170,103 @@ void SettingsActivity::loop() {
return;
}
const auto& metrics = UITheme::getInstance().getMetrics();
int tx = 0;
int ty = 0;
const int tabTop = metrics.topPadding + metrics.headerHeight;
const int listTop = metrics.topPadding + metrics.headerHeight + metrics.tabBarHeight + metrics.verticalSpacing;
const int listHeight =
renderer.getScreenHeight() - (metrics.topPadding + metrics.headerHeight + metrics.tabBarHeight +
metrics.buttonHintsHeight + metrics.verticalSpacing * 2);
auto buildTabs = [&]() {
std::vector<TabInfo> tabs;
tabs.reserve(categoryCount);
for (int i = 0; i < categoryCount; i++) {
tabs.push_back({I18N.get(categoryNames[i]), selectedCategoryIndex == i});
}
return tabs;
};
auto settingIndexFromPoint = [&](const int x, const int y, int& settingIndex) {
(void)x;
if (settingsCount <= 0 || y < listTop || y >= listTop + listHeight) return false;
const int rowStep = GUI.getListRowStep(false);
if (rowStep <= 0) return false;
const int pageItems = GUI.getListPageItems(listHeight, false);
const int selectedRow = std::max(0, selectedSettingIndex - 1);
const int pageStart = selectedRow / pageItems * pageItems;
const int row = (y - listTop) / rowStep;
const int touched = pageStart + row;
if (row < 0 || row >= pageItems || touched < 0 || touched >= settingsCount) return false;
settingIndex = touched + 1;
return true;
};
if (mappedInput.wasScreenTouchDown(tx, ty)) {
int touchedCategory = -1;
const auto tabs = buildTabs();
if (GUI.tabIndexFromPoint(renderer, Rect{0, tabTop, renderer.getScreenWidth(), metrics.tabBarHeight}, tabs, tx, ty,
touchedCategory)) {
if (selectedCategoryIndex != touchedCategory || selectedSettingIndex != 0) {
selectedCategoryIndex = touchedCategory;
selectedSettingIndex = 0;
applyCategorySelection();
requestUpdate();
}
return;
}
int touchedSetting = -1;
if (settingIndexFromPoint(tx, ty, touchedSetting)) {
if (selectedSettingIndex != touchedSetting) {
selectedSettingIndex = touchedSetting;
requestUpdate();
}
return;
}
}
if (mappedInput.wasScreenTapped(tx, ty)) {
int tappedCategory = -1;
const auto tabs = buildTabs();
if (GUI.tabIndexFromPoint(renderer, Rect{0, tabTop, renderer.getScreenWidth(), metrics.tabBarHeight}, tabs, tx, ty,
tappedCategory)) {
selectedCategoryIndex = tappedCategory;
selectedSettingIndex = 0;
applyCategorySelection();
requestUpdate();
return;
}
int tappedSetting = -1;
if (settingIndexFromPoint(tx, ty, tappedSetting)) {
selectedSettingIndex = tappedSetting;
toggleCurrentSetting();
requestUpdate();
return;
}
}
// Handle navigation
const auto& navMetrics = UITheme::getInstance().getMetrics();
const int settingsListHeight =
renderer.getScreenHeight() - (navMetrics.topPadding + navMetrics.headerHeight + navMetrics.tabBarHeight +
navMetrics.buttonHintsHeight + navMetrics.verticalSpacing * 2);
const int settingsPageItems = GUI.getListPageItems(settingsListHeight, false);
const auto swipe = mappedInput.wasSwipe();
if (swipe == MappedInputManager::SwipeDir::Up) {
selectedSettingIndex = selectedSettingIndex == 0 ? 1
: ButtonNavigator::nextPageIndex(
selectedSettingIndex, settingsCount + 1, settingsPageItems);
requestUpdate();
return;
}
if (swipe == MappedInputManager::SwipeDir::Down) {
selectedSettingIndex =
ButtonNavigator::previousPageIndex(selectedSettingIndex, settingsCount + 1, settingsPageItems);
requestUpdate();
return;
}
buttonNavigator.onNextRelease([this] {
selectedSettingIndex = ButtonNavigator::nextIndex(selectedSettingIndex, settingsCount + 1);
requestUpdate();
@@ -171,21 +291,7 @@ void SettingsActivity::loop() {
if (hasChangedCategory) {
selectedSettingIndex = (selectedSettingIndex == 0) ? 0 : 1;
switch (selectedCategoryIndex) {
case 0:
currentSettings = &displaySettings;
break;
case 1:
currentSettings = &readerSettings;
break;
case 2:
currentSettings = &controlsSettings;
break;
case 3:
currentSettings = &systemSettings;
break;
}
settingsCount = static_cast<int>(currentSettings->size());
applyCategorySelection();
}
}
@@ -127,6 +127,21 @@ void StatusBarSettingsActivity::onExit() { Activity::onExit(); }
void StatusBarSettingsActivity::loop() {
if (optionPopup.handleInput(mappedInput, [this] { requestUpdate(); })) return;
const auto& metrics = UITheme::getInstance().getMetrics();
const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
const int contentHeight =
renderer.getScreenHeight() - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing * 2;
switch (handleListTouch(selectedIndex, visibleItemCount, contentTop, contentHeight, false)) {
case ListTouchResult::Activated:
handleSelection();
requestUpdate();
return;
case ListTouchResult::Consumed:
return;
case ListTouchResult::None:
break;
}
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
finish();
return;
+29 -16
View File
@@ -180,11 +180,37 @@ void BmpViewerActivity::loop() {
// Keep CPU awake/polling so 1st click works
Activity::loop();
auto openSibling = [this](const int delta) {
if (currentImageIndex < 0) {
return false;
}
const int nextIndex = currentImageIndex + delta;
if (siblingImages.size() <= 1 || nextIndex < 0 || nextIndex >= static_cast<int>(siblingImages.size())) {
return false;
}
currentImageIndex = nextIndex;
std::string dirPath = FsHelpers::extractFolderPath(filePath);
if (dirPath.back() != '/') dirPath += "/";
filePath = dirPath + siblingImages[currentImageIndex];
onEnter();
return true;
};
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
activityManager.goToFileBrowser(filePath);
return;
}
const auto swipe = mappedInput.wasSwipe();
if (swipe == MappedInputManager::SwipeDir::Left) {
openSibling(1);
return;
}
if (swipe == MappedInputManager::SwipeDir::Right) {
openSibling(-1);
return;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
doSetSleepCover();
return;
@@ -192,26 +218,13 @@ void BmpViewerActivity::loop() {
if (mappedInput.wasReleased(MappedInputManager::Button::Left) ||
mappedInput.wasReleased(MappedInputManager::Button::Up)) {
if (siblingImages.size() > 1 && currentImageIndex > 0) {
currentImageIndex--;
std::string dirPath = FsHelpers::extractFolderPath(filePath);
if (dirPath.back() != '/') dirPath += "/";
filePath = dirPath + siblingImages[currentImageIndex];
onEnter();
}
openSibling(-1);
return;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Right) ||
mappedInput.wasReleased(MappedInputManager::Button::Down)) {
if (siblingImages.size() > 1 && currentImageIndex != -1 &&
currentImageIndex < static_cast<int>(siblingImages.size()) - 1) {
currentImageIndex++;
std::string dirPath = FsHelpers::extractFolderPath(filePath);
if (dirPath.back() != '/') dirPath += "/";
filePath = dirPath + siblingImages[currentImageIndex];
onEnter();
}
openSibling(1);
return;
}
}
}
+18 -23
View File
@@ -22,12 +22,17 @@ void ConfirmationActivity::onEnter() {
safeBody = renderer.truncatedText(fontId, body.c_str(), maxWidth, EpdFontFamily::REGULAR);
}
int totalHeight = 0;
if (!safeHeading.empty()) totalHeight += lineHeight;
if (!safeBody.empty()) totalHeight += lineHeight;
if (!safeHeading.empty() && !safeBody.empty()) totalHeight += spacing;
// Text sits in the upper part of the screen so the confirmation popup
// (centered) doesn't cover it.
startY = renderer.getScreenHeight() / 6;
startY = (renderer.getScreenHeight() - totalHeight) / 2;
const char* options[] = {I18N.get(StrId::STR_CANCEL), I18N.get(StrId::STR_CONFIRM)};
confirmPopup.show(safeHeading.c_str(), options, 2, 0, [this](int idx) {
ActivityResult res;
res.isCancelled = (idx != 1);
setResult(std::move(res));
finish();
});
requestUpdate(true);
}
@@ -48,27 +53,17 @@ void ConfirmationActivity::render(RenderLock&& lock) {
renderer.drawCenteredText(fontId, currentY, safeBody.c_str(), true, EpdFontFamily::REGULAR);
}
// Draw UI Elements
const auto labels = mappedInput.mapLabels("", "", I18N.get(StrId::STR_CANCEL), I18N.get(StrId::STR_CONFIRM));
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
if (confirmPopup.processRender(renderer, mappedInput)) return;
renderer.displayBuffer(HalDisplay::RefreshMode::FAST_REFRESH);
}
void ConfirmationActivity::loop() {
if (mappedInput.wasReleased(MappedInputManager::Button::Right)) {
ActivityResult res;
res.isCancelled = false;
setResult(std::move(res));
finish();
return;
}
if (confirmPopup.handleInput(mappedInput, [this] { requestUpdate(); })) return;
if (mappedInput.wasReleased(MappedInputManager::Button::Left)) {
ActivityResult res;
res.isCancelled = true;
setResult(std::move(res));
finish();
return;
}
}
// Popup dismissed without a selection (Back button or tap outside): cancel.
ActivityResult res;
res.isCancelled = true;
setResult(std::move(res));
finish();
}
@@ -3,6 +3,7 @@
#include <string>
#include "activities/Activity.h"
#include "components/OptionPopup.h"
#include "fontIds.h"
class ConfirmationActivity : public Activity {
@@ -17,6 +18,7 @@ class ConfirmationActivity : public Activity {
std::string safeHeading;
std::string safeBody;
OptionPopup confirmPopup;
int startY = 0;
int lineHeight = 0;
@@ -49,6 +49,36 @@ void IntervalSelectionActivity::loop() {
}
}
int tx = 0;
int ty = 0;
const int screenWidth = renderer.getScreenWidth();
const int barWidth = std::min(360, std::max(0, screenWidth - 40));
constexpr int barHeight = 16;
const int barX = std::max(0, (screenWidth - barWidth) / 2);
const int barY = 140;
// Live drag on the slider: once a touch lands on the bar, the value follows the
// finger until release. Runs before the Back/Confirm handlers because the release
// of a drag can also register as a swipe (e.g. the left-edge rightward back
// gesture) — the drag must consume it so it can't cancel or confirm the dialog.
if (mappedInput.isScreenTouchHeld(tx, ty)) {
if (draggingBar || (ty >= barY - 20 && ty < barY + barHeight + 20 && tx >= barX && tx < barX + barWidth)) {
draggingBar = true;
const int range = std::max(1, maxValue - minValue);
const int dragged =
clampedValue(minValue + std::clamp(tx - barX, 0, barWidth - 1) * range / std::max(1, barWidth - 1));
if (dragged != value) {
value = dragged;
requestUpdate();
}
return;
}
} else if (draggingBar) {
// Release frame of a drag: swallow the tap/swipe events it produced.
draggingBar = false;
return;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
ActivityResult result;
result.isCancelled = true;
@@ -63,6 +93,27 @@ void IntervalSelectionActivity::loop() {
return;
}
if (mappedInput.wasScreenTapped(tx, ty)) {
if (ty >= barY - 20 && ty < barY + barHeight + 20 && tx >= barX && tx < barX + barWidth) {
const int range = std::max(1, maxValue - minValue);
value = clampedValue(minValue + (tx - barX) * range / std::max(1, barWidth - 1));
requestUpdate();
return;
}
if (ty >= renderer.getScreenHeight() - 80) {
if (tx < renderer.getScreenWidth() / 3) {
ActivityResult result;
result.isCancelled = true;
setResult(std::move(result));
finish();
} else if (tx > renderer.getScreenWidth() * 2 / 3) {
setResult(IntervalResult{static_cast<uint32_t>(value)});
finish();
}
return;
}
}
buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Left}, [this] { adjustValue(-smallStep); });
buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Right}, [this] { adjustValue(smallStep); });
@@ -43,6 +43,7 @@ class IntervalSelectionActivity final : public Activity {
int largeStep;
bool readerActivity;
bool ignoreConfirmRelease;
bool draggingBar = false;
ButtonNavigator buttonNavigator;
void adjustValue(int delta);
File diff suppressed because it is too large Load Diff
+66 -163
View File
@@ -1,23 +1,21 @@
#pragma once
#include <FreeInkUIGfxRenderer.h>
#include <GfxRenderer.h>
#include <atomic>
#include <cstdint>
#include <functional>
#include <string>
#include <utility>
#include "activities/Activity.h"
#include "util/ButtonNavigator.h"
struct KeyDef {
char primary;
char secondary;
};
enum class SpecialKeyType { Shift, Mode, Space, Del, Ok };
enum class InputType { Text, Password, Url };
// Text entry on the FreeInkUI keyboard component: the SDK layout tables and
// keyboard() do the key rendering and hit-rect registration, InteractionBuffer
// routes taps/long-presses, and this activity owns the text field, cursor
// editing, and the URL snippet layouts.
class KeyboardEntryActivity : public Activity {
public:
explicit KeyboardEntryActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
@@ -43,16 +41,30 @@ class KeyboardEntryActivity : public Activity {
ButtonNavigator buttonNavigator;
int selectedRow = 0;
int selectedCol = 0;
int shiftState = 0;
bool symMode = false;
// Keyboard layers. The letter/symbol layers come from the SDK's builtin
// layouts (with the always-visible number row); the URL layers are
// app-defined tables in the .cpp.
freeink::ui::KeyboardLayoutId layoutId = freeink::ui::KeyboardLayoutId::QwertyEn;
bool shifted = false;
bool symbols = false;
bool urlPanel = false; // URL snippet panel replaces the letter layer
// Key hit rects registered by the keyboard component during render();
// loop() routes touch snapshots against them. 5-row EN layout registers 41
// keys, so 48 leaves headroom.
freeink::ui::InteractionBuffer<48> interactions;
// GPIO selection over the current layout grid (row/col in layout terms;
// the bottom action row is just the last row).
int selRow = 0;
int selCol = 0;
bool confirmHeld = false;
bool confirmLongHandled = false;
bool cursorMode = false;
bool togglePos = false;
size_t cursorPos = 0;
size_t cursorPos = 0; // byte offset into text (always on a code point boundary)
bool upHeld = false;
bool upLongHandled = false;
bool downHeld = false;
@@ -62,10 +74,15 @@ class KeyboardEntryActivity : public Activity {
size_t savedCursorPos = 0;
size_t rightStartCursorPos = 0;
bool urlMode = false;
static constexpr int URL_SNIPPET_COUNT = 9;
static constexpr const char* const urlSnippets[URL_SNIPPET_COUNT] = {
"https://", "www.", ".com", "http://", "192.168.", ".org", "/opds", ":8080", ".net"};
// Tap/hold routing (threshold long-press, release swallow, slide re-arm)
// lives in the SDK; loop() feeds it the level-triggered touch state.
freeink::ui::TouchHoldRouter touchRouter;
// loop() runs on the main task while render() rebuilds the interaction
// table on the render task; routing against a half-built table would read
// torn entries, so taps are dropped during the rebuild window. atomic (not
// volatile) so the flag also orders the table writes on dual-core targets.
std::atomic<bool> interactionsReady{false};
int delPressCount = 0;
bool hintVisible = false;
@@ -73,154 +90,40 @@ class KeyboardEntryActivity : public Activity {
void onComplete(std::string text);
void onCancel();
bool cursorPositionFromPoint(int x, int y, size_t& position) const;
std::string displayTextForCurrentState() const;
// Advance of s[start, end) measured in place by temporarily null-terminating
// at `end` — avoids a substr temporary per measurement.
int measureRange(std::string& s, int start, int end) const;
// Largest line end in (start, s.length()] whose advance fits maxWidth.
// Binary search over the monotonic prefix advance; always advances at least
// one byte so an oversized glyph cannot stall the wrap loop.
int lineBreakEnd(std::string& s, int start, int maxWidth) const;
const freeink::ui::KeyboardLayout& currentLayout() const;
const freeink::ui::KeyboardKey* selectedKey() const;
int selectedLogicalIndex() const;
void clampSelection();
void moveSelectionRow(int delta);
void moveSelectionCol(int delta);
bool syncSelectionToValue(int16_t value);
// Handles one key activation (by stable key id). Returns true when the
// screen needs a repaint; OK/cancel finish the activity instead.
bool activateValue(int16_t value, bool longPress);
bool clearAllOrAltOnSelected();
void insertUtf8(const char* out);
bool backspaceUtf8();
static size_t utf8Prev(const std::string& s, size_t pos);
static size_t utf8Next(const std::string& s, size_t pos);
freeink::ui::Rect keyboardRect() const;
static constexpr uint16_t LONG_PRESS_MS = 500;
static constexpr uint16_t DEL_LONG_PRESS_MS = 1500;
static constexpr uint16_t TOUCH_LONG_PRESS_MS = 350;
static constexpr uint16_t TOUCH_DEL_LONG_PRESS_MS = 900;
static constexpr int COLS = 10;
static constexpr int ABC_ROWS = 4;
static constexpr int SYM_ROWS = 4;
static constexpr int BOTTOM_KEY_COUNT = 5;
static constexpr KeyDef abcLayout[ABC_ROWS][COLS] = {
{{'1', '!'},
{'2', '@'},
{'3', '#'},
{'4', '$'},
{'5', '%'},
{'6', '^'},
{'7', '&'},
{'8', '*'},
{'9', '('},
{'0', ')'}},
{{'q', 'Q'},
{'w', 'W'},
{'e', 'E'},
{'r', 'R'},
{'t', 'T'},
{'y', 'Y'},
{'u', 'U'},
{'i', 'I'},
{'o', 'O'},
{'p', 'P'}},
{{'a', 'A'},
{'s', 'S'},
{'d', 'D'},
{'f', 'F'},
{'g', 'G'},
{'h', 'H'},
{'j', 'J'},
{'k', 'K'},
{'l', 'L'},
{'-', '_'}},
{{'z', 'Z'},
{'x', 'X'},
{'c', 'C'},
{'v', 'V'},
{'b', 'B'},
{'n', 'N'},
{'m', 'M'},
{'=', '+'},
{'.', '>'},
{',', '<'}},
};
static constexpr KeyDef urlLayout[ABC_ROWS][COLS] = {
{{'1', '!'},
{'2', '@'},
{'3', '#'},
{'4', '$'},
{'5', '%'},
{'6', '^'},
{'7', '&'},
{'8', '*'},
{'9', '('},
{'0', ')'}},
{{'q', 'Q'},
{'w', 'W'},
{'e', 'E'},
{'r', 'R'},
{'t', 'T'},
{'y', 'Y'},
{'u', 'U'},
{'i', 'I'},
{'o', 'O'},
{'p', 'P'}},
{{'a', 'A'},
{'s', 'S'},
{'d', 'D'},
{'f', 'F'},
{'g', 'G'},
{'h', 'H'},
{'j', 'J'},
{'k', 'K'},
{'l', 'L'},
{'-', '_'}},
{{'z', 'Z'},
{'x', 'X'},
{'c', 'C'},
{'v', 'V'},
{'b', 'B'},
{'n', 'N'},
{'m', 'M'},
{':', '+'},
{'.', '>'},
{'/', '<'}},
};
static constexpr KeyDef symLayout[SYM_ROWS][COLS] = {
{{'1', '\0'},
{'2', '\0'},
{'3', '\0'},
{'4', '\0'},
{'5', '\0'},
{'6', '\0'},
{'7', '\0'},
{'8', '\0'},
{'9', '\0'},
{'0', '\0'}},
{{'!', '\0'},
{'@', '\0'},
{'#', '\0'},
{'$', '\0'},
{'%', '\0'},
{'^', '\0'},
{'&', '\0'},
{'*', '\0'},
{'(', '\0'},
{')', '\0'}},
{{'-', '\0'},
{'_', '\0'},
{'=', '\0'},
{'+', '\0'},
{'[', '\0'},
{']', '\0'},
{'{', '\0'},
{'}', '\0'},
{';', '\0'},
{':', '\0'}},
{{'\'', '\0'},
{'"', '\0'},
{'/', '\0'},
{'\\', '\0'},
{'|', '\0'},
{'?', '\0'},
{'.', '\0'},
{',', '\0'},
{'~', '\0'},
{'`', '\0'}},
};
static const char* const shiftString[2];
int getContentRowCount() const;
int getContentColCount() const;
int getTotalRowCount() const;
bool isBottomRow(int row) const;
char getSelectedChar() const;
char getAlternativeChar() const;
bool handleKeyPress();
bool insertChar(char c);
void insertString(const std::string& str);
void mapColContentBottom(int& col, bool goingUp) const;
// App-specific key id: toggles the URL snippet panel (URL fields only).
static constexpr int16_t URL_PANEL_KEY = -3;
};
+98
View File
@@ -1,6 +1,7 @@
#pragma once
#include <I18n.h>
#include <algorithm>
#include <functional>
#include <string>
#include <vector>
@@ -8,6 +9,7 @@
#include "GfxRenderer.h"
#include "MappedInputManager.h"
#include "components/UITheme.h"
#include "fontIds.h"
class OptionPopup {
public:
@@ -20,6 +22,7 @@ class OptionPopup {
}
selectedIndex = currentIndex;
onSelectCallback = std::move(onSelect);
layoutValid = false;
active = true;
}
@@ -32,6 +35,7 @@ class OptionPopup {
}
selectedIndex = currentIndex;
onSelectCallback = std::move(onSelect);
layoutValid = false;
active = true;
}
@@ -41,6 +45,7 @@ class OptionPopup {
ownedStrings = options;
selectedIndex = currentIndex;
onSelectCallback = std::move(onSelect);
layoutValid = false;
active = true;
}
@@ -48,6 +53,39 @@ class OptionPopup {
if (!active) return false;
const int count = static_cast<int>(ownedStrings.size());
int tx = 0;
int ty = 0;
if (input.wasScreenTouchDown(tx, ty)) {
const auto& hitLayout = getLayout(input.getRenderer());
for (int i = 0; i < static_cast<int>(hitLayout.options.size()); i++) {
if (contains(hitLayout.options[i], tx, ty)) {
if (selectedIndex != i) {
selectedIndex = i;
requestUpdate();
}
break;
}
}
return true;
}
if (input.wasScreenTapped(tx, ty)) {
const auto& hitLayout = getLayout(input.getRenderer());
for (int i = 0; i < static_cast<int>(hitLayout.options.size()); i++) {
if (contains(hitLayout.options[i], tx, ty)) {
selectedIndex = i;
active = false;
if (onSelectCallback) onSelectCallback(selectedIndex);
requestUpdate();
return true;
}
}
// Taps on the dialog chrome (title, padding) keep the popup open; taps outside dismiss it
if (contains(hitLayout.dialog, tx, ty)) return true;
active = false;
requestUpdate();
return true;
}
if (input.wasPressed(MappedInputManager::Button::Up) || input.wasPressed(MappedInputManager::Button::Left)) {
selectedIndex = (selectedIndex - 1 + count) % count;
requestUpdate();
@@ -87,9 +125,69 @@ class OptionPopup {
bool isActive() const { return active; }
private:
struct Layout {
Rect dialog{0, 0, 0, 0};
std::vector<Rect> options;
};
// Text measurement is expensive and wasScreenTouchDown() is level-triggered, so the
// layout is computed once per show() and cached rather than rebuilt every loop().
const Layout& getLayout(const GfxRenderer& renderer) const {
if (layoutValid) return layout;
const auto& metrics = UITheme::getInstance().getMetrics();
const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = renderer.getScreenHeight();
const int optionFontId = metrics.optionPopupUseSmallFont ? UI_10_FONT_ID : UI_12_FONT_ID;
const EpdFontFamily::Style optionStyle =
metrics.optionPopupOptionFontBold ? EpdFontFamily::BOLD : EpdFontFamily::REGULAR;
const int itemSpacing = metrics.optionPopupItemSpacing;
const int innerPadding = metrics.optionPopupInnerPadding;
const int selectionHPadding = metrics.optionPopupSelectionHPadding;
const int selectionVPadding = metrics.optionPopupSelectionVPadding;
const int optionLineHeight = renderer.getLineHeight(optionFontId);
const int titleLineHeight = renderer.getLineHeight(UI_12_FONT_ID);
const int rowHeight = optionLineHeight + selectionVPadding * 2;
int maxTextWidth = renderer.getTextWidth(UI_12_FONT_ID, title.c_str(), EpdFontFamily::BOLD);
for (const auto& opt : ownedStrings) {
const int width = renderer.getTextWidth(optionFontId, opt.c_str(), optionStyle);
if (width > maxTextWidth) maxTextWidth = width;
}
const int optionCount = static_cast<int>(ownedStrings.size());
const int listHeight = rowHeight * optionCount + itemSpacing * (optionCount - 1);
const int dialogW = std::min((maxTextWidth + innerPadding * 2 + selectionHPadding * 2) * 12 / 10,
pageWidth - metrics.optionPopupDialogSideMargin * 2);
const int contentHeight = titleLineHeight + metrics.optionPopupTitleGap + listHeight;
const int dialogH = contentHeight + innerPadding * 2;
const int dialogX = (pageWidth - dialogW) / 2;
const int dialogY = (pageHeight - dialogH) / 2;
const int itemRectX = dialogX + innerPadding;
const int itemRectW = dialogW - innerPadding * 2;
const int firstItemY = dialogY + innerPadding + titleLineHeight + metrics.optionPopupTitleGap;
layout.dialog = Rect{dialogX, dialogY, dialogW, dialogH};
layout.options.clear();
layout.options.reserve(optionCount);
for (int i = 0; i < optionCount; i++) {
layout.options.push_back(Rect{itemRectX, firstItemY + i * (rowHeight + itemSpacing), itemRectW, rowHeight});
}
layoutValid = true;
return layout;
}
static bool contains(const Rect& rect, const int x, const int y) {
return x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height;
}
bool active = false;
std::string title;
std::vector<std::string> ownedStrings;
int selectedIndex = 0;
std::function<void(int)> onSelectCallback;
mutable Layout layout;
mutable bool layoutValid = false;
};
+28 -11
View File
@@ -2,6 +2,7 @@
#include <FsHelpers.h>
#include <GfxRenderer.h>
#include <HalGPIO.h>
#include <Logging.h>
#include <memory>
@@ -48,11 +49,27 @@ void UITheme::setTheme(CrossPointSettings::UI_THEME type) {
currentMetrics = &Lyra3CoversMetrics::values;
break;
}
metricsValid = false;
}
const ThemeMetrics& UITheme::getMetrics() const {
// hasTouch() can flip once touch init completes after static construction, so the
// cached copy is refreshed when the flag differs instead of copying the struct per call.
const bool touch = gpio.hasTouch();
if (!metricsValid || touch != metricsForTouch) {
adjustedMetrics = *currentMetrics;
if (touch) {
adjustedMetrics.buttonHintsHeight = 0;
}
metricsForTouch = touch;
metricsValid = true;
}
return adjustedMetrics;
}
int UITheme::getNumberOfItemsPerPage(const GfxRenderer& renderer, bool hasHeader, bool hasTabBar, bool hasButtonHints,
bool hasSubtitle, int extraReservedHeight) {
const ThemeMetrics& metrics = UITheme::getInstance().getMetrics();
const ThemeMetrics metrics = UITheme::getInstance().getMetrics();
auto orientation = renderer.getOrientation();
int reservedHeight = metrics.topPadding;
if (hasHeader) {
@@ -66,8 +83,7 @@ int UITheme::getNumberOfItemsPerPage(const GfxRenderer& renderer, bool hasHeader
reservedHeight += metrics.verticalSpacing + metrics.buttonHintsHeight;
}
const int availableHeight = renderer.getScreenHeight() - reservedHeight - extraReservedHeight;
int rowHeight = hasSubtitle ? metrics.listWithSubtitleRowHeight : metrics.listRowHeight;
return availableHeight / rowHeight;
return UITheme::getInstance().getTheme().getListPageItems(availableHeight, hasSubtitle);
}
// Screen area excluding the button hints
@@ -76,27 +92,28 @@ Rect UITheme::getScreenSafeArea(const GfxRenderer& renderer, bool hasFrontButton
const int screenWidth = renderer.getScreenWidth();
const int screenHeight = renderer.getScreenHeight();
Rect safeArea = Rect{0, 0, screenWidth, screenHeight};
const ThemeMetrics metrics = getMetrics();
switch (orientation) {
case GfxRenderer::Orientation::Portrait:
if (hasFrontButtonHints) {
safeArea.height -= currentMetrics->buttonHintsHeight;
safeArea.height -= metrics.buttonHintsHeight;
}
break;
case GfxRenderer::Orientation::LandscapeClockwise:
if (hasFrontButtonHints) {
safeArea.x += currentMetrics->buttonHintsHeight;
safeArea.width -= currentMetrics->buttonHintsHeight;
safeArea.x += metrics.buttonHintsHeight;
safeArea.width -= metrics.buttonHintsHeight;
}
break;
case GfxRenderer::Orientation::PortraitInverted:
if (hasFrontButtonHints) {
safeArea.y += currentMetrics->buttonHintsHeight;
safeArea.height -= currentMetrics->buttonHintsHeight;
safeArea.y += metrics.buttonHintsHeight;
safeArea.height -= metrics.buttonHintsHeight;
}
break;
case GfxRenderer::Orientation::LandscapeCounterClockwise:
if (hasFrontButtonHints) {
safeArea.width -= currentMetrics->buttonHintsHeight;
safeArea.width -= metrics.buttonHintsHeight;
}
break;
}
@@ -128,7 +145,7 @@ UIIcon UITheme::getFileIcon(const std::string& filename) {
}
int UITheme::getStatusBarHeight() {
const ThemeMetrics& metrics = UITheme::getInstance().getMetrics();
const ThemeMetrics metrics = UITheme::getInstance().getMetrics();
// Add status bar margin
const bool showStatusBar =
@@ -142,7 +159,7 @@ int UITheme::getStatusBarHeight() {
}
int UITheme::getProgressBarHeight() {
const ThemeMetrics& metrics = UITheme::getInstance().getMetrics();
const ThemeMetrics metrics = UITheme::getInstance().getMetrics();
const bool showProgressBar =
SETTINGS.statusBarProgressBar != CrossPointSettings::STATUS_BAR_PROGRESS_BAR::HIDE_PROGRESS;
return (showProgressBar ? (((SETTINGS.statusBarProgressBarThickness + 1) * 2) + metrics.progressBarMarginTop) : 0);
+4 -1
View File
@@ -16,7 +16,7 @@ class UITheme {
UITheme();
static UITheme& getInstance() { return instance; }
const ThemeMetrics& getMetrics() const { return *currentMetrics; }
const ThemeMetrics& getMetrics() const;
const BaseTheme& getTheme() const { return *currentTheme; }
Rect getScreenSafeArea(const GfxRenderer& renderer, bool hasFrontButtonHints = false,
bool hasSideButtonHints = false);
@@ -34,6 +34,9 @@ class UITheme {
private:
const ThemeMetrics* currentMetrics;
std::unique_ptr<BaseTheme> currentTheme;
mutable ThemeMetrics adjustedMetrics;
mutable bool metricsValid = false;
mutable bool metricsForTouch = false;
};
// Helper macro to access current theme
+13
View File
@@ -0,0 +1,13 @@
#pragma once
#include <Icon.h>
#include <cstdint>
// Generated by freeink-sdk/libs/assets/Icons/tools/gen_icons.py from Lucide search.svg.
static const uint8_t Search24IconBits[] = {
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x03, 0xFF, 0xFC, 0x00, 0xFF, 0xF8, 0xFC, 0x7F, 0xF1, 0xFE, 0x3F,
0xE3, 0xFF, 0x1F, 0xE7, 0xFF, 0x9F, 0xCF, 0xFF, 0xCF, 0xCF, 0xFF, 0xCF, 0xCF, 0xFF, 0xCF, 0xCF, 0xFF, 0xCF,
0xCF, 0xFF, 0xCF, 0xCF, 0xFF, 0xCF, 0xE7, 0xFF, 0x9F, 0xE3, 0xFF, 0x1F, 0xF1, 0xFE, 0x3F, 0xF8, 0xFC, 0x1F,
0xFC, 0x00, 0x8F, 0xFF, 0x03, 0xC7, 0xFF, 0xFF, 0xE3, 0xFF, 0xFF, 0xF3, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF};
static const freeink::Icon Search24Icon = {24, 24, 11, Search24IconBits};
+41 -101
View File
@@ -2,6 +2,7 @@
#include <GfxRenderer.h>
#include <HalClock.h>
#include <HalGPIO.h>
#include <HalPowerManager.h>
#include <HalStorage.h>
#include <Logging.h>
@@ -156,6 +157,10 @@ void BaseTheme::drawProgressBar(const GfxRenderer& renderer, Rect rect, const si
void BaseTheme::drawButtonHints(GfxRenderer& renderer, const char* btn1, const char* btn2, const char* btn3,
const char* btn4) const {
if (gpio.hasTouch()) {
return;
}
const GfxRenderer::Orientation orig_orientation = renderer.getOrientation();
renderer.setOrientation(GfxRenderer::Orientation::Portrait);
@@ -186,6 +191,10 @@ void BaseTheme::drawButtonHints(GfxRenderer& renderer, const char* btn1, const c
}
void BaseTheme::drawSideButtonHints(const GfxRenderer& renderer, const char* topBtn, const char* bottomBtn) const {
if (gpio.hasTouch()) {
return;
}
const int screenWidth = renderer.getScreenWidth();
constexpr int buttonWidth = BaseMetrics::values.sideButtonHintsWidth; // Width on screen (height when rotated)
constexpr int buttonHeight = 80; // Height on screen (width when rotated)
@@ -250,9 +259,15 @@ void BaseTheme::drawSideButtonHints(const GfxRenderer& renderer, const char* top
}
}
int BaseTheme::getListPageItems(int contentHeight, bool hasSubtitle) const {
int BaseTheme::getListRowStep(bool hasSubtitle) const {
int rowHeight = (hasSubtitle) ? BaseMetrics::values.listWithSubtitleRowHeight : BaseMetrics::values.listRowHeight;
return contentHeight / rowHeight;
return rowHeight;
}
int BaseTheme::getListPageItems(int contentHeight, bool hasSubtitle) const {
const int rowStep = getListRowStep(hasSubtitle);
if (rowStep <= 0) return 1;
return std::max(1, contentHeight / rowStep);
}
void BaseTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex,
@@ -263,7 +278,7 @@ void BaseTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount,
const std::function<bool(int index)>& rowDimmed) const {
int rowHeight =
(rowSubtitle != nullptr) ? BaseMetrics::values.listWithSubtitleRowHeight : BaseMetrics::values.listRowHeight;
int pageItems = rect.height / rowHeight;
int pageItems = rowHeight > 0 ? std::max(1, rect.height / rowHeight) : 1;
const int totalPages = (itemCount + pageItems - 1) / pageItems;
if (totalPages > 1) {
@@ -432,6 +447,29 @@ void BaseTheme::drawTabBar(const GfxRenderer& renderer, const Rect rect, const s
}
}
bool BaseTheme::tabIndexFromPoint(const GfxRenderer& renderer, const Rect rect, const std::vector<TabInfo>& tabs,
const int x, const int y, int& index) const {
if (tabs.empty() || y < rect.y || y >= rect.y + rect.height) {
return false;
}
int currentX = rect.x + BaseMetrics::values.contentSidePadding;
for (size_t i = 0; i < tabs.size(); i++) {
const auto& tab = tabs[i];
const int textWidth =
renderer.getTextWidth(UI_12_FONT_ID, tab.label, tab.selected ? EpdFontFamily::BOLD : EpdFontFamily::REGULAR);
const int left = (i == 0) ? rect.x : currentX - BaseMetrics::values.tabSpacing / 2;
const int right = currentX + textWidth + BaseMetrics::values.tabSpacing / 2;
if (x >= left && x < right) {
index = static_cast<int>(i);
return true;
}
currentX += textWidth + BaseMetrics::values.tabSpacing;
}
return false;
}
// Draw the "Recent Book" cover card on the home screen
// TODO: Refactor method to make it cleaner, split into smaller methods
void BaseTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector<RecentBook>& recentBooks,
@@ -912,104 +950,6 @@ void BaseTheme::drawTextField(const GfxRenderer& renderer, Rect rect, const int
}
}
void BaseTheme::drawKeyboardKey(const GfxRenderer& renderer, Rect rect, const char* label, const bool isSelected,
const char* secondaryLabel, const KeyboardKeyType keyType,
const bool inactiveSelection) const {
const auto& metrics = UITheme::getInstance().getMetrics();
const int cr = metrics.keyboardKeyCornerRadius;
const bool isSpecialKey = keyType == KeyboardKeyType::Shift || keyType == KeyboardKeyType::Mode ||
keyType == KeyboardKeyType::Del || keyType == KeyboardKeyType::Space ||
keyType == KeyboardKeyType::Ok || keyType == KeyboardKeyType::Disabled;
if (isSelected) {
if (inactiveSelection) {
if (cr > 0) {
renderer.fillRoundedRect(rect.x, rect.y, rect.width, rect.height, cr, Color::LightGray);
} else {
renderer.drawRect(rect.x, rect.y, rect.width, rect.height, 2, true);
}
} else if (keyType == KeyboardKeyType::Disabled) {
if (cr > 0) {
renderer.fillRoundedRect(rect.x, rect.y, rect.width, rect.height, cr, Color::LightGray);
} else {
renderer.fillRectDither(rect.x, rect.y, rect.width, rect.height, Color::LightGray);
}
} else {
if (cr > 0) {
renderer.fillRoundedRect(rect.x, rect.y, rect.width, rect.height, cr, Color::Black);
} else {
renderer.fillRect(rect.x, rect.y, rect.width, rect.height, true);
}
}
} else {
if (metrics.keyboardFillUnselected) {
if (keyType == KeyboardKeyType::Disabled) {
if (cr > 0) {
renderer.fillRoundedRect(rect.x, rect.y, rect.width, rect.height, cr, Color::LightGray);
} else {
renderer.fillRectDither(rect.x, rect.y, rect.width, rect.height, Color::LightGray);
}
} else {
if (cr > 0) {
renderer.fillRoundedRect(rect.x, rect.y, rect.width, rect.height, cr, Color::White);
} else {
renderer.fillRect(rect.x, rect.y, rect.width, rect.height, false);
}
}
}
const bool shouldDrawOutline =
(metrics.keyboardDrawSpecialOutlineWhenUnselected && isSpecialKey) || metrics.keyboardOutlineAllUnselected;
if (shouldDrawOutline) {
if (cr > 0) {
renderer.drawRoundedRect(rect.x, rect.y, rect.width, rect.height, 1, cr, true);
} else {
renderer.drawRect(rect.x, rect.y, rect.width, rect.height);
}
}
}
const bool invert = isSelected && !inactiveSelection;
if (keyType == KeyboardKeyType::Space) {
const int lineHalfWidth = rect.width * 3 / 10;
const int centerX = rect.x + rect.width / 2;
const int lineY = rect.y + rect.height / 2 + 3;
renderer.drawLine(centerX - lineHalfWidth, lineY, centerX + lineHalfWidth, lineY, 3, !invert);
return;
}
if (keyType == KeyboardKeyType::Del) {
const int centerX = rect.x + rect.width / 2;
const int centerY = rect.y + rect.height / 2;
const int arrowLen = rect.width / 4;
const int arrowHead = std::max(metrics.keyboardMinArrowHeadSize, arrowLen / 2);
renderer.drawLine(centerX - arrowLen / 2, centerY, centerX + arrowLen / 2, centerY, 3, !invert);
renderer.drawLine(centerX - arrowLen / 2, centerY, centerX - arrowLen / 2 + arrowHead, centerY - arrowHead, 3,
!invert);
renderer.drawLine(centerX - arrowLen / 2, centerY, centerX - arrowLen / 2 + arrowHead, centerY + arrowHead, 3,
!invert);
return;
}
if (label == nullptr || label[0] == '\0') {
return;
}
const bool hasSecondary = secondaryLabel != nullptr && secondaryLabel[0] != '\0';
const int itemWidth = renderer.getTextWidth(UI_12_FONT_ID, label);
const int textX = rect.x + (rect.width - itemWidth) / 2;
const int textY = rect.y + (rect.height - renderer.getLineHeight(UI_12_FONT_ID)) / 2;
renderer.drawText(UI_12_FONT_ID, textX, textY, label, !invert);
if (hasSecondary) {
const int secWidth = renderer.getTextWidth(SMALL_FONT_ID, secondaryLabel);
renderer.drawText(SMALL_FONT_ID, rect.x + rect.width - secWidth - metrics.keyboardSecondaryLabelRightPadding,
rect.y + metrics.keyboardSecondaryLabelTopPadding, secondaryLabel, !invert);
}
}
void BaseTheme::drawOptionPopup(const GfxRenderer& renderer, const char* title, const std::vector<std::string>& options,
int selectedIndex) const {
const auto& metrics = UITheme::getInstance().getMetrics();
+5 -30
View File
@@ -61,24 +61,12 @@ struct ThemeMetrics {
int progressBarMarginTop;
int statusBarHorizontalMargin;
int statusBarVerticalMargin;
int keyboardKeyWidth;
int keyboardKeyHeight;
int keyboardKeySpacing;
int keyboardBottomKeyHeight;
int keyboardBottomKeySpacing;
bool keyboardBottomAligned;
bool keyboardCenteredText;
int keyboardVerticalOffset;
int keyboardTextFieldWidthPercent;
int keyboardWidthPercent;
int keyboardKeyCornerRadius;
bool keyboardFillUnselected;
bool keyboardOutlineAllUnselected;
bool keyboardDrawSpecialOutlineWhenUnselected;
int keyboardSecondaryLabelRightPadding;
int keyboardSecondaryLabelTopPadding;
int keyboardMinArrowHeadSize;
float popupTopOffsetRatio;
int popupMarginX;
@@ -115,8 +103,6 @@ struct ThemeMetrics {
enum UIIcon { None = 0, Folder, Text, Image, Book, File, Recent, Settings, Transfer, Library, Wifi, Hotspot, Bookmark };
enum class KeyboardKeyType { Normal, Shift, Mode, Space, Del, Ok, Disabled };
// Default theme implementation (Classic Theme)
// Additional themes can inherit from this and override methods as needed
@@ -150,23 +136,12 @@ constexpr ThemeMetrics values = {.batteryWidth = 15,
.progressBarMarginTop = 1,
.statusBarHorizontalMargin = 5,
.statusBarVerticalMargin = 19,
.keyboardKeyWidth = 22,
.keyboardKeyHeight = 40,
.keyboardKeyHeight = 48,
.keyboardKeySpacing = 0,
.keyboardBottomKeyHeight = 35,
.keyboardBottomKeySpacing = 5,
.keyboardBottomAligned = true,
.keyboardCenteredText = false,
.keyboardVerticalOffset = -13,
.keyboardTextFieldWidthPercent = 85,
.keyboardWidthPercent = 90,
.keyboardKeyCornerRadius = 0,
.keyboardFillUnselected = false,
.keyboardOutlineAllUnselected = false,
.keyboardDrawSpecialOutlineWhenUnselected = true,
.keyboardSecondaryLabelRightPadding = 1,
.keyboardSecondaryLabelTopPadding = 0,
.keyboardMinArrowHeadSize = 0,
.keyboardWidthPercent = 94,
.popupTopOffsetRatio = 0.075f,
.popupMarginX = 15,
.popupMarginY = 15,
@@ -212,6 +187,7 @@ class BaseTheme {
virtual void drawButtonHints(GfxRenderer& renderer, const char* btn1, const char* btn2, const char* btn3,
const char* btn4) const;
virtual void drawSideButtonHints(const GfxRenderer& renderer, const char* topBtn, const char* bottomBtn) const;
virtual int getListRowStep(bool hasSubtitle) const;
virtual int getListPageItems(int contentHeight, bool hasSubtitle) const;
virtual void drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex,
const std::function<std::string(int index)>& rowTitle,
@@ -225,6 +201,8 @@ class BaseTheme {
const char* rightLabel = nullptr) const;
virtual void drawTabBar(const GfxRenderer& renderer, Rect rect, const std::vector<TabInfo>& tabs,
bool selected) const;
virtual bool tabIndexFromPoint(const GfxRenderer& renderer, Rect rect, const std::vector<TabInfo>& tabs, int x, int y,
int& index) const;
virtual void drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector<RecentBook>& recentBooks,
const int selectorIndex, bool& coverRendered, bool& coverBufferStored,
bool& bufferRestored, std::function<bool()> storeCoverBuffer) const;
@@ -242,9 +220,6 @@ class BaseTheme {
void drawHelpText(const GfxRenderer& renderer, Rect rect, const char* label) const;
virtual void drawTextField(const GfxRenderer& renderer, Rect rect, const int textWidth, bool cursorMode = false,
int contentStartX = 0, int contentWidth = 0) const;
virtual void drawKeyboardKey(const GfxRenderer& renderer, Rect rect, const char* label, const bool isSelected,
const char* secondaryLabel = nullptr, KeyboardKeyType keyType = KeyboardKeyType::Normal,
bool inactiveSelection = false) const;
virtual bool showsFileIcons() const { return false; }
// Shared constants and helpers for battery drawing (used by all themes)
+40 -3
View File
@@ -6,6 +6,7 @@
#include <HalStorage.h>
#include <I18n.h>
#include <algorithm>
#include <cstdint>
#include <string>
#include <vector>
@@ -205,9 +206,37 @@ void LyraTheme::drawTabBar(const GfxRenderer& renderer, Rect rect, const std::ve
renderer.drawLine(rect.x, rect.y + rect.height - 1, rect.x + rect.width - 1, rect.y + rect.height - 1, true);
}
int LyraTheme::getListPageItems(int contentHeight, bool hasSubtitle) const {
bool LyraTheme::tabIndexFromPoint(const GfxRenderer& renderer, const Rect rect, const std::vector<TabInfo>& tabs,
const int x, const int y, int& index) const {
if (tabs.empty() || y < rect.y || y >= rect.y + rect.height) {
return false;
}
int currentX = rect.x + LyraMetrics::values.contentSidePadding;
for (size_t i = 0; i < tabs.size(); i++) {
const int textWidth = renderer.getTextWidth(UI_10_FONT_ID, tabs[i].label, EpdFontFamily::REGULAR);
const int tabWidth = textWidth + 2 * hPaddingInSelection;
const int left = (i == 0) ? rect.x : currentX - LyraMetrics::values.tabSpacing / 2;
const int right = currentX + tabWidth + LyraMetrics::values.tabSpacing / 2;
if (x >= left && x < right) {
index = static_cast<int>(i);
return true;
}
currentX += tabWidth + LyraMetrics::values.tabSpacing;
}
return false;
}
int LyraTheme::getListRowStep(bool hasSubtitle) const {
int rowHeight = (hasSubtitle) ? LyraMetrics::values.listWithSubtitleRowHeight : LyraMetrics::values.listRowHeight;
return contentHeight / rowHeight;
return rowHeight;
}
int LyraTheme::getListPageItems(int contentHeight, bool hasSubtitle) const {
const int rowStep = getListRowStep(hasSubtitle);
if (rowStep <= 0) return 1;
return std::max(1, contentHeight / rowStep);
}
void LyraTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex,
@@ -218,7 +247,7 @@ void LyraTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount,
const std::function<bool(int index)>& rowDimmed) const {
int rowHeight =
(rowSubtitle != nullptr) ? LyraMetrics::values.listWithSubtitleRowHeight : LyraMetrics::values.listRowHeight;
int pageItems = rect.height / rowHeight;
int pageItems = rowHeight > 0 ? std::max(1, rect.height / rowHeight) : 1;
const int totalPages = (itemCount + pageItems - 1) / pageItems;
if (totalPages > 1) {
@@ -319,6 +348,10 @@ void LyraTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount,
void LyraTheme::drawButtonHints(GfxRenderer& renderer, const char* btn1, const char* btn2, const char* btn3,
const char* btn4) const {
if (gpio.hasTouch()) {
return;
}
const GfxRenderer::Orientation orig_orientation = renderer.getOrientation();
renderer.setOrientation(GfxRenderer::Orientation::Portrait);
@@ -357,6 +390,10 @@ void LyraTheme::drawButtonHints(GfxRenderer& renderer, const char* btn1, const c
}
void LyraTheme::drawSideButtonHints(const GfxRenderer& renderer, const char* topBtn, const char* bottomBtn) const {
if (gpio.hasTouch()) {
return;
}
const int screenWidth = renderer.getScreenWidth();
constexpr int buttonWidth = LyraMetrics::values.sideButtonHintsWidth; // Width on screen (height when rotated)
constexpr int buttonHeight = 78; // Height on screen (width when rotated)
+5 -13
View File
@@ -35,23 +35,12 @@ constexpr ThemeMetrics values = {.batteryWidth = 16,
.progressBarMarginTop = 1,
.statusBarHorizontalMargin = 5,
.statusBarVerticalMargin = 19,
.keyboardKeyWidth = 31,
.keyboardKeyHeight = 40,
.keyboardKeyHeight = 48,
.keyboardKeySpacing = 0,
.keyboardBottomKeyHeight = 35,
.keyboardBottomKeySpacing = 5,
.keyboardBottomAligned = true,
.keyboardCenteredText = false,
.keyboardVerticalOffset = -7,
.keyboardTextFieldWidthPercent = 85,
.keyboardWidthPercent = 90,
.keyboardKeyCornerRadius = 6,
.keyboardFillUnselected = false,
.keyboardOutlineAllUnselected = false,
.keyboardDrawSpecialOutlineWhenUnselected = true,
.keyboardSecondaryLabelRightPadding = 1,
.keyboardSecondaryLabelTopPadding = 0,
.keyboardMinArrowHeadSize = 0,
.keyboardWidthPercent = 94,
.popupTopOffsetRatio = 0.165f,
.popupMarginX = 16,
.popupMarginY = 12,
@@ -92,6 +81,9 @@ class LyraTheme : public BaseTheme {
const char* rightLabel = nullptr) const override;
void drawTabBar(const GfxRenderer& renderer, Rect rect, const std::vector<TabInfo>& tabs,
bool selected) const override;
bool tabIndexFromPoint(const GfxRenderer& renderer, Rect rect, const std::vector<TabInfo>& tabs, int x, int y,
int& index) const override;
int getListRowStep(bool hasSubtitle) const override;
int getListPageItems(int contentHeight, bool hasSubtitle) const override;
void drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex,
const std::function<std::string(int index)>& rowTitle,
@@ -1,6 +1,7 @@
#include "RoundedRaffTheme.h"
#include <GfxRenderer.h>
#include <HalGPIO.h>
#include <HalStorage.h>
#include <I18n.h>
@@ -112,6 +113,18 @@ void RoundedRaffTheme::drawTabBar(const GfxRenderer& renderer, Rect rect, const
renderer.drawLine(rect.x, rect.y + rect.height - 1, rect.x + rect.width - 1, rect.y + rect.height - 1, true);
}
bool RoundedRaffTheme::tabIndexFromPoint(const GfxRenderer& renderer, const Rect rect, const std::vector<TabInfo>& tabs,
const int x, const int y, int& index) const {
(void)renderer;
if (tabs.empty() || y < rect.y || y >= rect.y + rect.height || x < rect.x || x >= rect.x + rect.width) {
return false;
}
const int slotWidth = std::max(1, rect.width / static_cast<int>(tabs.size()));
index = std::min(static_cast<int>(tabs.size()) - 1, (x - rect.x) / slotWidth);
return true;
}
void RoundedRaffTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector<RecentBook>& recentBooks,
const int selectorIndex, bool& coverRendered, bool& coverBufferStored,
bool& bufferRestored, std::function<bool()> storeCoverBuffer) const {
@@ -248,57 +261,14 @@ void RoundedRaffTheme::drawTextField(const GfxRenderer& renderer, Rect rect, con
renderer.drawLine(lineStart, lineY, lineStart + lineW - 1, lineY, thickness, true);
}
void RoundedRaffTheme::drawKeyboardKey(const GfxRenderer& renderer, Rect rect, const char* label, const bool isSelected,
const char* secondaryLabel, const KeyboardKeyType keyType,
const bool inactiveSelection) const {
constexpr int keyRadius = 10;
const bool disabled = keyType == KeyboardKeyType::Disabled;
const bool invert = isSelected && !inactiveSelection;
int RoundedRaffTheme::getListRowStep(bool hasSubtitle) const {
const int rowHeight =
hasSubtitle ? RoundedRaffMetrics::values.listWithSubtitleRowHeight : RoundedRaffMetrics::values.listRowHeight;
return rowHeight + kSelectableRowGap;
}
if (isSelected) {
const Color fillColor = (inactiveSelection || disabled) ? Color::LightGray : Color::Black;
renderer.fillRoundedRect(rect.x, rect.y, rect.width, rect.height, keyRadius, fillColor);
} else {
if (disabled) {
renderer.fillRoundedRect(rect.x, rect.y, rect.width, rect.height, keyRadius, Color::LightGray);
} else {
renderer.fillRoundedRect(rect.x, rect.y, rect.width, rect.height, keyRadius, Color::White);
}
renderer.drawRoundedRect(rect.x, rect.y, rect.width, rect.height, 1, keyRadius, true);
}
if (keyType == KeyboardKeyType::Space) {
const int lineHalfWidth = rect.width * 3 / 10;
const int centerX = rect.x + rect.width / 2;
const int lineY = rect.y + rect.height / 2 + 3;
renderer.drawLine(centerX - lineHalfWidth, lineY, centerX + lineHalfWidth, lineY, 3, !invert);
return;
}
if (keyType == KeyboardKeyType::Del) {
const int centerX = rect.x + rect.width / 2;
const int centerY = rect.y + rect.height / 2;
const int arrowLen = rect.width / 4;
const int arrowHead = std::max(1, arrowLen / 2);
renderer.drawLine(centerX - arrowLen / 2, centerY, centerX + arrowLen / 2, centerY, 3, !invert);
renderer.drawLine(centerX - arrowLen / 2, centerY, centerX - arrowLen / 2 + arrowHead, centerY - arrowHead, 3,
!invert);
renderer.drawLine(centerX - arrowLen / 2, centerY, centerX - arrowLen / 2 + arrowHead, centerY + arrowHead, 3,
!invert);
return;
}
if (label != nullptr && label[0] != '\0') {
const int itemWidth = renderer.getTextWidth(UI_12_FONT_ID, label);
const int textX = rect.x + (rect.width - itemWidth) / 2;
const int textY = rect.y + (rect.height - renderer.getLineHeight(UI_12_FONT_ID)) / 2;
renderer.drawText(UI_12_FONT_ID, textX, textY, label, !invert);
}
if (secondaryLabel != nullptr && secondaryLabel[0] != '\0') {
const int secWidth = renderer.getTextWidth(SMALL_FONT_ID, secondaryLabel);
renderer.drawText(SMALL_FONT_ID, rect.x + rect.width - secWidth - 3, rect.y + 1, secondaryLabel, !invert);
}
int RoundedRaffTheme::getListPageItems(int contentHeight, bool hasSubtitle) const {
return std::max(1, contentHeight / getListRowStep(hasSubtitle));
}
void RoundedRaffTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex,
@@ -383,6 +353,10 @@ void RoundedRaffTheme::drawList(const GfxRenderer& renderer, Rect rect, int item
void RoundedRaffTheme::drawButtonHints(GfxRenderer& renderer, const char* btn1, const char* btn2, const char* btn3,
const char* btn4) const {
if (gpio.hasTouch()) {
return;
}
const GfxRenderer::Orientation origOrientation = renderer.getOrientation();
renderer.setOrientation(GfxRenderer::Orientation::Portrait);
@@ -35,23 +35,12 @@ constexpr ThemeMetrics values = {.batteryWidth = 15,
.progressBarMarginTop = 1,
.statusBarHorizontalMargin = 5,
.statusBarVerticalMargin = 19,
.keyboardKeyWidth = 22,
.keyboardKeyHeight = 30,
.keyboardKeyHeight = 36,
.keyboardKeySpacing = 10,
.keyboardBottomKeyHeight = 30,
.keyboardBottomKeySpacing = 5,
.keyboardBottomAligned = true,
.keyboardCenteredText = false,
.keyboardVerticalOffset = 0,
.keyboardTextFieldWidthPercent = 85,
.keyboardWidthPercent = 90,
.keyboardKeyCornerRadius = 10,
.keyboardFillUnselected = true,
.keyboardOutlineAllUnselected = true,
.keyboardDrawSpecialOutlineWhenUnselected = true,
.keyboardSecondaryLabelRightPadding = 3,
.keyboardSecondaryLabelTopPadding = 1,
.keyboardMinArrowHeadSize = 1,
.keyboardWidthPercent = 94,
.popupTopOffsetRatio = 0.12f,
.popupMarginX = 20,
.popupMarginY = 14,
@@ -89,6 +78,8 @@ class RoundedRaffTheme : public BaseTheme {
const char* subtitle = nullptr) const override;
void drawTabBar(const GfxRenderer& renderer, Rect rect, const std::vector<TabInfo>& tabs,
bool selected) const override;
bool tabIndexFromPoint(const GfxRenderer& renderer, Rect rect, const std::vector<TabInfo>& tabs, int x, int y,
int& index) const override;
void drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector<RecentBook>& recentBooks,
int selectorIndex, bool& coverRendered, bool& coverBufferStored, bool& bufferRestored,
std::function<bool()> storeCoverBuffer) const override;
@@ -97,9 +88,8 @@ class RoundedRaffTheme : public BaseTheme {
const std::function<UIIcon(int index)>& rowIcon) const override;
void drawTextField(const GfxRenderer& renderer, Rect rect, int textWidth, bool cursorMode = false,
int contentStartX = 0, int contentWidth = 0) const override;
void drawKeyboardKey(const GfxRenderer& renderer, Rect rect, const char* label, bool isSelected,
const char* secondaryLabel = nullptr, KeyboardKeyType keyType = KeyboardKeyType::Normal,
bool inactiveSelection = false) const override;
int getListRowStep(bool hasSubtitle) const override;
int getListPageItems(int contentHeight, bool hasSubtitle) const override;
void drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex,
const std::function<std::string(int index)>& rowTitle,
const std::function<std::string(int index)>& rowSubtitle = nullptr,
+7 -1
View File
@@ -1,4 +1,5 @@
#include <Arduino.h>
#include <BoardConfig.h>
#include <Epub.h>
#include <FontCacheManager.h>
#include <FontDecompressor.h>
@@ -260,6 +261,8 @@ void setupDisplayAndFonts(bool seamless = false) {
}
void setup() {
BoardConfig::holdPowerRails();
t1 = millis();
#ifdef ENABLE_SERIAL_LOG
@@ -270,7 +273,9 @@ void setup() {
// worked without the delay because USB was already enumerated.
delay(250);
Serial.begin(115200);
#if LOG_SERIAL_HAS_TX_TIMEOUT
logSerial.setTxTimeoutMs(1); // This is a load-bearing 1. Do not modify.
#endif
#endif
HalSystem::begin();
@@ -444,6 +449,7 @@ void loop() {
const unsigned long loopStartTime = millis();
static unsigned long lastMemPrint = 0;
gpio.setSharedConfirmPowerShortPressEmitsPower(SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::SLEEP);
gpio.update();
halTiltSensor.update(SETTINGS.tiltPageTurn, SETTINGS.orientation, activityManager.isReaderActivity());
@@ -474,7 +480,7 @@ void loop() {
// Check for any user activity (button press or release) or active background work
static unsigned long lastActivityTime = millis();
if (gpio.wasAnyPressed() || gpio.wasAnyReleased() || halTiltSensor.hadActivity() ||
if (gpio.wasAnyPressed() || gpio.wasAnyReleased() || gpio.wasTouchActivity() || halTiltSensor.hadActivity() ||
activityManager.preventAutoSleep()) {
lastActivityTime = millis(); // Reset inactivity timer
powerManager.setPowerSaving(false); // Restore normal CPU frequency on user activity
+23 -19
View File
@@ -8,7 +8,6 @@
#include <WiFi.h>
#include <esp_efuse.h>
#include <esp_efuse_table.h>
#include <esp_task_wdt.h>
#include <algorithm>
#include <cctype>
@@ -26,6 +25,7 @@
#include "html/SettingsPageHtml.generated.h"
#include "html/js/jszip_minJs.generated.h"
#include "util/BookCacheUtils.h"
#include "util/TaskWatchdog.h"
namespace {
// Folders/files to hide from the web interface file browser
@@ -394,6 +394,8 @@ void CrossPointWebServer::handleStatus() const {
char snBuf[33] = {0};
bool valid = false;
#if !CONFIG_IDF_TARGET_ESP32
// Classic ESP32's efuse table has no USER_DATA block (C3/S3 only)
if (esp_efuse_read_field_blob(ESP_EFUSE_USER_DATA, snBuf, 256) == ESP_OK) {
valid = snBuf[0] != '\0' && snBuf[0] != (char)0xFF;
for (int i = 0; i < 32 && snBuf[i] != '\0'; i++) {
@@ -403,6 +405,7 @@ void CrossPointWebServer::handleStatus() const {
}
}
}
#endif
if (valid) {
doc["serial"] = snBuf;
@@ -466,8 +469,8 @@ void CrossPointWebServer::scanFiles(const char* path, const std::function<void(F
}
file.close();
yield(); // Yield to allow WiFi and other tasks to process during long scans
esp_task_wdt_reset(); // Reset watchdog to prevent timeout on large directories
yield(); // Yield to allow WiFi and other tasks to process during long scans
resetTaskWatchdogIfSubscribed(); // Reset watchdog to prevent timeout on large directories
file = root.openNextFile();
}
root.close();
@@ -598,7 +601,7 @@ void CrossPointWebServer::handleDownload() const {
size_t bytesRead = static_cast<size_t>(result);
size_t totalWritten = 0;
while (totalWritten < bytesRead) {
esp_task_wdt_reset();
resetTaskWatchdogIfSubscribed();
size_t wrote = client.write(buffer + totalWritten, bytesRead - totalWritten);
if (wrote == 0) {
downloadOk = false;
@@ -618,12 +621,12 @@ static size_t writeCount = 0;
static bool flushUploadBuffer(CrossPointWebServer::UploadState& state) {
if (state.bufferPos > 0 && state.file) {
esp_task_wdt_reset(); // Reset watchdog before potentially slow SD write
resetTaskWatchdogIfSubscribed(); // Reset watchdog before potentially slow SD write
const unsigned long writeStart = millis();
const size_t written = state.file.write(state.buffer.data(), state.bufferPos);
totalWriteTime += millis() - writeStart;
writeCount++;
esp_task_wdt_reset(); // Reset watchdog after SD write
resetTaskWatchdogIfSubscribed(); // Reset watchdog after SD write
if (written != state.bufferPos) {
LOG_DBG("WEB", "[UPLOAD] Buffer flush failed: expected %d, wrote %d", state.bufferPos, written);
@@ -639,7 +642,7 @@ void CrossPointWebServer::handleUpload(UploadState& state) const {
static size_t lastLoggedSize = 0;
// Reset watchdog at start of every upload callback - HTTP parsing can be slow
esp_task_wdt_reset();
resetTaskWatchdogIfSubscribed();
// Safety check: ensure server is still valid
if (!running || !server) {
@@ -651,7 +654,7 @@ void CrossPointWebServer::handleUpload(UploadState& state) const {
if (upload.status == UPLOAD_FILE_START) {
// Reset watchdog - this is the critical 1% crash point
esp_task_wdt_reset();
resetTaskWatchdogIfSubscribed();
state.fileName = upload.filename;
state.size = 0;
@@ -687,7 +690,8 @@ void CrossPointWebServer::handleUpload(UploadState& state) const {
if (!filePath.endsWith("/")) filePath += "/";
filePath += state.fileName;
esp_task_wdt_reset();
// Check if file already exists - SD operations can be slow
resetTaskWatchdogIfSubscribed();
if (Storage.exists(filePath.c_str())) {
state.error = "File already exists: " + state.fileName;
LOG_DBG("WEB", "[UPLOAD] Collision: %s", filePath.c_str());
@@ -695,13 +699,13 @@ void CrossPointWebServer::handleUpload(UploadState& state) const {
}
// Open file for writing - this can be slow due to FAT cluster allocation
esp_task_wdt_reset();
resetTaskWatchdogIfSubscribed();
if (!Storage.openFileForWrite("WEB", filePath, state.file)) {
state.error = "Failed to create file on SD card";
LOG_DBG("WEB", "[UPLOAD] FAILED to create file: %s", filePath.c_str());
return;
}
esp_task_wdt_reset();
resetTaskWatchdogIfSubscribed();
LOG_DBG("WEB", "[UPLOAD] File created successfully: %s", filePath.c_str());
} else if (upload.status == UPLOAD_FILE_WRITE) {
@@ -1638,7 +1642,7 @@ void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t*
if (!filePath.endsWith("/")) filePath += "/";
filePath += wsUploadFileName;
esp_task_wdt_reset();
resetTaskWatchdogIfSubscribed();
if (Storage.exists(filePath.c_str())) {
LOG_DBG("WS", "Upload collision: %s", filePath.c_str());
wsServer->sendTXT(num, "ERROR:File already exists: " + wsUploadFileName);
@@ -1649,14 +1653,14 @@ void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t*
filePath.c_str());
// Open file for writing
esp_task_wdt_reset();
resetTaskWatchdogIfSubscribed();
if (!Storage.openFileForWrite("WS", filePath, wsUploadFile)) {
wsServer->sendTXT(num, "ERROR:Failed to create file");
wsUploadInProgress = false;
wsUploadClientNum = 255;
return;
}
esp_task_wdt_reset();
resetTaskWatchdogIfSubscribed();
// Zero-byte upload: complete immediately without waiting for BIN frames
if (wsUploadSize == 0) {
@@ -1695,9 +1699,9 @@ void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t*
wsServer->sendTXT(num, "ERROR:Upload overflow");
return;
}
esp_task_wdt_reset();
resetTaskWatchdogIfSubscribed();
size_t written = wsUploadFile.write(payload, length);
esp_task_wdt_reset();
resetTaskWatchdogIfSubscribed();
if (written != length) {
abortWsUpload("WS");
@@ -1801,7 +1805,7 @@ void CrossPointWebServer::handleFontUploadData() {
switch (upload.status) {
case UPLOAD_FILE_START: {
esp_task_wdt_reset();
resetTaskWatchdogIfSubscribed();
String family = server->arg("family");
fontUpload.file = HalFile();
fontUpload.familyName.clear();
@@ -1852,7 +1856,7 @@ void CrossPointWebServer::handleFontUploadData() {
case UPLOAD_FILE_WRITE: {
if (!fontUpload.valid) break;
esp_task_wdt_reset();
resetTaskWatchdogIfSubscribed();
// Validate magic bytes on first chunk only
if (!fontUpload.magicChecked && upload.currentSize >= 8) {
@@ -1879,7 +1883,7 @@ void CrossPointWebServer::handleFontUploadData() {
fontUpload.file.write(fontUpload.buffer.data(), fontUpload.bufferPos);
fontUpload.bytesWritten += fontUpload.bufferPos;
fontUpload.bufferPos = 0;
esp_task_wdt_reset();
resetTaskWatchdogIfSubscribed();
}
}
break;
+4 -4
View File
@@ -3,9 +3,9 @@
#include <FsHelpers.h>
#include <HalStorage.h>
#include <Logging.h>
#include <esp_task_wdt.h>
#include "util/BookCacheUtils.h"
#include "util/TaskWatchdog.h"
namespace {
constexpr const char* HIDDEN_ITEMS[] = {"System Volume Information", "XTCache"};
@@ -84,7 +84,7 @@ void WebDAVHandler::raw(WebServer& server, const String& uri, HTTPRaw& raw) {
} else if (raw.status == RAW_WRITE) {
if (_putFile && _putOk) {
esp_task_wdt_reset();
resetTaskWatchdogIfSubscribed();
size_t written = _putFile.write(raw.buf, raw.currentSize);
if (written != raw.currentSize) {
_putOk = false;
@@ -252,7 +252,7 @@ void WebDAVHandler::handlePropfind(WebServer& s) {
file.close();
yield();
esp_task_wdt_reset();
resetTaskWatchdogIfSubscribed();
file = root.openNextFile();
}
}
@@ -628,7 +628,7 @@ void WebDAVHandler::handleCopy(WebServer& s) {
uint8_t buf[4096];
bool copyOk = true;
while (srcFile.available()) {
esp_task_wdt_reset();
resetTaskWatchdogIfSubscribed();
int bytesRead = srcFile.read(buf, sizeof(buf));
if (bytesRead <= 0) break;
size_t written = dstFile.write(buf, bytesRead);
+10
View File
@@ -0,0 +1,10 @@
#pragma once
#include <esp_err.h>
#include <esp_task_wdt.h>
inline void resetTaskWatchdogIfSubscribed() {
if (esp_task_wdt_status(nullptr) == ESP_OK) {
esp_task_wdt_reset();
}
}