Compare commits

...

19 Commits

Author SHA1 Message Date
Istiak Tridip
269bca1cc6
Merge 66e69e750e into e5c0ddc9fa 2026-02-01 22:53:48 +11:00
Uri Tauber
e5c0ddc9fa
feat: Debugging monitor script (#555)
Some checks are pending
CI / build (push) Waiting to run
## Summary

* **What is the goal of this PR?**
Add a debugging script to help developers monitor the ESP32 serial port
directly from a PC.

* **What changes are included?**
Added a new script: scripts/debugging_monitor.py

## Additional Context

While working on a new Crosspoint-Reader feature, it quickly became
clear that watching the ESP32 serial output without any visual cues was
inconvenient and easy to mess up.

This script improves the debugging experience by reading data from the
serial port and providing:

1. A timestamp prefix for every log line (instead of milliseconds since
power-up)
2. Color-coded output for different message types
3. A secondary window displaying a live graph of RAM usage, which is
especially useful for tracking the memory impact of new features

<img width="1916" height="1049" alt="Screenshot_20260126_183811"
src="https://github.com/user-attachments/assets/6291887f-ac17-43ac-9e43-f5dec8a7097e"
/>

---

### AI Usage

Did you use AI tools to help write this code? _**< PARTIALLY >**_
I wrote the initial version of the script. Gemini was used to help add
the Matplotlib-based graphing and threading logic.
2026-02-01 22:53:20 +11:00
Arthur Tazhitdinov
b1dcb7733b
fix: truncating chapter titles using UTF-8 safe function (#599)
## Summary

* Truncating chapter titles using utf8 safe functions (Cyrillic titles
were split mid codepoint)
* refactoring of lib/Utf8

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? _**< PARTIALLY >**_
2026-02-01 22:23:48 +11:00
Arthur Tazhitdinov
0d82b03981
fix: don't wake up after USB connect (#644)
## Summary

* fixes problem that if short power button press is enabled, connecting
device to usb leads to waking up
2026-02-01 22:19:33 +11:00
Dave Allie
5a97334ace
Revert "fix: don't wake up after USB connect" (#643)
Reverts crosspoint-reader/crosspoint-reader#576

Causing a boot loop on master
2026-02-01 21:35:25 +11:00
Istiak Tridip
66e69e750e
feat: fall back to index navigation when items fit on one page 2026-01-29 20:41:59 +06:00
Istiak Tridip
45bba9a822
fix: replace for loop with std::any_of 2026-01-29 04:07:47 +06:00
Istiak Tridip
cebfc54266
refactor: file transfer activity 2026-01-29 03:48:24 +06:00
Istiak Tridip
fe119239ad
feat: ButtonNavigator::onPressAndContinuous helper function 2026-01-29 02:33:56 +06:00
Istiak Tridip
a6b4ed5cf9
refaxtor: keyboard activity 2026-01-29 00:43:22 +06:00
Istiak Tridip
91313d8505
refactor: wifi selection activity 2026-01-28 22:55:58 +06:00
Istiak Tridip
3c727dd8f9
refactor: my library activity 2026-01-28 22:46:25 +06:00
Istiak Tridip
bde92c288f
refactor: opds activity 2026-01-28 22:07:48 +06:00
Istiak Tridip
3d9c3bf101
refactor: xtc chapter selection 2026-01-28 22:07:34 +06:00
Istiak Tridip
b7b36f02e2
fix: button navigator triggers 2026-01-28 18:28:46 +06:00
Istiak Tridip
73c5f05843
refactor: epub chapter selection 2026-01-28 03:29:15 +06:00
Istiak Tridip
178c826b52
refactor: settings activities 2026-01-28 02:33:04 +06:00
Istiak Tridip
7aa21f2386
refactor: home activity 2026-01-28 02:23:42 +06:00
Istiak Tridip
727186f208
feat: ButtonNavigator class 2026-01-28 02:22:00 +06:00
36 changed files with 639 additions and 251 deletions

View File

@ -95,6 +95,20 @@ Connect your Xteink X4 to your computer via USB-C and run the following command.
```sh ```sh
pio run --target upload pio run --target upload
``` ```
### Debugging
After flashing the new features, its recommended to capture detailed logs from the serial port.
First, make sure all required Python packages are installed:
```python
python3 -m pip install serial colorama matplotlib
```
after that run the script:
```sh
python3 scripts/debugging_monitor.py
```
This was tested on Debian and should work on most Linux systems. Minor adjustments may be required for Windows or macOS.
## Internals ## Internals

View File

@ -415,13 +415,21 @@ void GfxRenderer::displayBuffer(const HalDisplay::RefreshMode refreshMode) const
std::string GfxRenderer::truncatedText(const int fontId, const char* text, const int maxWidth, std::string GfxRenderer::truncatedText(const int fontId, const char* text, const int maxWidth,
const EpdFontFamily::Style style) const { const EpdFontFamily::Style style) const {
if (!text || maxWidth <= 0) return "";
std::string item = text; std::string item = text;
int itemWidth = getTextWidth(fontId, item.c_str(), style); const char* ellipsis = "...";
while (itemWidth > maxWidth && item.length() > 8) { int textWidth = getTextWidth(fontId, item.c_str(), style);
item.replace(item.length() - 5, 5, "..."); if (textWidth <= maxWidth) {
itemWidth = getTextWidth(fontId, item.c_str(), style); // Text fits, return as is
return item;
} }
return item;
while (!item.empty() && getTextWidth(fontId, (item + ellipsis).c_str(), style) >= maxWidth) {
utf8RemoveLastChar(item);
}
return item.empty() ? ellipsis : item + ellipsis;
} }
// Note: Internal driver treats screen in command orientation; this library exposes a logical orientation // Note: Internal driver treats screen in command orientation; this library exposes a logical orientation

View File

@ -29,3 +29,20 @@ uint32_t utf8NextCodepoint(const unsigned char** string) {
return cp; return cp;
} }
size_t utf8RemoveLastChar(std::string& str) {
if (str.empty()) return 0;
size_t pos = str.size() - 1;
while (pos > 0 && (static_cast<unsigned char>(str[pos]) & 0xC0) == 0x80) {
--pos;
}
str.resize(pos);
return pos;
}
// Truncate string by removing N UTF-8 characters from the end
void utf8TruncateChars(std::string& str, const size_t numChars) {
for (size_t i = 0; i < numChars && !str.empty(); ++i) {
utf8RemoveLastChar(str);
}
}

View File

@ -1,7 +1,11 @@
#pragma once #pragma once
#include <cstdint> #include <cstdint>
#include <string>
#define REPLACEMENT_GLYPH 0xFFFD #define REPLACEMENT_GLYPH 0xFFFD
uint32_t utf8NextCodepoint(const unsigned char** string); uint32_t utf8NextCodepoint(const unsigned char** string);
// Remove the last UTF-8 codepoint from a std::string and return the new size.
size_t utf8RemoveLastChar(std::string& str);
// Truncate string by removing N UTF-8 codepoints from the end.
void utf8TruncateChars(std::string& str, size_t numChars);

214
scripts/debugging_monitor.py Executable file
View File

@ -0,0 +1,214 @@
import sys
import argparse
import re
import threading
from datetime import datetime
from collections import deque
import time
# Try to import potentially missing packages
try:
import serial
from colorama import init, Fore, Style
import matplotlib.pyplot as plt
import matplotlib.animation as animation
except ImportError as e:
missing_package = e.name
print("\n" + "!" * 50)
print(f" Error: The required package '{missing_package}' is not installed.")
print("!" * 50)
print(f"\nTo fix this, please run the following command in your terminal:\n")
install_cmd = "pip install "
packages = []
if 'serial' in str(e): packages.append("pyserial")
if 'colorama' in str(e): packages.append("colorama")
if 'matplotlib' in str(e): packages.append("matplotlib")
print(f" {install_cmd}{' '.join(packages)}")
print("\nExiting...")
sys.exit(1)
# --- Global Variables for Data Sharing ---
# Store last 50 data points
MAX_POINTS = 50
time_data = deque(maxlen=MAX_POINTS)
free_mem_data = deque(maxlen=MAX_POINTS)
total_mem_data = deque(maxlen=MAX_POINTS)
data_lock = threading.Lock() # Prevent reading while writing
# Initialize colors
init(autoreset=True)
def get_color_for_line(line):
"""
Classify log lines by type and assign appropriate colors.
"""
line_upper = line.upper()
if any(keyword in line_upper for keyword in ["ERROR", "[ERR]", "[SCT]", "FAILED", "WARNING"]):
return Fore.RED
if "[MEM]" in line_upper or "FREE:" in line_upper:
return Fore.CYAN
if any(keyword in line_upper for keyword in ["[GFX]", "[ERS]", "DISPLAY", "RAM WRITE", "RAM COMPLETE", "REFRESH", "POWERING ON", "FRAME BUFFER", "LUT"]):
return Fore.MAGENTA
if any(keyword in line_upper for keyword in ["[EBP]", "[BMC]", "[ZIP]", "[PARSER]", "[EHP]", "LOADING EPUB", "CACHE", "DECOMPRESSED", "PARSING"]):
return Fore.GREEN
if "[ACT]" in line_upper or "ENTERING ACTIVITY" in line_upper or "EXITING ACTIVITY" in line_upper:
return Fore.YELLOW
if any(keyword in line_upper for keyword in ["RENDERED PAGE", "[LOOP]", "DURATION", "WAIT COMPLETE"]):
return Fore.BLUE
if any(keyword in line_upper for keyword in ["[CPS]", "SETTINGS", "[CLEAR_CACHE]"]):
return Fore.LIGHTYELLOW_EX
if any(keyword in line_upper for keyword in ["ESP-ROM", "BUILD:", "RST:", "BOOT:", "SPIWP:", "MODE:", "LOAD:", "ENTRY", "[SD]", "STARTING CROSSPOINT", "VERSION"]):
return Fore.LIGHTBLACK_EX
if "[RBS]" in line_upper:
return Fore.LIGHTCYAN_EX
if "[KRS]" in line_upper:
return Fore.LIGHTMAGENTA_EX
if any(keyword in line_upper for keyword in ["EINKDISPLAY:", "STATIC FRAME", "INITIALIZING", "SPI INITIALIZED", "GPIO PINS", "RESETTING", "SSD1677", "E-INK"]):
return Fore.LIGHTMAGENTA_EX
if any(keyword in line_upper for keyword in ["[FNS]", "FOOTNOTE"]):
return Fore.LIGHTGREEN_EX
if any(keyword in line_upper for keyword in ["[CHAP]", "[OPDS]", "[COF]"]):
return Fore.LIGHTYELLOW_EX
return Fore.WHITE
def parse_memory_line(line):
"""
Extracts Free and Total bytes from the specific log line.
Format: [MEM] Free: 196344 bytes, Total: 226412 bytes, Min Free: 112620 bytes
"""
# Regex to find 'Free: <digits>' and 'Total: <digits>'
match = re.search(r"Free:\s*(\d+).*Total:\s*(\d+)", line)
if match:
try:
free_bytes = int(match.group(1))
total_bytes = int(match.group(2))
return free_bytes, total_bytes
except ValueError:
return None, None
return None, None
def serial_worker(port, baud):
"""
Runs in a background thread. Handles reading serial, printing to console,
and updating the data lists.
"""
print(f"{Fore.CYAN}--- Opening {port} at {baud} baud ---{Style.RESET_ALL}")
try:
ser = serial.Serial(port, baud, timeout=0.1)
ser.dtr = False
ser.rts = False
except serial.SerialException as e:
print(f"{Fore.RED}Error opening port: {e}{Style.RESET_ALL}")
return
try:
while True:
try:
raw_data = ser.readline().decode('utf-8', errors='replace')
if not raw_data:
continue
clean_line = raw_data.strip()
if not clean_line:
continue
# Add PC timestamp
pc_time = datetime.now().strftime("%H:%M:%S")
formatted_line = re.sub(r"^\[\d+\]", f"[{pc_time}]", clean_line)
# Check for Memory Line
if "[MEM]" in formatted_line:
free_val, total_val = parse_memory_line(formatted_line)
if free_val is not None:
with data_lock:
time_data.append(pc_time)
free_mem_data.append(free_val / 1024) # Convert to KB
total_mem_data.append(total_val / 1024) # Convert to KB
# Print to console
line_color = get_color_for_line(formatted_line)
print(f"{line_color}{formatted_line}")
except OSError:
print(f"{Fore.RED}Device disconnected.{Style.RESET_ALL}")
break
except Exception as e:
# If thread is killed violently (e.g. main exit), silence errors
pass
finally:
if 'ser' in locals() and ser.is_open:
ser.close()
def update_graph(frame):
"""
Called by Matplotlib animation to redraw the chart.
"""
with data_lock:
if not time_data:
return
# Convert deques to lists for plotting
x = list(time_data)
y_free = list(free_mem_data)
y_total = list(total_mem_data)
plt.cla() # Clear axis
# Plot Total RAM
plt.plot(x, y_total, label='Total RAM (KB)', color='red', linestyle='--')
# Plot Free RAM
plt.plot(x, y_free, label='Free RAM (KB)', color='green', marker='o')
# Fill area under Free RAM
plt.fill_between(x, y_free, color='green', alpha=0.1)
plt.title("ESP32 Memory Monitor")
plt.ylabel("Memory (KB)")
plt.xlabel("Time")
plt.legend(loc='upper left')
plt.grid(True, linestyle=':', alpha=0.6)
# Rotate date labels
plt.xticks(rotation=45, ha='right')
plt.tight_layout()
def main():
parser = argparse.ArgumentParser(description="ESP32 Monitor with Graph")
parser.add_argument("port", nargs="?", default="/dev/ttyACM0", help="Serial port")
parser.add_argument("--baud", type=int, default=115200, help="Baud rate")
args = parser.parse_args()
# 1. Start the Serial Reader in a separate thread
# Daemon=True means this thread dies when the main program closes
t = threading.Thread(target=serial_worker, args=(args.port, args.baud), daemon=True)
t.start()
# 2. Set up the Graph (Main Thread)
try:
plt.style.use('light_background')
except:
pass
fig = plt.figure(figsize=(10, 6))
# Update graph every 1000ms
ani = animation.FuncAnimation(fig, update_graph, interval=1000)
try:
print(f"{Fore.YELLOW}Starting Graph Window... (Close window to exit){Style.RESET_ALL}")
plt.show()
except KeyboardInterrupt:
print(f"\n{Fore.YELLOW}Exiting...{Style.RESET_ALL}")
plt.close('all') # Force close any lingering plot windows
if __name__ == "__main__":
main()

View File

@ -17,7 +17,6 @@
namespace { namespace {
constexpr int PAGE_ITEMS = 23; constexpr int PAGE_ITEMS = 23;
constexpr int SKIP_PAGE_MS = 700;
} // namespace } // namespace
void OpdsBookBrowserActivity::taskTrampoline(void* param) { void OpdsBookBrowserActivity::taskTrampoline(void* param) {
@ -118,12 +117,6 @@ void OpdsBookBrowserActivity::loop() {
// Handle browsing state // Handle browsing state
if (state == BrowserState::BROWSING) { if (state == BrowserState::BROWSING) {
const bool prevReleased = mappedInput.wasReleased(MappedInputManager::Button::Up) ||
mappedInput.wasReleased(MappedInputManager::Button::Left);
const bool nextReleased = mappedInput.wasReleased(MappedInputManager::Button::Down) ||
mappedInput.wasReleased(MappedInputManager::Button::Right);
const bool skipPage = mappedInput.getHeldTime() > SKIP_PAGE_MS;
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (!entries.empty()) { if (!entries.empty()) {
const auto& entry = entries[selectorIndex]; const auto& entry = entries[selectorIndex];
@ -135,20 +128,29 @@ void OpdsBookBrowserActivity::loop() {
} }
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { } else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
navigateBack(); navigateBack();
} else if (prevReleased && !entries.empty()) { }
if (skipPage) {
selectorIndex = ((selectorIndex / PAGE_ITEMS - 1) * PAGE_ITEMS + entries.size()) % entries.size(); // Handle navigation
} else { if (!entries.empty()) {
selectorIndex = (selectorIndex + entries.size() - 1) % entries.size(); buttonNavigator.onNextRelease([this] {
} selectorIndex = ButtonNavigator::nextIndex(selectorIndex, entries.size());
updateRequired = true; updateRequired = true;
} else if (nextReleased && !entries.empty()) { });
if (skipPage) {
selectorIndex = ((selectorIndex / PAGE_ITEMS + 1) * PAGE_ITEMS) % entries.size(); buttonNavigator.onPreviousRelease([this] {
} else { selectorIndex = ButtonNavigator::previousIndex(selectorIndex, entries.size());
selectorIndex = (selectorIndex + 1) % entries.size(); updateRequired = true;
} });
updateRequired = true;
buttonNavigator.onNextContinuous([this] {
selectorIndex = ButtonNavigator::nextPageIndex(selectorIndex, entries.size(), PAGE_ITEMS);
updateRequired = true;
});
buttonNavigator.onPreviousContinuous([this] {
selectorIndex = ButtonNavigator::previousPageIndex(selectorIndex, entries.size(), PAGE_ITEMS);
updateRequired = true;
});
} }
} }
} }

View File

@ -9,6 +9,7 @@
#include <vector> #include <vector>
#include "../ActivityWithSubactivity.h" #include "../ActivityWithSubactivity.h"
#include "util/ButtonNavigator.h"
/** /**
* Activity for browsing and downloading books from an OPDS server. * Activity for browsing and downloading books from an OPDS server.
@ -37,6 +38,7 @@ class OpdsBookBrowserActivity final : public ActivityWithSubactivity {
private: private:
TaskHandle_t displayTaskHandle = nullptr; TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr; SemaphoreHandle_t renderingMutex = nullptr;
ButtonNavigator buttonNavigator;
bool updateRequired = false; bool updateRequired = false;
BrowserState state = BrowserState::LOADING; BrowserState state = BrowserState::LOADING;

View File

@ -4,6 +4,7 @@
#include <Epub.h> #include <Epub.h>
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <SDCardManager.h> #include <SDCardManager.h>
#include <Utf8.h>
#include <Xtc.h> #include <Xtc.h>
#include <cstring> #include <cstring>
@ -161,13 +162,18 @@ void HomeActivity::freeCoverBuffer() {
} }
void HomeActivity::loop() { void HomeActivity::loop() {
const bool prevPressed = mappedInput.wasPressed(MappedInputManager::Button::Up) ||
mappedInput.wasPressed(MappedInputManager::Button::Left);
const bool nextPressed = mappedInput.wasPressed(MappedInputManager::Button::Down) ||
mappedInput.wasPressed(MappedInputManager::Button::Right);
const int menuCount = getMenuItemCount(); const int menuCount = getMenuItemCount();
buttonNavigator.onNext([this, menuCount] {
selectorIndex = ButtonNavigator::nextIndex(selectorIndex, menuCount);
updateRequired = true;
});
buttonNavigator.onPrevious([this, menuCount] {
selectorIndex = ButtonNavigator::previousIndex(selectorIndex, menuCount);
updateRequired = true;
});
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
// Calculate dynamic indices based on which options are available // Calculate dynamic indices based on which options are available
int idx = 0; int idx = 0;
@ -188,12 +194,6 @@ void HomeActivity::loop() {
} else if (selectorIndex == settingsIdx) { } else if (selectorIndex == settingsIdx) {
onSettingsOpen(); onSettingsOpen();
} }
} else if (prevPressed) {
selectorIndex = (selectorIndex + menuCount - 1) % menuCount;
updateRequired = true;
} else if (nextPressed) {
selectorIndex = (selectorIndex + 1) % menuCount;
updateRequired = true;
} }
} }
@ -366,7 +366,7 @@ void HomeActivity::render() {
while (!lines.back().empty() && renderer.getTextWidth(UI_12_FONT_ID, lines.back().c_str()) > maxLineWidth) { while (!lines.back().empty() && renderer.getTextWidth(UI_12_FONT_ID, lines.back().c_str()) > maxLineWidth) {
// Remove "..." first, then remove one UTF-8 char, then add "..." back // Remove "..." first, then remove one UTF-8 char, then add "..." back
lines.back().resize(lines.back().size() - 3); // Remove "..." lines.back().resize(lines.back().size() - 3); // Remove "..."
StringUtils::utf8RemoveLastChar(lines.back()); utf8RemoveLastChar(lines.back());
lines.back().append("..."); lines.back().append("...");
} }
break; break;
@ -375,7 +375,7 @@ void HomeActivity::render() {
int wordWidth = renderer.getTextWidth(UI_12_FONT_ID, i.c_str()); int wordWidth = renderer.getTextWidth(UI_12_FONT_ID, i.c_str());
while (wordWidth > maxLineWidth && !i.empty()) { while (wordWidth > maxLineWidth && !i.empty()) {
// Word itself is too long, trim it (UTF-8 safe) // Word itself is too long, trim it (UTF-8 safe)
StringUtils::utf8RemoveLastChar(i); utf8RemoveLastChar(i);
// Check if we have room for ellipsis // Check if we have room for ellipsis
std::string withEllipsis = i + "..."; std::string withEllipsis = i + "...";
wordWidth = renderer.getTextWidth(UI_12_FONT_ID, withEllipsis.c_str()); wordWidth = renderer.getTextWidth(UI_12_FONT_ID, withEllipsis.c_str());
@ -428,7 +428,7 @@ void HomeActivity::render() {
if (!lastBookAuthor.empty()) { if (!lastBookAuthor.empty()) {
std::string trimmedAuthor = lastBookAuthor; std::string trimmedAuthor = lastBookAuthor;
while (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) > maxLineWidth && !trimmedAuthor.empty()) { while (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) > maxLineWidth && !trimmedAuthor.empty()) {
StringUtils::utf8RemoveLastChar(trimmedAuthor); utf8RemoveLastChar(trimmedAuthor);
} }
if (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) < if (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) <
renderer.getTextWidth(UI_10_FONT_ID, lastBookAuthor.c_str())) { renderer.getTextWidth(UI_10_FONT_ID, lastBookAuthor.c_str())) {
@ -462,14 +462,14 @@ void HomeActivity::render() {
// Trim author if too long (UTF-8 safe) // Trim author if too long (UTF-8 safe)
bool wasTrimmed = false; bool wasTrimmed = false;
while (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) > maxLineWidth && !trimmedAuthor.empty()) { while (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) > maxLineWidth && !trimmedAuthor.empty()) {
StringUtils::utf8RemoveLastChar(trimmedAuthor); utf8RemoveLastChar(trimmedAuthor);
wasTrimmed = true; wasTrimmed = true;
} }
if (wasTrimmed && !trimmedAuthor.empty()) { if (wasTrimmed && !trimmedAuthor.empty()) {
// Make room for ellipsis // Make room for ellipsis
while (renderer.getTextWidth(UI_10_FONT_ID, (trimmedAuthor + "...").c_str()) > maxLineWidth && while (renderer.getTextWidth(UI_10_FONT_ID, (trimmedAuthor + "...").c_str()) > maxLineWidth &&
!trimmedAuthor.empty()) { !trimmedAuthor.empty()) {
StringUtils::utf8RemoveLastChar(trimmedAuthor); utf8RemoveLastChar(trimmedAuthor);
} }
trimmedAuthor.append("..."); trimmedAuthor.append("...");
} }

View File

@ -6,10 +6,12 @@
#include <functional> #include <functional>
#include "../Activity.h" #include "../Activity.h"
#include "util/ButtonNavigator.h"
class HomeActivity final : public Activity { class HomeActivity final : public Activity {
TaskHandle_t displayTaskHandle = nullptr; TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr; SemaphoreHandle_t renderingMutex = nullptr;
ButtonNavigator buttonNavigator;
int selectorIndex = 0; int selectorIndex = 0;
bool updateRequired = false; bool updateRequired = false;
bool hasContinueReading = false; bool hasContinueReading = false;

View File

@ -21,7 +21,6 @@ constexpr int LEFT_MARGIN = 20;
constexpr int RIGHT_MARGIN = 40; // Extra space for scroll indicator constexpr int RIGHT_MARGIN = 40; // Extra space for scroll indicator
// Timing thresholds // Timing thresholds
constexpr int SKIP_PAGE_MS = 700;
constexpr unsigned long GO_HOME_MS = 1000; constexpr unsigned long GO_HOME_MS = 1000;
void sortFileList(std::vector<std::string>& strs) { void sortFileList(std::vector<std::string>& strs) {
@ -178,13 +177,9 @@ void MyLibraryActivity::loop() {
return; return;
} }
const bool upReleased = mappedInput.wasReleased(MappedInputManager::Button::Up);
const bool downReleased = mappedInput.wasReleased(MappedInputManager::Button::Down);
const bool leftReleased = mappedInput.wasReleased(MappedInputManager::Button::Left); const bool leftReleased = mappedInput.wasReleased(MappedInputManager::Button::Left);
const bool rightReleased = mappedInput.wasReleased(MappedInputManager::Button::Right); const bool rightReleased = mappedInput.wasReleased(MappedInputManager::Button::Right);
const bool skipPage = mappedInput.getHeldTime() > SKIP_PAGE_MS;
// Confirm button - open selected item // Confirm button - open selected item
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (currentTab == Tab::Recent) { if (currentTab == Tab::Recent) {
@ -249,24 +244,28 @@ void MyLibraryActivity::loop() {
} }
// Navigation: Up/Down moves through items only // Navigation: Up/Down moves through items only
const bool prevReleased = upReleased; constexpr auto upButton = MappedInputManager::Button::Up;
const bool nextReleased = downReleased; constexpr auto downButton = MappedInputManager::Button::Down;
if (prevReleased && itemCount > 0) { buttonNavigator.onRelease({downButton}, [this, itemCount] {
if (skipPage) { selectorIndex = ButtonNavigator::nextIndex(selectorIndex, itemCount);
selectorIndex = ((selectorIndex / pageItems - 1) * pageItems + itemCount) % itemCount;
} else {
selectorIndex = (selectorIndex + itemCount - 1) % itemCount;
}
updateRequired = true; updateRequired = true;
} else if (nextReleased && itemCount > 0) { });
if (skipPage) {
selectorIndex = ((selectorIndex / pageItems + 1) * pageItems) % itemCount; buttonNavigator.onRelease({upButton}, [this, itemCount] {
} else { selectorIndex = ButtonNavigator::previousIndex(selectorIndex, itemCount);
selectorIndex = (selectorIndex + 1) % itemCount;
}
updateRequired = true; updateRequired = true;
} });
buttonNavigator.onContinuous({downButton}, [this, itemCount, pageItems] {
selectorIndex = ButtonNavigator::nextPageIndex(selectorIndex, itemCount, pageItems);
updateRequired = true;
});
buttonNavigator.onContinuous({upButton}, [this, itemCount, pageItems] {
selectorIndex = ButtonNavigator::previousPageIndex(selectorIndex, itemCount, pageItems);
updateRequired = true;
});
} }
void MyLibraryActivity::displayTaskLoop() { void MyLibraryActivity::displayTaskLoop() {

View File

@ -9,6 +9,7 @@
#include "../Activity.h" #include "../Activity.h"
#include "RecentBooksStore.h" #include "RecentBooksStore.h"
#include "util/ButtonNavigator.h"
class MyLibraryActivity final : public Activity { class MyLibraryActivity final : public Activity {
public: public:
@ -17,6 +18,7 @@ class MyLibraryActivity final : public Activity {
private: private:
TaskHandle_t displayTaskHandle = nullptr; TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr; SemaphoreHandle_t renderingMutex = nullptr;
ButtonNavigator buttonNavigator;
Tab currentTab = Tab::Recent; Tab currentTab = Tab::Recent;
int selectorIndex = 0; int selectorIndex = 0;

View File

@ -72,18 +72,15 @@ void NetworkModeSelectionActivity::loop() {
} }
// Handle navigation // Handle navigation
const bool prevPressed = mappedInput.wasPressed(MappedInputManager::Button::Up) || buttonNavigator.onNext([this] {
mappedInput.wasPressed(MappedInputManager::Button::Left); selectedIndex = ButtonNavigator::nextIndex(selectedIndex, MENU_ITEM_COUNT);
const bool nextPressed = mappedInput.wasPressed(MappedInputManager::Button::Down) || updateRequired = true;
mappedInput.wasPressed(MappedInputManager::Button::Right); });
if (prevPressed) { buttonNavigator.onPrevious([this] {
selectedIndex = (selectedIndex + MENU_ITEM_COUNT - 1) % MENU_ITEM_COUNT; selectedIndex = ButtonNavigator::previousIndex(selectedIndex, MENU_ITEM_COUNT);
updateRequired = true; updateRequired = true;
} else if (nextPressed) { });
selectedIndex = (selectedIndex + 1) % MENU_ITEM_COUNT;
updateRequired = true;
}
} }
void NetworkModeSelectionActivity::displayTaskLoop() { void NetworkModeSelectionActivity::displayTaskLoop() {

View File

@ -6,6 +6,7 @@
#include <functional> #include <functional>
#include "../Activity.h" #include "../Activity.h"
#include "util/ButtonNavigator.h"
// Enum for network mode selection // Enum for network mode selection
enum class NetworkMode { JOIN_NETWORK, CONNECT_CALIBRE, CREATE_HOTSPOT }; enum class NetworkMode { JOIN_NETWORK, CONNECT_CALIBRE, CREATE_HOTSPOT };
@ -22,6 +23,8 @@ enum class NetworkMode { JOIN_NETWORK, CONNECT_CALIBRE, CREATE_HOTSPOT };
class NetworkModeSelectionActivity final : public Activity { class NetworkModeSelectionActivity final : public Activity {
TaskHandle_t displayTaskHandle = nullptr; TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr; SemaphoreHandle_t renderingMutex = nullptr;
ButtonNavigator buttonNavigator;
int selectedIndex = 0; int selectedIndex = 0;
bool updateRequired = false; bool updateRequired = false;
const std::function<void(NetworkMode)> onModeSelected; const std::function<void(NetworkMode)> onModeSelected;

View File

@ -419,20 +419,16 @@ void WifiSelectionActivity::loop() {
return; return;
} }
// Handle UP/DOWN navigation // Handle navigation
if (mappedInput.wasPressed(MappedInputManager::Button::Up) || buttonNavigator.onNext([this] {
mappedInput.wasPressed(MappedInputManager::Button::Left)) { selectedNetworkIndex = ButtonNavigator::nextIndex(selectedNetworkIndex, networks.size());
if (selectedNetworkIndex > 0) { updateRequired = true;
selectedNetworkIndex--; });
updateRequired = true;
} buttonNavigator.onPrevious([this] {
} else if (mappedInput.wasPressed(MappedInputManager::Button::Down) || selectedNetworkIndex = ButtonNavigator::previousIndex(selectedNetworkIndex, networks.size());
mappedInput.wasPressed(MappedInputManager::Button::Right)) { updateRequired = true;
if (!networks.empty() && selectedNetworkIndex < static_cast<int>(networks.size()) - 1) { });
selectedNetworkIndex++;
updateRequired = true;
}
}
} }
} }

View File

@ -10,6 +10,7 @@
#include <vector> #include <vector>
#include "activities/ActivityWithSubactivity.h" #include "activities/ActivityWithSubactivity.h"
#include "util/ButtonNavigator.h"
// Structure to hold WiFi network information // Structure to hold WiFi network information
struct WifiNetworkInfo { struct WifiNetworkInfo {
@ -45,6 +46,7 @@ enum class WifiSelectionState {
class WifiSelectionActivity final : public ActivityWithSubactivity { class WifiSelectionActivity final : public ActivityWithSubactivity {
TaskHandle_t displayTaskHandle = nullptr; TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr; SemaphoreHandle_t renderingMutex = nullptr;
ButtonNavigator buttonNavigator;
bool updateRequired = false; bool updateRequired = false;
WifiSelectionState state = WifiSelectionState::SCANNING; WifiSelectionState state = WifiSelectionState::SCANNING;
int selectedNetworkIndex = 0; int selectedNetworkIndex = 0;

View File

@ -570,8 +570,8 @@ void EpubReaderActivity::renderStatusBar(const int orientedMarginRight, const in
availableTitleSpace = rendererableScreenWidth - titleMarginLeft - titleMarginRight; availableTitleSpace = rendererableScreenWidth - titleMarginLeft - titleMarginRight;
titleMarginLeftAdjusted = titleMarginLeft; titleMarginLeftAdjusted = titleMarginLeft;
} }
while (titleWidth > availableTitleSpace && title.length() > 11) { if (titleWidth > availableTitleSpace) {
title.replace(title.length() - 8, 8, "..."); title = renderer.truncatedText(SMALL_FONT_ID, title.c_str(), availableTitleSpace);
titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str()); titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str());
} }
} }

View File

@ -7,11 +7,6 @@
#include "MappedInputManager.h" #include "MappedInputManager.h"
#include "fontIds.h" #include "fontIds.h"
namespace {
// Time threshold for treating a long press as a page-up/page-down
constexpr int SKIP_PAGE_MS = 700;
} // namespace
bool EpubReaderChapterSelectionActivity::hasSyncOption() const { return KOREADER_STORE.hasCredentials(); } bool EpubReaderChapterSelectionActivity::hasSyncOption() const { return KOREADER_STORE.hasCredentials(); }
int EpubReaderChapterSelectionActivity::getTotalItems() const { int EpubReaderChapterSelectionActivity::getTotalItems() const {
@ -119,12 +114,6 @@ void EpubReaderChapterSelectionActivity::loop() {
return; return;
} }
const bool prevReleased = mappedInput.wasReleased(MappedInputManager::Button::Up) ||
mappedInput.wasReleased(MappedInputManager::Button::Left);
const bool nextReleased = mappedInput.wasReleased(MappedInputManager::Button::Down) ||
mappedInput.wasReleased(MappedInputManager::Button::Right);
const bool skipPage = mappedInput.getHeldTime() > SKIP_PAGE_MS;
const int pageItems = getPageItems(); const int pageItems = getPageItems();
const int totalItems = getTotalItems(); const int totalItems = getTotalItems();
@ -145,21 +134,27 @@ void EpubReaderChapterSelectionActivity::loop() {
} }
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { } else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
onGoBack(); onGoBack();
} else if (prevReleased) {
if (skipPage) {
selectorIndex = ((selectorIndex / pageItems - 1) * pageItems + totalItems) % totalItems;
} else {
selectorIndex = (selectorIndex + totalItems - 1) % totalItems;
}
updateRequired = true;
} else if (nextReleased) {
if (skipPage) {
selectorIndex = ((selectorIndex / pageItems + 1) * pageItems) % totalItems;
} else {
selectorIndex = (selectorIndex + 1) % totalItems;
}
updateRequired = true;
} }
buttonNavigator.onNextRelease([this, totalItems] {
selectorIndex = ButtonNavigator::nextIndex(selectorIndex, totalItems);
updateRequired = true;
});
buttonNavigator.onPreviousRelease([this, totalItems] {
selectorIndex = ButtonNavigator::previousIndex(selectorIndex, totalItems);
updateRequired = true;
});
buttonNavigator.onNextContinuous([this, totalItems, pageItems] {
selectorIndex = ButtonNavigator::nextPageIndex(selectorIndex, totalItems, pageItems);
updateRequired = true;
});
buttonNavigator.onPreviousContinuous([this, totalItems, pageItems] {
selectorIndex = ButtonNavigator::previousPageIndex(selectorIndex, totalItems, pageItems);
updateRequired = true;
});
} }
void EpubReaderChapterSelectionActivity::displayTaskLoop() { void EpubReaderChapterSelectionActivity::displayTaskLoop() {

View File

@ -7,12 +7,14 @@
#include <memory> #include <memory>
#include "../ActivityWithSubactivity.h" #include "../ActivityWithSubactivity.h"
#include "util/ButtonNavigator.h"
class EpubReaderChapterSelectionActivity final : public ActivityWithSubactivity { class EpubReaderChapterSelectionActivity final : public ActivityWithSubactivity {
std::shared_ptr<Epub> epub; std::shared_ptr<Epub> epub;
std::string epubPath; std::string epubPath;
TaskHandle_t displayTaskHandle = nullptr; TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr; SemaphoreHandle_t renderingMutex = nullptr;
ButtonNavigator buttonNavigator;
int currentSpineIndex = 0; int currentSpineIndex = 0;
int currentPage = 0; int currentPage = 0;
int totalPagesInSpine = 0; int totalPagesInSpine = 0;

View File

@ -533,8 +533,8 @@ void TxtReaderActivity::renderStatusBar(const int orientedMarginRight, const int
std::string title = txt->getTitle(); std::string title = txt->getTitle();
int titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str()); int titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str());
while (titleWidth > availableTextWidth && title.length() > 11) { if (titleWidth > availableTextWidth) {
title.replace(title.length() - 8, 8, "..."); title = renderer.truncatedText(SMALL_FONT_ID, title.c_str(), availableTextWidth);
titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str()); titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str());
} }

View File

@ -5,10 +5,6 @@
#include "MappedInputManager.h" #include "MappedInputManager.h"
#include "fontIds.h" #include "fontIds.h"
namespace {
constexpr int SKIP_PAGE_MS = 700;
} // namespace
int XtcReaderChapterSelectionActivity::getPageItems() const { int XtcReaderChapterSelectionActivity::getPageItems() const {
constexpr int startY = 60; constexpr int startY = 60;
constexpr int lineHeight = 30; constexpr int lineHeight = 30;
@ -75,13 +71,8 @@ void XtcReaderChapterSelectionActivity::onExit() {
} }
void XtcReaderChapterSelectionActivity::loop() { void XtcReaderChapterSelectionActivity::loop() {
const bool prevReleased = mappedInput.wasReleased(MappedInputManager::Button::Up) ||
mappedInput.wasReleased(MappedInputManager::Button::Left);
const bool nextReleased = mappedInput.wasReleased(MappedInputManager::Button::Down) ||
mappedInput.wasReleased(MappedInputManager::Button::Right);
const bool skipPage = mappedInput.getHeldTime() > SKIP_PAGE_MS;
const int pageItems = getPageItems(); const int pageItems = getPageItems();
const int totalItems = static_cast<int>(xtc->getChapters().size());
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
const auto& chapters = xtc->getChapters(); const auto& chapters = xtc->getChapters();
@ -90,29 +81,27 @@ void XtcReaderChapterSelectionActivity::loop() {
} }
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { } else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
onGoBack(); onGoBack();
} else if (prevReleased) {
const int total = static_cast<int>(xtc->getChapters().size());
if (total == 0) {
return;
}
if (skipPage) {
selectorIndex = ((selectorIndex / pageItems - 1) * pageItems + total) % total;
} else {
selectorIndex = (selectorIndex + total - 1) % total;
}
updateRequired = true;
} else if (nextReleased) {
const int total = static_cast<int>(xtc->getChapters().size());
if (total == 0) {
return;
}
if (skipPage) {
selectorIndex = ((selectorIndex / pageItems + 1) * pageItems) % total;
} else {
selectorIndex = (selectorIndex + 1) % total;
}
updateRequired = true;
} }
buttonNavigator.onNextRelease([this, totalItems] {
selectorIndex = ButtonNavigator::nextIndex(selectorIndex, totalItems);
updateRequired = true;
});
buttonNavigator.onPreviousRelease([this, totalItems] {
selectorIndex = ButtonNavigator::previousIndex(selectorIndex, totalItems);
updateRequired = true;
});
buttonNavigator.onNextContinuous([this, totalItems, pageItems] {
selectorIndex = ButtonNavigator::nextPageIndex(selectorIndex, totalItems, pageItems);
updateRequired = true;
});
buttonNavigator.onPreviousContinuous([this, totalItems, pageItems] {
selectorIndex = ButtonNavigator::previousPageIndex(selectorIndex, totalItems, pageItems);
updateRequired = true;
});
} }
void XtcReaderChapterSelectionActivity::displayTaskLoop() { void XtcReaderChapterSelectionActivity::displayTaskLoop() {

View File

@ -7,11 +7,13 @@
#include <memory> #include <memory>
#include "../Activity.h" #include "../Activity.h"
#include "util/ButtonNavigator.h"
class XtcReaderChapterSelectionActivity final : public Activity { class XtcReaderChapterSelectionActivity final : public Activity {
std::shared_ptr<Xtc> xtc; std::shared_ptr<Xtc> xtc;
TaskHandle_t displayTaskHandle = nullptr; TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr; SemaphoreHandle_t renderingMutex = nullptr;
ButtonNavigator buttonNavigator;
uint32_t currentPage = 0; uint32_t currentPage = 0;
int selectorIndex = 0; int selectorIndex = 0;
bool updateRequired = false; bool updateRequired = false;

View File

@ -62,15 +62,16 @@ void CalibreSettingsActivity::loop() {
return; return;
} }
if (mappedInput.wasPressed(MappedInputManager::Button::Up) || // Handle navigation
mappedInput.wasPressed(MappedInputManager::Button::Left)) { buttonNavigator.onNext([this] {
selectedIndex = (selectedIndex + MENU_ITEMS - 1) % MENU_ITEMS;
updateRequired = true;
} else if (mappedInput.wasPressed(MappedInputManager::Button::Down) ||
mappedInput.wasPressed(MappedInputManager::Button::Right)) {
selectedIndex = (selectedIndex + 1) % MENU_ITEMS; selectedIndex = (selectedIndex + 1) % MENU_ITEMS;
updateRequired = true; updateRequired = true;
} });
buttonNavigator.onPrevious([this] {
selectedIndex = (selectedIndex + MENU_ITEMS - 1) % MENU_ITEMS;
updateRequired = true;
});
} }
void CalibreSettingsActivity::handleSelection() { void CalibreSettingsActivity::handleSelection() {

View File

@ -6,6 +6,7 @@
#include <functional> #include <functional>
#include "activities/ActivityWithSubactivity.h" #include "activities/ActivityWithSubactivity.h"
#include "util/ButtonNavigator.h"
/** /**
* Submenu for OPDS Browser settings. * Submenu for OPDS Browser settings.
@ -24,6 +25,7 @@ class CalibreSettingsActivity final : public ActivityWithSubactivity {
private: private:
TaskHandle_t displayTaskHandle = nullptr; TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr; SemaphoreHandle_t renderingMutex = nullptr;
ButtonNavigator buttonNavigator;
bool updateRequired = false; bool updateRequired = false;
int selectedIndex = 0; int selectedIndex = 0;

View File

@ -62,15 +62,15 @@ void CategorySettingsActivity::loop() {
} }
// Handle navigation // Handle navigation
if (mappedInput.wasPressed(MappedInputManager::Button::Up) || buttonNavigator.onNext([this] {
mappedInput.wasPressed(MappedInputManager::Button::Left)) { selectedSettingIndex = (selectedSettingIndex + 1) % settingsCount;
selectedSettingIndex = (selectedSettingIndex > 0) ? (selectedSettingIndex - 1) : (settingsCount - 1);
updateRequired = true; updateRequired = true;
} else if (mappedInput.wasPressed(MappedInputManager::Button::Down) || });
mappedInput.wasPressed(MappedInputManager::Button::Right)) {
selectedSettingIndex = (selectedSettingIndex < settingsCount - 1) ? (selectedSettingIndex + 1) : 0; buttonNavigator.onPrevious([this] {
selectedSettingIndex = (selectedSettingIndex + settingsCount - 1) % settingsCount;
updateRequired = true; updateRequired = true;
} });
} }
void CategorySettingsActivity::toggleCurrentSetting() { void CategorySettingsActivity::toggleCurrentSetting() {

View File

@ -8,6 +8,7 @@
#include <vector> #include <vector>
#include "activities/ActivityWithSubactivity.h" #include "activities/ActivityWithSubactivity.h"
#include "util/ButtonNavigator.h"
class CrossPointSettings; class CrossPointSettings;
@ -44,6 +45,7 @@ struct SettingInfo {
class CategorySettingsActivity final : public ActivityWithSubactivity { class CategorySettingsActivity final : public ActivityWithSubactivity {
TaskHandle_t displayTaskHandle = nullptr; TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr; SemaphoreHandle_t renderingMutex = nullptr;
ButtonNavigator buttonNavigator;
bool updateRequired = false; bool updateRequired = false;
int selectedSettingIndex = 0; int selectedSettingIndex = 0;
const char* categoryName; const char* categoryName;

View File

@ -63,15 +63,16 @@ void KOReaderSettingsActivity::loop() {
return; return;
} }
if (mappedInput.wasPressed(MappedInputManager::Button::Up) || // Handle navigation
mappedInput.wasPressed(MappedInputManager::Button::Left)) { buttonNavigator.onNext([this] {
selectedIndex = (selectedIndex + MENU_ITEMS - 1) % MENU_ITEMS;
updateRequired = true;
} else if (mappedInput.wasPressed(MappedInputManager::Button::Down) ||
mappedInput.wasPressed(MappedInputManager::Button::Right)) {
selectedIndex = (selectedIndex + 1) % MENU_ITEMS; selectedIndex = (selectedIndex + 1) % MENU_ITEMS;
updateRequired = true; updateRequired = true;
} });
buttonNavigator.onPrevious([this] {
selectedIndex = (selectedIndex + MENU_ITEMS - 1) % MENU_ITEMS;
updateRequired = true;
});
} }
void KOReaderSettingsActivity::handleSelection() { void KOReaderSettingsActivity::handleSelection() {

View File

@ -6,6 +6,7 @@
#include <functional> #include <functional>
#include "activities/ActivityWithSubactivity.h" #include "activities/ActivityWithSubactivity.h"
#include "util/ButtonNavigator.h"
/** /**
* Submenu for KOReader Sync settings. * Submenu for KOReader Sync settings.
@ -24,6 +25,7 @@ class KOReaderSettingsActivity final : public ActivityWithSubactivity {
private: private:
TaskHandle_t displayTaskHandle = nullptr; TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr; SemaphoreHandle_t renderingMutex = nullptr;
ButtonNavigator buttonNavigator;
bool updateRequired = false; bool updateRequired = false;
int selectedIndex = 0; int selectedIndex = 0;

View File

@ -111,17 +111,15 @@ void SettingsActivity::loop() {
} }
// Handle navigation // Handle navigation
if (mappedInput.wasPressed(MappedInputManager::Button::Up) || buttonNavigator.onNext([this] {
mappedInput.wasPressed(MappedInputManager::Button::Left)) { selectedCategoryIndex = (selectedCategoryIndex + 1) % categoryCount;
// Move selection up (with wrap-around)
selectedCategoryIndex = (selectedCategoryIndex > 0) ? (selectedCategoryIndex - 1) : (categoryCount - 1);
updateRequired = true; updateRequired = true;
} else if (mappedInput.wasPressed(MappedInputManager::Button::Down) || });
mappedInput.wasPressed(MappedInputManager::Button::Right)) {
// Move selection down (with wrap around) buttonNavigator.onPrevious([this] {
selectedCategoryIndex = (selectedCategoryIndex < categoryCount - 1) ? (selectedCategoryIndex + 1) : 0; selectedCategoryIndex = (selectedCategoryIndex + categoryCount - 1) % categoryCount;
updateRequired = true; updateRequired = true;
} });
} }
void SettingsActivity::enterCategory(int categoryIndex) { void SettingsActivity::enterCategory(int categoryIndex) {

View File

@ -8,6 +8,7 @@
#include <vector> #include <vector>
#include "activities/ActivityWithSubactivity.h" #include "activities/ActivityWithSubactivity.h"
#include "util/ButtonNavigator.h"
class CrossPointSettings; class CrossPointSettings;
struct SettingInfo; struct SettingInfo;
@ -15,6 +16,7 @@ struct SettingInfo;
class SettingsActivity final : public ActivityWithSubactivity { class SettingsActivity final : public ActivityWithSubactivity {
TaskHandle_t displayTaskHandle = nullptr; TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr; SemaphoreHandle_t renderingMutex = nullptr;
ButtonNavigator buttonNavigator;
bool updateRequired = false; bool updateRequired = false;
int selectedCategoryIndex = 0; // Currently selected category int selectedCategoryIndex = 0; // Currently selected category
const std::function<void()> onGoHome; const std::function<void()> onGoHome;

View File

@ -138,37 +138,24 @@ void KeyboardEntryActivity::handleKeyPress() {
} }
void KeyboardEntryActivity::loop() { void KeyboardEntryActivity::loop() {
// Navigation // Handle navigation
if (mappedInput.wasPressed(MappedInputManager::Button::Up)) { buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Up}, [this] {
if (selectedRow > 0) { selectedRow = ButtonNavigator::previousIndex(selectedRow, NUM_ROWS);
selectedRow--;
// Clamp column to valid range for new row
const int maxCol = getRowLength(selectedRow) - 1;
if (selectedCol > maxCol) selectedCol = maxCol;
} else {
// Wrap to bottom row
selectedRow = NUM_ROWS - 1;
const int maxCol = getRowLength(selectedRow) - 1;
if (selectedCol > maxCol) selectedCol = maxCol;
}
updateRequired = true;
}
if (mappedInput.wasPressed(MappedInputManager::Button::Down)) { const int maxCol = getRowLength(selectedRow) - 1;
if (selectedRow < NUM_ROWS - 1) { if (selectedCol > maxCol) selectedCol = maxCol;
selectedRow++;
const int maxCol = getRowLength(selectedRow) - 1;
if (selectedCol > maxCol) selectedCol = maxCol;
} else {
// Wrap to top row
selectedRow = 0;
const int maxCol = getRowLength(selectedRow) - 1;
if (selectedCol > maxCol) selectedCol = maxCol;
}
updateRequired = true; updateRequired = true;
} });
if (mappedInput.wasPressed(MappedInputManager::Button::Left)) { buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Down}, [this] {
selectedRow = ButtonNavigator::nextIndex(selectedRow, NUM_ROWS);
const int maxCol = getRowLength(selectedRow) - 1;
if (selectedCol > maxCol) selectedCol = maxCol;
updateRequired = true;
});
buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Left}, [this] {
const int maxCol = getRowLength(selectedRow) - 1; const int maxCol = getRowLength(selectedRow) - 1;
// Special bottom row case // Special bottom row case
@ -187,20 +174,14 @@ void KeyboardEntryActivity::loop() {
// At done button, move to backspace // At done button, move to backspace
selectedCol = BACKSPACE_COL; selectedCol = BACKSPACE_COL;
} }
updateRequired = true;
return;
}
if (selectedCol > 0) {
selectedCol--;
} else { } else {
// Wrap to end of current row selectedCol = ButtonNavigator::previousIndex(selectedCol, maxCol + 1);
selectedCol = maxCol;
} }
updateRequired = true;
}
if (mappedInput.wasPressed(MappedInputManager::Button::Right)) { updateRequired = true;
});
buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Right}, [this] {
const int maxCol = getRowLength(selectedRow) - 1; const int maxCol = getRowLength(selectedRow) - 1;
// Special bottom row case // Special bottom row case
@ -219,18 +200,11 @@ void KeyboardEntryActivity::loop() {
// At done button, wrap to beginning of row // At done button, wrap to beginning of row
selectedCol = SHIFT_COL; selectedCol = SHIFT_COL;
} }
updateRequired = true;
return;
}
if (selectedCol < maxCol) {
selectedCol++;
} else { } else {
// Wrap to beginning of current row selectedCol = ButtonNavigator::nextIndex(selectedCol, maxCol + 1);
selectedCol = 0;
} }
updateRequired = true; updateRequired = true;
} });
// Selection // Selection
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {

View File

@ -9,6 +9,7 @@
#include <utility> #include <utility>
#include "../Activity.h" #include "../Activity.h"
#include "util/ButtonNavigator.h"
/** /**
* Reusable keyboard entry activity for text input. * Reusable keyboard entry activity for text input.
@ -65,6 +66,7 @@ class KeyboardEntryActivity : public Activity {
bool isPassword; bool isPassword;
TaskHandle_t displayTaskHandle = nullptr; TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr; SemaphoreHandle_t renderingMutex = nullptr;
ButtonNavigator buttonNavigator;
bool updateRequired = false; bool updateRequired = false;
// Keyboard state // Keyboard state

View File

@ -25,6 +25,7 @@
#include "activities/settings/SettingsActivity.h" #include "activities/settings/SettingsActivity.h"
#include "activities/util/FullScreenMessageActivity.h" #include "activities/util/FullScreenMessageActivity.h"
#include "fontIds.h" #include "fontIds.h"
#include "util/ButtonNavigator.h"
HalDisplay display; HalDisplay display;
HalGPIO gpio; HalGPIO gpio;
@ -293,6 +294,7 @@ void setup() {
SETTINGS.loadFromFile(); SETTINGS.loadFromFile();
KOREADER_STORE.loadFromFile(); KOREADER_STORE.loadFromFile();
ButtonNavigator::setMappedInputManager(mappedInputManager);
switch (gpio.getWakeupReason()) { switch (gpio.getWakeupReason()) {
case HalGPIO::WakeupReason::PowerButton: case HalGPIO::WakeupReason::PowerButton:

View File

@ -0,0 +1,124 @@
#include "ButtonNavigator.h"
const MappedInputManager* ButtonNavigator::mappedInput = nullptr;
void ButtonNavigator::onNext(const Callback& callback) {
onNextPress(callback);
onNextContinuous(callback);
}
void ButtonNavigator::onPrevious(const Callback& callback) {
onPreviousPress(callback);
onPreviousContinuous(callback);
}
void ButtonNavigator::onPressAndContinuous(const Buttons& buttons, const Callback& callback) {
onPress(buttons, callback);
onContinuous(buttons, callback);
}
void ButtonNavigator::onNextPress(const Callback& callback) { onPress(getNextButtons(), callback); }
void ButtonNavigator::onPreviousPress(const Callback& callback) { onPress(getPreviousButtons(), callback); }
void ButtonNavigator::onNextRelease(const Callback& callback) { onRelease(getNextButtons(), callback); }
void ButtonNavigator::onPreviousRelease(const Callback& callback) { onRelease(getPreviousButtons(), callback); }
void ButtonNavigator::onNextContinuous(const Callback& callback) { onContinuous(getNextButtons(), callback); }
void ButtonNavigator::onPreviousContinuous(const Callback& callback) { onContinuous(getPreviousButtons(), callback); }
void ButtonNavigator::onPress(const Buttons& buttons, const Callback& callback) {
const bool wasPressed = std::any_of(buttons.begin(), buttons.end(), [](const MappedInputManager::Button button) {
return mappedInput != nullptr && mappedInput->wasPressed(button);
});
if (wasPressed) {
callback();
}
}
void ButtonNavigator::onRelease(const Buttons& buttons, const Callback& callback) {
const bool wasReleased = std::any_of(buttons.begin(), buttons.end(), [](const MappedInputManager::Button button) {
return mappedInput != nullptr && mappedInput->wasReleased(button);
});
if (wasReleased) {
if (lastContinuousNavTime == 0) {
callback();
}
lastContinuousNavTime = 0;
}
}
void ButtonNavigator::onContinuous(const Buttons& buttons, const Callback& callback) {
const bool isPressed = std::any_of(buttons.begin(), buttons.end(), [this](const MappedInputManager::Button button) {
return mappedInput != nullptr && mappedInput->isPressed(button) && shouldNavigateContinuously();
});
if (isPressed) {
callback();
lastContinuousNavTime = millis();
}
}
bool ButtonNavigator::shouldNavigateContinuously() const {
if (!mappedInput) return false;
const bool buttonHeldLongEnough = mappedInput->getHeldTime() > continuousStartMs;
const bool navigationIntervalElapsed = (millis() - lastContinuousNavTime) > continuousIntervalMs;
return buttonHeldLongEnough && navigationIntervalElapsed;
}
int ButtonNavigator::nextIndex(const int currentIndex, const int totalItems) {
if (totalItems <= 0) return 0;
// Calculate the next index with wrap-around
return (currentIndex + 1) % totalItems;
}
int ButtonNavigator::previousIndex(const int currentIndex, const int totalItems) {
if (totalItems <= 0) return 0;
// Calculate the previous index with wrap-around
return (currentIndex + totalItems - 1) % totalItems;
}
int ButtonNavigator::nextPageIndex(const int currentIndex, const int totalItems, const int itemsPerPage) {
if (totalItems <= 0 || itemsPerPage <= 0) return 0;
// When items fit on one page, use index navigation instead
if (totalItems <= itemsPerPage) {
return nextIndex(currentIndex, totalItems);
}
const int lastPageIndex = (totalItems - 1) / itemsPerPage;
const int currentPageIndex = currentIndex / itemsPerPage;
if (currentPageIndex < lastPageIndex) {
return (currentPageIndex + 1) * itemsPerPage;
}
return 0;
}
int ButtonNavigator::previousPageIndex(const int currentIndex, const int totalItems, const int itemsPerPage) {
if (totalItems <= 0 || itemsPerPage <= 0) return 0;
// When items fit on one page, use index navigation instead
if (totalItems <= itemsPerPage) {
return previousIndex(currentIndex, totalItems);
}
const int lastPageIndex = (totalItems - 1) / itemsPerPage;
const int currentPageIndex = currentIndex / itemsPerPage;
if (currentPageIndex > 0) {
return (currentPageIndex - 1) * itemsPerPage;
}
return lastPageIndex * itemsPerPage;
}

View File

@ -0,0 +1,53 @@
#pragma once
#include <functional>
#include <vector>
#include "MappedInputManager.h"
class ButtonNavigator final {
using Callback = std::function<void()>;
using Buttons = std::vector<MappedInputManager::Button>;
const uint16_t continuousStartMs;
const uint16_t continuousIntervalMs;
uint32_t lastContinuousNavTime = 0;
static const MappedInputManager* mappedInput;
[[nodiscard]] bool shouldNavigateContinuously() const;
public:
explicit ButtonNavigator(const uint16_t continuousIntervalMs = 500, const uint16_t continuousStartMs = 500)
: continuousStartMs(continuousStartMs), continuousIntervalMs(continuousIntervalMs) {}
static void setMappedInputManager(const MappedInputManager& mappedInputManager) { mappedInput = &mappedInputManager; }
void onNext(const Callback& callback);
void onPrevious(const Callback& callback);
void onPressAndContinuous(const Buttons& buttons, const Callback& callback);
void onNextPress(const Callback& callback);
void onPreviousPress(const Callback& callback);
void onPress(const Buttons& buttons, const Callback& callback);
void onNextRelease(const Callback& callback);
void onPreviousRelease(const Callback& callback);
void onRelease(const Buttons& buttons, const Callback& callback);
void onNextContinuous(const Callback& callback);
void onPreviousContinuous(const Callback& callback);
void onContinuous(const Buttons& buttons, const Callback& callback);
[[nodiscard]] static int nextIndex(int currentIndex, int totalItems);
[[nodiscard]] static int previousIndex(int currentIndex, int totalItems);
[[nodiscard]] static int nextPageIndex(int currentIndex, int totalItems, int itemsPerPage);
[[nodiscard]] static int previousPageIndex(int currentIndex, int totalItems, int itemsPerPage);
[[nodiscard]] static Buttons getNextButtons() {
return {MappedInputManager::Button::Down, MappedInputManager::Button::Right};
}
[[nodiscard]] static Buttons getPreviousButtons() {
return {MappedInputManager::Button::Up, MappedInputManager::Button::Left};
}
};

View File

@ -61,23 +61,4 @@ bool checkFileExtension(const String& fileName, const char* extension) {
return localFile.endsWith(localExtension); return localFile.endsWith(localExtension);
} }
size_t utf8RemoveLastChar(std::string& str) {
if (str.empty()) return 0;
size_t pos = str.size() - 1;
// Walk back to find the start of the last UTF-8 character
// UTF-8 continuation bytes start with 10xxxxxx (0x80-0xBF)
while (pos > 0 && (static_cast<unsigned char>(str[pos]) & 0xC0) == 0x80) {
--pos;
}
str.resize(pos);
return pos;
}
// Truncate string by removing N UTF-8 characters from the end
void utf8TruncateChars(std::string& str, const size_t numChars) {
for (size_t i = 0; i < numChars && !str.empty(); ++i) {
utf8RemoveLastChar(str);
}
}
} // namespace StringUtils } // namespace StringUtils

View File

@ -19,10 +19,4 @@ std::string sanitizeFilename(const std::string& name, size_t maxLength = 100);
bool checkFileExtension(const std::string& fileName, const char* extension); bool checkFileExtension(const std::string& fileName, const char* extension);
bool checkFileExtension(const String& fileName, const char* extension); bool checkFileExtension(const String& fileName, const char* extension);
// UTF-8 safe string truncation - removes one character from the end
// Returns the new size after removing one UTF-8 character
size_t utf8RemoveLastChar(std::string& str);
// Truncate string by removing N UTF-8 characters from the end
void utf8TruncateChars(std::string& str, size_t numChars);
} // namespace StringUtils } // namespace StringUtils