Introduce extended button handler

This commit is contained in:
jpirnay
2026-04-26 12:26:29 +02:00
parent 499702a754
commit a1d0ddec49
19 changed files with 833 additions and 33 deletions
+27
View File
@@ -547,3 +547,30 @@ STR_CAPTIVE_PORTAL_DETECTED: "Login Required"
STR_CAPTIVE_PORTAL_HINT_1: "Network requires browser login. On another device,"
STR_CAPTIVE_PORTAL_HINT_2: "visit the URL below to authorize, then press OK."
STR_CAPTIVE_PORTAL_DONE: "I'm authorized"
STR_MENU_BTN_ACTIONS: "Button Actions"
STR_BTN_SHORT_PRESS: "Short Press"
STR_BTN_DOUBLE_PRESS: "Double Press"
STR_BTN_LONG_PRESS: "Long Press"
STR_BTN_BACK: "Back Button"
STR_BTN_CONFIRM: "Confirm Button"
STR_BTN_LEFT: "Left Button"
STR_BTN_RIGHT: "Right Button"
STR_BTN_UP: "Up Button"
STR_BTN_DOWN: "Down Button"
STR_BTN_PAGE_BACK: "Page Back Button"
STR_BTN_PAGE_FORWARD: "Page Forward Button"
STR_BTN_POWER: "Power Button"
STR_BTN_ACT_DEFAULT: "Default"
STR_BTN_ACT_PAGE_FORWARD: "Next Page"
STR_BTN_ACT_PAGE_BACK: "Previous Page"
STR_BTN_ACT_PAGE_FORWARD_10: "Skip 10 Pages Forward"
STR_BTN_ACT_PAGE_BACK_10: "Skip 10 Pages Back"
STR_BTN_ACT_GO_HOME: "Go Home"
STR_BTN_ACT_SLEEP: "Sleep"
STR_BTN_ACT_FORCE_REFRESH: "Refresh Screen"
STR_BTN_ACT_OPEN_TOC: "Open Table of Contents"
STR_BTN_ACT_OPEN_BOOKMARKS: "Open Bookmarks"
STR_BTN_ACT_STAR_PAGE: "Star Page"
STR_BTN_ACT_FOOTNOTES: "Footnotes"
STR_BTN_ACT_NEXT_SECTION: "Next Section / Chapter"
STR_BTN_ACT_PREV_SECTION: "Previous Section / Chapter"
+121
View File
@@ -0,0 +1,121 @@
#include "ButtonEventManager.h"
#include "CrossPointSettings.h"
// Required for constexpr array definition in .cpp
constexpr ButtonEventManager::Button ButtonEventManager::ALL_BUTTONS[ButtonEventManager::NUM_BUTTONS];
bool ButtonEventManager::hasDoubleAction(const Button button) {
using BA = CrossPointSettings::BUTTON_ACTION;
switch (button) {
case Button::Back:
return SETTINGS.btnDoubleBack != BA::BTN_DEFAULT;
case Button::Confirm:
return SETTINGS.btnDoubleConfirm != BA::BTN_DEFAULT;
case Button::Left:
return SETTINGS.btnDoubleLeft != BA::BTN_DEFAULT;
case Button::Right:
return SETTINGS.btnDoubleRight != BA::BTN_DEFAULT;
case Button::Up:
return SETTINGS.btnDoubleUp != BA::BTN_DEFAULT;
case Button::Down:
return SETTINGS.btnDoubleDown != BA::BTN_DEFAULT;
case Button::PageBack:
return SETTINGS.btnDoublePageBack != BA::BTN_DEFAULT;
case Button::PageForward:
return SETTINGS.btnDoublePageForward != BA::BTN_DEFAULT;
case Button::Power:
return SETTINGS.btnDoublePower != BA::BTN_DEFAULT;
}
return false;
}
void ButtonEventManager::pushEvent(const Button button, const PressType type) {
const int next = (eventTail + 1) % EVENT_BUF;
if (next == eventHead) return; // buffer full, drop oldest not possible — just drop newest
eventBuf[eventTail] = {button, type};
eventTail = next;
}
bool ButtonEventManager::consumeEvent(ButtonEvent& out) {
if (eventHead == eventTail) return false;
out = eventBuf[eventHead];
eventHead = (eventHead + 1) % EVENT_BUF;
return true;
}
void ButtonEventManager::drain() {
for (auto& b : buttons) {
b.state = State::Idle;
b.pressDownTime = 0;
b.releaseTime = 0;
}
eventHead = eventTail = 0;
}
void ButtonEventManager::processButton(const int idx, const Button btn) {
PerButton& s = buttons[idx];
const unsigned long now = millis();
const bool pressed = input.wasPressed(btn);
const bool released = input.wasReleased(btn);
const bool held = input.isPressed(btn);
switch (s.state) {
case State::Idle:
if (pressed) {
s.state = State::Pressed;
s.pressDownTime = now;
}
break;
case State::Pressed:
if (released) {
const unsigned long heldMs = now - s.pressDownTime;
if (heldMs >= LONG_PRESS_MS) {
pushEvent(btn, PressType::Long);
s.state = State::Idle;
} else if (hasDoubleAction(btn)) {
// Delay short-press decision until double-click window expires
s.releaseTime = now;
s.state = State::ReleasedOnce;
} else {
// No double action configured — fire immediately
pushEvent(btn, PressType::Short);
s.state = State::Idle;
}
} else if (!held) {
// Button disappeared without wasReleased edge (e.g. after drain) — reset
s.state = State::Idle;
}
break;
case State::ReleasedOnce:
if (pressed) {
// Second press within window — start tracking it
s.state = State::DoublePressed;
s.pressDownTime = now;
} else if (now - s.releaseTime >= DOUBLE_WINDOW_MS) {
// Window expired without a second press — it was a short press
pushEvent(btn, PressType::Short);
s.state = State::Idle;
}
break;
case State::DoublePressed:
if (released) {
pushEvent(btn, PressType::Double);
s.state = State::Idle;
} else if (!held) {
// Disappeared without edge — treat as double anyway
pushEvent(btn, PressType::Double);
s.state = State::Idle;
}
break;
}
}
void ButtonEventManager::update() {
for (int i = 0; i < NUM_BUTTONS; i++) {
processButton(i, ALL_BUTTONS[i]);
}
}
+84
View File
@@ -0,0 +1,84 @@
#pragma once
#include <Arduino.h>
#include "MappedInputManager.h"
// Forward declaration for the global accessor used by Activity.h.
// Defined in main.cpp alongside the ButtonEventManager instance.
class ButtonEventManager;
ButtonEventManager& globalButtonEvents();
// Classifies raw button edges into Short, Double, and Long press events.
//
// Per-button state machines run each loop() tick. The key latency rule:
// - If no double-click action is configured for a button, Short fires immediately
// on release (zero extra wait).
// - If a double-click action IS configured, Short is delayed by DOUBLE_WINDOW_MS
// to allow disambiguation.
// - Long fires on release once hold time >= LONG_PRESS_MS (no extra wait).
// - Double fires on the second release within DOUBLE_WINDOW_MS.
//
// Activities query consumeEvent() each loop tick to receive pending events.
// drain() resets all state machines — call it on activity transitions.
class ButtonEventManager {
public:
using Button = MappedInputManager::Button;
static constexpr int NUM_BUTTONS = 9; // matches MappedInputManager::Button count
enum class PressType { Short, Double, Long };
struct ButtonEvent {
Button button;
PressType type;
};
// Timing constants (milliseconds)
static constexpr unsigned long LONG_PRESS_MS = 600;
static constexpr unsigned long DOUBLE_WINDOW_MS = 300;
explicit ButtonEventManager(MappedInputManager& input) : input(input) {}
// Call once per main loop tick, after MappedInputManager::update().
void update();
// Returns the next pending event, or false if none. Call repeatedly until
// false to drain all events for this tick.
bool consumeEvent(ButtonEvent& out);
// Reset all per-button FSMs. Call on activity transitions to prevent bleed-through.
void drain();
// Returns true if a double-click action is configured for this button.
// ButtonEventManager queries CrossPointSettings internally.
static bool hasDoubleAction(Button button);
private:
static constexpr Button ALL_BUTTONS[NUM_BUTTONS] = {
Button::Back, Button::Confirm, Button::Left, Button::Right, Button::Up,
Button::Down, Button::PageBack, Button::PageForward, Button::Power,
};
enum class State { Idle, Pressed, ReleasedOnce, DoublePressed };
struct PerButton {
State state = State::Idle;
unsigned long pressDownTime = 0; // when the current (or first) press started
unsigned long releaseTime = 0; // when the first release happened (for double-click window)
};
PerButton buttons[NUM_BUTTONS];
// Pending events ring buffer (small — at most one event per button per tick)
static constexpr int EVENT_BUF = 16;
ButtonEvent eventBuf[EVENT_BUF] = {};
int eventHead = 0;
int eventTail = 0;
MappedInputManager& input;
void pushEvent(Button button, PressType type);
void processButton(int idx, Button btn);
};
+4 -1
View File
@@ -177,7 +177,10 @@ bool CrossPointSettings::loadFromBinaryFile() {
if (++settingsRead >= fileSettingsCount) break;
readAndValidate(inputFile, hideBatteryPercentage, HIDE_BATTERY_PERCENTAGE_COUNT);
if (++settingsRead >= fileSettingsCount) break;
serialization::readPod(inputFile, longPressChapterSkip);
{
uint8_t _unused;
serialization::readPod(inputFile, _unused);
} // was longPressChapterSkip
if (++settingsRead >= fileSettingsCount) break;
serialization::readPod(inputFile, hyphenationEnabled);
if (++settingsRead >= fileSettingsCount) break;
+53 -2
View File
@@ -249,8 +249,6 @@ class CrossPointSettings {
char opdsPassword[64] = "";
// Hide battery percentage
uint8_t hideBatteryPercentage = HIDE_NEVER;
// Long-press chapter skip on side buttons
uint8_t longPressChapterSkip = 1;
// UI Theme
uint8_t uiTheme = LYRA;
// Sunlight fading compensation
@@ -279,6 +277,59 @@ class CrossPointSettings {
// Show the Weather home screen menu item (1 = enabled, 0 = hidden)
uint8_t useWeather = 1;
// Configurable actions for short / double / long press on each logical button.
// BTN_DEFAULT means "use the button's normal built-in behaviour".
enum BUTTON_ACTION {
BTN_DEFAULT = 0,
BTN_PAGE_FORWARD,
BTN_PAGE_BACK,
BTN_PAGE_FORWARD_10,
BTN_PAGE_BACK_10,
BTN_GO_HOME,
BTN_SLEEP,
BTN_FORCE_REFRESH,
BTN_OPEN_TOC,
BTN_OPEN_BOOKMARKS,
BTN_STAR_PAGE,
BTN_FOOTNOTES,
BTN_NEXT_SECTION,
BTN_PREV_SECTION,
BUTTON_ACTION_COUNT
};
// Short-press actions (default: built-in)
uint8_t btnShortBack = BTN_DEFAULT;
uint8_t btnShortConfirm = BTN_DEFAULT;
uint8_t btnShortLeft = BTN_DEFAULT;
uint8_t btnShortRight = BTN_DEFAULT;
uint8_t btnShortUp = BTN_DEFAULT;
uint8_t btnShortDown = BTN_DEFAULT;
uint8_t btnShortPageBack = BTN_DEFAULT;
uint8_t btnShortPageForward = BTN_DEFAULT;
uint8_t btnShortPower = BTN_DEFAULT;
// Double-press actions (default: BTN_DEFAULT = disabled, no disambiguation wait)
uint8_t btnDoubleBack = BTN_DEFAULT;
uint8_t btnDoubleConfirm = BTN_DEFAULT;
uint8_t btnDoubleLeft = BTN_DEFAULT;
uint8_t btnDoubleRight = BTN_DEFAULT;
uint8_t btnDoubleUp = BTN_DEFAULT;
uint8_t btnDoubleDown = BTN_DEFAULT;
uint8_t btnDoublePageBack = BTN_DEFAULT;
uint8_t btnDoublePageForward = BTN_DEFAULT;
uint8_t btnDoublePower = BTN_DEFAULT;
// Long-press actions (default: built-in)
uint8_t btnLongBack = BTN_DEFAULT;
uint8_t btnLongConfirm = BTN_DEFAULT;
uint8_t btnLongLeft = BTN_DEFAULT;
uint8_t btnLongRight = BTN_DEFAULT;
uint8_t btnLongUp = BTN_DEFAULT;
uint8_t btnLongDown = BTN_DEFAULT;
uint8_t btnLongPageBack = BTN_DEFAULT;
uint8_t btnLongPageForward = BTN_DEFAULT;
uint8_t btnLongPower = BTN_DEFAULT;
~CrossPointSettings() = default;
// Get singleton instance
+112 -2
View File
@@ -133,13 +133,123 @@ inline const std::vector<SettingInfo> list = {
// --- Controls ---
SettingInfo::Enum(StrId::STR_SIDE_BTN_LAYOUT, &CrossPointSettings::sideButtonLayout,
{StrId::STR_PREV_NEXT, StrId::STR_NEXT_PREV}, "sideButtonLayout", StrId::STR_CAT_CONTROLS),
SettingInfo::Toggle(StrId::STR_LONG_PRESS_SKIP, &CrossPointSettings::longPressChapterSkip, "longPressChapterSkip",
StrId::STR_CAT_CONTROLS),
SettingInfo::Enum(StrId::STR_SHORT_PWR_BTN, &CrossPointSettings::shortPwrBtn,
{StrId::STR_IGNORE, StrId::STR_SLEEP, StrId::STR_PAGE_TURN, StrId::STR_FORCE_REFRESH,
StrId::STR_FOOTNOTES, StrId::STR_STAR_PAGE},
"shortPwrBtn", StrId::STR_CAT_CONTROLS),
// --- Button Actions (short / double / long press per logical button) ---
// All entries share the same ordered action-label list; the submenu groups them behind
// a single placeholder row in the device UI.
#define BTN_ACTION_ENUM_VALUES \
{StrId::STR_BTN_ACT_DEFAULT, StrId::STR_BTN_ACT_PAGE_FORWARD, StrId::STR_BTN_ACT_PAGE_BACK, \
StrId::STR_BTN_ACT_PAGE_FORWARD_10, StrId::STR_BTN_ACT_PAGE_BACK_10, StrId::STR_BTN_ACT_GO_HOME, \
StrId::STR_BTN_ACT_SLEEP, StrId::STR_BTN_ACT_FORCE_REFRESH, StrId::STR_BTN_ACT_OPEN_TOC, \
StrId::STR_BTN_ACT_OPEN_BOOKMARKS, StrId::STR_BTN_ACT_STAR_PAGE, StrId::STR_BTN_ACT_FOOTNOTES, \
StrId::STR_BTN_ACT_NEXT_SECTION, StrId::STR_BTN_ACT_PREV_SECTION}
// Back button
SettingInfo::Enum(StrId::STR_BTN_SHORT_PRESS, &CrossPointSettings::btnShortBack, BTN_ACTION_ENUM_VALUES,
"btnShortBack", StrId::STR_CAT_CONTROLS)
.withSubcategory(StrId::STR_BTN_BACK)
.withSubmenu(StrId::STR_MENU_BTN_ACTIONS),
SettingInfo::Enum(StrId::STR_BTN_DOUBLE_PRESS, &CrossPointSettings::btnDoubleBack, BTN_ACTION_ENUM_VALUES,
"btnDoubleBack", StrId::STR_CAT_CONTROLS)
.withSubmenu(StrId::STR_MENU_BTN_ACTIONS),
SettingInfo::Enum(StrId::STR_BTN_LONG_PRESS, &CrossPointSettings::btnLongBack, BTN_ACTION_ENUM_VALUES,
"btnLongBack", StrId::STR_CAT_CONTROLS)
.withSubmenu(StrId::STR_MENU_BTN_ACTIONS),
// Confirm button
SettingInfo::Enum(StrId::STR_BTN_SHORT_PRESS, &CrossPointSettings::btnShortConfirm, BTN_ACTION_ENUM_VALUES,
"btnShortConfirm", StrId::STR_CAT_CONTROLS)
.withSubcategory(StrId::STR_BTN_CONFIRM)
.withSubmenu(StrId::STR_MENU_BTN_ACTIONS),
SettingInfo::Enum(StrId::STR_BTN_DOUBLE_PRESS, &CrossPointSettings::btnDoubleConfirm, BTN_ACTION_ENUM_VALUES,
"btnDoubleConfirm", StrId::STR_CAT_CONTROLS)
.withSubmenu(StrId::STR_MENU_BTN_ACTIONS),
SettingInfo::Enum(StrId::STR_BTN_LONG_PRESS, &CrossPointSettings::btnLongConfirm, BTN_ACTION_ENUM_VALUES,
"btnLongConfirm", StrId::STR_CAT_CONTROLS)
.withSubmenu(StrId::STR_MENU_BTN_ACTIONS),
// Left button
SettingInfo::Enum(StrId::STR_BTN_SHORT_PRESS, &CrossPointSettings::btnShortLeft, BTN_ACTION_ENUM_VALUES,
"btnShortLeft", StrId::STR_CAT_CONTROLS)
.withSubcategory(StrId::STR_BTN_LEFT)
.withSubmenu(StrId::STR_MENU_BTN_ACTIONS),
SettingInfo::Enum(StrId::STR_BTN_DOUBLE_PRESS, &CrossPointSettings::btnDoubleLeft, BTN_ACTION_ENUM_VALUES,
"btnDoubleLeft", StrId::STR_CAT_CONTROLS)
.withSubmenu(StrId::STR_MENU_BTN_ACTIONS),
SettingInfo::Enum(StrId::STR_BTN_LONG_PRESS, &CrossPointSettings::btnLongLeft, BTN_ACTION_ENUM_VALUES,
"btnLongLeft", StrId::STR_CAT_CONTROLS)
.withSubmenu(StrId::STR_MENU_BTN_ACTIONS),
// Right button
SettingInfo::Enum(StrId::STR_BTN_SHORT_PRESS, &CrossPointSettings::btnShortRight, BTN_ACTION_ENUM_VALUES,
"btnShortRight", StrId::STR_CAT_CONTROLS)
.withSubcategory(StrId::STR_BTN_RIGHT)
.withSubmenu(StrId::STR_MENU_BTN_ACTIONS),
SettingInfo::Enum(StrId::STR_BTN_DOUBLE_PRESS, &CrossPointSettings::btnDoubleRight, BTN_ACTION_ENUM_VALUES,
"btnDoubleRight", StrId::STR_CAT_CONTROLS)
.withSubmenu(StrId::STR_MENU_BTN_ACTIONS),
SettingInfo::Enum(StrId::STR_BTN_LONG_PRESS, &CrossPointSettings::btnLongRight, BTN_ACTION_ENUM_VALUES,
"btnLongRight", StrId::STR_CAT_CONTROLS)
.withSubmenu(StrId::STR_MENU_BTN_ACTIONS),
// Up button
SettingInfo::Enum(StrId::STR_BTN_SHORT_PRESS, &CrossPointSettings::btnShortUp, BTN_ACTION_ENUM_VALUES, "btnShortUp",
StrId::STR_CAT_CONTROLS)
.withSubcategory(StrId::STR_BTN_UP)
.withSubmenu(StrId::STR_MENU_BTN_ACTIONS),
SettingInfo::Enum(StrId::STR_BTN_DOUBLE_PRESS, &CrossPointSettings::btnDoubleUp, BTN_ACTION_ENUM_VALUES,
"btnDoubleUp", StrId::STR_CAT_CONTROLS)
.withSubmenu(StrId::STR_MENU_BTN_ACTIONS),
SettingInfo::Enum(StrId::STR_BTN_LONG_PRESS, &CrossPointSettings::btnLongUp, BTN_ACTION_ENUM_VALUES, "btnLongUp",
StrId::STR_CAT_CONTROLS)
.withSubmenu(StrId::STR_MENU_BTN_ACTIONS),
// Down button
SettingInfo::Enum(StrId::STR_BTN_SHORT_PRESS, &CrossPointSettings::btnShortDown, BTN_ACTION_ENUM_VALUES,
"btnShortDown", StrId::STR_CAT_CONTROLS)
.withSubcategory(StrId::STR_BTN_DOWN)
.withSubmenu(StrId::STR_MENU_BTN_ACTIONS),
SettingInfo::Enum(StrId::STR_BTN_DOUBLE_PRESS, &CrossPointSettings::btnDoubleDown, BTN_ACTION_ENUM_VALUES,
"btnDoubleDown", StrId::STR_CAT_CONTROLS)
.withSubmenu(StrId::STR_MENU_BTN_ACTIONS),
SettingInfo::Enum(StrId::STR_BTN_LONG_PRESS, &CrossPointSettings::btnLongDown, BTN_ACTION_ENUM_VALUES,
"btnLongDown", StrId::STR_CAT_CONTROLS)
.withSubmenu(StrId::STR_MENU_BTN_ACTIONS),
// Page Back button
SettingInfo::Enum(StrId::STR_BTN_SHORT_PRESS, &CrossPointSettings::btnShortPageBack, BTN_ACTION_ENUM_VALUES,
"btnShortPageBack", StrId::STR_CAT_CONTROLS)
.withSubcategory(StrId::STR_BTN_PAGE_BACK)
.withSubmenu(StrId::STR_MENU_BTN_ACTIONS),
SettingInfo::Enum(StrId::STR_BTN_DOUBLE_PRESS, &CrossPointSettings::btnDoublePageBack, BTN_ACTION_ENUM_VALUES,
"btnDoublePageBack", StrId::STR_CAT_CONTROLS)
.withSubmenu(StrId::STR_MENU_BTN_ACTIONS),
SettingInfo::Enum(StrId::STR_BTN_LONG_PRESS, &CrossPointSettings::btnLongPageBack, BTN_ACTION_ENUM_VALUES,
"btnLongPageBack", StrId::STR_CAT_CONTROLS)
.withSubmenu(StrId::STR_MENU_BTN_ACTIONS),
// Page Forward button
SettingInfo::Enum(StrId::STR_BTN_SHORT_PRESS, &CrossPointSettings::btnShortPageForward, BTN_ACTION_ENUM_VALUES,
"btnShortPageForward", StrId::STR_CAT_CONTROLS)
.withSubcategory(StrId::STR_BTN_PAGE_FORWARD)
.withSubmenu(StrId::STR_MENU_BTN_ACTIONS),
SettingInfo::Enum(StrId::STR_BTN_DOUBLE_PRESS, &CrossPointSettings::btnDoublePageForward, BTN_ACTION_ENUM_VALUES,
"btnDoublePageForward", StrId::STR_CAT_CONTROLS)
.withSubmenu(StrId::STR_MENU_BTN_ACTIONS),
SettingInfo::Enum(StrId::STR_BTN_LONG_PRESS, &CrossPointSettings::btnLongPageForward, BTN_ACTION_ENUM_VALUES,
"btnLongPageForward", StrId::STR_CAT_CONTROLS)
.withSubmenu(StrId::STR_MENU_BTN_ACTIONS),
// Power button
SettingInfo::Enum(StrId::STR_BTN_SHORT_PRESS, &CrossPointSettings::btnShortPower, BTN_ACTION_ENUM_VALUES,
"btnShortPower", StrId::STR_CAT_CONTROLS)
.withSubcategory(StrId::STR_BTN_POWER)
.withSubmenu(StrId::STR_MENU_BTN_ACTIONS),
SettingInfo::Enum(StrId::STR_BTN_DOUBLE_PRESS, &CrossPointSettings::btnDoublePower, BTN_ACTION_ENUM_VALUES,
"btnDoublePower", StrId::STR_CAT_CONTROLS)
.withSubmenu(StrId::STR_MENU_BTN_ACTIONS),
SettingInfo::Enum(StrId::STR_BTN_LONG_PRESS, &CrossPointSettings::btnLongPower, BTN_ACTION_ENUM_VALUES,
"btnLongPower", StrId::STR_CAT_CONTROLS)
.withSubmenu(StrId::STR_MENU_BTN_ACTIONS),
#undef BTN_ACTION_ENUM_VALUES
// --- System ---
SettingInfo::Toggle(StrId::STR_SHOW_HIDDEN_FILES, &CrossPointSettings::showHiddenFiles, "showHiddenFiles",
StrId::STR_CAT_SYSTEM),
+8 -1
View File
@@ -8,6 +8,7 @@
#include "ActivityManager.h" // for using the ActivityManager singleton
#include "ActivityResult.h"
#include "ButtonEventManager.h"
#include "GfxRenderer.h"
#include "MappedInputManager.h"
#include "RenderLock.h"
@@ -19,13 +20,14 @@ class Activity {
std::string name;
GfxRenderer& renderer;
MappedInputManager& mappedInput;
ButtonEventManager& buttonEvents;
ActivityResultHandler resultHandler;
ActivityResult result;
public:
explicit Activity(std::string name, GfxRenderer& renderer, MappedInputManager& mappedInput)
: name(std::move(name)), renderer(renderer), mappedInput(mappedInput) {}
: name(std::move(name)), renderer(renderer), mappedInput(mappedInput), buttonEvents(globalButtonEvents()) {}
virtual ~Activity() = default;
const std::string& getName() const { return name; }
virtual void onEnter();
@@ -45,6 +47,11 @@ class Activity {
virtual bool preventAutoSleep() { return false; }
virtual bool isReaderActivity() const { return false; }
// Called by ActivityManager when a globally-configured button action targets the
// current activity. Override in reader activities to handle reader-specific actions.
// Non-reader activities can ignore this (default is no-op).
virtual void onButtonAction(CrossPointSettings::BUTTON_ACTION) {}
// Start a new activity without destroying the current one
// Note: requestUpdate() will be invoked automatically once resultHandler finishes
void startActivityForResult(std::unique_ptr<Activity>&& activity, ActivityResultHandler resultHandler);
+8
View File
@@ -154,6 +154,7 @@ void ActivityManager::loop() {
// Arm input drain so the button that triggered the pop doesn't bleed into the
// restored activity (or into a new activity the handler just pushed).
drainInput = true;
buttonEvents.drain();
// Request an update to ensure the popped activity gets re-rendered
if (pendingAction == PendingAction::None) {
@@ -204,6 +205,7 @@ void ActivityManager::loop() {
// Arm input drain so the button that triggered the transition doesn't bleed
// into the new activity.
drainInput = true;
buttonEvents.drain();
// onEnter may request another pending action, we will handle it in the next loop iteration
continue;
@@ -387,6 +389,12 @@ bool ActivityManager::isReaderActivity() const { return currentActivity && curre
bool ActivityManager::skipLoopDelay() const { return currentActivity && currentActivity->skipLoopDelay(); }
void ActivityManager::dispatchButtonAction(const CrossPointSettings::BUTTON_ACTION action) {
if (currentActivity) {
currentActivity->onButtonAction(action);
}
}
void ActivityManager::requestUpdate(bool immediate) {
if (immediate) {
if (renderTaskHandle) {
+12 -1
View File
@@ -9,6 +9,8 @@
#include <string>
#include <vector>
#include "ButtonEventManager.h"
#include "CrossPointSettings.h"
#include "GfxRenderer.h"
#include "MappedInputManager.h"
@@ -52,6 +54,7 @@ class ActivityManager {
protected:
GfxRenderer& renderer;
MappedInputManager& mappedInput;
ButtonEventManager& buttonEvents;
std::vector<std::unique_ptr<Activity>> stackActivities;
std::unique_ptr<Activity> currentActivity;
@@ -96,7 +99,10 @@ class ActivityManager {
public:
explicit ActivityManager(GfxRenderer& renderer, MappedInputManager& mappedInput)
: renderer(renderer), mappedInput(mappedInput), renderingMutex(xSemaphoreCreateMutex()) {
: renderer(renderer),
mappedInput(mappedInput),
buttonEvents(globalButtonEvents()),
renderingMutex(xSemaphoreCreateMutex()) {
assert(renderingMutex != nullptr && "Failed to create rendering mutex");
stackActivities.reserve(10);
}
@@ -160,6 +166,11 @@ class ActivityManager {
bool isReaderActivity() const;
bool skipLoopDelay() const;
// Dispatch a globally-configured button action to the current activity.
// Reader-specific actions (page navigation, TOC, bookmarks, footnotes) are forwarded
// only when the current activity is a reader; others are no-ops in other contexts.
void dispatchButtonAction(CrossPointSettings::BUTTON_ACTION action);
// If immediate is true, the update will be triggered immediately.
// Otherwise, it will be deferred until the end of the current loop iteration.
void requestUpdate(bool immediate = false);
+115 -1
View File
@@ -343,7 +343,7 @@ void EpubReaderActivity::loop() {
return;
}
const bool skipChapter = SETTINGS.longPressChapterSkip && mappedInput.getHeldTime() > skipChapterMs;
const bool skipChapter = mappedInput.getHeldTime() > skipChapterMs;
// Chapter skip navigates by TOC entries, not spine boundaries.
// Spine items without their own TOC entry inherit the previous spine's tocIndex
@@ -1795,3 +1795,117 @@ bool EpubReaderActivity::drawCurrentPageToBuffer(const std::string& filePath, Gf
// No displayBuffer call — caller (SleepActivity) handles that after compositing the overlay
return true;
}
void EpubReaderActivity::onButtonAction(const CrossPointSettings::BUTTON_ACTION action) {
using BA = CrossPointSettings::BUTTON_ACTION;
switch (action) {
case BA::BTN_PAGE_FORWARD:
pageTurn(true);
break;
case BA::BTN_PAGE_BACK:
pageTurn(false);
break;
case BA::BTN_PAGE_FORWARD_10:
for (int i = 0; i < 10; i++) {
if (!stepPageState(true)) break;
}
requestUpdate();
break;
case BA::BTN_PAGE_BACK_10:
for (int i = 0; i < 10; i++) {
if (!stepPageState(false)) break;
}
requestUpdate();
break;
case BA::BTN_STAR_PAGE:
if (section) {
bookmarkStore.toggle(static_cast<uint16_t>(currentSpineIndex), static_cast<uint16_t>(section->currentPage));
requestUpdate();
}
break;
case BA::BTN_FOOTNOTES:
if (!currentPageFootnotes.empty()) {
if (currentPageFootnotes.size() == 1) {
navigateToHref(currentPageFootnotes[0].href, true);
} else {
startActivityForResult(
std::make_unique<EpubReaderFootnotesActivity>(renderer, mappedInput, currentPageFootnotes),
[this](const ActivityResult& result) {
if (!result.isCancelled) {
const auto& footnoteResult = std::get<FootnoteResult>(result.data);
navigateToHref(footnoteResult.href, true);
}
});
}
}
break;
case BA::BTN_OPEN_TOC:
if (epub) {
const int spineIdx = currentSpineIndex;
const int tocIdx = section ? section->getTocIndexForPage(section->currentPage)
: epub->getTocIndexForSpineIndex(currentSpineIndex);
startActivityForResult(std::make_unique<EpubReaderChapterSelectionActivity>(renderer, mappedInput, epub,
epub->getPath(), spineIdx, tocIdx),
[this](const ActivityResult& result) {
if (result.isCancelled) return;
RenderLock lock(*this);
const auto& chapter = std::get<ChapterResult>(result.data);
auto resolvedPage =
(chapter.tocIndex && chapter.spineIndex == currentSpineIndex && section)
? section->getPageForTocIndex(*chapter.tocIndex)
: std::nullopt;
if (resolvedPage) {
section->currentPage = *resolvedPage;
} else {
pendingTocIndex = chapter.tocIndex;
currentSpineIndex = chapter.spineIndex;
nextPageNumber = 0;
section.reset();
}
});
}
break;
case BA::BTN_NEXT_SECTION:
case BA::BTN_PREV_SECTION: {
const bool forward = (action == BA::BTN_NEXT_SECTION);
RenderLock lock(*this);
if (section && section->pageCount > 0) {
const int curTocIndex = section->getTocIndexForPage(section->currentPage);
const int nextTocIndex = forward ? curTocIndex + 1 : curTocIndex - 1;
if (curTocIndex < 0) {
nextPageNumber = 0;
currentSpineIndex = forward ? currentSpineIndex + 1 : currentSpineIndex - 1;
section.reset();
} else if (nextTocIndex >= 0 && nextTocIndex < epub->getTocItemsCount()) {
const int newSpineIndex = epub->getSpineIndexForTocIndex(nextTocIndex);
if (newSpineIndex == currentSpineIndex) {
if (const auto resolvedPage = section->getPageForTocIndex(nextTocIndex)) {
section->currentPage = *resolvedPage;
}
} else {
pendingTocIndex = nextTocIndex;
nextPageNumber = 0;
currentSpineIndex = newSpineIndex;
section.reset();
}
} else if (forward) {
nextPageNumber = 0;
currentSpineIndex = epub->getSpineItemsCount();
section.reset();
} else {
nextPageNumber = 0;
currentSpineIndex = epub->getTocItem(curTocIndex).spineIndex - 1;
section.reset();
}
} else {
nextPageNumber = 0;
currentSpineIndex = forward ? currentSpineIndex + 1 : currentSpineIndex - 1;
section.reset();
}
requestUpdate();
break;
}
default:
break;
}
}
@@ -188,6 +188,7 @@ class EpubReaderActivity final : public Activity {
void loop() override;
void render(RenderLock&& lock) override;
bool isReaderActivity() const override { return true; }
void onButtonAction(CrossPointSettings::BUTTON_ACTION action) override;
// Renders the last saved page to the frame buffer without flushing to display.
// Used by SleepActivity to prepare the background for the overlay sleep mode.
+60 -2
View File
@@ -253,7 +253,7 @@ void MdReaderActivity::loop() {
return;
}
const bool headingSkip = SETTINGS.longPressChapterSkip && mappedInput.getHeldTime() > HEADING_SKIP_MS;
const bool headingSkip = mappedInput.getHeldTime() > HEADING_SKIP_MS;
if (headingSkip && !headings.empty()) {
jumpToHeading(nextTriggered);
return;
@@ -892,4 +892,62 @@ void MdReaderActivity::savePageIndexCache() const {
}
LOG_DBG("MDR", "Saved page index cache: %d pages", totalPages);
}
}
void MdReaderActivity::onButtonAction(const CrossPointSettings::BUTTON_ACTION action) {
using BA = CrossPointSettings::BUTTON_ACTION;
auto clampPage = [this]() {
if (currentPage < 0) currentPage = 0;
if (currentPage >= totalPages) currentPage = totalPages - 1;
};
switch (action) {
case BA::BTN_PAGE_FORWARD:
if (currentPage < totalPages - 1) {
currentPage++;
currentHeadingIndex = pageOffsets.empty() ? -1 : getHeadingIndexForOffset(pageOffsets[currentPage]);
requestUpdate();
}
break;
case BA::BTN_PAGE_BACK:
if (currentPage > 0) {
currentPage--;
currentHeadingIndex = pageOffsets.empty() ? -1 : getHeadingIndexForOffset(pageOffsets[currentPage]);
requestUpdate();
}
break;
case BA::BTN_PAGE_FORWARD_10:
currentPage += 10;
clampPage();
currentHeadingIndex = pageOffsets.empty() ? -1 : getHeadingIndexForOffset(pageOffsets[currentPage]);
requestUpdate();
break;
case BA::BTN_PAGE_BACK_10:
currentPage -= 10;
clampPage();
currentHeadingIndex = pageOffsets.empty() ? -1 : getHeadingIndexForOffset(pageOffsets[currentPage]);
requestUpdate();
break;
case BA::BTN_NEXT_SECTION:
jumpToHeading(true);
break;
case BA::BTN_PREV_SECTION:
jumpToHeading(false);
break;
case BA::BTN_OPEN_TOC:
if (!headings.empty()) {
currentHeadingIndex = pageOffsets.empty() ? -1 : getHeadingIndexForOffset(pageOffsets[currentPage]);
startActivityForResult(
std::make_unique<MdReaderTocSelectionActivity>(renderer, mappedInput, headings, currentHeadingIndex),
[this](const ActivityResult& result) {
if (!result.isCancelled) {
currentPage = std::get<PageResult>(result.data).page;
currentHeadingIndex = pageOffsets.empty() ? -1 : getHeadingIndexForOffset(pageOffsets[currentPage]);
requestUpdate();
}
});
}
break;
default:
break;
}
}
+1
View File
@@ -89,4 +89,5 @@ class MdReaderActivity final : public Activity {
void loop() override;
void render(RenderLock&&) override;
bool isReaderActivity() const override { return true; }
void onButtonAction(CrossPointSettings::BUTTON_ACTION action) override;
};
+6 -11
View File
@@ -31,8 +31,8 @@ inline void applyOrientation(GfxRenderer& renderer, const uint8_t orientation) {
// Suppresses input processing on activity entry until the user has released all buttons and a
// clean frame (no pending press/release events) has been observed. Without this, the power-button
// hold used to wake the device leaks into detectPageTurn() and triggers a page turn or, with
// longPressChapterSkip enabled, a chapter skip (the wake-hold easily exceeds skipChapterMs).
// hold used to wake the device leaks into detectPageTurn() and triggers a page turn or chapter
// skip (the wake-hold easily exceeds skipChapterMs).
// Each reader holds an instance, calls arm() in onEnter(), and calls shouldDrain() at the top
// of loop() — returning early when it returns true.
struct InputDrainGuard {
@@ -62,17 +62,12 @@ struct PageTurnResult {
};
inline PageTurnResult detectPageTurn(const MappedInputManager& input) {
const bool usePress = !SETTINGS.longPressChapterSkip;
const bool prev = usePress ? (input.wasPressed(MappedInputManager::Button::PageBack) ||
input.wasPressed(MappedInputManager::Button::Left))
: (input.wasReleased(MappedInputManager::Button::PageBack) ||
input.wasReleased(MappedInputManager::Button::Left));
const bool prev =
input.wasReleased(MappedInputManager::Button::PageBack) || input.wasReleased(MappedInputManager::Button::Left);
const bool powerTurn = SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::PAGE_TURN &&
input.wasReleased(MappedInputManager::Button::Power);
const bool next = usePress ? (input.wasPressed(MappedInputManager::Button::PageForward) || powerTurn ||
input.wasPressed(MappedInputManager::Button::Right))
: (input.wasReleased(MappedInputManager::Button::PageForward) || powerTurn ||
input.wasReleased(MappedInputManager::Button::Right));
const bool next = input.wasReleased(MappedInputManager::Button::PageForward) || powerTurn ||
input.wasReleased(MappedInputManager::Button::Right);
return {prev, next};
}
@@ -782,3 +782,51 @@ bool TxtReaderActivity::drawCurrentPageToBuffer(const std::string& filePath, Gfx
}
return true;
}
void TxtReaderActivity::onButtonAction(const CrossPointSettings::BUTTON_ACTION action) {
using BA = CrossPointSettings::BUTTON_ACTION;
auto clampPage = [this]() {
if (currentPage < 0) currentPage = 0;
if (currentPage >= totalPages) currentPage = totalPages - 1;
};
switch (action) {
case BA::BTN_PAGE_FORWARD:
if (currentPage < totalPages - 1) {
currentPage++;
requestUpdate();
}
break;
case BA::BTN_PAGE_BACK:
if (currentPage > 0) {
currentPage--;
requestUpdate();
}
break;
case BA::BTN_PAGE_FORWARD_10:
currentPage += 10;
clampPage();
requestUpdate();
break;
case BA::BTN_PAGE_BACK_10:
currentPage -= 10;
clampPage();
requestUpdate();
break;
case BA::BTN_STAR_PAGE:
bookmarkStore.toggle(0, static_cast<uint16_t>(currentPage));
requestUpdate();
break;
case BA::BTN_NEXT_SECTION:
currentPage += 10;
clampPage();
requestUpdate();
break;
case BA::BTN_PREV_SECTION:
currentPage -= 10;
clampPage();
requestUpdate();
break;
default:
break;
}
}
@@ -58,6 +58,7 @@ class TxtReaderActivity final : public Activity {
void loop() override;
void render(RenderLock&&) override;
bool isReaderActivity() const override { return true; }
void onButtonAction(CrossPointSettings::BUTTON_ACTION action) override;
// Renders the last saved page to the frame buffer without flushing to display.
// Used by SleepActivity to prepare the background for the overlay sleep mode.
+43 -12
View File
@@ -92,19 +92,12 @@ void XtcReaderActivity::loop() {
return;
}
// When long-press chapter skip is disabled, turn pages on press instead of release.
const bool usePressForPageTurn = !SETTINGS.longPressChapterSkip;
const bool prevTriggered = usePressForPageTurn ? (mappedInput.wasPressed(MappedInputManager::Button::PageBack) ||
mappedInput.wasPressed(MappedInputManager::Button::Left))
: (mappedInput.wasReleased(MappedInputManager::Button::PageBack) ||
mappedInput.wasReleased(MappedInputManager::Button::Left));
const bool prevTriggered = mappedInput.wasReleased(MappedInputManager::Button::PageBack) ||
mappedInput.wasReleased(MappedInputManager::Button::Left);
const bool powerPageTurn = SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::PAGE_TURN &&
mappedInput.wasReleased(MappedInputManager::Button::Power);
const bool nextTriggered = usePressForPageTurn
? (mappedInput.wasPressed(MappedInputManager::Button::PageForward) || powerPageTurn ||
mappedInput.wasPressed(MappedInputManager::Button::Right))
: (mappedInput.wasReleased(MappedInputManager::Button::PageForward) || powerPageTurn ||
mappedInput.wasReleased(MappedInputManager::Button::Right));
const bool nextTriggered = mappedInput.wasReleased(MappedInputManager::Button::PageForward) || powerPageTurn ||
mappedInput.wasReleased(MappedInputManager::Button::Right);
if (!prevTriggered && !nextTriggered) {
return;
@@ -121,7 +114,7 @@ void XtcReaderActivity::loop() {
return;
}
const bool skipPages = SETTINGS.longPressChapterSkip && mappedInput.getHeldTime() > skipPageMs;
const bool skipPages = mappedInput.getHeldTime() > skipPageMs;
const int skipAmount = skipPages ? 10 : 1;
if (prevTriggered) {
@@ -442,3 +435,41 @@ bool XtcReaderActivity::drawCurrentPageToBuffer(const std::string& filePath, Gfx
free(pageBuffer);
return true;
}
void XtcReaderActivity::onButtonAction(const CrossPointSettings::BUTTON_ACTION action) {
using BA = CrossPointSettings::BUTTON_ACTION;
if (!xtc) return;
const uint32_t pageCount = xtc->getPageCount();
switch (action) {
case BA::BTN_PAGE_FORWARD:
if (currentPage + 1 < pageCount) {
currentPage++;
requestUpdate();
}
break;
case BA::BTN_PAGE_BACK:
if (currentPage > 0) {
currentPage--;
requestUpdate();
}
break;
case BA::BTN_PAGE_FORWARD_10:
currentPage = (currentPage + 10 < pageCount) ? currentPage + 10 : pageCount - 1;
requestUpdate();
break;
case BA::BTN_PAGE_BACK_10:
currentPage = (currentPage >= 10) ? currentPage - 10 : 0;
requestUpdate();
break;
case BA::BTN_NEXT_SECTION:
currentPage = (currentPage + 10 < pageCount) ? currentPage + 10 : pageCount - 1;
requestUpdate();
break;
case BA::BTN_PREV_SECTION:
currentPage = (currentPage >= 10) ? currentPage - 10 : 0;
requestUpdate();
break;
default:
break;
}
}
@@ -31,6 +31,7 @@ class XtcReaderActivity final : public Activity {
void loop() override;
void render(RenderLock&&) override;
bool isReaderActivity() const override { return true; }
void onButtonAction(CrossPointSettings::BUTTON_ACTION action) override;
// Renders the last saved page to the frame buffer without flushing to display.
// Used by SleepActivity to prepare the background for the overlay sleep mode.
+128
View File
@@ -17,6 +17,7 @@
#include <cstring>
#include "ButtonEventManager.h"
#include "CrossPointSettings.h"
#include "CrossPointState.h"
#include "GlobalBookmarkIndex.h"
@@ -33,6 +34,8 @@
#include "util/ScreenshotUtil.h"
MappedInputManager mappedInputManager(gpio);
ButtonEventManager buttonEventManager(mappedInputManager);
ButtonEventManager& globalButtonEvents() { return buttonEventManager; }
GfxRenderer renderer(display);
ActivityManager activityManager(renderer, mappedInputManager);
FontDecompressor fontDecompressor;
@@ -284,6 +287,7 @@ void loop() {
static unsigned long lastMemPrint = 0;
gpio.update();
buttonEventManager.update();
HalClock::updatePeriodic();
renderer.setFadingFix(SETTINGS.fadingFix);
@@ -384,6 +388,130 @@ void loop() {
activityManager.requestUpdate();
}
// Dispatch globally-configured button actions before handing control to the activity.
// Only non-Default actions are intercepted here; Default falls through to the activity.
{
using BA = CrossPointSettings::BUTTON_ACTION;
using B = MappedInputManager::Button;
ButtonEventManager::ButtonEvent ev;
while (buttonEventManager.consumeEvent(ev)) {
auto actionFor = [&](B btn) -> uint8_t {
switch (ev.type) {
case ButtonEventManager::PressType::Short:
switch (btn) {
case B::Back:
return SETTINGS.btnShortBack;
case B::Confirm:
return SETTINGS.btnShortConfirm;
case B::Left:
return SETTINGS.btnShortLeft;
case B::Right:
return SETTINGS.btnShortRight;
case B::Up:
return SETTINGS.btnShortUp;
case B::Down:
return SETTINGS.btnShortDown;
case B::PageBack:
return SETTINGS.btnShortPageBack;
case B::PageForward:
return SETTINGS.btnShortPageForward;
case B::Power:
return SETTINGS.btnShortPower;
}
break;
case ButtonEventManager::PressType::Double:
switch (btn) {
case B::Back:
return SETTINGS.btnDoubleBack;
case B::Confirm:
return SETTINGS.btnDoubleConfirm;
case B::Left:
return SETTINGS.btnDoubleLeft;
case B::Right:
return SETTINGS.btnDoubleRight;
case B::Up:
return SETTINGS.btnDoubleUp;
case B::Down:
return SETTINGS.btnDoubleDown;
case B::PageBack:
return SETTINGS.btnDoublePageBack;
case B::PageForward:
return SETTINGS.btnDoublePageForward;
case B::Power:
return SETTINGS.btnDoublePower;
}
break;
case ButtonEventManager::PressType::Long:
switch (btn) {
case B::Back:
return SETTINGS.btnLongBack;
case B::Confirm:
return SETTINGS.btnLongConfirm;
case B::Left:
return SETTINGS.btnLongLeft;
case B::Right:
return SETTINGS.btnLongRight;
case B::Up:
return SETTINGS.btnLongUp;
case B::Down:
return SETTINGS.btnLongDown;
case B::PageBack:
return SETTINGS.btnLongPageBack;
case B::PageForward:
return SETTINGS.btnLongPageForward;
case B::Power:
return SETTINGS.btnLongPower;
}
break;
}
return BA::BTN_DEFAULT;
};
const uint8_t action = actionFor(ev.button);
if (action == BA::BTN_DEFAULT) continue;
switch (static_cast<BA>(action)) {
case BA::BTN_PAGE_FORWARD:
activityManager.dispatchButtonAction(BA::BTN_PAGE_FORWARD);
break;
case BA::BTN_PAGE_BACK:
activityManager.dispatchButtonAction(BA::BTN_PAGE_BACK);
break;
case BA::BTN_PAGE_FORWARD_10:
activityManager.dispatchButtonAction(BA::BTN_PAGE_FORWARD_10);
break;
case BA::BTN_PAGE_BACK_10:
activityManager.dispatchButtonAction(BA::BTN_PAGE_BACK_10);
break;
case BA::BTN_GO_HOME:
activityManager.goHome();
break;
case BA::BTN_SLEEP:
activityManager.goToSleep();
break;
case BA::BTN_FORCE_REFRESH: {
RenderLock lock;
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
break;
}
case BA::BTN_OPEN_TOC:
activityManager.dispatchButtonAction(BA::BTN_OPEN_TOC);
break;
case BA::BTN_OPEN_BOOKMARKS:
activityManager.goToGlobalBookmarks();
break;
case BA::BTN_STAR_PAGE:
activityManager.dispatchButtonAction(BA::BTN_STAR_PAGE);
break;
case BA::BTN_FOOTNOTES:
activityManager.dispatchButtonAction(BA::BTN_FOOTNOTES);
break;
default:
break;
}
}
}
const unsigned long activityStartTime = millis();
activityManager.loop();
const unsigned long activityDuration = millis() - activityStartTime;